Skip to content

Commit 7efc496

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14510-runtime-image-sql-drivers
2 parents 57b482b + bd4aa4e commit 7efc496

6 files changed

Lines changed: 196 additions & 9 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): name the terminally-failed-but-repairable run on `AutomationResult.status``'stranded'` (#14384, contract half of #13937)
6+
7+
`AutomationResult.status` (`contracts/automation-service.ts`) gains a fourth
8+
member beside `'completed' | 'paused' | 'failed'`: **`'stranded'`** — the run
9+
whose resume CONSUMED its suspension and then had a downstream node throw, so
10+
the run is recorded as failed and can be re-armed only by an explicit operator
11+
verb (#13909's condition; the #13937 shape-4 ruling, maintainer 2026-09-01
12+
「命名同批定」). The wire mirror `TriggerFlowResponseSchema.data.status`
13+
(`api/automation-api.zod.ts`) carries the same four, and a pin test binds the
14+
two at the type level and the value level.
15+
16+
Additive: no existing literal changes meaning, `'failed'` still says "the run
17+
ran and was rejected", and no engine, route or client behaviour moves in this
18+
change — the engine begins stamping `'stranded'` when #13937's services half
19+
(the operator re-arm verb) lands. A consumer that switches exhaustively over
20+
`status` needs a `'stranded'` arm; the measured count of such switches in this
21+
repo is zero. plugin-approvals' report-only `StrandedRunState`
22+
(`'missing' | 'failed'`) is deliberately not promoted (same ruling).

content/docs/references/api/automation-api.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ const result = AutomationApiErrorCode.parse(data);
607607
| **error** | `string` | optional | Error message if execution failed |
608608
| **durationMs** | `number` | optional | Execution duration in milliseconds |
609609
| **code** | `Enum<'PERMISSION_DENIED' \| 'INVALID_SIGNAL' \| 'RUN_NOT_FOUND' \| 'STORE_UNAVAILABLE' \| …>` | optional | Machine-readable failure classification, set alongside `error` when the caller must distinguish WHY it failed. A closed union - the members and their transport mappings are documented on the contract (`AutomationResult.code`, contracts/automation-service.ts). |
610-
| **status** | `Enum<'completed' \| 'paused' \| 'failed'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed` means the run reached a terminal state. |
610+
| **status** | `Enum<'completed' \| 'paused' \| 'failed' \| 'stranded'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed`/`stranded` means the run reached a terminal state. `stranded` is the terminally-failed-but-repairable run: a resume consumed the suspension and a downstream node threw, so the run is recorded as failed and can be re-armed only by an explicit operator verb - never by the resume route, which answers RUN_NOT_FOUND for it. |
611611
| **runId** | `string` | optional | Run id - set when `status` is `paused`, so callers can resume it |
612612
| **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. |
613613
| **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |

docker/Dockerfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
#
33
# A generic, app-agnostic production runtime: Node + @objectstack/cli + the
44
# `pg` / `mysql2` SQL drivers + `os start`. It contains NO app — bring your
5-
# compiled artifact
6-
# (dist/objectstack.json, built by `os build` in CI):
5+
# compiled artifact (dist/objectstack.json, built by `os build` in CI):
76
#
87
# FROM ghcr.io/objectstack-ai/objectstack:<version>
98
# COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json

packages/spec/src/api/automation-api.zod.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,10 +337,17 @@ export const TriggerFlowResponseSchema = lazySchema(() => BaseResponseSchema.ext
337337
+ 'their transport mappings are documented on the contract '
338338
+ '(`AutomationResult.code`, contracts/automation-service.ts).',
339339
),
340-
status: z.enum(['completed', 'paused', 'failed']).optional().describe(
340+
// `stranded` is the contract half of the #13937 shape-4 ruling (#14384);
341+
// the condition is #13909's. Mirrors `AutomationResult.status` member for
342+
// member — the pin is `contracts/automation-result-status.pin.test.ts`.
343+
status: z.enum(['completed', 'paused', 'failed', 'stranded']).optional().describe(
341344
'Lifecycle status. `paused` means the run suspended at a node and can be '
342-
+ 'continued with the resume route. Absent or `completed`/`failed` means '
343-
+ 'the run reached a terminal state.',
345+
+ 'continued with the resume route. Absent or `completed`/`failed`/`stranded` '
346+
+ 'means the run reached a terminal state. `stranded` is the '
347+
+ 'terminally-failed-but-repairable run: a resume consumed the suspension '
348+
+ 'and a downstream node threw, so the run is recorded as failed and can be '
349+
+ 're-armed only by an explicit operator verb - never by the resume route, '
350+
+ 'which answers RUN_NOT_FOUND for it.',
344351
),
345352
runId: z.string().optional()
346353
.describe('Run id - set when `status` is `paused`, so callers can resume it'),
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#14384] `AutomationResult.status` is exactly
5+
* `'completed' | 'paused' | 'failed' | 'stranded'`, and the wire mirror
6+
* (`TriggerFlowResponseSchema.data.status`, `api/automation-api.zod.ts`) is
7+
* the same four — contract half of the #13937 shape-4 ruling (maintainer
8+
* 2026-09-01), which names the terminally-failed-but-repairable run on this
9+
* union: a resume consumed the suspension, a downstream node threw, the run is
10+
* recorded as failed and can be re-armed only by an explicit operator verb
11+
* (#13909's condition). The literal is `'stranded'`.
12+
*
13+
* Three things are pinned, because each drifts on its own:
14+
*
15+
* 1. **The union's membership, at the type level.** `status` is a TypeScript
16+
* interface member, not a Zod enum, so the only thing that can assert it is
17+
* a compile-time identity (`Eq`, the `automation-api.zod.test.ts` form —
18+
* a widening or a narrowing on either side turns the exported alias red
19+
* under `check:test-typecheck`, which reads this file).
20+
* 2. **Wire ↔ contract parity, at both levels.** The Zod enum's `.options`
21+
* are read at runtime and compared to the same list, and its inferred
22+
* type is bound to the contract's. #13078 bound the whole `data` object;
23+
* this pins the ONE member the ruling added so a future member added to
24+
* one side alone fails here by name.
25+
* 3. **The JSDoc names the condition.** The card's acceptance is a JSDoc line
26+
* naming the condition, and prose is unassertable except by reading it:
27+
* the contract source is read and the doc block above the union is
28+
* required to say what `'stranded'` is.
29+
*
30+
* ⛔ Not pinned, deliberately: any relation to `ExecutionStatus`
31+
* (`automation/execution.zod.ts`, the persisted run-row vocabulary) or to
32+
* plugin-approvals' `StrandedRunState` — the ruling keeps the latter a
33+
* plugin-local report label, and whether the run ROW ever carries this word is
34+
* the services half's to measure (#13937).
35+
*/
36+
37+
import { readFileSync } from 'node:fs';
38+
import { fileURLToPath } from 'node:url';
39+
40+
import { describe, it, expect } from 'vitest';
41+
42+
import { TriggerFlowResponseSchema } from '../api/automation-api.zod';
43+
import type { TriggerFlowResponse } from '../api/automation-api.zod';
44+
45+
import type { AutomationResult } from './automation-service';
46+
47+
/** Type-level identity: true iff A and B are the same type. */
48+
type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
49+
/** Compile error when the argument is not `true`. */
50+
type Assert< T extends true > = T;
51+
52+
type ContractStatus = NonNullable<AutomationResult['status']>;
53+
type WireStatus = NonNullable<TriggerFlowResponse['data']['status']>;
54+
55+
/**
56+
* The closed union, spelled once, in declaration order. `satisfies` proves
57+
* every literal here is a member; the `Eq` below proves there is no member
58+
* that is not here.
59+
*/
60+
export const AUTOMATION_RESULT_STATUSES = [
61+
'completed',
62+
'paused',
63+
'failed',
64+
'stranded',
65+
] as const satisfies readonly ContractStatus[];
66+
67+
/**
68+
* Exported deliberately — an unread alias inside a test body is TS6196, and a
69+
* pin no program compiles is no pin at all (`check:test-typecheck` compiles
70+
* this file under `tsconfig.test.json`).
71+
*/
72+
export type AutomationResultStatusIsExactlyTheFour = Assert< Eq< ContractStatus, (typeof AUTOMATION_RESULT_STATUSES)[number] > >;
73+
/** Wire ↔ contract: the Zod enum's inferred type IS the interface's union. */
74+
export type WireStatusMatchesContract = Assert< Eq< WireStatus, ContractStatus > >;
75+
76+
/** The wire enum, unwrapped from `.optional()` through the `lazySchema` Proxy. */
77+
const wireStatusEnum = TriggerFlowResponseSchema.shape.data.shape.status.unwrap();
78+
79+
describe('[#14384] AutomationResult.status names the stranded run', () => {
80+
it('reads a non-empty membership (anti-vacuity)', () => {
81+
expect(AUTOMATION_RESULT_STATUSES.length).toBe(4);
82+
expect(wireStatusEnum.options.length).toBeGreaterThan(0);
83+
});
84+
85+
it('the wire enum carries exactly the contract union, in the same order', () => {
86+
expect([...wireStatusEnum.options]).toEqual([...AUTOMATION_RESULT_STATUSES]);
87+
});
88+
89+
it("names the terminally-failed-but-repairable run 'stranded' (#13937 shape 4)", () => {
90+
expect(AUTOMATION_RESULT_STATUSES).toContain('stranded');
91+
expect(wireStatusEnum.options).toContain('stranded');
92+
});
93+
94+
it('a stranded terminal envelope parses and is PRESERVED on the wire', () => {
95+
// A strip-mode object drops undeclared keys silently — the #13078 lesson —
96+
// so parse success alone proves nothing; the value must come back out.
97+
const parsed = TriggerFlowResponseSchema.parse({
98+
success: true,
99+
data: {
100+
success: false,
101+
status: 'stranded',
102+
runId: 'run_stranded_001',
103+
error: "node 'notify' threw after the approval was consumed",
104+
},
105+
});
106+
expect(parsed.data.status).toBe('stranded');
107+
expect(parsed.data.success).toBe(false);
108+
expect(parsed.data.runId).toBe('run_stranded_001');
109+
});
110+
111+
it('refuses a status outside the four, at `data.status`, as an enum violation', () => {
112+
const result = TriggerFlowResponseSchema.safeParse({
113+
success: true,
114+
data: { success: false, status: 'strand' },
115+
});
116+
expect(result.success).toBe(false);
117+
if (result.success) return;
118+
const issue = result.error.issues.find((i) => i.path.join('.') === 'data.status');
119+
expect(issue).toBeDefined();
120+
expect(issue?.code).toBe('invalid_value');
121+
});
122+
123+
it('the contract JSDoc names the condition beside the literal', () => {
124+
const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8');
125+
const declaration = "status?: 'completed' | 'paused' | 'failed' | 'stranded';";
126+
const at = source.indexOf(declaration);
127+
expect(at).toBeGreaterThan(-1);
128+
// The doc block immediately above the declaration — from its last `/**`.
129+
const docStart = source.lastIndexOf('/**', at);
130+
const doc = source.slice(docStart, at);
131+
expect(doc).toContain("`'stranded'`");
132+
// The condition, in the ruling's own terms: a consumed suspension, a
133+
// downstream throw, re-armable only by an explicit operator verb.
134+
expect(doc).toMatch(/consumed the\s+\*?\s*suspension/i);
135+
expect(doc).toMatch(/downstream node threw/i);
136+
expect(doc).toMatch(/explicit operator verb/i);
137+
// And the ruling's boundary: the plugin-local label is not promoted.
138+
expect(doc).toContain('StrandedRunState');
139+
});
140+
});

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,28 @@ export interface AutomationResult {
291291
* Lifecycle status. `'paused'` means the run suspended at a node (e.g.
292292
* an Approval node awaiting a human decision, ADR-0019) and can be
293293
* continued later with {@link IAutomationService.resume}. Absent or
294-
* `'completed'`/`'failed'` ⇒ the run reached a terminal state.
295-
*/
296-
status?: 'completed' | 'paused' | 'failed';
294+
* `'completed'`/`'failed'`/`'stranded'` ⇒ the run reached a terminal
295+
* state.
296+
*
297+
* `'stranded'` names the terminally-failed-but-repairable run (#13909;
298+
* the #13937 shape-4 ruling, maintainer 2026-09-01): a resume CONSUMED the
299+
* suspension, a downstream node threw, and the run is recorded as failed —
300+
* terminal exactly like `'failed'`, except that the pause a durable
301+
* decision (an approval, a screen submission) was waiting on is gone with
302+
* it, so the run can be re-armed only by an explicit operator verb — never
303+
* by {@link IAutomationService.resume} (which answers `'RUN_NOT_FOUND'`:
304+
* there is no suspension left) and never automatically. Distinct from
305+
* `'failed'` on purpose: that one says the run ran and was rejected; this
306+
* one says a recorded continuation stopped mid-flight and an operator has
307+
* something to repair. This member is the ruling's contract half; the
308+
* engine begins stamping it when #13937's services half (the re-arm verb
309+
* and the catch-arm stamp in `resumeInternal`) lands. plugin-approvals'
310+
* `StrandedRunState` (`'missing' | 'failed'`) is a report-only label over
311+
* a request's run and is deliberately NOT promoted to this status (same
312+
* ruling): it classifies WHY a request's run is unrecoverable, this names
313+
* the run's own lifecycle verdict.
314+
*/
315+
status?: 'completed' | 'paused' | 'failed' | 'stranded';
297316
/** Run id — set when `status` is `'paused'`, so callers can resume it. */
298317
runId?: string;
299318
/**

0 commit comments

Comments
 (0)