@nanobpm/nano-workforce 0.113.0 → 0.114.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ // Unit coverage for the delivery-graph RUNNER's PURE prepare step (ADR 0005 slice S4). `prepareDeliveryGraph`
2
+ // compiles a graph, content-addresses its deploy id, rewrites the base process id, and builds the
3
+ // `nodeInputs` seed — all without touching the engine. These tests pin, directly:
4
+ // • the content-addressed id (`delivery-graph-<sha12>`), and that it is DETERMINISTIC (same graph → same
5
+ // id) but CONTENT-SENSITIVE (a different graph → a different id) — the property that makes redeploy
6
+ // idempotent and stale definitions GC-identifiable (the ADR definition-lifecycle open question),
7
+ // • the base process id is rewritten to the content-addressed id in the deployable BPMN,
8
+ // • each node kind seeds the exact `nodeInputs` fields its compiled subProcess ioMapping reads,
9
+ // • a malformed graph returns the S1 compile errors and prepares nothing.
10
+ // The engine-native EXECUTION of a prepared graph (deploy + run + gate + fan-in + late-bind + dedupe) is
11
+ // proven end-to-end in `e2e/delivery-graph.e2e.ts`.
12
+ import { test } from "node:test";
13
+ import { assert, assertEquals } from "#test-assert";
14
+ import { prepareDeliveryGraph, runDeliveryGraph } from "./deliveryRunner.ts";
15
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
16
+
17
+ const GRAPH: DeliveryGraph = {
18
+ name: "release runbook",
19
+ nodes: [
20
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
21
+ { id: "watch-b", kind: "wait", wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" } }, emits: [{ name: "mergedSha", type: "string" }] },
22
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" }, emits: [{ name: "resolvedArtifact", type: "artifact" }] },
23
+ { id: "consume", kind: "connector", connector: { target: "npm:install", dedupeKey: "consume-1" } },
24
+ ],
25
+ edges: [
26
+ { from: "open-b", to: "watch-b" },
27
+ { from: "watch-b.mergedSha", to: "publish" },
28
+ { from: "publish.resolvedArtifact", to: "consume" },
29
+ ],
30
+ };
31
+
32
+ function prepareOk(graph: DeliveryGraph, options = {}) {
33
+ const r = prepareDeliveryGraph(graph, options);
34
+ assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
35
+ return r.prepared;
36
+ }
37
+
38
+ test("content-addressed id: deterministic for the same graph, content-sensitive across graphs", () => {
39
+ const a = prepareOk(GRAPH);
40
+ const b = prepareOk(GRAPH);
41
+ assert(/^delivery-graph-[0-9a-f]{12}$/.test(a.processDefinitionId), `id is content-addressed, got ${a.processDefinitionId}`);
42
+ assertEquals(a.processDefinitionId, b.processDefinitionId);
43
+
44
+ // A structurally different graph gets a DIFFERENT id (no collision / no accidental redeploy-as-same).
45
+ const other = prepareOk({ ...GRAPH, nodes: [...GRAPH.nodes, { id: "extra", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [...GRAPH.edges, { from: "consume", to: "extra" }] });
46
+ assert(other.processDefinitionId !== a.processDefinitionId, "a different graph yields a different id");
47
+ });
48
+
49
+ test("the deployable BPMN rewrites the base process id to the content-addressed deploy id", () => {
50
+ const p = prepareOk(GRAPH);
51
+ assert(p.bpmn.includes(`<bpmn:process id="${p.processDefinitionId}"`), "process id is the content-addressed id");
52
+ assert(!p.bpmn.includes('<bpmn:process id="delivery-graph"'), "the base id no longer appears as the process id");
53
+ });
54
+
55
+ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMapping reads", () => {
56
+ const p = prepareOk(GRAPH, { nodeTimeout: "PT10M", probeTimeout: "PT20M", escalationSlaTimeout: "PT2H", escalationAssignee: "alice", runKey: "run-7" });
57
+ // Element ids are positional by sorted node id: consume, open-b, publish, watch-b → n0..n3.
58
+ const inputs = p.nodeInputs;
59
+ const byField = (pred: (v: Record<string, unknown>) => boolean) => Object.values(inputs).find((v) => pred(v as Record<string, unknown>)) as Record<string, unknown> | undefined;
60
+
61
+ const agent = byField((v) => v.jobType === "senior:feature");
62
+ assertEquals(agent, { jobType: "senior:feature", appendPrompt: "un-draft + merge #B", timeout: "PT10M" });
63
+
64
+ const wait = byField((v) => "gateKey" in v);
65
+ assertEquals(wait?.gateKey, "run-7:n3");
66
+ assertEquals(wait?.probeTimeout, "PT20M");
67
+ assert(wait?.probe && typeof wait.probe === "object", "the wait node carries its ReadinessProbe descriptor");
68
+
69
+ const human = byField((v) => "escalationSlaTimeout" in v);
70
+ assertEquals(human, { escalationSlaTimeout: "PT2H", escalationAssignee: "alice" });
71
+
72
+ const connector = byField((v) => v.target === "npm:install");
73
+ assertEquals(connector, { target: "npm:install", dedupeKey: "consume-1", payload: null, timeout: "PT10M" });
74
+ });
75
+
76
+ test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", () => {
77
+ const gateKeyOf = (p: ReturnType<typeof prepareOk>) =>
78
+ (Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
79
+
80
+ const a = prepareOk(GRAPH);
81
+ const b = prepareOk(GRAPH);
82
+ assert(gateKeyOf(a) && gateKeyOf(b), "each run seeds a wait gateKey");
83
+ assert(gateKeyOf(a) !== gateKeyOf(b), "two runs of the same graph get DISTINCT default gate scopes");
84
+ // The gate key must NOT be derived from the (shared) content digest — that is the bug this guards.
85
+ assert(!gateKeyOf(a)?.startsWith(a.processDefinitionId.slice(-12)), "default gateKey is not the graph digest");
86
+ // The deployable definition (id + bpmn) stays deterministic regardless of the per-run gate scope.
87
+ assertEquals(a.processDefinitionId, b.processDefinitionId);
88
+ assertEquals(a.bpmn, b.bpmn);
89
+
90
+ // An explicit runKey is honoured verbatim (reproducible seed).
91
+ const seeded = prepareOk(GRAPH, { runKey: "run-7" });
92
+ assertEquals(gateKeyOf(seeded), "run-7:n3");
93
+ });
94
+
95
+ test("a malformed graph returns the S1 compile errors and prepares nothing", () => {
96
+ const r = prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
97
+ assert(!r.ok, "a dangling edge fails to prepare");
98
+ assert(r.errors.some((e) => e.path === "edges[0].to"), `expected a dangling-edge error, got ${JSON.stringify(r.errors)}`);
99
+ });
100
+
101
+ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string handle", async () => {
102
+ // The engine can yield a NUMERIC key; the handle is typed `string` and downstream expects a string.
103
+ const engine = {
104
+ deployResources: async () => [],
105
+ createInstance: async () => ({ processInstanceKey: 987654321 as unknown as string }),
106
+ };
107
+ const r = await runDeliveryGraph(engine, GRAPH);
108
+ assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
109
+ assertEquals(r.handle.processInstanceKey, "987654321");
110
+ assertEquals(typeof r.handle.processInstanceKey, "string");
111
+ });
@@ -0,0 +1,169 @@
1
+ // nano-workforce — the delivery-graph RUNNER (ADR 0005 slice S4). The integration step that turns the
2
+ // PURE compiled preview (S1's `compileDeliveryGraph`) into a RUNNING, engine-native process: it deploys
3
+ // the compile-to-native one-shot definition and starts an instance, seeding each node's config so the
4
+ // inlined subProcess bodies (agent/wait/human/connector) delegate to their existing worker / user-task
5
+ // bodies. It builds NO execution machinery of its own (Decision 2 — the graph SCHEDULES; the engine
6
+ // runs it); its whole job is deploy + seed + start.
7
+ //
8
+ // Definition lifecycle (the ADR open question, resolved here): the deployed process id is
9
+ // CONTENT-ADDRESSED — `delivery-graph-<sha256(bpmn)[:12]>`. Identical graphs compile byte-identically
10
+ // (S1 determinism) → identical id → an idempotent redeploy (the engine versions the same id, never a
11
+ // duplicate definition per run); different graphs get different ids and never collide; and because the
12
+ // id ENCODES its content, a stale one-shot definition is GC-identifiable by a later sweeper (out of
13
+ // scope to implement the sweeper — the naming is what enables it). The base id the compiler emits
14
+ // (`DELIVERY_GRAPH_PROCESS_ID`) is the single substitution target, so the runner never hardcodes it.
15
+
16
+ import { createHash, randomUUID } from "node:crypto";
17
+ import type { EngineClient } from "@nanobpm/urban";
18
+ import type { DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
+ import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
+
21
+ /** The bounded-timeout / SLA envelope every node inherits (Decision: bounded → escalate). ISO-8601
22
+ * durations. Defaults are conservative; a caller (the S5 door) may tighten them per run. */
23
+ export interface DeliveryRunTimeouts {
24
+ /** `agent`/`connector` service-node bounded timeout before it escalates onto a human-completable task. */
25
+ nodeTimeout?: string;
26
+ /** `wait` gate poll budget before it escalates (the engine bound; the probe itself is read-only). */
27
+ probeTimeout?: string;
28
+ /** `human` node SLA before it records an `escalated` outcome and settles. */
29
+ escalationSlaTimeout?: string;
30
+ /** Optional explicit assignee for `human` nodes + escalation tasks (else candidate-group routed). */
31
+ escalationAssignee?: string | null;
32
+ }
33
+
34
+ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
35
+ /** A per-run token that scopes each `wait` node's gate key (`<runKey>:<element>`) so two concurrent
36
+ * runs of the same graph never share a gate correlation. Defaults to a fresh random per-run token
37
+ * (`randomUUID()`) — NOT the graph digest, which every run of an identical graph would share and so
38
+ * cross-correlate. Pass an explicit `runKey` only when you need a reproducible/externally-owned gate
39
+ * scope. */
40
+ runKey?: string;
41
+ }
42
+
43
+ const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
44
+ nodeTimeout: "PT30M",
45
+ probeTimeout: "PT30M",
46
+ escalationSlaTimeout: "P1D",
47
+ };
48
+
49
+ /** The per-node config the compiled subProcess ioMappings read from `nodeInputs.<element>`. A closed
50
+ * union mirrored by the compiler's `ioMappingLines` — the two must agree on field names (a drift here
51
+ * silently seeds `null` into a node body), so both derive from the same node kinds. */
52
+ type NodeInput =
53
+ | { jobType: string; appendPrompt: string; timeout: string }
54
+ | { gateKey: string; probe: unknown; probeTimeout: string }
55
+ | { escalationSlaTimeout: string; escalationAssignee: string | null }
56
+ | { target: string; dedupeKey: string | null; payload: Record<string, unknown> | null; timeout: string };
57
+
58
+ /** The result of compiling + preparing a graph for deployment: the content-addressed process id, the
59
+ * deployable BPMN (base id rewritten), and the seeded `nodeInputs` map — everything `runDeliveryGraph`
60
+ * needs, exposed separately so a caller can deploy/inspect without starting an instance. */
61
+ export interface PreparedDeliveryGraph {
62
+ processDefinitionId: string;
63
+ bpmn: string;
64
+ nodeInputs: Record<string, NodeInput>;
65
+ }
66
+
67
+ export type PrepareDeliveryResult =
68
+ | { ok: true; prepared: PreparedDeliveryGraph }
69
+ | { ok: false; errors: { path: string; message: string }[] };
70
+
71
+ /** A live delivery-graph run: the deployed definition + the started instance + the seed it ran with. */
72
+ export interface DeliveryRunHandle extends PreparedDeliveryGraph {
73
+ processInstanceKey: string;
74
+ }
75
+
76
+ export type RunDeliveryResult =
77
+ | { ok: true; handle: DeliveryRunHandle }
78
+ | { ok: false; errors: { path: string; message: string }[] };
79
+
80
+ /** Compile a graph and prepare it for deployment WITHOUT touching the engine: content-address its id,
81
+ * rewrite the base process id, and build the `nodeInputs` seed. The deployable DEFINITION (the
82
+ * content-addressed `processDefinitionId` and the `bpmn`) is deterministic — the same graph yields the
83
+ * same id, which is what makes redeploy idempotent — because the gate scope lives in `nodeInputs`
84
+ * (runtime instance variables), not in the BPMN. The default `runKey` is a fresh random per-run token,
85
+ * so each call's `wait` `gateKey`s differ (two concurrent runs of the same graph never cross-correlate);
86
+ * pass an explicit `runKey` for a reproducible seed. Returns the S1 compile errors verbatim for a
87
+ * malformed graph. */
88
+ export function prepareDeliveryGraph(graph: DeliveryGraph, options: DeliveryRunOptions = {}): PrepareDeliveryResult {
89
+ const compiled = compileDeliveryGraph(graph);
90
+ if (!compiled.ok) return { ok: false, errors: compiled.errors };
91
+
92
+ const digest = createHash("sha256").update(compiled.bpmn).digest("hex").slice(0, 12);
93
+ const processDefinitionId = `${DELIVERY_GRAPH_PROCESS_ID}-${digest}`;
94
+ const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
95
+
96
+ const runKey = options.runKey?.trim() || randomUUID();
97
+ const timeouts = {
98
+ nodeTimeout: options.nodeTimeout ?? DEFAULTS.nodeTimeout,
99
+ probeTimeout: options.probeTimeout ?? DEFAULTS.probeTimeout,
100
+ escalationSlaTimeout: options.escalationSlaTimeout ?? DEFAULTS.escalationSlaTimeout,
101
+ escalationAssignee: options.escalationAssignee ?? null,
102
+ };
103
+ const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
104
+ const nodeInputs: Record<string, NodeInput> = {};
105
+ for (const node of graph.nodes) {
106
+ const element = elementByNodeId.get(node.id);
107
+ if (element === undefined) continue; // unreachable — resolved covers every node — but keep total.
108
+ nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts });
109
+ }
110
+ return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs } };
111
+ }
112
+
113
+ /** Deploy + start a compiled graph as a running engine-native instance. Idempotent at the DEFINITION
114
+ * level (content-addressed id — redeploying the same graph re-uses the definition); each call still
115
+ * starts a fresh INSTANCE (a distinct run of that definition). Returns the run handle, or the compile
116
+ * errors for a malformed graph (the engine is never touched in that case). */
117
+ export async function runDeliveryGraph(
118
+ engine: Pick<EngineClient, "deployResources" | "createInstance">,
119
+ graph: DeliveryGraph,
120
+ options: DeliveryRunOptions = {},
121
+ ): Promise<RunDeliveryResult> {
122
+ const prep = prepareDeliveryGraph(graph, options);
123
+ if (!prep.ok) return prep;
124
+ const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
125
+
126
+ await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
127
+ const { processInstanceKey } = await engine.createInstance({
128
+ processDefinitionId,
129
+ variables: { nodeInputs },
130
+ });
131
+ // The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
132
+ // downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
133
+ return {
134
+ ok: true,
135
+ handle: { processDefinitionId, bpmn, nodeInputs, processInstanceKey: String(processInstanceKey) },
136
+ };
137
+ }
138
+
139
+ /** Rewrite the compiled BPMN's base `bpmn:process` id to the content-addressed deploy id. The base id
140
+ * appears exactly once — as the process element's `id` attribute (element ids are `n<i>`/`gw*`/`Start`/
141
+ * `End`, never the process id) — so a single targeted replacement is unambiguous. */
142
+ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
143
+ return bpmn.replace(`id="${DELIVERY_GRAPH_PROCESS_ID}"`, `id="${processDefinitionId}"`);
144
+ }
145
+
146
+ /** Build the `nodeInputs.<element>` seed for one node, per its kind — the exact fields the compiled
147
+ * subProcess ioMapping pulls. Total over the closed kind set. */
148
+ function buildNodeInput(
149
+ node: DeliveryNode,
150
+ ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; escalationSlaTimeout: string; escalationAssignee: string | null },
151
+ ): NodeInput {
152
+ switch (node.kind) {
153
+ case "agent":
154
+ return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: ctx.nodeTimeout };
155
+ case "wait":
156
+ return { gateKey: `${ctx.runKey}:${ctx.element}`, probe: node.wait, probeTimeout: ctx.probeTimeout };
157
+ case "human":
158
+ return { escalationSlaTimeout: ctx.escalationSlaTimeout, escalationAssignee: ctx.escalationAssignee };
159
+ case "connector":
160
+ return {
161
+ target: node.connector.target,
162
+ dedupeKey: node.connector.dedupeKey ?? null,
163
+ payload: node.connector.payload ?? null,
164
+ timeout: ctx.nodeTimeout,
165
+ };
166
+ default:
167
+ return assertNever(node, "buildNodeInput");
168
+ }
169
+ }
@@ -6,6 +6,7 @@
6
6
  import { test } from "node:test";
7
7
  import { assert, assertEquals } from "#test-assert";
8
8
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
9
+ import { DELIVERY_HUMAN_ELEMENT } from "./deliveryHuman.ts";
9
10
  import type { PlanReview } from "./plan.ts";
10
11
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
11
12
  import {
@@ -19,6 +20,7 @@ import {
19
20
  type PrEscalationRow,
20
21
  reconcileUserTasks,
21
22
  TRIAL_MERGE_ELEMENT,
23
+ userTaskKindLabel,
22
24
  type UserTaskRow,
23
25
  } from "./userTasks.ts";
24
26
 
@@ -91,6 +93,40 @@ test("buildUserTaskRow: an unknown (non-escalation) element yields null — no a
91
93
  assertEquals(row, null);
92
94
  });
93
95
 
96
+ test("userTaskKindLabel/buildUserTaskRow: an INLINED delivery-human element surfaces under one label; a non-matching internal task is dropped (app/userTasks.ts:122)", () => {
97
+ // The S4 compiler inlines each `human` node (and its bounded-timeout escalation twin) with a
98
+ // per-node id `delivery-human-task__<el>[__esc]`, which a bare `USER_TASK_KIND_LABELS` lookup would
99
+ // miss — so the delivery-human convention MUST be recognised through `isDeliveryHumanElement`.
100
+ const LABEL = "Delivery: human step";
101
+ // The bare static body id and both inlined conventions resolve to the one delivery-human label…
102
+ assertEquals(userTaskKindLabel(DELIVERY_HUMAN_ELEMENT), LABEL);
103
+ assertEquals(userTaskKindLabel(`${DELIVERY_HUMAN_ELEMENT}__ship-it`), LABEL);
104
+ assertEquals(userTaskKindLabel(`${DELIVERY_HUMAN_ELEMENT}__ship-it__esc`), LABEL);
105
+ // …while a non-matching internal task (a near-miss that is NOT the delivery-human prefix) is not a
106
+ // surfaced kind, so it never leaks into the inbox.
107
+ assertEquals(userTaskKindLabel("delivery-human-taskish"), undefined);
108
+ assertEquals(userTaskKindLabel("some-internal-task"), undefined);
109
+
110
+ // And end-to-end through the row builder: an inlined delivery-human task projects a labelled row…
111
+ const inlined = buildUserTaskRow(
112
+ { userTaskKey: "ut-dh", elementId: `${DELIVERY_HUMAN_ELEMENT}__ship-it`, subjectType: "delivery", subjectKey: "graph-1", question: "approve the release?" },
113
+ AT,
114
+ );
115
+ assert(inlined !== null);
116
+ assertEquals(inlined?.element_id, `${DELIVERY_HUMAN_ELEMENT}__ship-it`);
117
+ assertEquals(inlined?.kind_label, LABEL);
118
+ assertEquals(inlined?.subject_type, "delivery");
119
+ assertEquals(inlined?.question, "approve the release?");
120
+ // …whereas a non-matching internal task still yields null (the leak guard holds for the near-miss).
121
+ assertEquals(
122
+ buildUserTaskRow(
123
+ { userTaskKey: "ut-dh2", elementId: "delivery-human-taskish", subjectType: "delivery", subjectKey: "graph-1" },
124
+ AT,
125
+ ),
126
+ null,
127
+ );
128
+ });
129
+
94
130
  test("buildUserTaskRow: a blank userTaskKey yields null; a known kind with a blank subject key still builds (issue #358)", () => {
95
131
  // The completable key is load-bearing (the page completes THROUGH it), so a blank one is still null.
96
132
  assertEquals(
package/app/userTasks.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  // it open.
20
20
  import type { DataLayer } from "@nanobpm/urban";
21
21
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
22
- import { DELIVERY_HUMAN_ELEMENT } from "./deliveryHuman.ts";
22
+ import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
23
23
  import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
24
24
  import type { PlanReview } from "./plan.ts";
25
25
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
@@ -80,6 +80,16 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
80
80
  [DELIVERY_HUMAN_ELEMENT]: "Delivery: human step",
81
81
  };
82
82
 
83
+ /** The Tasks-inbox label for an open user-task `elementId`, or `undefined` when the element is not a
84
+ * surfaced kind (so an arbitrary internal user task is never listed). Exact table lookup PLUS the
85
+ * delivery-human convention: the S4 compiler inlines each `human` node (and each service node's
86
+ * bounded-timeout escalation twin) with a per-node id `delivery-human-task__<el>[__esc]`, which a bare
87
+ * table lookup would miss — matched through the single-source-of-truth `isDeliveryHumanElement`
88
+ * predicate so every inlined human/escalation task surfaces under the one delivery-human label. */
89
+ export function userTaskKindLabel(elementId: string): string | undefined {
90
+ return USER_TASK_KIND_LABELS[elementId] ?? (isDeliveryHumanElement(elementId) ? USER_TASK_KIND_LABELS[DELIVERY_HUMAN_ELEMENT] : undefined);
91
+ }
92
+
83
93
  /** The denormalised context the poller has resolved for an open escalation user task. */
84
94
  export interface UserTaskContext {
85
95
  userTaskKey: string;
@@ -108,7 +118,7 @@ export interface UserTaskContext {
108
118
  * tasks out of the inbox). */
109
119
  export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): UserTaskRow | null {
110
120
  const userTaskKey = ctx.userTaskKey.trim();
111
- const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
121
+ const kindLabel = userTaskKindLabel(ctx.elementId);
112
122
  if (!userTaskKey || !kindLabel) return null;
113
123
  const subjectKey = ctx.subjectKey.trim() || (ctx.processKey ?? "").trim() || userTaskKey;
114
124
  const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
@@ -0,0 +1,26 @@
1
+ -- The delivery-graph `connector` node's idempotency ledger (ADR 0005 Decision 6/7, slice S4). A
2
+ -- `connector` is the epic's one SIDE-EFFECTING node kind (`agent`/`wait`/`human` are read-only or
3
+ -- human-gated): it drives an outbound action against the forward-declared connector I/O surface. The
4
+ -- engine delivers a service-task job AT-LEAST-ONCE — a worker/hub restart, a lost completion ack, or a
5
+ -- graph resume re-activates the same job — so a naive connector would double-fire its side effect on
6
+ -- every redelivery. This ledger makes each dispatch fire AT-MOST-ONCE per dedupe key: the worker
7
+ -- claims the key here BEFORE performing the action, and a redelivery that finds the key already
8
+ -- claimed short-circuits to the recorded outcome instead of re-dispatching.
9
+ --
10
+ -- The UNIQUE fence on `dedupe_key` is the durable at-most-once guarantee (the canonical durable-fence
11
+ -- idiom — cf. `ux_merges_abandon_pr_closed` in 053 and the world-store ledgers in 049): a concurrent
12
+ -- redelivery that races the claim loses the insert and is classified by `app/dbFence.ts`
13
+ -- (`isUniqueConstraintFence`) as the SAME idempotent outcome, never a spurious job failure. The key is
14
+ -- author-supplied (`connector.dedupeKey`) or graph-derived (`<processInstanceKey>:<elementId>`) — both
15
+ -- stable across a re-activation of the same node instance.
16
+ CREATE TABLE IF NOT EXISTS delivery_connector_dispatches (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ dedupe_key TEXT NOT NULL,
19
+ target TEXT NOT NULL,
20
+ outcome TEXT NOT NULL,
21
+ detail TEXT,
22
+ dispatched_at TEXT NOT NULL
23
+ );
24
+
25
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_delivery_connector_dedupe
26
+ ON delivery_connector_dispatches (dedupe_key);
@@ -0,0 +1,197 @@
1
+ // End-to-end proof that a COMPILED delivery graph deploys and runs ENGINE-NATIVELY on the WASM engine
2
+ // + virtual clock (ADR 0005 slice S4) — the integration acceptance the whole slice hinges on. Driven
3
+ // via `bootTestApp`, hermetic (deterministic shell-builtin `command` probes, no network, no GitHub;
4
+ // the `pr` kind's merge-state semantics are S2's surface, proven there — S4 proves the wait NODE
5
+ // executes engine-natively and gates, whatever the probe kind):
6
+ //
7
+ // • RUNS END-TO-END + FAN-IN + LATE-BIND: a graph with `agent`, `wait`, `human` and `connector`
8
+ // nodes deploys and runs; the agent job fires, the wait gate resolves, the human task completes,
9
+ // the connector fires — and the graph reaches End only after the wait AND the human both feed the
10
+ // connector (fan-in). The human's emitted `artifact` fact LATE-BINDS into the connector's input.
11
+ // • RESUME NEVER DOUBLE-FIRES: after the connector has fired once, an at-least-once redelivery of the
12
+ // same dispatch (a resume) DEDUPES — the durable ledger still holds exactly one row (Decision 7).
13
+ // • CONCURRENCY-CORRECTNESS: while a `wait` is parked on a never-green probe, completing an UNRELATED
14
+ // parallel human node does NOT falsely resolve the wait (the node polls its OWN target — there is no
15
+ // shared message correlation an unrelated event could trip, inheriting #274/S2); the wait stays
16
+ // parked until its bounded budget elapses, then escalates (bounded → escalate, never wedged).
17
+ import { mkdtempSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join, resolve } from "node:path";
20
+ import { after, before, describe, test } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
23
+ import { connectorDedupeKey, deliveryConnectorDispatches, dispatchConnector } from "../app/deliveryConnector.ts";
24
+ import { readConnectorInput } from "../workers/delivery-connector/worker.ts";
25
+ import { runDeliveryGraph } from "../app/deliveryRunner.ts";
26
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
27
+
28
+ const APP_ROOT = resolve(import.meta.dirname, "..");
29
+ const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
30
+
31
+ interface TakenFlow {
32
+ from: string;
33
+ to: string;
34
+ }
35
+ function takenFlows(app: TestApp): string[] {
36
+ const snap = app.snapshot();
37
+ const flows = Array.isArray(snap.takenSequenceFlows) ? snap.takenSequenceFlows : [];
38
+ return flows
39
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
40
+ .map((f) => `${f.from}->${f.to}`);
41
+ }
42
+
43
+ /** Boot a fresh app per scenario (the WASM engine's taken-flow snapshot is engine-global cumulative). */
44
+ async function boot(dir: string): Promise<TestApp> {
45
+ return bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
46
+ }
47
+
48
+ describe("delivery-graph runner — engine-native execution (S4)", () => {
49
+ const dirs: string[] = [];
50
+ const apps: TestApp[] = [];
51
+ const freshDir = (): string => {
52
+ const d = mkdtempSync(join(tmpdir(), "nwf-delivery-e2e-"));
53
+ dirs.push(d);
54
+ return d;
55
+ };
56
+ const track = (app: TestApp): TestApp => {
57
+ apps.push(app);
58
+ return app;
59
+ };
60
+ after(async () => {
61
+ for (const app of apps) await app.stop?.();
62
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
63
+ });
64
+
65
+ test("runs end-to-end: agent, wait, human execute; edges gate; fan-in works; human fact late-binds into the connector", async () => {
66
+ const app = track(await boot(freshDir()));
67
+
68
+ let agentFired = 0;
69
+ let connectorBoundFacts: unknown;
70
+ await app.engine.registerWorker("senior:demo", async () => {
71
+ agentFired++;
72
+ return {};
73
+ });
74
+ // Wrap the REAL connector job path so we can observe the late-bound facts it received. The worker
75
+ // itself is registered from the manifest; here we register a same-type observer stub for the e2e.
76
+ // Mirror the REAL worker's normalization — `readConnectorInput` (trim+require `target`, coerce a
77
+ // wrong-shaped payload/boundFacts) and `connectorDedupeKey` (derive the effective key from the
78
+ // author key OR the engine identity `processInstanceKey:elementId`, fail closed if neither) — so
79
+ // this observer exercises the same fail-closed/derivation behavior the production worker does and
80
+ // a regression in that surface can't hide behind a `String(... ?? "")` coercion.
81
+ await app.engine.registerWorker(
82
+ "pr.delivery-connector",
83
+ async (job) => {
84
+ const vars = job.variables as Record<string, unknown>;
85
+ connectorBoundFacts = vars.boundFacts;
86
+ const { target, payload, boundFacts } = readConnectorInput(
87
+ vars as Parameters<typeof readConnectorInput>[0],
88
+ );
89
+ const dedupeKey = connectorDedupeKey({
90
+ dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
91
+ processInstanceKey: job.processInstanceKey ?? null,
92
+ elementId: job.elementId ?? null,
93
+ });
94
+ if (!dedupeKey) {
95
+ throw new Error("delivery-connector: no dedupe key (author-supplied or graph-derived) available");
96
+ }
97
+ return await dispatchConnector(
98
+ app.db,
99
+ { dedupeKey, target, payload, boundFacts },
100
+ new Date().toISOString(),
101
+ );
102
+ },
103
+ { fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
104
+ );
105
+
106
+ const graph: DeliveryGraph = {
107
+ name: "e2e end-to-end",
108
+ nodes: [
109
+ { id: "a", kind: "agent", agent: { jobType: "senior:demo" } },
110
+ { id: "w", kind: "wait", wait: { kind: "command", target: "true", poll: { everyMs: 5, backoff: "fixed" } } },
111
+ { id: "h", kind: "human", emits: [{ name: "art", type: "artifact" }] },
112
+ { id: "c", kind: "connector", connector: { target: "slack", dedupeKey: "c-e2e-1" } },
113
+ ],
114
+ edges: [
115
+ { from: "a", to: "h" },
116
+ { from: "h.art", to: "c" },
117
+ { from: "w", to: "c" },
118
+ ],
119
+ };
120
+
121
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S" });
122
+ assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
123
+ await app.settle();
124
+
125
+ // The agent node executed via its engine-native serviceTask body.
126
+ assert.equal(agentFired, 1, "the agent node's job fired once");
127
+
128
+ // The human node scheduled its per-node user task (the isDeliveryHumanElement convention id).
129
+ const open = await app.engine.searchUserTasks({ state: "CREATED" });
130
+ const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
131
+ assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
132
+
133
+ // Before the human completes, the connector has NOT fired — the fan-in edge from `h` gates it.
134
+ assert.equal((await deliveryConnectorDispatches(app.db).find({})).length, 0, "connector waits on the human edge");
135
+
136
+ // Complete the human with a resolved artifact — its typed emit late-binds downstream.
137
+ await app.engine.completeUserTask(human.userTaskKey, { resolvedArtifact: "ARTIFACT-1", humanOutcome: "completed" });
138
+ await app.settle();
139
+
140
+ // The connector fired exactly once (fan-in of the wait AND the human both satisfied), and it
141
+ // received the human's emitted fact as a late-bound input.
142
+ const rows = await deliveryConnectorDispatches(app.db).find({ dedupe_key: "c-e2e-1" });
143
+ assert.equal(rows.length, 1, "the connector fired exactly once");
144
+ assert.equal(rows[0].outcome, "delivered");
145
+ assert.deepEqual(connectorBoundFacts, [{ from: "h", name: "art", value: "ARTIFACT-1" }], "the human fact late-binds into the connector");
146
+
147
+ // The graph reached End — the fan-in join released only after BOTH upstream branches completed.
148
+ assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the graph reached its End event");
149
+ });
150
+
151
+ test("resume never double-fires: an at-least-once redelivery of the connector dedupes", async () => {
152
+ const app = track(await boot(freshDir()));
153
+ // The connector fired once above's-style; here prove the idempotency directly against the ledger a
154
+ // resumed graph shares. First dispatch delivers; a redelivery of the SAME dispatch (the resume) is
155
+ // deduped and the durable ledger still holds exactly ONE row — the side effect never re-fires.
156
+ const first = await dispatchConnector(app.db, { dedupeKey: "resume-1", target: "slack" }, new Date().toISOString());
157
+ assert.equal(first.connectorOutcome, "delivered");
158
+ const replay = await dispatchConnector(app.db, { dedupeKey: "resume-1", target: "slack" }, new Date().toISOString());
159
+ assert.equal(replay.connectorOutcome, "deduped", "a resume redelivery dedupes");
160
+ assert.equal((await deliveryConnectorDispatches(app.db).find({ dedupe_key: "resume-1" })).length, 1, "exactly one durable dispatch");
161
+ });
162
+
163
+ test("concurrency-correctness: an unrelated human completion does not falsely resolve a parked wait", async () => {
164
+ const app = track(await boot(freshDir()));
165
+ // Two independent parallel branches: a NEVER-GREEN wait, and an unrelated human. The wait polls its
166
+ // own `false` target (never ready) — there is NO shared correlation an unrelated event could trip.
167
+ const graph: DeliveryGraph = {
168
+ name: "e2e concurrency",
169
+ nodes: [
170
+ { id: "gate", kind: "wait", wait: { kind: "command", target: "false", poll: { everyMs: 5, backoff: "fixed" } } },
171
+ { id: "side", kind: "human", emits: [{ name: "ok", type: "string" }] },
172
+ ],
173
+ edges: [],
174
+ };
175
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", escalationSlaTimeout: "PT1H" });
176
+ assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
177
+ await app.settle();
178
+
179
+ // Complete the UNRELATED human node — an upstream event with no edge to the wait.
180
+ const open = await app.engine.searchUserTasks({ state: "CREATED" });
181
+ const side = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
182
+ assert.ok(side, "the unrelated human task is open");
183
+ await app.engine.completeUserTask(side.userTaskKey, { value: "done", humanOutcome: "completed" });
184
+ await app.settle();
185
+
186
+ // The wait polls `false` — it can NEVER resolve as ready, so completing the unrelated human could
187
+ // not trip it: the graph never reaches End (the wait never released its "ready" branch). Instead the
188
+ // wait is BOUNDED — its poll budget elapses and it escalates onto a human-completable task, parking
189
+ // for a human rather than silently wedging or falsely resolving.
190
+ assert.ok(!takenFlows(app).some((f) => f.endsWith("->End")), "the wait branch never falsely resolves to End");
191
+ const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.endsWith("__esc"));
192
+ assert.ok(
193
+ esc.length >= 1,
194
+ `the parked wait escalates (bounded), never falsely resolved by the unrelated event, got ${JSON.stringify((await app.engine.searchUserTasks({ state: "CREATED" })).map((t) => t.elementId))}`,
195
+ );
196
+ });
197
+ });
package/nano.app.json CHANGED
@@ -199,6 +199,10 @@
199
199
  {
200
200
  "taskType": "pr.readiness-probe",
201
201
  "handler": "workers/readiness-probe/worker.ts"
202
+ },
203
+ {
204
+ "taskType": "pr.delivery-connector",
205
+ "handler": "workers/delivery-connector/worker.ts"
202
206
  }
203
207
  ],
204
208
  "externalTaskTypes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.113.0",
3
+ "version": "0.114.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",