diff --git a/.changeset/automation-service-operator-verbs-contract.md b/.changeset/automation-service-operator-verbs-contract.md new file mode 100644 index 0000000000..7e861c999d --- /dev/null +++ b/.changeset/automation-service-operator-verbs-contract.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare the two operator run-lifecycle verbs on `IAutomationService` — `cancelRun` and `restoreConsumedSuspension` (#16495, the contract half of #13953) + +`IAutomationService` (`contracts/automation-service.ts`) gains two OPTIONAL +members, typed as the engine already implements them rather than as the +ruling's `verb(runId)` shorthand, so a door calling through the contract can +say who asked and why: + +- `cancelRun?(runId: string, reason?: string): Promise` — end a + suspended run (ADR-0044's run-cancel primitive): `true` only when this call + consumed a suspension, `false` when none exists under the id (idempotent + success — and the answer an unreadable store lands on too, which the + implementation reports at `error`). +- `restoreConsumedSuspension?(runId: string, options?: { requestedBy?: string; reason?: string })` + answering `{ restored: boolean; runId: string; refusal?: string; reason: string }` + — the operator exit from a run a resume left terminally unresumable + (`AutomationResult.status: 'stranded'`, #13909 / #13937): puts the consumed + suspension back verbatim, replays no signal, undoes nothing, never resumes, + never throws. + +Both docblocks carry the #13953 ruling's persistent-face statement (maintainer +2026-09-05, decision batch #42): "listing and acting go through +`sys_automation_run` (the persistent face), never engine memory" — and its +permission posture: platform-operator verbs gated on the existing +`platform_admin` position, no new permission type, no per-run ownership. + +Additive. Both members are optional, so every existing implementation — +including the `{ execute, listFlows }` minimum the contract's own test pins — +still conforms, and the one non-test implementor (`AutomationEngine` in +`@objectstack/service-automation`) already satisfies both under `implements`. +The result of `restoreConsumedSuspension` is a deliberately NARROWER +structural shape than the engine's `SuspensionRestoreResult`: the engine's +eight-member refusal vocabulary stays with the engine, so `refusal` is typed +`string` on the contract (route (i); a second consumer that needs the +vocabulary is a spec card). No REST route, CLI command, lister or engine +behaviour moves in this change — #13953's services half owns the doors. A +service that does not declare a verb has no operator door for it, and a door +must probe for presence and refuse fail-closed when it is absent. diff --git a/packages/spec/src/contracts/automation-service.test.ts b/packages/spec/src/contracts/automation-service.test.ts index 62a001e2f5..8c55ad016d 100644 --- a/packages/spec/src/contracts/automation-service.test.ts +++ b/packages/spec/src/contracts/automation-service.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + import { describe, it, expect } from 'vitest'; import type { IAutomationService, AutomationResult } from './automation-service'; import type { FlowParsed } from '../automation/flow.zod'; @@ -5,6 +8,30 @@ import { FlowSchema } from '../automation/flow.zod'; import type { ExecutionLog } from '../automation/execution.zod'; import type { ConnectorDescriptor } from '../integration/connector-descriptor'; +/** + * [#16495] Type-level identities for the two operator verbs (the #14384 pin's + * form): a change to either signature — a dropped optional parameter, a + * widened or narrowed result — turns an exported alias red under + * `check:test-typecheck`, which compiles this file. Exported deliberately: an + * unread alias inside a test body is TS6196, and a pin no program compiles is + * no pin at all. + */ +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Assert = T; +type CancelRun = NonNullable; +type RestoreConsumedSuspension = NonNullable; +/** `cancelRun(runId, reason?)` — the engine's shape, not the ruling's `cancelRun(runId)`. */ +export type CancelRunTakesRunIdAndReason = Assert, [runId: string, reason?: string]>>; +export type CancelRunAnswersBoolean = Assert, Promise>>; +/** `restoreConsumedSuspension(runId, options?)` — who asked and why travel through the contract. */ +export type RestoreTakesRunIdAndOptions = Assert< + Eq, [runId: string, options?: { requestedBy?: string; reason?: string }]> +>; +/** The narrower structural result (route (i)): restored, the id echoed, the refusal code, the one-sentence reason. */ +export type RestoreAnswersTheNarrowResult = Assert< + Eq>, { restored: boolean; runId: string; refusal?: string; reason: string }> +>; + describe('Automation Service Contract', () => { it('should allow a minimal IAutomationService implementation with required methods', () => { const service: IAutomationService = { @@ -249,4 +276,121 @@ describe('Automation Service Contract', () => { expect(service.getConnectorDescriptors).toBeUndefined(); }); + + // [#16495] The two operator run-lifecycle verbs — the contract half of + // #13953's ruling A (maintainer 2026-09-05, decision batch #42). The same + // shape of pin #14384 added for `'stranded'`: the members are declared, + // OPTIONAL, typed as the engine implements them (not as the ruling's + // `verb(runId)` shorthand), and their docblocks carry the ruling's + // persistent-face statement. The type-level identities are the exported + // aliases above the suite; the compile of the literals below is the rest. + describe('[#16495] cancelRun / restoreConsumedSuspension — the operator verbs, declared', () => { + it('are optional: the minimal implementation still conforms and has no operator door', () => { + const service: IAutomationService = { + execute: async () => ({ success: true }), + listFlows: async () => [], + }; + + expect(service.cancelRun).toBeUndefined(); + expect(service.restoreConsumedSuspension).toBeUndefined(); + }); + + it('carry the engine signatures through the contract — who asked, and why, reach the implementation', async () => { + const seen: Array> = []; + const service: IAutomationService = { + execute: async () => ({ success: true }), + listFlows: async () => [], + cancelRun: async (runId: string, reason?: string): Promise => { + seen.push({ verb: 'cancelRun', runId, reason }); + return runId === 'run_paused'; + }, + restoreConsumedSuspension: async (runId, options) => { + seen.push({ verb: 'restoreConsumedSuspension', runId, ...options }); + return runId === 'run_stranded' + ? { restored: true, runId, reason: `Run '${runId}' is suspended again at node 'approve'` } + : { restored: false, runId, refusal: 'RUN_NOT_FOUND', reason: `No run '${runId}' is known` }; + }, + }; + + expect(await service.cancelRun!('run_paused', 'submitter withdrew the request')).toBe(true); + // No suspended run under the id ⇒ `false`: idempotent success, not a throw. + expect(await service.cancelRun!('run_gone')).toBe(false); + + const restored = await service.restoreConsumedSuspension!('run_stranded', { + requestedBy: 'ops@example.com', + reason: 'notify node fixed; the approval will be re-issued', + }); + expect(restored.restored).toBe(true); + expect(restored.runId).toBe('run_stranded'); + // `refusal` is absent exactly when `restored` is `true`. + expect(restored.refusal).toBeUndefined(); + expect(restored.reason).toContain('run_stranded'); + + const refused = await service.restoreConsumedSuspension!('run_gone'); + expect(refused.restored).toBe(false); + expect(refused.refusal).toBe('RUN_NOT_FOUND'); + // `reason` is present both ways — the operator is told what was observed. + expect(refused.reason).toBe("No run 'run_gone' is known"); + + // The optional parameters ARE the reason the signatures follow the + // engine: a door calling through the contract can say who asked and why. + expect(seen).toEqual([ + { verb: 'cancelRun', runId: 'run_paused', reason: 'submitter withdrew the request' }, + { verb: 'cancelRun', runId: 'run_gone', reason: undefined }, + { + verb: 'restoreConsumedSuspension', + runId: 'run_stranded', + requestedBy: 'ops@example.com', + reason: 'notify node fixed; the approval will be re-issued', + }, + { verb: 'restoreConsumedSuspension', runId: 'run_gone' }, + ]); + }); + + it('refuse a restore result that omits the always-present `reason` (compile-time, under check:test-typecheck)', () => { + const service: IAutomationService = { + execute: async () => ({ success: true }), + listFlows: async () => [], + // @ts-expect-error — `reason` is required both ways: an operator whose repair was refused must be told what was observed. + restoreConsumedSuspension: async (runId) => ({ restored: false, runId, refusal: 'RUN_NOT_FOUND' }), + }; + + expect(service.restoreConsumedSuspension).toBeDefined(); + }); + + it('the docblocks carry the persistent-face statement, the ruled permission posture, and the no-door-when-absent rule', () => { + const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8'); + const docAbove = (declaration: string): string => { + const at = source.indexOf(declaration); + expect(at).toBeGreaterThan(-1); + // The doc block immediately above the declaration — from its last `/**`. + return source.slice(source.lastIndexOf('/**', at), at); + }; + const cancel = docAbove('cancelRun?(runId: string, reason?: string): Promise;'); + const restore = docAbove('restoreConsumedSuspension?('); + + for (const doc of [cancel, restore]) { + // The ruling's persistent-face sentence, in its own words. + expect(doc).toContain('listing and acting go through `sys_automation_run`'); + expect(doc).toMatch(/never engine memory/); + // The ruled permission posture, so the door does not invent one. + expect(doc).toContain('`platform_admin`'); + expect(doc).toMatch(/no new permission type, no[\s*]+per-run ownership/); + // Optional ⇒ absent means no door, and the door refuses fail-closed. + expect(doc).toMatch(/NO[\s*]+operator door/); + expect(doc).toMatch(/refuse[\s*]+fail-closed/); + } + // Cancel: `false` is idempotent success — and an unreadable store lands there too. + expect(cancel).toMatch(/idempotent/); + expect(cancel).toMatch(/could[\s*]+not[\s*]+READ/); + // Restore: the trace records who asked and why (the reason the signature + // follows the engine), and the two things an operator must know. + expect(restore).toContain('not recorded'); + expect(restore).toMatch(/NOT[\s*]+replayed/); + expect(restore).toMatch(/NOT[\s*]+undone/); + // Restore: the narrower-result decision is stated where it is read. + expect(restore).toContain('SuspensionRestoreResult'); + expect(restore).toMatch(/route \(i\)/); + }); + }); }); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index f593d577b3..aabe2113d2 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -681,4 +681,159 @@ export interface IAutomationService { * non-screen node still resolves to `null`. */ getSuspendedScreen?(runId: string): Promise; + + /** + * **Operator verb — end a suspended run** (ADR-0044's run-cancel + * primitive; the #13953 ruling A, contract half, #16495). + * + * Consumes the run's continuation and records a terminal `cancelled` run + * log, so it stops surfacing as resumable; `reason` lands on that terminal + * record's `error`. Answers `true` when it cancelled a suspended run, and + * `false` when no suspended run exists under the id — it is already + * terminal, or unknown — which callers treat as idempotent success. `true` + * is NOT exclusive to this call — this contract carries no cancel-side + * exclusivity guarantee, so two cancels of one run overlapping in time can + * each answer `true` (and each record the terminal log): a caller may not + * read `true` as sole authorship, nor use it as an idempotency token for a + * once-only side effect. ⚠️ A durable store the implementation could not + * READ also answers `false`: the two are indistinguishable to the caller + * and the run may still be parked, which is why an implementation reports + * that path at `error` — nothing above it can tell the difference. + * + * **The persistent face (the #13953 ruling, maintainer 2026-09-05):** + * "listing and acting go through `sys_automation_run` (the persistent + * face), never engine memory". The run this verb acts on is the one the + * durable `sys_automation_run` row knows under `runId`: an implementation + * reads the store-authoritative suspended row, not a per-process snapshot, + * so the verb answers the same way on every replica — and a door lists + * the candidates it offers an operator from `sys_automation_run` rows, + * never from one process's in-memory journal (a confident zero from the + * wrong process is exactly the failure #13909 exists to name). + * + * **Who may call it (same ruling):** a platform-operator verb "gated on + * the existing `platform_admin` position (no new permission type, no + * per-run ownership — a run belongs to the environment, not a user)". + * That gate is the door's (the REST route of #13953's services half), not + * this method's: an in-process owner — plugin-approvals' revise-window + * recall (ADR-0044) — cancels on behalf of a decision it already + * authorized and recorded. + * + * **Optional, deliberately.** Cancelling a suspension is a capability of + * the flow-engine implementation, exactly like {@link resume} and + * {@link listSuspendedRuns}; a script-runner slot never suspends and has + * nothing to cancel. A service that does not declare this member has NO + * operator door for it: a door MUST probe for presence and refuse + * fail-closed when it is absent — never answer success for a verb it + * could not dispatch (#13909's posture: no door that returns success to + * hide the condition). + * + * @param runId - The suspended run's id (its `sys_automation_run` row) + * @param reason - Why, in the operator's words; recorded on the terminal + * `cancelled` log as its `error`. The signature follows the engine, not + * the ruling's `cancelRun(runId)` shorthand: a door calling through + * this contract must be able to say why. + * @returns `true` when this call cancelled a suspended run; `false` when + * none exists under the id (idempotent) — or when the store could not + * be read, see above + */ + cancelRun?(runId: string, reason?: string): Promise; + + /** + * **Operator verb — the exit from a run a resume left terminally + * unresumable** (#13909; the #13953 ruling A, contract half, #16495). + * Puts back the suspension a failed resume consumed, so the run is + * resumable again. + * + * The state it exits from is `AutomationResult.status: 'stranded'` + * (#13937 shape 4): a resume CONSUMED the suspension, a downstream node + * threw, and the run is recorded as failed — {@link resume} answers + * `RUN_NOT_FOUND` on it, {@link cancelRun} is a no-op on it, and nothing + * moves it automatically. This is the explicit operator verb that + * `'stranded'` names. There is no retry, no sweeper, no self-healing arm: + * an operator (or an admin door standing in for one) asks for THIS run, + * by id, on purpose. + * + * What it does, exactly, and what it does not: + * - the pause goes back VERBATIM — its own variables, step log, node, + * screen — and nothing else; ⚠️ the resume signal is NOT replayed (the + * continuation must be re-issued through {@link resume}, through the + * same authority gate as any other), and ⚠️ the failed attempt is NOT + * undone — this re-arms a pause, it does not roll a transaction back; + * - it does not resume: it re-arms the pause and stops; + * - it is idempotent by construction: a suspension is keyed by run id, + * so a second restore finds one live and is refused (`RUN_SUSPENDED`), + * across processes and across a restart; + * - it never throws: every outcome, an unreadable store included, is the + * result below naming what was observed. + * + * **The persistent face (the #13953 ruling, maintainer 2026-09-05):** + * "listing and acting go through `sys_automation_run` (the persistent + * face), never engine memory". The consumed suspension this verb puts + * back is the snapshot the run's terminal `sys_automation_run` row carries + * (#13937 reads that row and a process's hot copy as two witnesses of one + * strand: the row is the record every replica can read, and a hot copy is + * honoured only for the same pause the row describes, or where there is + * no row to ask). And the list of runs an operator may repair is a QUERY + * over `sys_automation_run` terminal rows carrying a restorable snapshot, + * never a read of engine memory — a lister backed by one process's + * journal answers ZERO in every process that did not itself strand the + * run, and a confident zero is the failure this whole class is about. + * ⛔ This contract declares no lister; #13953's services half owns the + * door. + * + * **Who may call it (same ruling):** a platform-operator verb "gated on + * the existing `platform_admin` position (no new permission type, no + * per-run ownership — a run belongs to the environment, not a user)". + * That gate is the door's, not this method's. Re-arming a run the + * platform recorded as terminally failed is a real decision, which is why + * "who asked, and why" is part of the signature rather than the ruling's + * `restoreConsumedSuspension(runId)` shorthand: the implementation's + * trace records both, and writes `not recorded` when `requestedBy` is + * absent — itself something an operator can find later. + * + * **Optional, deliberately** — for the reason {@link cancelRun} is: a + * service that does not declare this member has NO operator door for it, + * and a door MUST probe for presence and refuse fail-closed when it is + * absent, never answer a restore it could not dispatch. + * + * **The result is deliberately NARROWER than the implementation's.** The + * engine answers its own wider `SuspensionRestoreResult` — a closed + * eight-member refusal vocabulary plus the restored run's flow, node and + * consumption time. That type lives with the engine and is not moved + * here (#16495, route (i)): `refusal` is typed as the string the + * implementation answers, not as an enumeration this contract would have + * to keep in step, and the wider type satisfies this one under + * `implements`. What a door needs is here — `restored`, the `runId` + * echoed, the refusal code and its one-sentence `reason`. A second + * consumer that needs the vocabulary itself is a spec card, never a + * widening at a call site. + * + * @param runId - The run to re-arm (its `sys_automation_run` row) + * @param options.requestedBy - Who asked; logged, `not recorded` when + * absent + * @param options.reason - Why; logged the same way + * @returns `restored: true` only when a suspension was actually put back + * by THIS call, and then `refusal` is absent; otherwise + * `restored: false` with `refusal` naming the code the implementation + * observed. `reason` is always present, both ways — one sentence naming + * what was observed, so an operator whose repair is refused can tell + * "this run is fine" from "this run is beyond this verb" from "I could + * not read the store". + */ + restoreConsumedSuspension?( + runId: string, + options?: { requestedBy?: string; reason?: string }, + ): Promise<{ + /** `true` only when a suspension was actually put back by THIS call. */ + restored: boolean; + /** The run id, echoed. */ + runId: string; + /** + * The refusal code the implementation observed (its own closed + * vocabulary — see above); absent exactly when `restored` is `true`. + */ + refusal?: string; + /** One sentence naming what was observed — always present, both ways. */ + reason: string; + }>; }