@nanobpm/nano-workforce 0.142.0 → 0.144.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/deliveryGraphReadModel.test.ts +392 -0
- package/app/deliveryGraphReadModel.ts +166 -0
- package/app/deliveryRunner.test.ts +50 -8
- package/app/deliveryRunner.ts +43 -1
- package/app/stepAxis.test.ts +142 -0
- package/app/stepAxis.ts +217 -0
- package/db/migrations/087_delivery_graph_read_model.sql +75 -0
- package/package.json +1 -1
- package/pages/delivery-graph-detail.page.json +18 -2
- package/pages/delivery-graphs.page.json +25 -8
- package/pages/overview.page.json +18 -2
- package/scripts/pages-contract.test.ts +15 -11
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// proven end-to-end in `e2e/delivery-graph.e2e.ts`.
|
|
12
12
|
import { test } from "node:test";
|
|
13
13
|
import { assert, assertEquals } from "#test-assert";
|
|
14
|
-
import { prepareDeliveryGraph, runDeliveryGraph } from "./deliveryRunner.ts";
|
|
14
|
+
import { prepareDeliveryGraph, renderIdempotencyPreamble, runDeliveryGraph } from "./deliveryRunner.ts";
|
|
15
15
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
16
16
|
|
|
17
17
|
const GRAPH: DeliveryGraph = {
|
|
@@ -70,7 +70,9 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
|
|
|
70
70
|
const byField = (pred: (v: Record<string, unknown>) => boolean) => Object.values(inputs).find((v) => pred(v as Record<string, unknown>)) as Record<string, unknown> | undefined;
|
|
71
71
|
|
|
72
72
|
const agent = byField((v) => v.jobType === "senior:feature");
|
|
73
|
-
|
|
73
|
+
// Every agent node's appendPrompt is prefixed with the idempotency preflight (#551), then the
|
|
74
|
+
// authored prompt (this node declares no emits, so the emit contract adds nothing).
|
|
75
|
+
assertEquals(agent, { jobType: "senior:feature", appendPrompt: renderIdempotencyPreamble() + "un-draft + merge #B", timeout: "PT10M" });
|
|
74
76
|
|
|
75
77
|
const wait = byField((v) => "gateKey" in v);
|
|
76
78
|
assertEquals(wait?.gateKey, "run-7:n3");
|
|
@@ -135,20 +137,60 @@ test("agent node classifier-emit contract (#506): a declared `emits` threads the
|
|
|
135
137
|
};
|
|
136
138
|
const p = await prepareOk(graph);
|
|
137
139
|
const agents = Object.values(p.nodeInputs).filter((v) => "jobType" in v) as Array<Record<string, unknown>>;
|
|
138
|
-
const adopt = agents.find((v) => String(v.appendPrompt).
|
|
139
|
-
const plain = agents.find((v) => String(v.appendPrompt).
|
|
140
|
+
const adopt = agents.find((v) => String(v.appendPrompt).includes("adopt the package"));
|
|
141
|
+
const plain = agents.find((v) => String(v.appendPrompt).includes("just implement it"));
|
|
140
142
|
|
|
141
|
-
// The emit-declaring node keeps its authored prompt AND gains the emit contract naming its fact
|
|
143
|
+
// The emit-declaring node keeps its authored prompt AND gains the emit contract naming its fact,
|
|
144
|
+
// both AFTER the unconditional idempotency preflight (#551).
|
|
142
145
|
assert(adopt, "the emit-declaring agent node must be seeded");
|
|
143
146
|
const adoptPrompt = String(adopt?.appendPrompt);
|
|
144
|
-
assert(adoptPrompt.startsWith(
|
|
147
|
+
assert(adoptPrompt.startsWith(renderIdempotencyPreamble()), "the idempotency preflight leads every agent prompt");
|
|
148
|
+
assert(adoptPrompt.includes("adopt the package"), "the authored prompt is preserved after the preflight");
|
|
145
149
|
assert(adoptPrompt.includes("Classifier emit contract"), `the emit contract must be threaded in, got: ${adoptPrompt}`);
|
|
146
150
|
assert(adoptPrompt.includes("`result`") && adoptPrompt.includes("(string)"), "the declared fact name + type must be surfaced to the agent");
|
|
147
151
|
assert(adoptPrompt.includes("breaking | compatible"), "the fact's optional description rides the contract");
|
|
148
152
|
assert(adoptPrompt.includes("AGENT_RESULT_FILE"), "the contract names the completion channel the fact rides");
|
|
149
153
|
|
|
150
|
-
// A node that declares NO facts
|
|
151
|
-
|
|
154
|
+
// A node that declares NO facts still carries the preflight, then exactly the authored prompt — the
|
|
155
|
+
// emit contract contributes nothing.
|
|
156
|
+
assertEquals(plain?.appendPrompt, renderIdempotencyPreamble() + "just implement it");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
test("agent node idempotency preflight (#551): every agent prompt leads with adopt-and-report guidance; non-agent nodes are untouched", async () => {
|
|
161
|
+
// #551: a delivery agent node dispatches a raw retry-carrying `senior:feature` job with no
|
|
162
|
+
// PR-existence guard, so a re-dispatch opened a DUPLICATE PR (instance 43077 n0 → #979/#980). The
|
|
163
|
+
// fix threads an unconditional idempotency preflight into every agent node's `appendPrompt` telling
|
|
164
|
+
// the agent to CHECK for an existing claim/open PR and ADOPT-AND-REPORT it rather than open a second.
|
|
165
|
+
// Pin: (a) both agent nodes lead with the preflight regardless of emits, (b) it names the check +
|
|
166
|
+
// the adopt-and-report contract, and (c) wait/human/connector nodes never carry it.
|
|
167
|
+
const graph: DeliveryGraph = {
|
|
168
|
+
name: "idempotency",
|
|
169
|
+
nodes: [
|
|
170
|
+
{ id: "emitter", kind: "agent", agent: { jobType: "senior:feature", prompt: "do X" }, emits: [{ name: "result", type: "string" }] },
|
|
171
|
+
{ id: "plain", kind: "agent", agent: { jobType: "senior:feature", prompt: "do Y" } },
|
|
172
|
+
{ id: "gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" } } },
|
|
173
|
+
],
|
|
174
|
+
edges: [{ from: "emitter", to: "plain" }, { from: "plain", to: "gate" }],
|
|
175
|
+
};
|
|
176
|
+
const p = await prepareOk(graph);
|
|
177
|
+
const preamble = renderIdempotencyPreamble();
|
|
178
|
+
|
|
179
|
+
// Every agent node leads with the preflight, ahead of its authored prompt (and any emit contract).
|
|
180
|
+
const agents = Object.values(p.nodeInputs).filter((v) => "jobType" in v) as Array<Record<string, unknown>>;
|
|
181
|
+
assertEquals(agents.length, 2, "both agent nodes are seeded");
|
|
182
|
+
for (const a of agents) {
|
|
183
|
+
const prompt = String(a.appendPrompt);
|
|
184
|
+
assert(prompt.startsWith(preamble), "the preflight is the leading prefix of every agent prompt");
|
|
185
|
+
assert(prompt.includes("Idempotency preflight"), "the preflight heading is present");
|
|
186
|
+
assert(prompt.includes("DO NOT open a second PR") || prompt.includes("do not open a second"), "it forbids a duplicate PR");
|
|
187
|
+
assert(prompt.includes("adopt and report"), "it names the adopt-and-report contract");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The preflight is unconditional but AGENT-ONLY — a wait node's seed carries no prompt at all.
|
|
191
|
+
const wait = Object.values(p.nodeInputs).find((v) => "gateKey" in v) as Record<string, unknown> | undefined;
|
|
192
|
+
assert(wait, "the wait node is seeded");
|
|
193
|
+
assert(!("appendPrompt" in wait!), "a non-agent node never carries the agent idempotency preflight");
|
|
152
194
|
});
|
|
153
195
|
|
|
154
196
|
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -178,6 +178,48 @@ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
|
|
|
178
178
|
.replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/** The idempotency preflight prepended to EVERY `agent` node's `appendPrompt` (issue #551). A delivery
|
|
182
|
+
* agent node dispatches a raw `senior:feature` job with no `feature_runs` idempotency row and no
|
|
183
|
+
* PR-existence guard, and the job carries retries — so an idle/timeout re-dispatch hands the SAME
|
|
184
|
+
* "implement #N" prompt to another worker, who (in a fresh worktree, blind to the first) opens a SECOND
|
|
185
|
+
* PR on the same issue. That is exactly how instance 43077's node n0 (`Magikcraft/nano-bpm#977`) spawned
|
|
186
|
+
* the #979/#980 duplicate. The advisory AGENTS.md claim protocol did not prevent it because nothing tells
|
|
187
|
+
* the *agent* to look first. This block does: a preflight that makes the agent **adopt-and-report** an
|
|
188
|
+
* existing PR instead of opening a duplicate. Adopt-and-report (not "escalate") because a delivery agent
|
|
189
|
+
* node has NO in-band escalate route — an in-flight agent can only complete (job done) or fail (which
|
|
190
|
+
* raises an incident, the stuck state we are avoiding); adopting completes the completion-barrier node
|
|
191
|
+
* cleanly, with no duplicate and no incident. Fixed wording (no derived data), so identical graphs still
|
|
192
|
+
* compile+seed deterministically; it is unconditional because every agent node that opens a PR is exposed
|
|
193
|
+
* to the same re-dispatch race. This is an advisory guard — the categorical fix (an engine-level
|
|
194
|
+
* preflight guard, or routing the node through the idempotent feature cell) is tracked as a follow-up. */
|
|
195
|
+
export function renderIdempotencyPreamble(): string {
|
|
196
|
+
return [
|
|
197
|
+
"## Idempotency preflight (delivery graph) — check BEFORE you implement",
|
|
198
|
+
"",
|
|
199
|
+
"This node may be re-dispatched (a retry after a timeout) or run in parallel with another worker.",
|
|
200
|
+
"BEFORE you write ANY code, confirm nobody is already delivering the issue you were asked to implement:",
|
|
201
|
+
"",
|
|
202
|
+
"1. Read that issue's comments for an existing **claim** (a comment beginning `Claimed —`, an",
|
|
203
|
+
" assignee, or a referenced in-progress branch/worktree).",
|
|
204
|
+
"2. List the repository's OPEN pull requests for one that already references the issue (a `Closes",
|
|
205
|
+
" #N`, the issue number in its title/body, or a branch named for it).",
|
|
206
|
+
"",
|
|
207
|
+
"If an existing claim OR an open PR already covers this issue, DO NOT open a second PR — a duplicate",
|
|
208
|
+
"PR is a defect: it splits review and collides in the same files. Instead **adopt and report**:",
|
|
209
|
+
"complete WITHOUT making any changes and return the EXISTING PR as your result — put it in your `pr`",
|
|
210
|
+
"field (a URL or `owner/repo#N`) and complete with your normal success status, with a `summary` that",
|
|
211
|
+
"names the PR you adopted. This satisfies the node cleanly; a downstream door (or a human) drives the",
|
|
212
|
+
"existing PR the rest of the way.",
|
|
213
|
+
"",
|
|
214
|
+
"Only implement — and open your own PR — when NO claim and NO open PR exist for the issue.",
|
|
215
|
+
"",
|
|
216
|
+
"---",
|
|
217
|
+
"",
|
|
218
|
+
"",
|
|
219
|
+
].join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
181
223
|
/** Render the classifier-emit contract appended to an `agent` node's `appendPrompt` (issue #506) — the
|
|
182
224
|
* instruction that turns a declared `emits[]` into completion variables a downstream guarded split (S7)
|
|
183
225
|
* can route on. A `senior:*` fleet agent completes with the Output-contract envelope (`status`,
|
|
@@ -231,7 +273,7 @@ function buildNodeInput(
|
|
|
231
273
|
// truth. A no-emit node appends nothing, so a plain implementation node is unchanged.
|
|
232
274
|
const basePrompt = node.agent.prompt ?? "";
|
|
233
275
|
const emits = Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [];
|
|
234
|
-
return { jobType: node.agent.jobType, appendPrompt: basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
|
|
276
|
+
return { jobType: node.agent.jobType, appendPrompt: renderIdempotencyPreamble() + basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
|
|
235
277
|
}
|
|
236
278
|
case "wait": {
|
|
237
279
|
const probe = parseProbe(node.wait);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Unit coverage for the ONE canonical progress step axis (app/stepAxis.ts) — the single source of the
|
|
2
|
+
// derived stepper's vocabulary, cell→step mapping, terminal-tier normalization, and the deterministic
|
|
3
|
+
// parallel-frontier reduction (ADR 0006 §4b, issue #541 / S7).
|
|
4
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import { STAGE_DONE_STATUSES, STAGE_KEYS } from "./stage.ts";
|
|
8
|
+
import {
|
|
9
|
+
CELL_STEP,
|
|
10
|
+
DONE_TERMINAL,
|
|
11
|
+
type FrontierBranch,
|
|
12
|
+
INITIAL_STEP,
|
|
13
|
+
reduceFrontier,
|
|
14
|
+
STEP_KEYS,
|
|
15
|
+
stepOrdinal,
|
|
16
|
+
terminalTier,
|
|
17
|
+
TERMINAL_STEP,
|
|
18
|
+
} from "./stepAxis.ts";
|
|
19
|
+
|
|
20
|
+
test("STEP_KEYS is SEEDED from STAGE_KEYS — the axis cannot fork across surfaces", () => {
|
|
21
|
+
assertEquals([...STEP_KEYS], [...STAGE_KEYS]);
|
|
22
|
+
// The two lifecycle bookends are the head/tail of the shared axis.
|
|
23
|
+
assertEquals(INITIAL_STEP, STAGE_KEYS[0]);
|
|
24
|
+
assertEquals(INITIAL_STEP, "Requested");
|
|
25
|
+
assertEquals(TERMINAL_STEP, STAGE_KEYS[STAGE_KEYS.length - 1]);
|
|
26
|
+
assertEquals(TERMINAL_STEP, "Done");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the explicit cell→step mapping collapses every process cell onto an existing STAGE_KEYS bracket (no new axis entries)", () => {
|
|
30
|
+
// The three executable cells map to their lifecycle bracket; the interstitial wait/human/escalation
|
|
31
|
+
// cells HOLD at the host bracket rather than owning a distinct step.
|
|
32
|
+
assertEquals(CELL_STEP.implement, "Implementing");
|
|
33
|
+
assertEquals(CELL_STEP.converge, "Converging");
|
|
34
|
+
assertEquals(CELL_STEP.merge, "Merging");
|
|
35
|
+
assertEquals(CELL_STEP.wait, "Implementing");
|
|
36
|
+
assertEquals(CELL_STEP.human, "Converging");
|
|
37
|
+
assertEquals(CELL_STEP.escalation, "Converging");
|
|
38
|
+
// Every mapped bracket is a real axis key — v1 adds no stages, so the pipeline renderer is unchanged.
|
|
39
|
+
for (const step of Object.values(CELL_STEP)) {
|
|
40
|
+
assert(STEP_KEYS.includes(step), `cell step "${step}" is not a STAGE_KEYS member`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stepOrdinal gives the axis total order the frontier reduction compares advancement by", () => {
|
|
45
|
+
assertEquals(stepOrdinal("Requested"), 0);
|
|
46
|
+
assertEquals(stepOrdinal("Implementing"), 1);
|
|
47
|
+
assertEquals(stepOrdinal("PR open"), 2);
|
|
48
|
+
assertEquals(stepOrdinal("Converging"), 3);
|
|
49
|
+
assertEquals(stepOrdinal("Merging"), 4);
|
|
50
|
+
assertEquals(stepOrdinal("Done"), 5);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("terminalTier reuses the shipped stage_state tiers (converged/merged/done→ok, blocked distinct, failed/skipped/abandoned→failed, active→null)", () => {
|
|
54
|
+
assertEquals(terminalTier("merged"), "ok");
|
|
55
|
+
assertEquals(terminalTier("converged"), "ok");
|
|
56
|
+
assertEquals(terminalTier("done"), "ok");
|
|
57
|
+
assertEquals(terminalTier("blocked"), "blocked");
|
|
58
|
+
assertEquals(terminalTier("failed"), "failed");
|
|
59
|
+
assertEquals(terminalTier("skipped"), "failed");
|
|
60
|
+
assertEquals(terminalTier("abandoned"), "failed");
|
|
61
|
+
assertEquals(terminalTier("running"), null);
|
|
62
|
+
assertEquals(terminalTier("converging"), null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("terminalTier's partition is DERIVED from STAGE_DONE_STATUSES — every canonical terminal is tiered exactly once (drift guard)", () => {
|
|
66
|
+
// The stepAxis module already throws at load if the partition drifts from STAGE_DONE_STATUSES; assert
|
|
67
|
+
// the coupling here too so a reviewer sees the invariant. Every STAGE_DONE_STATUSES member gets a
|
|
68
|
+
// non-null tier, and the only status tiered OUTSIDE that canonical set is the delivery-graph `done`.
|
|
69
|
+
for (const status of STAGE_DONE_STATUSES) {
|
|
70
|
+
assert(terminalTier(status) !== null, `STAGE_DONE_STATUSES member "${status}" must be tiered by terminalTier`);
|
|
71
|
+
}
|
|
72
|
+
assertEquals(terminalTier(DONE_TERMINAL), "ok");
|
|
73
|
+
assert(!STAGE_DONE_STATUSES.includes(DONE_TERMINAL), "`done` is the S7 canonical success value, tiered on top of STAGE_DONE_STATUSES (not a member of it)");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── reduceFrontier — the deterministic parallel-frontier rollup (§4b §280-332) ────────────────────
|
|
77
|
+
|
|
78
|
+
const branch = (nodeId: string, step: FrontierBranch["step"], terminal: string | null = null): FrontierBranch => ({ nodeId, step, terminal });
|
|
79
|
+
|
|
80
|
+
test("a single branch reduces trivially to itself (the feature + S7 delivery-graph coarse case)", () => {
|
|
81
|
+
assertEquals(reduceFrontier([branch("n0", "Implementing")]), { step: "Implementing", state: null });
|
|
82
|
+
assertEquals(reduceFrontier([branch("n0", "Done", "done")]), { step: "Done", state: "ok" });
|
|
83
|
+
assertEquals(reduceFrontier([branch("n0", "Done", "failed")]), { step: "Done", state: "failed" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("all-active frontier reduces to the LEAST-ADVANCED active branch (never further than the slowest in-flight branch)", () => {
|
|
87
|
+
const r = reduceFrontier([branch("a", "Merging"), branch("b", "Implementing"), branch("c", "Converging")]);
|
|
88
|
+
assertEquals(r, { step: "Implementing", state: null });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("all-active ties on step break deterministically by stable node id", () => {
|
|
92
|
+
const r = reduceFrontier([branch("z", "Converging"), branch("a", "Converging")]);
|
|
93
|
+
assertEquals(r, { step: "Converging", state: null });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("MIXED with only SUCCESS terminals + active branches reduces to the least-advanced ACTIVE branch (terminal branches are past, not 'still blocked on')", () => {
|
|
97
|
+
const r = reduceFrontier([branch("done1", "Done", "merged"), branch("active1", "Converging"), branch("active2", "Merging")]);
|
|
98
|
+
assertEquals(r, { step: "Converging", state: null });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("MIXED: a NON-SUCCESS terminal (failed) takes precedence over in-flight siblings — the aggregate renders that branch's step + failed state", () => {
|
|
102
|
+
const r = reduceFrontier([branch("active", "Implementing"), branch("bad", "Converging", "failed"), branch("done", "Done", "merged")]);
|
|
103
|
+
assertEquals(r, { step: "Converging", state: "failed" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("MIXED: a blocked terminal surfaces as the DISTINCT blocked render state (operator-actionable), not masked by an active sibling", () => {
|
|
107
|
+
const r = reduceFrontier([branch("active", "Merging"), branch("stuck", "Implementing", "blocked")]);
|
|
108
|
+
assertEquals(r, { step: "Implementing", state: "blocked" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("MULTIPLE non-success terminals tie-break by earliest terminal step, then stable node id", () => {
|
|
112
|
+
// Two failed branches at different steps → earliest step wins.
|
|
113
|
+
assertEquals(
|
|
114
|
+
reduceFrontier([branch("a", "Converging", "failed"), branch("b", "Implementing", "blocked"), branch("act", "Merging")]),
|
|
115
|
+
{ step: "Implementing", state: "blocked" },
|
|
116
|
+
);
|
|
117
|
+
// Two non-success terminals at the SAME step → stable node id wins (and its own render state).
|
|
118
|
+
assertEquals(
|
|
119
|
+
reduceFrontier([branch("z", "Converging", "failed"), branch("a", "Converging", "blocked")]),
|
|
120
|
+
{ step: "Converging", state: "blocked" },
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("ALL-TERMINAL, all success → done (the axis tail, ok)", () => {
|
|
125
|
+
const r = reduceFrontier([branch("a", "Converging", "converged"), branch("b", "Merging", "merged")]);
|
|
126
|
+
assertEquals(r, { step: "Done", state: "ok" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("ALL-TERMINAL with any non-success → earliest non-success terminal step (same tie-break)", () => {
|
|
130
|
+
const r = reduceFrontier([branch("a", "Merging", "merged"), branch("b", "Converging", "failed"), branch("c", "Implementing", "blocked")]);
|
|
131
|
+
assertEquals(r, { step: "Implementing", state: "blocked" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("reduceFrontier throws on an empty frontier — a unit always has at least its own branch", () => {
|
|
135
|
+
let threw = false;
|
|
136
|
+
try {
|
|
137
|
+
reduceFrontier([]);
|
|
138
|
+
} catch {
|
|
139
|
+
threw = true;
|
|
140
|
+
}
|
|
141
|
+
assert(threw, "expected reduceFrontier([]) to throw");
|
|
142
|
+
});
|
package/app/stepAxis.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// app/stepAxis.ts — the ONE canonical progress step axis for the derived stepper (ADR 0006 §4b, S7).
|
|
2
|
+
//
|
|
3
|
+
// §4b collapses the unit's three progress projections (feature `deriveStage`, epic write-time
|
|
4
|
+
// `epic_phase`, delivery-graph `pollDeliveryGraphPhase`) onto ONE derivation over a single step axis,
|
|
5
|
+
// rendered by the ONE `pipeline` renderer kind. This module owns the canonical definition of that
|
|
6
|
+
// axis: the ordered steps, the explicit cell→step mapping, the terminal-tier normalization, and the
|
|
7
|
+
// deterministic parallel-frontier reduction. Feature (`app/stage.ts` / `app/featureReadModel.ts`) and
|
|
8
|
+
// delivery-graph (`app/deliveryGraphReadModel.ts`) both project onto it; there is no per-surface
|
|
9
|
+
// re-declaration of the step vocabulary.
|
|
10
|
+
//
|
|
11
|
+
// SEEDED FROM `STAGE_KEYS`, OWNS THE MAPPING (§4b §217-232). `STAGE_KEYS` (app/stage.ts) is the closest
|
|
12
|
+
// EXISTING projection of the axis but it is NOT literally a clean cell sequence — it mixes lifecycle
|
|
13
|
+
// states (`Requested` / `PR open` / `Done`) with process cells (`implement` / `converge` / `merge`),
|
|
14
|
+
// and hosts interstitial `wait` / `human` / `escalation` cells. So this module SEEDS `STEP_KEYS` from
|
|
15
|
+
// `STAGE_KEYS` (the single source of truth for the six brackets) but adds the thing `STAGE_KEYS` lacks:
|
|
16
|
+
// an explicit map of which cell entry/exit each step corresponds to, and how the interstitial cells
|
|
17
|
+
// collapse into an existing bracket. v1 leaves the two existing axis consumers physically in place (the
|
|
18
|
+
// exported `STAGE_KEYS` and the static `stages` array in `pages/feature.page.json`) and only SEEDS this
|
|
19
|
+
// mapping from them — deriving/retiring those duplicates is a flagged follow-up, not S7.
|
|
20
|
+
//
|
|
21
|
+
// LIFECYCLE-STAGE FIDELITY ONLY (S7). Per §4b, S7 renders the coarse LIFECYCLE stage, not a per-cell
|
|
22
|
+
// position: even feature is not per-cell today (`deriveStage` collapses a readiness-probe/timer park or
|
|
23
|
+
// an active `implement-task` all to `Implementing`). True mid-cell / per-node resolution is the S8
|
|
24
|
+
// element-instance source (#542, #473). The interstitial-cell mapping below therefore documents the
|
|
25
|
+
// bracket each cell COLLAPSES into; it does not add per-cell steps.
|
|
26
|
+
|
|
27
|
+
import { STAGE_DONE_STATUSES, STAGE_KEYS, type StageKey, type StageState } from "./stage.ts";
|
|
28
|
+
|
|
29
|
+
/** The canonical ordered step axis — SEEDED from `STAGE_KEYS` (app/stage.ts), the single source of
|
|
30
|
+
* truth for the six pipeline brackets: Requested → Implementing → PR open → Converging → Merging →
|
|
31
|
+
* Done. This module owns the cell→step MAPPING onto these keys; the keys themselves stay sourced from
|
|
32
|
+
* `STAGE_KEYS` so the axis cannot fork across surfaces. */
|
|
33
|
+
export const STEP_KEYS: readonly StageKey[] = STAGE_KEYS;
|
|
34
|
+
export type StepKey = StageKey;
|
|
35
|
+
|
|
36
|
+
/** The deterministic INITIAL step for a pre-run / dispatch-pending / first-observation unit — the head
|
|
37
|
+
* of the axis (`Requested`, `STAGE_KEYS[0]`). Pins the scalar `activeField` so it is never undefined on
|
|
38
|
+
* a first observation with no prior lifecycle key (§4b §431-449). */
|
|
39
|
+
export const INITIAL_STEP: StepKey = STEP_KEYS[0];
|
|
40
|
+
|
|
41
|
+
/** The TERMINAL step — the tail of the axis (`Done`). A terminal unit pins its `activeField` here
|
|
42
|
+
* outright (with an `ok`/`failed` render state) so it can never render an undefined/invalid active
|
|
43
|
+
* stage (§4b §436-438). */
|
|
44
|
+
export const TERMINAL_STEP: StepKey = STEP_KEYS[STEP_KEYS.length - 1];
|
|
45
|
+
|
|
46
|
+
/** The process-cell vocabulary of the composed unit (§2/S4): the three executable pipeline cells
|
|
47
|
+
* (`implement` / `converge` / `merge`) plus the interstitial `wait` / `human` / `escalation` cells that
|
|
48
|
+
* can be inserted around them. */
|
|
49
|
+
export type ProcessCell = "implement" | "converge" | "merge" | "wait" | "human" | "escalation";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The explicit **cell → step** mapping (§4b's first deliverable). Each executable cell ENTERS its
|
|
53
|
+
* bracket when the token arrives and EXITS it when the token advances to the next cell's bracket; the
|
|
54
|
+
* three lifecycle markers bracket the cell run — a token before `implement` reads `Requested`, raising
|
|
55
|
+
* the PR on `implement` exit enters `PR open`, and a merged/terminal token reads `Done`.
|
|
56
|
+
*
|
|
57
|
+
* The interstitial `wait` / `human` / `escalation` cells do **not** own a distinct step — they HOLD the
|
|
58
|
+
* frontier at the bracket of the cell they interrupt (a human gate mid-convergence reads `Converging`,
|
|
59
|
+
* a wait-gate before implementation reads `Implementing`), collapsing into an existing `STAGE_KEYS`
|
|
60
|
+
* bracket rather than extending the axis. This is why the `pipeline` renderer needs no new stages for
|
|
61
|
+
* v1: every cell maps onto one of the six existing keys.
|
|
62
|
+
*/
|
|
63
|
+
export const CELL_STEP: Readonly<Record<ProcessCell, StepKey>> = {
|
|
64
|
+
implement: "Implementing",
|
|
65
|
+
converge: "Converging",
|
|
66
|
+
merge: "Merging",
|
|
67
|
+
// Interstitial cells collapse into the host bracket (hold, do not advance).
|
|
68
|
+
wait: "Implementing",
|
|
69
|
+
human: "Converging",
|
|
70
|
+
escalation: "Converging",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** The zero-based ordinal of a step on the axis — the total order the frontier reduction compares
|
|
74
|
+
* "advancement" by. `-1` for an unknown key (defensive; every derived step is a `STEP_KEYS` member). */
|
|
75
|
+
export function stepOrdinal(step: StepKey): number {
|
|
76
|
+
return STEP_KEYS.indexOf(step);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The renderer's terminal render-state tiers, reusing the SHIPPED `featureReadModel` `stage_state`
|
|
80
|
+
* basis (`STAGE_DONE_STATUSES` + the `stage_state` CASE, app/featureReadModel.ts) rather than inventing
|
|
81
|
+
* a second mapping (derivation-over-duplication, §4b §296-308):
|
|
82
|
+
* - `ok` — a SUCCESS terminal: `merged` / `converged` / the new canonical `done`.
|
|
83
|
+
* - `blocked` — the renderer's DISTINCT operator-actionable `blocked` terminal (never folded away).
|
|
84
|
+
* - `failed` — a FAILED terminal: `failed` / `skipped` / `abandoned`.
|
|
85
|
+
* - `null` — not terminal (in progress).
|
|
86
|
+
* Note this is the PER-CELL / per-PR tier: `converged` is a success terminal here. The EPIC rollup's
|
|
87
|
+
* shape-aware predicate (`converged` is resolved-not-landed) is applied by the caller, not here. */
|
|
88
|
+
export type TerminalTier = "ok" | "failed" | "blocked";
|
|
89
|
+
|
|
90
|
+
/** The delivery-graph canonical SUCCESS terminal (app/deliveryGraphReadModel.ts). `STAGE_DONE_STATUSES`
|
|
91
|
+
* predates the S7 axis and does NOT include it, so it is the one status tiered ON TOP of the shared
|
|
92
|
+
* feature basis below (special-cased per the ADR §4b `done` value). */
|
|
93
|
+
export const DONE_TERMINAL = "done";
|
|
94
|
+
|
|
95
|
+
/** The per-cell terminal PARTITION — the SAME basis as featureReadModel's `stage_state` CASE
|
|
96
|
+
* (app/featureReadModel.ts: `merged`/`converged`→ok, `blocked`→blocked, `failed`/`skipped`/`abandoned`→
|
|
97
|
+
* failed). It is NOT a second hand-kept copy: every member is drawn from the shipped
|
|
98
|
+
* `STAGE_DONE_STATUSES`, and the exhaustiveness guard below fails at module load if this partition ever
|
|
99
|
+
* stops covering that canonical set EXACTLY — so a terminal status added to `STAGE_DONE_STATUSES` cannot
|
|
100
|
+
* silently fall through untiered (drift becomes a hard error, not a wrong render). */
|
|
101
|
+
const SUCCESS_TERMINALS: readonly string[] = ["merged", "converged"];
|
|
102
|
+
const BLOCKED_TERMINAL = "blocked";
|
|
103
|
+
const FAILED_TERMINALS: readonly string[] = ["failed", "skipped", "abandoned"];
|
|
104
|
+
|
|
105
|
+
// Structural coupling to the single source of truth: the partition must tier EXACTLY the members of
|
|
106
|
+
// STAGE_DONE_STATUSES (the delivery-graph `done` value is tiered separately, so it is excluded here).
|
|
107
|
+
const _tiered = new Set<string>([...SUCCESS_TERMINALS, BLOCKED_TERMINAL, ...FAILED_TERMINALS]);
|
|
108
|
+
const _canonical = new Set<string>(STAGE_DONE_STATUSES);
|
|
109
|
+
if (_tiered.size !== _canonical.size || [..._canonical].some((s) => !_tiered.has(s))) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"stepAxis terminalTier partition drifted from STAGE_DONE_STATUSES — every terminal status must be " +
|
|
112
|
+
`tiered exactly once. tiered=[${[..._tiered].sort().join(",")}] canonical=[${[..._canonical].sort().join(",")}]`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function terminalTier(status: string): TerminalTier | null {
|
|
117
|
+
if (status === DONE_TERMINAL || SUCCESS_TERMINALS.includes(status)) return "ok";
|
|
118
|
+
if (status === BLOCKED_TERMINAL) return "blocked";
|
|
119
|
+
if (FAILED_TERMINALS.includes(status)) return "failed";
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A `TerminalTier` is a NON-SUCCESS terminal (`failed` / `blocked`) iff it is operator-actionable —
|
|
124
|
+
* the thing an in-flight sibling must not mask. `ok` (success) is the only success terminal. */
|
|
125
|
+
export function isNonSuccessTerminal(tier: TerminalTier): boolean {
|
|
126
|
+
return tier === "failed" || tier === "blocked";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Map a terminal tier onto the `pipeline` column's `stateField` vocabulary (`ok`/`failed`/`blocked`).
|
|
130
|
+
* The raw canonical `done` never reaches the renderer verbatim (any other string silently degrades to
|
|
131
|
+
* in-progress), so a success terminal renders as `ok`. Total over the three tiers. */
|
|
132
|
+
export function tierRenderState(tier: TerminalTier): StageState {
|
|
133
|
+
return tier;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One branch of an aggregate's active frontier, projected onto the canonical axis. `terminal` is the
|
|
137
|
+
* branch's canonical terminal status when it has settled (`merged`/`converged`/`done`/`blocked`/
|
|
138
|
+
* `failed`/`skipped`/`abandoned`), or `null` while the branch is still active at `step`. `nodeId` is
|
|
139
|
+
* the stable identity used as the deterministic tie-break. */
|
|
140
|
+
export interface FrontierBranch {
|
|
141
|
+
nodeId: string;
|
|
142
|
+
step: StepKey;
|
|
143
|
+
terminal: string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The reduced scalar the `pipeline` column binds: one `STAGE_KEYS` step plus its render state. */
|
|
147
|
+
export interface ReducedFrontier {
|
|
148
|
+
step: StepKey;
|
|
149
|
+
state: StageState;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Deterministic tie-break: earliest step ordinal, then stable `nodeId`. */
|
|
153
|
+
function earliest(a: FrontierBranch, b: FrontierBranch): FrontierBranch {
|
|
154
|
+
const da = stepOrdinal(a.step);
|
|
155
|
+
const db = stepOrdinal(b.step);
|
|
156
|
+
if (da !== db) return da < db ? a : b;
|
|
157
|
+
return a.nodeId <= b.nodeId ? a : b;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reduce a parallel active frontier to ONE deterministic step for the scalar `pipeline` `activeField`
|
|
162
|
+
* (§4b §280-332). The `pipeline` column binds a single scalar, but an N-node/parallel DAG (epic waves,
|
|
163
|
+
* delivery graphs) can occupy incomparable cells at once, so the frontier is reduced deterministically:
|
|
164
|
+
*
|
|
165
|
+
* - A **non-success terminal** (`failed` / `blocked`) takes PRECEDENCE in every case — it is an
|
|
166
|
+
* operator-actionable signal an in-flight sibling must not mask. Among multiple non-success
|
|
167
|
+
* terminals the tie-break is earliest terminal step, then stable node id; the aggregate renders at
|
|
168
|
+
* that branch's step with that terminal's render state (`failed` / `blocked`).
|
|
169
|
+
* - Otherwise, if any branch is still ACTIVE, reduce to the **least-advanced active branch** (the
|
|
170
|
+
* "still blocked on" read) with an in-progress state — the aggregate never renders further along
|
|
171
|
+
* than its slowest in-flight branch. Terminal (success) branches are past, not "still blocked on".
|
|
172
|
+
* - Otherwise every branch is a SUCCESS terminal → `done` (the axis tail, `ok`).
|
|
173
|
+
*
|
|
174
|
+
* The shape-aware epic success predicate (`converged` is resolved-not-landed for an epic rollup, not a
|
|
175
|
+
* success terminal) is applied by the CALLER when it classifies each branch's `terminal`; this reducer
|
|
176
|
+
* treats whatever terminal it is handed per the per-cell tier. A single-branch unit (feature, and a
|
|
177
|
+
* delivery-graph at S7's coarse run-level fidelity) reduces trivially to that branch.
|
|
178
|
+
*
|
|
179
|
+
* Throws on an empty frontier — a unit always has at least its own (initial) branch; an empty input is
|
|
180
|
+
* a caller bug, not a renderable state.
|
|
181
|
+
*/
|
|
182
|
+
export function reduceFrontier(branches: readonly FrontierBranch[]): ReducedFrontier {
|
|
183
|
+
if (branches.length === 0) {
|
|
184
|
+
throw new Error("reduceFrontier: empty frontier — a unit always has at least one branch");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const nonSuccess: FrontierBranch[] = [];
|
|
188
|
+
const active: FrontierBranch[] = [];
|
|
189
|
+
for (const b of branches) {
|
|
190
|
+
if (b.terminal === null) {
|
|
191
|
+
active.push(b);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const tier = terminalTier(b.terminal);
|
|
195
|
+
// An unrecognised terminal degrades to a failed-tier signal (defensive; the caller passes canonical
|
|
196
|
+
// union terminals) so it is never silently masked by an in-flight sibling.
|
|
197
|
+
if (tier === null || isNonSuccessTerminal(tier)) nonSuccess.push(b);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (nonSuccess.length > 0) {
|
|
201
|
+
const pick = nonSuccess.reduce(earliest);
|
|
202
|
+
const tier = terminalTier(pick.terminal ?? "") ?? "failed";
|
|
203
|
+
return { step: pick.step, state: tierRenderState(tier) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (active.length > 0) {
|
|
207
|
+
const pick = active.reduce(earliest);
|
|
208
|
+
return { step: pick.step, state: null };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// All branches are success terminals → the shared success bucket (`done`).
|
|
212
|
+
return { step: TERMINAL_STEP, state: "ok" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Re-export the shipped terminal-status set the tier basis draws on, so a consumer can reference the
|
|
216
|
+
* single source without a second import of app/featureReadModel.ts. */
|
|
217
|
+
export { STAGE_DONE_STATUSES };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- Delivery-graph read model: DECLARE ONCE, compile to BOTH backends (ADR-0065, nano-ide#452), for the
|
|
2
|
+
-- ONE derived stepper (ADR 0006 §4b, issue #541 / S7).
|
|
3
|
+
--
|
|
4
|
+
-- Before §4b the delivery-graph surfaces rendered `delivery_graph_runs.phase` — a bare text projection
|
|
5
|
+
-- the user-task park poll (`pollDeliveryGraphPhase`) recomputes — on a DIFFERENT renderer from
|
|
6
|
+
-- feature's `pipeline` stepper. S7 collapses feature + delivery-graph onto the ONE canonical step axis
|
|
7
|
+
-- (app/stepAxis.ts, seeded from `STAGE_KEYS`) rendered by the ONE `pipeline` kind. This migration adds
|
|
8
|
+
-- the delivery-graph half: a `delivery_graph_read_model` VIEW that maps the run's lifecycle onto a
|
|
9
|
+
-- CONFIGURED `STAGE_KEYS` bracket (`stage`) with a render state (`stage_state`), plus a companion
|
|
10
|
+
-- `park_label` display column carrying the actionable "Parked on human node: <label>" text so promoting
|
|
11
|
+
-- the stepper does not drop the detail the plain Phase cell showed.
|
|
12
|
+
--
|
|
13
|
+
-- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
|
|
14
|
+
-- app/deliveryGraphReadModel.ts — the member-PR rollup DDL from `deliveryGraphPrCounts.viewDdl()`, and
|
|
15
|
+
-- `stage`/`stage_state` from `deliveryGraphReadModel.sqlSelectFor(col, { baseAlias: "dg" })` — which
|
|
16
|
+
-- ALSO drive the runtime TS via `reduce`/`fnFor`. The two lowerings fall out of the same closed-DSL AST
|
|
17
|
+
-- and cannot diverge; a drift guard (app/deliveryGraphReadModel.test.ts) fails if this file stops
|
|
18
|
+
-- matching the declaration, and `assertRollupParity`/`assertReadModelParity` prove the SQL and TS
|
|
19
|
+
-- lowerings agree.
|
|
20
|
+
--
|
|
21
|
+
-- PER-SHAPE CORRELATION. A delivery-graph run has no aggregate `pr_key`; its downstream PRs attach via
|
|
22
|
+
-- `pull_requests.root_request_key = delivery_graph_runs.run_key`. The `delivery_graph_pr_counts` rollup
|
|
23
|
+
-- folds the member PRs (through `pull_requests__tracking.derived_status`, so an out-of-band-terminated
|
|
24
|
+
-- PR is not held in flight) grouped by `root_request_key`, and the read model LEFT JOINs it on
|
|
25
|
+
-- `dg.run_key = pc.root_request_key`. `prs_in_flight > 0` tempers a `running` run to `Converging`,
|
|
26
|
+
-- matching the shipped `deliveryOriginStage` (app/lineage.ts) — NOT a `process_key` join (that key is
|
|
27
|
+
-- reassigned to the downstream convergence/merge instances).
|
|
28
|
+
--
|
|
29
|
+
-- SEMANTICS. The status-classifying `stage`/`stage_state` read the terminal-folded `derived_status` off
|
|
30
|
+
-- the auto-provisioned `delivery_graph_runs__tracking` derived VIEW (ADR-0065), so a cancelled run
|
|
31
|
+
-- renders `Done`/`failed` instead of freezing at `Implementing`/`Converging`. Base columns stay aliased
|
|
32
|
+
-- identity pass-throughs (so the static pages↔schema contract guard sees the VIEW's columns), sourced
|
|
33
|
+
-- off `delivery_graph_runs__tracking`'s re-export of the base `delivery_graph_runs.*`; `status` is the
|
|
34
|
+
-- effective `COALESCE(derived_status, status)` so the pages' Active/History status filter tracks a
|
|
35
|
+
-- terminated run. `park_label` is a hand-authored DISPLAY column (D3 — display formatting is out of the
|
|
36
|
+
-- framework AST, so it carries no TS twin): the run's `phase` when it is parked on a human node
|
|
37
|
+
-- (`phase_node_id` set), else NULL.
|
|
38
|
+
--
|
|
39
|
+
-- Forward-only VIEW definition (DROP then CREATE). `delivery_graph_runs__tracking` is the managed VIEW
|
|
40
|
+
-- urban provisions at mount; SQLite does not validate a view body at CREATE time, so this migration
|
|
41
|
+
-- (which runs before that mount) is created fine and resolves once the managed VIEW exists. The runner
|
|
42
|
+
-- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 085.
|
|
43
|
+
|
|
44
|
+
DROP VIEW IF EXISTS delivery_graph_read_model;
|
|
45
|
+
DROP VIEW IF EXISTS delivery_graph_pr_counts;
|
|
46
|
+
|
|
47
|
+
CREATE VIEW IF NOT EXISTS "delivery_graph_pr_counts" AS
|
|
48
|
+
SELECT
|
|
49
|
+
"__urban_rollup_src"."root_request_key" AS "root_request_key",
|
|
50
|
+
SUM(CASE WHEN COALESCE(((NOT COALESCE(("__urban_rollup_src"."root_request_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("__urban_rollup_src"."derived_status" = 'converged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'merged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight"
|
|
51
|
+
FROM "pull_requests__tracking" "__urban_rollup_src"
|
|
52
|
+
GROUP BY "__urban_rollup_src"."root_request_key";
|
|
53
|
+
|
|
54
|
+
CREATE VIEW delivery_graph_read_model AS
|
|
55
|
+
SELECT
|
|
56
|
+
dg.run_key AS run_key,
|
|
57
|
+
COALESCE(dg.derived_status, dg.status) AS status,
|
|
58
|
+
dg.process_key AS process_key,
|
|
59
|
+
dg.process_definition_id AS process_definition_id,
|
|
60
|
+
dg.digest AS digest,
|
|
61
|
+
dg.side_effecting AS side_effecting,
|
|
62
|
+
dg.node_count AS node_count,
|
|
63
|
+
dg.human_node_count AS human_node_count,
|
|
64
|
+
dg.side_effect_count AS side_effect_count,
|
|
65
|
+
dg.title AS title,
|
|
66
|
+
dg.phase AS phase,
|
|
67
|
+
dg.phase_node_id AS phase_node_id,
|
|
68
|
+
dg.human_labels AS human_labels,
|
|
69
|
+
dg.created_at AS created_at,
|
|
70
|
+
dg.updated_at AS updated_at,
|
|
71
|
+
CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'Done' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("dg"."derived_status" = 'awaiting-approval'), 0) THEN 'Requested' WHEN COALESCE((COALESCE("pc"."prs_in_flight", 0) > 0), 0) THEN 'Converging' ELSE 'Implementing' END AS stage,
|
|
72
|
+
CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'ok' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
|
|
73
|
+
CASE WHEN dg.phase_node_id IS NOT NULL THEN dg.phase ELSE NULL END AS park_label
|
|
74
|
+
FROM delivery_graph_runs__tracking dg
|
|
75
|
+
LEFT JOIN delivery_graph_pr_counts pc ON dg.run_key = pc.root_request_key;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.144.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",
|
|
@@ -85,14 +85,30 @@
|
|
|
85
85
|
"data": {
|
|
86
86
|
"kind": "datasource",
|
|
87
87
|
"source": "app",
|
|
88
|
-
"table": "
|
|
88
|
+
"table": "delivery_graph_read_model",
|
|
89
89
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
90
90
|
"filter": [{ "field": "run_key", "eqParam": true }]
|
|
91
91
|
},
|
|
92
92
|
"columns": [
|
|
93
93
|
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "30%" },
|
|
94
94
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
95
|
-
{
|
|
95
|
+
{
|
|
96
|
+
"field": "stage",
|
|
97
|
+
"header": "Pipeline",
|
|
98
|
+
"kind": "pipeline",
|
|
99
|
+
"stages": [
|
|
100
|
+
{ "key": "Requested", "label": "Requested" },
|
|
101
|
+
{ "key": "Implementing", "label": "Implementing" },
|
|
102
|
+
{ "key": "PR open", "label": "PR open" },
|
|
103
|
+
{ "key": "Converging", "label": "Converging" },
|
|
104
|
+
{ "key": "Merging", "label": "Merging" },
|
|
105
|
+
{ "key": "Done", "label": "Done" }
|
|
106
|
+
],
|
|
107
|
+
"activeField": "stage",
|
|
108
|
+
"stateField": "stage_state",
|
|
109
|
+
"locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
|
|
110
|
+
},
|
|
111
|
+
{ "field": "park_label", "header": "Parked", "truncate": true, "width": "30%" },
|
|
96
112
|
{ "field": "node_count", "header": "Nodes" },
|
|
97
113
|
{ "field": "human_node_count", "header": "Human" },
|
|
98
114
|
{ "field": "side_effect_count", "header": "Side effects" },
|