From ee3b3e03039f69237b115e25b555fbad32180d35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:03:36 +0000 Subject: [PATCH 1/6] feat(service-automation): AutomationResult carries the flow's authored label Every result of an evaluation of a registered flow (paused, terminal success including the skip exits, failed, stranded, refused, and a resumed parent whose delegated child failed) now carries `flowLabel`, the flow definition's `label` copied verbatim the way `successMessage` / `errorMessage` are. Refusals carrying a `code` and unknown flows carry none. A subflow chain answers with the addressed (parent) run's label. The trigger response schema mirrors the new member, as its compile-time parity guard with `AutomationResult` requires. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx --- .../services/service-automation/src/engine.ts | 39 +++++++++++++++---- packages/spec/src/api/automation-api.zod.ts | 7 ++++ .../spec/src/contracts/automation-service.ts | 22 +++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 679f73ddd8f..f0a7f62aac1 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -5364,7 +5364,10 @@ export class AutomationEngine implements IAutomationService { typeof startCondition === 'string' ? { dialect: 'cel', source: startCondition } : startCondition; if (!this.evaluateCondition(condExpr, variables)) { this.logger.debug(`Flow '${flowName}' skipped: start condition not met`); - return { success: true, output: { skipped: true, reason: 'condition_not_met' } }; + // `flowLabel` rides even here, unlike `successMessage` / + // `summary`: it names the flow, it claims no work done, and + // this answer reaches a runner as a 200 like any other. + return { success: true, output: { skipped: true, reason: 'condition_not_met' }, flowLabel: flow.label }; } } @@ -5411,7 +5414,7 @@ export class AutomationEngine implements IAutomationService { `note booleans persist as 0/1 on SQLite/libsql and CEL \`1 != true\` is true.`, { recordId: String(guardRecordId) }, ); - return { success: true, output: { skipped: true, reason: 'reentrancy_loop_guard' } }; + return { success: true, output: { skipped: true, reason: 'reentrancy_loop_guard' }, flowLabel: flow.label }; } if (reentryKey) { this.activeRecordFlows.add(reentryKey); @@ -5568,6 +5571,10 @@ export class AutomationEngine implements IAutomationService { // one would be a toast about work nobody did. They carry no // `summary` for exactly the same reason. successMessage: flow.successMessage, + // The authored flow name for the runner header and the + // completion toast — on EVERY evaluation's result, paused and + // terminal alike (see `AutomationResult.flowLabel`). + flowLabel: flow.label, // #4354 — hand the counts back synchronously so a caller // (a `subflow` roll-up, a runtime test asserting the sweep wrote // something) never has to re-read the run to learn what it did. @@ -5631,6 +5638,7 @@ export class AutomationEngine implements IAutomationService { runId, durationMs, screen: err.screen, + flowLabel: flow.label, }; } @@ -5780,7 +5788,9 @@ export class AutomationEngine implements IAutomationService { // `persistSuspendedRun` stored the continuation under — so it is // the only id `resume()` can be called with. if (flow.errorHandling?.strategy === 'retry') { - return this.retryExecution(flowName, context, startTime, flow.errorHandling, flow.errorMessage); + return this.retryExecution( + flowName, context, startTime, flow.errorHandling, flow.errorMessage, flow.label, + ); } return { success: false, @@ -5833,6 +5843,7 @@ export class AutomationEngine implements IAutomationService { // to the raw node error text, which is what every non-screen // flow showed until now. errorMessage: flow.errorMessage, + flowLabel: flow.label, // A failed run's counts matter MORE, not less: they say how far // it got before dying — how many rows it had already written. // [#17562] Recomputed when the guard above had to abandon @@ -6532,6 +6543,10 @@ export class AutomationEngine implements IAutomationService { runId, durationMs: Date.now() - run.startTime, screen: childRes.screen, + // THIS run's flow, not the child's: the caller + // addressed this run id and its runner names the + // flow it launched. The child only lends a screen. + flowLabel: flow.label, }; } // [#14379] A child REFUSAL is not a child failure. The @@ -6593,7 +6608,7 @@ export class AutomationEngine implements IAutomationService { error, this.consumedSuspensions.has(childRunId) ? childRunId : undefined, ); - return { success: false, error, durationMs: Date.now() - run.startTime }; + return { success: false, error, durationMs: Date.now() - run.startTime, flowLabel: flow.label }; } // [#18714] DELEGATED-LEG REFUSAL. The child ran to a // refusing terminal — an `end` declaring @@ -7007,6 +7022,7 @@ export class AutomationEngine implements IAutomationService { output, durationMs, successMessage: flow.successMessage, + flowLabel: flow.label, summary, }; } catch (err: unknown) { @@ -7097,7 +7113,7 @@ export class AutomationEngine implements IAutomationService { steps, variables: variablesSnapshot, }, context); - return { success: true, status: 'paused', runId, durationMs, screen: err.screen }; + return { success: true, status: 'paused', runId, durationMs, screen: err.screen, flowLabel: flow.label }; } const errorMessage = err instanceof Error ? err.message : String(err); @@ -7256,6 +7272,7 @@ export class AutomationEngine implements IAutomationService { // worse) condition, which this stamp must not claim. status: 'stranded', errorMessage: flow.errorMessage, + flowLabel: flow.label, // [#15555] Recomputed when the guard above had to abandon // `recordLog`: the same pure function of the same steps // that `recordLog`'s own first statement runs, so the two @@ -8970,6 +8987,8 @@ export class AutomationEngine implements IAutomationService { * `'paused'`, so callers can resume it", and a refused run is never * resumed — handing one back would advertise a verb that answers * `RUN_NOT_FOUND`. + * - `flowLabel` — the refused run's own flow, as on every evaluation's + * result: the refusal notice is still shown under the flow's name. * * The `recordLog` call is guarded exactly as the completion sites are * (#16274 / #15555): a history write must never break the run that @@ -9038,6 +9057,7 @@ export class AutomationEngine implements IAutomationService { success: true, status: 'refused', refusalMessage: args.refusalMessage, + flowLabel: args.flow.label, output, durationMs: args.durationMs, summary: logged?.summary ?? summarizeRun(args.steps), @@ -11252,7 +11272,8 @@ export class AutomationEngine implements IAutomationService { * passed rather than re-read: `execute()` already holds the parsed flow, * and the exhausted exit below must report the definition THIS dispatch * started under — not whatever a hot-reload re-registered under the same - * name while the loop slept between attempts (#9414). + * name while the loop slept between attempts (#9414). `flowLabel` is + * passed for the same reason. */ private async retryExecution( flowName: string, @@ -11260,6 +11281,7 @@ export class AutomationEngine implements IAutomationService { startTime: number, errorHandling: NonNullable, flowErrorMessage: string | undefined, + flowLabel: string, ): Promise { // `maxRetries >= 1` is guaranteed under `strategy: 'retry'` — the schema // refuses the zero-attempt spelling of "retry" (#4247), so reaching this @@ -11332,6 +11354,7 @@ export class AutomationEngine implements IAutomationService { durationMs: Date.now() - startTime, status: 'failed', errorMessage: flowErrorMessage, + flowLabel, }; } @@ -11627,7 +11650,7 @@ export class AutomationEngine implements IAutomationService { // The author's completion text has to be produced here as well, or // `successMessage` would be a function of which attempt happened to // work — the same route-dependent shape the fix is removing. - return { success: true, output, durationMs, successMessage: flow.successMessage, summary }; + return { success: true, output, durationMs, successMessage: flow.successMessage, flowLabel: flow.label, summary }; } catch (err: unknown) { // [#15788] The THIRD producer: an attempt that reached a refusing // `end`. A flow under `errorHandling.strategy: 'retry'` is handed @@ -11730,6 +11753,7 @@ export class AutomationEngine implements IAutomationService { runId, durationMs, screen: err.screen, + flowLabel: flow.label, }; } @@ -11802,6 +11826,7 @@ export class AutomationEngine implements IAutomationService { durationMs, status: 'failed', errorMessage: flow.errorMessage, + flowLabel: flow.label, // [#17562] Recomputed when the guard above had to abandon // `recordLog`: the same pure function of the same steps that // `recordLog`'s own first statement runs, so the two spellings diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index c4d86b26972..720ad6ffa5d 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -354,6 +354,13 @@ export const TriggerFlowResponseSchema = lazySchema(() => BaseResponseSchema.ext errorMessage: z.string().optional().describe( 'Friendly terminal message copied from the flow definition on failure', ), + flowLabel: z.string().optional().describe( + 'The flow definition\'s authored `label`, copied verbatim so a runner can name the flow ' + + '(header, completion toast) and translate it against `flows..label`. Set on every ' + + 'result of an evaluation of a registered flow (paused and terminal alike); absent on a ' + + 'refusal carrying `code`. For a subflow chain it is the addressed (parent) run\'s flow. ' + + 'Never defaulted to the API name', + ), refusalMessage: z.string().optional().describe( 'Rendered refusal, set when `status` is `refused` - the `end` node\'s `message` ' + 'template interpolated against the run\'s variables, so it names the record. ' diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 8ed0f496775..002654f2027 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -462,6 +462,28 @@ export interface AutomationResult { */ successMessage?: string; errorMessage?: string; + /** + * The flow definition's authored `label`, copied verbatim the same way as + * the two messages above, so a flow runner can name the flow it is running + * (the runner header, the completion toast) in words rather than by its + * API name — and translate it against `flows..label`, falling back + * to this string. + * + * Set on every result that describes an EVALUATION of a registered flow — + * `status: 'paused'`, a terminal success (no `status`, including the two + * skip exits), `'failed'`, `'stranded'` and `'refused'`, and a resumed + * parent whose delegated child failed. Absent on every refusal that + * carries a {@link code} (the run never dispatched, or a resume never + * continued it) and when the flow is not registered. + * + * Always the label of the flow the result's run belongs to — for a + * `subflow` chain that is the run the caller addressed (the parent), never + * the child the screen came from. `FlowSchema` requires `label`, so on + * those results it is always present and always what the author wrote — + * ⛔ never replaced by the flow's API name, which the caller already + * holds as the name it triggered. + */ + flowLabel?: string; /** * #14945: the rendered refusal, set when `status` is `'refused'` — the * `end` node's `message` template interpolated against the run's From 06beddef8f396dfd839c3452534e6d1abcdd8466 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:10:15 +0000 Subject: [PATCH 2/6] test(service-automation): pin which results carry flowLabel Present on every evaluation's result (paused, terminal success, skip, failed, retry exhausted, retry-attempt success and pause, stranded, refused), absent on every code-bearing refusal and on an unknown flow; a subflow chain answers with the parent's label; an empty label is served verbatim, never replaced by the API name. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx --- .../src/flow-label-on-result.test.ts | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 packages/services/service-automation/src/flow-label-on-result.test.ts diff --git a/packages/services/service-automation/src/flow-label-on-result.test.ts b/packages/services/service-automation/src/flow-label-on-result.test.ts new file mode 100644 index 00000000000..04a0b46e615 --- /dev/null +++ b/packages/services/service-automation/src/flow-label-on-result.test.ts @@ -0,0 +1,320 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `AutomationResult.flowLabel` — the flow's authored `label`, on the result. + * + * A flow runner names the flow it is running (the runner header, the + * completion toast) and translates that name against `flows..label`, + * falling back to the authored label. The client holds only the flow's API + * name, so the authored label has to arrive on the result — copied from the + * definition the same way `successMessage` / `errorMessage` are. + * + * The served shape, member by member (the contract's docblock is the + * authority; these tests are its pins): + * + * - PRESENT on every result of an evaluation of a registered flow: `paused` + * (first attempt, a retry attempt, a resume that pauses again, a subflow + * chain that pauses again), terminal success (trigger, retry attempt, + * resume, and the two skip exits), `failed` (trigger, retry exhausted), + * `stranded`, `refused` (trigger and resume), and a resumed parent whose + * delegated child failed. + * - ABSENT on every refusal carrying a `code` (the run never dispatched, or + * a resume never continued it) and when the flow is not registered. + * - SUBFLOW: always the label of the run the caller addressed — the parent — + * never the child the screen came from. + * - VERBATIM: the authored string, ⛔ never the API name. `FlowSchema` + * requires `label`, so a flow without one never registers and there is no + * "absent label" arm to serve; the empty string is the case a server-side + * name fallback would betray, so it is pinned as served verbatim. + * + * ⚠️ Direction, predicted before running: every PRESENT pin fails against an + * engine without the member (`undefined` where the label is expected); every + * ABSENT pin is green either way on purpose — they fence the boundary rather + * than demonstrate the change. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { AutomationEngine } from './engine.js'; +import type { NodeExecutor } from './engine.js'; +import { installBuiltinNodes } from './builtin/index.js'; + +function silentLogger() { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; +} +function pluginCtx() { + return { logger: silentLogger(), getService() { return undefined; } } as any; +} + +/** Deliberately unlike the API names, so a name fallback cannot pass a pin. */ +const PARENT_LABEL = 'Approve Large Orders'; +const CHILD_LABEL = 'Collect Order Details'; +const WIZARD_LABEL = 'Order Intake Wizard'; + +const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }]; + +type Step = { id: string; type: string; label?: string; config?: Record }; + +/** A straight-line flow: start → ...steps → end, one default edge between each pair. */ +function chain( + name: string, + label: string, + steps: Step[], + opts: { startConfig?: Record; endConfig?: Record; flow?: Record } = {}, +) { + const nodes = [ + { id: 'start', type: 'start', label: 'Start', ...(opts.startConfig ? { config: opts.startConfig } : {}) }, + ...steps.map((s) => ({ label: s.id, ...s })), + { id: 'end', type: 'end', label: 'End', ...(opts.endConfig ? { config: opts.endConfig } : {}) }, + ]; + const edges = nodes.slice(1).map((n, i) => ({ id: `e${i}`, source: nodes[i].id, target: n.id, type: 'default' })); + return { name, label, type: 'autolaunched', status: 'active', version: 1, nodes, edges, ...opts.flow }; +} + +const ASK: Step = { id: 'ask', type: 'screen', config: { fields: REQUIRED_KIND } }; +const ASK_AGAIN: Step = { id: 'ask_again', type: 'screen', config: { fields: [{ name: 'note', label: 'Note', type: 'text' }] } }; + +describe('AutomationResult.flowLabel — the authored flow label rides the result', () => { + let engine: AutomationEngine; + let flakyCalls: number; + + beforeEach(() => { + engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, pluginCtx()); + flakyCalls = 0; + engine.registerNodeExecutor({ + type: 'work', + async execute() { return { success: true }; }, + } as NodeExecutor); + engine.registerNodeExecutor({ + type: 'boom', + async execute() { return { success: false, error: 'downstream 503' }; }, + } as NodeExecutor); + // Fails its first call only — the retry-attempt fixture. + engine.registerNodeExecutor({ + type: 'flaky', + async execute() { + flakyCalls++; + return flakyCalls === 1 ? { success: false, error: 'transient' } : { success: true }; + }, + } as NodeExecutor); + }); + + describe('present — every evaluation of a registered flow', () => { + it('terminal success on the trigger path', async () => { + engine.registerFlow('approve_orders', chain('approve_orders', PARENT_LABEL, [{ id: 'w', type: 'work' }]) as never); + + const res = await engine.execute('approve_orders'); + + expect(res.success).toBe(true); + expect(res.status).toBeUndefined(); + expect(res.flowLabel).toBe(PARENT_LABEL); + }); + + it('paused at a screen on the trigger path — the runner header\'s source', async () => { + engine.registerFlow('intake', chain('intake', WIZARD_LABEL, [ASK]) as never); + + const res = await engine.execute('intake'); + + expect(res.status).toBe('paused'); + expect(res.screen?.nodeId).toBe('ask'); + expect(res.flowLabel).toBe(WIZARD_LABEL); + }); + + it('a resume that pauses again, then a resume that completes — the completion toast\'s source', async () => { + engine.registerFlow('intake', chain('intake', WIZARD_LABEL, [ASK, ASK_AGAIN]) as never); + const started = await engine.execute('intake'); + + const next = await engine.resume(started.runId!, { variables: { kind: 'vip' } }); + expect(next.status).toBe('paused'); + expect(next.screen?.nodeId).toBe('ask_again'); + expect(next.flowLabel).toBe(WIZARD_LABEL); + + const done = await engine.resume(started.runId!, { variables: { note: 'rush' } }); + expect(done.success).toBe(true); + expect(done.status).toBeUndefined(); + expect(done.flowLabel).toBe(WIZARD_LABEL); + }); + + it('failed on the trigger path, beside errorMessage', async () => { + engine.registerFlow('approve_orders', chain('approve_orders', PARENT_LABEL, [{ id: 'b', type: 'boom' }], { + flow: { errorMessage: 'Could not approve.' }, + }) as never); + + const res = await engine.execute('approve_orders'); + + expect(res.status).toBe('failed'); + expect(res.errorMessage).toBe('Could not approve.'); + expect(res.flowLabel).toBe(PARENT_LABEL); + }); + + it('failed after the retry budget is exhausted — the definition this dispatch started under', async () => { + engine.registerFlow('approve_orders', chain('approve_orders', PARENT_LABEL, [{ id: 'b', type: 'boom' }], { + flow: { errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 0 } }, + }) as never); + + const res = await engine.execute('approve_orders'); + + expect(res.status).toBe('failed'); + expect(res.flowLabel).toBe(PARENT_LABEL); + }); + + it('success and pause on a RETRY attempt — executeWithoutRetry\'s exits', async () => { + const retry = { flow: { errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 0 } } }; + engine.registerFlow('flaky_done', chain('flaky_done', PARENT_LABEL, [{ id: 'f', type: 'flaky' }], retry) as never); + const done = await engine.execute('flaky_done'); + expect(flakyCalls).toBe(2); + expect(done.success).toBe(true); + expect(done.flowLabel).toBe(PARENT_LABEL); + + flakyCalls = 0; + engine.registerFlow('flaky_ask', chain('flaky_ask', WIZARD_LABEL, [{ id: 'f', type: 'flaky' }, ASK], retry) as never); + const paused = await engine.execute('flaky_ask'); + expect(flakyCalls).toBe(2); + expect(paused.status).toBe('paused'); + expect(paused.flowLabel).toBe(WIZARD_LABEL); + }); + + it('stranded — a resume consumed the pause and a downstream node threw', async () => { + engine.registerFlow('intake', chain('intake', WIZARD_LABEL, [ASK, { id: 'b', type: 'boom' }]) as never); + const started = await engine.execute('intake'); + + const res = await engine.resume(started.runId!, { variables: { kind: 'vip' } }); + + expect(res.status).toBe('stranded'); + expect(res.flowLabel).toBe(WIZARD_LABEL); + }); + + it('refused — on the trigger path and on the resume path (the one finishRefusedRun chokepoint)', async () => { + const refuse = { endConfig: { outcome: 'refused', message: 'Not eligible' } }; + engine.registerFlow('gate', chain('gate', PARENT_LABEL, [{ id: 'w', type: 'work' }], refuse) as never); + const triggered = await engine.execute('gate'); + expect(triggered.status).toBe('refused'); + expect(triggered.successMessage).toBeUndefined(); + expect(triggered.flowLabel).toBe(PARENT_LABEL); + + engine.registerFlow('gate_ask', chain('gate_ask', WIZARD_LABEL, [ASK], refuse) as never); + const started = await engine.execute('gate_ask'); + const resumed = await engine.resume(started.runId!, { variables: { kind: 'vip' } }); + expect(resumed.status).toBe('refused'); + expect(resumed.flowLabel).toBe(WIZARD_LABEL); + }); + + it('the skip exit — a 200 a runner can receive, though no node ran', async () => { + engine.registerFlow('approve_orders', chain('approve_orders', PARENT_LABEL, [{ id: 'w', type: 'work' }], { + startConfig: { condition: 'false' }, + }) as never); + + const res = await engine.execute('approve_orders'); + + expect(res.output).toEqual({ skipped: true, reason: 'condition_not_met' }); + // A skip still claims no work done: no toast text, no counts. + expect(res.successMessage).toBeUndefined(); + expect(res.summary).toBeUndefined(); + expect(res.flowLabel).toBe(PARENT_LABEL); + }); + + it('VERBATIM — an empty authored label is served empty, never replaced by the API name', async () => { + engine.registerFlow('approve_orders', chain('approve_orders', '', [{ id: 'w', type: 'work' }]) as never); + + const res = await engine.execute('approve_orders'); + + expect(res.success).toBe(true); + expect(res.flowLabel).toBe(''); + }); + }); + + describe('subflow chains — the addressed (parent) run\'s label, never the child\'s', () => { + beforeEach(() => { + engine.registerFlow('collect_details', chain('collect_details', CHILD_LABEL, [ASK, ASK_AGAIN], { + flow: { type: 'screen' }, + }) as never); + engine.registerFlow('collect_then_fail', chain('collect_then_fail', CHILD_LABEL, [ASK, { id: 'b', type: 'boom' }], { + flow: { type: 'screen' }, + }) as never); + }); + + const parent = (child: string) => chain('approve_orders', PARENT_LABEL, [ + { id: 'call', type: 'subflow', config: { flowName: child } }, + { id: 'w', type: 'work' }, + ]); + + it('paused on the child\'s screen, re-paused on the next one, then completed — the parent\'s label throughout', async () => { + engine.registerFlow('approve_orders', parent('collect_details') as never); + + const started = await engine.execute('approve_orders'); + expect(started.status).toBe('paused'); + expect(started.screen?.nodeId).toBe('ask'); + expect(started.flowLabel).toBe(PARENT_LABEL); + + const next = await engine.resume(started.runId!, { variables: { kind: 'vip' } }); + expect(next.status).toBe('paused'); + expect(next.runId).toBe(started.runId); + expect(next.screen?.nodeId).toBe('ask_again'); + expect(next.flowLabel).toBe(PARENT_LABEL); + + const done = await engine.resume(started.runId!, { variables: { note: 'rush' } }); + expect(done.success).toBe(true); + expect(done.flowLabel).toBe(PARENT_LABEL); + }); + + it('a delegated child that fails terminally — the parent\'s failure carries the parent\'s label', async () => { + engine.registerFlow('approve_orders', parent('collect_then_fail') as never); + const started = await engine.execute('approve_orders'); + + const res = await engine.resume(started.runId!, { variables: { kind: 'vip' } }); + + expect(res.success).toBe(false); + expect(res.error).toContain('subflow run'); + expect(res.flowLabel).toBe(PARENT_LABEL); + }); + + it('a delegated child\'s screen refusal passes through with NO label — neither the child\'s nor the parent\'s', async () => { + engine.registerFlow('approve_orders', parent('collect_details') as never); + const started = await engine.execute('approve_orders'); + + const res = await engine.resume(started.runId!, { variables: {} }); + + expect(res.code).toBe('INVALID_SCREEN_INPUT'); + expect(res).not.toHaveProperty('flowLabel'); + }); + }); + + describe('absent — a refusal carrying `code`, or no registered flow', () => { + it('never-dispatched refusals: FLOW_DISABLED and FLOW_NO_START_NODE', async () => { + engine.registerFlow('approve_orders', chain('approve_orders', PARENT_LABEL, [{ id: 'w', type: 'work' }]) as never); + await engine.toggleFlow('approve_orders', false); + const disabled = await engine.execute('approve_orders'); + expect(disabled.code).toBe('FLOW_DISABLED'); + expect(disabled).not.toHaveProperty('flowLabel'); + + engine.registerFlow('no_start', { + name: 'no_start', label: PARENT_LABEL, type: 'autolaunched', + nodes: [{ id: 'end', type: 'end', label: 'End' }], edges: [], + } as never); + const noStart = await engine.execute('no_start'); + expect(noStart.code).toBe('FLOW_NO_START_NODE'); + expect(noStart).not.toHaveProperty('flowLabel'); + }); + + it('resume refusals: INVALID_SCREEN_INPUT and RUN_NOT_FOUND', async () => { + engine.registerFlow('intake', chain('intake', WIZARD_LABEL, [ASK]) as never); + const started = await engine.execute('intake'); + + const invalid = await engine.resume(started.runId!, { variables: {} }); + expect(invalid.code).toBe('INVALID_SCREEN_INPUT'); + expect(invalid).not.toHaveProperty('flowLabel'); + + const missing = await engine.resume('run_that_never_was', { variables: {} }); + expect(missing.code).toBe('RUN_NOT_FOUND'); + expect(missing).not.toHaveProperty('flowLabel'); + }); + + it('an unregistered flow', async () => { + const res = await engine.execute('nobody_registered_this'); + + expect(res.success).toBe(false); + expect(res).not.toHaveProperty('flowLabel'); + }); + }); +}); From 3c3fccdd76b8153066cc0fcab6ee7b23f1d4d6a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:12:04 +0000 Subject: [PATCH 3/6] test(spec): the trigger response schema preserves flowLabel Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx --- .../spec/src/api/automation-api.zod.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/spec/src/api/automation-api.zod.test.ts b/packages/spec/src/api/automation-api.zod.test.ts index 4dd147a752c..067fbad5c17 100644 --- a/packages/spec/src/api/automation-api.zod.test.ts +++ b/packages/spec/src/api/automation-api.zod.test.ts @@ -403,6 +403,30 @@ describe('TriggerFlowResponseSchema', () => { expect(result.data.summary?.acted).toBe(1); }); + // `AutomationResult.flowLabel` — the flow's authored label, which a runner + // shows (and translates) in its header and completion toast. The compile- + // time `TriggerFlowDataMatchesContract` guard above holds the member's + // presence; this holds its SURVIVAL: the schema strips undeclared keys, so + // a mirror missing the member would parse clean and drop the label. + it('should preserve the authored flowLabel on a paused and on a finished run', () => { + const paused = TriggerFlowResponseSchema.parse({ + success: true, + data: { + success: true, + status: 'paused', + runId: 'run_screen_003', + screen: { nodeId: 'ask', fields: [] }, + flowLabel: 'Order Intake Wizard', + }, + }); + const finished = TriggerFlowResponseSchema.parse({ + success: true, + data: { success: true, successMessage: 'Done.', flowLabel: 'Order Intake Wizard' }, + }); + expect(paused.data.flowLabel).toBe('Order Intake Wizard'); + expect(finished.data.flowLabel).toBe('Order Intake Wizard'); + }); + it('should preserve the failure classification code alongside error', () => { const result = TriggerFlowResponseSchema.parse({ success: true, From 6367da0795f58cd153e28f166b810811c240689c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:17:06 +0000 Subject: [PATCH 4/6] docs(spec): regenerate the automation API reference for flowLabel Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx --- content/docs/references/api/automation-api.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 8b603415414..486d8d43851 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -642,6 +642,7 @@ const result = AutomationApiErrorCode.parse(data); | **screen** | `{ nodeId: string; title?: string; description?: string; fields: object[]; … }` | optional | The screen to render - set when the run paused at a `screen` node awaiting user input. The client collects values for `screen.fields` and resumes the run with them. | | **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast | | **errorMessage** | `string` | optional | Friendly terminal message copied from the flow definition on failure | +| **flowLabel** | `string` | optional | The flow definition's authored `label`, copied verbatim so a runner can name the flow (header, completion toast) and translate it against `flows..label`. Set on every result of an evaluation of a registered flow (paused and terminal alike); absent on a refusal carrying `code`. For a subflow chain it is the addressed (parent) run's flow. Never defaulted to the API name | | **refusalMessage** | `string` | optional | Rendered refusal, set when `status` is `refused` - the `end` node's `message` template interpolated against the run's variables, so it names the record. Authored per-record text (not a flow-level copy like the two above); absent on every other status. A runner shows it with Close only | | **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | What the run did - records selected / acted on, gate skips, per-node status. Set on a TERMINAL result (a paused run has not finished doing it yet). | From f3f6f1aed5c5b852d1ca972e7b465f7c03290986 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:25:03 +0000 Subject: [PATCH 5/6] test(verify): flowLabel reaches the wire on the runner's 200 doors The paused launch, a resume that completes and a terminal launch each carry data.flowLabel; the resume door's 400 FLOW_FAILED details keep their fixed set (no flowLabel), fenced so a widening is deliberate. translateFlow's docblock now says where the authored label the client falls back to comes from, and that the translation key is still unread. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx --- packages/spec/src/system/i18n-resolver.ts | 5 +- .../src/automation-trigger-flow-label.test.ts | 129 ++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 packages/verify/src/automation-trigger-flow-label.test.ts diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 2da8893f3a8..ca2633cf141 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3522,7 +3522,10 @@ export function resolveFlowScreenTitle( * draws, read at the `.objectui-sha` pin `f8a9d0fb`, and the ledger's * `flows.screens` row is `live` citing it. This function stays unregistered * because the server-side route is not the one taken. `flows..label` - * has no reader on either side yet, so its ledger row stays `planned`. + * takes the same client side: the authored label it falls back to reaches the + * runner on every run result as `AutomationResult.flowLabel`, but nothing reads + * the translation key on either side yet (the objectui runner half is still to + * land), so its ledger row stays `planned`. */ export function translateFlow( flow: T, diff --git a/packages/verify/src/automation-trigger-flow-label.test.ts b/packages/verify/src/automation-trigger-flow-label.test.ts new file mode 100644 index 00000000000..b0c8b130b73 --- /dev/null +++ b/packages/verify/src/automation-trigger-flow-label.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `AutomationResult.flowLabel` reaches THE WIRE on the doors a flow runner uses. + * + * The console's runner names the flow it is running in its header and its + * completion toast, and translates that name against `flows..label`, + * falling back to the flow's authored label. The runner holds only the API + * name, so the authored label has to arrive on the response it already reads: + * `POST /api/v1/automation/:name/trigger` (the launch) and + * `POST /api/v1/automation/:name/runs/:runId/resume` (every later step). + * + * It lives here because `@objectstack/verify` is the one package depending on + * BOTH `@objectstack/runtime` (the two doors) and + * `@objectstack/service-automation` (the engine that stamps the member). The + * engine-side pins are `flow-label-on-result.test.ts` in that package; this + * file asserts the sentence neither half can alone: the label is ON the + * response body a runner parses, at `data.flowLabel`. + * + * **The served shape at the wire, measured:** every `200` answer carries it — + * the paused launch, a resume that completes, a terminal launch. The + * `400 FLOW_FAILED` answers do NOT: those doors project `error.details` from a + * fixed set (`errorMessage`, `summary`, and on resume the stranded verdict), + * and no runner surface names the flow on a failure — its failure toast shows + * the error text. The last case fences that boundary so a widening of the + * details set is a deliberate act, not a drift. + * + * ⚠️ This suite resolves both packages through their BUILT `dist/`. Rebuild + * `@objectstack/service-automation` and `@objectstack/runtime` before trusting + * a run of this file — and especially an ABLATED one, where a stale `dist` + * would run the pre-mutation code and report green. + */ + +import { describe, it, expect } from 'vitest'; + +import { HttpDispatcher } from '@objectstack/runtime'; +import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; + +const CTX = { request: {}, executionContext: { userId: 'user_1' } } as never; +const WIZARD_LABEL = 'Order Intake Wizard'; + +function createTestLogger(): never { + const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => logger }; + return logger as never; +} + +/** A screen flow (start → ask → work → end) whose `work` node passes or fails as asked. */ +function boot(opts: { failsAfterScreen?: boolean; screenless?: boolean } = {}): HttpDispatcher { + const engine = new AutomationEngine(createTestLogger()); + installBuiltinNodes(engine as never, { logger: createTestLogger(), getService: () => undefined } as never); + engine.registerNodeExecutor({ + type: 'work', + async execute() { + return opts.failsAfterScreen ? { success: false, error: 'downstream 503' } : { success: true }; + }, + } as never); + const ask = { id: 'ask', type: 'screen', label: 'Ask', config: { fields: [{ name: 'kind', label: 'Kind', type: 'text' }] } }; + const nodes = [ + { id: 'start', type: 'start', label: 'Start' }, + ...(opts.screenless ? [] : [ask]), + { id: 'work', type: 'work', label: 'Work' }, + { id: 'end', type: 'end', label: 'End' }, + ]; + engine.registerFlow('order_intake', { + name: 'order_intake', + label: WIZARD_LABEL, + type: opts.screenless ? 'autolaunched' : 'screen', + nodes, + edges: nodes.slice(1).map((n, i) => ({ id: `e${i}`, source: nodes[i].id, target: n.id })), + } as never); + + const services: Record = { automation: engine }; + const resolve = (name: string): unknown => services[name]; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string): Promise => resolve(name), + context: { getService: resolve }, + }; + return new HttpDispatcher(kernel as never); +} + +const trigger = (d: HttpDispatcher) => d.handleAutomation('/order_intake/trigger', 'POST', {}, CTX); +const resume = (d: HttpDispatcher, runId: string) => + d.handleAutomation(`/order_intake/runs/${runId}/resume`, 'POST', { inputs: { kind: 'vip' } }, CTX); + +describe('AutomationResult.flowLabel at the wire — the doors a flow runner reads', () => { + it('200 paused launch: data.flowLabel is the authored label — the runner header\'s source', async () => { + const res = await trigger(boot()); + + expect(res.response?.status).toBe(200); + expect(res.response?.body?.data?.status).toBe('paused'); + expect(res.response?.body?.data?.screen?.nodeId).toBe('ask'); + expect(res.response?.body?.data?.flowLabel).toBe(WIZARD_LABEL); + }); + + it('200 resume that completes: data.flowLabel is still there — the completion toast\'s source', async () => { + const dispatcher = boot(); + const paused = await trigger(dispatcher); + + const done = await resume(dispatcher, paused.response?.body?.data?.runId as string); + + expect(done.response?.status).toBe(200); + expect(done.response?.body?.data?.success).toBe(true); + expect(done.response?.body?.data?.status).toBeUndefined(); + expect(done.response?.body?.data?.flowLabel).toBe(WIZARD_LABEL); + }); + + it('200 terminal launch of a screenless flow: data.flowLabel, never the API name', async () => { + const res = await trigger(boot({ screenless: true })); + + expect(res.response?.status).toBe(200); + expect(res.response?.body?.data?.flowLabel).toBe(WIZARD_LABEL); + expect(res.response?.body?.data?.flowLabel).not.toBe('order_intake'); + }); + + it('400 FLOW_FAILED on resume: the details set is unchanged — no flowLabel (the fenced boundary)', async () => { + const dispatcher = boot({ failsAfterScreen: true }); + const paused = await trigger(dispatcher); + expect(paused.response?.body?.data?.flowLabel).toBe(WIZARD_LABEL); + + const failed = await resume(dispatcher, paused.response?.body?.data?.runId as string); + + expect(failed.response?.status).toBe(400); + expect(failed.response?.body?.error?.code).toBe('FLOW_FAILED'); + expect(failed.response?.body?.error?.details?.status).toBe('stranded'); + expect(failed.response?.body?.error?.details).not.toHaveProperty('flowLabel'); + expect(failed.response?.body?.data).toBeUndefined(); + }); +}); From 01cd34c7e4e3f416c44b5a1139f4e44ec4375ba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:26:16 +0000 Subject: [PATCH 6/6] chore(changeset): spec and service-automation minor for flowLabel Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx --- .../20318-automation-result-flow-label.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .changeset/20318-automation-result-flow-label.md diff --git a/.changeset/20318-automation-result-flow-label.md b/.changeset/20318-automation-result-flow-label.md new file mode 100644 index 00000000000..3d50ecd181b --- /dev/null +++ b/.changeset/20318-automation-result-flow-label.md @@ -0,0 +1,46 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +--- + +feat(spec,service-automation): a flow run's result carries the flow's authored label as `flowLabel` (#20318) + +Clause-②: yes (widening) + +**The widening.** `AutomationResult` (`@objectstack/spec/contracts`) gains one +optional member, `flowLabel?: string`, and `TriggerFlowResponseSchema` +(`@objectstack/spec/api`) mirrors it on `data`. The automation engine sets it to +the flow definition's `label`, copied verbatim, the same way it copies +`successMessage` and `errorMessage`. Nothing is removed or renamed, and no +existing member changes meaning. + +**Why.** A flow runner names the flow it is running, in its header and in its +completion toast, and translates that name against the `flows..label` +translation key, falling back to the authored label. The runner only held the +flow's API name, so there was no authored label to fall back to. The console's +reader of the translation key is a separate change. + +**Which results carry it.** + +- **Set** on every result of an evaluation of a registered flow: `status: 'paused'` + (first attempt, retry attempt, a resume that pauses again), a terminal success + (including the two skip exits), `'failed'` (including an exhausted retry budget), + `'stranded'`, `'refused'`, and a resumed parent whose delegated child failed. +- **Absent** on every refusal that carries a `code` (the run never dispatched, or a + resume never continued it) and when the flow is not registered. +- **Subflow chains** answer with the label of the run the caller addressed, which + is the parent. The child that supplied the screen does not lend its label. +- **Never the API name.** `FlowSchema` requires `label`, so the value is always + what the author wrote, an empty string included. + +**At the wire.** Both runner doors relay the result verbatim on a `200`, so +`data.flowLabel` arrives on `POST /api/v1/automation/:name/trigger` (a paused or +finished launch) and on `POST /api/v1/automation/:name/runs/:runId/resume` (a +further pause or the completion). A `400 FLOW_FAILED` answer is unchanged: its +`error.details` keep their fixed set (`errorMessage`, `summary` and, on resume, +the stranded verdict), with no `flowLabel`. + +**For a consumer.** A client that parses the trigger response with +`TriggerFlowResponseSchema` now keeps `data.flowLabel`, where an undeclared key +would have been stripped. A caller that deep-compares a whole `AutomationResult` +from `execute()` or `resume()` sees one more key on the results listed above.