@nanobpm/nano-workforce 0.94.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 +7 -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/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 +1 -1
- 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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// End-to-end proof for the inter-epic capability PREFLIGHT seeded into plan-fanout (issue #292, slice
|
|
2
|
+
// S3). Boots the whole app against the WASM engine + virtual clock and drives the REAL
|
|
3
|
+
// `plan-fanout.bpmn` with `readinessProbes` seeded — the exact shape slice S3's lowering seeds for a
|
|
4
|
+
// DEPENDENT epic — proving the leading readiness-gate executes on the engine BEFORE wave 0:
|
|
5
|
+
// • GREEN — a dependent seeded with a probe that is ready reruns the reused `pr.readiness-probe`
|
|
6
|
+
// worker inside the multi-instance preflight, releases through `pf_gw → pf_end`, binds the
|
|
7
|
+
// resolved artifact into `resolvedArtifacts`, and only THEN reaches `ensure-base-branch` (the
|
|
8
|
+
// head of the fan-out) — it never fans a wave before the gate is green, and never escalates.
|
|
9
|
+
// • ROOT — an epic seeded with `readinessProbes = null` skips the gate entirely
|
|
10
|
+
// (`gw-readiness → ensure-base-branch`), fanning out immediately as a single epic does today.
|
|
11
|
+
//
|
|
12
|
+
// The probe is a deterministic shell builtin (`true`) with a bound artifact, so the gate itself is
|
|
13
|
+
// hermetic (no network, no GitHub). The fan-out head (`pr.ensure-base-branch`) that follows a green
|
|
14
|
+
// gate is handled by the shared hermetic admit-github stub (installAdmitGithub) like the sibling
|
|
15
|
+
// plan-fanout e2es, so the whole flow runs offline.
|
|
16
|
+
// We assert on the cumulative taken sequence flows (the WASM engine folds completed variables away).
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { after, before, describe, test } from "node:test";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
24
|
+
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
25
|
+
|
|
26
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
|
|
28
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
29
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
30
|
+
GITHUB_TOKEN: "",
|
|
31
|
+
};
|
|
32
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
33
|
+
|
|
34
|
+
interface TakenFlow {
|
|
35
|
+
from: string;
|
|
36
|
+
to: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function takenFlows(app: TestApp): string[] {
|
|
40
|
+
const snapshot = app.snapshot();
|
|
41
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
42
|
+
return flows
|
|
43
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
44
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The full variable set `startPlan` seeds onto a plan-fanout instance (app/plan.ts). We seed it
|
|
48
|
+
// directly so we can inject `readinessProbes` (which the `startPlanFanout` op never sets — only S3's
|
|
49
|
+
// set lowering does) without standing up a whole two-epic set + release provenance.
|
|
50
|
+
function planVars(overrides: Record<string, unknown>): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
planKey: "owner/repo#2",
|
|
53
|
+
repo: "owner/repo",
|
|
54
|
+
issue: "owner/repo#2",
|
|
55
|
+
issueNumber: 2,
|
|
56
|
+
issueUrl: "https://github.com/owner/repo/issues/2",
|
|
57
|
+
planFindings: null,
|
|
58
|
+
planReviewEpoch: 0,
|
|
59
|
+
escalationSlaTimeout: "PT24H",
|
|
60
|
+
escalationAssignee: null,
|
|
61
|
+
blackboardUrl: "http://blackboard.local/x",
|
|
62
|
+
blackboardBrief: "",
|
|
63
|
+
baseBranch: "epic/e2e",
|
|
64
|
+
baseBranchBrief: "",
|
|
65
|
+
waveCount: 1,
|
|
66
|
+
readinessProbes: null,
|
|
67
|
+
probeTimeout: null,
|
|
68
|
+
gateKey: null,
|
|
69
|
+
resolvedArtifacts: null,
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
75
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-preflight-"));
|
|
76
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
|
|
77
|
+
return { app, dbDir };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #292 S3)", () => {
|
|
81
|
+
let restoreGithub: (() => void) | undefined;
|
|
82
|
+
|
|
83
|
+
before(() => {
|
|
84
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
85
|
+
savedEnv.set(k, process.env[k]);
|
|
86
|
+
process.env[k] = v;
|
|
87
|
+
}
|
|
88
|
+
// The fan-out head (`pr.ensure-base-branch`, ADR 0003) reads/creates the base ref via the token
|
|
89
|
+
// transport, which would throw `no GitHub transport available` under an empty token. Pin the
|
|
90
|
+
// shared hermetic admit-github stub (dummy token + fetch intercept) like the sibling plan-fanout
|
|
91
|
+
// e2es so base-branch admission is deterministic and offline.
|
|
92
|
+
restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
|
|
93
|
+
});
|
|
94
|
+
after(() => {
|
|
95
|
+
restoreGithub?.();
|
|
96
|
+
for (const [k, v] of savedEnv) {
|
|
97
|
+
if (v === undefined) delete process.env[k];
|
|
98
|
+
else process.env[k] = v;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("GREEN: a dependent waits on the preflight, releases green, and only THEN reaches the fan-out head", async () => {
|
|
103
|
+
const { app, dbDir } = await boot();
|
|
104
|
+
try {
|
|
105
|
+
const { processInstanceKey } = await app.engine.createInstance({
|
|
106
|
+
processDefinitionId: "plan-fanout",
|
|
107
|
+
variables: planVars({
|
|
108
|
+
// The shape S3 lowering seeds — here a hermetic green probe that binds a version, standing
|
|
109
|
+
// in for the `capability` probe (whose green/bind path is unit-tested in planLowering.test).
|
|
110
|
+
readinessProbes: [
|
|
111
|
+
{
|
|
112
|
+
kind: "command",
|
|
113
|
+
target: "true",
|
|
114
|
+
resolvedArtifact: "@scope/pkg@1.4.0",
|
|
115
|
+
poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" },
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
probeTimeout: "PT30M",
|
|
119
|
+
gateKey: "preflight:owner/repo#2",
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
await app.settle();
|
|
123
|
+
|
|
124
|
+
const flows = takenFlows(app);
|
|
125
|
+
// The DEPENDENT was gated: it entered the preflight (not the root skip) and released green.
|
|
126
|
+
assert.ok(
|
|
127
|
+
flows.includes("gw-readiness->readiness-preflight"),
|
|
128
|
+
`a dependent enters the preflight (flows: ${flows.join(", ")})`,
|
|
129
|
+
);
|
|
130
|
+
assert.ok(flows.includes("pf_gw->pf_end"), "the probe went green and settled the gate");
|
|
131
|
+
// Only AFTER the gate does it reach ensure-base-branch — the head of the fan-out (wave 0 is
|
|
132
|
+
// downstream of it). The gate is a true PREFLIGHT, not a parallel afterthought.
|
|
133
|
+
assert.ok(
|
|
134
|
+
flows.includes("readiness-preflight->ensure-base-branch"),
|
|
135
|
+
"the green gate leads into the fan-out head",
|
|
136
|
+
);
|
|
137
|
+
// A green probe never escalates.
|
|
138
|
+
const tasks = await app.engine.searchUserTasks({ processInstanceKey });
|
|
139
|
+
assert.equal(
|
|
140
|
+
tasks.filter((t) => t.elementId === "readiness-escalation-pf").length,
|
|
141
|
+
0,
|
|
142
|
+
"a green preflight never opens an escalation task",
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
await app.stop();
|
|
146
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("ROOT: readinessProbes = null skips the gate and fans out immediately", async () => {
|
|
151
|
+
const { app, dbDir } = await boot();
|
|
152
|
+
try {
|
|
153
|
+
await app.engine.createInstance({
|
|
154
|
+
processDefinitionId: "plan-fanout",
|
|
155
|
+
variables: planVars({ planKey: "owner/repo#1", issue: "owner/repo#1", readinessProbes: null }),
|
|
156
|
+
});
|
|
157
|
+
await app.settle();
|
|
158
|
+
|
|
159
|
+
const flows = takenFlows(app);
|
|
160
|
+
assert.ok(
|
|
161
|
+
flows.includes("gw-readiness->ensure-base-branch"),
|
|
162
|
+
`a root skips straight to the fan-out head (flows: ${flows.join(", ")})`,
|
|
163
|
+
);
|
|
164
|
+
assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "a root never enters the preflight");
|
|
165
|
+
} finally {
|
|
166
|
+
await app.stop();
|
|
167
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
package/openapi.yaml
CHANGED
|
@@ -954,11 +954,12 @@ components:
|
|
|
954
954
|
minLength: 1
|
|
955
955
|
description: The producer epic's issue handle, used to resolve which published pkg@version first carries the capability (S3).
|
|
956
956
|
StartEpicSetResult:
|
|
957
|
-
description: The result of a successful set admission — the epics admitted
|
|
957
|
+
description: The result of a successful set admission + lowering (issue #292, slice S3) — the epics admitted, the roots started immediately, the dependents started behind their leading capability readiness-gate, and the inter-epic edges materialized into `plan_deps`.
|
|
958
958
|
type: object
|
|
959
959
|
required:
|
|
960
960
|
- epics
|
|
961
961
|
- roots
|
|
962
|
+
- dependents
|
|
962
963
|
- edges
|
|
963
964
|
properties:
|
|
964
965
|
epics:
|
|
@@ -976,12 +977,28 @@ components:
|
|
|
976
977
|
type: string
|
|
977
978
|
roots:
|
|
978
979
|
type: array
|
|
979
|
-
description: The plan keys of epics with NO inbound edge — the roots
|
|
980
|
+
description: The plan keys of epics with NO inbound edge — the roots started immediately (fan out right away).
|
|
980
981
|
items:
|
|
981
982
|
type: string
|
|
983
|
+
dependents:
|
|
984
|
+
type: array
|
|
985
|
+
description: The epics with ≥1 inbound edge — started behind a leading capability readiness-gate that holds wave 0 until every listed producer publishes its capability.
|
|
986
|
+
items:
|
|
987
|
+
type: object
|
|
988
|
+
required:
|
|
989
|
+
- planKey
|
|
990
|
+
- producers
|
|
991
|
+
properties:
|
|
992
|
+
planKey:
|
|
993
|
+
type: string
|
|
994
|
+
producers:
|
|
995
|
+
type: array
|
|
996
|
+
description: The producer plan keys whose capabilities this dependent waits for (ALL must be green before it fans out).
|
|
997
|
+
items:
|
|
998
|
+
type: string
|
|
982
999
|
edges:
|
|
983
1000
|
type: array
|
|
984
|
-
description: The inter-epic edges
|
|
1001
|
+
description: The inter-epic edges materialized into the durable `plan_deps` graph (endpoints resolved to plan keys).
|
|
985
1002
|
items:
|
|
986
1003
|
type: object
|
|
987
1004
|
required:
|
|
@@ -168,18 +168,27 @@ test("valid DAG: admits all epics and persists all edges", async () => {
|
|
|
168
168
|
assertEquals(res.body.edges, [
|
|
169
169
|
{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` },
|
|
170
170
|
]);
|
|
171
|
-
// S2 admits + STAGES (epics and edges)
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
assertEquals(
|
|
171
|
+
// S2 admits + STAGES (epics and edges); S3 (this slice) then reads that staging and LOWERS the
|
|
172
|
+
// set — starting every epic (roots immediately, dependents behind a seeded capability preflight)
|
|
173
|
+
// and materializing the durable `plan_deps` graph after the `plans` rows exist.
|
|
174
|
+
assertEquals(started.length, 2); // both epics started by the S3 lowering
|
|
175
|
+
assertEquals(planDepsRows(tables).length, 1); // durable edge materialized by S3
|
|
175
176
|
const epicRows = admittedEpicRows(tables);
|
|
176
|
-
assertEquals(epicRows.length, 2); // both epics staged (roots included)
|
|
177
|
+
assertEquals(epicRows.length, 2); // both epics staged (roots included) then materialized
|
|
177
178
|
const rows = admittedDepRows(tables);
|
|
178
179
|
assertEquals(rows.length, 1);
|
|
179
180
|
assertEquals(rows[0].plan_key, `${REPO}#2`);
|
|
180
181
|
assertEquals(rows[0].depends_on_plan_key, `${REPO}#1`);
|
|
181
182
|
assertEquals(rows[0].package, "@scope/pkg");
|
|
182
183
|
assertEquals(rows[0].capability_ref, `${REPO}#1`);
|
|
184
|
+
// The dependent (#2) is seeded with a capability probe + bounded timeout; the root (#1) starts
|
|
185
|
+
// with none so it fans out immediately.
|
|
186
|
+
const startedByKey = new Map(started.map((s) => [s.variables?.["planKey"], s.variables ?? {}]));
|
|
187
|
+
const rootVars = startedByKey.get(`${REPO}#1`);
|
|
188
|
+
const depVars = startedByKey.get(`${REPO}#2`);
|
|
189
|
+
assertEquals(rootVars?.["readinessProbes"], null);
|
|
190
|
+
assertEquals(Array.isArray(depVars?.["readinessProbes"]), true);
|
|
191
|
+
assertEquals((depVars?.["readinessProbes"] as unknown[]).length, 1);
|
|
183
192
|
});
|
|
184
193
|
});
|
|
185
194
|
|
|
@@ -485,7 +494,11 @@ function makeSqliteApp(
|
|
|
485
494
|
) {
|
|
486
495
|
const db = new DatabaseSync(":memory:");
|
|
487
496
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
488
|
-
db.exec(
|
|
497
|
+
db.exec(
|
|
498
|
+
"CREATE TABLE plans (plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, " +
|
|
499
|
+
"title TEXT, base_branch TEXT, status TEXT, task_count INTEGER, epic_phase TEXT, " +
|
|
500
|
+
"blackboard_token TEXT, list_bucket TEXT, ack_open INTEGER, created_at TEXT, updated_at TEXT);",
|
|
501
|
+
);
|
|
489
502
|
for (const p of seedPlans) {
|
|
490
503
|
db.prepare("INSERT INTO plans (plan_key, repo, base_branch, status) VALUES (?, ?, ?, ?)")
|
|
491
504
|
.run(p.plan_key, p.repo, p.base_branch, p.status);
|
|
@@ -520,7 +533,7 @@ function makeSqliteApp(
|
|
|
520
533
|
return { app, db };
|
|
521
534
|
}
|
|
522
535
|
|
|
523
|
-
test("SQLite (FK ON): admits a set with NO plans row,
|
|
536
|
+
test("SQLite (FK ON): admits a set with NO plans row, then S3 lowering starts plans FK-first and materializes plan_deps", async () => {
|
|
524
537
|
const gh = freshGithub(REPO);
|
|
525
538
|
await withGithub(gh, async () => {
|
|
526
539
|
const { app, db } = makeSqliteApp(); // no plans rows — the first-submission FK-failure condition
|
|
@@ -532,10 +545,13 @@ test("SQLite (FK ON): admits a set with NO plans row, stages FK-free, never writ
|
|
|
532
545
|
],
|
|
533
546
|
deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` }],
|
|
534
547
|
});
|
|
535
|
-
// Under the old code this was a 500 FK violation on `plan_deps.plan_key`.
|
|
548
|
+
// Under the old code this was a 500 FK violation on `plan_deps.plan_key`. S3 lowering inserts
|
|
549
|
+
// BOTH `plans` rows before recording the edge, so the durable `plan_deps` FK is satisfied.
|
|
536
550
|
assertEquals(res.status, 202);
|
|
551
|
+
const planN = db.prepare("SELECT COUNT(*) AS n FROM plans").get() as { n: number };
|
|
552
|
+
assertEquals(planN.n, 2); // both epics materialized a plans row
|
|
537
553
|
const planDepN = db.prepare("SELECT COUNT(*) AS n FROM plan_deps").get() as { n: number };
|
|
538
|
-
assertEquals(planDepN.n,
|
|
554
|
+
assertEquals(planDepN.n, 1); // durable edge materialized FK-clean by S3
|
|
539
555
|
const staged = db
|
|
540
556
|
.prepare("SELECT plan_key, depends_on_plan_key, package FROM admitted_plan_deps")
|
|
541
557
|
.all() as { plan_key: string; depends_on_plan_key: string; package: string }[];
|
|
@@ -15,18 +15,19 @@
|
|
|
15
15
|
// in-request intra-set shared-base guard (two members of the same set cannot silently grab the
|
|
16
16
|
// same custom base, which admitPlan's durable-only rule 4 would miss). The first failure maps to
|
|
17
17
|
// its 4xx (400/409) via the shared `admitPlanErrorResponse`, before anything is persisted.
|
|
18
|
-
// 4.
|
|
19
|
-
// validated edge into `admitted_plan_deps` — then
|
|
20
|
-
//
|
|
18
|
+
// 4. Once every epic admits: STAGE the admitted set — each epic into `admitted_epics` and each
|
|
19
|
+
// validated edge into `admitted_plan_deps` — then LOWER it (issue #292 slice S3): start every
|
|
20
|
+
// ROOT immediately, start every DEPENDENT behind its leading capability readiness-gate, and
|
|
21
|
+
// materialize the durable `plan_deps` edges. Returns the admitted epics, the roots, the gated
|
|
22
|
+
// dependents, and the materialized edges.
|
|
21
23
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// inactive plan; the staging records collapse a duplicate epic/edge).
|
|
24
|
+
// The FK-free staging (step 4a) survives a crash between admission and lowering: `lowerAdmittedSet`
|
|
25
|
+
// reads `admitted_epics` / `admitted_plan_deps` and creates the durable `plans` row (via `startPlan`)
|
|
26
|
+
// before recording each `plan_deps` edge, so the `plan_deps.plan_key REFERENCES plans(plan_key)` FK
|
|
27
|
+
// is satisfied by construction. Re-submitting the identical set is a no-op: `admitPlan` is idempotent
|
|
28
|
+
// on an already-created base + inactive plan, the staging records collapse a duplicate epic/edge, and
|
|
29
|
+
// lowering neither double-starts an epic nor re-seeds a gate (`startPlan` short-circuits a running
|
|
30
|
+
// plan) nor duplicates a durable edge.
|
|
30
31
|
|
|
31
32
|
import { fetchDefaultBranch } from "../app/github.ts";
|
|
32
33
|
import {
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
SharedBaseError,
|
|
41
42
|
validateEpicSet,
|
|
42
43
|
} from "../app/plan.ts";
|
|
44
|
+
import { lowerAdmittedSet } from "../app/planLowering.ts";
|
|
43
45
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
44
46
|
|
|
45
47
|
/** One parsed, admission-ready epic member: its parsed issue reference plus the per-epic admission
|
|
@@ -167,13 +169,13 @@ export default defineOperation("startEpicSet", async ({ body }, app) => {
|
|
|
167
169
|
}
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
// ── Step 4: STAGE the admitted set + validated edges (idempotent)
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// the WHOLE set admitted.
|
|
172
|
+
// ── Step 4: STAGE the admitted set + validated edges FK-free (idempotent), THEN lower it (step 5).
|
|
173
|
+
// Staging first keeps the door crash-safe: `admitted_epics` / `admitted_plan_deps` carry exactly
|
|
174
|
+
// what `lowerAdmittedSet` needs to materialize the durable `plans` + `plan_deps` graph, so a crash
|
|
175
|
+
// between staging and lowering loses nothing (a re-dispatch re-reads the staging). The staging
|
|
176
|
+
// tables are FK-free (no `plans` row need exist yet); lowering creates the `plans` row before
|
|
177
|
+
// recording each `plan_deps` edge, so the FK holds by construction. Each admitted epic (INCLUDING
|
|
178
|
+
// roots) is staged; each validated edge is staged. Only reached once the WHOLE set admitted.
|
|
177
179
|
for (const a of admitted) {
|
|
178
180
|
await recordAdmittedEpic(app.data, {
|
|
179
181
|
plan_key: a.parsed.planKey,
|
|
@@ -192,20 +194,25 @@ export default defineOperation("startEpicSet", async ({ body }, app) => {
|
|
|
192
194
|
});
|
|
193
195
|
}
|
|
194
196
|
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
// ── Step 5: LOWER the admitted set into a running schedule (issue #292, slice S3). Now that the
|
|
198
|
+
// whole set has admitted and staged, materialize it: start every ROOT immediately, start every
|
|
199
|
+
// DEPENDENT behind its leading capability readiness-gate (seeded from its inbound edges), and
|
|
200
|
+
// materialize the durable `plan_deps` edges. Idempotent — a re-submitted set neither double-starts
|
|
201
|
+
// an epic nor re-seeds a gate nor duplicates an edge (see lowerAdmittedSet).
|
|
202
|
+
const lowered = await lowerAdmittedSet(app.data, app.engine, planKeys);
|
|
198
203
|
|
|
199
|
-
app.log.info("epic set admitted", {
|
|
204
|
+
app.log.info("epic set admitted + lowered", {
|
|
200
205
|
epics: admitted.length,
|
|
201
206
|
edges: edges.length,
|
|
202
|
-
roots: roots.length,
|
|
207
|
+
roots: lowered.roots.length,
|
|
208
|
+
dependents: lowered.dependents.length,
|
|
203
209
|
});
|
|
204
210
|
return {
|
|
205
211
|
status: 202,
|
|
206
212
|
body: {
|
|
207
213
|
epics: admitted.map((a) => ({ planKey: a.parsed.planKey, baseBranch: a.baseBranch })),
|
|
208
|
-
roots,
|
|
214
|
+
roots: lowered.roots,
|
|
215
|
+
dependents: lowered.dependents,
|
|
209
216
|
edges: edges.map((e) => ({
|
|
210
217
|
consumer: e.consumer,
|
|
211
218
|
producer: e.producer,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.95.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",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@nanobpm/agentic": "^0.1.0",
|
|
56
|
-
"@nanobpm/urban": "^0.
|
|
56
|
+
"@nanobpm/urban": "^0.59.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@biomejs/biome": "^2.4.11",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
{ "field": "promotion_state", "header": "Promotion" },
|
|
65
65
|
{ "field": "wave_label", "header": "Wave" },
|
|
66
66
|
{ "field": "task_count", "header": "Tasks" },
|
|
67
|
-
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
67
|
+
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
68
68
|
],
|
|
69
69
|
"detail": {
|
|
70
70
|
"linkField": "issue_url",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
{ "field": "status", "header": "Status" },
|
|
128
128
|
{ "field": "pr_key", "header": "PR" },
|
|
129
129
|
{ "field": "summary", "header": "Summary" },
|
|
130
|
-
{ "field": "updated_at", "header": "Updated" }
|
|
130
|
+
{ "field": "updated_at", "header": "Updated", "format": "datetime" }
|
|
131
131
|
],
|
|
132
132
|
"detail": {
|
|
133
133
|
"fields": [
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
{ "field": "task_b", "header": "Task B" },
|
|
203
203
|
{ "field": "files", "header": "Shared files" },
|
|
204
204
|
{ "field": "source", "header": "Source" },
|
|
205
|
-
{ "field": "updated_at", "header": "Updated" }
|
|
205
|
+
{ "field": "updated_at", "header": "Updated", "format": "datetime" }
|
|
206
206
|
]
|
|
207
207
|
}
|
|
208
208
|
},
|
package/pages/epic.page.json
CHANGED
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
{ "field": "wave_label", "header": "Wave" },
|
|
86
86
|
{ "field": "task_count", "header": "Tasks" },
|
|
87
87
|
{ "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
|
|
88
|
-
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
88
|
+
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
89
89
|
],
|
|
90
90
|
"rowActions": [
|
|
91
91
|
{
|
package/pages/feature.page.json
CHANGED
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"notInPathField": "stage_skipped",
|
|
92
92
|
"locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
|
|
93
93
|
},
|
|
94
|
-
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
94
|
+
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
95
95
|
],
|
|
96
96
|
"rowActions": [
|
|
97
97
|
{
|
package/pages/home.page.json
CHANGED
package/pages/lineage.page.json
CHANGED
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"link": { "kind": "processExplorer", "keyField": "process_key" }
|
|
80
80
|
},
|
|
81
81
|
{ "field": "pr_count", "header": "PRs", "width": "5rem" },
|
|
82
|
-
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
82
|
+
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
83
83
|
],
|
|
84
84
|
"detail": {
|
|
85
85
|
"linkField": "issue_url",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
104
104
|
{ "field": "current_round", "header": "Round" },
|
|
105
105
|
{ "field": "outcome", "header": "Outcome", "truncate": true },
|
|
106
|
-
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
106
|
+
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
107
107
|
],
|
|
108
108
|
"detail": {
|
|
109
109
|
"linkField": "url",
|
package/pages/overview.page.json
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
70
70
|
{ "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
|
|
71
71
|
{ "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
|
|
72
|
-
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
72
|
+
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
73
73
|
]
|
|
74
74
|
}
|
|
75
75
|
},
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
96
96
|
{ "field": "delivery_label", "header": "Landing" },
|
|
97
97
|
{ "field": "wave_label", "header": "Wave" },
|
|
98
|
-
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
98
|
+
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
99
99
|
],
|
|
100
100
|
"rowActions": [
|
|
101
101
|
{
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "feature_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
|
|
132
132
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
133
133
|
{ "field": "delivery_label", "header": "Delivery" },
|
|
134
|
-
{ "field": "updated_at", "header": "Updated", "width": "9rem" }
|
|
134
|
+
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
135
135
|
],
|
|
136
136
|
"rowActions": [
|
|
137
137
|
{
|
package/pages/tasks.page.json
CHANGED
|
@@ -105,7 +105,8 @@
|
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
"field": "updated_at",
|
|
108
|
-
"header": "Updated"
|
|
108
|
+
"header": "Updated",
|
|
109
|
+
"format": "datetime"
|
|
109
110
|
}
|
|
110
111
|
],
|
|
111
112
|
"rowKey": "user_task_key",
|
|
@@ -170,7 +171,8 @@
|
|
|
170
171
|
},
|
|
171
172
|
{
|
|
172
173
|
"field": "updated_at",
|
|
173
|
-
"header": "Updated"
|
|
174
|
+
"header": "Updated",
|
|
175
|
+
"format": "datetime"
|
|
174
176
|
}
|
|
175
177
|
],
|
|
176
178
|
"rowKey": "user_task_key",
|
|
@@ -235,7 +237,8 @@
|
|
|
235
237
|
},
|
|
236
238
|
{
|
|
237
239
|
"field": "updated_at",
|
|
238
|
-
"header": "Updated"
|
|
240
|
+
"header": "Updated",
|
|
241
|
+
"format": "datetime"
|
|
239
242
|
}
|
|
240
243
|
],
|
|
241
244
|
"rowKey": "user_task_key",
|
|
@@ -301,7 +304,8 @@
|
|
|
301
304
|
},
|
|
302
305
|
{
|
|
303
306
|
"field": "updated_at",
|
|
304
|
-
"header": "Updated"
|
|
307
|
+
"header": "Updated",
|
|
308
|
+
"format": "datetime"
|
|
305
309
|
}
|
|
306
310
|
],
|
|
307
311
|
"rowKey": "user_task_key",
|
|
@@ -384,7 +388,8 @@
|
|
|
384
388
|
},
|
|
385
389
|
{
|
|
386
390
|
"field": "updated_at",
|
|
387
|
-
"header": "Updated"
|
|
391
|
+
"header": "Updated",
|
|
392
|
+
"format": "datetime"
|
|
388
393
|
}
|
|
389
394
|
],
|
|
390
395
|
"rowKey": "user_task_key",
|