Skip to content

Commit ae66145

Browse files
os-helpclaude
andauthored
fix(examples): capture the connector response on the two showcase REST ping flows (#7542) (#7618)
`TaskCompletedRestPingFlow` and `ShowcaseDeclarativeConnectorPingFlow` both claimed in their source comments that the call and its `{ status: 'ok' }` response were captured on the flow run. Neither declared any variables, and the engine collects `run.output` from declared `isOutput` variables only — so `run.output` came back empty and the comment pointed the next reader at an engine bug that does not exist. Both flows now declare the output variable they were evidently meant to carry, mirroring the sibling `ShowcaseMcpConnectorEchoFlow`. The name follows what the step actually produces: the `rest` connector's `request` action returns `{ status, ok, body }`, written back under `${nodeId}.${key}`, and both flows' connector node is `ping` — so `ping.body` is the parsed health payload. New test drives the real flow definitions through a real `AutomationEngine` with the real `createRestConnector` / `createRestProviderFactory` bundles (only `fetch` stubbed) and content-pins `run.output` to `{ status: 'ok' }`. Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy Co-authored-by: Claude <noreply@anthropic.com>
1 parent b3de0dd commit ae66145

2 files changed

Lines changed: 169 additions & 5 deletions

File tree

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -411,16 +411,22 @@ export const ScheduledDigestFlow = defineFlow({
411411
* needs a real bot token + channel), this flow dispatches to the `rest`
412412
* connector contributed by `@objectstack/connector-rest`, configured to point
413413
* at the running server itself. On task completion it issues
414-
* `GET /api/v1/health`; the request and its `{ status: 'ok' }` response are
415-
* captured on the flow run, so the connector dispatch is fully observable
416-
* without any external service or credentials.
414+
* `GET /api/v1/health`; the response body is captured on the flow run as the
415+
* declared output variable `ping.body` (`{ status: 'ok' }`), so the connector
416+
* dispatch is fully observable without any external service or credentials.
417417
*/
418418
export const TaskCompletedRestPingFlow = defineFlow({
419419
name: 'showcase_task_completed_rest_ping',
420420
label: 'REST Ping on Task Completed',
421421
description: 'Calls the local server health endpoint via the rest connector when a task is marked Done.',
422422
type: 'autolaunched',
423423
status: 'active',
424+
// Surface the health response on the run output. Nothing is captured on a run
425+
// unless the flow ASKS for it: the engine collects `run.output` from the
426+
// declared `isOutput` variables only (#7542). The `request` action of the
427+
// `rest` connector returns `{ status, ok, body }`, written back under
428+
// `${nodeId}.${key}` — so `ping.body` is the parsed `{ status: 'ok' }` payload.
429+
variables: [{ name: 'ping.body', type: 'json', isOutput: true }],
424430
nodes: [
425431
{
426432
id: 'start',
@@ -464,8 +470,9 @@ export const TaskCompletedRestPingFlow = defineFlow({
464470
* `rest` generic executor (ADR-0097). Nothing registered it in code: the
465471
* `connectors:` entry named `provider: 'rest'`, and the automation service turned
466472
* it into a live connector. On task creation the flow issues `GET /api/v1/health`
467-
* through it; the call and its `{ status: 'ok' }` response are captured on the
468-
* flow run, proving the declarative path dispatches end-to-end.
473+
* through it; the response body is captured on the flow run as the declared
474+
* output variable `ping.body` (`{ status: 'ok' }`), proving the declarative path
475+
* dispatches end-to-end.
469476
*/
470477
export const ShowcaseDeclarativeConnectorPingFlow = defineFlow({
471478
name: 'showcase_declarative_connector_ping',
@@ -474,6 +481,11 @@ export const ShowcaseDeclarativeConnectorPingFlow = defineFlow({
474481
'Dispatches GET /api/v1/health through showcase_status_api — a provider-bound connector instance materialized from pure metadata at boot.',
475482
type: 'autolaunched',
476483
status: 'active',
484+
// Same as TaskCompletedRestPingFlow above: the materialized `showcase_status_api`
485+
// instance is built by the same `rest` factory, so its `request` action returns
486+
// `{ status, ok, body }` and `ping.body` carries the `{ status: 'ok' }` payload
487+
// onto `run.output` (#7542).
488+
variables: [{ name: 'ping.body', type: 'json', isOutput: true }],
477489
nodes: [
478490
{
479491
id: 'start',
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#7542] The two REST-dispatching showcase flows capture the connector
5+
* response on the run — the claim their source comments make.
6+
*
7+
* `run.output` is not a free side effect of dispatching: the engine collects it
8+
* from the flow's declared `isOutput` variables ONLY
9+
* (`AutomationEngine.execute` → "Collect output variables"). Both
10+
* `TaskCompletedRestPingFlow` and `ShowcaseDeclarativeConnectorPingFlow`
11+
* dispatched correctly but declared no variables at all, so `run.output` came
12+
* back empty while the comments said the response was captured — a comment that
13+
* sends the next reader hunting for an engine bug that does not exist. The
14+
* sibling `ShowcaseMcpConnectorEchoFlow` proved it was authoring, not engine:
15+
* same dispatch path, one declared variable, captured output.
16+
*
17+
* This file pins the fixed behaviour at the level the defect lives at — the
18+
* flow's own declaration against the real connector's real output keys:
19+
*
20+
* - the flows come from `src/automation/flows/index.ts` (not a copy);
21+
* - the `rest` connector is the REAL `createRestConnector` bundle, and the
22+
* declarative instance is materialized by the REAL `createRestProviderFactory`
23+
* from the REAL `StatusApiConnector` metadata — only `fetch` is stubbed, so
24+
* the handler's `{ status, ok, body }` output shape is the shipped one;
25+
* - the assertion is a CONTENT pin on `run.output`, not schema validity:
26+
* `ping.body` must deep-equal the health payload `{ status: 'ok' }`.
27+
*
28+
* The variable name is not free either — the engine writes a node's output back
29+
* under `${nodeId}.${key}`, so a flow declaring the wrong name captures
30+
* `undefined` while still parsing and still dispatching. Asserting the value
31+
* rather than the key's presence is what makes that failure visible.
32+
*/
33+
34+
import { describe, it, expect } from 'vitest';
35+
import { AutomationEngine, registerConnectorNodes } from '@objectstack/service-automation';
36+
import { createRestConnector, createRestProviderFactory } from '@objectstack/connector-rest';
37+
38+
import {
39+
TaskCompletedRestPingFlow,
40+
ShowcaseDeclarativeConnectorPingFlow,
41+
} from '../src/automation/flows/index.js';
42+
import { StatusApiConnector } from '../src/system/connectors/index.js';
43+
44+
/** The payload `GET /api/v1/health` answers with on a live showcase boot. */
45+
const HEALTH_PAYLOAD = { status: 'ok' } as const;
46+
47+
function silentLogger(): any {
48+
const logger: any = {
49+
info: () => {},
50+
warn: () => {},
51+
error: () => {},
52+
debug: () => {},
53+
};
54+
logger.child = () => logger;
55+
return logger;
56+
}
57+
58+
/**
59+
* A `fetch` stand-in that answers every call with the health payload and records
60+
* the URLs it was asked for, so the test can also confirm the flow-owned path
61+
* (`/api/v1/health`) actually went out.
62+
*/
63+
function stubFetch(requested: string[]): typeof fetch {
64+
return (async (input: any) => {
65+
requested.push(typeof input === 'string' ? input : String(input?.url ?? input));
66+
return new Response(JSON.stringify(HEALTH_PAYLOAD), {
67+
status: 200,
68+
headers: { 'content-type': 'application/json' },
69+
});
70+
}) as unknown as typeof fetch;
71+
}
72+
73+
function newEngine(): AutomationEngine {
74+
const engine = new AutomationEngine(silentLogger());
75+
registerConnectorNodes(engine, { logger: silentLogger() } as any);
76+
return engine;
77+
}
78+
79+
describe('showcase REST connector flows — run output capture (#7542)', () => {
80+
it('showcase_task_completed_rest_ping captures the health response as ping.body', async () => {
81+
const requested: string[] = [];
82+
const engine = newEngine();
83+
84+
// The plugin-registered `rest` connector, exactly as ConnectorRestPlugin
85+
// builds it in objectstack.config.ts — only `fetch` is injected.
86+
const { def, handlers } = createRestConnector({
87+
name: 'rest',
88+
baseUrl: 'http://127.0.0.1:3000',
89+
fetchImpl: stubFetch(requested),
90+
});
91+
engine.registerConnector(def, handlers);
92+
93+
engine.registerFlow(TaskCompletedRestPingFlow.name, TaskCompletedRestPingFlow);
94+
95+
// The flow is gated on the done-transition, so drive it with the same
96+
// trigger context a record-after-update hook would supply.
97+
const result = await engine.execute(TaskCompletedRestPingFlow.name, {
98+
object: 'showcase_task',
99+
event: 'on_update',
100+
record: { id: 't1', status: 'done' },
101+
previous: { id: 't1', status: 'in_progress' },
102+
});
103+
104+
expect(result.success).toBe(true);
105+
// The call the flow declared actually went out...
106+
expect(requested).toHaveLength(1);
107+
expect(requested[0]).toContain('/api/v1/health');
108+
// ...and its response is on the run, which is what the comment claims.
109+
expect(result.output).toEqual({ 'ping.body': HEALTH_PAYLOAD });
110+
});
111+
112+
it('showcase_declarative_connector_ping captures it too, through the materialized ADR-0097 instance', async () => {
113+
const requested: string[] = [];
114+
const engine = newEngine();
115+
116+
// Materialize `showcase_status_api` the way the automation service does at
117+
// boot: the REAL provider factory, fed the REAL declared metadata.
118+
// The factory contract allows an async materialization, so await it — the
119+
// `rest` one happens to be synchronous.
120+
const factory = createRestProviderFactory({ fetchImpl: stubFetch(requested) });
121+
const { def, handlers } = await factory({
122+
name: StatusApiConnector.name,
123+
label: StatusApiConnector.label,
124+
providerConfig: StatusApiConnector.providerConfig,
125+
auth: StatusApiConnector.auth,
126+
} as any);
127+
engine.registerConnector(def, handlers);
128+
129+
engine.registerFlow(ShowcaseDeclarativeConnectorPingFlow.name, ShowcaseDeclarativeConnectorPingFlow);
130+
131+
const result = await engine.execute(ShowcaseDeclarativeConnectorPingFlow.name, {
132+
object: 'showcase_task',
133+
event: 'on_create',
134+
record: { id: 't2', status: 'todo' },
135+
});
136+
137+
expect(result.success).toBe(true);
138+
expect(requested).toHaveLength(1);
139+
expect(requested[0]).toContain('/api/v1/health');
140+
expect(result.output).toEqual({ 'ping.body': HEALTH_PAYLOAD });
141+
});
142+
143+
it('declares the output variable on both flows — the defect was its absence, not a wrong value', () => {
144+
for (const flow of [TaskCompletedRestPingFlow, ShowcaseDeclarativeConnectorPingFlow]) {
145+
const outputs = (flow.variables ?? []).filter((v) => v.isOutput);
146+
expect(outputs.map((v) => v.name)).toEqual(['ping.body']);
147+
// The name must match the connector node it reads from: the engine writes
148+
// back under `${nodeId}.${key}`.
149+
expect(flow.nodes.some((n) => n.id === 'ping' && n.type === 'connector_action')).toBe(true);
150+
}
151+
});
152+
});

0 commit comments

Comments
 (0)