diff --git a/.changeset/23842-automation-stamps-unbound-cause.md b/.changeset/23842-automation-stamps-unbound-cause.md new file mode 100644 index 00000000000..bc2d1ba1630 --- /dev/null +++ b/.changeset/23842-automation-stamps-unbound-cause.md @@ -0,0 +1,9 @@ +--- +'@objectstack/service-automation': minor +--- + +feat(automation): `getFlowRuntimeStates()` stamps `unboundCause` beside `reason` + +Clause-②: yes (widening) + +Each branch that decides why an enabled, triggered flow is not armed now answers its closed `FlowUnboundCause` together with its sentence: `deployment_policy`, `disabled_subflow`, `missing_trigger` or `binding_failure`. `GET /automation/_status` passes the rows through unchanged, so the field reaches the console with no route change. Bound, disabled and trigger-less rows carry neither `reason` nor `unboundCause`. The sentences and `getTriggerBindingAudit()` are unchanged. diff --git a/.changeset/23842-flow-runtime-state-unbound-cause.md b/.changeset/23842-flow-runtime-state-unbound-cause.md new file mode 100644 index 00000000000..d8d3b4281cf --- /dev/null +++ b/.changeset/23842-flow-runtime-state-unbound-cause.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `FlowRuntimeState` gains `unboundCause`, a closed classifier beside `reason` + +Clause-②: yes (widening) + +`FlowRuntimeState.reason` is a sentence a consumer renders and must not parse, so until now a console could not tell a flow left unarmed by deployment policy from one whose binding is broken: both arrive as `enabled: true, bound: false` with a `reason`. The row now also carries an optional `unboundCause`, present on exactly the rows that carry `reason` and absent on every other row. + +Its values are published as `FLOW_UNBOUND_CAUSES` (`@objectstack/spec/contracts`), with the derived type `FlowUnboundCause`: + +- `deployment_policy`: package-authored scheduled work is switched off on this deployment. An operator remedy, not a defect. +- `disabled_subflow`: the flow calls a packaged subflow that is disabled (ADR-0126 §7.3). Enable the subflow and the caller arms. +- `missing_trigger`: no trigger of the declared type is registered. Depend on the trigger package (`requires: ['triggers']`). +- `binding_failure`: the trigger is registered but binding the flow to it failed. The earlier boot warnings name the error. + +Additive: existing producers and consumers keep working unchanged, and `reason` keeps its text. diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index bf78713e7b0..030e916ac0d 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -3573,6 +3573,8 @@ describe('AutomationEngine - the deployment switch (#17396)', () => { expect(row?.reason).toBe(SCHEDULED_WORK_DISABLED_REASON); expect(row?.reason).not.toMatch(/binding failed/); expect(row?.reason).toContain(SCHEDULED_WORK_ENV); + // [#23842] Classified without parsing the sentence: an operator remedy. + expect(row?.unboundCause).toBe('deployment_policy'); }); it('the two doors answer the SAME sentence for the same flow — one computation, no drift', () => { @@ -3607,6 +3609,10 @@ describe('AutomationEngine - the deployment switch (#17396)', () => { // And they really are two different sentences — an identity between two // constants would pass vacuously. expect(new Set(audit.map((a) => a.reason)).size).toBe(2); + // [#23842] ...and two different causes, stamped where each branch is + // decided: no `record_change` trigger is registered on this kernel. + expect(states.find((s) => s.name === 'digest')?.unboundCause).toBe('deployment_policy'); + expect(states.find((s) => s.name === 'missing_trigger')?.unboundCause).toBe('missing_trigger'); }); it('the status row reports what HAPPENED, not what the environment says when it is read', () => { @@ -3642,6 +3648,8 @@ describe('AutomationEngine - the deployment switch (#17396)', () => { const row = engine.getFlowRuntimeStates().find((s) => s.name === 'digest'); expect(row?.reason).toMatch(/binding failed/); expect(row?.reason).not.toBe(SCHEDULED_WORK_DISABLED_REASON); + // [#23842] A defect, classified as one — ⛔ never `deployment_policy`. + expect(row?.unboundCause).toBe('binding_failure'); }); it('⭐ DARK: a row with nothing to explain carries NO reason key at all', () => { @@ -3655,14 +3663,32 @@ describe('AutomationEngine - the deployment switch (#17396)', () => { engine.registerTrigger(recordingTrigger('record_change').trigger); engine.registerFlow('digest', scheduleFlow('digest')); // bound engine.registerFlow('rc_obsolete', { ...recordChangeFlow('rc_obsolete'), status: 'obsolete' }); // disabled + engine.registerFlow('by_hand', { // manual + name: 'by_hand', + label: 'by_hand', + type: 'autolaunched' as const, + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }); const states = engine.getFlowRuntimeStates(); const bound = states.find((s) => s.name === 'digest'); const disabled = states.find((s) => s.name === 'rc_obsolete'); + const manual = states.find((s) => s.name === 'by_hand'); expect(bound).toMatchObject({ enabled: true, bound: true }); expect(disabled).toMatchObject({ enabled: false, bound: false }); + expect(manual, 'control: the manual flow is listed, unbound, with no trigger').toMatchObject({ enabled: true, bound: false }); + expect(manual?.triggerType).toBeUndefined(); expect(Object.keys(bound ?? {}), 'a bound flow explains nothing').not.toContain('reason'); expect(Object.keys(disabled ?? {}), '`enabled: false` already says it').not.toContain('reason'); + // [#23842] The classifier is paired with the sentence: absent together. + for (const row of [bound, disabled, manual]) { + expect(Object.keys(row ?? {}), `'${row?.name}' carries no reason`).not.toContain('reason'); + expect(Object.keys(row ?? {}), `'${row?.name}' carries no unboundCause`).not.toContain('unboundCause'); + } expect(engine.getTriggerBindingAudit(), 'control: the sibling door is empty for the same reason').toHaveLength(0); }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index d071ba2ba7c..4c735c08881 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -21,6 +21,7 @@ import { type ScreenFieldVisibility, } from './screen-input-contract.js'; import type { Logger } from '@objectstack/spec/contracts'; +import type { FlowUnboundCause } from '@objectstack/spec/contracts'; import { FLOW_HOOK_SECRET_KEY, flowCredentialClassLabel, flowCredentialPositions } from './flow-credential-projection.js'; import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor, DecisionConfigSchema } from '@objectstack/spec/automation'; // [#14328] The ONE answer to "which trigger kind does this flow ask for?" — @@ -4850,6 +4851,7 @@ export class AutomationEngine implements IAutomationService { triggerType?: string; object?: string; reason?: string; + unboundCause?: FlowUnboundCause; armedFrom?: FlowContender; shadowed?: FlowContender[]; }> { @@ -4859,7 +4861,7 @@ export class AutomationEngine implements IAutomationService { // reads. This map holds ONE entry per bare name, so without these // two fields a displaced contender leaves no trace on this surface. const shadowing = this.flowShadowing.get(name); - const reason = this.describeUnboundReason(name, resolved); + const unbound = this.describeUnboundReason(name, resolved); return { name, enabled: this.isFlowEnabled(name), @@ -4870,7 +4872,11 @@ export class AutomationEngine implements IAutomationService { // Absent, not `undefined`-valued: a row that is bound, disabled // or trigger-less has no reason to carry, and `reason: null` on // the wire would read as "we looked and found nothing". - ...(reason === undefined ? {} : { reason }), + // [#23842] `unboundCause` rides the SAME spread, so the pair is + // present together or absent together by construction. + ...(unbound === undefined + ? {} + : { reason: unbound.reason, unboundCause: unbound.cause }), ...(shadowing ? { armedFrom: shadowing.armed, shadowed: shadowing.shadowed } : {}), @@ -4886,6 +4892,11 @@ export class AutomationEngine implements IAutomationService { * drift apart. `undefined` means this flow has no unbound reason to report: * it is bound, it is disabled, or it declares no trigger at all. * + * [#23842] Each branch answers its sentence AND its closed + * `FlowUnboundCause` together, so the status door can let a consumer + * classify a row without parsing the sentence — one value per branch, + * stamped where the branch is decided, never inferred from the text. + * * The POLICY branch outranks both binding branches, and deliberately so. * When package-authored scheduled work is off, neither of the other two * reasons is true in any useful sense: the trigger was never called, so @@ -4912,21 +4923,27 @@ export class AutomationEngine implements IAutomationService { private describeUnboundReason( name: string, resolved: { triggerType: string } | undefined, - ): string | undefined { + ): { reason: string; cause: FlowUnboundCause } | undefined { if (!resolved) return undefined; // manual / screen flow — nothing to bind if (!this.isFlowEnabled(name)) return undefined; if (this.boundFlowTriggers.has(name)) return undefined; const policyReason = this.policyDisabledFlows.get(name); - if (policyReason !== undefined) return policyReason; + if (policyReason !== undefined) return { reason: policyReason, cause: 'deployment_policy' }; // [#20725, ADR-0126 §7.3] The arming gate declined it onto a disabled // packaged subflow — read from the record, for the policy line's // reason, and ahead of the trigger branches for the gate's: with the // child off, the trigger would not arm it either. const declined = this.subflowDeclinedFlows.get(name); - if (declined !== undefined) return declined; + if (declined !== undefined) return { reason: declined, cause: 'disabled_subflow' }; return this.triggers.has(resolved.triggerType) - ? `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings` - : `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`; + ? { + reason: `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings`, + cause: 'binding_failure', + } + : { + reason: `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`, + cause: 'missing_trigger', + }; } /** @@ -4946,9 +4963,9 @@ export class AutomationEngine implements IAutomationService { // trigger) and the three-branch vocabulary both live in // `describeUnboundReason` now, so this audit and the status door // report the SAME sentence for the same flow by construction. - const reason = this.describeUnboundReason(name, resolved); - if (reason === undefined || !resolved) continue; - audit.push({ flowName: name, triggerType: resolved.triggerType, reason }); + const unbound = this.describeUnboundReason(name, resolved); + if (unbound === undefined || !resolved) continue; + audit.push({ flowName: name, triggerType: resolved.triggerType, reason: unbound.reason }); } return audit; } diff --git a/packages/services/service-automation/src/subflow-guard-every-door.test.ts b/packages/services/service-automation/src/subflow-guard-every-door.test.ts index 28c2accbbb8..c702c947fe0 100644 --- a/packages/services/service-automation/src/subflow-guard-every-door.test.ts +++ b/packages/services/service-automation/src/subflow-guard-every-door.test.ts @@ -185,6 +185,9 @@ describe('ADR-0126 §7.3 at REGISTRATION — the arming gate declines a packaged expect(row).toMatchObject({ enabled: true, bound: false }); expect(row?.reason).toContain("'shared_step'"); expect(row?.reason).toContain('ADR-0126 §7.3'); + // [#23842] The classifier rides beside the sentence: an admin remedy + // (enable the subflow), ⛔ neither a policy nor a binding defect. + expect(row?.unboundCause).toBe('disabled_subflow'); const declined = declineWarnings(logger, 'vendor_process'); expect(declined).toHaveLength(1); expect(declined[0]).toContain("'shared_step' (switched off in the activation ledger)"); @@ -230,6 +233,7 @@ describe('ADR-0126 §7.3 at REGISTRATION — the arming gate declines a packaged expect(trigger.isBound('vendor_process')).toBe(true); expect(stateOf(engine, 'vendor_process')?.reason).toBeUndefined(); + expect(Object.keys(stateOf(engine, 'vendor_process') ?? {}), '[#23842] armed: the cause clears with the sentence').not.toContain('unboundCause'); expect((await engine.execute('vendor_process')).success).toBe(true); }); diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 4f2e8a74743..a93ebfcc39e 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -100,8 +100,10 @@ "ExecuteUpgradeInput (interface)", "ExplainAccessRequest (interface)", "FILE_ATTACHMENT_SERVICE (const)", + "FLOW_UNBOUND_CAUSES (const)", "FinishReason (type)", "FlowRuntimeState (interface)", + "FlowUnboundCause (type)", "GenerateDraftOpts (interface)", "GenerateObjectOptions (interface)", "GrantShareInput (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 24815be18b6..cc5c172cadb 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -100,8 +100,10 @@ "ExecuteUpgradeInput": "src/contracts/package-service.ts#ExecuteUpgradeInput (interface)", "ExplainAccessRequest": "src/contracts/security-service.ts#ExplainAccessRequest (interface)", "FILE_ATTACHMENT_SERVICE": "src/contracts/storage-service.ts#FILE_ATTACHMENT_SERVICE (const)", + "FLOW_UNBOUND_CAUSES": "src/contracts/automation-service.ts#FLOW_UNBOUND_CAUSES (const)", "FinishReason": "node_modules/ai/dist/index.d.ts#FinishReason (type)", "FlowRuntimeState": "src/contracts/automation-service.ts#FlowRuntimeState (interface)", + "FlowUnboundCause": "src/contracts/automation-service.ts#FlowUnboundCause (type)", "GenerateDraftOpts": "src/contracts/external-datasource-service.ts#GenerateDraftOpts (interface)", "GenerateObjectOptions": "src/contracts/ai-service.ts#GenerateObjectOptions (interface)", "GrantShareInput": "src/contracts/sharing-service.ts#GrantShareInput (interface)", diff --git a/packages/spec/src/contracts/automation-service.test.ts b/packages/spec/src/contracts/automation-service.test.ts index 75b13be2632..d6f0697eb7f 100644 --- a/packages/spec/src/contracts/automation-service.test.ts +++ b/packages/spec/src/contracts/automation-service.test.ts @@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; -import type { IAutomationService, AutomationResult, FlowRuntimeState } from './automation-service'; +import type { IAutomationService, AutomationResult, FlowRuntimeState, FlowUnboundCause } from './automation-service'; +import { FLOW_UNBOUND_CAUSES } from './automation-service'; import type { FlowParsed } from '../automation/flow.zod'; import { FlowSchema } from '../automation/flow.zod'; import type { ExecutionLog } from '../automation/execution.zod'; @@ -49,6 +50,18 @@ export type FlowRuntimeStateCarriesAnOptionalReason = Assert; +/** + * [#23842] The classifier beside the sentence: optional, and CLOSED over the + * exported tuple, so a consumer can enumerate it and a producer cannot stamp a + * value the tuple does not list. The identity fails if the key is dropped, + * widened to `string`, or drifts from `FLOW_UNBOUND_CAUSES`. + */ +export type FlowRuntimeStateUnboundCauseIsTheClosedTuple = Assert< + Eq +>; +export type FlowUnboundCauseIsTheTupleUnion = Assert< + Eq +>; describe('Automation Service Contract', () => { it('should allow a minimal IAutomationService implementation with required methods', () => { @@ -457,4 +470,27 @@ describe('FlowRuntimeState — carries the reason a flow is unbound', () => { expect(doc).toContain('RECORDED refusal'); expect(doc).toContain('getTriggerBindingAudit()'); }); + + it('[#23842] classifies through a closed tuple beside the sentence, and the sentence stays render-only', () => { + // One machine value per cause, snake_case, no duplicates, no catch-all. + expect(new Set(FLOW_UNBOUND_CAUSES).size).toBe(FLOW_UNBOUND_CAUSES.length); + for (const cause of FLOW_UNBOUND_CAUSES) expect(cause).toMatch(/^[a-z]+(_[a-z]+)*$/); + expect(FLOW_UNBOUND_CAUSES).not.toContain('other'); + const policyRow: FlowRuntimeState = { + name: 'daily_digest', + enabled: true, + bound: false, + triggerType: 'schedule', + reason: 'disabled by deployment policy', + unboundCause: 'deployment_policy', + }; + expect(FLOW_UNBOUND_CAUSES).toContain(policyRow.unboundCause); + + const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8'); + const block = source.slice(source.indexOf('export interface FlowRuntimeState')); + const doc = block.slice(0, block.indexOf('\n}')); + // `reason` still forbids parsing, and now names where classification lives. + expect(doc).toContain('do not parse it'); + expect(doc).toContain('{@link FlowRuntimeState.unboundCause}'); + }); }); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index dbae3465780..4498d729cba 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -620,7 +620,8 @@ export interface FlowRuntimeState { * Whether a trigger is actually wired to this flow. `false` for a flow with * no declared trigger (manual / screen flows) AND for one whose declared * trigger type has no registered trigger — `triggerType` distinguishes the - * two. + * two, and on an enabled, triggered row {@link FlowRuntimeState.unboundCause} + * names which unbound cause applies. */ bound: boolean; /** Persisted deployment status, when the flow carries one. */ @@ -658,10 +659,50 @@ export interface FlowRuntimeState { * * A free-form sentence, deliberately — the shape the two surfaces that * already carry this reason use. Consumers RENDER it; ⛔ do not parse it. + * A consumer that must CLASSIFY the row (style a policy row apart from a + * defect) reads {@link FlowRuntimeState.unboundCause}, which rides beside + * this sentence on exactly the same rows. */ reason?: string; + /** + * WHICH cause {@link FlowRuntimeState.reason} describes, as a closed + * machine value — one of {@link FLOW_UNBOUND_CAUSES}. Present exactly on + * the rows that carry `reason`, absent on every other row (bound, + * disabled, or declaring no trigger), so a consumer classifies without + * parsing the sentence and renders the sentence without branching on it. + */ + unboundCause?: FlowUnboundCause; } +/** + * Every distinct cause the automation engine reports for an ENABLED, + * trigger-declaring flow that is `bound: false` — one value per cause the + * producer actually composes, each with its own owner and remedy, so a value + * is never a catch-all: + * + * - `deployment_policy` — package-authored scheduled work is switched off on + * this deployment (the `ScheduledWorkPolicy` refusal, read from the + * recorded refusal). Operator remedy: the deployment switch or the host. + * ⛔ Not a defect. + * - `disabled_subflow` — the arming gate declined the flow because a packaged + * subflow it calls is disabled (ADR-0126 §7.3). Admin remedy: enable the + * subflow, and the caller arms. + * - `missing_trigger` — no trigger of the declared type is registered on this + * kernel. Author remedy: depend on the trigger package + * (`requires: ['triggers']`). + * - `binding_failure` — the trigger is registered but binding the flow to it + * failed. Engineering remedy: the earlier boot warnings name the error. + */ +export const FLOW_UNBOUND_CAUSES = [ + 'deployment_policy', + 'disabled_subflow', + 'missing_trigger', + 'binding_failure', +] as const; + +/** Why an enabled, triggered flow is not armed — derived from {@link FLOW_UNBOUND_CAUSES}. */ +export type FlowUnboundCause = (typeof FLOW_UNBOUND_CAUSES)[number]; + /** * One window of execution runs, plus the truncation fact the window alone * cannot carry (#19543).