|
| 1 | +--- |
| 2 | +'@objectstack/spec': minor |
| 3 | +'@objectstack/service-automation': minor |
| 4 | +--- |
| 5 | + |
| 6 | +fix(automation): a `wait` node must say what resumes it — the config block is required at the contract, and the executor stops defaulting to a duration-less timer (#17928) |
| 7 | + |
| 8 | +**BREAKING** — a `type: 'wait'` flow node with no `waitEventConfig` block, and a |
| 9 | +`type: 'boundary_event'` node with no `boundaryConfig` block, no longer parse. |
| 10 | +Under `eventType: 'timer'`, `timerDuration` is now required and may not be blank |
| 11 | +— and that half sits on the `waitEventConfig` BLOCK, not on the node type, so it |
| 12 | +bites on ANY node carrying the block: a `start` node spelled |
| 13 | +`waitEventConfig: { eventType: 'timer' }` parsed before and is refused now. It is |
| 14 | +still a narrowing in every direction (no shape starts parsing that did not), and |
| 15 | +the block is inert on a node type no executor reads it from, so the practical |
| 16 | +reach is `wait`. |
| 17 | + |
| 18 | +`eventType` has been required *inside* each block since protocol 17, so |
| 19 | +`waitEventConfig: {}` was already a loud parse error. The block itself was |
| 20 | +optional — so "omit the key" and "omit the block" were two documents with two |
| 21 | +verdicts, and the accepted one was the silent one. It is also the state a |
| 22 | +freshly created node is in, which is what made it reachable from a designer's |
| 23 | +default screen rather than only by hand-authoring. |
| 24 | + |
| 25 | +What that document did, measured through a real `engine.execute()` run rather |
| 26 | +than read off the source: |
| 27 | + |
| 28 | +``` |
| 29 | +FROM { id: 'pause', type: 'wait', label: 'Wait' } // parses clean |
| 30 | + -> { success: true, suspend: true } // run status: paused |
| 31 | + scheduled jobs: [] <- with a job service ANSWERING |
| 32 | + variables: no `pause.waitUntil` <- cold boot cannot re-arm |
| 33 | + log lines: 0 at any level <- warn, error, info, debug |
| 34 | +
|
| 35 | +TO FlowNodeSchema.safeParse(...) |
| 36 | + -> { success: false, |
| 37 | + issues: [{ code: 'custom', path: ['waitEventConfig'], |
| 38 | + message: 'a `wait` node requires a `waitEventConfig` block saying |
| 39 | + what resumes it … `waitEventConfig: { eventType: 'timer', |
| 40 | + timerDuration: 'PT1H' }` … or `{ eventType: 'signal', |
| 41 | + signalName: 'order_paid' }` …' }] } |
| 42 | +``` |
| 43 | + |
| 44 | +The control — the same node with `{ eventType: 'timer', timerDuration: 'PT1H' }` |
| 45 | +— armed the one-shot job and persisted the deadline, so the zeros above are a |
| 46 | +reading of this path and not of a dead harness. |
| 47 | + |
| 48 | +**The executor follows the contract.** `wait-node.ts` carried |
| 49 | +`(node.waitEventConfig ?? {})` and `String(wec.eventType ?? 'timer')` under a |
| 50 | +comment declaring the second one deliberate — "a wait node without one is a |
| 51 | +VALID TIMER WAIT". Both fallbacks are retired. A node that still reaches |
| 52 | +`execute` without the block (a stored pre-migration document on a path that |
| 53 | +skipped the parse) is now a **guard refusal** — `errorClass: 'guard'`, so a |
| 54 | +`fault` edge cannot route a metadata defect into a handler that reports success |
| 55 | +— and it **logs**, naming the node and the remedy, because the defect being |
| 56 | +closed was silence. It never suspends with `success: true` again. Two smaller |
| 57 | +corrections ride along in the same return: the timer branch stops answering |
| 58 | +`output` as a present key holding `undefined` (it is absent when no deadline was |
| 59 | +computed), and the reversed comment is deleted rather than left describing a |
| 60 | +behaviour that is gone. |
| 61 | + |
| 62 | +**`screen.mode` now declares the default the executor applies; `http.method` |
| 63 | +still declares none.** Both were read by running the executors with the key |
| 64 | +absent, not by reading the Zod: |
| 65 | + |
| 66 | +| key | absent ⇒ the runtime applies | declared | |
| 67 | +| --- | --- | --- | |
| 68 | +| `ScreenConfig.mode` | `'create'` (object-form branch; the flat `fields` branch never reads it) | `.default('create')` | |
| 69 | +| `HttpConfig.method` | `GET` inline, **`POST`** when `durable: true` | ⛔ none — two values, no single default | |
| 70 | + |
| 71 | +Declaring `.default('GET')` on `method` would materialise `GET` at parse time, |
| 72 | +the durable arm's own `?? 'POST'` would never fire again, and every stored |
| 73 | +durable callout that omits the method would silently change verb. That is the |
| 74 | +defect this card exists to end, pointed the other way. |
| 75 | + |
| 76 | +**Migration.** A stored `wait` node with no block has no lossless conversion — |
| 77 | +the missing value is an intent no artifact records, and the old runtime's pick |
| 78 | +(`'timer'` with no duration) was not a wait at all — so this is an ADR-0087 D3 |
| 79 | +semantic entry rather than a D2 conversion: `os migrate meta --from 17` names |
| 80 | +each node to edit. Declare the resume condition and re-publish the flow. ⚠️ |
| 81 | +Behaviour the fix deliberately changes: a run that used to park forever now |
| 82 | +waits the duration you declare or the signal you name. |
| 83 | + |
| 84 | +**`boundary_event` gets the contract half only.** The runtime registers no |
| 85 | +executor for that node type at all — a flow reaching one fails with |
| 86 | +`NO_EXECUTOR` before any config is read, identically whether the block is |
| 87 | +present or absent — so there is no silent executor branch behind it. The |
| 88 | +refusal fixes the authoring surface; `try_catch` (ADR-0031) remains the native |
| 89 | +construct for error handling. |
| 90 | + |
| 91 | +<!-- adr-0087: registered wait-node-event-config-required --> |
0 commit comments