Skip to content

Commit bce5270

Browse files
os-warrenclaude
andauthored
fix(automation): refuse a wait node whose timerDuration yields no wait, instead of parking the run forever (#18370)
Fixes #18179 The #17928 hole reached through a different door. Under `eventType: 'timer'` the contract requires a non-blank `timerDuration`, but the key is `z.string()` and no schema can evaluate the string: `'not-a-duration'`, `'1 hour'`, `'P'`, `'PT0S'`, `'PT0H0M0S'`, `'0'` and `'-5'` are all documents that **save**, and `parseIsoDuration` answers `undefined` for every one of them — exactly as it did for the absent key. This PR makes the executor refuse them loudly instead of parking the run forever while reporting success. ## Premise re-check on this branch's base, `b57671edc1` — every cited line holds The dispatch measured at `1411cf2c6f`; this branch forked at `b57671edc1` (`origin/main` is still `b57671edc1` as this PR opens). Re-measured here rather than carried: | cited | dispatch | measured on `b57671edc1` | |---|---|---| | `parseIsoDuration` declaration | `:526` | `:526` OK | | no deadline computed (`durationMs && durationMs > 0`) | `:298` | `:298` OK | | no `waitUntil` persisted (`const output = at ? …`) | `:304` | `:304` OK | | no job armed (`if (job && runId != null && at)`) | `:307` | `:307` OK | | the no-job-service fallback (`else if (!job)`) | `:329` | `:329` OK | | returns `{ success: true, suspend: true }` | `:337` | `:337` OK | | the tree pins it as expected | `wait-node.test.ts:332` | `:332` OK | ## The state the old path left behind With a job service **answering**, neither branch could run: the arming guard fails on the deadline, and the `else if (!job)` fallback fires only when the job service is **missing**. Control fell straight to the suspending return, whose comment points at recovery through "a later boot's re-arm pass, when the deadline was persisted" — and `:304` had just established that it was not. Measured through a real `engine.execute()` run, not read off the source: | channel | before | after | |---|---|---| | `engine.execute()` | `{ success: true, suspend: true }`, status `paused` | run **fails**, node status `failure` | | suspended runs | one, parked forever | none — the run never suspends | | persisted `waitUntil` | absent, so the cold-boot re-arm pass is blind to the run | nothing persisted, because nothing parks | | scheduled jobs | none, with a job service **answering** | none, and the run is refused instead | | log lines during the run | **0 at any level** | one `warn` naming the node, the value and the remedy | | `errorClass` | n/a — it reported success | `'guard'`, so a `fault` edge cannot route it | A warning-only fix would have moved the last row and nothing else; the run would still park forever. ## What lands **`wait-node.ts` — the timer branch refuses before it suspends.** When `parseIsoDuration` answers `undefined` (which already covers zero and negative), the node emits a `warn` and returns `refuseNode(...)`, mirroring what PR #18175 (`cb1f27434b`, on `main`) already did for a stored absent block. The refusal is `guard`-class for the same reason: the metadata is wrong, re-running changes nothing, and a `fault` edge must not be able to route a metadata defect into a handler that then reports success. - **`warn`, not `error`**, by AGENTS.md's degradation rule and for the same reason the block-less refusal above it is `warn`: the failure is handed to the **caller** — the run fails and says so — so nothing looks normal from the outside and this is not a durability degradation. - **The offending value travels with the complaint**, quoted through `JSON.stringify` rather than spliced raw. `timerDuration` is author-controlled foreign text arriving on a log record, and a value carrying a newline would split the alarm into physical lines of which only the first carries its level — the #5737 hazard, arriving from the authoring side instead of the driver side. Pinned by its own test. - **Zero and negative are the same verdict, deliberately not a separate one.** `'PT0S'` is not a short wait; it is a deadline already past, and it parks just as permanently. - **`output` is now unconditional in that branch.** The old `at ? { output: … } : {}` spread existed so `output` would be absent rather than a present key holding `undefined` when no deadline was computed. That distinction still matters and is still pinned (in `absent-config-node-characterization.test.ts`, on the signal branch); what is gone is the arm that reached it. The two degraded returns still carry the deadline — they lose the auto-resume, never the `waitUntil` a later boot re-arms from. **`wait-node.test.ts` — the pin that asserted today's behaviour as expected moves, and the moved pin is the evidence.** `:332` ("cancels nothing for a timer wait that armed no job") kept its subject — teardown — and swapped its fixture: it used `timerDuration: 'not-a-duration'`, and its own comment named that fixture as the repro for a residual with no card. That card is #18179 and this PR discharges it, so the fixture moved to a job service whose `schedule` throws, the one route left to a degraded `timer:pause` correlation on a host that **has** a job service. A new block pins the reversal on every channel in the table above, across all seven unusable spellings, with a `PT1H` control that still suspends, arms the one-shot and persists the deadline. ## Scope — and the half deliberately NOT decided here The queued scope was the runtime half only, and that is all this PR contains. A **format refinement on `timerDuration`** narrows a published accept set; decision batch #127 item 5 requires the block and the key and 「⛔ does not rule on the key's lexical validity」, so there is no ruling behind a narrowing. No file under `packages/spec/src/automation/**` is touched: `builtin-node-config.zod.ts`, `flow.zod.ts`, `node-executor.zod.ts` and `schemaless-node-config.zod.ts` are all unchanged, and every one of the seven fixtures above still parses clean — which the new premise leg asserts explicitly, so this PR's own tests would go red if the accept set ever moved underneath them. ## Clause-②: no Clause-②: no — this PR widens nothing. The accept set does not move and no published surface grows: a run that used to report `success: true` forever is refused loudly instead. Nothing new is exported: the refusal reuses `refuseNode` from `guard-refusal.ts`, which this file already imported; no new error code, no new exported helper, no new schema refinement. `packages/services/service-automation`'s `exports` map is byte-identical. ## Evidence Every command below was run on this branch, most of them through `scripts/pm/os-verify-lock.sh`. - **Dependency-closure build** — `pnpm --workspace-concurrency=2 --filter '@objectstack/service-automation^...' build` :: exit 0. - **Package tests** — `pnpm --filter @objectstack/service-automation test` :: exit 0 — **136 files, 1625 tests passed**. - **Package typecheck** — `pnpm --filter @objectstack/service-automation typecheck` :: exit 0, including `check:test-typecheck` (0 files / 0 errors held in the debt ledger). - **Gate families** — derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no hand-written path list), run, then reconciled with `--ran`: **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN**, all carrying recorded exit codes. Five of them first answered `exit 3 PREREQUISITE NOT MET`; they were re-run to a real `exit 0` after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 tasks) rather than recorded as passes. - **Repo-wide lint, not a narrowed one** — `pnpm lint` (`eslint . --no-inline-config`) :: exit 0 over the whole population, so no narrowing needs defending. - **Reverse verification** — the fix was committed first, then `git restore --source=b57671edc1` put the pre-fix file back (tree only, never the index; 0 staged paths confirmed). On-disk landing proven by anchor counts, not by the editor's exit code: the `REVERSAL (#18179)` marker went 1 -> 0 and the old `const at = durationMs && durationMs > 0` anchor 0 -> 1, and the mutated blob hash equalled the base blob hash exactly. **`src/builtin/wait-node.test.ts` then failed: 8 failed | 28 passed (36).** Restored with `git checkout HEAD -- THE_FILE`; the restored blob hash equalled the HEAD blob hash, `git diff HEAD` was empty, and the re-run was **36 passed (36)**. The subject resolves through a relative import inside its own package, so no `dist` leg applies to this ablation. The ablation script carried a `trap … EXIT INT TERM` restore on an absolute path throughout. - Direction, predicted before the run and observed: **turns red**, and only the new block did. The premise leg, the teardown pin and the `PT1H` control all stayed green under the ablation — they are not about the fix. - **Changeset — measured, not assumed.** `@objectstack/service-automation` is published and its `files` is `["dist","README.md","CHANGELOG.md"]` (no `src`). After building the package: the subject text `is not a usable wait` reaches **both** published bundles (`dist/index.js` 2 hits, `dist/index.cjs` 2 hits); positive control `a wait node must declare what resumes it` (text that already ships) 1 hit in each; negative controls `REFUSED, not parked` and `scheduleThrows` (both test-only) 0 hits in each. => it publishes => `.changeset/18179-timer-duration-unusable-refusal.md`, `patch`, named for the card so it cannot collide with another PR's pending note. - **Control bytes** — `grep -naP` over the changed files for the C0/C1 range: no match, beside `pnpm check:nul-bytes` :: exit 0. ## Declared file face - `packages/services/service-automation/src/builtin/wait-node.ts` — the timer branch - `packages/services/service-automation/src/builtin/wait-node.test.ts` — the moved pin plus the reversal block - `.changeset/18179-timer-duration-unusable-refusal.md` Three files, and nothing else. In particular **not** `packages/services/service-automation/package.json` or `vitest.config.ts`, which PR #18198 owns right now — the fix needed neither, and no file under `src/builtin/` overlaps any of the three concurrent faces named at dispatch. ## Acceptance notes Recorded here rather than filed, per the scope rules. **1. The sibling that rides with #18179 — and a correction to how it was described.** The dispatch (and the card) say that `{ eventType: 'signal' }` with no `signalName` makes the executor "correlate on `undefined`". Re-measured on `b57671edc1`, it does not: `wait-node.ts:342` on `b57671edc1` reads ``const signal = String(wec.signalName ?? `wait:${node.id}`)``, so the pause carries a synthesized `wait:pause` correlation. That difference is load-bearing, and it is why the sibling is **not** discharged inside scope 1: - A timer wait declares a deadline and then has no mechanism that can ever arrive. A signal / webhook / manual / condition wait declares an **external** resumer, and the documented external route still works — `resumeAuthority: 'any'`, the REST resume door #3801 deliberately leaves open for `wait`, and `resume(runId)` — which the existing pin "cancels nothing for a signal wait" exercises end to end. The run is parked but reachable, not parked and unreachable. - Refusing an absent `signalName` would bite `eventType: 'manual'`, where absence is the normal authoring shape for an operator-resumed pause, and `'condition'`. Deciding which of the four must carry the key is a question about the contract's accept set — scope 2 — not a runtime bug the runtime may settle by itself. Reported rather than widened, as the dispatch directs. ⛔ No card filed for it; this PR is its carrier. - One narrower shape found while measuring it, recorded and **not** acted on: `signalName: ''` passes `z.string()` and survives `??` (which catches only nullish), so `{ eventType: 'signal', signalName: '' }` suspends with an **empty** correlation. Same scope-2 question, same answer: reported, not decided. **2. `runId == null` on the timer branch is still a silent degrade.** With a job service present but no run id, neither `if (job && runId != null)` nor `else if (!job)` runs, so the node suspends with no line logged. Re-measured, it is not reachable through the engine: `engine.ts:10600` sets `variables.set('$runId', runId)` unconditionally in the seeding path. Not a class (a) finding, therefore noted, not filed. Successor: none — there is no PR or person queued on this file after this one; it is recorded so the next reader of this branch does not have to re-derive it. Authored by the `os-dev` round of session `session_01KB5PFtxuy1x3dcR5gxudx6`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 271d6bb commit bce5270

3 files changed

Lines changed: 367 additions & 29 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(automation): a `wait` node whose `timerDuration` yields no wait is refused loudly instead of parking the run forever (#18179)
6+
7+
#17928 closed the **absent** `waitEventConfig` block: the contract now requires
8+
the block, and requires a non-blank `timerDuration` under `eventType: 'timer'`.
9+
Neither half can evaluate the string. `timerDuration` is `z.string()`, so
10+
`'not-a-duration'`, `'1 hour'`, `'P'`, `'PT0S'`, `'0'` and `'-5'` are all
11+
documents that SAVE — and `parseIsoDuration` answers `undefined` for every one
12+
of them, exactly as it did for the absent key.
13+
14+
Measured through a real `engine.execute()` run with a job service **answering**,
15+
not read off the source:
16+
17+
```
18+
FROM waitEventConfig: { eventType: 'timer', timerDuration: 'not-a-duration' }
19+
-> FlowNodeSchema.safeParse(...) // succeeds — the document saves
20+
-> { success: true, suspend: true } // run status: paused, forever
21+
scheduled jobs: [] <- with a job service ANSWERING
22+
variables: no `pause.waitUntil` <- cold boot cannot re-arm it
23+
log lines: 0 at any level <- warn, error, info, debug
24+
25+
TO -> { success: false, errorClass: 'guard', error: "wait 'pause': timerDuration
26+
\"not-a-duration\" is not a usable wait — …" } // run status: failed
27+
one `warn` naming the node, the offending value and the remedy
28+
```
29+
30+
The state the old path left behind was **un-refused, un-armed, un-persisted and
31+
un-logged, while reporting success**: neither the arming branch (guarded on the
32+
deadline) nor the "no job service" fallback (guarded on the service) could run,
33+
so control fell straight through to the suspending return. The comment there
34+
pointed at recovery via a later boot's re-arm pass "when the deadline was
35+
persisted" — and no deadline had been persisted.
36+
37+
**The remedy the refusal prints.** Write an ISO-8601 duration
38+
(`timerDuration: 'PT1H'`, `'P3D'`, `'PT90M'`) or a QUOTED positive millisecond
39+
count (`'60000'`), then re-publish the flow. For a pause with no deadline,
40+
declare an `eventType` that names its resumer instead (`'signal'` / `'webhook'`
41+
/ `'manual'` / `'condition'`).
42+
43+
Zero and negative are the same verdict and deliberately not a separate one:
44+
`'PT0S'` is not a short wait, it is a deadline already past, and it parks just
45+
as permanently as an unparseable string.
46+
47+
⚠️ Behaviour this deliberately changes: a stored flow carrying one of these
48+
values used to reach `paused` and report success. It now fails the run at that
49+
node. Nothing that parsed stops parsing — no authorable key is removed, renamed
50+
or narrowed — and the refusal is `guard`-class, so a `fault` edge cannot route
51+
the metadata defect into a handler that reports success.

packages/services/service-automation/src/builtin/wait-node.test.ts

Lines changed: 247 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import { describe, it, expect, beforeEach } from 'vitest';
44
import { AutomationEngine } from '../engine.js';
5-
import type { NodeExecutor } from '../engine.js';
5+
import type { NodeExecutionResult, NodeExecutor } from '../engine.js';
66
import { InMemorySuspendedRunStore } from '../suspended-run-store.js';
77
import { registerWaitNode, parseIsoDuration, rearmSuspendedWaitTimers } from './wait-node.js';
8-
import type { IJobService, JobHandler, JobSchedule } from '@objectstack/spec/contracts';
8+
import type { AutomationResult, IJobService, JobHandler, JobSchedule } from '@objectstack/spec/contracts';
99
// #6758 — the wait tombstone's prescription is checked against BOTH gates an
1010
// author's value must clear: the spec schema and `parseIsoDuration` above.
1111
import { FlowNodeSchema } from '@objectstack/spec/automation';
@@ -19,12 +19,24 @@ function ctxNoJob() {
1919
return { logger: silentLogger(), getService() { throw new Error('no service'); } } as any;
2020
}
2121

22-
/** A fake job service that records `schedule()` calls and exposes the handler. */
23-
function fakeJobCtx() {
22+
/**
23+
* A fake job service that records `schedule()` calls and exposes the handler.
24+
*
25+
* `scheduleThrows` makes the arming call FAIL while the service itself stays
26+
* present and answering — the one remaining route to a timer pause that carries
27+
* the degraded `timer:<nodeId>` correlation on a host that HAS a job service, and
28+
* therefore the only fixture from which `cancelled` is still observable for that
29+
* shape. Since #18179 an unparseable duration no longer reaches it (the node is
30+
* refused before any of this), so the teardown case below had to move here.
31+
*/
32+
function fakeJobCtx(opts: { scheduleThrows?: boolean } = {}) {
2433
const scheduled: Array<{ name: string; schedule: JobSchedule; handler: JobHandler }> = [];
2534
const cancelled: string[] = [];
2635
const job: IJobService = {
27-
async schedule(name, schedule, handler) { scheduled.push({ name, schedule, handler }); },
36+
async schedule(name, schedule, handler) {
37+
if (opts.scheduleThrows) throw new Error('job queue unreachable');
38+
scheduled.push({ name, schedule, handler });
39+
},
2840
async cancel(name) { cancelled.push(name); },
2941
async trigger() {},
3042
};
@@ -329,25 +341,27 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
329341
expect(cancelled).toEqual([]);
330342
});
331343

332-
it('cancels nothing for a timer wait that armed no job (no parseable duration)', async () => {
333-
const { ctx, scheduled, cancelled } = fakeJobCtx();
344+
it('cancels nothing for a timer wait that armed no job (the arming call failed)', async () => {
345+
const { ctx, scheduled, cancelled } = fakeJobCtx({ scheduleThrows: true });
334346
registerWaitNode(engine, ctx);
335-
// An UNPARSEABLE `timerDuration` ⇒ no deadline ⇒ nothing scheduled; the
336-
// pause carries the degraded `timer:<nodeId>` correlation instead of a job
337-
// name. This case used to be spelled `{ eventType: 'timer' }` with the key
338-
// ABSENT, which the contract now refuses at parse — so the fixture moved to
339-
// the shape that still reaches this branch. ⚠️ That it still reaches it is a
340-
// residual: the contract can require the key but cannot evaluate the string,
341-
// so a malformed (or zero-valued) duration reproduces the old silent park —
342-
// `parseIsoDuration` returns `undefined` for it exactly as it did for the
343-
// absent key. ⚠️ NO card exists for that yet: it is reported in this PR's
344-
// acceptance notes for triage to file, and THIS fixture is its repro. ⛔ Do
345-
// not read the line above as a filing. What this test is about is the
346-
// TEARDOWN, and that is unchanged.
347-
engine.registerFlow('wait_flow', waitFlow({ eventType: 'timer', timerDuration: 'not-a-duration' }));
347+
// A job service that is PRESENT and answering, whose `schedule` throws: the
348+
// run still suspends (degrade-don't-crash — only auto-resume is lost) and
349+
// carries the degraded `timer:<nodeId>` correlation instead of a job name, so
350+
// the teardown must not hand that string to `cancel()`.
351+
//
352+
// ⚠️ The fixture MOVED. This case used to be spelled
353+
// `{ timerDuration: 'not-a-duration' }`, because an unparseable duration
354+
// reached the same degraded return — and the comment here said that residual
355+
// had no card and named this very fixture as its repro. #18179 is that card,
356+
// and it closed the route: an unparseable, zero or negative duration is now
357+
// REFUSED before the node suspends at all (pinned in the `#18179` block
358+
// below). So the fixture moved to the shape that still reaches this branch.
359+
// What this test is about is the TEARDOWN, and that is unchanged.
360+
engine.registerFlow('wait_flow', waitFlow({ eventType: 'timer', timerDuration: 'P1D' }));
348361

349362
const paused = await engine.execute('wait_flow');
350-
expect(scheduled).toEqual([]);
363+
expect(paused.status).toBe('paused');
364+
expect(scheduled).toEqual([]); // the arming call threw, so no job was recorded
351365
expect(engine.listSuspendedRuns()[0]).toMatchObject({ correlation: 'timer:pause' });
352366

353367
const resumed = await engine.resume(paused.runId!);
@@ -374,6 +388,218 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
374388
});
375389
});
376390

391+
/**
392+
* #18179 — the #17928 hole reached through a DIFFERENT DOOR.
393+
*
394+
* #17928 closed the ABSENT `waitEventConfig` block: the contract now requires the
395+
* block, and requires a non-blank `timerDuration` under `eventType: 'timer'`.
396+
* Neither half can evaluate the string. `timerDuration` is `z.string()`, so
397+
* `'not-a-duration'`, `'P'`, `'PT0S'`, `'0'` and `'-5'` all SAVE — and
398+
* `parseIsoDuration` answers `undefined` for every one of them, exactly as it did
399+
* for the absent key.
400+
*
401+
* ⭐ The premise leg below is what makes this card's evidence stronger than
402+
* #17928's, not weaker. Those documents had to be STAGED (the block stripped after
403+
* registration, because the parse refuses it). These need no staging at all:
404+
* `registerFlow` parses them clean, so every reading here is of a document an
405+
* author can save from the designer's own screen.
406+
*
407+
* What moved, on each channel — the same channels #17928's characterization
408+
* measured, so the two rows can be read side by side:
409+
*
410+
* | channel | before #18179 | pinned now |
411+
* | ------------------------ | ---------------------------------- | -------------------------------------------- |
412+
* | `engine.execute()` | `{ success: true, suspend: true }` | run FAILS, node status `failure` |
413+
* | suspended runs | one, parked forever | none — the run never suspends |
414+
* | persisted `waitUntil` | absent ⇒ cold-boot re-arm blind | nothing persisted, because nothing parks |
415+
* | scheduled jobs | none, with a job service ANSWERING | none, and the run is refused instead |
416+
* | log lines during the run | **0 at any level** | a `warn` naming the node, the value, the fix |
417+
* | `errorClass` | n/a — it reported success | `'guard'` — a `fault` edge cannot route it |
418+
*
419+
* ⛔ A warning-only fix would have left every other row where it was. The run
420+
* would still park forever; only the operator's chance of noticing would change.
421+
*/
422+
describe('an unparseable, zero or negative `timerDuration` is REFUSED, not parked (#18179)', () => {
423+
type LogLine = { level: string; text: string };
424+
425+
/**
426+
* Every level the `Logger` contract offers funnelled into ONE ordered sink, so
427+
* "did anything at all get logged" is answerable rather than "did the level I
428+
* happened to spy on get logged" — the reading that carried #17928 was a zero
429+
* across all four.
430+
*/
431+
function recordingLogger(sink: LogLine[]): any {
432+
const at = (level: string) => (...args: unknown[]) => {
433+
sink.push({ level, text: args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') });
434+
};
435+
const logger = { info: at('info'), warn: at('warn'), error: at('error'), debug: at('debug'), child: () => logger };
436+
return logger;
437+
}
438+
439+
/**
440+
* One real, engine-driven run of a timer wait, reporting every channel above.
441+
*
442+
* The executor's own return value is captured by wrapping the executor
443+
* `registerWaitNode` publishes, so the value recorded is the one the ENGINE
444+
* received from a genuine run — never a second invocation staged by the test.
445+
*/
446+
async function runTimerWait(timerDuration: unknown) {
447+
const logs: LogLine[] = [];
448+
const ran: string[] = [];
449+
const engine = new AutomationEngine(recordingLogger(logs));
450+
const store = new InMemorySuspendedRunStore();
451+
engine.setSuspendedRunStore(store);
452+
engine.registerNodeExecutor(markerExecutor(ran));
453+
454+
// A job service that is PRESENT and answering — the instrument for "was a
455+
// wake-up armed", and the composition in which the old silence was total.
456+
const scheduled: Array<{ name: string; schedule: JobSchedule }> = [];
457+
const job: IJobService = {
458+
async schedule(name, schedule) { scheduled.push({ name, schedule }); },
459+
async cancel() {},
460+
async trigger() {},
461+
};
462+
const ctx = { logger: recordingLogger(logs), getService: (id: string) => (id === 'job' ? job : undefined) } as any;
463+
464+
let returned: NodeExecutionResult | undefined;
465+
const realRegister = engine.registerNodeExecutor.bind(engine);
466+
const patchable = engine as unknown as { registerNodeExecutor: (e: NodeExecutor) => void };
467+
patchable.registerNodeExecutor = (exec: NodeExecutor) => {
468+
if (exec.type !== 'wait') return realRegister(exec);
469+
const inner = exec.execute.bind(exec);
470+
return realRegister({
471+
...exec,
472+
async execute(n, v, c) { const r = await inner(n, v, c); returned = r; return r; },
473+
});
474+
};
475+
registerWaitNode(engine, ctx);
476+
delete (engine as unknown as Record<string, unknown>).registerNodeExecutor;
477+
478+
engine.registerFlow('wait_flow', waitFlow({ eventType: 'timer', timerDuration }));
479+
// Production seals the vocabulary at `kernel:bootstrapped`
480+
// (`AutomationServicePlugin`); sealing here keeps the engine's own "never
481+
// sealed" warning out of the window, so a line seen during the run is one the
482+
// NODE produced.
483+
engine.sealNodeTypeVocabulary();
484+
const from = logs.length;
485+
const result = await engine.execute('wait_flow');
486+
return {
487+
result,
488+
returned,
489+
scheduled,
490+
ran,
491+
suspended: engine.listSuspendedRuns(),
492+
stored: await store.list(),
493+
logsDuringRun: logs.slice(from),
494+
};
495+
}
496+
497+
const nodeStatus = (result: AutomationResult, nodeId: string) =>
498+
result.summary?.nodes?.find((n) => n.nodeId === nodeId)?.status;
499+
500+
/**
501+
* Every spelling that SAVES and yields no wait. Unparseable, the empty ISO
502+
* designator, two zero-valued spellings (one ISO, one bare millisecond count)
503+
* and a negative one — the zero/negative half is not a separate verdict,
504+
* because `'PT0S'` is not a short wait, it is a deadline already past.
505+
*/
506+
const UNUSABLE = ['not-a-duration', '1 hour', 'P', 'PT0S', 'PT0H0M0S', '0', '-5'] as const;
507+
508+
it('⭐ premise — the contract ACCEPTS every one of these, so they reach the executor UNSTAGED', () => {
509+
for (const timerDuration of UNUSABLE) {
510+
const parsed = FlowNodeSchema.safeParse({
511+
id: 'pause', type: 'wait', label: 'Wait',
512+
waitEventConfig: { eventType: 'timer', timerDuration },
513+
});
514+
expect(parsed.success, `the contract still accepts \`timerDuration: '${timerDuration}'\``).toBe(true);
515+
// Both halves, or the refusals below could be pinning a document that never
516+
// existed: the contract lets it through AND the reader answers nothing.
517+
expect(parseIsoDuration(timerDuration), `\`${timerDuration}\` must yield no duration`).toBeUndefined();
518+
}
519+
// CONTROL — #17928's half is still in place, so what this block measures is
520+
// the gap that contract deliberately left, not the absence of a contract.
521+
expect(FlowNodeSchema.safeParse({
522+
id: 'pause', type: 'wait', label: 'Wait',
523+
waitEventConfig: { eventType: 'timer', timerDuration: ' ' },
524+
}).success, 'a BLANK duration is still refused at the parse').toBe(false);
525+
});
526+
527+
it.each(UNUSABLE)(
528+
'fails the run with a named, LOGGED guard refusal for %o — never a silent suspension reporting success',
529+
async (timerDuration) => {
530+
const m = await runTimerWait(timerDuration);
531+
532+
// ① ⭐ the whole reversal, on the run's own verdict
533+
expect(m.result.success).toBe(false);
534+
expect(m.result.status).toBe('failed');
535+
expect(m.result.status).not.toBe('paused');
536+
expect(nodeStatus(m.result, 'pause'), 'the run reached the wait node and failed AT it').toBe('failure');
537+
expect(m.ran, 'nothing downstream of the wait ran').toEqual([]);
538+
539+
// ② the executor's return: a guard refusal, so a `fault` edge cannot route
540+
// a metadata defect into a handler that then reports success
541+
expect(m.returned?.success).toBe(false);
542+
expect(m.returned?.errorClass).toBe('guard');
543+
expect(m.returned?.suspend).toBeUndefined();
544+
expect(m.returned?.error).toContain('timerDuration');
545+
expect(m.returned?.error).toContain('pause');
546+
547+
// ③ nothing parked, nothing armed, nothing persisted — where the old
548+
// behaviour left a run in the suspension ledger AND in the durable store
549+
expect(m.suspended).toEqual([]);
550+
expect(m.stored).toEqual([]);
551+
expect(m.scheduled).toEqual([]);
552+
553+
// ④ ⭐ the silence is gone. With a job service ANSWERING, the old reading
554+
// here was [] at every level — that zero is what made the hang
555+
// unobservable, and neither existing branch could produce a line: the
556+
// arming guard failed on `at`, and the `!job` fallback needs no job.
557+
const named = m.logsDuringRun.find((l) => l.level === 'warn' && l.text.includes("node 'pause'"));
558+
expect(named, 'the refusal names the node in the log, not only in the step record').toBeDefined();
559+
expect(named!.text).toContain('timerDuration');
560+
// the offending value travels with the complaint…
561+
expect(named!.text).toContain(JSON.stringify(timerDuration));
562+
// …and so does the remedy, so an operator can fix the flow from this line
563+
expect(named!.text).toContain("timerDuration: 'PT1H'");
564+
},
565+
);
566+
567+
it('quotes the authored value rather than splicing it — a newline cannot split the alarm (#5737)', async () => {
568+
// `timerDuration` is author-controlled foreign text on a log record. Spliced
569+
// raw, a value carrying a newline breaks the warning into physical lines of
570+
// which only the first carries its level — the hazard #5737 measured from the
571+
// driver side, arriving here from the authoring side.
572+
const m = await runTimerWait('not\na duration');
573+
574+
const named = m.logsDuringRun.find((l) => l.level === 'warn' && l.text.includes("node 'pause'"));
575+
expect(named, 'the value still saves and is still refused').toBeDefined();
576+
expect(named!.text, 'one physical line').not.toContain('\n');
577+
expect(named!.text, 'the newline is present, escaped').toContain('\\n');
578+
});
579+
580+
it('CONTROL — a parseable duration still suspends, arms the one-shot and persists the deadline', async () => {
581+
const control = await runTimerWait('PT1H');
582+
583+
// A wake-up job IS armed, one shot…
584+
expect(control.scheduled).toHaveLength(1);
585+
expect(control.scheduled[0].schedule.type).toBe('once');
586+
// …the deadline IS persisted, under the key the cold-boot re-arm pass reads…
587+
const vars = (control.stored[0]?.variables ?? {}) as Record<string, unknown>;
588+
expect(typeof vars['pause.waitUntil']).toBe('string');
589+
expect(control.returned?.output).toEqual({ waitUntil: vars['pause.waitUntil'] });
590+
// …and the run parks, successfully, exactly as before this change.
591+
expect(control.result.status).toBe('paused');
592+
expect(control.result.success).toBe(true);
593+
expect(control.suspended).toHaveLength(1);
594+
// …saying nothing, because a legitimate pause owes no complaint.
595+
expect(control.logsDuringRun.filter((l) => l.level === 'warn' || l.level === 'error')).toEqual([]);
596+
597+
// ⇒ the instrument discriminates: every channel that reads non-zero here
598+
// reads the opposite above, so the refusals are the DURATION and not a
599+
// harness that fails everything.
600+
});
601+
});
602+
377603
/**
378604
* The other half of "when may the one-shot disarm itself?" (#5529).
379605
*

0 commit comments

Comments
 (0)