Skip to content

Commit abb140c

Browse files
huangyiireneclaude
andauthored
feat(spec): declare the operator run-lifecycle verbs cancelRun and restoreConsumedSuspension on IAutomationService (contract half of the #13953 ruling) (#16563)
* feat(spec): declare cancelRun and restoreConsumedSuspension on IAutomationService The contract half of #13953's ruling A: both operator run-lifecycle verbs are declared as optional members, typed as the engine implements them (cancelRun(runId, reason?) -> boolean; restoreConsumedSuspension(runId, options?) -> a narrower structural result), with the ruling's persistent-face statement in their docblocks and a pin test in automation-service.test.ts. @objectstack/spec minor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno * docs(spec): cancelRun's `true` is not exclusive to the call that read it The contract docblock said `true` is answered "only when a suspension was consumed by THIS call". The only implementation gives no such guarantee: the engine has no `cancelling` guard (only `resuming` / `restoring`), and `cancelRun` consumes through `forgetSuspendedRun(run, 'cancelled')`, whose store delete is by id and unconditional — nothing like `resume`'s advance claim. Two cancels of one run overlapping in time therefore both answer `true` and both record the terminal `cancelled` log. Say what the engine does: `true` means this call cancelled a suspended run (the `@returns` line, unchanged), NOT that it was the only one — a caller may not read sole authorship out of it, nor use it as an idempotency token for a once-only side effect. Prose only; the `@returns` line, the pin, the changeset and the result shape are untouched. Whether the engine should grow a cancel-side compare-and-set is the services half's call, not this contract's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab47712 commit abb140c

3 files changed

Lines changed: 340 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare the two operator run-lifecycle verbs on `IAutomationService` — `cancelRun` and `restoreConsumedSuspension` (#16495, the contract half of #13953)
6+
7+
`IAutomationService` (`contracts/automation-service.ts`) gains two OPTIONAL
8+
members, typed as the engine already implements them rather than as the
9+
ruling's `verb(runId)` shorthand, so a door calling through the contract can
10+
say who asked and why:
11+
12+
- `cancelRun?(runId: string, reason?: string): Promise<boolean>` — end a
13+
suspended run (ADR-0044's run-cancel primitive): `true` only when this call
14+
consumed a suspension, `false` when none exists under the id (idempotent
15+
success — and the answer an unreadable store lands on too, which the
16+
implementation reports at `error`).
17+
- `restoreConsumedSuspension?(runId: string, options?: { requestedBy?: string; reason?: string })`
18+
answering `{ restored: boolean; runId: string; refusal?: string; reason: string }`
19+
— the operator exit from a run a resume left terminally unresumable
20+
(`AutomationResult.status: 'stranded'`, #13909 / #13937): puts the consumed
21+
suspension back verbatim, replays no signal, undoes nothing, never resumes,
22+
never throws.
23+
24+
Both docblocks carry the #13953 ruling's persistent-face statement (maintainer
25+
2026-09-05, decision batch #42): "listing and acting go through
26+
`sys_automation_run` (the persistent face), never engine memory" — and its
27+
permission posture: platform-operator verbs gated on the existing
28+
`platform_admin` position, no new permission type, no per-run ownership.
29+
30+
Additive. Both members are optional, so every existing implementation —
31+
including the `{ execute, listFlows }` minimum the contract's own test pins —
32+
still conforms, and the one non-test implementor (`AutomationEngine` in
33+
`@objectstack/service-automation`) already satisfies both under `implements`.
34+
The result of `restoreConsumedSuspension` is a deliberately NARROWER
35+
structural shape than the engine's `SuspensionRestoreResult`: the engine's
36+
eight-member refusal vocabulary stays with the engine, so `refusal` is typed
37+
`string` on the contract (route (i); a second consumer that needs the
38+
vocabulary is a spec card). No REST route, CLI command, lister or engine
39+
behaviour moves in this change — #13953's services half owns the doors. A
40+
service that does not declare a verb has no operator door for it, and a door
41+
must probe for presence and refuse fail-closed when it is absent.

‎packages/spec/src/contracts/automation-service.test.ts‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,37 @@
1+
import { readFileSync } from 'node:fs';
2+
import { fileURLToPath } from 'node:url';
3+
14
import { describe, it, expect } from 'vitest';
25
import type { IAutomationService, AutomationResult } from './automation-service';
36
import type { FlowParsed } from '../automation/flow.zod';
47
import { FlowSchema } from '../automation/flow.zod';
58
import type { ExecutionLog } from '../automation/execution.zod';
69
import type { ConnectorDescriptor } from '../integration/connector-descriptor';
710

11+
/**
12+
* [#16495] Type-level identities for the two operator verbs (the #14384 pin's
13+
* form): a change to either signature — a dropped optional parameter, a
14+
* widened or narrowed result — turns an exported alias red under
15+
* `check:test-typecheck`, which compiles this file. Exported deliberately: an
16+
* unread alias inside a test body is TS6196, and a pin no program compiles is
17+
* no pin at all.
18+
*/
19+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
20+
type Assert<T extends true> = T;
21+
type CancelRun = NonNullable<IAutomationService['cancelRun']>;
22+
type RestoreConsumedSuspension = NonNullable<IAutomationService['restoreConsumedSuspension']>;
23+
/** `cancelRun(runId, reason?)` — the engine's shape, not the ruling's `cancelRun(runId)`. */
24+
export type CancelRunTakesRunIdAndReason = Assert<Eq<Parameters<CancelRun>, [runId: string, reason?: string]>>;
25+
export type CancelRunAnswersBoolean = Assert<Eq<ReturnType<CancelRun>, Promise<boolean>>>;
26+
/** `restoreConsumedSuspension(runId, options?)` — who asked and why travel through the contract. */
27+
export type RestoreTakesRunIdAndOptions = Assert<
28+
Eq<Parameters<RestoreConsumedSuspension>, [runId: string, options?: { requestedBy?: string; reason?: string }]>
29+
>;
30+
/** The narrower structural result (route (i)): restored, the id echoed, the refusal code, the one-sentence reason. */
31+
export type RestoreAnswersTheNarrowResult = Assert<
32+
Eq<Awaited<ReturnType<RestoreConsumedSuspension>>, { restored: boolean; runId: string; refusal?: string; reason: string }>
33+
>;
34+
835
describe('Automation Service Contract', () => {
936
it('should allow a minimal IAutomationService implementation with required methods', () => {
1037
const service: IAutomationService = {
@@ -249,4 +276,121 @@ describe('Automation Service Contract', () => {
249276

250277
expect(service.getConnectorDescriptors).toBeUndefined();
251278
});
279+
280+
// [#16495] The two operator run-lifecycle verbs — the contract half of
281+
// #13953's ruling A (maintainer 2026-09-05, decision batch #42). The same
282+
// shape of pin #14384 added for `'stranded'`: the members are declared,
283+
// OPTIONAL, typed as the engine implements them (not as the ruling's
284+
// `verb(runId)` shorthand), and their docblocks carry the ruling's
285+
// persistent-face statement. The type-level identities are the exported
286+
// aliases above the suite; the compile of the literals below is the rest.
287+
describe('[#16495] cancelRun / restoreConsumedSuspension — the operator verbs, declared', () => {
288+
it('are optional: the minimal implementation still conforms and has no operator door', () => {
289+
const service: IAutomationService = {
290+
execute: async () => ({ success: true }),
291+
listFlows: async () => [],
292+
};
293+
294+
expect(service.cancelRun).toBeUndefined();
295+
expect(service.restoreConsumedSuspension).toBeUndefined();
296+
});
297+
298+
it('carry the engine signatures through the contract — who asked, and why, reach the implementation', async () => {
299+
const seen: Array<Record<string, unknown>> = [];
300+
const service: IAutomationService = {
301+
execute: async () => ({ success: true }),
302+
listFlows: async () => [],
303+
cancelRun: async (runId: string, reason?: string): Promise<boolean> => {
304+
seen.push({ verb: 'cancelRun', runId, reason });
305+
return runId === 'run_paused';
306+
},
307+
restoreConsumedSuspension: async (runId, options) => {
308+
seen.push({ verb: 'restoreConsumedSuspension', runId, ...options });
309+
return runId === 'run_stranded'
310+
? { restored: true, runId, reason: `Run '${runId}' is suspended again at node 'approve'` }
311+
: { restored: false, runId, refusal: 'RUN_NOT_FOUND', reason: `No run '${runId}' is known` };
312+
},
313+
};
314+
315+
expect(await service.cancelRun!('run_paused', 'submitter withdrew the request')).toBe(true);
316+
// No suspended run under the id ⇒ `false`: idempotent success, not a throw.
317+
expect(await service.cancelRun!('run_gone')).toBe(false);
318+
319+
const restored = await service.restoreConsumedSuspension!('run_stranded', {
320+
requestedBy: 'ops@example.com',
321+
reason: 'notify node fixed; the approval will be re-issued',
322+
});
323+
expect(restored.restored).toBe(true);
324+
expect(restored.runId).toBe('run_stranded');
325+
// `refusal` is absent exactly when `restored` is `true`.
326+
expect(restored.refusal).toBeUndefined();
327+
expect(restored.reason).toContain('run_stranded');
328+
329+
const refused = await service.restoreConsumedSuspension!('run_gone');
330+
expect(refused.restored).toBe(false);
331+
expect(refused.refusal).toBe('RUN_NOT_FOUND');
332+
// `reason` is present both ways — the operator is told what was observed.
333+
expect(refused.reason).toBe("No run 'run_gone' is known");
334+
335+
// The optional parameters ARE the reason the signatures follow the
336+
// engine: a door calling through the contract can say who asked and why.
337+
expect(seen).toEqual([
338+
{ verb: 'cancelRun', runId: 'run_paused', reason: 'submitter withdrew the request' },
339+
{ verb: 'cancelRun', runId: 'run_gone', reason: undefined },
340+
{
341+
verb: 'restoreConsumedSuspension',
342+
runId: 'run_stranded',
343+
requestedBy: 'ops@example.com',
344+
reason: 'notify node fixed; the approval will be re-issued',
345+
},
346+
{ verb: 'restoreConsumedSuspension', runId: 'run_gone' },
347+
]);
348+
});
349+
350+
it('refuse a restore result that omits the always-present `reason` (compile-time, under check:test-typecheck)', () => {
351+
const service: IAutomationService = {
352+
execute: async () => ({ success: true }),
353+
listFlows: async () => [],
354+
// @ts-expect-error — `reason` is required both ways: an operator whose repair was refused must be told what was observed.
355+
restoreConsumedSuspension: async (runId) => ({ restored: false, runId, refusal: 'RUN_NOT_FOUND' }),
356+
};
357+
358+
expect(service.restoreConsumedSuspension).toBeDefined();
359+
});
360+
361+
it('the docblocks carry the persistent-face statement, the ruled permission posture, and the no-door-when-absent rule', () => {
362+
const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8');
363+
const docAbove = (declaration: string): string => {
364+
const at = source.indexOf(declaration);
365+
expect(at).toBeGreaterThan(-1);
366+
// The doc block immediately above the declaration — from its last `/**`.
367+
return source.slice(source.lastIndexOf('/**', at), at);
368+
};
369+
const cancel = docAbove('cancelRun?(runId: string, reason?: string): Promise<boolean>;');
370+
const restore = docAbove('restoreConsumedSuspension?(');
371+
372+
for (const doc of [cancel, restore]) {
373+
// The ruling's persistent-face sentence, in its own words.
374+
expect(doc).toContain('listing and acting go through `sys_automation_run`');
375+
expect(doc).toMatch(/never engine memory/);
376+
// The ruled permission posture, so the door does not invent one.
377+
expect(doc).toContain('`platform_admin`');
378+
expect(doc).toMatch(/no new permission type, no[\s*]+per-run ownership/);
379+
// Optional ⇒ absent means no door, and the door refuses fail-closed.
380+
expect(doc).toMatch(/NO[\s*]+operator door/);
381+
expect(doc).toMatch(/refuse[\s*]+fail-closed/);
382+
}
383+
// Cancel: `false` is idempotent success — and an unreadable store lands there too.
384+
expect(cancel).toMatch(/idempotent/);
385+
expect(cancel).toMatch(/could[\s*]+not[\s*]+READ/);
386+
// Restore: the trace records who asked and why (the reason the signature
387+
// follows the engine), and the two things an operator must know.
388+
expect(restore).toContain('not recorded');
389+
expect(restore).toMatch(/NOT[\s*]+replayed/);
390+
expect(restore).toMatch(/NOT[\s*]+undone/);
391+
// Restore: the narrower-result decision is stated where it is read.
392+
expect(restore).toContain('SuspensionRestoreResult');
393+
expect(restore).toMatch(/route \(i\)/);
394+
});
395+
});
252396
});

0 commit comments

Comments
 (0)