Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,16 +411,22 @@ export const ScheduledDigestFlow = defineFlow({
* needs a real bot token + channel), this flow dispatches to the `rest`
* connector contributed by `@objectstack/connector-rest`, configured to point
* at the running server itself. On task completion it issues
* `GET /api/v1/health`; the request and its `{ status: 'ok' }` response are
* captured on the flow run, so the connector dispatch is fully observable
* without any external service or credentials.
* `GET /api/v1/health`; the response body is captured on the flow run as the
* declared output variable `ping.body` (`{ status: 'ok' }`), so the connector
* dispatch is fully observable without any external service or credentials.
*/
export const TaskCompletedRestPingFlow = defineFlow({
name: 'showcase_task_completed_rest_ping',
label: 'REST Ping on Task Completed',
description: 'Calls the local server health endpoint via the rest connector when a task is marked Done.',
type: 'autolaunched',
status: 'active',
// Surface the health response on the run output. Nothing is captured on a run
// unless the flow ASKS for it: the engine collects `run.output` from the
// declared `isOutput` variables only (#7542). The `request` action of the
// `rest` connector returns `{ status, ok, body }`, written back under
// `${nodeId}.${key}` — so `ping.body` is the parsed `{ status: 'ok' }` payload.
variables: [{ name: 'ping.body', type: 'json', isOutput: true }],
nodes: [
{
id: 'start',
Expand Down Expand Up @@ -464,8 +470,9 @@ export const TaskCompletedRestPingFlow = defineFlow({
* `rest` generic executor (ADR-0097). Nothing registered it in code: the
* `connectors:` entry named `provider: 'rest'`, and the automation service turned
* it into a live connector. On task creation the flow issues `GET /api/v1/health`
* through it; the call and its `{ status: 'ok' }` response are captured on the
* flow run, proving the declarative path dispatches end-to-end.
* through it; the response body is captured on the flow run as the declared
* output variable `ping.body` (`{ status: 'ok' }`), proving the declarative path
* dispatches end-to-end.
*/
export const ShowcaseDeclarativeConnectorPingFlow = defineFlow({
name: 'showcase_declarative_connector_ping',
Expand All @@ -474,6 +481,11 @@ export const ShowcaseDeclarativeConnectorPingFlow = defineFlow({
'Dispatches GET /api/v1/health through showcase_status_api — a provider-bound connector instance materialized from pure metadata at boot.',
type: 'autolaunched',
status: 'active',
// Same as TaskCompletedRestPingFlow above: the materialized `showcase_status_api`
// instance is built by the same `rest` factory, so its `request` action returns
// `{ status, ok, body }` and `ping.body` carries the `{ status: 'ok' }` payload
// onto `run.output` (#7542).
variables: [{ name: 'ping.body', type: 'json', isOutput: true }],
nodes: [
{
id: 'start',
Expand Down
152 changes: 152 additions & 0 deletions examples/app-showcase/test/connector-ping-run-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#7542] The two REST-dispatching showcase flows capture the connector
* response on the run — the claim their source comments make.
*
* `run.output` is not a free side effect of dispatching: the engine collects it
* from the flow's declared `isOutput` variables ONLY
* (`AutomationEngine.execute` → "Collect output variables"). Both
* `TaskCompletedRestPingFlow` and `ShowcaseDeclarativeConnectorPingFlow`
* dispatched correctly but declared no variables at all, so `run.output` came
* back empty while the comments said the response was captured — a comment that
* sends the next reader hunting for an engine bug that does not exist. The
* sibling `ShowcaseMcpConnectorEchoFlow` proved it was authoring, not engine:
* same dispatch path, one declared variable, captured output.
*
* This file pins the fixed behaviour at the level the defect lives at — the
* flow's own declaration against the real connector's real output keys:
*
* - the flows come from `src/automation/flows/index.ts` (not a copy);
* - the `rest` connector is the REAL `createRestConnector` bundle, and the
* declarative instance is materialized by the REAL `createRestProviderFactory`
* from the REAL `StatusApiConnector` metadata — only `fetch` is stubbed, so
* the handler's `{ status, ok, body }` output shape is the shipped one;
* - the assertion is a CONTENT pin on `run.output`, not schema validity:
* `ping.body` must deep-equal the health payload `{ status: 'ok' }`.
*
* The variable name is not free either — the engine writes a node's output back
* under `${nodeId}.${key}`, so a flow declaring the wrong name captures
* `undefined` while still parsing and still dispatching. Asserting the value
* rather than the key's presence is what makes that failure visible.
*/

import { describe, it, expect } from 'vitest';
import { AutomationEngine, registerConnectorNodes } from '@objectstack/service-automation';
import { createRestConnector, createRestProviderFactory } from '@objectstack/connector-rest';

import {
TaskCompletedRestPingFlow,
ShowcaseDeclarativeConnectorPingFlow,
} from '../src/automation/flows/index.js';
import { StatusApiConnector } from '../src/system/connectors/index.js';

/** The payload `GET /api/v1/health` answers with on a live showcase boot. */
const HEALTH_PAYLOAD = { status: 'ok' } as const;

function silentLogger(): any {
const logger: any = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
logger.child = () => logger;
return logger;
}

/**
* A `fetch` stand-in that answers every call with the health payload and records
* the URLs it was asked for, so the test can also confirm the flow-owned path
* (`/api/v1/health`) actually went out.
*/
function stubFetch(requested: string[]): typeof fetch {
return (async (input: any) => {
requested.push(typeof input === 'string' ? input : String(input?.url ?? input));
return new Response(JSON.stringify(HEALTH_PAYLOAD), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}) as unknown as typeof fetch;
}

function newEngine(): AutomationEngine {
const engine = new AutomationEngine(silentLogger());
registerConnectorNodes(engine, { logger: silentLogger() } as any);
return engine;
}

describe('showcase REST connector flows — run output capture (#7542)', () => {
it('showcase_task_completed_rest_ping captures the health response as ping.body', async () => {
const requested: string[] = [];
const engine = newEngine();

// The plugin-registered `rest` connector, exactly as ConnectorRestPlugin
// builds it in objectstack.config.ts — only `fetch` is injected.
const { def, handlers } = createRestConnector({
name: 'rest',
baseUrl: 'http://127.0.0.1:3000',
fetchImpl: stubFetch(requested),
});
engine.registerConnector(def, handlers);

engine.registerFlow(TaskCompletedRestPingFlow.name, TaskCompletedRestPingFlow);

// The flow is gated on the done-transition, so drive it with the same
// trigger context a record-after-update hook would supply.
const result = await engine.execute(TaskCompletedRestPingFlow.name, {
object: 'showcase_task',
event: 'on_update',
record: { id: 't1', status: 'done' },
previous: { id: 't1', status: 'in_progress' },
});

expect(result.success).toBe(true);
// The call the flow declared actually went out...
expect(requested).toHaveLength(1);
expect(requested[0]).toContain('/api/v1/health');
// ...and its response is on the run, which is what the comment claims.
expect(result.output).toEqual({ 'ping.body': HEALTH_PAYLOAD });
});

it('showcase_declarative_connector_ping captures it too, through the materialized ADR-0097 instance', async () => {
const requested: string[] = [];
const engine = newEngine();

// Materialize `showcase_status_api` the way the automation service does at
// boot: the REAL provider factory, fed the REAL declared metadata.
// The factory contract allows an async materialization, so await it — the
// `rest` one happens to be synchronous.
const factory = createRestProviderFactory({ fetchImpl: stubFetch(requested) });
const { def, handlers } = await factory({
name: StatusApiConnector.name,
label: StatusApiConnector.label,
providerConfig: StatusApiConnector.providerConfig,
auth: StatusApiConnector.auth,
} as any);
engine.registerConnector(def, handlers);

engine.registerFlow(ShowcaseDeclarativeConnectorPingFlow.name, ShowcaseDeclarativeConnectorPingFlow);

const result = await engine.execute(ShowcaseDeclarativeConnectorPingFlow.name, {
object: 'showcase_task',
event: 'on_create',
record: { id: 't2', status: 'todo' },
});

expect(result.success).toBe(true);
expect(requested).toHaveLength(1);
expect(requested[0]).toContain('/api/v1/health');
expect(result.output).toEqual({ 'ping.body': HEALTH_PAYLOAD });
});

it('declares the output variable on both flows — the defect was its absence, not a wrong value', () => {
for (const flow of [TaskCompletedRestPingFlow, ShowcaseDeclarativeConnectorPingFlow]) {
const outputs = (flow.variables ?? []).filter((v) => v.isOutput);
expect(outputs.map((v) => v.name)).toEqual(['ping.body']);
// The name must match the connector node it reads from: the engine writes
// back under `${nodeId}.${key}`.
expect(flow.nodes.some((n) => n.id === 'ping' && n.type === 'connector_action')).toBe(true);
}
});
});
Loading