|
| 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 | +}); |
0 commit comments