Skip to content

Commit d12e650

Browse files
feat(spec): ExecutionStatus gains missed, a scheduled due time that passed while no kernel was ticking (#25363)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6zwDuV2XvoDkqC7bXgqnpc)_ Fixes #25325 Clause-②: yes (widening) Metadata-protocol: none ## What changes - `packages/spec/src/automation/execution.zod.ts`: `ExecutionStatus` gains `'missed'`, appended **last** (the file's own rule, as for `refused`). The TSDoc says what it means: a scheduled flow's due time that passed while no kernel was ticking, recorded once by the next kernel that found it. It is terminal and is never started, resumed or replayed. It is distinct from `cancelled` (nobody cancelled it) and from `failed` (nothing ran). No new field. - `execution.test.ts`: the order pin moves. The first eight members are unchanged, the ninth is `refused`, the tenth is `missed`, and the length is 10. - `content/docs/references/automation/execution.mdx` and `api/automation-api.mdx`: regenerated with `gen:docs` after `check:generated` named exactly that artifact as stale. Nothing else regenerated. - One changeset, `.changeset/25325-execution-status-missed.md`, at `patch` for `@objectstack/spec` and `@objectstack/plugin-approvals` (`Metadata-protocol: none` takes `patch`). ## Declared services hunk (`domain:services`) `packages/plugins/plugin-approvals/src/approval-service.ts`: `RUN_STATUS_LIVENESS` (declared `as const satisfies` a Record keyed by `ExecutionStatus` with `RunLiveness` values) gains `missed: 'terminal'`. The services seat agreed to this line in advance in hand-off addendum `6354069893` on #25235. Without it the new member does not compile. The services PR cannot carry the line either, because `satisfies` refuses the extra key until the member exists. ## TSDoc claims checked against the schema at this head - `ExecutionLogSchema` has `startedAt` (required), `completedAt` and `durationMs` (optional), and `refusalMessage` (optional). It has **no** top-level `error` field: errors live only on each `steps[]` entry. So the TSDoc says that on the `ExecutionLog`, `durationMs` and `refusalMessage` are absent and `steps` is empty. - The stored run row (`sys_automation_run`) spells the same instants `started_at` / `finished_at`, and it has `duration_ms`, `error` and `refusal_message` columns. The TSDoc names those three as absent on the row. This is a correction to the card's wording, which listed `error` as absent on both surfaces. ## Not touched `AutomationResult.status`, `TriggerFlowResponseSchema.data.status` (`contracts/automation-result-status.pin.test.ts` is unchanged) and `JobExecutionStatus`. `ListRunsRequestSchema.status` and the REST `?status=` filter (`parseEnumParam(..., ExecutionStatus.options)` in `packages/runtime/src/domains/automation.ts`) widen by construction. The `sys_automation_run.status` column options, `TERMINAL_RUN_STATUSES` and the retention `onlyWhen` set widen in the services PR for #25235, together with the producer. #25235 remains open. ## Sweep for totality sites Outside `packages/spec`, every reader of `ExecutionStatus` in `packages/**` is one of three things: a parameter type (`client`, `service-automation` `engine.ts`), an `.options` read (`runtime` `automation.ts`, and its `it.each(ExecutionStatus.options)` test), or the one total map, `RUN_STATUS_LIVENESS`. No other Record keyed by `ExecutionStatus` and no exhaustive switch exists beyond that map. ## Tests At head `a6b929fa3`: - `pnpm --filter @objectstack/spec build`: lock VERDICT command-exit 0. - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/automation/execution.test.ts`: 1 file, 49/49 passed. - `pnpm --filter '@objectstack/plugin-approvals^...' build`, then `pnpm --filter @objectstack/plugin-approvals typecheck && pnpm --filter @objectstack/plugin-approvals test && pnpm --filter @objectstack/spec typecheck`: lock VERDICT command-exit 0, with plugin-approvals at 94 files and 1214/1214 tests passed (the totality test `run-status-liveness.test.ts` included). - Reverse verification. `node scripts/ablation-replace.mjs` deleted the `missed: 'terminal'` line (anchor x1 -> x0) and the plugin-approvals typecheck exited 2 with TS1360 (the map does not satisfy the Record type whose key union now includes `"missed"`). That shows the check read the rebuilt `.d.ts`. The line was then restored with `git checkout HEAD --`: `git diff HEAD` is empty and the HEAD blob hash matches. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 116 families. Each one ran with its exit code captured before any pipe, and `--ran` reconciled the record: `116 derived famil(ies) accounted for — 113 run, 3 NOT-MEASURED`, 0 unrun, `--ran` exit 0. The three NOT MEASURED families all exited 3 (PREREQUISITE NOT MET) because they need built output from packages outside this diff's closure. CI measures them: - `check:skill-examples` needs the client SDK `dist`. - `check:i18n` needs the workspace CLI `dist`. - `check:dual-build-cjs-loads` needs a whole-repo build. `pnpm --filter @objectstack/spec check:generated` passes after the `gen:docs` regeneration (`check:api-surface`, `check:authorable-surface` and `check:docs` are among the 113 that ran green). The full farm is CI's. ## Acceptance notes - The member has no producer until the services PR for #25235 lands. This is the same short producer-less window that `refused` had. - Contract review of record is owed before the queue (`needs:contract-review`, per the claim). --- _Generated by [Claude Code](https://claude.ai/code/session_01B2EeooonSXrANpkpgUSfFZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb63d39 commit d12e650

6 files changed

Lines changed: 48 additions & 6 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-approvals': patch
4+
---
5+
6+
feat: `ExecutionStatus` gains `missed`, the run status for a scheduled flow's due time that passed while no kernel was ticking
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
`ExecutionStatus` gains a tenth member, `missed`, appended last so every reader that indexes `.options` keeps its positions. It records a scheduled flow's due time that passed while no kernel was ticking, written once by the next kernel that finds the gap, one entry per missed due time. It is terminal: the run never started, is never resumed and is never replayed. It is distinct from `cancelled` (nobody cancelled it) and from `failed` (nothing ran).
12+
13+
No field is added. On an `ExecutionLog`, `startedAt` is the due time and `completedAt` the detection time; `durationMs` and `refusalMessage` are absent and `steps` is empty. On the stored run row, `started_at` and `finished_at` carry the same two instants, and `duration_ms`, `error` and `refusal_message` are absent.
14+
15+
`ListRunsRequestSchema.status` and the REST `?status=` filter read the enum, so both accept `missed`. `AutomationResult.status`, `TriggerFlowResponseSchema.data.status` and `JobExecutionStatus` do not change.
16+
17+
`@objectstack/plugin-approvals`: `RUN_STATUS_LIVENESS` maps `missed` to `terminal`, so an approval pending on a missed run is swept like one pending on any other finished run.
18+
19+
This release only declares the value. The engine writes `missed` rows in a later release.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -860,7 +860,7 @@ const result = AutomationApiErrorCode.parse(data);
860860
| Property | Type | Required | Description |
861861
| :--- | :--- | :--- | :--- |
862862
| **name** | `string` | ✅ | Flow machine name (snake_case) |
863-
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | optional | Filter by execution status |
863+
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused' \| 'missed'>` | optional | Filter by execution status |
864864
| **limit** | `integer` | optional (default: `20`) | Maximum number of runs to return |
865865
| **cursor** | `never` | optional | [REMOVED] `cursor` was removed from GET /api/v1/automation/:name/runs in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it was VALIDATED at the boundary and then read by nothing: the option reached the service and the engine never looked at it, no emit site has ever written the response half `nextCursor`, and the only ordering this door has is a required but non-unique `startedAt` timestamp that nothing ever minted a resume point from — so a caller looping "until the cursor runs out" re-read the first and only window forever, with no error. Delete the key. `limit` is the real window and STAYS: it is read end to end (boundary to service to store) and bounded to 1..100, so ask for a wider window instead of a next page. Read the response `hasMore` to learn whether the window was short — it is now COMPUTED from the engine rather than the constant `false` it used to be. |
866866

‎content/docs/references/automation/execution.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ const result = CheckpointSchema.parse(data);
104104
| **id** | `string` | ✅ | Execution instance ID |
105105
| **flowName** | `string` | ✅ | Machine name of the executed flow |
106106
| **flowVersion** | `integer` | optional | Version of the flow that was executed |
107-
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | ✅ | Current execution status |
107+
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused' \| 'missed'>` | ✅ | Current execution status |
108108
| **refusalMessage** | `string` | optional | Rendered `end` node `message` when `status` is `refused` — the per-record text the flow refused with. Absent on every other status. |
109109
| **trigger** | `{ type: string; recordId?: string; object?: string; userId?: string; … }` | ✅ | What triggered this execution |
110110
| **steps** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` | ✅ | Ordered list of executed steps |
@@ -177,6 +177,7 @@ const result = CheckpointSchema.parse(data);
177177
* `timed_out`
178178
* `retrying`
179179
* `refused`
180+
* `missed`
180181

181182

182183
---

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,9 @@ export const RUN_STATUS_LIVENESS = {
529529
// documents it "Terminal, never resumed"; this is that sentence made
530530
// machine-readable for the one reader that has to act on it.
531531
refused: 'terminal',
532+
// A scheduled due time that passed while no kernel was ticking: recorded
533+
// once, never started, never resumed.
534+
missed: 'terminal',
532535
} as const satisfies Record<ExecutionStatus, RunLiveness>;
533536

534537
/**

‎packages/spec/src/automation/execution.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@ import {
1818

1919
describe('ExecutionStatus', () => {
2020
it('should accept all valid statuses', () => {
21-
const valid = ['pending', 'running', 'paused', 'completed', 'failed', 'cancelled', 'timed_out', 'retrying', 'refused'];
21+
const valid = ['pending', 'running', 'paused', 'completed', 'failed', 'cancelled', 'timed_out', 'retrying', 'refused', 'missed'];
2222
valid.forEach((v) => {
2323
expect(() => ExecutionStatus.parse(v)).not.toThrow();
2424
});
2525
});
2626

27-
it('names the refused terminal — appended LAST, so every `.options` index reader keeps its positions', () => {
28-
expect(ExecutionStatus.options).toContain('refused');
29-
expect(ExecutionStatus.options.at(-1)).toBe('refused');
27+
it('names the refused and missed terminals — each appended LAST, so every `.options` index reader keeps its positions', () => {
28+
expect(ExecutionStatus.options).toHaveLength(10);
29+
expect(ExecutionStatus.options[8]).toBe('refused');
30+
expect(ExecutionStatus.options.at(-1)).toBe('missed');
3031
expect(ExecutionStatus.options.slice(0, 8)).toEqual(
3132
['pending', 'running', 'paused', 'completed', 'failed', 'cancelled', 'timed_out', 'retrying'],
3233
);
@@ -35,6 +36,9 @@ describe('ExecutionStatus', () => {
3536
expect(ExecutionStatus.safeParse('refused').success).toBe(true);
3637
expect(ExecutionStatus.safeParse('failed').success).toBe(true);
3738
expect(ExecutionStatus.safeParse('rejected').success).toBe(false);
39+
// `missed` is its own member, not a spelling of `cancelled` or `failed`.
40+
expect(ExecutionStatus.safeParse('missed').success).toBe(true);
41+
expect(ExecutionStatus.safeParse('skipped').success).toBe(false);
3842
});
3943

4044
it('should reject invalid statuses', () => {

‎packages/spec/src/automation/execution.zod.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ export const ExecutionStatus = z.enum([
3636
// refusal text rides beside it on the run row as `refusalMessage`. Appended
3737
// last so every reader that indexes `.options` keeps its positions.
3838
'refused', // Terminal: the flow refused (an `end` node with outcome: 'refused')
39+
/**
40+
* A scheduled flow's due time that passed while no kernel was ticking,
41+
* recorded ONCE by the next kernel that found the gap (one entry per missed
42+
* due time). Terminal: the run was never started, is never resumed, and is
43+
* never replayed — no catch-up run follows it. DISTINCT from `cancelled`
44+
* (nobody cancelled it) and from `failed` (nothing ran, so nothing failed).
45+
*
46+
* What the entry carries, with no field of its own: on the `ExecutionLog`,
47+
* `startedAt` is the due time and `completedAt` the detection time, while
48+
* `durationMs` and `refusalMessage` are absent and `steps` is empty; on the
49+
* stored run row, `started_at` / `finished_at` carry the same two instants
50+
* and `duration_ms`, `error` and `refusal_message` are absent. Appended last,
51+
* like `refused`, so every reader that indexes `.options` keeps its positions.
52+
*/
53+
'missed', // Terminal: a scheduled due time passed while no kernel was ticking; never run
3954
]);
4055
export type ExecutionStatus = z.input<typeof ExecutionStatus>;
4156

0 commit comments

Comments
 (0)