From 5e7bc3f6ee4e9cb529f912a04e84cfcd198830d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 08:57:09 +0000 Subject: [PATCH] fix(examples): capture the connector response on the two showcase REST ping flows (#7542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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' }`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy --- .../src/automation/flows/index.ts | 22 ++- .../test/connector-ping-run-output.test.ts | 152 ++++++++++++++++++ 2 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 examples/app-showcase/test/connector-ping-run-output.test.ts diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index ad2bdb04fb..34dc8f8927 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -411,9 +411,9 @@ 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', @@ -421,6 +421,12 @@ export const TaskCompletedRestPingFlow = defineFlow({ 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', @@ -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', @@ -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', diff --git a/examples/app-showcase/test/connector-ping-run-output.test.ts b/examples/app-showcase/test/connector-ping-run-output.test.ts new file mode 100644 index 0000000000..36f3920302 --- /dev/null +++ b/examples/app-showcase/test/connector-ping-run-output.test.ts @@ -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); + } + }); +});