Feat/wb 501 decision endpoint - #133
Conversation
…a parked run WB-501 step 1. WorkflowEnginePort gains a required resolveNode(executionId, nodeId, resolution) that answers every expected refusal as a result, never a throw: the validator's four codes plus run_not_found and delivery_timeout. The codes live once, as `as const` arrays in the execution-core port module; the validator types its throws with them and the Temporal adapter derives its runtime check from them. The adapter addresses the update by name (RESOLVE_NODE_UPDATE_NAME, a deliberate root export pinned like RUN_WORKFLOW_NAME) and bounds the RPC with client.withDeadline (resolveTimeoutMs, default 10 s). Unknown failure types are rethrown. The harness showed that an update abandoned at the client deadline is not dropped: the server still hands it to the next worker, so a retry may hear verdict_already_delivered. The test pins "exactly one lands". The validator's messages moved into one dictionary keyed by rejection, in the backend's style.
WB-501 step 2. toNodeResolution turns an accepted Decision and its matched action into the completion the engine delivers: output is the decision itself, nextPort the action's port, rerun-source excluded at the type level and the reserved errorRoute port refused. findDecisionRequest reads a node's request out of the parsed snapshot. A non-resume submission carrying edits is now refused with edits_not_allowed, checked before the field rules so the refusal names the edits.
WB-501 step 3. countNodeWaits(executionId, nodeId) counts the node_waiting events of one node in one run. The number is the wait instance a decision must name, and zero says the node never parked. Shared with the coming pending-decision resource, so it lives beside the event query, not in a route.
…ecision to a parked run WB-501 step 4. One door for every future channel. The route loads the row, authorizes executions:decide with the row's attributes (a deny wins over 404), refuses terminal and cancelling runs, parses the body, reads the node's request out of the parsed snapshot, judges the submission, checks the wait instance (attempt = the node's node_waiting count), refuses rerun-source with 501 until the engine can re-run a source, and delivers the completion through engine.resolveNode. Every engine refusal is answered on the first try; no retry. Codes, statuses and messages live once in decision-refusals.ts: the status map spells each code, situations are typed against it, and total maps over the engine's and the lookup's codes make a new code a compile error here.
…lares A code added to the port's validator group without a throw site compiled fine and stayed dead. The dictionary is exported and a type-level pin equates its codes with VerdictRejection in both directions.
…s 503 with Retry-After WB-501 step 5. The adapter's delivery_timeout becomes decision_delivery_timeout. The update is not durable until a worker accepts it, yet the server may still hand it to the next worker, so the message says the decision may or may not have landed and that a retry answering decision_already_made means it did. No retry anywhere on the server side.
WB-501 step 6. The backend README is the one place for the endpoint and its answers; the decision log keeps only the reasons and closes its open points; the Temporal README and decision log say what resolveNode answers with.
…ine error The row is checked before the body, the attempt before the effect. An error the engine throws instead of returning surfaces as 500.
The refusal table reuses the domain's {value} filler instead of carrying its own copy.
The rerun-source marker in the route states the limitation in words.
…rt's code array VERDICT_REJECTIONS meant a private array of codes in execution-core and an exported map of messages in the validator. The validator's is now VERDICT_REJECTION_MESSAGES.
A fake client with a frozen clock checks that withDeadline receives now + 10 s by default and now + resolveTimeoutMs when set. The entry-points table names RUN_WORKFLOW_NAME and RESOLVE_NODE_UPDATE_NAME.
Deny-before-404 hides which ids exist only if the port denies on absent attributes too. Re-parsing a stored snapshot with today's schema can leave a parked run undecidable after a deploy.
|
|
||
| const waits = await countNodeWaits(executionId, nodeId); | ||
| if (waits === 0) return refuse(c, 'node_never_parked', nodeId); | ||
| if (waits !== attempt) return refuse(c, 'attempt_mismatch', undefined, { attempt: waits }); |
There was a problem hiding this comment.
The attempt check lives here in the backend, but the engine never sees the attempt: resolveNode gets only nodeId + resolution, and the waits map in run-workflow.ts is keyed by node id alone. So the check isn't atomic with delivery.
Today that's fine (a node parks once, rerun-source is 501), but once the rerun loop lands, a count read just before a re-park can deliver into the wrong wait, and the engine has no way to notice. The ticket keys the wait instance on execution + node + attempt for exactly this reason. Decision-log 15 acknowledges the gap, but nothing in code marks the seam.
Two options:
Preferred (small, additive, replay-safe since validators write nothing to history):
- add an optional
attempttoresolveNodeon the port and to the update input - give
NodeWaitStateanattemptcounter, incremented at the park site - have the validator reject
verdict.attempt !== state.attemptwith a newverdict_attempt_mismatchcode, mapped todecision_attempt_mismatchinENGINE_REFUSALS
The completeness tests will catch any missed dictionary. Old callers keep working since the field is optional.
Minimum: a (follow-up: decision-attempt-in-engine) marker here and on the waits map, plus the matching Code marker line on the rerun ticket, so the rerun work can't miss it.
No description provided.