@nanobpm/nano-workforce 0.112.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.
- package/CHANGELOG.md +14 -0
- package/app/agentCompletion.ts +23 -1
- package/app/deliveryConnector.test.ts +215 -0
- package/app/deliveryConnector.ts +210 -0
- package/app/deliveryGraph.ts +2 -2
- package/app/deliveryGraphCompiler.test.ts +35 -13
- package/app/deliveryGraphCompiler.ts +394 -47
- package/app/deliveryHuman.test.ts +306 -0
- package/app/deliveryHuman.ts +384 -0
- package/app/deliveryRunner.test.ts +111 -0
- package/app/deliveryRunner.ts +169 -0
- package/app/userTasks.test.ts +36 -0
- package/app/userTasks.ts +14 -2
- package/db/migrations/055_delivery_connector_dedupe.sql +26 -0
- package/e2e/delivery-graph.e2e.ts +197 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/forms/delivery-human-ack.form +19 -0
- package/resources/forms/delivery-human-generic.form +28 -0
- package/resources/forms/delivery-human-publish.form +28 -0
- package/resources/processes/delivery-human.bpmn +87 -0
- package/test/derivation-parity/derivation-parity.test.ts +11 -3
- package/test/derivation-parity/flows.ts +7 -3
- package/workers/delivery-connector/worker.test.ts +44 -0
- package/workers/delivery-connector/worker.ts +83 -0
|
@@ -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
|
+
}
|
package/app/userTasks.test.ts
CHANGED
|
@@ -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,6 +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, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
22
23
|
import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
|
|
23
24
|
import type { PlanReview } from "./plan.ts";
|
|
24
25
|
import type { TrialMergeAuditRow } from "./trialMerge.ts";
|
|
@@ -76,13 +77,24 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
|
76
77
|
[PR_WAIT_ANSWER_ELEMENT]: "PR review",
|
|
77
78
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
|
|
78
79
|
[CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
|
|
80
|
+
[DELIVERY_HUMAN_ELEMENT]: "Delivery: human step",
|
|
79
81
|
};
|
|
80
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
|
+
|
|
81
93
|
/** The denormalised context the poller has resolved for an open escalation user task. */
|
|
82
94
|
export interface UserTaskContext {
|
|
83
95
|
userTaskKey: string;
|
|
84
96
|
elementId: string;
|
|
85
|
-
subjectType: "feature" | "plan" | "pr";
|
|
97
|
+
subjectType: "feature" | "plan" | "pr" | "delivery";
|
|
86
98
|
subjectKey: string;
|
|
87
99
|
/** The subject's human-readable title from its own row (`feature_runs`/`plans`/`pull_requests`.
|
|
88
100
|
* `title`). Optional/blank tolerated — `buildUserTaskRow` coalesces it to `subjectKey` so the
|
|
@@ -106,7 +118,7 @@ export interface UserTaskContext {
|
|
|
106
118
|
* tasks out of the inbox). */
|
|
107
119
|
export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): UserTaskRow | null {
|
|
108
120
|
const userTaskKey = ctx.userTaskKey.trim();
|
|
109
|
-
const kindLabel =
|
|
121
|
+
const kindLabel = userTaskKindLabel(ctx.elementId);
|
|
110
122
|
if (!userTaskKey || !kindLabel) return null;
|
|
111
123
|
const subjectKey = ctx.subjectKey.trim() || (ctx.processKey ?? "").trim() || userTaskKey;
|
|
112
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "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",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "delivery-human-ack",
|
|
3
|
+
"schemaVersion": 18,
|
|
4
|
+
"type": "default",
|
|
5
|
+
"components": [
|
|
6
|
+
{
|
|
7
|
+
"type": "textarea",
|
|
8
|
+
"key": "prompt",
|
|
9
|
+
"label": "Now do this",
|
|
10
|
+
"description": "The scheduled human step this delivery graph is waiting on. Complete it once done — this node emits nothing (the degenerate click-done case) and simply unblocks its dependents.",
|
|
11
|
+
"readonly": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "textarea",
|
|
15
|
+
"key": "note",
|
|
16
|
+
"label": "Note (optional)"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "delivery-human-generic",
|
|
3
|
+
"schemaVersion": 18,
|
|
4
|
+
"type": "default",
|
|
5
|
+
"components": [
|
|
6
|
+
{
|
|
7
|
+
"type": "textarea",
|
|
8
|
+
"key": "prompt",
|
|
9
|
+
"label": "Now do this",
|
|
10
|
+
"description": "The scheduled human step this delivery graph is waiting on.",
|
|
11
|
+
"readonly": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "textfield",
|
|
15
|
+
"key": "value",
|
|
16
|
+
"label": "Emitted value",
|
|
17
|
+
"description": "The typed value this step hands forward to its downstream dependents. Validated against the node's declared emitted fact.",
|
|
18
|
+
"validate": {
|
|
19
|
+
"required": true
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "textarea",
|
|
24
|
+
"key": "note",
|
|
25
|
+
"label": "Note (optional)"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "delivery-human-publish",
|
|
3
|
+
"schemaVersion": 18,
|
|
4
|
+
"type": "default",
|
|
5
|
+
"components": [
|
|
6
|
+
{
|
|
7
|
+
"type": "textarea",
|
|
8
|
+
"key": "prompt",
|
|
9
|
+
"label": "Now do this (manual publish)",
|
|
10
|
+
"description": "The manual step no automation can cross — e.g. an OTP publish + OIDC trusted-publishing setup. Complete it once the artifact is published.",
|
|
11
|
+
"readonly": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "textfield",
|
|
15
|
+
"key": "resolvedArtifact",
|
|
16
|
+
"label": "Resolved artifact (pkg@version)",
|
|
17
|
+
"description": "The exact published handle to hand forward, e.g. @nanobpm/urban@0.54.0. A downstream capability/npm/pr edge binds and pins this.",
|
|
18
|
+
"validate": {
|
|
19
|
+
"required": true
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "textarea",
|
|
24
|
+
"key": "note",
|
|
25
|
+
"label": "Note (optional)"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|