@nanobpm/nano-workforce 0.154.0 → 0.156.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/deliveryDispatch.test.ts +248 -0
- package/app/deliveryDispatch.ts +153 -0
- package/app/deliveryUnit.test.ts +244 -0
- package/app/deliveryUnit.ts +109 -0
- package/db/migrations/088_delivery_units.sql +94 -0
- package/db/migrations/089_delivery_units_sync_triggers.sql +94 -0
- package/db/migrations/090_delivery_units_backfill.sql +120 -0
- package/db/migrations/091_delivery_units_read_models.sql +111 -0
- package/nano.app.json +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.156.0](https://github.com/nanobpm/nano-workforce/compare/v0.155.0...v0.156.0) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-units:** single (kind, instanceId) dispatch door on delivery_units (ADR 0006 S3) ([#602](https://github.com/nanobpm/nano-workforce/issues/602)) ([f736b4b](https://github.com/nanobpm/nano-workforce/commit/f736b4bfaf838b4b4b000b0b84d76330a05edf7c)), closes [#590](https://github.com/nanobpm/nano-workforce/issues/590)
|
|
6
|
+
|
|
7
|
+
## [0.155.0](https://github.com/nanobpm/nano-workforce/compare/v0.154.0...v0.155.0) (2026-08-29)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **delivery-units:** delivery_units aggregate table (ADR 0006 S2, [#589](https://github.com/nanobpm/nano-workforce/issues/589)) ([#601](https://github.com/nanobpm/nano-workforce/issues/601)) ([6949fc4](https://github.com/nanobpm/nano-workforce/commit/6949fc4d3ee567b95497e43391ba15e9367a88e0))
|
|
12
|
+
|
|
1
13
|
## [0.154.0](https://github.com/nanobpm/nano-workforce/compare/v0.153.0...v0.154.0) (2026-08-29)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Coverage for ADR 0006 slice S3 (#590) — the SINGLE dispatch door (`app/deliveryDispatch.ts`) and
|
|
2
|
+
// its one `delivery_units` `instanceTracking` binding, which collapse the three per-representation
|
|
3
|
+
// `senior:*` dispatch paths onto the aggregate.
|
|
4
|
+
//
|
|
5
|
+
// Proves:
|
|
6
|
+
// 1. VERB PARITY — the door's kind → `senior:*` target map uses the SAME job-type names the
|
|
7
|
+
// pre-collapse BPMN models dispatch (`senior:feature`, `senior:plan`). Collapsing the doors never
|
|
8
|
+
// renames a dispatch target; every mapped verb is a real prompt-bearing agent task in the
|
|
9
|
+
// deployed models.
|
|
10
|
+
// 2. KEY IDENTITY — `(kind, instanceId)` names exactly the S2 `unit_id` the identity helpers build,
|
|
11
|
+
// so the two-arg door key IS the aggregate key (no per-representation key builder survives).
|
|
12
|
+
// 3. GATE PARITY — the one re-dispatch gate matches the S1/S2 `isDeliveryUnitSettled` /
|
|
13
|
+
// `dispatchStatusForDelivery` short-circuit for EVERY canonical status: only `requested`(pending)
|
|
14
|
+
// dispatches; every live/parked/terminal state short-circuits — the exact rule each pre-collapse
|
|
15
|
+
// launcher re-implemented.
|
|
16
|
+
// 4. BINDING — the single `delivery_units` binding drives the door: it is the ONLY new binding, keys
|
|
17
|
+
// on `dispatch_status`, lists no `settled` state active, and provisions its `__tracking` VIEW
|
|
18
|
+
// against a REAL migrated DB.
|
|
19
|
+
import { DatabaseSync } from "node:sqlite";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { test } from "node:test";
|
|
22
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
23
|
+
import { applyMigrationSet, readMigrationSetFromDisk } from "#test-migrations";
|
|
24
|
+
import { assert, assertEquals } from "#test-assert";
|
|
25
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
26
|
+
import {
|
|
27
|
+
DELIVERY_UNITS_TABLE,
|
|
28
|
+
DISPATCH_JOB_TYPE_BY_KIND,
|
|
29
|
+
deliveryUnitKey,
|
|
30
|
+
dispatchGate,
|
|
31
|
+
dispatchJobTypeForKind,
|
|
32
|
+
resolveDeliveryDispatch,
|
|
33
|
+
} from "./deliveryDispatch.ts";
|
|
34
|
+
import {
|
|
35
|
+
DELIVERY_UNIT_KINDS,
|
|
36
|
+
deliveryGraphUnitId,
|
|
37
|
+
dispatchStatusForDelivery,
|
|
38
|
+
epicUnitId,
|
|
39
|
+
featureUnitId,
|
|
40
|
+
planTaskUnitId,
|
|
41
|
+
} from "./deliveryUnit.ts";
|
|
42
|
+
import { DELIVERY_UNIT_STATUSES, type DeliveryUnitStatus, isDeliveryUnitSettled } from "./deliveryUnitStatus.ts";
|
|
43
|
+
import { promptBearingTaskTypes } from "./agentic/vocab/job-types.ts";
|
|
44
|
+
|
|
45
|
+
interface Binding {
|
|
46
|
+
table: string;
|
|
47
|
+
keyField?: string;
|
|
48
|
+
statusField?: string;
|
|
49
|
+
activeStatuses?: string[];
|
|
50
|
+
onTerminated: { set: Record<string, unknown> };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function manifestBindings(): Binding[] {
|
|
54
|
+
const manifest = JSON.parse(readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"));
|
|
55
|
+
return manifest.instanceTracking as Binding[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The deployed process models — scanned for their prompt-bearing agent tasks, the real dispatch
|
|
59
|
+
// corpus the door's verbs must already exist in.
|
|
60
|
+
const MODEL_FILES = [
|
|
61
|
+
"feature.bpmn",
|
|
62
|
+
"plan-fanout.bpmn",
|
|
63
|
+
"convergence-loop.bpmn",
|
|
64
|
+
"merge-loop.bpmn",
|
|
65
|
+
"retro.bpmn",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function deployedAgentJobTypes(): Set<string> {
|
|
69
|
+
const types = new Set<string>();
|
|
70
|
+
for (const file of MODEL_FILES) {
|
|
71
|
+
const xml = readFileSync(new URL(`../resources/processes/${file}`, import.meta.url), "utf8");
|
|
72
|
+
for (const t of promptBearingTaskTypes(xml)) types.add(t);
|
|
73
|
+
}
|
|
74
|
+
return types;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("VERB PARITY: every mapped dispatch verb is a real senior:* agent task in the deployed models", () => {
|
|
78
|
+
const deployed = deployedAgentJobTypes();
|
|
79
|
+
for (const kind of DELIVERY_UNIT_KINDS) {
|
|
80
|
+
const verb = DISPATCH_JOB_TYPE_BY_KIND[kind];
|
|
81
|
+
if (verb === null) continue; // delivery-graph is runner-launched, no single verb
|
|
82
|
+
assert(verb.startsWith("senior:"), `${kind} must dispatch a senior:* verb, got ${verb}`);
|
|
83
|
+
assert(deployed.has(verb), `${kind} dispatches ${verb}, which must be a deployed agent task`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("VERB PARITY: the implementation kinds keep the pre-collapse senior:feature target", () => {
|
|
88
|
+
// feature.bpmn's implement task and plan-fanout.bpmn's per-slice implement task both dispatch
|
|
89
|
+
// senior:feature today — the door preserves that for the single-issue implementation kinds.
|
|
90
|
+
assertEquals(dispatchJobTypeForKind("feature"), "senior:feature");
|
|
91
|
+
assertEquals(dispatchJobTypeForKind("plan-task"), "senior:feature");
|
|
92
|
+
assertEquals(dispatchJobTypeForKind("bugfix"), "senior:feature");
|
|
93
|
+
assertEquals(dispatchJobTypeForKind("chore"), "senior:feature");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("VERB PARITY: an epic dispatches the pre-collapse senior:plan decomposition target", () => {
|
|
97
|
+
assertEquals(dispatchJobTypeForKind("epic"), "senior:plan");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("VERB PARITY: a delivery-graph unit has no single agent verb (runner-launched)", () => {
|
|
101
|
+
assertEquals(dispatchJobTypeForKind("delivery-graph"), null);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("VERB PARITY: the map covers exactly the closed kind enum (no drift)", () => {
|
|
105
|
+
assertEquals(Object.keys(DISPATCH_JOB_TYPE_BY_KIND).sort(), [...DELIVERY_UNIT_KINDS].sort());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("KEY IDENTITY: (kind, instanceId) names exactly the S2 unit_id the identity helpers build", () => {
|
|
109
|
+
assertEquals(deliveryUnitKey("feature", "owner/repo#42"), featureUnitId("owner/repo#42"));
|
|
110
|
+
assertEquals(deliveryUnitKey("epic", "plan-key-1"), epicUnitId("plan-key-1"));
|
|
111
|
+
assertEquals(deliveryUnitKey("plan-task", "plan-key-1#3"), planTaskUnitId("plan-key-1", 3));
|
|
112
|
+
assertEquals(deliveryUnitKey("delivery-graph", "run-key-1"), deliveryGraphUnitId("run-key-1"));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("GATE PARITY: the door dispatches iff pending, and matches isDeliveryUnitSettled for every status", () => {
|
|
116
|
+
for (const status of DELIVERY_UNIT_STATUSES as readonly DeliveryUnitStatus[]) {
|
|
117
|
+
const dispatchStatus = dispatchStatusForDelivery(status);
|
|
118
|
+
const gate = dispatchGate(dispatchStatus);
|
|
119
|
+
// Only the pre-dispatch canonical `requested` (⇒ pending) launches a fresh executor.
|
|
120
|
+
assertEquals(gate.dispatch, status === "requested", `dispatch decision for canonical ${status}`);
|
|
121
|
+
if (isDeliveryUnitSettled(status)) {
|
|
122
|
+
assertEquals(gate.reason, "settled", `${status} is settled-for-re-dispatch ⇒ short-circuit`);
|
|
123
|
+
} else if (status === "requested") {
|
|
124
|
+
assertEquals(gate.reason, "pending");
|
|
125
|
+
} else {
|
|
126
|
+
assertEquals(gate.reason, "in-flight", `${status} has a live executor ⇒ at-most-once skip`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("GATE PARITY: a unit the aggregate never recorded short-circuits as unknown-unit", () => {
|
|
132
|
+
assertEquals(dispatchGate(null), { dispatch: false, reason: "unknown-unit" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("BINDING: exactly one new delivery_units binding drives the door", () => {
|
|
136
|
+
const du = manifestBindings().filter((b) => b.table === DELIVERY_UNITS_TABLE);
|
|
137
|
+
assertEquals(du.length, 1, "there must be exactly one delivery_units instanceTracking binding");
|
|
138
|
+
const b = du[0];
|
|
139
|
+
assertEquals(b.keyField, "process_key");
|
|
140
|
+
assertEquals(b.statusField, "dispatch_status");
|
|
141
|
+
assertEquals(b.onTerminated.set, { dispatch_status: "settled" });
|
|
142
|
+
// A settled unit is terminal/resting — listing it active would let the reconciler clobber it.
|
|
143
|
+
assert(!b.activeStatuses?.includes("settled"), "settled must not be an active dispatch status");
|
|
144
|
+
// `pending` is NOT instance-tracked: it has no engine instance yet, and some pending rows (e.g.
|
|
145
|
+
// kind="plan-task") carry a NULL process_key, so a process_key-keyed reconciler would treat them as
|
|
146
|
+
// "vanished" and wrongly apply onTerminated. Only the instance-backed `dispatched` is tracked —
|
|
147
|
+
// mirroring the delivery_graph_runs binding's invariant.
|
|
148
|
+
assert(!b.activeStatuses?.includes("pending"), "pending must not be an active dispatch status");
|
|
149
|
+
assertEquals(b.activeStatuses, ["dispatched"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("BINDING: the delivery_units__tracking VIEW provisions against the migrated schema", () => {
|
|
153
|
+
const db = new DatabaseSync(":memory:");
|
|
154
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
155
|
+
applyMigrationSet(db, readMigrationSetFromDisk());
|
|
156
|
+
// The base aggregate exists with the door's status column; the derived tracking VIEW the binding
|
|
157
|
+
// provisions (delivery_units__tracking) is created by urban at gen/deploy time, not migration time,
|
|
158
|
+
// so here we assert the base columns the binding names are present and typed for the door to read.
|
|
159
|
+
const cols = db.prepare("PRAGMA table_info(delivery_units)").all() as { name: string }[];
|
|
160
|
+
const names = new Set(cols.map((c) => c.name));
|
|
161
|
+
assert(names.has("process_key"), "keyField process_key must exist on delivery_units");
|
|
162
|
+
assert(names.has("dispatch_status"), "statusField dispatch_status must exist on delivery_units");
|
|
163
|
+
db.close();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// A fake DataLayer whose `delivery_units` store is keyed on `unit_id` and whose `delivery_units__tracking`
|
|
167
|
+
// derived VIEW is served by `withTrackingViews` (projecting `derived_status := seeded ?? base.dispatch_status`,
|
|
168
|
+
// exactly the ADR-0065 fall-through the real runtime computes). This lets a test seed a base row whose
|
|
169
|
+
// `derived_status` DIVERGES from its base `dispatch_status` — the terminated-executor case the door's
|
|
170
|
+
// derived-view read exists to fold in — without a live engine.
|
|
171
|
+
function memData(): DataLayer {
|
|
172
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only fake over dynamic row shapes.
|
|
173
|
+
const stores: Record<string, any[]> = {};
|
|
174
|
+
function tbl(name: string, pk = "unit_id") {
|
|
175
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row store.
|
|
176
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
177
|
+
return {
|
|
178
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row.
|
|
179
|
+
async insert(row: any) {
|
|
180
|
+
rows.push({ ...row });
|
|
181
|
+
return row[pk];
|
|
182
|
+
},
|
|
183
|
+
async get(key: unknown) {
|
|
184
|
+
return rows.find((r) => r[pk] === key);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function seedUnit(
|
|
192
|
+
data: DataLayer,
|
|
193
|
+
unit_id: string,
|
|
194
|
+
dispatch_status: string | null,
|
|
195
|
+
derived_status?: string,
|
|
196
|
+
) {
|
|
197
|
+
const row: Record<string, unknown> = { unit_id, dispatch_status };
|
|
198
|
+
if (derived_status !== undefined) row.derived_status = derived_status;
|
|
199
|
+
await data.table(DELIVERY_UNITS_TABLE, "unit_id").insert(row);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test("DOOR: resolveDeliveryDispatch dispatches a pending unit off the derived VIEW", async () => {
|
|
203
|
+
const data = memData();
|
|
204
|
+
await seedUnit(data, deliveryUnitKey("feature", "owner/repo#7"), "pending");
|
|
205
|
+
const decision = await resolveDeliveryDispatch(data, "feature", "owner/repo#7");
|
|
206
|
+
assertEquals(decision, {
|
|
207
|
+
unitId: "feature:owner/repo#7",
|
|
208
|
+
kind: "feature",
|
|
209
|
+
dispatch: true,
|
|
210
|
+
jobType: "senior:feature",
|
|
211
|
+
reason: "pending",
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("DOOR: resolveDeliveryDispatch skips a still-dispatched unit as in-flight (at-most-once)", async () => {
|
|
216
|
+
const data = memData();
|
|
217
|
+
await seedUnit(data, deliveryUnitKey("plan-task", "plan-1#3"), "dispatched");
|
|
218
|
+
const decision = await resolveDeliveryDispatch(data, "plan-task", "plan-1#3");
|
|
219
|
+
assertEquals(decision.dispatch, false);
|
|
220
|
+
assertEquals(decision.reason, "in-flight");
|
|
221
|
+
assertEquals(decision.jobType, null);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("DOOR: resolveDeliveryDispatch reads derived_status, so a terminated executor is settled not stranded dispatched", async () => {
|
|
225
|
+
// The base row still reads `dispatched` (the worker-owned transient the reconciler no longer
|
|
226
|
+
// overwrites), but the executor terminated out-of-band so the __tracking VIEW's `derived_status`
|
|
227
|
+
// is `settled` (the binding's onTerminated edge). Reading the base column would strand the unit as
|
|
228
|
+
// `in-flight`; the door MUST read `derived_status` and report `settled`. This is the regression the
|
|
229
|
+
// Copilot review flagged — a guard against reading the base table/statusField instead of the view.
|
|
230
|
+
const data = memData();
|
|
231
|
+
await seedUnit(data, deliveryUnitKey("feature", "owner/repo#9"), "dispatched", "settled");
|
|
232
|
+
const decision = await resolveDeliveryDispatch(data, "feature", "owner/repo#9");
|
|
233
|
+
assertEquals(decision.reason, "settled", "derived terminal status must win over the base dispatched");
|
|
234
|
+
assertEquals(decision.dispatch, false);
|
|
235
|
+
assertEquals(decision.jobType, null);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("DOOR: resolveDeliveryDispatch refuses to launch a unit the aggregate never recorded", async () => {
|
|
239
|
+
const data = memData();
|
|
240
|
+
const decision = await resolveDeliveryDispatch(data, "feature", "owner/repo#404");
|
|
241
|
+
assertEquals(decision, {
|
|
242
|
+
unitId: "feature:owner/repo#404",
|
|
243
|
+
kind: "feature",
|
|
244
|
+
dispatch: false,
|
|
245
|
+
jobType: null,
|
|
246
|
+
reason: "unknown-unit",
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// ADR 0006 slice S3 (#590) — the SINGLE dispatch door, keyed on `(kind, instanceId)`.
|
|
2
|
+
//
|
|
3
|
+
// Before S3 the fleet had THREE parallel dispatch paths — one per delivery-unit REPRESENTATION
|
|
4
|
+
// (`feature` via feature.bpmn, `epic`/`plan-task` via plan-fanout.bpmn, `delivery-graph` via the
|
|
5
|
+
// engine-native runner) — each choosing its own `senior:*` implementation job and each gating
|
|
6
|
+
// re-dispatch off its own bespoke status union. ADR 0006 §2 collapses those onto ONE door that reads
|
|
7
|
+
// the aggregate's `delivery_units.dispatch_status` and dispatches on the two universal facts a unit
|
|
8
|
+
// carries — its `kind` and its `instanceId` — instead of three representation-specific launchers.
|
|
9
|
+
//
|
|
10
|
+
// What this door owns:
|
|
11
|
+
// 1. The `(kind, instanceId) → unit_id` key derivation (the aggregate's universal identity; the
|
|
12
|
+
// unit_id IS `<kind>:<instanceId>`, so the door's two args ARE the delivery unit's key).
|
|
13
|
+
// 2. The kind → STABLE `senior:*` dispatch-target verb map. Per #464 ("What survives" #3) the
|
|
14
|
+
// `senior:*` names are DISPATCH TARGETS, not implementations — they stay the stable verbs the
|
|
15
|
+
// deployed fleet already answers (`senior:feature`, `senior:plan`), so collapsing the doors never
|
|
16
|
+
// renames a job type.
|
|
17
|
+
// 3. The single re-dispatch gate, read straight off `dispatch_status` (the S2 lifecycle derived from
|
|
18
|
+
// the S1 canonical union): dispatch ONLY when `pending`; a `dispatched` unit has a live executor
|
|
19
|
+
// (at-most-once) and a `settled` unit already reached a terminal/resting outcome — both
|
|
20
|
+
// short-circuit. This is exactly the `isDeliveryUnitSettled` re-dispatch semantics S1/S2 defined,
|
|
21
|
+
// so the one door matches every pre-collapse launcher's short-circuit without re-deriving it.
|
|
22
|
+
//
|
|
23
|
+
// The active/tracking half is wired in `nano.app.json`: a single `delivery_units` `instanceTracking`
|
|
24
|
+
// binding (keyField `process_key`, statusField `dispatch_status`) is ADDED as the SOURCE the door is
|
|
25
|
+
// driven by — `deliveryUnitActiveDispatchStatuses()` reads that one binding, so the door and the
|
|
26
|
+
// framework reconciler can never drift on "what counts as in-flight". The legacy per-representation
|
|
27
|
+
// bindings (`feature_runs`, `plans`, `delivery_graph_runs`, …) are retained in this slice and retired
|
|
28
|
+
// onto this single binding in the later contract phase, not by this diff.
|
|
29
|
+
//
|
|
30
|
+
// Only `dispatched` is instance-tracked (an executor/engine instance backs it): a `pending` unit has
|
|
31
|
+
// no instance yet — and some `pending` rows (e.g. `kind="plan-task"`) carry a NULL `process_key` — so
|
|
32
|
+
// tracking `pending` would make the `process_key`-keyed reconciler treat those rows as "vanished" and
|
|
33
|
+
// wrongly apply `onTerminated`. `settled` is terminal/resting. This mirrors the `delivery_graph_runs`
|
|
34
|
+
// binding's invariant (only instance-backed statuses are instance-tracked).
|
|
35
|
+
|
|
36
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
37
|
+
import type { DeliveryUnitDispatchStatus, DeliveryUnitKind } from "./deliveryUnit.ts";
|
|
38
|
+
import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
|
|
39
|
+
|
|
40
|
+
/** The base table the single dispatch door is keyed on — the S2 aggregate. */
|
|
41
|
+
export const DELIVERY_UNITS_TABLE = "delivery_units";
|
|
42
|
+
|
|
43
|
+
/** Narrow a raw `derived_status` string to the closed dispatch-status domain (no `as`). Any value
|
|
44
|
+
* outside the domain — including a missing row / NULL — resolves to `null` (⇒ `unknown-unit`). */
|
|
45
|
+
function asDispatchStatus(value: string | null | undefined): DeliveryUnitDispatchStatus | null {
|
|
46
|
+
return value === "pending" || value === "dispatched" || value === "settled" ? value : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The stable `senior:*` dispatch-target verb each delivery-unit KIND dispatches to — the collapse of
|
|
51
|
+
* the per-representation launchers onto one map (#464 "What survives" #3). The verbs are unchanged
|
|
52
|
+
* from the pre-collapse models (`app/deliveryDispatch.test.ts` pins them against the deployed BPMN):
|
|
53
|
+
* - `feature` / `plan-task` — a single-issue implementation ⇒ `senior:feature` (feature.bpmn's
|
|
54
|
+
* implement task and plan-fanout.bpmn's per-slice implement task both dispatch this today).
|
|
55
|
+
* - `epic` — decomposed into a wave of slices by the planner ⇒ `senior:plan` (plan-fanout.bpmn's
|
|
56
|
+
* decomposition task).
|
|
57
|
+
* - `bugfix` / `chore` — reserved §2 implementation units with no legacy table yet; they implement an
|
|
58
|
+
* issue like a feature ⇒ `senior:feature`.
|
|
59
|
+
* - `delivery-graph` — dispatched by the engine-native runner (`app/deliveryRunner.ts`), NOT a single
|
|
60
|
+
* agent verb: every node in the graph carries its OWN `jobType`, so the unit has no single dispatch
|
|
61
|
+
* target. `null` records that the runner, not this verb map, launches a delivery-graph unit.
|
|
62
|
+
*/
|
|
63
|
+
export const DISPATCH_JOB_TYPE_BY_KIND: Readonly<Record<DeliveryUnitKind, string | null>> = {
|
|
64
|
+
feature: "senior:feature",
|
|
65
|
+
"plan-task": "senior:feature",
|
|
66
|
+
epic: "senior:plan",
|
|
67
|
+
bugfix: "senior:feature",
|
|
68
|
+
chore: "senior:feature",
|
|
69
|
+
"delivery-graph": null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The dispatch-target verb for a kind, or `null` for a runner-launched (`delivery-graph`) unit. */
|
|
73
|
+
export function dispatchJobTypeForKind(kind: DeliveryUnitKind): string | null {
|
|
74
|
+
return DISPATCH_JOB_TYPE_BY_KIND[kind];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The universal `unit_id` a `(kind, instanceId)` pair names — `<kind>:<instanceId>`. The S2 identity
|
|
79
|
+
* helpers (`featureUnitId` = `feature:<key>`, `epicUnitId` = `epic:<key>`, `planTaskUnitId` =
|
|
80
|
+
* `plan-task:<key>#<idx>`, `deliveryGraphUnitId` = `delivery-graph:<runKey>`) are all exactly this
|
|
81
|
+
* shape, so the door's two args ARE the aggregate key — no per-representation key builder survives.
|
|
82
|
+
*/
|
|
83
|
+
export function deliveryUnitKey(kind: DeliveryUnitKind, instanceId: string): string {
|
|
84
|
+
return `${kind}:${instanceId}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The non-`settled` dispatch statuses the single `delivery_units` binding declares in-flight, read
|
|
88
|
+
* from nano.app.json (the one source of truth). The door never hard-codes this set — it derives from
|
|
89
|
+
* the same binding the framework reconciler polls, so "in-flight" can't drift between the two. */
|
|
90
|
+
export function deliveryUnitActiveDispatchStatuses(): readonly string[] {
|
|
91
|
+
return activeStatusesFor(DELIVERY_UNITS_TABLE);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Why the single door did or did not dispatch — a closed reason set the caller can log/route on. */
|
|
95
|
+
export type DispatchReason = "pending" | "in-flight" | "settled" | "unknown-unit";
|
|
96
|
+
|
|
97
|
+
/** The single dispatch door's decision for one `(kind, instanceId)`. `dispatch` is the gate; `jobType`
|
|
98
|
+
* is the stable `senior:*` target when a fresh dispatch is due (and the kind has an agent verb). */
|
|
99
|
+
export interface DispatchDecision {
|
|
100
|
+
unitId: string;
|
|
101
|
+
kind: DeliveryUnitKind;
|
|
102
|
+
dispatch: boolean;
|
|
103
|
+
jobType: string | null;
|
|
104
|
+
reason: DispatchReason;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The single re-dispatch gate, applied to a `dispatch_status`. This is the ONE short-circuit rule the
|
|
109
|
+
* three pre-collapse launchers each re-implemented against their own union:
|
|
110
|
+
* - `pending` ⇒ dispatch (created, no executor yet — the canonical `requested`).
|
|
111
|
+
* - `dispatched` ⇒ skip, a live executor already holds the unit (at-most-once).
|
|
112
|
+
* - `settled` ⇒ skip, the prior run reached a terminal / live-PR resting outcome
|
|
113
|
+
* (`isDeliveryUnitSettled`) — re-dispatch short-circuits onto it.
|
|
114
|
+
* - missing row ⇒ skip (`unknown-unit`): the door refuses to launch a unit the aggregate never saw
|
|
115
|
+
* rather than dispatch blind.
|
|
116
|
+
*/
|
|
117
|
+
export function dispatchGate(status: DeliveryUnitDispatchStatus | null): { dispatch: boolean; reason: DispatchReason } {
|
|
118
|
+
if (status === null) return { dispatch: false, reason: "unknown-unit" };
|
|
119
|
+
if (status === "pending") return { dispatch: true, reason: "pending" };
|
|
120
|
+
if (status === "dispatched") return { dispatch: false, reason: "in-flight" };
|
|
121
|
+
return { dispatch: false, reason: "settled" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the single dispatch door for a `(kind, instanceId)`: read the unit's EFFECTIVE dispatch
|
|
126
|
+
* status off the ADR-0065 `delivery_units__tracking` VIEW's `derived_status` (NOT the base
|
|
127
|
+
* `dispatch_status`, which the reconciler no longer writes) and apply {@link dispatchGate}, returning
|
|
128
|
+
* the stable `senior:*` target verb when a fresh dispatch is due. Reading `derived_status` folds in the
|
|
129
|
+
* binding's `onTerminated` edge, so a unit whose executor terminated out-of-band is seen `settled` and
|
|
130
|
+
* re-dispatchable rather than stranded `dispatched`. A row the aggregate never recorded resolves to
|
|
131
|
+
* `unknown-unit` (no dispatch) — the door never launches blind.
|
|
132
|
+
*/
|
|
133
|
+
export async function resolveDeliveryDispatch(
|
|
134
|
+
data: DataLayer,
|
|
135
|
+
kind: DeliveryUnitKind,
|
|
136
|
+
instanceId: string,
|
|
137
|
+
): Promise<DispatchDecision> {
|
|
138
|
+
const unitId = deliveryUnitKey(kind, instanceId);
|
|
139
|
+
const view = derivedTrackingTable<{ unit_id: string; derived_status: string | null }>(
|
|
140
|
+
data,
|
|
141
|
+
DELIVERY_UNITS_TABLE,
|
|
142
|
+
"unit_id",
|
|
143
|
+
);
|
|
144
|
+
const unit = await view.get(unitId);
|
|
145
|
+
const { dispatch, reason } = dispatchGate(asDispatchStatus(unit?.derived_status));
|
|
146
|
+
return {
|
|
147
|
+
unitId,
|
|
148
|
+
kind,
|
|
149
|
+
dispatch,
|
|
150
|
+
jobType: dispatch ? dispatchJobTypeForKind(kind) : null,
|
|
151
|
+
reason,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Coverage for ADR 0006 slice S2 (#589) — the `delivery_units` aggregate and its DB-level sync.
|
|
2
|
+
//
|
|
3
|
+
// Proves, against a REAL in-memory SQLite with the full migration set applied:
|
|
4
|
+
// 1. VOCABULARY PARITY — the TS `DELIVERY_UNIT_KINDS` closed enum matches the `CHECK (kind IN (…))`
|
|
5
|
+
// constraint in migration 088 (the two lowerings of the §2 kind enum can't drift).
|
|
6
|
+
// 2. DERIVATION PARITY — for every source status of every shape, the DB triggers/backfill derive the
|
|
7
|
+
// same canonical `delivery_status` as the S1 read models (app/deliveryUnitStatus.ts) and the same
|
|
8
|
+
// `dispatch_status` as the TS `dispatchStatusForDelivery` (the SQL and TS lowerings agree).
|
|
9
|
+
// 3. IDENTITY — the derived `unit_id` matches the TS `*UnitId` helpers, and an epic slice node hangs
|
|
10
|
+
// under its epic (`parent_unit_id`), so the aggregate's universal key is what the door will name.
|
|
11
|
+
// 4. COMPAT-VIEW PARITY — each legacy-shaped `<table>__units` VIEW is row-for-row identical to its
|
|
12
|
+
// base table over insert/update/delete, so a read path can swap onto the aggregate losslessly.
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { applyMigrationSet, readMigrationSetFromDisk } from "#test-migrations";
|
|
16
|
+
import { assert, assertEquals } from "#test-assert";
|
|
17
|
+
import {
|
|
18
|
+
DELIVERY_UNIT_KINDS,
|
|
19
|
+
deliveryGraphUnitId,
|
|
20
|
+
dispatchStatusForDelivery,
|
|
21
|
+
epicUnitId,
|
|
22
|
+
featureUnitId,
|
|
23
|
+
planTaskUnitId,
|
|
24
|
+
} from "./deliveryUnit.ts";
|
|
25
|
+
import {
|
|
26
|
+
type DeliveryUnitStatus,
|
|
27
|
+
deliveryGraphDeliveryStatus,
|
|
28
|
+
featureDeliveryStatus,
|
|
29
|
+
planDeliveryStatus,
|
|
30
|
+
PLAN_STATUSES,
|
|
31
|
+
planTaskDeliveryStatus,
|
|
32
|
+
toDeliveryUnitStatus,
|
|
33
|
+
} from "./deliveryUnitStatus.ts";
|
|
34
|
+
import { DELIVERY_GRAPH_RUN_STATUSES } from "./deliveryGraphRun.ts";
|
|
35
|
+
import { FEATURE_RUN_STATUSES } from "./feature.ts";
|
|
36
|
+
import { PLAN_TASK_STATUSES } from "./plan.ts";
|
|
37
|
+
|
|
38
|
+
function freshDb(): DatabaseSync {
|
|
39
|
+
const db = new DatabaseSync(":memory:");
|
|
40
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
41
|
+
applyMigrationSet(db, readMigrationSetFromDisk());
|
|
42
|
+
return db;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NOW = "2026-01-01T00:00:00Z";
|
|
46
|
+
const row = (db: DatabaseSync, sql: string, ...p: unknown[]) => db.prepare(sql).get(...(p as never[])) as Record<string, unknown>;
|
|
47
|
+
const rows = (db: DatabaseSync, sql: string, ...p: unknown[]) => db.prepare(sql).all(...(p as never[])) as Record<string, unknown>[];
|
|
48
|
+
const exec = (db: DatabaseSync, sql: string, ...p: unknown[]) => db.prepare(sql).run(...(p as never[]));
|
|
49
|
+
|
|
50
|
+
test("VOCABULARY PARITY: DELIVERY_UNIT_KINDS matches migration 088's CHECK (kind IN (…))", () => {
|
|
51
|
+
const mig = readMigrationSetFromDisk().find((m) => m.name === "088_delivery_units.sql");
|
|
52
|
+
assert(mig, "088_delivery_units.sql must exist");
|
|
53
|
+
const m = mig.sql.match(/kind IN \(([^)]*)\)/);
|
|
54
|
+
assert(m, "088 must declare a CHECK (kind IN (…)) constraint");
|
|
55
|
+
const declared = m[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
56
|
+
assertEquals(declared, [...DELIVERY_UNIT_KINDS], "the SQL kind enum and the TS enum must agree");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("DERIVATION PARITY: feature triggers derive the S1 canonical + dispatch status for every source status", () => {
|
|
60
|
+
const db = freshDb();
|
|
61
|
+
FEATURE_RUN_STATUSES.forEach((status, i) => {
|
|
62
|
+
const key = `o/r#${100 + i}`;
|
|
63
|
+
exec(
|
|
64
|
+
db,
|
|
65
|
+
`INSERT INTO feature_runs(feature_key,repo,issue_number,issue_url,base_branch,status,created_at,updated_at)
|
|
66
|
+
VALUES(?,?,?,?,?,?,?,?)`,
|
|
67
|
+
key, "o/r", 100 + i, "u", "main", status, NOW, NOW,
|
|
68
|
+
);
|
|
69
|
+
const u = row(db, "SELECT * FROM delivery_units WHERE unit_id=?", featureUnitId(key));
|
|
70
|
+
const canonical = toDeliveryUnitStatus(featureDeliveryStatus, status) as DeliveryUnitStatus;
|
|
71
|
+
assertEquals(u.kind, "feature");
|
|
72
|
+
assertEquals(u.delivery_status, canonical, `feature ${status} → canonical`);
|
|
73
|
+
assertEquals(u.dispatch_status, dispatchStatusForDelivery(canonical), `feature ${status} → dispatch`);
|
|
74
|
+
assertEquals(u.status, status, "raw legacy status is preserved verbatim");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("DERIVATION PARITY: epic (plans) triggers derive the S1 canonical + dispatch status for every source status", () => {
|
|
79
|
+
const db = freshDb();
|
|
80
|
+
PLAN_STATUSES.forEach((status, i) => {
|
|
81
|
+
const key = `o/r#${200 + i}`;
|
|
82
|
+
exec(
|
|
83
|
+
db,
|
|
84
|
+
`INSERT INTO plans(plan_key,repo,issue_number,issue_url,status,created_at,updated_at)
|
|
85
|
+
VALUES(?,?,?,?,?,?,?)`,
|
|
86
|
+
key, "o/r", 200 + i, "u", status, NOW, NOW,
|
|
87
|
+
);
|
|
88
|
+
const u = row(db, "SELECT * FROM delivery_units WHERE unit_id=?", epicUnitId(key));
|
|
89
|
+
const canonical = toDeliveryUnitStatus(planDeliveryStatus, status) as DeliveryUnitStatus;
|
|
90
|
+
assertEquals(u.kind, "epic");
|
|
91
|
+
assertEquals(u.delivery_status, canonical, `epic ${status} → canonical`);
|
|
92
|
+
assertEquals(u.dispatch_status, dispatchStatusForDelivery(canonical), `epic ${status} → dispatch`);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("DERIVATION PARITY + IDENTITY: plan-task nodes derive status and hang under their epic", () => {
|
|
97
|
+
const db = freshDb();
|
|
98
|
+
const planKey = "o/r#300";
|
|
99
|
+
exec(
|
|
100
|
+
db,
|
|
101
|
+
`INSERT INTO plans(plan_key,repo,issue_number,issue_url,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`,
|
|
102
|
+
planKey, "o/r", 300, "u", "dispatched", NOW, NOW,
|
|
103
|
+
);
|
|
104
|
+
PLAN_TASK_STATUSES.forEach((status, i) => {
|
|
105
|
+
exec(
|
|
106
|
+
db,
|
|
107
|
+
`INSERT INTO plan_tasks(plan_key,task_index,task_id,status,created_at,updated_at) VALUES(?,?,?,?,?,?)`,
|
|
108
|
+
planKey, i, `t${i}`, status, NOW, NOW,
|
|
109
|
+
);
|
|
110
|
+
const u = row(db, "SELECT * FROM delivery_units WHERE unit_id=?", planTaskUnitId(planKey, i));
|
|
111
|
+
const canonical = toDeliveryUnitStatus(planTaskDeliveryStatus, status) as DeliveryUnitStatus;
|
|
112
|
+
assertEquals(u.kind, "plan-task");
|
|
113
|
+
assertEquals(u.delivery_status, canonical, `plan-task ${status} → canonical`);
|
|
114
|
+
assertEquals(u.dispatch_status, dispatchStatusForDelivery(canonical), `plan-task ${status} → dispatch`);
|
|
115
|
+
assertEquals(u.parent_unit_id, epicUnitId(planKey), "a node hangs under its epic composition");
|
|
116
|
+
assertEquals(u.node_index, i, "the node carries its slice ordinal");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("DERIVATION PARITY: delivery-graph triggers derive the S1 canonical + dispatch status for every source status", () => {
|
|
121
|
+
const db = freshDb();
|
|
122
|
+
DELIVERY_GRAPH_RUN_STATUSES.forEach((status, i) => {
|
|
123
|
+
const key = `dg${i}`;
|
|
124
|
+
exec(
|
|
125
|
+
db,
|
|
126
|
+
`INSERT INTO delivery_graph_runs(run_key,digest,status,created_at,updated_at) VALUES(?,?,?,?,?)`,
|
|
127
|
+
key, "abc", status, NOW, NOW,
|
|
128
|
+
);
|
|
129
|
+
const u = row(db, "SELECT * FROM delivery_units WHERE unit_id=?", deliveryGraphUnitId(key));
|
|
130
|
+
const canonical = toDeliveryUnitStatus(deliveryGraphDeliveryStatus, status) as DeliveryUnitStatus;
|
|
131
|
+
assertEquals(u.kind, "delivery-graph");
|
|
132
|
+
assertEquals(u.delivery_status, canonical, `delivery-graph ${status} → canonical`);
|
|
133
|
+
assertEquals(u.dispatch_status, dispatchStatusForDelivery(canonical), `delivery-graph ${status} → dispatch`);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("FAIL CLOSED: an unknown legacy status yields NULL delivery_status AND NULL dispatch_status (no inconsistent row)", () => {
|
|
138
|
+
// The legacy `status` columns are plain TEXT NOT NULL without a CHECK, so an unexpected value can
|
|
139
|
+
// reach the triggers. `dispatch_status` is derived FROM the canonical `delivery_status`, so when the
|
|
140
|
+
// status is unrecognised (delivery_status → NULL) dispatch_status MUST also be NULL — never a
|
|
141
|
+
// dangling 'dispatched'/'settled' on a row whose delivery_status is NULL.
|
|
142
|
+
const db = freshDb();
|
|
143
|
+
const cases: [string, () => void, string][] = [
|
|
144
|
+
[
|
|
145
|
+
"feature",
|
|
146
|
+
() =>
|
|
147
|
+
exec(
|
|
148
|
+
db,
|
|
149
|
+
`INSERT INTO feature_runs(feature_key,repo,issue_number,issue_url,base_branch,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`,
|
|
150
|
+
"o/r#900", "o/r", 900, "u", "main", "bogus-status", NOW, NOW,
|
|
151
|
+
),
|
|
152
|
+
featureUnitId("o/r#900"),
|
|
153
|
+
],
|
|
154
|
+
[
|
|
155
|
+
"epic",
|
|
156
|
+
() =>
|
|
157
|
+
exec(
|
|
158
|
+
db,
|
|
159
|
+
`INSERT INTO plans(plan_key,repo,issue_number,issue_url,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`,
|
|
160
|
+
"o/r#901", "o/r", 901, "u", "bogus-status", NOW, NOW,
|
|
161
|
+
),
|
|
162
|
+
epicUnitId("o/r#901"),
|
|
163
|
+
],
|
|
164
|
+
[
|
|
165
|
+
"delivery-graph",
|
|
166
|
+
() =>
|
|
167
|
+
exec(
|
|
168
|
+
db,
|
|
169
|
+
`INSERT INTO delivery_graph_runs(run_key,digest,status,created_at,updated_at) VALUES(?,?,?,?,?)`,
|
|
170
|
+
"dg900", "abc", "bogus-status", NOW, NOW,
|
|
171
|
+
),
|
|
172
|
+
deliveryGraphUnitId("dg900"),
|
|
173
|
+
],
|
|
174
|
+
];
|
|
175
|
+
for (const [kind, insert, unitId] of cases) {
|
|
176
|
+
insert();
|
|
177
|
+
const u = row(db, "SELECT delivery_status, dispatch_status FROM delivery_units WHERE unit_id=?", unitId);
|
|
178
|
+
assertEquals(u.delivery_status, null, `${kind} unknown status → NULL delivery_status`);
|
|
179
|
+
assertEquals(u.dispatch_status, null, `${kind} unknown status → NULL dispatch_status (fail closed)`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// plan-task needs a parent epic row first.
|
|
183
|
+
exec(db, `INSERT INTO plans(plan_key,repo,issue_number,issue_url,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`, "o/r#902", "o/r", 902, "u", "planning", NOW, NOW);
|
|
184
|
+
exec(db, `INSERT INTO plan_tasks(plan_key,task_index,task_id,status,created_at,updated_at) VALUES(?,?,?,?,?,?)`, "o/r#902", 0, "t0", "bogus-status", NOW, NOW);
|
|
185
|
+
const pt = row(db, "SELECT delivery_status, dispatch_status FROM delivery_units WHERE unit_id=?", planTaskUnitId("o/r#902", 0));
|
|
186
|
+
assertEquals(pt.delivery_status, null, "plan-task unknown status → NULL delivery_status");
|
|
187
|
+
assertEquals(pt.dispatch_status, null, "plan-task unknown status → NULL dispatch_status (fail closed)");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ── COMPAT-VIEW PARITY — each `<table>__units` VIEW is byte-identical to its base table. ────────────
|
|
191
|
+
const norm = (r: Record<string, unknown>[]) =>
|
|
192
|
+
JSON.stringify(r.map((x) => Object.fromEntries(Object.entries(x).sort())));
|
|
193
|
+
|
|
194
|
+
function assertViewParity(db: DatabaseSync, base: string, view: string) {
|
|
195
|
+
assertEquals(norm(rows(db, `SELECT * FROM ${base} ORDER BY 1`)), norm(rows(db, `SELECT * FROM ${view} ORDER BY 1`)), `${view} must equal ${base}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test("COMPAT-VIEW PARITY: every legacy surface is served row-for-row from the aggregate across insert/update/delete", () => {
|
|
199
|
+
const db = freshDb();
|
|
200
|
+
exec(
|
|
201
|
+
db,
|
|
202
|
+
`INSERT INTO feature_runs(feature_key,repo,issue_number,issue_url,base_branch,status,converge,auto_merge,outcome,delivery_label,title,created_at,updated_at)
|
|
203
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
204
|
+
"o/r#1", "o/r", 1, "u", "main", "running", 1, 0, null, null, "Feat", NOW, NOW,
|
|
205
|
+
);
|
|
206
|
+
exec(
|
|
207
|
+
db,
|
|
208
|
+
`INSERT INTO plans(plan_key,repo,issue_number,issue_url,title,status,task_count,base_branch,epic_phase,created_at,updated_at)
|
|
209
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
|
210
|
+
"o/r#2", "o/r", 2, "u", "Epic", "planning", 2, "main", "Planning", NOW, NOW,
|
|
211
|
+
);
|
|
212
|
+
exec(
|
|
213
|
+
db,
|
|
214
|
+
`INSERT INTO plan_tasks(plan_key,task_index,task_id,title,prompt,status,pr_key,summary,wave,created_at,updated_at)
|
|
215
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
|
216
|
+
"o/r#2", 0, "t0", "Slice", "do it", "pending", null, null, 1, NOW, NOW,
|
|
217
|
+
);
|
|
218
|
+
exec(
|
|
219
|
+
db,
|
|
220
|
+
`INSERT INTO delivery_graph_runs(run_key,process_key,digest,status,side_effecting,node_count,human_node_count,side_effect_count,title,created_at,updated_at)
|
|
221
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
|
222
|
+
"dg1", null, "deadbeef", "awaiting-approval", 1, 5, 2, 3, "Graph", NOW, NOW,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const pairs: [string, string][] = [
|
|
226
|
+
["feature_runs", "feature_runs__units"],
|
|
227
|
+
["plans", "plans__units"],
|
|
228
|
+
["plan_tasks", "plan_tasks__units"],
|
|
229
|
+
["delivery_graph_runs", "delivery_graph_runs__units"],
|
|
230
|
+
];
|
|
231
|
+
for (const [b, v] of pairs) assertViewParity(db, b, v);
|
|
232
|
+
|
|
233
|
+
// UPDATE — a status transition (and a projected column) must re-project onto the aggregate.
|
|
234
|
+
exec(db, "UPDATE feature_runs SET status=?, pr_key=?, updated_at=? WHERE feature_key=?", "merged", "o/r#5", "2026-02", "o/r#1");
|
|
235
|
+
const fu = row(db, "SELECT delivery_status, dispatch_status FROM delivery_units WHERE unit_id=?", featureUnitId("o/r#1"));
|
|
236
|
+
assertEquals(fu.delivery_status, "merged");
|
|
237
|
+
assertEquals(fu.dispatch_status, "settled");
|
|
238
|
+
for (const [b, v] of pairs) assertViewParity(db, b, v);
|
|
239
|
+
|
|
240
|
+
// DELETE — the aggregate row is dropped with the legacy row.
|
|
241
|
+
exec(db, "DELETE FROM plan_tasks WHERE plan_key=?", "o/r#2");
|
|
242
|
+
assertEquals(rows(db, "SELECT * FROM delivery_units WHERE kind='plan-task'").length, 0, "deleting a slice drops its node unit");
|
|
243
|
+
assertViewParity(db, "plan_tasks", "plan_tasks__units");
|
|
244
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// ADR 0006 slice S2 (#589) — the `delivery_units` aggregate, TS side.
|
|
2
|
+
//
|
|
3
|
+
// S2 collapses the four legacy delivery-unit representations (`feature_runs`, `plans`, `plan_tasks`,
|
|
4
|
+
// `delivery_graph_runs`) onto ONE kind-tagged aggregate table (`db/migrations/088_delivery_units.sql`).
|
|
5
|
+
// The data-level sync is done at the DB layer — triggers mirror every legacy write into the aggregate
|
|
6
|
+
// (089), a backfill seeds pre-existing rows (090), and legacy-shaped compat VIEWs are served FROM the
|
|
7
|
+
// aggregate (091) — so this module carries NO write path of its own; it is the canonical TS home for
|
|
8
|
+
// the aggregate's VOCABULARY (the closed `kind` enum, the `dispatch_status` lifecycle), the universal
|
|
9
|
+
// `unit_id` derivation, and the `dispatch_status` derivation. The last two are also lowered to SQL in
|
|
10
|
+
// the trigger/backfill migrations; app/deliveryUnit.test.ts proves the two lowerings agree over the
|
|
11
|
+
// full status/shape matrix (the same declare-once / parity-guard discipline S1 uses for the status
|
|
12
|
+
// union — derivation over duplication).
|
|
13
|
+
//
|
|
14
|
+
// SCOPE (S2). This owns the aggregate's identity + dispatch vocabulary and a read gateway. The single
|
|
15
|
+
// dispatch door (S3) will key on `dispatch_status`; repointing live writers onto `delivery_units` and
|
|
16
|
+
// retiring the legacy write paths is the later CONTRACT phase (S3), sequenced after the
|
|
17
|
+
// `instanceTracking` doors move. Nothing here changes existing behaviour.
|
|
18
|
+
|
|
19
|
+
import type { DataLayer, Table } from "@nanobpm/urban";
|
|
20
|
+
import { type DeliveryUnitStatus, isDeliveryUnitSettled } from "./deliveryUnitStatus.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The closed §2 `kind` enum covering every delivery unit. A run of an epic is ONE unit (`kind='epic'`,
|
|
24
|
+
* a composition over its slices); each slice is a `plan-task` node under it. `feature` is the
|
|
25
|
+
* degenerate 1-node unit; `delivery-graph` is the arbitrary-DAG unit. `bugfix`/`chore` are reserved
|
|
26
|
+
* §2 members with no legacy table yet. Kept in lockstep with the `CHECK (kind IN (…))` constraint in
|
|
27
|
+
* migration 088 — a parity test pins the two.
|
|
28
|
+
*/
|
|
29
|
+
export const DELIVERY_UNIT_KINDS = ["feature", "epic", "plan-task", "delivery-graph", "bugfix", "chore"] as const;
|
|
30
|
+
export type DeliveryUnitKind = (typeof DELIVERY_UNIT_KINDS)[number];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `dispatch_status` lifecycle — the single dispatch door's status (ADR 0006 §4; the S3 door
|
|
34
|
+
* collapses onto it). Derived from the canonical {@link DeliveryUnitStatus}:
|
|
35
|
+
* - `pending` — created, not yet dispatched to an executor (canonical `requested`).
|
|
36
|
+
* - `dispatched` — a live executor/instance is working the unit (a live/parked non-terminal status).
|
|
37
|
+
* - `settled` — terminal, or a live PR resting stage (`opened`/`converging`): re-dispatchable. This
|
|
38
|
+
* is exactly the {@link isDeliveryUnitSettled} predicate, so the dispatch door's
|
|
39
|
+
* short-circuit gate matches the redispatch-settled semantics S1 defined.
|
|
40
|
+
*/
|
|
41
|
+
export const DISPATCH_STATUSES = ["pending", "dispatched", "settled"] as const;
|
|
42
|
+
export type DeliveryUnitDispatchStatus = (typeof DISPATCH_STATUSES)[number];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Derive the `dispatch_status` from a canonical delivery-unit status — the TS lowering of the CASE the
|
|
46
|
+
* sync-trigger/backfill migrations (089/090) apply in SQL. `requested` ⇒ `pending`; a settled status
|
|
47
|
+
* (terminal or a PR resting stage) ⇒ `settled`; every other (live/parked) status ⇒ `dispatched`.
|
|
48
|
+
*/
|
|
49
|
+
export function dispatchStatusForDelivery(status: DeliveryUnitStatus): DeliveryUnitDispatchStatus {
|
|
50
|
+
if (status === "requested") return "pending";
|
|
51
|
+
if (isDeliveryUnitSettled(status)) return "settled";
|
|
52
|
+
return "dispatched";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Universal `unit_id` — the cross-representation fact every agent can name and the dispatch door
|
|
56
|
+
// keys on (#464 "What survives" #4). Kept in lockstep with the `unit_id` expressions the
|
|
57
|
+
// trigger/backfill migrations build; app/deliveryUnit.test.ts pins the two. ─────────────────────
|
|
58
|
+
|
|
59
|
+
/** The `feature:<feature_key>` unit id for a single-issue feature run. */
|
|
60
|
+
export const featureUnitId = (featureKey: string): string => `feature:${featureKey}`;
|
|
61
|
+
|
|
62
|
+
/** The `epic:<plan_key>` unit id for an epic (the `plans` aggregate row). */
|
|
63
|
+
export const epicUnitId = (planKey: string): string => `epic:${planKey}`;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `plan-task:<plan_key>#<task_index>` unit id for one epic slice NODE. Its composition parent is
|
|
67
|
+
* {@link epicUnitId}(planKey) — the epic unit it hangs under.
|
|
68
|
+
*/
|
|
69
|
+
export const planTaskUnitId = (planKey: string, taskIndex: number): string => `plan-task:${planKey}#${taskIndex}`;
|
|
70
|
+
|
|
71
|
+
/** The `delivery-graph:<run_key>` unit id for a delivery-graph run. */
|
|
72
|
+
export const deliveryGraphUnitId = (runKey: string): string => `delivery-graph:${runKey}`;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One row of the `delivery_units` aggregate. `unit_id`/`kind` are the identity pair; `delivery_status`
|
|
76
|
+
* is the canonical S1 union value; `status` is the raw legacy source status (kept verbatim so the
|
|
77
|
+
* compat VIEWs reconstruct legacy rows losslessly); `dispatch_status` is the door lifecycle. The
|
|
78
|
+
* per-shape legacy columns ride the same row (nullable off-kind). Read-only for S2 — the physical
|
|
79
|
+
* write target stays the legacy tables (the triggers mirror in).
|
|
80
|
+
*/
|
|
81
|
+
export interface DeliveryUnitRow {
|
|
82
|
+
unit_id: string;
|
|
83
|
+
kind: DeliveryUnitKind;
|
|
84
|
+
legacy_key: string | null;
|
|
85
|
+
legacy_id: number | null;
|
|
86
|
+
parent_unit_id: string | null;
|
|
87
|
+
node_index: number | null;
|
|
88
|
+
delivery_status: DeliveryUnitStatus | null;
|
|
89
|
+
dispatch_status: DeliveryUnitDispatchStatus | null;
|
|
90
|
+
status: string | null;
|
|
91
|
+
repo: string | null;
|
|
92
|
+
issue_number: number | null;
|
|
93
|
+
issue_url: string | null;
|
|
94
|
+
title: string | null;
|
|
95
|
+
base_branch: string | null;
|
|
96
|
+
process_key: string | null;
|
|
97
|
+
pr_key: string | null;
|
|
98
|
+
outcome: string | null;
|
|
99
|
+
created_at: string | null;
|
|
100
|
+
updated_at: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The read gateway over the `delivery_units` aggregate, keyed on the universal `unit_id`. Read-only in
|
|
105
|
+
* S2 (writes flow through the legacy tables + the sync triggers); the S3 dispatch door will own the
|
|
106
|
+
* write path once the legacy writers retire.
|
|
107
|
+
*/
|
|
108
|
+
export const deliveryUnits = (data: DataLayer): Table<DeliveryUnitRow> =>
|
|
109
|
+
data.table<DeliveryUnitRow>("delivery_units", "unit_id");
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
-- ADR 0006 slice S2 (#589) — the `delivery_units` aggregate: ONE kind-tagged table consolidating the
|
|
2
|
+
-- four legacy delivery-unit representations (`feature_runs`, `plans`, `plan_tasks`,
|
|
3
|
+
-- `delivery_graph_runs`) that ADR 0006 §2 declared to be one aggregate. S1 (#494) delivered the derived
|
|
4
|
+
-- status union (app/deliveryUnitStatus.ts); this is its data-aggregate half.
|
|
5
|
+
--
|
|
6
|
+
-- EXPAND phase (ADR 0006 §S2 rollout, expand-and-contract): this migration + 089 (sync triggers) + 090
|
|
7
|
+
-- (backfill) ADD the aggregate and keep it in lockstep with the legacy tables, which stay the physical
|
|
8
|
+
-- WRITE target through S2 (the framework `instanceTracking` bindings + every app writer still target
|
|
9
|
+
-- them; ADR 0065 makes `instanceTracking` a SOURCE that no longer writes base rows, so the legacy base
|
|
10
|
+
-- `status` is only ever written through the app gateways — mirrored here at the DB level). 091 adds the
|
|
11
|
+
-- legacy-shaped compat VIEWs served FROM this aggregate, parity-tested against the base tables
|
|
12
|
+
-- (app/deliveryUnit.test.ts). Repointing live writers onto `delivery_units` and retiring the legacy
|
|
13
|
+
-- write paths is the later CONTRACT phase (S3), which the ADR sequences after the `instanceTracking`
|
|
14
|
+
-- doors move.
|
|
15
|
+
--
|
|
16
|
+
-- Identity (#464 "What survives"): `unit_id` is the universal, human-nameable cross-representation key
|
|
17
|
+
-- the dispatch door (S3) keys on — `feature:<key>` / `epic:<plan_key>` / `plan-task:<plan_key>#<idx>` /
|
|
18
|
+
-- `delivery-graph:<run_key>`. A run of an epic is ONE unit (`kind='epic'`); each of its slices is a
|
|
19
|
+
-- `kind='plan-task'` node under it (`parent_unit_id = 'epic:<plan_key>'`). `kind` is the closed §2 enum.
|
|
20
|
+
-- `dispatch_status` is the single dispatch door's status (S3 collapses onto it): 'pending' (created,
|
|
21
|
+
-- not yet dispatched), 'dispatched' (a live executor/instance), 'settled' (terminal or a live PR
|
|
22
|
+
-- resting stage — re-dispatchable), derived from the canonical `delivery_status`.
|
|
23
|
+
--
|
|
24
|
+
-- Every column except the identity pair (`unit_id`/`kind`) is nullable so a sync trigger over a
|
|
25
|
+
-- partial legacy row can never trip a NOT NULL. `delivery_status` is the canonical S1 union value;
|
|
26
|
+
-- `status` is the raw legacy source status (kept verbatim so the compat VIEWs reconstruct the legacy
|
|
27
|
+
-- rows losslessly). The runner wraps each file in its own transaction — no BEGIN/COMMIT here. Numbered
|
|
28
|
+
-- after the current highest prefix (087).
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS delivery_units (
|
|
31
|
+
unit_id TEXT PRIMARY KEY,
|
|
32
|
+
kind TEXT NOT NULL CHECK (kind IN ('feature', 'epic', 'plan-task', 'delivery-graph', 'bugfix', 'chore')),
|
|
33
|
+
legacy_key TEXT,
|
|
34
|
+
legacy_id INTEGER,
|
|
35
|
+
parent_unit_id TEXT,
|
|
36
|
+
node_index INTEGER,
|
|
37
|
+
delivery_status TEXT,
|
|
38
|
+
dispatch_status TEXT,
|
|
39
|
+
repo TEXT,
|
|
40
|
+
issue_number INTEGER,
|
|
41
|
+
issue_url TEXT,
|
|
42
|
+
title TEXT,
|
|
43
|
+
base_branch TEXT,
|
|
44
|
+
status TEXT,
|
|
45
|
+
process_key TEXT,
|
|
46
|
+
pr_key TEXT,
|
|
47
|
+
outcome TEXT,
|
|
48
|
+
acknowledged_at TEXT,
|
|
49
|
+
list_bucket TEXT,
|
|
50
|
+
created_at TEXT,
|
|
51
|
+
updated_at TEXT,
|
|
52
|
+
converge INTEGER,
|
|
53
|
+
auto_merge INTEGER,
|
|
54
|
+
delivery_label TEXT,
|
|
55
|
+
stage TEXT,
|
|
56
|
+
stage_state TEXT,
|
|
57
|
+
stage_skipped TEXT,
|
|
58
|
+
attention TEXT,
|
|
59
|
+
task_count INTEGER,
|
|
60
|
+
gate_wave INTEGER,
|
|
61
|
+
blackboard_token TEXT,
|
|
62
|
+
retro_started_at TEXT,
|
|
63
|
+
epic_phase TEXT,
|
|
64
|
+
promotion_pr TEXT,
|
|
65
|
+
promotion_state TEXT,
|
|
66
|
+
ack_open INTEGER,
|
|
67
|
+
wait_gate TEXT,
|
|
68
|
+
wait_gate_label TEXT,
|
|
69
|
+
bound_artifacts TEXT,
|
|
70
|
+
task_id TEXT,
|
|
71
|
+
prompt TEXT,
|
|
72
|
+
summary TEXT,
|
|
73
|
+
wave INTEGER,
|
|
74
|
+
open_question TEXT,
|
|
75
|
+
answer TEXT,
|
|
76
|
+
draft_pr_key TEXT,
|
|
77
|
+
corr_key TEXT,
|
|
78
|
+
process_definition_id TEXT,
|
|
79
|
+
digest TEXT,
|
|
80
|
+
side_effecting INTEGER,
|
|
81
|
+
node_count INTEGER,
|
|
82
|
+
human_node_count INTEGER,
|
|
83
|
+
side_effect_count INTEGER,
|
|
84
|
+
phase TEXT,
|
|
85
|
+
phase_node_id TEXT,
|
|
86
|
+
human_labels TEXT
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_units_kind ON delivery_units (kind);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_units_dispatch_status ON delivery_units (dispatch_status);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_units_parent ON delivery_units (parent_unit_id);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_units_process_key ON delivery_units (process_key);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_units_pr_key ON delivery_units (pr_key);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_units_legacy_key ON delivery_units (legacy_key);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
-- ADR 0006 slice S2 (#589) — DB-level sync triggers mirroring every legacy delivery-unit write into
|
|
2
|
+
-- the `delivery_units` aggregate (088). AFTER INSERT/UPDATE/DELETE on each of the four legacy tables
|
|
3
|
+
-- keeps the aggregate in perfect lockstep regardless of WHICH writer touched the base row — the app
|
|
4
|
+
-- gateways (app/feature.ts, app/plan.ts, app/deliveryGraphRun.ts), the plan/wave/results workers, the
|
|
5
|
+
-- service pollers, AND the raw-SQL compare-and-swap in `claimRunForLaunch` (app/deliveryGraphRun.ts)
|
|
6
|
+
-- that bypasses the gateway. A trigger fires at the DB level, so there is no drift surface and no
|
|
7
|
+
-- write-path code to keep in sync (derivation over duplication). ADR 0065 makes `instanceTracking` a
|
|
8
|
+
-- SOURCE that no longer writes base rows, so the base `status` these triggers read is always the
|
|
9
|
+
-- worker-owned transient — the terminal fold stays derived on the legacy `__tracking` VIEWs.
|
|
10
|
+
--
|
|
11
|
+
-- `INSERT OR REPLACE` on the derived `unit_id` makes each trigger idempotent (an update re-projects
|
|
12
|
+
-- the whole row). `delivery_status` (canonical S1 union) and `dispatch_status` are computed by the
|
|
13
|
+
-- same CASE lowerings app/deliveryUnit.ts mirrors in TS, guarded at parity by app/deliveryUnit.test.ts.
|
|
14
|
+
-- The runner wraps each file in its own transaction — no BEGIN/COMMIT here.
|
|
15
|
+
|
|
16
|
+
DROP TRIGGER IF EXISTS feature_runs__du_ai;
|
|
17
|
+
CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
|
|
18
|
+
BEGIN
|
|
19
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
20
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
21
|
+
END;
|
|
22
|
+
|
|
23
|
+
DROP TRIGGER IF EXISTS feature_runs__du_au;
|
|
24
|
+
CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
|
|
25
|
+
BEGIN
|
|
26
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
27
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
28
|
+
END;
|
|
29
|
+
|
|
30
|
+
DROP TRIGGER IF EXISTS feature_runs__du_ad;
|
|
31
|
+
CREATE TRIGGER feature_runs__du_ad AFTER DELETE ON feature_runs
|
|
32
|
+
BEGIN
|
|
33
|
+
DELETE FROM delivery_units WHERE unit_id = 'feature:' || OLD.feature_key;
|
|
34
|
+
END;
|
|
35
|
+
|
|
36
|
+
DROP TRIGGER IF EXISTS plans__du_ai;
|
|
37
|
+
CREATE TRIGGER plans__du_ai AFTER INSERT ON plans
|
|
38
|
+
BEGIN
|
|
39
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, created_at, updated_at)
|
|
40
|
+
VALUES ('epic:' || NEW.plan_key, 'epic', NEW.plan_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'planning' THEN 'requested' WHEN NEW.status = 'dispatched' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('planning') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('dispatched') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.status, NEW.task_count, NEW.process_key, NEW.outcome, NEW.gate_wave, NEW.blackboard_token, NEW.retro_started_at, NEW.base_branch, NEW.epic_phase, NEW.promotion_pr, NEW.promotion_state, NEW.acknowledged_at, NEW.list_bucket, NEW.ack_open, NEW.wait_gate, NEW.wait_gate_label, NEW.bound_artifacts, NEW.created_at, NEW.updated_at);
|
|
41
|
+
END;
|
|
42
|
+
|
|
43
|
+
DROP TRIGGER IF EXISTS plans__du_au;
|
|
44
|
+
CREATE TRIGGER plans__du_au AFTER UPDATE ON plans
|
|
45
|
+
BEGIN
|
|
46
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, created_at, updated_at)
|
|
47
|
+
VALUES ('epic:' || NEW.plan_key, 'epic', NEW.plan_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'planning' THEN 'requested' WHEN NEW.status = 'dispatched' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('planning') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('dispatched') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.status, NEW.task_count, NEW.process_key, NEW.outcome, NEW.gate_wave, NEW.blackboard_token, NEW.retro_started_at, NEW.base_branch, NEW.epic_phase, NEW.promotion_pr, NEW.promotion_state, NEW.acknowledged_at, NEW.list_bucket, NEW.ack_open, NEW.wait_gate, NEW.wait_gate_label, NEW.bound_artifacts, NEW.created_at, NEW.updated_at);
|
|
48
|
+
END;
|
|
49
|
+
|
|
50
|
+
DROP TRIGGER IF EXISTS plans__du_ad;
|
|
51
|
+
CREATE TRIGGER plans__du_ad AFTER DELETE ON plans
|
|
52
|
+
BEGIN
|
|
53
|
+
DELETE FROM delivery_units WHERE unit_id = 'epic:' || OLD.plan_key;
|
|
54
|
+
END;
|
|
55
|
+
|
|
56
|
+
DROP TRIGGER IF EXISTS plan_tasks__du_ai;
|
|
57
|
+
CREATE TRIGGER plan_tasks__du_ai AFTER INSERT ON plan_tasks
|
|
58
|
+
BEGIN
|
|
59
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, title, prompt, status, pr_key, summary, wave, open_question, answer, draft_pr_key, corr_key, created_at, updated_at, task_id)
|
|
60
|
+
VALUES ('plan-task:' || NEW.plan_key || '#' || NEW.task_index, 'plan-task', NEW.plan_key, NEW.id, 'epic:' || NEW.plan_key, NEW.task_index, CASE WHEN NEW.status = 'pending' THEN 'requested' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'waiting-for-lane' THEN 'waiting' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('pending') THEN 'pending' WHEN NEW.status IN ('opened', 'blocked', 'skipped', 'abandoned') THEN 'settled' WHEN NEW.status IN ('escalated', 'waiting-for-lane') THEN 'dispatched' ELSE NULL END, NEW.title, NEW.prompt, NEW.status, NEW.pr_key, NEW.summary, NEW.wave, NEW.open_question, NEW.answer, NEW.draft_pr_key, NEW.corr_key, NEW.created_at, NEW.updated_at, NEW.task_id);
|
|
61
|
+
END;
|
|
62
|
+
|
|
63
|
+
DROP TRIGGER IF EXISTS plan_tasks__du_au;
|
|
64
|
+
CREATE TRIGGER plan_tasks__du_au AFTER UPDATE ON plan_tasks
|
|
65
|
+
BEGIN
|
|
66
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, title, prompt, status, pr_key, summary, wave, open_question, answer, draft_pr_key, corr_key, created_at, updated_at, task_id)
|
|
67
|
+
VALUES ('plan-task:' || NEW.plan_key || '#' || NEW.task_index, 'plan-task', NEW.plan_key, NEW.id, 'epic:' || NEW.plan_key, NEW.task_index, CASE WHEN NEW.status = 'pending' THEN 'requested' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'waiting-for-lane' THEN 'waiting' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('pending') THEN 'pending' WHEN NEW.status IN ('opened', 'blocked', 'skipped', 'abandoned') THEN 'settled' WHEN NEW.status IN ('escalated', 'waiting-for-lane') THEN 'dispatched' ELSE NULL END, NEW.title, NEW.prompt, NEW.status, NEW.pr_key, NEW.summary, NEW.wave, NEW.open_question, NEW.answer, NEW.draft_pr_key, NEW.corr_key, NEW.created_at, NEW.updated_at, NEW.task_id);
|
|
68
|
+
END;
|
|
69
|
+
|
|
70
|
+
DROP TRIGGER IF EXISTS plan_tasks__du_ad;
|
|
71
|
+
CREATE TRIGGER plan_tasks__du_ad AFTER DELETE ON plan_tasks
|
|
72
|
+
BEGIN
|
|
73
|
+
DELETE FROM delivery_units WHERE unit_id = 'plan-task:' || OLD.plan_key || '#' || OLD.task_index;
|
|
74
|
+
END;
|
|
75
|
+
|
|
76
|
+
DROP TRIGGER IF EXISTS delivery_graph_runs__du_ai;
|
|
77
|
+
CREATE TRIGGER delivery_graph_runs__du_ai AFTER INSERT ON delivery_graph_runs
|
|
78
|
+
BEGIN
|
|
79
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, created_at, updated_at)
|
|
80
|
+
VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.created_at, NEW.updated_at);
|
|
81
|
+
END;
|
|
82
|
+
|
|
83
|
+
DROP TRIGGER IF EXISTS delivery_graph_runs__du_au;
|
|
84
|
+
CREATE TRIGGER delivery_graph_runs__du_au AFTER UPDATE ON delivery_graph_runs
|
|
85
|
+
BEGIN
|
|
86
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, created_at, updated_at)
|
|
87
|
+
VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.created_at, NEW.updated_at);
|
|
88
|
+
END;
|
|
89
|
+
|
|
90
|
+
DROP TRIGGER IF EXISTS delivery_graph_runs__du_ad;
|
|
91
|
+
CREATE TRIGGER delivery_graph_runs__du_ad AFTER DELETE ON delivery_graph_runs
|
|
92
|
+
BEGIN
|
|
93
|
+
DELETE FROM delivery_units WHERE unit_id = 'delivery-graph:' || OLD.run_key;
|
|
94
|
+
END;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
-- ADR 0006 slice S2 (#589) — backfill the `delivery_units` aggregate (088) from the existing rows of
|
|
2
|
+
-- the four legacy tables, using the SAME projection the sync triggers (089) apply going forward. Idempotent
|
|
3
|
+
-- (`INSERT OR IGNORE` on the `unit_id` PK) so re-running the migration set — or upgrading an install
|
|
4
|
+
-- whose triggers already mirrored some rows — never double-inserts. Runs AFTER the triggers so a fresh
|
|
5
|
+
-- install with seeded legacy rows is fully covered either way (triggers fire on live writes; this seeds
|
|
6
|
+
-- pre-existing rows). The runner wraps each file in its own transaction — no BEGIN/COMMIT here.
|
|
7
|
+
|
|
8
|
+
INSERT OR IGNORE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
9
|
+
SELECT 'feature:' || feature_runs.feature_key AS unit_id,
|
|
10
|
+
'feature' AS kind,
|
|
11
|
+
feature_runs.feature_key AS legacy_key,
|
|
12
|
+
NULL AS legacy_id,
|
|
13
|
+
NULL AS parent_unit_id,
|
|
14
|
+
NULL AS node_index,
|
|
15
|
+
CASE WHEN feature_runs.status = 'running' THEN 'running' WHEN feature_runs.status = 'escalated' THEN 'escalated' WHEN feature_runs.status = 'opened' THEN 'opened' WHEN feature_runs.status = 'converging' THEN 'converging' WHEN feature_runs.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN feature_runs.status = 'merged' THEN 'merged' WHEN feature_runs.status = 'converged' THEN 'converged' WHEN feature_runs.status = 'blocked' THEN 'blocked' WHEN feature_runs.status = 'skipped' THEN 'skipped' WHEN feature_runs.status = 'failed' THEN 'failed' WHEN feature_runs.status = 'abandoned' THEN 'abandoned' ELSE NULL END AS delivery_status,
|
|
16
|
+
CASE WHEN feature_runs.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN feature_runs.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END AS dispatch_status,
|
|
17
|
+
feature_runs.repo AS repo,
|
|
18
|
+
feature_runs.issue_number AS issue_number,
|
|
19
|
+
feature_runs.issue_url AS issue_url,
|
|
20
|
+
feature_runs.title AS title,
|
|
21
|
+
feature_runs.base_branch AS base_branch,
|
|
22
|
+
feature_runs.status AS status,
|
|
23
|
+
feature_runs.process_key AS process_key,
|
|
24
|
+
feature_runs.pr_key AS pr_key,
|
|
25
|
+
feature_runs.converge AS converge,
|
|
26
|
+
feature_runs.auto_merge AS auto_merge,
|
|
27
|
+
feature_runs.outcome AS outcome,
|
|
28
|
+
feature_runs.delivery_label AS delivery_label,
|
|
29
|
+
feature_runs.acknowledged_at AS acknowledged_at,
|
|
30
|
+
feature_runs.stage AS stage,
|
|
31
|
+
feature_runs.stage_state AS stage_state,
|
|
32
|
+
feature_runs.stage_skipped AS stage_skipped,
|
|
33
|
+
feature_runs.attention AS attention,
|
|
34
|
+
feature_runs.list_bucket AS list_bucket,
|
|
35
|
+
feature_runs.created_at AS created_at,
|
|
36
|
+
feature_runs.updated_at AS updated_at
|
|
37
|
+
FROM feature_runs;
|
|
38
|
+
|
|
39
|
+
INSERT OR IGNORE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, created_at, updated_at)
|
|
40
|
+
SELECT 'epic:' || plans.plan_key AS unit_id,
|
|
41
|
+
'epic' AS kind,
|
|
42
|
+
plans.plan_key AS legacy_key,
|
|
43
|
+
NULL AS legacy_id,
|
|
44
|
+
NULL AS parent_unit_id,
|
|
45
|
+
NULL AS node_index,
|
|
46
|
+
CASE WHEN plans.status = 'planning' THEN 'requested' WHEN plans.status = 'dispatched' THEN 'running' WHEN plans.status = 'done' THEN 'done' WHEN plans.status = 'failed' THEN 'failed' WHEN plans.status = 'abandoned' THEN 'abandoned' ELSE NULL END AS delivery_status,
|
|
47
|
+
CASE WHEN plans.status IN ('planning') THEN 'pending' WHEN plans.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN plans.status IN ('dispatched') THEN 'dispatched' ELSE NULL END AS dispatch_status,
|
|
48
|
+
plans.repo AS repo,
|
|
49
|
+
plans.issue_number AS issue_number,
|
|
50
|
+
plans.issue_url AS issue_url,
|
|
51
|
+
plans.title AS title,
|
|
52
|
+
plans.status AS status,
|
|
53
|
+
plans.task_count AS task_count,
|
|
54
|
+
plans.process_key AS process_key,
|
|
55
|
+
plans.outcome AS outcome,
|
|
56
|
+
plans.gate_wave AS gate_wave,
|
|
57
|
+
plans.blackboard_token AS blackboard_token,
|
|
58
|
+
plans.retro_started_at AS retro_started_at,
|
|
59
|
+
plans.base_branch AS base_branch,
|
|
60
|
+
plans.epic_phase AS epic_phase,
|
|
61
|
+
plans.promotion_pr AS promotion_pr,
|
|
62
|
+
plans.promotion_state AS promotion_state,
|
|
63
|
+
plans.acknowledged_at AS acknowledged_at,
|
|
64
|
+
plans.list_bucket AS list_bucket,
|
|
65
|
+
plans.ack_open AS ack_open,
|
|
66
|
+
plans.wait_gate AS wait_gate,
|
|
67
|
+
plans.wait_gate_label AS wait_gate_label,
|
|
68
|
+
plans.bound_artifacts AS bound_artifacts,
|
|
69
|
+
plans.created_at AS created_at,
|
|
70
|
+
plans.updated_at AS updated_at
|
|
71
|
+
FROM plans;
|
|
72
|
+
|
|
73
|
+
INSERT OR IGNORE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, title, prompt, status, pr_key, summary, wave, open_question, answer, draft_pr_key, corr_key, created_at, updated_at, task_id)
|
|
74
|
+
SELECT 'plan-task:' || plan_tasks.plan_key || '#' || plan_tasks.task_index AS unit_id,
|
|
75
|
+
'plan-task' AS kind,
|
|
76
|
+
plan_tasks.plan_key AS legacy_key,
|
|
77
|
+
plan_tasks.id AS legacy_id,
|
|
78
|
+
'epic:' || plan_tasks.plan_key AS parent_unit_id,
|
|
79
|
+
plan_tasks.task_index AS node_index,
|
|
80
|
+
CASE WHEN plan_tasks.status = 'pending' THEN 'requested' WHEN plan_tasks.status = 'opened' THEN 'opened' WHEN plan_tasks.status = 'blocked' THEN 'blocked' WHEN plan_tasks.status = 'skipped' THEN 'skipped' WHEN plan_tasks.status = 'escalated' THEN 'escalated' WHEN plan_tasks.status = 'waiting-for-lane' THEN 'waiting' WHEN plan_tasks.status = 'abandoned' THEN 'abandoned' ELSE NULL END AS delivery_status,
|
|
81
|
+
CASE WHEN plan_tasks.status IN ('pending') THEN 'pending' WHEN plan_tasks.status IN ('opened', 'blocked', 'skipped', 'abandoned') THEN 'settled' WHEN plan_tasks.status IN ('escalated', 'waiting-for-lane') THEN 'dispatched' ELSE NULL END AS dispatch_status,
|
|
82
|
+
plan_tasks.title AS title,
|
|
83
|
+
plan_tasks.prompt AS prompt,
|
|
84
|
+
plan_tasks.status AS status,
|
|
85
|
+
plan_tasks.pr_key AS pr_key,
|
|
86
|
+
plan_tasks.summary AS summary,
|
|
87
|
+
plan_tasks.wave AS wave,
|
|
88
|
+
plan_tasks.open_question AS open_question,
|
|
89
|
+
plan_tasks.answer AS answer,
|
|
90
|
+
plan_tasks.draft_pr_key AS draft_pr_key,
|
|
91
|
+
plan_tasks.corr_key AS corr_key,
|
|
92
|
+
plan_tasks.created_at AS created_at,
|
|
93
|
+
plan_tasks.updated_at AS updated_at,
|
|
94
|
+
plan_tasks.task_id AS task_id
|
|
95
|
+
FROM plan_tasks;
|
|
96
|
+
|
|
97
|
+
INSERT OR IGNORE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, created_at, updated_at)
|
|
98
|
+
SELECT 'delivery-graph:' || delivery_graph_runs.run_key AS unit_id,
|
|
99
|
+
'delivery-graph' AS kind,
|
|
100
|
+
delivery_graph_runs.run_key AS legacy_key,
|
|
101
|
+
NULL AS legacy_id,
|
|
102
|
+
NULL AS parent_unit_id,
|
|
103
|
+
NULL AS node_index,
|
|
104
|
+
CASE WHEN delivery_graph_runs.status = 'awaiting-approval' THEN 'requested' WHEN delivery_graph_runs.status = 'running' THEN 'running' WHEN delivery_graph_runs.status = 'done' THEN 'done' WHEN delivery_graph_runs.status = 'failed' THEN 'failed' WHEN delivery_graph_runs.status = 'abandoned' THEN 'abandoned' ELSE NULL END AS delivery_status,
|
|
105
|
+
CASE WHEN delivery_graph_runs.status IN ('awaiting-approval') THEN 'pending' WHEN delivery_graph_runs.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN delivery_graph_runs.status IN ('running') THEN 'dispatched' ELSE NULL END AS dispatch_status,
|
|
106
|
+
delivery_graph_runs.process_key AS process_key,
|
|
107
|
+
delivery_graph_runs.process_definition_id AS process_definition_id,
|
|
108
|
+
delivery_graph_runs.digest AS digest,
|
|
109
|
+
delivery_graph_runs.status AS status,
|
|
110
|
+
delivery_graph_runs.side_effecting AS side_effecting,
|
|
111
|
+
delivery_graph_runs.node_count AS node_count,
|
|
112
|
+
delivery_graph_runs.human_node_count AS human_node_count,
|
|
113
|
+
delivery_graph_runs.side_effect_count AS side_effect_count,
|
|
114
|
+
delivery_graph_runs.title AS title,
|
|
115
|
+
delivery_graph_runs.phase AS phase,
|
|
116
|
+
delivery_graph_runs.phase_node_id AS phase_node_id,
|
|
117
|
+
delivery_graph_runs.human_labels AS human_labels,
|
|
118
|
+
delivery_graph_runs.created_at AS created_at,
|
|
119
|
+
delivery_graph_runs.updated_at AS updated_at
|
|
120
|
+
FROM delivery_graph_runs;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
-- ADR 0006 slice S2 (#589) — legacy-shaped compatibility VIEWs served FROM the `delivery_units`
|
|
2
|
+
-- aggregate (088). Each VIEW reconstructs one legacy table's exact column set from the kind-tagged
|
|
3
|
+
-- aggregate row, so a read path can swap onto the aggregate with byte-identical results — the
|
|
4
|
+
-- "legacy tables become VIEWs/rows over the aggregate" surface of ADR 0006 §2, guarded by the parity
|
|
5
|
+
-- tests in app/deliveryUnit.test.ts (each VIEW is proven row-for-row equal to its base table over the
|
|
6
|
+
-- full status/shape matrix). These are ADDITIVE read surfaces; the live pages/read-models keep binding
|
|
7
|
+
-- the physical base tables until S3 moves the writers, so no behaviour changes here.
|
|
8
|
+
--
|
|
9
|
+
-- A single plain `CREATE VIEW … SELECT … FROM delivery_units` per shape (one top-level FROM, every
|
|
10
|
+
-- column aliased) so the static pages<->schema contract guard can parse the column list. The runner
|
|
11
|
+
-- wraps each file in its own transaction — no BEGIN/COMMIT here.
|
|
12
|
+
|
|
13
|
+
DROP VIEW IF EXISTS feature_runs__units;
|
|
14
|
+
CREATE VIEW feature_runs__units AS
|
|
15
|
+
SELECT
|
|
16
|
+
du.legacy_key AS feature_key,
|
|
17
|
+
du.repo AS repo,
|
|
18
|
+
du.issue_number AS issue_number,
|
|
19
|
+
du.issue_url AS issue_url,
|
|
20
|
+
du.base_branch AS base_branch,
|
|
21
|
+
du.status AS status,
|
|
22
|
+
du.process_key AS process_key,
|
|
23
|
+
du.pr_key AS pr_key,
|
|
24
|
+
du.converge AS converge,
|
|
25
|
+
du.auto_merge AS auto_merge,
|
|
26
|
+
du.outcome AS outcome,
|
|
27
|
+
du.created_at AS created_at,
|
|
28
|
+
du.updated_at AS updated_at,
|
|
29
|
+
du.delivery_label AS delivery_label,
|
|
30
|
+
du.title AS title,
|
|
31
|
+
du.acknowledged_at AS acknowledged_at,
|
|
32
|
+
du.stage AS stage,
|
|
33
|
+
du.stage_state AS stage_state,
|
|
34
|
+
du.stage_skipped AS stage_skipped,
|
|
35
|
+
du.attention AS attention,
|
|
36
|
+
du.list_bucket AS list_bucket
|
|
37
|
+
FROM delivery_units du
|
|
38
|
+
WHERE du.kind = 'feature';
|
|
39
|
+
|
|
40
|
+
DROP VIEW IF EXISTS plans__units;
|
|
41
|
+
CREATE VIEW plans__units AS
|
|
42
|
+
SELECT
|
|
43
|
+
du.legacy_key AS plan_key,
|
|
44
|
+
du.repo AS repo,
|
|
45
|
+
du.issue_number AS issue_number,
|
|
46
|
+
du.issue_url AS issue_url,
|
|
47
|
+
du.title AS title,
|
|
48
|
+
du.status AS status,
|
|
49
|
+
du.task_count AS task_count,
|
|
50
|
+
du.process_key AS process_key,
|
|
51
|
+
du.outcome AS outcome,
|
|
52
|
+
du.created_at AS created_at,
|
|
53
|
+
du.updated_at AS updated_at,
|
|
54
|
+
du.gate_wave AS gate_wave,
|
|
55
|
+
du.blackboard_token AS blackboard_token,
|
|
56
|
+
du.retro_started_at AS retro_started_at,
|
|
57
|
+
du.base_branch AS base_branch,
|
|
58
|
+
du.epic_phase AS epic_phase,
|
|
59
|
+
du.promotion_pr AS promotion_pr,
|
|
60
|
+
du.promotion_state AS promotion_state,
|
|
61
|
+
du.acknowledged_at AS acknowledged_at,
|
|
62
|
+
du.list_bucket AS list_bucket,
|
|
63
|
+
du.ack_open AS ack_open,
|
|
64
|
+
du.wait_gate AS wait_gate,
|
|
65
|
+
du.wait_gate_label AS wait_gate_label,
|
|
66
|
+
du.bound_artifacts AS bound_artifacts
|
|
67
|
+
FROM delivery_units du
|
|
68
|
+
WHERE du.kind = 'epic';
|
|
69
|
+
|
|
70
|
+
DROP VIEW IF EXISTS plan_tasks__units;
|
|
71
|
+
CREATE VIEW plan_tasks__units AS
|
|
72
|
+
SELECT
|
|
73
|
+
du.legacy_id AS id,
|
|
74
|
+
du.legacy_key AS plan_key,
|
|
75
|
+
du.node_index AS task_index,
|
|
76
|
+
du.task_id AS task_id,
|
|
77
|
+
du.title AS title,
|
|
78
|
+
du.prompt AS prompt,
|
|
79
|
+
du.status AS status,
|
|
80
|
+
du.pr_key AS pr_key,
|
|
81
|
+
du.summary AS summary,
|
|
82
|
+
du.created_at AS created_at,
|
|
83
|
+
du.updated_at AS updated_at,
|
|
84
|
+
du.wave AS wave,
|
|
85
|
+
du.open_question AS open_question,
|
|
86
|
+
du.answer AS answer,
|
|
87
|
+
du.draft_pr_key AS draft_pr_key,
|
|
88
|
+
du.corr_key AS corr_key
|
|
89
|
+
FROM delivery_units du
|
|
90
|
+
WHERE du.kind = 'plan-task';
|
|
91
|
+
|
|
92
|
+
DROP VIEW IF EXISTS delivery_graph_runs__units;
|
|
93
|
+
CREATE VIEW delivery_graph_runs__units AS
|
|
94
|
+
SELECT
|
|
95
|
+
du.legacy_key AS run_key,
|
|
96
|
+
du.process_key AS process_key,
|
|
97
|
+
du.process_definition_id AS process_definition_id,
|
|
98
|
+
du.digest AS digest,
|
|
99
|
+
du.status AS status,
|
|
100
|
+
du.side_effecting AS side_effecting,
|
|
101
|
+
du.node_count AS node_count,
|
|
102
|
+
du.human_node_count AS human_node_count,
|
|
103
|
+
du.side_effect_count AS side_effect_count,
|
|
104
|
+
du.title AS title,
|
|
105
|
+
du.phase AS phase,
|
|
106
|
+
du.phase_node_id AS phase_node_id,
|
|
107
|
+
du.human_labels AS human_labels,
|
|
108
|
+
du.created_at AS created_at,
|
|
109
|
+
du.updated_at AS updated_at
|
|
110
|
+
FROM delivery_units du
|
|
111
|
+
WHERE du.kind = 'delivery-graph';
|
package/nano.app.json
CHANGED
|
@@ -95,6 +95,20 @@
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
"pollMs": 5000
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"table": "delivery_units",
|
|
101
|
+
"keyField": "process_key",
|
|
102
|
+
"statusField": "dispatch_status",
|
|
103
|
+
"activeStatuses": [
|
|
104
|
+
"dispatched"
|
|
105
|
+
],
|
|
106
|
+
"onTerminated": {
|
|
107
|
+
"set": {
|
|
108
|
+
"dispatch_status": "settled"
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"pollMs": 5000
|
|
98
112
|
}
|
|
99
113
|
],
|
|
100
114
|
"workers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.156.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",
|