@nanobpm/nano-workforce 0.93.0 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/plan.test.ts +54 -0
- package/app/plan.ts +48 -0
- package/app/planFanoutPreflight.test.ts +73 -0
- package/app/planLowering.test.ts +184 -0
- package/app/planLowering.ts +201 -0
- package/db/migrations/{043_user_tasks_subject_title.sql → 046_user_tasks_subject_title.sql} +2 -1
- package/e2e/plan-fanout-preflight.e2e.ts +170 -0
- package/openapi.yaml +20 -3
- package/operations/startEpicSet.admission.integration.test.ts +25 -9
- package/operations/startEpicSet.ts +31 -24
- package/package.json +2 -2
- package/pages/epic-detail.page.json +3 -3
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +2 -2
- package/pages/home.page.json +1 -0
- package/pages/lineage.page.json +2 -2
- package/pages/overview.page.json +3 -3
- package/pages/tasks.page.json +10 -5
- package/resources/processes/plan-fanout.bpmn +407 -184
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.95.0](https://github.com/nanobpm/nano-workforce/compare/v0.94.0...v0.95.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **pages:** render "Updated" columns in the viewer's local time ([#302](https://github.com/nanobpm/nano-workforce/issues/302)) ([a73504d](https://github.com/nanobpm/nano-workforce/commit/a73504d6d6ebc968f21fa0f49eebf72774aa4b51)), closes [nano-ide#327](https://github.com/nano-ide/issues/327) [nano-ide#329](https://github.com/nano-ide/issues/329) [#301](https://github.com/nanobpm/nano-workforce/issues/301) [nano-ide#329](https://github.com/nano-ide/issues/329)
|
|
7
|
+
|
|
8
|
+
# [0.94.0](https://github.com/nanobpm/nano-workforce/compare/v0.93.0...v0.94.0) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pages:** feature-runs current stage links to the process instance ([#316](https://github.com/nanobpm/nano-workforce/issues/316)) ([4984f13](https://github.com/nanobpm/nano-workforce/commit/4984f13c1a3bd101499763731843ee4807bec49e)), closes [#315](https://github.com/nanobpm/nano-workforce/issues/315) [nano-ide#347](https://github.com/nano-ide/issues/347) [nanobpm/nano-ide#348](https://github.com/nanobpm/nano-ide/issues/348) [#311](https://github.com/nanobpm/nano-workforce/issues/311) [#307](https://github.com/nanobpm/nano-workforce/issues/307)
|
|
14
|
+
|
|
1
15
|
# [0.93.0](https://github.com/nanobpm/nano-workforce/compare/v0.92.0...v0.93.0) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
package/app/plan.test.ts
CHANGED
|
@@ -306,6 +306,60 @@ test("startPlan grandfathers a pre-existing null base_branch row: re-plan reads
|
|
|
306
306
|
assertEquals(seen.baseBranch, "epic/gate-branch");
|
|
307
307
|
});
|
|
308
308
|
|
|
309
|
+
test("startPlan fails fast when readiness probes are seeded without a probeTimeout (unusable gate)", async () => {
|
|
310
|
+
// A gated dependent's preflight escalation timers read `=probeTimeout` and `pr.readiness-probe`
|
|
311
|
+
// rejects a blank bound, so a non-empty probe set with a null/blank `probeTimeout` would incident
|
|
312
|
+
// at runtime. Guard the whole class at the start door: reject it before any side effect, and never
|
|
313
|
+
// create the process instance. The lowering always derives the pair together, so this only fires
|
|
314
|
+
// for a mis-seeded direct caller.
|
|
315
|
+
const PLAN_KEY = "owner/repo#292";
|
|
316
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
317
|
+
plans: { rows: [], key: "plan_key" },
|
|
318
|
+
plan_tasks: { rows: [], key: "id" },
|
|
319
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
320
|
+
plan_escalations: { rows: [], key: "id" },
|
|
321
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
322
|
+
};
|
|
323
|
+
const data = memData(stores);
|
|
324
|
+
let created = false;
|
|
325
|
+
const engine = {
|
|
326
|
+
createInstance: () => {
|
|
327
|
+
created = true;
|
|
328
|
+
return Promise.resolve({ processInstanceKey: "PI-292" });
|
|
329
|
+
},
|
|
330
|
+
} as any;
|
|
331
|
+
|
|
332
|
+
const probe = { kind: "command", target: "true", resolvedArtifact: "@scope/pkg@1.0.0" };
|
|
333
|
+
await assertRejects(
|
|
334
|
+
() =>
|
|
335
|
+
startPlan(
|
|
336
|
+
data,
|
|
337
|
+
engine,
|
|
338
|
+
{ repo: "owner/repo", number: 292, url: "https://github.com/owner/repo/issues/292", planKey: PLAN_KEY },
|
|
339
|
+
"epic/gate-branch",
|
|
340
|
+
{ readinessProbes: [probe] as any },
|
|
341
|
+
),
|
|
342
|
+
Error,
|
|
343
|
+
"probeTimeout",
|
|
344
|
+
);
|
|
345
|
+
// A blank/whitespace bound is rejected the same way.
|
|
346
|
+
await assertRejects(
|
|
347
|
+
() =>
|
|
348
|
+
startPlan(
|
|
349
|
+
data,
|
|
350
|
+
engine,
|
|
351
|
+
{ repo: "owner/repo", number: 292, url: "https://github.com/owner/repo/issues/292", planKey: PLAN_KEY },
|
|
352
|
+
"epic/gate-branch",
|
|
353
|
+
{ readinessProbes: [probe] as any, probeTimeout: " " },
|
|
354
|
+
),
|
|
355
|
+
Error,
|
|
356
|
+
"probeTimeout",
|
|
357
|
+
);
|
|
358
|
+
// No side effect leaked: no plan row written, no process instance created.
|
|
359
|
+
assertEquals(stores.plans.rows.length, 0);
|
|
360
|
+
assertEquals(created, false);
|
|
361
|
+
});
|
|
362
|
+
|
|
309
363
|
// ── admitPlan decision matrix (ADR 0003 §Decision, rules 1-4) ────────────────
|
|
310
364
|
// The fail-fast admission gate composes four ORDERED rules before any fan-out. These drive it
|
|
311
365
|
// through a faked github transport (token mode + stubbed `globalThis.fetch`) and an in-memory
|
package/app/plan.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
fetchIssueTitle,
|
|
23
23
|
} from "./github.ts";
|
|
24
24
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
25
|
+
import type { ReadinessProbe } from "./readiness.ts";
|
|
25
26
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
26
27
|
|
|
27
28
|
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
|
|
@@ -873,6 +874,20 @@ function assertAcyclic(adjacency: Map<string, Set<string>>): void {
|
|
|
873
874
|
}
|
|
874
875
|
}
|
|
875
876
|
|
|
877
|
+
/** Optional scheduling inputs a planner (slice S3) threads into a plan-fanout instance at start.
|
|
878
|
+
*
|
|
879
|
+
* `readinessProbes` is the set of `capability` {@link ReadinessProbe} descriptors a DEPENDENT epic
|
|
880
|
+
* must satisfy before it fans out any wave — one per inbound inter-epic edge (its producers). The
|
|
881
|
+
* plan-fanout process runs them as a LEADING readiness-gate preflight (resources/processes/plan-fanout.bpmn):
|
|
882
|
+
* a root epic (no inbound edge) is started with `undefined`/empty here and skips the gate entirely,
|
|
883
|
+
* fanning out immediately exactly as a single epic does today. `probeTimeout` is the ISO-8601 bound
|
|
884
|
+
* the preflight's timers fire off (derived once, via {@link readinessTimeout}, from the same probes)
|
|
885
|
+
* so a never-publishing producer escalates in bounded time instead of wedging the dependent. */
|
|
886
|
+
export interface StartPlanOptions {
|
|
887
|
+
readinessProbes?: ReadinessProbe[];
|
|
888
|
+
probeTimeout?: string;
|
|
889
|
+
}
|
|
890
|
+
|
|
876
891
|
/** Register a plan row (if new) and start the plan-fanout process. Idempotent on
|
|
877
892
|
* planKey: a plan already in flight is not restarted. */
|
|
878
893
|
export async function startPlan(
|
|
@@ -880,7 +895,21 @@ export async function startPlan(
|
|
|
880
895
|
engine: EngineClient,
|
|
881
896
|
parsed: ParsedIssue,
|
|
882
897
|
baseBranch: string,
|
|
898
|
+
opts: StartPlanOptions = {},
|
|
883
899
|
) {
|
|
900
|
+
// A gated dependent must carry BOTH its probes and the bound its preflight timers fire off:
|
|
901
|
+
// `pr.readiness-probe` rejects a blank `probeTimeout` (worker.ts) and the preflight escalation
|
|
902
|
+
// timers read `=probeTimeout`, so a non-empty probe set with a null/blank bound would incident at
|
|
903
|
+
// runtime. Fail fast at the start door instead — the lowering (planLowering.ts) always derives the
|
|
904
|
+
// two together via `readinessTimeout`, so this only fires for a mis-seeded direct caller.
|
|
905
|
+
const probes = opts.readinessProbes && opts.readinessProbes.length > 0 ? opts.readinessProbes : null;
|
|
906
|
+
if (probes && (opts.probeTimeout ?? "").trim() === "") {
|
|
907
|
+
throw new Error(
|
|
908
|
+
`startPlan(${parsed.planKey}): ${probes.length} readiness probe(s) seeded without a probeTimeout — ` +
|
|
909
|
+
"the preflight escalation timers (=probeTimeout) and pr.readiness-probe both require a non-blank " +
|
|
910
|
+
"bound. Derive it via readinessTimeout (see planLowering) before starting a gated dependent.",
|
|
911
|
+
);
|
|
912
|
+
}
|
|
884
913
|
const table = plans(data);
|
|
885
914
|
const existing = await table.get(parsed.planKey);
|
|
886
915
|
if (existing && !PLAN_TERMINAL_STATUSES.includes(existing.status)) {
|
|
@@ -982,6 +1011,25 @@ export async function startPlan(
|
|
|
982
1011
|
// (normalizeBaseBranch rejects blank), so the brief is always rendered.
|
|
983
1012
|
baseBranch: base,
|
|
984
1013
|
baseBranchBrief: renderBaseBranchBrief(base),
|
|
1014
|
+
// Inter-epic scheduling (issue #292, slice S3): the leading capability readiness-gate the
|
|
1015
|
+
// plan-fanout runs as a PREFLIGHT before wave 0. `readinessProbes` is a DEPENDENT epic's set
|
|
1016
|
+
// of `capability` probes (one per inbound `plan_deps` edge / producer); it is `null` for a
|
|
1017
|
+
// ROOT (no inbound edge), whose preflight gateway then routes straight past the gate so it
|
|
1018
|
+
// fans out immediately. `probeTimeout` bounds the preflight's escalation timers (derived once
|
|
1019
|
+
// from the same probes) so a never-publishing producer escalates without wedging the set.
|
|
1020
|
+
// `resolvedArtifacts` is filled by the preflight on green — the exact `pkg@version`s carrying
|
|
1021
|
+
// each awaited capability (one per probe, `null` for any that escalated). It rides the
|
|
1022
|
+
// implement task's `appendPrompt` (like `baseBranchBrief`) so slices build against exactly the
|
|
1023
|
+
// bound version. Seeded `null` here so a ROOT (which never runs the preflight) still resolves
|
|
1024
|
+
// the variable in that FEEL expression instead of raising an incident.
|
|
1025
|
+
readinessProbes: probes,
|
|
1026
|
+
probeTimeout: opts.probeTimeout ?? null,
|
|
1027
|
+
// The preflight probe worker (`pr.readiness-probe`) requires a non-blank `gateKey` correlation
|
|
1028
|
+
// key (it publishes `readiness-ready` on it). The typed `ReadinessProbeIn` envelope projects it
|
|
1029
|
+
// from THIS process scope (not task-local ioMapping), so it is seeded here — one per dependent
|
|
1030
|
+
// instance. A ROOT never runs the preflight, so its `gateKey` stays `null`, unused.
|
|
1031
|
+
gateKey: probes ? `preflight:${parsed.planKey}` : null,
|
|
1032
|
+
resolvedArtifacts: null,
|
|
985
1033
|
},
|
|
986
1034
|
});
|
|
987
1035
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Structural guard for the inter-epic capability PREFLIGHT wired into plan-fanout (issue #292, slice
|
|
2
|
+
// S3). Before S3, `plan-fanout.bpmn` fanned out every epic immediately and `readiness-gate.bpmn` was a
|
|
3
|
+
// standalone process it never invoked. S3 seeds a LEADING capability readiness-gate before wave 0 for
|
|
4
|
+
// dependent epics: an exclusive gateway after Start routes a ROOT (readinessProbes == null) straight to
|
|
5
|
+
// ensure-base-branch, and a DEPENDENT into a multi-instance preflight subprocess (over =readinessProbes)
|
|
6
|
+
// that polls each producer's capability via the reused `pr.readiness-probe` worker and escalates
|
|
7
|
+
// (bounded) via the reused `readiness-escalation` form — never fanning a wave until the gate is green.
|
|
8
|
+
// The bound `pkg@version`s (resolvedArtifacts) ride the implement task's appendPrompt.
|
|
9
|
+
//
|
|
10
|
+
// Pure text assertions over the committed BPMN (no engine), matching the repo's model-guard style
|
|
11
|
+
// (see mergeEscalationUserTask.test.ts / mergeRebaseArm.test.ts).
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
15
|
+
|
|
16
|
+
const bpmn = readFileSync("resources/processes/plan-fanout.bpmn", "utf8");
|
|
17
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
18
|
+
|
|
19
|
+
function hasFlow(source: string, target: string): boolean {
|
|
20
|
+
const re = new RegExp(
|
|
21
|
+
`<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
|
|
22
|
+
`<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
|
|
23
|
+
);
|
|
24
|
+
return re.test(flat);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("Start routes through a readiness gateway that a ROOT skips and a DEPENDENT enters", () => {
|
|
28
|
+
assert(hasFlow("Start", "gw-readiness"), "Start must reach the readiness gateway");
|
|
29
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-readiness"[\s\S]*?<\/bpmn:exclusiveGateway>/);
|
|
30
|
+
assert(gw, "gw-readiness must be an exclusiveGateway");
|
|
31
|
+
// Default (root) skips straight to ensure-base-branch; the gated flow enters the preflight only when
|
|
32
|
+
// readinessProbes is present (a dependent).
|
|
33
|
+
assertStringIncludes(gw![0], 'default="f_readiness_skip"', "root (no probes) is the default skip flow");
|
|
34
|
+
assert(hasFlow("gw-readiness", "ensure-base-branch"), "root skips straight to ensure-base-branch");
|
|
35
|
+
assert(hasFlow("gw-readiness", "readiness-preflight"), "dependent enters the preflight subprocess");
|
|
36
|
+
const gate = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="f_readiness_gate"[\s\S]*?<\/bpmn:sequenceFlow>/);
|
|
37
|
+
assert(gate, "the gated flow must be conditional");
|
|
38
|
+
assertStringIncludes(gate![0], "readinessProbes != null", "only a dependent (has probes) is gated");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the preflight is a multi-instance subprocess over =readinessProbes collecting resolvedArtifacts", () => {
|
|
42
|
+
const sub = flat.match(/<bpmn:subProcess\b[^>]*\bid="readiness-preflight"[\s\S]*?<\/bpmn:subProcess>/);
|
|
43
|
+
assert(sub, "readiness-preflight must be a subProcess");
|
|
44
|
+
assertStringIncludes(sub![0], "<bpmn:multiInstanceLoopCharacteristics", "it waits for ALL producers in parallel");
|
|
45
|
+
assertStringIncludes(sub![0], 'inputCollection="=readinessProbes"', "one instance per seeded capability probe");
|
|
46
|
+
assertStringIncludes(sub![0], 'outputCollection="resolvedArtifacts"', "binds each resolved pkg@version");
|
|
47
|
+
assert(hasFlow("readiness-preflight", "ensure-base-branch"), "the gate leads into the fan-out, before wave 0");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the preflight reuses the pr.readiness-probe worker and the readiness-escalation form (no reinvention)", () => {
|
|
51
|
+
const sub = flat.match(/<bpmn:subProcess\b[^>]*\bid="readiness-preflight"[\s\S]*?<\/bpmn:subProcess>/)![0];
|
|
52
|
+
assertStringIncludes(sub, 'type="pr.readiness-probe"', "reuses the existing capability probe worker");
|
|
53
|
+
assertStringIncludes(sub, 'value="ReadinessProbeIn"', "feeds the shared probe input envelope");
|
|
54
|
+
assertStringIncludes(sub, 'value="ReadinessProbeOut"', "reads the shared probe output envelope");
|
|
55
|
+
assertStringIncludes(sub, 'formId="readiness-escalation"', "reuses the existing readiness escalation form");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a never-green producer escalates (bounded) without wedging: probe timeout + SLA both settle the gate", () => {
|
|
59
|
+
const sub = flat.match(/<bpmn:subProcess\b[^>]*\bid="readiness-preflight"[\s\S]*?<\/bpmn:subProcess>/)![0];
|
|
60
|
+
// The probe carries an interrupting timeout bound (reuses the gate's =probeTimeout), and the human
|
|
61
|
+
// escalation carries the shared SLA bound — so a stuck producer can never wedge the dependent.
|
|
62
|
+
assertStringIncludes(sub, "=probeTimeout", "the probe is bounded by the reused =probeTimeout");
|
|
63
|
+
assertStringIncludes(sub, "=escalationSlaTimeout", "the escalation is bounded by the shared SLA");
|
|
64
|
+
assert(hasFlow("be_pf_probe_timeout", "readiness-escalation-pf"), "a timed-out probe routes to escalation");
|
|
65
|
+
assert(hasFlow("pf_gw", "readiness-escalation-pf"), "a not-ready probe routes to escalation");
|
|
66
|
+
assert(hasFlow("be_pf_sla", "pf_end"), "an elapsed escalation SLA settles the preflight instead of wedging");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the bound resolvedArtifacts version rides the implement task's appendPrompt", () => {
|
|
70
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="implement-task"[\s\S]*?<\/bpmn:serviceTask>/);
|
|
71
|
+
assert(task, "implement-task must exist");
|
|
72
|
+
assertStringIncludes(task![0], "resolvedArtifacts", "the bound pkg@version is threaded into the slice prompt");
|
|
73
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Unit coverage for the inter-epic planner LOWERING (issue #292, slice S3) — the pure schedule
|
|
2
|
+
// derivation (`deriveEpicSchedule` / `capabilityProbeForEdge`) plus the `lowerAdmittedSet` executor
|
|
3
|
+
// that reads S2's staging, starts roots immediately, seeds a capability preflight for dependents, and
|
|
4
|
+
// materializes the durable `plan_deps` edges. Runs against an in-memory data/engine double (no engine,
|
|
5
|
+
// no network) exactly like the admission integration harness.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
9
|
+
import type { PlanDep } from "./plan.ts";
|
|
10
|
+
import { capabilityProbeForEdge, deriveEpicSchedule, lowerAdmittedSet } from "./planLowering.ts";
|
|
11
|
+
|
|
12
|
+
const edge = (consumer: string, producer: string, pkg = "@scope/pkg", capRef = producer): PlanDep => ({
|
|
13
|
+
plan_key: consumer,
|
|
14
|
+
depends_on_plan_key: producer,
|
|
15
|
+
package: pkg,
|
|
16
|
+
capability_ref: capRef,
|
|
17
|
+
created_at: "t0",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// ── in-memory data + engine double ───────────────────────────────────────────────────────────────
|
|
21
|
+
function makeData() {
|
|
22
|
+
const tables = new Map<string, Record<string, unknown>[]>();
|
|
23
|
+
const rowsFor = (name: string) => {
|
|
24
|
+
const r = tables.get(name) ?? [];
|
|
25
|
+
tables.set(name, r);
|
|
26
|
+
return r;
|
|
27
|
+
};
|
|
28
|
+
const table = (name: string, key: string) => {
|
|
29
|
+
const rows = rowsFor(name);
|
|
30
|
+
return {
|
|
31
|
+
get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
32
|
+
find: (q: Record<string, unknown>) =>
|
|
33
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
34
|
+
insert: (r: Record<string, unknown>) => {
|
|
35
|
+
rows.push(r);
|
|
36
|
+
return Promise.resolve(r);
|
|
37
|
+
},
|
|
38
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
39
|
+
const row = rows.find((r) => r[key] === k);
|
|
40
|
+
if (row) Object.assign(row, patch);
|
|
41
|
+
return Promise.resolve(row);
|
|
42
|
+
},
|
|
43
|
+
delete: (k: unknown) => {
|
|
44
|
+
const i = rows.findIndex((r) => r[key] === k);
|
|
45
|
+
if (i >= 0) rows.splice(i, 1);
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const started: { processDefinitionId: string; variables?: Record<string, unknown> }[] = [];
|
|
51
|
+
const engine = {
|
|
52
|
+
createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
53
|
+
started.push(req);
|
|
54
|
+
return Promise.resolve({ processInstanceKey: `PI-${started.length}` });
|
|
55
|
+
},
|
|
56
|
+
} as unknown as EngineClient;
|
|
57
|
+
const data = { table } as unknown as DataLayer;
|
|
58
|
+
return { data, engine, tables, started };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stageEpic(tables: Map<string, Record<string, unknown>[]>, planKey: string, base: string) {
|
|
62
|
+
const rows = tables.get("admitted_epics") ?? [];
|
|
63
|
+
tables.set("admitted_epics", rows);
|
|
64
|
+
const [repo, num] = planKey.split("#");
|
|
65
|
+
rows.push({
|
|
66
|
+
plan_key: planKey,
|
|
67
|
+
repo,
|
|
68
|
+
issue_number: Number(num),
|
|
69
|
+
issue_url: `https://github.com/${repo}/issues/${num}`,
|
|
70
|
+
base_branch: base,
|
|
71
|
+
created_at: "t0",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stageEdge(tables: Map<string, Record<string, unknown>[]>, e: PlanDep) {
|
|
76
|
+
const rows = tables.get("admitted_plan_deps") ?? [];
|
|
77
|
+
tables.set("admitted_plan_deps", rows);
|
|
78
|
+
rows.push({ ...e });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Force token-transport with NO token so startPlan's best-effort issue-title lookup short-circuits to
|
|
82
|
+
// null (no `gh` shell-out, no network) — the epic falls back to its plan key for identity.
|
|
83
|
+
const noFetch = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
84
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
85
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
86
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
87
|
+
delete process.env["GITHUB_TOKEN"];
|
|
88
|
+
try {
|
|
89
|
+
return await fn();
|
|
90
|
+
} finally {
|
|
91
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
92
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
93
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
94
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ── capabilityProbeForEdge ──────────────────────────────────────────────────────────────────────
|
|
99
|
+
test("capabilityProbeForEdge derives a capability probe pinned to the producer repo's releases", () => {
|
|
100
|
+
const probe = capabilityProbeForEdge(edge("owner/repo#2", "owner/repo#1", "@scope/pkg", "owner/repo#1"));
|
|
101
|
+
assertEquals(probe.kind, "capability");
|
|
102
|
+
assertEquals(probe.target, "github-releases:owner/repo");
|
|
103
|
+
assertEquals(probe.match?.package, "@scope/pkg");
|
|
104
|
+
assertEquals(probe.match?.capabilityRef, "owner/repo#1");
|
|
105
|
+
assertEquals(probe.onTimeout, "escalate");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("capabilityProbeForEdge splits the producer repo even across different owner/repo producers", () => {
|
|
109
|
+
const probe = capabilityProbeForEdge(edge("a/consumer#5", "b/producer#9", "@b/lib", "b/producer#9"));
|
|
110
|
+
assertEquals(probe.target, "github-releases:b/producer");
|
|
111
|
+
assertEquals(probe.match?.capabilityRef, "b/producer#9");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── deriveEpicSchedule ──────────────────────────────────────────────────────────────────────────
|
|
115
|
+
test("deriveEpicSchedule: an epic with no inbound edge is a ROOT (started immediately)", () => {
|
|
116
|
+
const sched = deriveEpicSchedule(["o/r#1", "o/r#2"], [edge("o/r#2", "o/r#1")]);
|
|
117
|
+
assertEquals(sched.roots, ["o/r#1"]);
|
|
118
|
+
assertEquals(sched.dependents.length, 1);
|
|
119
|
+
assertEquals(sched.dependents[0].planKey, "o/r#2");
|
|
120
|
+
assertEquals(sched.dependents[0].producers, ["o/r#1"]);
|
|
121
|
+
assertEquals(sched.dependents[0].probes.length, 1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("deriveEpicSchedule: a set with no edges makes every epic a root, no dependents", () => {
|
|
125
|
+
const sched = deriveEpicSchedule(["o/r#1", "o/r#2"], []);
|
|
126
|
+
assertEquals(sched.roots.sort(), ["o/r#1", "o/r#2"]);
|
|
127
|
+
assertEquals(sched.dependents.length, 0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("deriveEpicSchedule: a dependent with MULTIPLE inbound edges waits for ALL its producers", () => {
|
|
131
|
+
const sched = deriveEpicSchedule(
|
|
132
|
+
["o/r#1", "o/r#2", "o/r#3"],
|
|
133
|
+
[edge("o/r#3", "o/r#1", "@a/x", "o/r#1"), edge("o/r#3", "o/r#2", "@b/y", "o/r#2")],
|
|
134
|
+
);
|
|
135
|
+
assertEquals(sched.roots.sort(), ["o/r#1", "o/r#2"]);
|
|
136
|
+
assertEquals(sched.dependents.length, 1);
|
|
137
|
+
const dep = sched.dependents[0];
|
|
138
|
+
assertEquals(dep.planKey, "o/r#3");
|
|
139
|
+
assertEquals(dep.producers.sort(), ["o/r#1", "o/r#2"]);
|
|
140
|
+
assertEquals(dep.probes.length, 2); // one probe per producer — must satisfy both to fan out
|
|
141
|
+
assert(dep.probeTimeout.startsWith("PT") || dep.probeTimeout.startsWith("P"), "an ISO-8601 bound");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── lowerAdmittedSet ────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
test("lowerAdmittedSet starts roots with no probe and dependents with their seeded capability gate", async () => {
|
|
146
|
+
const { data, engine, tables, started } = makeData();
|
|
147
|
+
stageEpic(tables, "o/r#1", "epic/producer");
|
|
148
|
+
stageEpic(tables, "o/r#2", "epic/consumer");
|
|
149
|
+
stageEdge(tables, edge("o/r#2", "o/r#1"));
|
|
150
|
+
|
|
151
|
+
const res = await noFetch(() => lowerAdmittedSet(data, engine, ["o/r#1", "o/r#2"]));
|
|
152
|
+
|
|
153
|
+
assertEquals(res.roots, ["o/r#1"]);
|
|
154
|
+
assertEquals(res.dependents, [{ planKey: "o/r#2", producers: ["o/r#1"] }]);
|
|
155
|
+
assertEquals(res.edgesMaterialized, 1);
|
|
156
|
+
assertEquals(started.length, 2);
|
|
157
|
+
|
|
158
|
+
const byKey = new Map(started.map((s) => [s.variables?.["planKey"], s.variables ?? {}]));
|
|
159
|
+
assertEquals(byKey.get("o/r#1")?.["readinessProbes"], null); // root fans out immediately
|
|
160
|
+
const depProbes = byKey.get("o/r#2")?.["readinessProbes"] as unknown[] | null;
|
|
161
|
+
assert(Array.isArray(depProbes) && depProbes.length === 1, "dependent seeded with one capability probe");
|
|
162
|
+
assert(byKey.get("o/r#2")?.["probeTimeout"] != null, "dependent seeded with a bounded timeout");
|
|
163
|
+
|
|
164
|
+
// Durable edge materialized (after the plans rows exist), and a plans row per epic.
|
|
165
|
+
assertEquals((tables.get("plan_deps") ?? []).length, 1);
|
|
166
|
+
assertEquals((tables.get("plans") ?? []).length, 2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("lowerAdmittedSet is idempotent: re-running neither double-starts an epic nor duplicates an edge", async () => {
|
|
170
|
+
const { data, engine, tables, started } = makeData();
|
|
171
|
+
stageEpic(tables, "o/r#1", "epic/producer");
|
|
172
|
+
stageEpic(tables, "o/r#2", "epic/consumer");
|
|
173
|
+
stageEdge(tables, edge("o/r#2", "o/r#1"));
|
|
174
|
+
|
|
175
|
+
await noFetch(() => lowerAdmittedSet(data, engine, ["o/r#1", "o/r#2"]));
|
|
176
|
+
assertEquals(started.length, 2);
|
|
177
|
+
assertEquals((tables.get("plan_deps") ?? []).length, 1);
|
|
178
|
+
|
|
179
|
+
// Second admission of the same set: startPlan short-circuits the already-running plans, recordPlanDep
|
|
180
|
+
// collapses the duplicate edge — no new instance, no duplicate row.
|
|
181
|
+
await noFetch(() => lowerAdmittedSet(data, engine, ["o/r#1", "o/r#2"]));
|
|
182
|
+
assertEquals(started.length, 2, "no epic re-started on a re-run");
|
|
183
|
+
assertEquals((tables.get("plan_deps") ?? []).length, 1, "no duplicate durable edge on a re-run");
|
|
184
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// nano-workforce — inter-epic planner lowering (issue #292, slice S3).
|
|
2
|
+
//
|
|
3
|
+
// S1 landed the durable inter-epic edge (`plan_deps` + the `PlanDep` read API); S2 added the
|
|
4
|
+
// set/batch admission DOOR, which validates a whole set all-or-nothing and STAGES the admitted epics
|
|
5
|
+
// + validated edges FK-free into `admitted_epics` / `admitted_plan_deps` — deliberately materializing
|
|
6
|
+
// NEITHER a `plans` row NOR a `plan_deps` edge, and starting nothing.
|
|
7
|
+
//
|
|
8
|
+
// This module is the LOWERING half: it reads that staging and turns the validated DAG into a running
|
|
9
|
+
// schedule. It is the seam `startEpicSet` calls once the whole set has admitted:
|
|
10
|
+
// 1. ROOTS (no inbound edge) start IMMEDIATELY — each fans out right away, exactly as a single epic
|
|
11
|
+
// does today (`startPlan` with no readiness probe).
|
|
12
|
+
// 2. DEPENDENTS (≥1 inbound edge) start with a LEADING capability readiness-gate: `startPlan`
|
|
13
|
+
// seeds the epic's `capability` {@link ReadinessProbe} set (one probe per inbound edge / producer)
|
|
14
|
+
// + the ISO timeout bound, and the plan-fanout process runs them as a preflight before wave 0
|
|
15
|
+
// (resources/processes/plan-fanout.bpmn). The dependent fans out NO wave until every probe is
|
|
16
|
+
// green; a never-publishing producer escalates (bounded) via the reused readiness-escalation
|
|
17
|
+
// user task without wedging the dependent or the rest of the set. A dependent with MULTIPLE
|
|
18
|
+
// inbound edges waits for ALL its producers (the preflight is multi-instance over the probe set).
|
|
19
|
+
// 3. Once each epic's `plans` row exists (created by `startPlan`), the validated edges are
|
|
20
|
+
// MATERIALIZED durably via `recordPlanDep` — the `plan_deps.plan_key REFERENCES plans(plan_key)`
|
|
21
|
+
// FK is satisfied by construction because the consumer's `plans` row was just created.
|
|
22
|
+
//
|
|
23
|
+
// Everything here is idempotent so RE-RUNNING admission for an already-lowered set neither
|
|
24
|
+
// double-starts an epic nor re-seeds a gate nor duplicates an edge: `startPlan` short-circuits an
|
|
25
|
+
// already-running plan (returning `alreadyRunning`), and `recordPlanDep` collapses a duplicate edge.
|
|
26
|
+
// Because the probes are seeded once at instance creation, a re-run that finds the plan already
|
|
27
|
+
// running never creates a second instance — so the gate is seeded exactly once.
|
|
28
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
29
|
+
import {
|
|
30
|
+
type AdmittedEpic,
|
|
31
|
+
admittedEpics,
|
|
32
|
+
admittedPlanDeps,
|
|
33
|
+
type PlanDep,
|
|
34
|
+
parseIssue,
|
|
35
|
+
recordPlanDep,
|
|
36
|
+
startPlan,
|
|
37
|
+
} from "./plan.ts";
|
|
38
|
+
import { type ReadinessProbe, readinessTimeout } from "./readiness.ts";
|
|
39
|
+
|
|
40
|
+
/** Derive the `capability` readiness probe for ONE inbound inter-epic edge: it goes green when the
|
|
41
|
+
* producer epic (`depends_on_plan_key`) has published a release of `package` whose provenance carries
|
|
42
|
+
* the `capability_ref` issue handle, resolving the LOWEST such version (`matchCapability`) and binding
|
|
43
|
+
* it as `resolvedArtifact` (`pkg@version`) — the exact version that FIRST carries the capability, not
|
|
44
|
+
* merely the newest. The provenance source repo is the producer's repo, split from its plan key. */
|
|
45
|
+
export function capabilityProbeForEdge(edge: PlanDep): ReadinessProbe {
|
|
46
|
+
const producer = parseIssue(edge.depends_on_plan_key);
|
|
47
|
+
// The producer plan key is always a parseable `owner/repo#N` (S1/S2 admitted it), but fall back to
|
|
48
|
+
// the pre-`#` segment defensively so a probe is always well-formed rather than throwing here.
|
|
49
|
+
const repo = producer ? producer.repo : edge.depends_on_plan_key.split("#")[0];
|
|
50
|
+
return {
|
|
51
|
+
kind: "capability",
|
|
52
|
+
target: `github-releases:${repo}`,
|
|
53
|
+
match: { package: edge.package, capabilityRef: edge.capability_ref },
|
|
54
|
+
// A stuck/never-publishing producer must ESCALATE (bounded) — never fail the dependent silently
|
|
55
|
+
// nor let it proceed unbound. This is the readiness gate's default, made explicit here.
|
|
56
|
+
onTimeout: "escalate",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One dependent epic's leading gate: the `capability` probes it must ALL satisfy (one per inbound
|
|
61
|
+
* edge / producer) and the ISO-8601 timeout bound its preflight escalation timers fire off. */
|
|
62
|
+
export interface DependentGate {
|
|
63
|
+
planKey: string;
|
|
64
|
+
probes: ReadinessProbe[];
|
|
65
|
+
producers: string[];
|
|
66
|
+
probeTimeout: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The pure schedule derived from a validated set: the ROOTS to start immediately and the
|
|
70
|
+
* DEPENDENTS whose fan-out is gated behind their producers' capabilities. */
|
|
71
|
+
export interface EpicSchedule {
|
|
72
|
+
roots: string[];
|
|
73
|
+
dependents: DependentGate[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pure, side-effect-free lowering of a validated DAG into a schedule (no data/engine access) — the
|
|
77
|
+
* unit-testable core of {@link lowerAdmittedSet}. Given the set's plan keys and its inter-epic edges,
|
|
78
|
+
* it partitions the epics into roots (no inbound edge → start immediately) and dependents (≥1 inbound
|
|
79
|
+
* edge → wait behind a capability gate), deriving each dependent's probe set from its inbound edges.
|
|
80
|
+
* A dependent with multiple inbound edges carries multiple probes — it must wait for ALL of them. */
|
|
81
|
+
export function deriveEpicSchedule(
|
|
82
|
+
planKeys: readonly string[],
|
|
83
|
+
edges: readonly PlanDep[],
|
|
84
|
+
env: Record<string, string | undefined> = process.env,
|
|
85
|
+
): EpicSchedule {
|
|
86
|
+
const inbound = new Map<string, PlanDep[]>();
|
|
87
|
+
for (const edge of edges) {
|
|
88
|
+
const list = inbound.get(edge.plan_key);
|
|
89
|
+
if (list) list.push(edge);
|
|
90
|
+
else inbound.set(edge.plan_key, [edge]);
|
|
91
|
+
}
|
|
92
|
+
const roots: string[] = [];
|
|
93
|
+
const dependents: DependentGate[] = [];
|
|
94
|
+
for (const planKey of planKeys) {
|
|
95
|
+
const edgesForKey = inbound.get(planKey);
|
|
96
|
+
if (!edgesForKey || edgesForKey.length === 0) {
|
|
97
|
+
roots.push(planKey);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const probes = edgesForKey.map(capabilityProbeForEdge);
|
|
101
|
+
// One bound governs the whole dependent's preflight timers — the LONGEST of its probes' derived
|
|
102
|
+
// timeouts, so no producer is cut short. In practice every derived probe shares the default, so
|
|
103
|
+
// this is that default; the max keeps it correct if a probe ever carries a bespoke poll budget.
|
|
104
|
+
const probeTimeout = probes
|
|
105
|
+
.map((p) => readinessTimeout(p, env))
|
|
106
|
+
.reduce((a, b) => (isoLonger(a, b) ? a : b));
|
|
107
|
+
dependents.push({ planKey, probes, producers: edgesForKey.map((e) => e.depends_on_plan_key), probeTimeout });
|
|
108
|
+
}
|
|
109
|
+
return { roots, dependents };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The result of lowering a set: which epics were started as roots vs gated dependents, and how many
|
|
113
|
+
* durable edges were materialized. Returned so the admission door can report the schedule. */
|
|
114
|
+
export interface LoweringResult {
|
|
115
|
+
roots: string[];
|
|
116
|
+
dependents: { planKey: string; producers: string[] }[];
|
|
117
|
+
edgesMaterialized: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Lower a WHOLE admitted set (read from the S2 staging tables) into a running schedule. Reads the
|
|
121
|
+
* staged epics + edges, derives the schedule, starts roots immediately and dependents behind their
|
|
122
|
+
* capability gate, then materializes the durable `plan_deps` edges (after the consumer's `plans` row
|
|
123
|
+
* exists, so the FK holds). Idempotent end-to-end: re-running it neither double-starts an epic, nor
|
|
124
|
+
* re-seeds a gate, nor duplicates an edge. The set's membership is `planKeys` (every admitted epic,
|
|
125
|
+
* roots included) — the same list the door staged. */
|
|
126
|
+
export async function lowerAdmittedSet(
|
|
127
|
+
data: DataLayer,
|
|
128
|
+
engine: EngineClient,
|
|
129
|
+
planKeys: readonly string[],
|
|
130
|
+
env: Record<string, string | undefined> = process.env,
|
|
131
|
+
): Promise<LoweringResult> {
|
|
132
|
+
// Read the staged epics (for repo/base/issue) and the staged edges (the DAG) for the set.
|
|
133
|
+
const epicByKey = new Map<string, AdmittedEpic>();
|
|
134
|
+
for (const planKey of planKeys) {
|
|
135
|
+
const staged = await admittedEpics(data).get(planKey);
|
|
136
|
+
if (staged) epicByKey.set(planKey, staged);
|
|
137
|
+
}
|
|
138
|
+
const edges: PlanDep[] = [];
|
|
139
|
+
const seenEdges = new Set<string>();
|
|
140
|
+
for (const planKey of new Set(planKeys)) {
|
|
141
|
+
for (const edge of await admittedPlanDeps(data).find({ plan_key: planKey })) {
|
|
142
|
+
const id = `${edge.plan_key}\u0000${edge.depends_on_plan_key}`;
|
|
143
|
+
if (seenEdges.has(id)) continue;
|
|
144
|
+
seenEdges.add(id);
|
|
145
|
+
edges.push(edge);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const schedule = deriveEpicSchedule([...epicByKey.keys()], edges, env);
|
|
150
|
+
const gateByKey = new Map(schedule.dependents.map((d) => [d.planKey, d]));
|
|
151
|
+
|
|
152
|
+
// Start every admitted epic — roots with no probe (immediate fan-out), dependents with their
|
|
153
|
+
// seeded capability gate. `startPlan` creates the `plans` row (idempotent on an already-running
|
|
154
|
+
// plan), which the durable edge FK below then references.
|
|
155
|
+
for (const planKey of epicByKey.keys()) {
|
|
156
|
+
const staged = epicByKey.get(planKey);
|
|
157
|
+
if (!staged) continue;
|
|
158
|
+
const parsed = parseIssue(staged.issue_url) ?? parseIssue(planKey);
|
|
159
|
+
if (!parsed) continue;
|
|
160
|
+
const gate = gateByKey.get(planKey);
|
|
161
|
+
await startPlan(data, engine, parsed, staged.base_branch, {
|
|
162
|
+
readinessProbes: gate?.probes,
|
|
163
|
+
probeTimeout: gate?.probeTimeout,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Materialize the durable edges now that every consumer's `plans` row exists. Idempotent: a
|
|
168
|
+
// re-run collapses the duplicate. Done AFTER the starts so the `plan_deps.plan_key` FK is satisfied.
|
|
169
|
+
let edgesMaterialized = 0;
|
|
170
|
+
for (const edge of edges) {
|
|
171
|
+
await recordPlanDep(data, {
|
|
172
|
+
plan_key: edge.plan_key,
|
|
173
|
+
depends_on_plan_key: edge.depends_on_plan_key,
|
|
174
|
+
package: edge.package,
|
|
175
|
+
capability_ref: edge.capability_ref,
|
|
176
|
+
});
|
|
177
|
+
edgesMaterialized += 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
roots: schedule.roots,
|
|
182
|
+
dependents: schedule.dependents.map((d) => ({ planKey: d.planKey, producers: d.producers })),
|
|
183
|
+
edgesMaterialized,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Compare two ISO-8601 durations of the shape `readinessTimeout` emits (`PT…`), returning true when
|
|
188
|
+
* `a` is the LONGER. Kept deliberately small: the derived probes all share one default, so this only
|
|
189
|
+
* ever breaks a tie; it parses the `PnDTnHnMnS` fields the emitter can produce and never throws. */
|
|
190
|
+
function isoLonger(a: string, b: string): boolean {
|
|
191
|
+
return isoDurationSeconds(a) >= isoDurationSeconds(b);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Coarse ISO-8601 duration → seconds for the `PnDTnHnMnS` subset `msToIsoDuration` emits. Only used
|
|
195
|
+
* to pick the longer of two derived bounds; a malformed value parses as 0 rather than throwing. */
|
|
196
|
+
function isoDurationSeconds(iso: string): number {
|
|
197
|
+
const m = iso.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/);
|
|
198
|
+
if (!m) return 0;
|
|
199
|
+
const [, d, h, min, s] = m;
|
|
200
|
+
return Number(d ?? 0) * 86400 + Number(h ?? 0) * 3600 + Number(min ?? 0) * 60 + Number(s ?? 0);
|
|
201
|
+
}
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
-- The backfill coalesces existing rows to `subject_key` (matching the write-time coalesce
|
|
19
19
|
-- in `pollUserTasks`, which re-derives the real title in place on the next poll — a
|
|
20
20
|
-- completed task's row is deleted, not migrated). Idempotent: re-running is a no-op once
|
|
21
|
-
-- set.
|
|
21
|
+
-- set. Renumbered from 043 to 046 to resolve an apply-order prefix collision with
|
|
22
|
+
-- 043_pr_epic_phase.sql (both landed ~concurrently on main); the runner wraps
|
|
22
23
|
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
23
24
|
ALTER TABLE user_tasks ADD COLUMN subject_title TEXT;
|
|
24
25
|
UPDATE user_tasks SET subject_title = subject_key WHERE subject_title IS NULL OR trim(subject_title) = '';
|