@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,384 @@
|
|
|
1
|
+
// nano-workforce — the `human` delivery-graph node (ADR 0005, slice S3). A `human` node promotes
|
|
2
|
+
// ADR 0002's user-task+form machinery from an *exception* (something broke) to a *scheduled node* (a
|
|
3
|
+
// planned "now do X" stop that BLOCKS its dependents, is answerable by a human OR an agent — ADR 0046
|
|
4
|
+
// — is SLA-bounded so it nags and cannot silently wedge the graph, and can EMIT a typed fact its form
|
|
5
|
+
// captures which late-binds downstream). It is the emit-side of #263's emit-vs-poll dual: a *human*
|
|
6
|
+
// emitter is the same shape as an automated one (the `capability`/`pr` probe's `resolvedArtifact`
|
|
7
|
+
// bind), which is what unifies human and automated steps in one graph.
|
|
8
|
+
//
|
|
9
|
+
// This module owns the PURE, side-effect-free machinery the compiler (S1, author-time) and the
|
|
10
|
+
// runner (S4, runtime) reuse — it never itself creates a user task or completes one. Execution stays
|
|
11
|
+
// engine-native (Decision 2): the human node's body is the deployed `delivery-human` `bpmn:userTask`
|
|
12
|
+
// (`resources/processes/delivery-human.bpmn`, an SLA-bounded scheduled user task), completion routes
|
|
13
|
+
// through the ONE canonical completer (`completeEscalationAsAgent`/`completeEscalationAsHuman`,
|
|
14
|
+
// `app/agentCompletion.ts`) because `DELIVERY_HUMAN_ELEMENT` is registered in
|
|
15
|
+
// `ESCALATION_TASK_ELEMENTS`, and the emitted fact threads downstream via the #289 brief-appender /
|
|
16
|
+
// bound-output pattern (`renderResolvedDepsBrief` / the `caps-resolved` → `appendPrompt` recipe).
|
|
17
|
+
//
|
|
18
|
+
// The three pure concerns:
|
|
19
|
+
// 1. FORM RESOLUTION — specific-else-generic (Decision 4): an explicit `human.formKey` on the node,
|
|
20
|
+
// else a form SELECTED by node category (derived from the node's typed emits), else a GENERIC
|
|
21
|
+
// fallback form that STILL captures a typed emitted fact — so every human node can emit even with
|
|
22
|
+
// no bespoke form. A runtime agent-form-router is a GATED exception: it fires ONLY when a node
|
|
23
|
+
// activates with no statically resolvable form (multiple heterogeneous emits, no explicit/bespoke
|
|
24
|
+
// form), never in the common path — the same "deterministic default, agent judgment as the escape
|
|
25
|
+
// hatch" grain as the capability probe's empirical verifier.
|
|
26
|
+
// 2. TYPED EMIT — validate + coerce the completed form output against the node's declared `emits[]`
|
|
27
|
+
// (Decision 3/4 — binds are validated, not stringly), producing the typed facts the node hands
|
|
28
|
+
// forward. A "click done" node declares no emits — the degenerate no-emit case.
|
|
29
|
+
// 3. LATE-BINDING — render the emitted facts into a downstream node's brief (`renderHumanEmitBrief`,
|
|
30
|
+
// mirroring `renderResolvedDepsBrief`) and into a qualified bind map keyed `<nodeId>.<fact>`
|
|
31
|
+
// (`humanEmitBind`, mirroring the probe `bind`) so a downstream `capability`/`npm`/`pr` edge
|
|
32
|
+
// binds and pins exactly the value the human handed forward.
|
|
33
|
+
import type { DeliveryFact, DeliveryNodeHuman } from "../nano-generated/api-io.d.ts";
|
|
34
|
+
import { DELIVERY_FACT_TYPES, type DeliveryFactType, FACT_NAME_MAX_LENGTH, FACT_NAME_PATTERN } from "./deliveryGraph.ts";
|
|
35
|
+
|
|
36
|
+
/** The single BPMN `bpmn:userTask` element id every `human` delivery-graph node schedules its work
|
|
37
|
+
* as (`resources/processes/delivery-human.bpmn`). One reusable engine-native body, instantiated once
|
|
38
|
+
* per human node (mirroring the one `readiness-gate` process instantiated per `wait` node). It is
|
|
39
|
+
* registered in `ESCALATION_TASK_ELEMENTS` (`app/agentCompletion.ts`) so a human OR an agent
|
|
40
|
+
* (ADR 0046) can complete it through the ONE canonical `complete-user-task` / `agent-complete` door,
|
|
41
|
+
* and in `USER_TASK_KIND_LABELS` (`app/userTasks.ts`) so it surfaces on the Tasks inbox. */
|
|
42
|
+
export const DELIVERY_HUMAN_ELEMENT = "delivery-human-task";
|
|
43
|
+
|
|
44
|
+
/** True for any user-task element id a delivery-graph `human` node schedules. The S3 static body
|
|
45
|
+
* (`delivery-human.bpmn`) uses the bare `delivery-human-task` id, but the S4 compiler INLINES the
|
|
46
|
+
* human body once per node into the one-shot process (embedded sub-processes — call activities are a
|
|
47
|
+
* no-op on the pinned WASM engine, so a single shared static instance is not reusable), giving each a
|
|
48
|
+
* UNIQUE convention id `delivery-human-task__<element>` (and its bounded-timeout escalation twin
|
|
49
|
+
* `delivery-human-task__<element>__esc`). Exact-membership routing on the bare id would drop these, so
|
|
50
|
+
* every surface that recognises a delivery human task (completion routing in `agentCompletion.ts`, the
|
|
51
|
+
* Tasks-inbox label in `userTasks.ts`) matches through THIS one predicate — the single source of truth
|
|
52
|
+
* for the id convention, so the compiler's id form and the routers can never drift apart. */
|
|
53
|
+
export function isDeliveryHumanElement(elementId: string): boolean {
|
|
54
|
+
return elementId === DELIVERY_HUMAN_ELEMENT || elementId.startsWith(`${DELIVERY_HUMAN_ELEMENT}__`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The GENERIC fallback form (Decision 4, step 3): captures ONE typed value into the node's single
|
|
58
|
+
* declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
|
|
59
|
+
export const GENERIC_HUMAN_FORM = "delivery-human-generic";
|
|
60
|
+
|
|
61
|
+
/** The "click done" category form: a degenerate no-emit acknowledgement ("now do X" → done). */
|
|
62
|
+
export const HUMAN_ACK_FORM = "delivery-human-ack";
|
|
63
|
+
|
|
64
|
+
/** The manual-publish category form: captures a `resolvedArtifact` (`pkg@version`) — the motivating
|
|
65
|
+
* case where a human hands a just-published version forward to a downstream `capability`/`npm`/`pr`
|
|
66
|
+
* edge. */
|
|
67
|
+
export const HUMAN_PUBLISH_FORM = "delivery-human-publish";
|
|
68
|
+
|
|
69
|
+
/** A human node's derived CATEGORY — a coarse classification of what the node emits, used to SELECT a
|
|
70
|
+
* bespoke form (Decision 4, step 2) without the author naming one. Derived from the node's typed
|
|
71
|
+
* `emits[]` so the same typed-fact declaration that drives late-binding also drives form selection —
|
|
72
|
+
* one source of truth, no second author-facing knob (the frozen S0 `human` config carries only
|
|
73
|
+
* `formKey`/`prompt`). `null` ⇒ no bespoke category form applies (fall through to generic/router). */
|
|
74
|
+
export type HumanNodeCategory = "ack" | "publish";
|
|
75
|
+
|
|
76
|
+
/** Category → its bespoke form. Kept as the single source of truth for "which form a category selects"
|
|
77
|
+
* so {@link resolveHumanForm} and any preview/compile step agree. */
|
|
78
|
+
export const HUMAN_CATEGORY_FORMS: Readonly<Record<HumanNodeCategory, string>> = {
|
|
79
|
+
ack: HUMAN_ACK_FORM,
|
|
80
|
+
publish: HUMAN_PUBLISH_FORM,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** How a human node's form was resolved. `explicit` — the node named a `human.formKey`; `category` —
|
|
84
|
+
* a form was selected by the node's derived category; `generic` — the typed-emit-capturing fallback;
|
|
85
|
+
* `agent-router` — NOTHING statically resolved, so a runtime agent must pick/assemble one (the gated
|
|
86
|
+
* exception). */
|
|
87
|
+
export type HumanFormSource = "explicit" | "category" | "generic" | "agent-router";
|
|
88
|
+
|
|
89
|
+
/** The resolved form for a human node (Decision 4). `formKey` is the `.form` to attach — `null` ONLY
|
|
90
|
+
* for `agent-router`, where the runtime supplies it. `category` records the derived category when one
|
|
91
|
+
* applied. `reason` is a human-readable one-liner for the compiled preview. */
|
|
92
|
+
export interface HumanFormResolution {
|
|
93
|
+
readonly source: HumanFormSource;
|
|
94
|
+
readonly formKey: string | null;
|
|
95
|
+
readonly category: HumanNodeCategory | null;
|
|
96
|
+
readonly emits: readonly DeliveryFact[];
|
|
97
|
+
readonly reason: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A typed fact a completed human node hands forward — its declared `name`/`type` plus the validated,
|
|
101
|
+
* canonically-serialised `value` (a string, like the probe `bind`, so it threads uniformly into
|
|
102
|
+
* prompts, messages, and qualified bind maps). */
|
|
103
|
+
export interface BoundFact {
|
|
104
|
+
readonly name: string;
|
|
105
|
+
readonly type: DeliveryFactType;
|
|
106
|
+
readonly value: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The outcome of binding a completed form's output to a node's declared emits: the typed `facts` in
|
|
110
|
+
* declaration order plus any path-qualified `errors` (a missing/ill-typed declared fact). An empty
|
|
111
|
+
* `emits` yields `{ facts: [], errors: [] }` — the degenerate no-emit "click done" case. */
|
|
112
|
+
export interface HumanEmitResult {
|
|
113
|
+
readonly facts: readonly BoundFact[];
|
|
114
|
+
readonly errors: readonly string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Narrow an untyped value to a plain object so its fields can be read defensively. */
|
|
118
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
119
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** True when `value` is one of the closed set of delivery fact types (a type guard, so the pure
|
|
123
|
+
* form/emit logic narrows without an `as` assertion). */
|
|
124
|
+
function isDeliveryFactType(value: unknown): value is DeliveryFactType {
|
|
125
|
+
return typeof value === "string" && DELIVERY_FACT_TYPES.some((t) => t === value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Normalise a human node's `emits[]` to the well-formed, typed declarations, dropping anything the
|
|
129
|
+
* S0 validator would already have rejected (a non-record entry, a blank name, a name that is not a
|
|
130
|
+
* dot-free identifier within the 128-char cap, a duplicate name, or an unknown type) so the pure
|
|
131
|
+
* form/emit logic never trips on a malformed declaration — the graph is validated upstream by
|
|
132
|
+
* `validateDeliveryGraph`, this is defence in depth. Fact-name pattern/length are the SAME canonical
|
|
133
|
+
* constants the validator enforces (`FACT_NAME_PATTERN`/`FACT_NAME_MAX_LENGTH`), so a dotted /
|
|
134
|
+
* over-long / duplicate name can't slip through here and make `<nodeId>.<fact>` binding ambiguous. */
|
|
135
|
+
export function normalizeEmits(node: Pick<DeliveryNodeHuman, "emits">): DeliveryFact[] {
|
|
136
|
+
const raw = node.emits;
|
|
137
|
+
if (!Array.isArray(raw)) return [];
|
|
138
|
+
const facts: DeliveryFact[] = [];
|
|
139
|
+
const seen = new Set<string>();
|
|
140
|
+
for (const entry of raw) {
|
|
141
|
+
if (!isRecord(entry)) continue;
|
|
142
|
+
const name = entry.name;
|
|
143
|
+
const type = entry.type;
|
|
144
|
+
if (typeof name !== "string" || name.length === 0) continue;
|
|
145
|
+
if (name.length > FACT_NAME_MAX_LENGTH || !FACT_NAME_PATTERN.test(name)) continue;
|
|
146
|
+
if (seen.has(name)) continue;
|
|
147
|
+
if (!isDeliveryFactType(type)) continue;
|
|
148
|
+
seen.add(name);
|
|
149
|
+
facts.push({ name, type, ...(typeof entry.description === "string" ? { description: entry.description } : {}) });
|
|
150
|
+
}
|
|
151
|
+
return facts;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Derive a human node's CATEGORY from its typed emits (Decision 4, step 2), or `null` when no bespoke
|
|
155
|
+
* category form applies:
|
|
156
|
+
* - `ack` — the node emits NOTHING (a "click done" acknowledgement).
|
|
157
|
+
* - `publish` — the node emits EXACTLY ONE `artifact` fact (a `pkg@version` handle — the manual-
|
|
158
|
+
* publish-hands-a-version-forward case), which the bespoke publish form captures as a
|
|
159
|
+
* `resolvedArtifact`. A lone `version` (a BARE version, e.g. `1.4.0`) does NOT map here: the
|
|
160
|
+
* publish form captures a `pkg@version` into `resolvedArtifact`, which fails `version` coercion in
|
|
161
|
+
* {@link bindHumanEmits} — so a single `version` falls through to the generic single-value form
|
|
162
|
+
* (which captures a bare `value`, validated against the `version` type).
|
|
163
|
+
* - `null` — a single non-artifact scalar/url/version fact (the generic fallback captures it), OR
|
|
164
|
+
* two-or-more facts (no bespoke or generic single-value form can hold them → the agent-router
|
|
165
|
+
* territory).
|
|
166
|
+
* Kept coarse ON PURPOSE: bespoke category forms are a deterministic convenience, not an open
|
|
167
|
+
* taxonomy; anything they don't cover falls through to the generic fallback or the gated router. */
|
|
168
|
+
export function deriveHumanCategory(emits: readonly DeliveryFact[]): HumanNodeCategory | null {
|
|
169
|
+
if (emits.length === 0) return "ack";
|
|
170
|
+
if (emits.length === 1 && emits[0].type === "artifact") return "publish";
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Resolve which form a human node uses, specific-else-generic (ADR 0005 Decision 4). Preference:
|
|
175
|
+
* 1. `explicit` — the node carries a non-blank `human.formKey`.
|
|
176
|
+
* 2. `category` — the node's derived category selects a bespoke form ({@link deriveHumanCategory}).
|
|
177
|
+
* 3. `generic` — the node emits ≤1 fact, captured by the generic typed-emit fallback form.
|
|
178
|
+
* 4. `agent-router` — NOTHING statically resolved (≥2 heterogeneous emits, no explicit/bespoke
|
|
179
|
+
* form): a runtime agent must pick/assemble a form. This is the GATED escape
|
|
180
|
+
* hatch — it fires ONLY here, never in the common path.
|
|
181
|
+
* Pure and total (never throws): the graph is shape/semantic-validated upstream, and malformed emits
|
|
182
|
+
* are normalised away, so this always returns a resolution. Author-time callers (the S1 compiler)
|
|
183
|
+
* attach `formKey` deterministically so the resolved form is visible in the preview; only the
|
|
184
|
+
* `agent-router` case defers to runtime. */
|
|
185
|
+
export function resolveHumanForm(node: Pick<DeliveryNodeHuman, "emits" | "human">): HumanFormResolution {
|
|
186
|
+
const emits = normalizeEmits(node);
|
|
187
|
+
const explicit = node.human?.formKey;
|
|
188
|
+
if (typeof explicit === "string" && explicit.trim().length > 0) {
|
|
189
|
+
return {
|
|
190
|
+
source: "explicit",
|
|
191
|
+
formKey: explicit.trim(),
|
|
192
|
+
category: null,
|
|
193
|
+
emits,
|
|
194
|
+
reason: `explicit form "${explicit.trim()}" attached on the node`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const category = deriveHumanCategory(emits);
|
|
198
|
+
if (category !== null) {
|
|
199
|
+
return {
|
|
200
|
+
source: "category",
|
|
201
|
+
formKey: HUMAN_CATEGORY_FORMS[category],
|
|
202
|
+
category,
|
|
203
|
+
emits,
|
|
204
|
+
reason:
|
|
205
|
+
category === "ack"
|
|
206
|
+
? "no emitted fact — the generic acknowledgement (click-done) form"
|
|
207
|
+
: `emits a single ${emits[0].type} fact — the manual-publish form (captures a resolvedArtifact)`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (emits.length <= 1) {
|
|
211
|
+
return {
|
|
212
|
+
source: "generic",
|
|
213
|
+
formKey: GENERIC_HUMAN_FORM,
|
|
214
|
+
category: null,
|
|
215
|
+
reason: `emits a single ${emits[0].type} fact — the generic typed-emit fallback form`,
|
|
216
|
+
emits,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
source: "agent-router",
|
|
221
|
+
formKey: null,
|
|
222
|
+
category: null,
|
|
223
|
+
emits,
|
|
224
|
+
reason:
|
|
225
|
+
`emits ${emits.length} typed facts and carries no explicit/bespoke form — no static form can ` +
|
|
226
|
+
"capture them, so a runtime agent-form-router must assemble one (the gated exception)",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** True when a human node resolves to the gated runtime agent-form-router — i.e. nothing statically
|
|
231
|
+
* resolved. A thin predicate over {@link resolveHumanForm} so callers can branch without re-deriving. */
|
|
232
|
+
export function needsAgentFormRouter(node: Pick<DeliveryNodeHuman, "emits" | "human">): boolean {
|
|
233
|
+
return resolveHumanForm(node).source === "agent-router";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The canonical form-field keys a bespoke/generic form captures its single typed value under, tried
|
|
237
|
+
* (in addition to the fact's own name) when binding a single-fact node's output. The generic form
|
|
238
|
+
* captures `value`; the publish form captures `resolvedArtifact`. */
|
|
239
|
+
const CANONICAL_VALUE_KEYS = ["resolvedArtifact", "value"] as const;
|
|
240
|
+
|
|
241
|
+
/** The ONE accepted shape of a bare version string (an optional `v` then a digit-led `[\w.+-]` run,
|
|
242
|
+
* e.g. `1.4.0`, `v2.0.0-rc.1`). The single source of truth shared by the `version` fact type AND the
|
|
243
|
+
* version segment of an `artifact` handle — so a `pkg@version` artifact validates its version the
|
|
244
|
+
* same way a bare `version` does, with no second notion of "valid version" to drift. */
|
|
245
|
+
const VERSION_PATTERN = /^v?\d[\w.+-]*$/;
|
|
246
|
+
|
|
247
|
+
/** Coerce + validate one raw form value against a declared fact type, returning the canonical string
|
|
248
|
+
* serialisation or an error message. Typed so a bind is validated, not stringly (Decision 3/4): an
|
|
249
|
+
* `artifact` must be `pkg@version` (with a well-formed version segment), a `version` a bare version, a
|
|
250
|
+
* `url` a parseable location, a `number` finite, a `boolean` a real boolean. */
|
|
251
|
+
function coerceFactValue(type: DeliveryFactType, raw: unknown): { value: string } | { error: string } {
|
|
252
|
+
switch (type) {
|
|
253
|
+
case "string": {
|
|
254
|
+
if (typeof raw !== "string" || raw.trim() === "") return { error: "expected a non-empty string" };
|
|
255
|
+
// Return the trimmed value, mirroring the other string-like types (version/artifact/url) so the
|
|
256
|
+
// canonical serialisation does not depend on incidental surrounding whitespace.
|
|
257
|
+
return { value: raw.trim() };
|
|
258
|
+
}
|
|
259
|
+
case "number": {
|
|
260
|
+
const n = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() !== "" ? Number(raw) : Number.NaN;
|
|
261
|
+
if (!Number.isFinite(n)) return { error: "expected a finite number" };
|
|
262
|
+
return { value: String(n) };
|
|
263
|
+
}
|
|
264
|
+
case "boolean": {
|
|
265
|
+
if (typeof raw === "boolean") return { value: String(raw) };
|
|
266
|
+
// Trim before validating, mirroring the other string-like types (version/artifact/url) so a
|
|
267
|
+
// boolean captured via the generic textfield (e.g. " true ") is not needlessly brittle.
|
|
268
|
+
const trimmed = typeof raw === "string" ? raw.trim() : raw;
|
|
269
|
+
if (trimmed === "true" || trimmed === "false") return { value: trimmed };
|
|
270
|
+
return { error: "expected a boolean (true/false)" };
|
|
271
|
+
}
|
|
272
|
+
case "version": {
|
|
273
|
+
if (typeof raw !== "string" || !VERSION_PATTERN.test(raw.trim())) {
|
|
274
|
+
return { error: "expected a version string (e.g. 1.4.0)" };
|
|
275
|
+
}
|
|
276
|
+
return { value: raw.trim() };
|
|
277
|
+
}
|
|
278
|
+
case "artifact": {
|
|
279
|
+
if (typeof raw !== "string") return { error: "expected a pkg@version artifact handle" };
|
|
280
|
+
const at = raw.trim().lastIndexOf("@");
|
|
281
|
+
const name = raw.trim().slice(0, at);
|
|
282
|
+
const version = raw.trim().slice(at + 1);
|
|
283
|
+
if (at <= 0 || name.length === 0 || version.length === 0 || !VERSION_PATTERN.test(version)) {
|
|
284
|
+
return { error: "expected a pkg@version artifact handle (e.g. @nanobpm/urban@0.54.0)" };
|
|
285
|
+
}
|
|
286
|
+
return { value: raw.trim() };
|
|
287
|
+
}
|
|
288
|
+
case "url": {
|
|
289
|
+
if (typeof raw !== "string" || raw.trim() === "") return { error: "expected a URL" };
|
|
290
|
+
try {
|
|
291
|
+
new URL(raw.trim());
|
|
292
|
+
} catch {
|
|
293
|
+
return { error: "expected a valid URL (with a scheme)" };
|
|
294
|
+
}
|
|
295
|
+
return { value: raw.trim() };
|
|
296
|
+
}
|
|
297
|
+
default:
|
|
298
|
+
return { error: `unknown fact type "${type}"` };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Bind a completed human node's form `output` to its declared `emits[]` (Decision 3/4). For each
|
|
303
|
+
* declared fact, read its value from the output — preferring the fact's own `name` key, then, for a
|
|
304
|
+
* single-fact node, the canonical capture keys the generic/publish forms use (`resolvedArtifact` /
|
|
305
|
+
* `value`) — and validate/coerce it against the fact's type. Returns the typed facts in declaration
|
|
306
|
+
* order plus one error per missing/ill-typed declared fact. A node with no declared emits yields no
|
|
307
|
+
* facts and no errors (the degenerate "click done" case) regardless of what the form captured.
|
|
308
|
+
*
|
|
309
|
+
* This is the typed-emit contract the runner (S4) enforces on completion before threading the facts
|
|
310
|
+
* downstream via {@link humanEmitBind} / {@link renderHumanEmitBrief} — the emit-side of #263. */
|
|
311
|
+
export function bindHumanEmits(
|
|
312
|
+
emits: readonly DeliveryFact[],
|
|
313
|
+
output: Record<string, unknown> | null | undefined,
|
|
314
|
+
): HumanEmitResult {
|
|
315
|
+
const out = isRecord(output) ? output : {};
|
|
316
|
+
const facts: BoundFact[] = [];
|
|
317
|
+
const errors: string[] = [];
|
|
318
|
+
const single = emits.length === 1;
|
|
319
|
+
for (const fact of emits) {
|
|
320
|
+
let raw = out[fact.name];
|
|
321
|
+
if (raw === undefined && single) {
|
|
322
|
+
for (const key of CANONICAL_VALUE_KEYS) {
|
|
323
|
+
if (out[key] !== undefined) {
|
|
324
|
+
raw = out[key];
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (raw === undefined || raw === null) {
|
|
330
|
+
errors.push(`emits.${fact.name}: no value captured for the declared ${fact.type} fact`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const coerced = coerceFactValue(fact.type, raw);
|
|
334
|
+
if ("error" in coerced) {
|
|
335
|
+
errors.push(`emits.${fact.name}: ${coerced.error}`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
facts.push({ name: fact.name, type: fact.type, value: coerced.value });
|
|
339
|
+
}
|
|
340
|
+
return { facts, errors };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Build the qualified bind map a human node's emitted facts publish downstream, keyed exactly as a
|
|
344
|
+
* delivery edge references them — `<nodeId>.<fact>` (mirroring the probe `bind: Record<string,string>`
|
|
345
|
+
* and the `capability` kind's `resolvedArtifact`). A downstream edge `from: "<nodeId>.<fact>"` binds
|
|
346
|
+
* and PINS the human-handed value from this map. Empty for a no-emit node. */
|
|
347
|
+
export function humanEmitBind(nodeId: string, facts: readonly BoundFact[]): Record<string, string> {
|
|
348
|
+
const bind: Record<string, string> = {};
|
|
349
|
+
for (const fact of facts) bind[`${nodeId}.${fact.name}`] = fact.value;
|
|
350
|
+
return bind;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Neutralise a human-submitted value before it is embedded in a Markdown inline-code span in the
|
|
354
|
+
* brief. A raw backtick would terminate the span (corrupting the brief and letting free-form form
|
|
355
|
+
* text inject unintended prompt content into the downstream agent's prompt), and a newline would
|
|
356
|
+
* break the span; collapse both. Display-only — the authoritative value published downstream via
|
|
357
|
+
* {@link humanEmitBind} is untouched, so the pinned contract is unaffected. */
|
|
358
|
+
function inlineCodeSafe(value: string): string {
|
|
359
|
+
return value.replace(/`/g, "'").replace(/\r?\n/g, " ");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Render the "human-emitted facts" brief appended to a downstream node's prompt once a human node
|
|
363
|
+
* completes and hands its typed facts forward (#289 §3), mirroring `renderResolvedDepsBrief`. It pins
|
|
364
|
+
* each `name → value` the human declared + captured so a downstream agent/edge consumes EXACTLY that
|
|
365
|
+
* value — no re-derivation, no human re-entry. Returns "" for a no-emit node so callers concatenate
|
|
366
|
+
* unconditionally (the same `if X = null then "" else X` FEEL convention as the other briefs). */
|
|
367
|
+
export function renderHumanEmitBrief(facts: readonly BoundFact[]): string {
|
|
368
|
+
if (facts.length === 0) return "";
|
|
369
|
+
const lines = [
|
|
370
|
+
"",
|
|
371
|
+
"",
|
|
372
|
+
"---",
|
|
373
|
+
"",
|
|
374
|
+
"**Human-emitted facts (authoritative — a scheduled human step handed these forward):**",
|
|
375
|
+
"",
|
|
376
|
+
"A `human` node upstream captured and emitted the typed values below. Consume/pin exactly these —",
|
|
377
|
+
"do NOT re-derive, float, or re-request them:",
|
|
378
|
+
"",
|
|
379
|
+
];
|
|
380
|
+
for (const fact of facts) lines.push(`- \`${fact.name}\` (${fact.type}) → \`${inlineCodeSafe(fact.value)}\``);
|
|
381
|
+
lines.push("");
|
|
382
|
+
lines.push("These are the contract the human handed forward; a different value is a different run.");
|
|
383
|
+
return lines.join("\n");
|
|
384
|
+
}
|
|
@@ -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
|
+
});
|