Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 126 additions & 2 deletions apps/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ A node asks a human for a decision by carrying `data.properties.decisionRequest`

The runner does not read the field, deliberately: it learns no product's vocabulary, so a run stops where a node's executor returns a waiting result. A request on a node that never parks therefore validates, reaches the worker and asks nobody anything. The node whose executor does nothing but park is `ai-studio/human-decision`: `apps/execution-worker/src/executors/human-decision.ts` returns `{ waiting: true }`, and `apps/ai-studio/src/nodes/human-decision/` renders one output handle per action that carries a port.

The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. A `resume` or `reject` action always names its `port`: a port is the id of an output handle on the canvas, and the backend supplies no default for it. A `rerun-source` action takes no port, because it does not route. A node that carries a request may not set `errorPolicy: 'continue'`, since a failure would then light every port at once; `fail` and `errorRoute` are accepted. Structural issues come first; the graph rules (proposal source, predecessors) run once the structure parses, so a second round of issues can follow a fix. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. Each such detail also carries `domainCode`, its key in that dictionary, and `params` with the value the message interpolates, so a client branches and translates on the identifier and never on the wording; `code` stays zod's own.
The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. A `resume` or `reject` action always names its `port`: a port is the id of an output handle on the canvas, and the backend supplies no default for it. A `rerun-source` action takes no port, because it does not route. A request offers one or more `resume` actions, each its own branch, at most one `reject` and at most one `rerun-source`. Every routed action has its own port: a port two actions share is refused as `duplicate_port` on the later one. A node that carries a request may not set `errorPolicy: 'continue'`, since a failure would then light every port at once; `fail` and `errorRoute` are accepted. Structural issues come first; the graph rules (proposal source, predecessors) run once the structure parses, so a second round of issues can follow a fix. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. Each such detail also carries `domainCode`, its key in that dictionary, and `params` with the value the message interpolates, so a client branches and translates on the identifier and never on the wording; `code` stays zod's own.

One key is refused outright, wherever it sits. An own `__proto__` anywhere in the snapshot answers `invalid_snapshot` 400 naming its path: `JSON.parse` turns it into an ordinary key, and a loose object copies unknown keys by assignment, which for that one swaps the parsed output's prototype and hands the engine a request no schema ever saw. The check does not weigh position, so it also refuses a `__proto__` buried inside an opaque node property, where zod never copies keys one by one and the key is inert. A node type that keeps a raw JSON document in `data.properties` therefore cannot carry one.

Expand All @@ -45,7 +45,7 @@ A submitted decision is checked against the request by `validateSubmittedDecisio

Body: `{ nodeId, attempt, action, edits?, reason?, comment? }`. `action` is the `name` of one of the node's actions. `attempt` is how many times the node has parked in this run (its `node_waiting` count; today always 1). `edits` are a patch of the proposal for whoever reads the decision to apply; the node's output carries them unapplied. Nothing applies them yet, so a step after the decision that reads the source's output sees the proposal without the corrections (follow-up: decision-settled-values). An object merges field by field and a list element by element, so a field whose schema declares `properties` or `items` must keep that shape; only `null` also passes, where the field's `type` allows it. Checks run in this order, each answering before the next: row, authorization (`executions:decide` with the row's `{ workflowId, tenantId, status }`; a deny wins over 404), status, body, node, decision, `attempt`, effect, engine. The engine is asked once; nothing is retried. Success: `200 { executionId, nodeId, attempt, action, effect }`. Codes and messages live in `src/routes/decision-refusals.ts`.

The route stamps `resolvedBy: 'human'` on the decision; a body naming an initiator is ignored. A `reject` also declares the run's outcome, edge or no edge: the run closes `completed` unless another branch ends it `incomplete` or `failed`, `GET /api/executions/:id` answers `outcome: 'rejected'` and `resolvedBy: 'human'` (`null` otherwise), and `execution_completed` carries `{ outcome: { value, resolvedBy, nodeId } }`. Publish requires no edge on a reject port.
The route stamps `resolvedBy: 'human'` on the decision; a body naming an initiator is ignored. A decision on any `resume` action continues the run on that action's port and declares no outcome. A `reason` is accepted with any action and required only where a `reject` sets `reasonRequired`. A `reject` also declares the run's outcome, edge or no edge: the run closes `completed` unless another branch ends it `incomplete` or `failed`, `GET /api/executions/:id` answers `outcome: 'rejected'` and `resolvedBy: 'human'` (`null` otherwise), and `execution_completed` carries `{ outcome: { value, resolvedBy, nodeId } }`. Publish requires no edge on a reject port.

| Status | Code | When |
| ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
Expand All @@ -60,6 +60,130 @@ The route stamps `resolvedBy: 'human'` on the decision; a body naming an initiat
| 501 | `effect_not_supported` | `rerun-source`, until the engine can re-run a source |
| 503 | `decision_delivery_timeout` | No worker accepted it in time. It may still land: resend (`Retry-After`); `decision_already_made` then names the wait, not the sender |

### Trying several resume actions on the local stack

A review with two normal outcomes and a rejection: `complete` and `incomplete` are both `resume` actions, each on its own port with its own next node; `reject` has no edge, which is a deliberate end. The run below never reaches an AI node. Save this as `review.json`:

```json
{
"name": "Review with two outcomes",
"draftJson": {
"nodes": [
{
"id": "trigger-1",
"type": "start-node",
"position": { "x": 0, "y": 200 },
"data": {
"isStartNode": true,
"type": "ai-studio/trigger",
"icon": "Lightning",
"segments": [],
"properties": { "label": "Case received", "description": "", "inputPrompt": "" }
}
},
{
"id": "review-1",
"type": "node",
"position": { "x": 350, "y": 200 },
"data": {
"type": "ai-studio/human-decision",
"icon": "UserCheck",
"segments": [],
"properties": {
"label": "Review",
"description": "",
"decisionRequest": {
"version": 1,
"actions": [
{ "name": "complete", "label": "Complete", "effect": "resume", "port": "source:inner:complete" },
{ "name": "incomplete", "label": "Incomplete", "effect": "resume", "port": "source:inner:incomplete" },
{
"name": "reject",
"label": "Reject",
"effect": "reject",
"port": "source:inner:rejected",
"reasonRequired": true
}
],
"schema": { "type": "object", "properties": {} }
}
}
}
},
{
"id": "done-1",
"type": "node",
"position": { "x": 700, "y": 100 },
"data": {
"type": "ai-studio/visualize",
"icon": "Eye",
"segments": [],
"properties": { "label": "Complete", "description": "", "mode": "json" }
}
},
{
"id": "hold-1",
"type": "node",
"position": { "x": 700, "y": 300 },
"data": {
"type": "ai-studio/visualize",
"icon": "Eye",
"segments": [],
"properties": { "label": "Incomplete", "description": "", "mode": "json" }
}
}
],
"edges": [
{
"id": "e-trigger-review",
"source": "trigger-1",
"sourceHandle": "source",
"target": "review-1",
"targetHandle": "target",
"type": "labelEdge",
"data": {}
},
{
"id": "e-review-done",
"source": "review-1",
"sourceHandle": "source:inner:complete",
"target": "done-1",
"targetHandle": "target",
"type": "labelEdge",
"data": {}
},
{
"id": "e-review-hold",
"source": "review-1",
"sourceHandle": "source:inner:incomplete",
"target": "hold-1",
"targetHandle": "target",
"type": "labelEdge",
"data": {}
}
]
}
}
```

With the backend on 3001 and a worker running (`pnpm infra:up`, then `pnpm dev:backend` and `pnpm dev:worker`, or `pnpm dev:ai-studio` for all three):

```bash
WF=$(curl -s -X POST localhost:3001/api/workflows -H 'content-type: application/json' -d @review.json | jq -r .id)
curl -s -X POST localhost:3001/api/workflows/$WF/publish > /dev/null
RUN=$(curl -s -X POST localhost:3001/api/workflows/$WF/execute -H 'content-type: application/json' \
-d '{"sourceVersion":"published","triggerPayload":{"input":"Case 48213"}}' | jq -r .executionId)
curl -s localhost:3001/api/executions/$RUN | jq .status
# "waiting", once the node has parked; repeat until it is
curl -s -X POST localhost:3001/api/executions/$RUN/decision -H 'content-type: application/json' \
-d '{"nodeId":"review-1","attempt":1,"action":"incomplete"}' | jq .effect
# "resume"
curl -s localhost:3001/api/executions/$RUN | jq '{status, outcome}'
# { "status": "completed", "outcome": null }: the run took the incomplete branch and no outcome was declared
```

A second run decided with `"action":"complete"` takes the other branch; one decided with `"action":"reject"` needs a `reason` and ends with `"outcome": "rejected"`. AI Studio opens the workflow at `?workflowId=$WF` and the run at `?executionId=$RUN`: the canvas draws one handle per routed action, while the decision form offers the first `resume` action only, for now.

## Listing executions: `GET /api/executions`

Newest first, filtered and paged. Query: `status` (one `ExecutionStatus`), `workflowId` (a UUID), `limit` (default 50, capped at 200; a larger value is clamped, not refused), `cursor` (opaque, taken from the previous page's `nextCursor`). Success: `200 { items, nextCursor }`, `nextCursor` is `null` on the last page. Items carry summary fields only (`id`, `workflowId`, `sourceVersion`, `status`, `startedAt`, `finishedAt`, `createdAt`): no snapshot, no trigger payload, no outputs. Authorization is `executions:list` on `{ kind: 'executions' }`, checked before the query string is read. With a tenant context the list holds the caller's rows plus untenanted rows, the stream route's rule applied as a filter; without one, every row. An empty value counts as absent. Paging is keyset on `(date_trunc('milliseconds', created_at), id)`, not on the raw column: the cursor carries the millisecond `createdAt` the client saw, so both sides of the comparison are truncated the same way. A run submitted between two requests lands on top and never repeats or shifts the pages that follow. A run whose insert committed after a page was read but whose `created_at` predates the cursor is returned mid-walk, on a later page — the predicate only asks for rows older than the cursor. The reverse is the case a walk cannot show: a row whose `created_at` is newer than the cursor stays invisible until the client restarts from a fresh first page. No index serves this order: every page sorts the whole matching set, and `status` or `workflowId` narrow what is scanned but not what is sorted. The two-argument `date_trunc` used here depends on the session `TimeZone` and is therefore STABLE, so it cannot appear in an expression index; the three-argument `date_trunc('milliseconds', created_at, 'UTC')` is IMMUTABLE and returns the same values, so switching to it allows an index on `(date_trunc('milliseconds', created_at, 'UTC'), id)`. Storing `created_at` as `timestamptz(3)` is the other route; it rounds where the current key truncates, so a cursor minted before that migration repeats one row once (follow-up: executions-created-at-millis). A cursor minted under one filter stays valid under another.
Expand Down
11 changes: 9 additions & 2 deletions apps/backend/decision-request.decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

### Proposed by: Piotr Błaszczyk

### Date: 07.09.2026 (shape), 08.09.2026 (names), 10.09.2026 (endpoint), 17.09.2026 (ports), 21.09.2026 (outcome), 29.09.2026 (failure policy, edit shape)
### Date: 07.09.2026 (shape), 08.09.2026 (names), 10.09.2026 (endpoint), 17.09.2026 (ports), 21.09.2026 (outcome), 29.09.2026 (failure policy, edit shape), 01.10.2026 (several resume actions)

## Context

Expand Down Expand Up @@ -54,6 +54,13 @@ What the endpoint does and answers is in the README. Only the reasons are here.
25. **A node that carries a request may not use `errorPolicy: 'continue'`.** The runner absorbs such a failure with no port, which lights every non-error edge: a worker without the node's executor, or a `node_completed` write that fails after an accepted verdict, would run approve and reject together. Publish and execute refuse it with `error_policy_continue`; `fail` and `errorRoute` keep failures visible. The rule lives in the backend, not the runner, because the runner deliberately reads no request. Like decision 21 it tightens a schema the decision route re-parses, so a run parked on such a node would answer 500; accepted, because the feature lives on its branch with no run in flight.
26. **Edits are a patch of the proposal.** The node's output carries them unapplied; whoever reads the decision merges an object field by field and a list element by element. That is why the walk checks only the keys an edit names. A level with `properties` or `items` must therefore keep its shape: `null`, a primitive or the other container could drop the read-only and required children it may hold, so it answers `field_shape_changed`, except `null` where the level's `type` allows it. `null` is the patch's own way to empty a field, and listing it in `type` is the author's consent; any other value would replace the level rather than patch it, even one its `type` lists. Replacement was rejected: it would make every object with a read-only child uneditable as a whole.

## Several resume actions (01.10.2026)

27. **One or more `resume` actions, each its own branch.** A step with two normal outcomes, a review that is complete or incomplete, could not be modelled: the second had to be `reject`, which records `rejected` on the run, and a run keeps the first outcome it declares, so a case put on hold once and processed later read as rejected. `reject` stays at most one, as the only action that records an outcome; a per-action outcome is not in this change. `rerun-source` stays at most one. The route needed nothing: it finds the action by name and routes on that action's port.
28. **Ports are unique across routed actions.** `duplicate_port` on the later action's port replaces the reject-versus-resume rule: with several resume actions there is no one pair to compare, and the port is what routes, so two actions on one handle would be one branch under two labels. A blank port answers `port_empty` alone, as before.
29. **A reason stays optional on `resume`.** `reasonRequired` is honoured on `reject` only. An "incomplete" that must carry a note is a real case, but no client can ask for it yet: the AI Studio form takes a reason only in its reject dialog, and the request is not authored in the panel. A flag that defaults to `false` can be added without changing any stored request, so it waits for the first form that asks `(follow-up: decision-resume-reason-required)`.
30. **A parked run still decides.** Unlike decisions 21 and 25 this change only loosens the schema, so a run parked under the old rule still decides.

## Rejected

- Detecting the node by its type string: the backend would have to learn every product's vocabulary.
Expand Down Expand Up @@ -86,7 +93,7 @@ A whitespace-only `reason` counts as missing, and "emptied" means `undefined`, `

## Not in this change

Further request fields (condition, four-eyes, several decisions), identity and `x-pii` masking, the pending-decision resource, the rerun loop, the deadline timer, authoring the request in the editor `(follow-up: decision-request-properties-ui)`, and the node that actually parks. The runner learns no product's vocabulary by design, so a run stops where a node's executor returns a waiting result, never because a field is present. The node type whose executor does only that, and therefore waits without side effects of its own, was its own task; it shipped as `apps/execution-worker/src/executors/human-decision.ts`.
Further request fields (condition, four-eyes, several decisions), identity and `x-pii` masking, the pending-decision resource, the rerun loop, the deadline timer, authoring the request in the editor `(follow-up: decision-request-properties-ui)`, and the node that actually parks. The runner learns no product's vocabulary by design, so a run stops where a node's executor returns a waiting result, never because a field is present. The node type whose executor does only that, and therefore waits without side effects of its own, was its own task; it shipped as `apps/execution-worker/src/executors/human-decision.ts`. Offering every resume action to the decider in AI Studio, and a showcase template with two normal outcomes, are the next two slices of the same work.

## Status

Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/domain/decision/decision-issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const DECISION_ISSUE_MESSAGES = {
port_empty: 'port must not be blank',
port_reserved: "port must not be the reserved 'errorRoute'",
port_not_allowed: 'a rerun-source action does not route and takes no port',
reject_port_equals_resume_port: "reject port '{value}' must differ from the resume port",
duplicate_port: "port '{value}' is used by more than one action",
required_field_undeclared: "required field '{value}' is not declared in properties",
deadline_format:
"must be a duration such as '30s' or '3d' (number plus ms, s, m, h or d), above zero and at most '3652500d'",
Expand Down
Loading
Loading