Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/automation-service-operator-verbs-contract.md
Original file line number Diff line number Diff line change
@@ -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<boolean>` — 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.
144 changes: 144 additions & 0 deletions packages/spec/src/contracts/automation-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
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';
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<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Assert<T extends true> = T;
type CancelRun = NonNullable<IAutomationService['cancelRun']>;
type RestoreConsumedSuspension = NonNullable<IAutomationService['restoreConsumedSuspension']>;
/** `cancelRun(runId, reason?)` — the engine's shape, not the ruling's `cancelRun(runId)`. */
export type CancelRunTakesRunIdAndReason = Assert<Eq<Parameters<CancelRun>, [runId: string, reason?: string]>>;
export type CancelRunAnswersBoolean = Assert<Eq<ReturnType<CancelRun>, Promise<boolean>>>;
/** `restoreConsumedSuspension(runId, options?)` — who asked and why travel through the contract. */
export type RestoreTakesRunIdAndOptions = Assert<
Eq<Parameters<RestoreConsumedSuspension>, [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<Awaited<ReturnType<RestoreConsumedSuspension>>, { 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 = {
Expand Down Expand Up @@ -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<Record<string, unknown>> = [];
const service: IAutomationService = {
execute: async () => ({ success: true }),
listFlows: async () => [],
cancelRun: async (runId: string, reason?: string): Promise<boolean> => {
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<boolean>;');
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\)/);
});
});
});
Loading
Loading