@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 +12 -0
- package/app/deliveryGraphCompiler.test.ts +47 -0
- package/app/deliveryGraphCompiler.ts +66 -8
- package/app/deliveryRunner.test.ts +35 -1
- package/app/deliveryRunner.ts +13 -3
- package/app/lineage.test.ts +146 -0
- package/app/lineage.ts +100 -3
- package/app/migration079.test.ts +153 -0
- package/db/migrations/079_lineage_thread_view_delivery.sql +65 -0
- package/openapi.yaml +2 -2
- package/package.json +1 -1
- package/pages/lineage.page.json +1 -1
- package/resources/forms/delivery-human-generic.form +24 -0
- package/resources/processes/delivery-human.bpmn +4 -0
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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, {
|
|
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;
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/app/lineage.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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),
|
|
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.
|
|
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",
|
package/pages/lineage.page.json
CHANGED
|
@@ -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)) != "") 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 "typed" else "none"" target="emitMode" />
|
|
16
|
+
<zeebe:input source="=if (is defined(emits)) then string join(for _e in emits return _e.name + " (" + _e.type + ")", ", ") else """ target="emitLabel" />
|
|
13
17
|
<zeebe:output source="="completed"" 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" />
|