@nanobpm/nano-workforce 0.131.0 → 0.132.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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.132.0](https://github.com/nanobpm/nano-workforce/compare/v0.131.1...v0.132.0) (2026-08-24)
2
+
3
+ ### Features
4
+
5
+ * **lineage:** surface delivery-graph runs as fan-in parent threads ([#504](https://github.com/nanobpm/nano-workforce/issues/504)) ([c62b925](https://github.com/nanobpm/nano-workforce/commit/c62b92556dd8ec21f42bc70ca4eba5455e448343)), closes [#498](https://github.com/nanobpm/nano-workforce/issues/498) [#498](https://github.com/nanobpm/nano-workforce/issues/498)
6
+
7
+ ## [0.131.1](https://github.com/nanobpm/nano-workforce/compare/v0.131.0...v0.131.1) (2026-08-24)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **delivery-graph:** seed human-task prompt/context so its form is not contextless ([#502](https://github.com/nanobpm/nano-workforce/issues/502)) ([d2991ea](https://github.com/nanobpm/nano-workforce/commit/d2991ea0ec54e36711c27f58b8901ca85b7179e6)), closes [#499](https://github.com/nanobpm/nano-workforce/issues/499) [#499](https://github.com/nanobpm/nano-workforce/issues/499)
12
+
1
13
  ## [0.131.0](https://github.com/nanobpm/nano-workforce/compare/v0.130.0...v0.131.0) (2026-08-23)
2
14
 
3
15
  ### Features
@@ -29,6 +29,17 @@ async function compileFail(graph: unknown) {
29
29
  return r.errors;
30
30
  }
31
31
 
32
+ /** The sub-process element id of the PLANNED human user task in a compiled graph — i.e. the
33
+ * delivery-human-task element that is NOT a bounded node's __esc timeout twin. Returns "" if none. */
34
+ function humanTaskSubEl(bpmn: string): string {
35
+ const parts = bpmn.split('<bpmn:userTask id="delivery-human-task__');
36
+ for (let k = 1; k < parts.length; k++) {
37
+ const id = parts[k].slice(0, parts[k].indexOf('"'));
38
+ if (!id.endsWith("__esc")) return id;
39
+ }
40
+ return "";
41
+ }
42
+
32
43
  // The ADR's motivating case: an agent merges PR #B, a `pr` wait node watches it merge and emits
33
44
  // `mergedSha`, a human does the manual OTP publish emitting `resolvedArtifact`, and a connector
34
45
  // consumes the published artifact.
@@ -114,6 +125,42 @@ test("late-binding: a fact-qualified edge threads a boundFacts input into the co
114
125
  assert(boundInput, `boundFacts is a single-quoted FEEL list literal, got: ${r.bpmn.match(/source='[^']*' target="boundFacts"/)?.[0] ?? r.bpmn.match(/source="[^"]*" target="boundFacts"/)?.[0]}`);
115
126
  });
116
127
 
128
+ test("#499 human context: the human user-task seeds prompt/nodeId/emit context so its generic form is not contextless", async () => {
129
+ const r = await compileOk(RELEASE_RUNBOOK);
130
+ // The human node's subProcess ioMapping must thread the authored prompt + node identity + emit
131
+ // context from `nodeInputs.<el>` onto the user task (the form reads them). A dropped prompt input is
132
+ // exactly the contextless-form bug (#499). Locate the PLANNED human task (not an `__esc` twin).
133
+ const subEl = humanTaskSubEl(r.bpmn);
134
+ assert(subEl !== "", "the graph inlines a planned human user task");
135
+ assert(r.bpmn.includes(`source="=nodeInputs.${subEl}.prompt" target="prompt"`), "the human task seeds its authored prompt");
136
+ assert(r.bpmn.includes(`source="=nodeInputs.${subEl}.nodeId" target="nodeId"`), "the human task seeds its node identity");
137
+ // The emit label/mode are DERIVED in FEEL from the seeded emits list (single source of truth) — a
138
+ // single-quoted attribute so the literal quotes survive the engine deploy path.
139
+ assert(
140
+ r.bpmn.includes(`source='=if count(nodeInputs.${subEl}.emits) = 0 then "none" else "typed"' target="emitMode"`),
141
+ "emitMode is derived from the emits count",
142
+ );
143
+ assert(
144
+ r.bpmn.includes(`for _e in nodeInputs.${subEl}.emits return _e.name`) && r.bpmn.includes('target="emitLabel"'),
145
+ "emitLabel is derived from the emits list",
146
+ );
147
+ });
148
+
149
+ test("#499 escalation context: an agent-node timeout escalation seeds a context line naming the node, its job type, and the elapsed SLA", async () => {
150
+ const r = await compileOk(RELEASE_RUNBOOK);
151
+ // The `__esc` timeout-escalation user task previously carried NO ioMapping — a blank form that never
152
+ // said a timeout occurred, on which node. It must now seed a `prompt` context line from a compile-time
153
+ // literal (node id + job type) concatenated with the runtime elapsed SLA (`nodeTimeout`).
154
+ const start = r.bpmn.indexOf('<bpmn:userTask id="delivery-human-task__n1__esc"');
155
+ assert(start !== -1, "a bounded node inlines an escalation user task");
156
+ const escBlock = r.bpmn.slice(start, r.bpmn.indexOf("</bpmn:userTask>", start));
157
+ assert(escBlock.includes('target="prompt"'), "the escalation task seeds a prompt context line");
158
+ assert(escBlock.includes("Node open-b (senior:feature) exceeded its SLA ("), "the context names the node and its job type");
159
+ assert(escBlock.includes("string(nodeTimeout)"), "the context reports the elapsed SLA at runtime");
160
+ assert(escBlock.includes('="none"') && escBlock.includes('target="emitMode"'), "the escalation labels its emit field N/A so the generic form hides the inert value input");
161
+ });
162
+
163
+
117
164
  test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", async () => {
118
165
  const errors = await compileFail({
119
166
  nodes: [{ id: "x", kind: "deploy", deploy: { target: "prod" } }],
@@ -823,6 +823,16 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
823
823
  case "human":
824
824
  inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
825
825
  inputs.push({ source: cfg("escalationAssignee"), target: "escalationAssignee" });
826
+ // Seed the authored instruction + node identity + emit context so the generic human form renders
827
+ // "now do X", names the parked node, and labels/hides its emit field (issue #499). `emits` is the
828
+ // single source of truth; the emit label/mode are derived from it in FEEL here (no duplicate seed).
829
+ inputs.push({ source: cfg("prompt"), target: "prompt" });
830
+ inputs.push({ source: cfg("nodeId"), target: "nodeId" });
831
+ inputs.push({ source: `=if count(${cfg("emits").slice(1)}) = 0 then "none" else "typed"`, target: "emitMode" });
832
+ inputs.push({
833
+ source: `=string join(for _e in ${cfg("emits").slice(1)} return _e.name + " (" + _e.type + ")", ", ")`,
834
+ target: "emitLabel",
835
+ });
826
836
  break;
827
837
  case "connector":
828
838
  inputs.push({ source: cfg("target"), target: "target" });
@@ -868,9 +878,9 @@ function innerBodyLines(w: NodeWiring): string[] {
868
878
  const node = w.node;
869
879
  switch (node.kind) {
870
880
  case "agent":
871
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), []);
881
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType);
872
882
  case "connector":
873
- return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, []);
883
+ return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
874
884
  case "wait":
875
885
  return waitBodyLines(el, node.id);
876
886
  case "human":
@@ -882,8 +892,15 @@ function innerBodyLines(w: NodeWiring): string[] {
882
892
 
883
893
  /** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
884
894
  * escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
885
- * `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines. */
886
- function serviceBodyLines(el: string, nodeId: string, taskDefAttr: string, taskProps: readonly string[]): string[] {
895
+ * `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
896
+ * names the stalled work (job type / connector target) for the escalation task's context line (#499). */
897
+ function serviceBodyLines(
898
+ el: string,
899
+ nodeId: string,
900
+ taskDefAttr: string,
901
+ taskProps: readonly string[],
902
+ descriptor: string,
903
+ ): string[] {
887
904
  const esc = escalationTaskElement(el);
888
905
  const taskExt =
889
906
  taskProps.length > 0
@@ -911,7 +928,18 @@ function serviceBodyLines(el: string, nodeId: string, taskDefAttr: string, taskP
911
928
  ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
912
929
  ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=nodeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
913
930
  " </bpmn:boundaryEvent>",
914
- ...escalationTaskLines(esc, nodeId, [`${el}_i2`], `${el}_i3`),
931
+ ...escalationTaskLines(
932
+ esc,
933
+ nodeId,
934
+ [`${el}_i2`],
935
+ `${el}_i3`,
936
+ escalationContextFeel(
937
+ nodeId,
938
+ descriptor,
939
+ "nodeTimeout",
940
+ "; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
941
+ ),
942
+ ),
915
943
  ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
916
944
  flow(`${el}_i0`, `${el}_start`, `${el}_task`),
917
945
  flow(`${el}_i1`, `${el}_task`, `${el}_end`),
@@ -991,7 +1019,13 @@ function waitBodyLines(el: string, nodeId: string): string[] {
991
1019
  ` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
992
1020
  ` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
993
1021
  " </bpmn:exclusiveGateway>",
994
- ...escalationTaskLines(esc, nodeId, [`${el}_i4`], `${el}_i5`),
1022
+ ...escalationTaskLines(
1023
+ esc,
1024
+ nodeId,
1025
+ [`${el}_i4`],
1026
+ `${el}_i5`,
1027
+ escalationContextFeel(nodeId, "readiness gate", "probeTimeout", " before its ReadinessProbe went green — decide how to proceed."),
1028
+ ),
995
1029
  ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
996
1030
  flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
997
1031
  flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
@@ -1047,14 +1081,29 @@ function humanBodyLines(el: string, nodeId: string): string[] {
1047
1081
  }
1048
1082
 
1049
1083
  /** A bounded node's escalation user task — a human-completable stop (`isDeliveryHumanElement`
1050
- * convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. */
1051
- function escalationTaskLines(esc: string, nodeId: string, incoming: readonly string[], outgoing: string): string[] {
1084
+ * convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. `contextFeel` is
1085
+ * a FEEL expression yielding the context line seeded onto the generic form's read-only prompt field
1086
+ * (issue #499) — e.g. "Node n1 (senior:feature) exceeded its SLA (PT30M); …" — so the operator can see
1087
+ * WHICH node timed out and that in-flight work may already exist, instead of a blank form. The emit
1088
+ * field is labelled "none" so the generic form hides its (inert on an escalation) typed-value input. */
1089
+ function escalationTaskLines(
1090
+ esc: string,
1091
+ nodeId: string,
1092
+ incoming: readonly string[],
1093
+ outgoing: string,
1094
+ contextFeel: string,
1095
+ ): string[] {
1052
1096
  return [
1053
1097
  ` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(nodeId)}">`,
1054
1098
  " <bpmn:extensionElements>",
1055
1099
  ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
1056
1100
  " <zeebe:userTask />",
1057
1101
  ' <zeebe:assignmentDefinition candidateGroups="operators" />',
1102
+ " <zeebe:ioMapping>",
1103
+ ` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
1104
+ ` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
1105
+ ` <zeebe:input ${attr("source", '="none"')} target="emitMode" />`,
1106
+ " </zeebe:ioMapping>",
1058
1107
  " </bpmn:extensionElements>",
1059
1108
  ...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
1060
1109
  ` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
@@ -1062,6 +1111,15 @@ function escalationTaskLines(esc: string, nodeId: string, incoming: readonly str
1062
1111
  ];
1063
1112
  }
1064
1113
 
1114
+ /** Build the FEEL context line seeded onto an escalation task's read-only prompt field (issue #499).
1115
+ * The node id + descriptor (job type / connector target / "readiness gate") are baked as compile-time
1116
+ * literals; the elapsed SLA is read from the node body's runtime `timeoutVar` (`nodeTimeout` for a
1117
+ * bounded service node, `probeTimeout` for a `wait` gate). `tail` closes the sentence per kind. */
1118
+ function escalationContextFeel(nodeId: string, descriptor: string, timeoutVar: string, tail: string): string {
1119
+ const head = feelStr(`Node ${nodeId} (${descriptor}) exceeded its SLA (`);
1120
+ return `=${head} + string(${timeoutVar}) + ${feelStr(`)${tail}`)}`;
1121
+ }
1122
+
1065
1123
  /** A plain `<bpmn:sequenceFlow>` (6-space indented). */
1066
1124
  function flow(id: string, source: string, target: string): string {
1067
1125
  return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
@@ -79,12 +79,46 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
79
79
  assert(wait?.probe && typeof wait.probe === "object", "the wait node carries its ReadinessProbe descriptor");
80
80
 
81
81
  const human = byField((v) => "escalationSlaTimeout" in v);
82
- assertEquals(human, { escalationSlaTimeout: "PT2H", escalationAssignee: "alice" });
82
+ assertEquals(human, {
83
+ escalationSlaTimeout: "PT2H",
84
+ escalationAssignee: "alice",
85
+ // #499: the human node seeds its authored prompt, node identity, and declared emits so the
86
+ // generic user-task form renders "now do X", names the parked node, and labels its emit field.
87
+ prompt: "run the manual OTP publish",
88
+ nodeId: "publish",
89
+ emits: [{ name: "resolvedArtifact", type: "artifact" }],
90
+ });
83
91
 
84
92
  const connector = byField((v) => v.target === "npm:install");
85
93
  assertEquals(connector, { target: "npm:install", dedupeKey: "consume-1", payload: null, timeout: "PT10M" });
86
94
  });
87
95
 
96
+ test("the human node seeds prompt/nodeId/emits; a click-done (no-emit, no-prompt) node seeds empty defaults", async () => {
97
+ // #499: the compiled human user-task's form reads `prompt`/`nodeId`/`emits` from `nodeInputs.<el>`;
98
+ // a discarded prompt is the contextless-form bug. Pin both an emit-declaring node and the degenerate
99
+ // click-done node (no `human` config, no `emits`) so the seed never regresses to null/undefined.
100
+ const graph: DeliveryGraph = {
101
+ nodes: [
102
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" }, emits: [{ name: "resolvedArtifact", type: "artifact" }] },
103
+ { id: "ack", kind: "human" },
104
+ ],
105
+ edges: [{ from: "publish.resolvedArtifact", to: "ack" }],
106
+ };
107
+ const p = await prepareOk(graph);
108
+ const humans = Object.values(p.nodeInputs).filter((v) => "escalationSlaTimeout" in v) as Array<Record<string, unknown>>;
109
+ const publish = humans.find((v) => v.nodeId === "publish");
110
+ const ack = humans.find((v) => v.nodeId === "ack");
111
+
112
+ assertEquals(publish?.prompt, "run the manual OTP publish");
113
+ assertEquals(publish?.emits, [{ name: "resolvedArtifact", type: "artifact" }]);
114
+
115
+ // The click-done node carries a defined-but-empty prompt and an empty emits list (never undefined),
116
+ // so the form seeds a blank instruction and hides its emit field rather than seeding null.
117
+ assertEquals(ack?.prompt, "");
118
+ assertEquals(ack?.emits, []);
119
+ assertEquals(ack?.nodeId, "ack");
120
+ });
121
+
88
122
  test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", async () => {
89
123
  const gateKeyOf = (p: Awaited<ReturnType<typeof prepareOk>>) =>
90
124
  (Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { createHash, randomUUID } from "node:crypto";
17
17
  import type { EngineClient } from "@nanobpm/urban";
18
- import type { DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
18
+ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
19
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
20
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
21
21
 
@@ -65,7 +65,7 @@ const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
65
65
  type NodeInput =
66
66
  | { jobType: string; appendPrompt: string; timeout: string }
67
67
  | { gateKey: string; probe: unknown; probeTimeout: string; probePollEvery: string }
68
- | { escalationSlaTimeout: string; escalationAssignee: string | null }
68
+ | { escalationSlaTimeout: string; escalationAssignee: string | null; prompt: string; nodeId: string; emits: DeliveryFact[] }
69
69
  | { target: string; dedupeKey: string | null; payload: Record<string, unknown> | null; timeout: string };
70
70
 
71
71
  /** The result of compiling + preparing a graph for deployment: the content-addressed process id, the
@@ -185,7 +185,17 @@ function buildNodeInput(
185
185
  };
186
186
  }
187
187
  case "human":
188
- return { escalationSlaTimeout: ctx.escalationSlaTimeout, escalationAssignee: ctx.escalationAssignee };
188
+ return {
189
+ escalationSlaTimeout: ctx.escalationSlaTimeout,
190
+ escalationAssignee: ctx.escalationAssignee,
191
+ // Seed the authored instruction, node identity, and declared emits so the human user-task's
192
+ // form can render its "now do X" prompt, name the parked node, and label/hide its emit field
193
+ // (issue #499 — the generic form otherwise renders contextless). `emits` stays the single
194
+ // source of truth: the compiled ioMapping derives the emit label/mode from it in FEEL.
195
+ prompt: node.human?.prompt ?? "",
196
+ nodeId: node.id ?? ctx.element,
197
+ emits: Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [],
198
+ };
189
199
  case "connector":
190
200
  return {
191
201
  target: node.connector.target,
@@ -131,6 +131,91 @@ test("feature/self-rooted threads carry no epic phase label", () => {
131
131
  assertEquals(self.epicPhaseLabel, null, "a self-rooted PR is not an epic slice");
132
132
  });
133
133
 
134
+ // ── delivery-graph fan-in parent (issue #498) ─────────────────────────────────────────────────
135
+
136
+ test("delivery: a running run with no PR yet is implementing, frontier from its phase", () => {
137
+ const t = deriveLineage(
138
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "running", phase: "Running", processKey: "d1" },
139
+ [],
140
+ );
141
+ assertEquals(t.kind, "delivery");
142
+ assertEquals(t.rootRequestKey, "dg-abc");
143
+ assertEquals(t.stage, "implementing");
144
+ assertEquals(t.stageLabel, "Running", "the frontier reflects the run's derived phase");
145
+ assert(t.active, "a running run is active");
146
+ assertEquals(t.processKey, "d1");
147
+ assertEquals(t.title, "Ship widget");
148
+ assertEquals(t.issueUrl, null, "a delivery run is keyed by run_key, not a GitHub issue");
149
+ assertEquals(t.epicPhaseLabel, null, "a delivery run's member PRs are not epic slices");
150
+ assertEquals(t.prCount, 0);
151
+ });
152
+
153
+ test("delivery: downstream PR convergences nest under the run and temper the frontier to converging", () => {
154
+ const t = deriveLineage(
155
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "running", phase: "Parked on human node: publish", processKey: "d1" },
156
+ [
157
+ pr({ prKey: "a/b#1", status: "merged" }),
158
+ pr({ prKey: "c/d#9", status: "converging", processKey: "c9" }),
159
+ ],
160
+ );
161
+ assertEquals(t.kind, "delivery");
162
+ assertEquals(t.stage, "converging", "a member PR still in flight tempers the frontier to converging");
163
+ assertEquals(t.stageLabel, "Parked on human node: publish", "the label still prefers the run's stamped phase");
164
+ assertEquals(t.processKey, "c9", "frontier prefers the in-flight member PR's instance");
165
+ assertEquals(t.prKeys, ["a/b#1", "c/d#9"], "heterogeneous downstream PRs across repos nest under the run");
166
+ assert(t.active);
167
+ });
168
+
169
+ test("delivery: a done run settles as resolved (no active frontier)", () => {
170
+ const t = deriveLineage(
171
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "done", phase: "Completed", processKey: "d1" },
172
+ [pr({ prKey: "a/b#1", status: "merged" })],
173
+ );
174
+ assertEquals(t.stage, "resolved");
175
+ assertEquals(t.stageLabel, "Completed");
176
+ assert(!t.active, "a completed run has no active frontier");
177
+ });
178
+
179
+ test("delivery: a failed run settles as abandoned", () => {
180
+ const t = deriveLineage(
181
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "failed", phase: "Failed", processKey: "d1" },
182
+ [],
183
+ );
184
+ assertEquals(t.stage, "abandoned");
185
+ assert(!t.active);
186
+ });
187
+
188
+ test("delivery: an abandoned run with no phase is labeled 'Abandoned', not 'Failed'", () => {
189
+ // `deliveryOriginStage` folds both `failed` and `abandoned` statuses onto the `abandoned` stage, so
190
+ // the label must consult the run status: a genuinely abandoned run reads "Abandoned" (only a failed
191
+ // one reads "Failed"). Regress with no stamped phase so the status-derived fallback label is used.
192
+ const t = deriveLineage(
193
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "abandoned", phase: null, processKey: "d1" },
194
+ [],
195
+ );
196
+ assertEquals(t.stage, "abandoned");
197
+ assertEquals(t.stageLabel, "Abandoned", "an abandoned run is not mislabeled as failed");
198
+ assert(!t.active);
199
+ });
200
+
201
+ test("delivery: a failed run with no phase is labeled 'Failed'", () => {
202
+ const t = deriveLineage(
203
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "failed", phase: null, processKey: "d1" },
204
+ [],
205
+ );
206
+ assertEquals(t.stage, "abandoned");
207
+ assertEquals(t.stageLabel, "Failed");
208
+ });
209
+
210
+ test("delivery: with no stamped phase, the frontier falls back to a status-derived label", () => {
211
+ const t = deriveLineage(
212
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "running", phase: null, processKey: "d1" },
213
+ [],
214
+ );
215
+ assertEquals(t.stage, "implementing");
216
+ assertEquals(t.stageLabel, "Running", "null phase falls back to the status-derived frontier label");
217
+ });
218
+
134
219
  // ── self-rooted (human/webhook) PR ───────────────────────────────────────────────────────────
135
220
 
136
221
  test("pr: a human/webhook PR with no origin is its own root", () => {
@@ -237,6 +322,67 @@ test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_
237
322
  assertEquals(after, before, "steady-state pass is a no-op");
238
323
  });
239
324
 
325
+ test("pollLineage: projects a delivery-graph run as a fan-in parent thread with its downstream PRs nested", async () => {
326
+ // Issue #498: a dispatched delivery-graph run appears as its own thread, and the downstream PR
327
+ // convergences threaded to it (root_request_key = run_key) across DIFFERENT repos nest under it
328
+ // rather than appearing as disconnected self-rooted PRs.
329
+ const { data, stores } = memData();
330
+ stores.feature_runs = [];
331
+ stores.plans = [];
332
+ stores.plan_tasks = [];
333
+ stores.delivery_graph_runs = [
334
+ { run_key: "dg-xyz", title: "Ship widget across repos", status: "running", phase: "Running", process_key: "P-dg" },
335
+ ];
336
+ stores.pull_requests = [
337
+ { pr_key: "a/b#1", title: "widget in a/b", url: "x", status: "merged", current_round: 1, process_key: "c1", outcome: null, root_request_key: "dg-xyz" },
338
+ { pr_key: "c/d#9", title: "widget in c/d", url: "x", status: "converging", current_round: 2, process_key: "c2", outcome: null, root_request_key: "dg-xyz" },
339
+ ];
340
+
341
+ await pollLineage(data);
342
+
343
+ const threads: LineageThreadRow[] = stores.lineage_threads;
344
+ assertEquals(threads.length, 1, "one delivery thread, not two disconnected self-rooted PRs");
345
+ const dg = threads.find((t) => t.root_request_key === "dg-xyz");
346
+ assert(dg, "delivery thread present, keyed on run_key");
347
+ // A member PR still converging tempers the frontier to converging; the label prefers the run phase.
348
+ assertEquals(dg?.stage, "converging");
349
+ assertEquals(dg?.stage_label, "Running");
350
+ assertEquals(dg?.active, 1);
351
+ assertEquals(dg?.pr_count, 2);
352
+ assertEquals(JSON.parse(dg?.pr_keys ?? "[]").sort(), ["a/b#1", "c/d#9"]);
353
+ // A delivery run's member PRs are not epic slices, so no epic phase label is projected onto them.
354
+ const prById = (k: string) => stores.pull_requests.find((r: any) => r.pr_key === k);
355
+ assertEquals(prById("a/b#1").epic_phase_label ?? null, null);
356
+ assertEquals(prById("c/d#9").epic_phase_label ?? null, null);
357
+ });
358
+
359
+ test("pollLineage: a delivery run_key colliding with a feature key does not overwrite the feature thread", async () => {
360
+ // The SQL view (migration 079) classifies epic > feature > delivery, so a `delivery_graph_runs.run_key`
361
+ // that equals an existing `feature_key`/`plan_key` must NOT clobber that earlier thread — otherwise the
362
+ // poller would stamp delivery-derived frontier columns onto a row the view still classifies feature/epic,
363
+ // and the two projections drift. The colliding run is skipped; feature precedence is preserved.
364
+ const { data, stores } = memData();
365
+ stores.feature_runs = [
366
+ { feature_key: "o/r#7", title: "Feature seven", issue_url: "u7", status: "converging", process_key: "f7", pr_key: "o/r#700" },
367
+ ];
368
+ stores.plans = [];
369
+ stores.plan_tasks = [];
370
+ stores.delivery_graph_runs = [
371
+ { run_key: "o/r#7", title: "Colliding run", status: "running", phase: "Running", process_key: "P-dup" },
372
+ ];
373
+ stores.pull_requests = [
374
+ { pr_key: "o/r#700", title: "Feat PR", url: "x", status: "converging", current_round: 2, process_key: "c1", outcome: null, root_request_key: "o/r#7" },
375
+ ];
376
+
377
+ await pollLineage(data);
378
+
379
+ const threads: LineageThreadRow[] = stores.lineage_threads;
380
+ assertEquals(threads.length, 1, "the colliding run does not create a second row for the same key");
381
+ const t = threads.find((r) => r.root_request_key === "o/r#7");
382
+ assert(t, "the single thread for the shared key is the feature thread");
383
+ assertEquals(t?.stage_label, "Converging (round 2)", "feature frontier wins; the delivery run did not overwrite it");
384
+ });
385
+
240
386
  test("pollLineage: a self-rooted PR row (root_request_key === pr_key) projects exactly one thread keyed on its pr_key", async () => {
241
387
  // Regression (#245): submitPr now self-roots a human/webhook PR on its own `pr_key` (rather than
242
388
  // NULL) so the Lineage page's `lineage_threads.root_request_key → pull_requests.root_request_key`
package/app/lineage.ts CHANGED
@@ -20,14 +20,15 @@
20
20
  // `pr_key` (kind `pr`), and also tolerates a legacy NULL `root_request_key` the same way.
21
21
  import type { DataLayer } from "@nanobpm/urban";
22
22
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
23
+ import { type DeliveryGraphRun, deliveryGraphRuns } from "./deliveryGraphRun.ts";
23
24
  import { type FeatureRun, featureRuns } from "./feature.ts";
24
25
  import { derivedTrackingTable } from "./instanceTracking.ts";
25
26
  import { type Plan, type PlanTask, plans, planTasks } from "./plan.ts";
26
27
 
27
28
  const now = () => new Date().toISOString();
28
29
 
29
- /** The three origin shapes a lineage arc can spring from. */
30
- export type LineageKind = "feature" | "epic" | "pr";
30
+ /** The origin shapes a lineage arc can spring from. */
31
+ export type LineageKind = "feature" | "epic" | "pr" | "delivery";
31
32
 
32
33
  /** A member PR of a lineage thread — the subset of `pull_requests` the projection reads. */
33
34
  export interface LineagePr {
@@ -66,6 +67,23 @@ export type LineageOrigin =
66
67
  // A human/webhook PR with no originating request: its own root.
67
68
  kind: "pr";
68
69
  key: string;
70
+ }
71
+ | {
72
+ // A delivery-graph run (issue #498): a FAN-IN parent thread. The run is the thread root, and
73
+ // the heterogeneous downstream tasks it spawns — PR convergences across different repos/issue
74
+ // numbers, package publishes, human gates — nest under it (they thread `root_request_key =
75
+ // run_key`, mirroring how `submitPr` threads feature/epic roots). Closer to an epic than a
76
+ // single-PR arc.
77
+ kind: "delivery";
78
+ key: string;
79
+ title: string | null;
80
+ status: string;
81
+ // The run's already-derived display phase (`delivery_graph_runs.phase`, recomputed by
82
+ // `pollDeliveryGraphPhase` from engine truth — generalised from `epic_phase`), e.g. "Running",
83
+ // "Parked on human node: manual OTP publish", "Completed". NULL until the poller stamps one; the
84
+ // thread then falls back to a status-derived frontier label.
85
+ phase: string | null;
86
+ processKey: string | null;
69
87
  };
70
88
 
71
89
  /** One stitched arc: `request → implementation → PR(s) → convergence → merge → outcome`. */
@@ -241,6 +259,18 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
241
259
  stageLabel = featureStageLabel(stage);
242
260
  processKey = origin.processKey ?? rep?.processKey ?? null;
243
261
  }
262
+ } else if (origin.kind === "delivery") {
263
+ // Fan-in parent (issue #498): the delivery-graph RUN is the thread root; its heterogeneous
264
+ // downstream PR convergences (across different repos/issues) nest under it. Unlike an epic's
265
+ // slice rollup, a run's narrative is "where is the run" — so the frontier reflects the run's OWN
266
+ // derived phase (`delivery_graph_runs.phase`), with member PRs shown as nested children. The
267
+ // machine `stage` is derived from the run status (tempered to `converging` while a member PR is
268
+ // still in flight); the human `stageLabel` prefers the run's stamped phase, else a status label.
269
+ stage = deliveryOriginStage(origin.status, prs);
270
+ stageLabel = origin.phase ?? deliveryStageLabel(stage, origin.status);
271
+ // Active-frontier instance: an in-flight member PR's process, else the run's own.
272
+ const activePr = prs.find((p) => !TERMINAL_STATUSES.includes(p.status));
273
+ processKey = activePr?.processKey ?? origin.processKey ?? rep?.processKey ?? null;
244
274
  } else {
245
275
  // Self-rooted PR (human/webhook): the PR IS the whole arc.
246
276
  stage = rep ? prStage(rep.status) : "converging";
@@ -259,7 +289,9 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
259
289
  rootRequestKey: origin.key,
260
290
  kind: origin.kind,
261
291
  title: origin.kind === "pr" ? (rep?.title ?? null) : origin.title,
262
- issueUrl: origin.kind === "pr" ? null : origin.issueUrl,
292
+ // Only feature/epic threads root on a GitHub issue; a self-rooted PR and a delivery-graph run
293
+ // (issue #498, keyed by `run_key`) have none.
294
+ issueUrl: origin.kind === "feature" || origin.kind === "epic" ? origin.issueUrl : null,
263
295
  stage,
264
296
  stageLabel,
265
297
  epicPhaseLabel,
@@ -293,6 +325,45 @@ function featureStageLabel(stage: LineageStage): string {
293
325
  }
294
326
  }
295
327
 
328
+ /** Map a delivery-graph run's lifecycle status onto a frontier stage (issue #498). A `running` run
329
+ * with a member PR still in flight reads as `converging` (the fan-in is landing PRs); otherwise it is
330
+ * `implementing`. Terminal run statuses settle: `done` → `resolved` (the run completed), `failed` /
331
+ * `abandoned` → `abandoned`. `awaiting-approval` (reserved, no longer produced) parks at `planning`. */
332
+ function deliveryOriginStage(status: string, prs: readonly LineagePr[]): LineageStage {
333
+ switch (status) {
334
+ case "awaiting-approval":
335
+ return "planning";
336
+ case "running":
337
+ return prs.some((p) => !TERMINAL_STATUSES.includes(p.status)) ? "converging" : "implementing";
338
+ case "done":
339
+ return "resolved";
340
+ case "failed":
341
+ case "abandoned":
342
+ return "abandoned";
343
+ default:
344
+ return "implementing";
345
+ }
346
+ }
347
+
348
+ /** The fallback frontier label for a delivery thread when the run has not stamped a `phase` yet.
349
+ * `deliveryOriginStage` folds both the `failed` and `abandoned` run statuses onto the terminal
350
+ * `abandoned` stage, so the stage alone cannot tell them apart — take the run `status` too and label a
351
+ * genuinely `abandoned` run "Abandoned" (only a `failed` run reads "Failed"). */
352
+ function deliveryStageLabel(stage: LineageStage, status: string): string {
353
+ switch (stage) {
354
+ case "planning":
355
+ return "Awaiting approval";
356
+ case "converging":
357
+ return "Converging";
358
+ case "resolved":
359
+ return "Completed";
360
+ case "abandoned":
361
+ return status === "abandoned" ? "Abandoned" : "Failed";
362
+ default:
363
+ return "Running";
364
+ }
365
+ }
366
+
296
367
  // ── gateway glue ───────────────────────────────────────────────────────────────────────────────
297
368
 
298
369
  /** The subset of `pull_requests` the lineage projection reads. */
@@ -450,6 +521,21 @@ async function collectThreads(
450
521
  threads.set(plan.plan_key, deriveLineage(epicOrigin(plan), prs.map(toLineagePr)));
451
522
  }
452
523
 
524
+ // Delivery-graph runs (issue #498): each run is a fan-in parent thread keyed on its `run_key`,
525
+ // attaching the downstream PRs threaded to it (`pull_requests.root_request_key = run_key`). Mirrors
526
+ // the feature/epic loops — a run with no PR landed yet still projects a thread (its derived phase).
527
+ const deliveryRows = await deliveryGraphRuns(data).all();
528
+ for (const run of deliveryRows) {
529
+ // Feature/epic precedence: the SQL view's CASE classifies epic > feature > delivery, so a
530
+ // `run_key` that collides with an existing `plan_key`/`feature_key` must NOT overwrite that
531
+ // thread — otherwise the poller projection would stamp delivery-derived frontier columns onto a
532
+ // row the view still classifies epic/feature, and the two drift. Skip the colliding run so the
533
+ // earlier feature/epic thread (and the view's precedence) stays intact.
534
+ if (threads.has(run.run_key)) continue;
535
+ const prs = collectRootPrs(run.run_key, null, prsByRoot, prByKey, claimed);
536
+ threads.set(run.run_key, deriveLineage(deliveryGraphOrigin(run), prs.map(toLineagePr)));
537
+ }
538
+
453
539
  // Any PR not claimed by a feature/epic root is its own root: a human/webhook PR, a legacy row
454
540
  // predating migration 037's backfill, or a `root_request_key` whose origin row no longer survives.
455
541
  // Key each such thread by the root STORED on the PR row (`root_request_key`, falling back to
@@ -532,6 +618,17 @@ function epicOrigin(plan: Plan): LineageOrigin {
532
618
  };
533
619
  }
534
620
 
621
+ function deliveryGraphOrigin(run: DeliveryGraphRun): LineageOrigin {
622
+ return {
623
+ kind: "delivery",
624
+ key: run.run_key,
625
+ title: run.title,
626
+ status: run.status,
627
+ phase: run.phase,
628
+ processKey: run.process_key,
629
+ };
630
+ }
631
+
535
632
  /** On-demand: the stitched thread for one origin issue (or self-rooted PR), computed from the live
536
633
  * rows. Returns null when the root is unknown. */
537
634
  export async function getLineage(
@@ -0,0 +1,153 @@
1
+ // Read-model guard for migration 079's extended `lineage_thread_view` VIEW (issue #498: surface
2
+ // delivery-graph runs in the Lineage tab as a fan-in parent thread). Mirrors app/migration064.test.ts:
3
+ // apply the migration to a real in-memory SQLite DB and assert the VIEW's output over sample rows —
4
+ // so this exercises the real view, not a re-implementation.
5
+ //
6
+ // The extended view adds a third origin arm: a root that matches a `delivery_graph_runs.run_key`
7
+ // derives `kind = 'delivery'`, `title` from the run, and a NULL `issue_url` (a run is keyed by
8
+ // run_key/digest, not a GitHub issue). The epic/feature/pr arms must keep behaving exactly as 064's
9
+ // view did (precedence unchanged).
10
+ import { readFileSync } from "node:fs";
11
+ import { DatabaseSync } from "node:sqlite";
12
+ import { test } from "node:test";
13
+ import { fileURLToPath } from "node:url";
14
+ import { assertEquals } from "#test-assert";
15
+
16
+ const MIGRATION_064 = fileURLToPath(new URL("../db/migrations/064_lineage_thread_view.sql", import.meta.url));
17
+ const MIGRATION_079 = fileURLToPath(
18
+ new URL("../db/migrations/079_lineage_thread_view_delivery.sql", import.meta.url),
19
+ );
20
+
21
+ /** A DB with the base shapes the view reads (`lineage_threads`, `plans`, `feature_runs`,
22
+ * `delivery_graph_runs`) plus 064 then 079 applied in order — so this exercises the real DROP VIEW +
23
+ * re-CREATE the migration performs, not just the final definition. */
24
+ function viewDb(): DatabaseSync {
25
+ const db = new DatabaseSync(":memory:");
26
+ db.exec(
27
+ `CREATE TABLE lineage_threads (
28
+ root_request_key TEXT PRIMARY KEY, title TEXT, stage TEXT,
29
+ stage_label TEXT, process_key TEXT, pr_keys TEXT, pr_count INTEGER, active INTEGER,
30
+ created_at TEXT, updated_at TEXT);
31
+ CREATE TABLE plans (plan_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);
32
+ CREATE TABLE feature_runs (feature_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);
33
+ CREATE TABLE delivery_graph_runs (run_key TEXT PRIMARY KEY, title TEXT, phase TEXT, status TEXT);`,
34
+ );
35
+ db.exec(readFileSync(MIGRATION_064, "utf8"));
36
+ db.exec(readFileSync(MIGRATION_079, "utf8"));
37
+ return db;
38
+ }
39
+
40
+ /** Insert a `lineage_threads` row exactly as `pollLineage` denormalises one (post-072 schema — no
41
+ * `kind`/`issue_url` columns; the view derives both from the origin joins). */
42
+ function addThread(
43
+ db: DatabaseSync,
44
+ row: {
45
+ root_request_key: string;
46
+ title: string | null;
47
+ stage: string;
48
+ stage_label: string | null;
49
+ process_key: string | null;
50
+ pr_keys: string | null;
51
+ pr_count: number;
52
+ active: number;
53
+ },
54
+ ): void {
55
+ db.prepare(
56
+ `INSERT INTO lineage_threads (root_request_key, title, stage, stage_label,
57
+ process_key, pr_keys, pr_count, active, created_at, updated_at)
58
+ VALUES (@root_request_key, @title, @stage, @stage_label, @process_key,
59
+ @pr_keys, @pr_count, @active, 't0', 't1')`,
60
+ ).run(row);
61
+ }
62
+
63
+ test("lineage_thread_view derives kind/title/NULL issue_url for a delivery-graph thread from the run origin", () => {
64
+ const db = viewDb();
65
+ db.prepare(
66
+ "INSERT INTO delivery_graph_runs (run_key, title, phase, status) VALUES (?, ?, ?, ?)",
67
+ ).run("dg-abc123", "Ship widget across repos", "Parked on human node: manual OTP publish", "running");
68
+ // pollLineage wrote the fan-in run's procedural frontier onto lineage_threads, keyed on run_key.
69
+ addThread(db, {
70
+ root_request_key: "dg-abc123",
71
+ title: "Ship widget across repos",
72
+ stage: "converging",
73
+ stage_label: "Parked on human node: manual OTP publish",
74
+ process_key: "P-dg",
75
+ pr_keys: '["a/b#1","c/d#9"]',
76
+ pr_count: 2,
77
+ active: 1,
78
+ });
79
+
80
+ const v = db
81
+ .prepare("SELECT * FROM lineage_thread_view WHERE root_request_key = ?")
82
+ .get("dg-abc123") as Record<string, unknown>;
83
+ // Derived from the delivery_graph_runs join.
84
+ assertEquals(v.kind, "delivery");
85
+ assertEquals(v.title, "Ship widget across repos");
86
+ // A run is keyed by run_key/digest, not a GitHub issue — issue_url is always NULL.
87
+ assertEquals(v.issue_url, null);
88
+ // Procedural frontier columns pass through unchanged from lineage_threads (the run's derived phase).
89
+ assertEquals(v.stage, "converging");
90
+ assertEquals(v.stage_label, "Parked on human node: manual OTP publish");
91
+ assertEquals(v.process_key, "P-dg");
92
+ assertEquals(v.pr_keys, '["a/b#1","c/d#9"]');
93
+ assertEquals(v.pr_count, 2);
94
+ assertEquals(v.active, 1);
95
+ });
96
+
97
+ test("lineage_thread_view renders a delivery thread with no PR landed yet (empty member set, run phase)", () => {
98
+ const db = viewDb();
99
+ db.prepare(
100
+ "INSERT INTO delivery_graph_runs (run_key, title, phase, status) VALUES (?, ?, ?, ?)",
101
+ ).run("dg-empty", "Fresh run", "Running", "running");
102
+ addThread(db, {
103
+ root_request_key: "dg-empty",
104
+ title: "Fresh run",
105
+ stage: "implementing",
106
+ stage_label: "Running",
107
+ process_key: "P-e",
108
+ pr_keys: "[]",
109
+ pr_count: 0,
110
+ active: 1,
111
+ });
112
+
113
+ const v = db
114
+ .prepare("SELECT kind, title, issue_url, stage_label, pr_count FROM lineage_thread_view WHERE root_request_key = ?")
115
+ .get("dg-empty") as Record<string, unknown>;
116
+ assertEquals(v.kind, "delivery");
117
+ assertEquals(v.title, "Fresh run");
118
+ assertEquals(v.issue_url, null);
119
+ assertEquals(v.stage_label, "Running");
120
+ assertEquals(v.pr_count, 0);
121
+ });
122
+
123
+ test("lineage_thread_view keeps the epic/feature/pr arms unchanged after the delivery arm is added", () => {
124
+ const db = viewDb();
125
+ db.prepare("INSERT INTO plans (plan_key, title, issue_url) VALUES ('o/r#2', 'Epic', 'u-epic')").run();
126
+ db.prepare("INSERT INTO feature_runs (feature_key, title, issue_url) VALUES ('o/r#1', 'Feat', 'u-feat')").run();
127
+ const rows = [
128
+ { root_request_key: "o/r#2", kind: "epic", title: "Epic", issue_url: "u-epic" },
129
+ { root_request_key: "o/r#1", kind: "feature", title: "Feat", issue_url: "u-feat" },
130
+ { root_request_key: "o/r#30", kind: "pr", title: "PR", issue_url: null },
131
+ ];
132
+ for (const r of rows) {
133
+ addThread(db, {
134
+ root_request_key: r.root_request_key,
135
+ title: r.title,
136
+ stage: "opened",
137
+ stage_label: "Opened",
138
+ process_key: null,
139
+ pr_keys: "[]",
140
+ pr_count: 1,
141
+ active: 1,
142
+ });
143
+ }
144
+
145
+ for (const r of rows) {
146
+ const v = db
147
+ .prepare("SELECT kind, title, issue_url FROM lineage_thread_view WHERE root_request_key = ?")
148
+ .get(r.root_request_key) as Record<string, unknown>;
149
+ assertEquals(v.kind, r.kind);
150
+ assertEquals(v.title, r.title);
151
+ assertEquals(v.issue_url, r.issue_url);
152
+ }
153
+ });
@@ -0,0 +1,65 @@
1
+ -- Surface delivery-graph runs in the Lineage read model (issue #498: "surface dynamic delivery
2
+ -- graphs in the Lineage tab as a fan-in parent thread").
3
+ --
4
+ -- 064_lineage_thread_view.sql created `lineage_thread_view`, deriving a thread's view-expressible
5
+ -- identity columns (`kind`, `title`, `issue_url`) from the `plans` / `feature_runs` origin joins and
6
+ -- passing the procedural frontier columns through from `lineage_threads`. It matched a root against
7
+ -- exactly two origin tables (else a self-rooted `'pr'`), so a delivery-graph run — a SEPARATE
8
+ -- aggregate (`delivery_graph_runs`, keyed by `run_key`) — was structurally invisible: its thread fell
9
+ -- through to `'pr'` with a NULL title.
10
+ --
11
+ -- `collectThreads` (app/lineage.ts) now enumerates `delivery_graph_runs` as a fan-in parent thread
12
+ -- keyed on `run_key`, so `pollLineage` writes a `lineage_threads` row for each run. Extend the view
13
+ -- with a third origin arm so it derives that row's identity from the run:
14
+ -- • `kind` — 'delivery' when the root matches a `delivery_graph_runs.run_key` (after the
15
+ -- epic/feature arms, mirroring the precedence in `collectThreads` / `deriveLineage`).
16
+ -- • `title` — the run's `title` (its authored delivery-graph title).
17
+ -- • `issue_url` — NULL: a delivery-graph run is keyed by `run_key`/`digest`, not a GitHub issue,
18
+ -- exactly as `deriveLineage` sets it (only feature/epic threads root on an issue).
19
+ -- The procedural frontier columns (`stage`/`stage_label`/`process_key`/`pr_keys`/`pr_count`/`active`)
20
+ -- still pass through from `lineage_threads`, so a delivery thread's frontier reflects the run's
21
+ -- derived phase that `pollLineage` wrote.
22
+ --
23
+ -- A VIEW cannot be `ALTER`ed, so DROP the old definition and CREATE the extended one. This is a NEW
24
+ -- forward-only migration — 064 stays immutable. The view remains a plain `CREATE VIEW <name> AS
25
+ -- SELECT … FROM …` (no CTE, no select-list subquery, every column aliased) so the static
26
+ -- pages↔schema contract guard can still introspect its output columns; the added CASE arm and LEFT
27
+ -- JOIN keep the SAME output column set, so the repointed Lineage page renders identically.
28
+ --
29
+ -- Forward-only, additive: no schema change to any base table, no DROP of `lineage_threads`. The
30
+ -- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
31
+
32
+ DROP VIEW IF EXISTS lineage_thread_view;
33
+
34
+ CREATE VIEW lineage_thread_view AS
35
+ SELECT
36
+ lt.root_request_key AS root_request_key,
37
+ CASE
38
+ WHEN pl.plan_key IS NOT NULL THEN 'epic'
39
+ WHEN fr.feature_key IS NOT NULL THEN 'feature'
40
+ WHEN dg.run_key IS NOT NULL THEN 'delivery'
41
+ ELSE 'pr'
42
+ END AS kind,
43
+ CASE
44
+ WHEN pl.plan_key IS NOT NULL THEN pl.title
45
+ WHEN fr.feature_key IS NOT NULL THEN fr.title
46
+ WHEN dg.run_key IS NOT NULL THEN dg.title
47
+ ELSE lt.title
48
+ END AS title,
49
+ CASE
50
+ WHEN pl.plan_key IS NOT NULL THEN pl.issue_url
51
+ WHEN fr.feature_key IS NOT NULL THEN fr.issue_url
52
+ ELSE NULL
53
+ END AS issue_url,
54
+ lt.stage AS stage,
55
+ lt.stage_label AS stage_label,
56
+ lt.process_key AS process_key,
57
+ lt.pr_keys AS pr_keys,
58
+ lt.pr_count AS pr_count,
59
+ lt.active AS active,
60
+ lt.created_at AS created_at,
61
+ lt.updated_at AS updated_at
62
+ FROM lineage_threads lt
63
+ LEFT JOIN plans pl ON pl.plan_key = lt.root_request_key
64
+ LEFT JOIN feature_runs fr ON fr.feature_key = lt.root_request_key
65
+ LEFT JOIN delivery_graph_runs dg ON dg.run_key = lt.root_request_key;
package/openapi.yaml CHANGED
@@ -148,10 +148,10 @@ components:
148
148
  properties:
149
149
  rootRequestKey:
150
150
  type: string
151
- description: The origin issue key (feature_key/plan_key), or a self-rooted pr_key.
151
+ description: The origin issue key (feature_key/plan_key), a self-rooted pr_key, or a delivery-graph run_key.
152
152
  kind:
153
153
  type: string
154
- enum: [feature, epic, pr]
154
+ enum: [feature, epic, pr, delivery]
155
155
  title:
156
156
  type: string
157
157
  nullable: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.131.0",
3
+ "version": "0.132.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",
@@ -70,7 +70,7 @@
70
70
  "type": "text",
71
71
  "id": "subtitle",
72
72
  "props": {
73
- "text": "One narrative per request, not a card-swap. Each row is a single arc of your intent \u2014 request \u2192 implementing \u2192 PR opened \u2192 converging (round n) \u2192 merged/converged/abandoned \u2014 stitched from the feature/epic that started it, the PR(s) it produced, and their convergence + merge outcome. The Stage column is the active frontier; drill into a row for its member PR(s) and rounds. Epics fan out to N PR sub-threads. A human/webhook PR with no originating request is shown as its own root.",
73
+ "text": "One narrative per request, not a card-swap. Each row is a single arc of your intent \u2014 request \u2192 implementing \u2192 PR opened \u2192 converging (round n) \u2192 merged/converged/abandoned \u2014 stitched from the feature/epic that started it, the PR(s) it produced, and their convergence + merge outcome. The Stage column is the active frontier; drill into a row for its member PR(s) and rounds. Epics fan out to N PR sub-threads. A delivery-graph run is a fan-in parent thread: the heterogeneous downstream PR convergences it spawns (across different repos/issues) nest under the run, whose frontier reflects its derived phase. A human/webhook PR with no originating request is shown as its own root.",
74
74
  "variant": "sub"
75
75
  }
76
76
  },
@@ -3,6 +3,13 @@
3
3
  "schemaVersion": 18,
4
4
  "type": "default",
5
5
  "components": [
6
+ {
7
+ "type": "text",
8
+ "text": "### Delivery graph — node `{{nodeId}}`",
9
+ "conditional": {
10
+ "hide": "=(nodeId = null) or (nodeId = \"\")"
11
+ }
12
+ },
6
13
  {
7
14
  "type": "textarea",
8
15
  "key": "prompt",
@@ -10,15 +17,32 @@
10
17
  "description": "The scheduled human step this delivery graph is waiting on.",
11
18
  "readonly": true
12
19
  },
20
+ {
21
+ "type": "text",
22
+ "text": "**Emits:** {{emitLabel}} — enter its typed value below (validated against the node's declared fact).",
23
+ "conditional": {
24
+ "hide": "=emitMode != \"typed\""
25
+ }
26
+ },
13
27
  {
14
28
  "type": "textfield",
15
29
  "key": "value",
16
30
  "label": "Emitted value",
17
31
  "description": "The typed value this step hands forward to its downstream dependents. Validated against the node's declared emitted fact.",
32
+ "conditional": {
33
+ "hide": "=emitMode != \"typed\""
34
+ },
18
35
  "validate": {
19
36
  "required": true
20
37
  }
21
38
  },
39
+ {
40
+ "type": "text",
41
+ "text": "_This step emits no typed fact (N/A) — just complete it to unblock its dependents._",
42
+ "conditional": {
43
+ "hide": "=emitMode = \"typed\""
44
+ }
45
+ },
22
46
  {
23
47
  "type": "textarea",
24
48
  "key": "note",
@@ -10,6 +10,10 @@
10
10
  <zeebe:userTask />
11
11
  <zeebe:assignmentDefinition candidateGroups="operators" assignee="=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != &#34;&#34;) then escalationAssignee else null" />
12
12
  <zeebe:ioMapping>
13
+ <zeebe:input source="=if (is defined(prompt)) then prompt else null" target="prompt" />
14
+ <zeebe:input source="=if (is defined(nodeId)) then nodeId else null" target="nodeId" />
15
+ <zeebe:input source="=if (is defined(emits) and count(emits) != 0) then &#34;typed&#34; else &#34;none&#34;" target="emitMode" />
16
+ <zeebe:input source="=if (is defined(emits)) then string join(for _e in emits return _e.name + &#34; (&#34; + _e.type + &#34;)&#34;, &#34;, &#34;) else &#34;&#34;" target="emitLabel" />
13
17
  <zeebe:output source="=&#34;completed&#34;" target="humanOutcome" />
14
18
  <zeebe:output source="=if (is defined(value)) then value else null" target="humanEmitValue" />
15
19
  <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="humanEmitArtifact" />