@nanobpm/nano-workforce 0.79.0 → 0.81.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/contracts.ts +23 -0
- package/app/delivery.test.ts +2 -1
- package/app/delivery.ts +76 -0
- package/app/instance-tracking.test.ts +2 -1
- package/app/lineage.test.ts +300 -0
- package/app/lineage.ts +537 -0
- package/app/migration037.test.ts +62 -0
- package/app/readiness.test.ts +300 -0
- package/app/readiness.ts +493 -0
- package/app/retro.ts +1 -1
- package/app/reviewWait.test.ts +19 -0
- package/app/reviewWait.ts +20 -0
- package/app/service.test.ts +104 -1
- package/app/service.ts +33 -71
- package/db/migrations/037_lineage.sql +69 -0
- package/e2e/readiness-gate.e2e.ts +285 -0
- package/nano.app.json +4 -0
- package/openapi.yaml +120 -0
- package/operations/getLineage.test.ts +105 -0
- package/operations/getLineage.ts +32 -0
- package/package.json +1 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +146 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +4 -0
- package/resources/forms/readiness-escalation.form +29 -0
- package/resources/processes/readiness-gate.bpmn +343 -0
- package/workers/converge-feature/worker.ts +1 -1
- package/workers/readiness-probe/worker.test.ts +236 -0
- package/workers/readiness-probe/worker.ts +146 -0
- package/workers/record-wave/worker.ts +2 -2
package/app/service.test.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
|
-
import { parsePr, pollIncidentsImpl, repoEnvelopeVars, submitPr } from "./service.ts";
|
|
10
|
+
import { parsePr, pollIncidentsImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
|
|
11
11
|
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
13
13
|
return {
|
|
@@ -331,6 +331,109 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
|
|
|
331
331
|
});
|
|
332
332
|
});
|
|
333
333
|
|
|
334
|
+
// Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
|
|
335
|
+
// and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
|
|
336
|
+
// merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
|
|
337
|
+
// root), and a resubmit that omits the root must not clobber a root already learned.
|
|
338
|
+
function captureRoot() {
|
|
339
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
340
|
+
pull_requests: { rows: [], key: "pr_key" },
|
|
341
|
+
escalations: { rows: [], key: "id" },
|
|
342
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
343
|
+
};
|
|
344
|
+
const data = {
|
|
345
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
346
|
+
} as any;
|
|
347
|
+
let captured: unknown;
|
|
348
|
+
const engine = {
|
|
349
|
+
createInstance: (req: { variables?: Record<string, unknown> }) => {
|
|
350
|
+
captured = req.variables?.rootRequestKey;
|
|
351
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
352
|
+
},
|
|
353
|
+
} as any;
|
|
354
|
+
return { data, engine, stores, get: () => captured };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
test("submitPr persists root_request_key and threads it onto the convergence instance", async () => {
|
|
358
|
+
await withGithubOff(async () => {
|
|
359
|
+
const { data, engine, stores, get } = captureRoot();
|
|
360
|
+
await submitPr(
|
|
361
|
+
data,
|
|
362
|
+
engine,
|
|
363
|
+
{ repo: "owner/repo", number: 8, url: "https://github.com/owner/repo/pull/8", prKey: "owner/repo#8" },
|
|
364
|
+
[],
|
|
365
|
+
20,
|
|
366
|
+
false,
|
|
367
|
+
"owner/repo#1",
|
|
368
|
+
);
|
|
369
|
+
assertEquals(get(), "owner/repo#1");
|
|
370
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
371
|
+
assertEquals(pr.root_request_key, "owner/repo#1");
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("submitPr self-roots root_request_key on the pr_key for a human/webhook submit (its own root)", async () => {
|
|
376
|
+
await withGithubOff(async () => {
|
|
377
|
+
const { data, engine, stores, get } = captureRoot();
|
|
378
|
+
await submitPr(data, engine, {
|
|
379
|
+
repo: "owner/repo",
|
|
380
|
+
number: 9,
|
|
381
|
+
url: "https://github.com/owner/repo/pull/9",
|
|
382
|
+
prKey: "owner/repo#9",
|
|
383
|
+
});
|
|
384
|
+
assertEquals(get(), "owner/repo#9");
|
|
385
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
386
|
+
assertEquals(pr.root_request_key, "owner/repo#9");
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("submitPr resubmit does not clobber an already-learned root when omitted", async () => {
|
|
391
|
+
await withGithubOff(async () => {
|
|
392
|
+
const { data, engine, stores, get } = captureRoot();
|
|
393
|
+
(stores.pull_requests.rows as unknown[]).push({
|
|
394
|
+
pr_key: "owner/repo#8",
|
|
395
|
+
repo: "owner/repo",
|
|
396
|
+
number: 8,
|
|
397
|
+
url: "https://github.com/owner/repo/pull/8",
|
|
398
|
+
status: "abandoned", // terminal -> re-open path
|
|
399
|
+
current_round: 3,
|
|
400
|
+
root_request_key: "owner/repo#1",
|
|
401
|
+
});
|
|
402
|
+
await submitPr(data, engine, {
|
|
403
|
+
repo: "owner/repo",
|
|
404
|
+
number: 8,
|
|
405
|
+
url: "https://github.com/owner/repo/pull/8",
|
|
406
|
+
prKey: "owner/repo#8",
|
|
407
|
+
});
|
|
408
|
+
assertEquals(get(), "owner/repo#1", "resubmit re-threads the learned root");
|
|
409
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
410
|
+
assertEquals(pr.root_request_key, "owner/repo#1");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("startMerge reads root_request_key off the PR row onto the merge instance", async () => {
|
|
415
|
+
await withGithubOff(async () => {
|
|
416
|
+
const { data, engine, get } = captureRoot();
|
|
417
|
+
await submitPr(
|
|
418
|
+
data,
|
|
419
|
+
engine,
|
|
420
|
+
{ repo: "owner/repo", number: 8, url: "https://github.com/owner/repo/pull/8", prKey: "owner/repo#8" },
|
|
421
|
+
[],
|
|
422
|
+
20,
|
|
423
|
+
false,
|
|
424
|
+
"owner/repo#1",
|
|
425
|
+
);
|
|
426
|
+
await startMerge(data, engine, {
|
|
427
|
+
repo: "owner/repo",
|
|
428
|
+
number: 8,
|
|
429
|
+
url: "https://github.com/owner/repo/pull/8",
|
|
430
|
+
prKey: "owner/repo#8",
|
|
431
|
+
round: 2,
|
|
432
|
+
});
|
|
433
|
+
assertEquals(get(), "owner/repo#1");
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
334
437
|
// The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
|
|
335
438
|
// emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
|
|
336
439
|
// checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
|
package/app/service.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
|
|
|
11
11
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
12
12
|
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
13
13
|
import { agentSlaTimeout } from "./agentSla.ts";
|
|
14
|
+
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
14
15
|
import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
|
|
15
16
|
import {
|
|
16
17
|
classifyMergeability,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
type PrState,
|
|
27
28
|
requestCopilotReview,
|
|
28
29
|
} from "./github.ts";
|
|
30
|
+
import { pollLineage } from "./lineage.ts";
|
|
29
31
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
30
32
|
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
31
33
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
@@ -122,77 +124,6 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
|
|
|
122
124
|
|
|
123
125
|
const now = () => new Date().toISOString();
|
|
124
126
|
|
|
125
|
-
/** A PR is "done" in exactly these states; everything else (converging, waiting_review,
|
|
126
|
-
* escalated, and the merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight. `converged`
|
|
127
|
-
* is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
|
|
128
|
-
* transitions into the merge stage and lands as `merged`. The status endpoint and the cancel
|
|
129
|
-
* guard both key off this set. */
|
|
130
|
-
export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
|
|
131
|
-
|
|
132
|
-
/** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
|
|
133
|
-
* means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
|
|
134
|
-
* sets it as soon as one PR opened — other slices may be blocked/skipped), which conflates hand-off
|
|
135
|
-
* with landing. `delivery` reports whether those slice PRs have actually MERGED. */
|
|
136
|
-
export type Delivery = "converging" | "landed";
|
|
137
|
-
|
|
138
|
-
/** Rollup of a plan's slice-PR landing state, derived by joining `plan_tasks.pr_key` →
|
|
139
|
-
* `pull_requests.status`. Pure and read-only — the single source of truth for the denormalised
|
|
140
|
-
* `plans.delivery` / `plans.delivery_label` columns the poller projects. */
|
|
141
|
-
export interface DeliveryRollup {
|
|
142
|
-
delivery: Delivery | null;
|
|
143
|
-
label: string | null;
|
|
144
|
-
prsOpened: number;
|
|
145
|
-
prsMerged: number;
|
|
146
|
-
prsInFlight: number;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Derive the delivery signal for one plan from its status and the statuses of its slice PRs.
|
|
150
|
-
*
|
|
151
|
-
* - `converging` — the plan is `done` but ≥1 slice PR is still non-terminal (in flight).
|
|
152
|
-
* - `landed` — every slice PR merged: `prsInFlight == 0 && prsMerged == prsOpened && prsOpened > 0`.
|
|
153
|
-
* - `null` — no positive signal yet: the plan isn't `done`, it opened no PRs, or every PR is
|
|
154
|
-
* terminal but not all merged (some `abandoned`/`converged` — resolved-not-landed, per the issue).
|
|
155
|
-
*
|
|
156
|
-
* A slice's PR status is "in flight" iff it is NOT in `TERMINAL_STATUSES`; `abandoned`/`converged`
|
|
157
|
-
* count as resolved-not-landed (terminal but not merged), so they never make an epic `landed`. */
|
|
158
|
-
export function deriveDelivery(
|
|
159
|
-
planStatus: string,
|
|
160
|
-
prStatuses: readonly string[],
|
|
161
|
-
): DeliveryRollup {
|
|
162
|
-
const prsOpened = prStatuses.length;
|
|
163
|
-
let prsMerged = 0;
|
|
164
|
-
let prsInFlight = 0;
|
|
165
|
-
for (const s of prStatuses) {
|
|
166
|
-
if (s === "merged") prsMerged++;
|
|
167
|
-
else if (!TERMINAL_STATUSES.includes(s)) prsInFlight++;
|
|
168
|
-
}
|
|
169
|
-
// `delivery` is only meaningful once the fan-out has been dispatched (`status = done`) and at
|
|
170
|
-
// least one slice PR exists; otherwise there is nothing to have landed yet.
|
|
171
|
-
if (planStatus !== "done" || prsOpened === 0) {
|
|
172
|
-
return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
|
|
173
|
-
}
|
|
174
|
-
if (prsInFlight > 0) {
|
|
175
|
-
return {
|
|
176
|
-
delivery: "converging",
|
|
177
|
-
label: `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`,
|
|
178
|
-
prsOpened,
|
|
179
|
-
prsMerged,
|
|
180
|
-
prsInFlight,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if (prsMerged === prsOpened) {
|
|
184
|
-
return {
|
|
185
|
-
delivery: "landed",
|
|
186
|
-
label: `${prsOpened}/${prsOpened} slices merged`,
|
|
187
|
-
prsOpened,
|
|
188
|
-
prsMerged,
|
|
189
|
-
prsInFlight,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
// Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
|
|
193
|
-
return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
127
|
interface PullRequest {
|
|
197
128
|
pr_key: string;
|
|
198
129
|
repo: string;
|
|
@@ -233,6 +164,13 @@ interface PullRequest {
|
|
|
233
164
|
// workflow stage.
|
|
234
165
|
incident_key: string | null;
|
|
235
166
|
incident_message: string | null;
|
|
167
|
+
// Lineage projection (037_lineage.sql, issue #245): the stable ORIGIN identity (the issue =
|
|
168
|
+
// feature_key / plan_key) threaded onto this PR by `submitPr`, and passed as a `createInstance`
|
|
169
|
+
// variable onto the convergence + merge instances so every descendant carries the root. For a
|
|
170
|
+
// human-opened / webhook PR with no originating request, `submitPr` self-roots it to its own
|
|
171
|
+
// `pr_key` so the Lineage UI join resolves; a legacy NULL is tolerated the same way by the
|
|
172
|
+
// lineage read projection (`pollLineage`), which self-roots on `pr_key`.
|
|
173
|
+
root_request_key: string | null;
|
|
236
174
|
}
|
|
237
175
|
|
|
238
176
|
interface PrDependency {
|
|
@@ -413,6 +351,7 @@ export async function submitPr(
|
|
|
413
351
|
dependsOn: string[] = [],
|
|
414
352
|
maxRounds: number = MAX_ROUNDS,
|
|
415
353
|
convergeOnly = false,
|
|
354
|
+
rootRequestKey: string | null = null,
|
|
416
355
|
) {
|
|
417
356
|
const table = prs(data);
|
|
418
357
|
const existing = await table.get(parsed.prKey);
|
|
@@ -447,6 +386,15 @@ export async function submitPr(
|
|
|
447
386
|
// Cooperative abandon check (#76): reuse the PR's existing capability token across re-runs (and
|
|
448
387
|
// the later merge instance), or mint one for a first submission.
|
|
449
388
|
const abandonToken = existing?.abandon_token ?? mintAbandonToken();
|
|
389
|
+
// Lineage (issue #245): the origin identity threaded onto this PR + its convergence/merge
|
|
390
|
+
// instances. A caller (feature/epic hand-off) supplies it on the first submit; a resubmit that
|
|
391
|
+
// omits it must not clobber a root already learned, so coalesce onto the existing row's value. A
|
|
392
|
+
// human/webhook submit supplies none → the PR is its OWN root, so self-root on its `pr_key`
|
|
393
|
+
// (never NULL): the Lineage page drills into a thread's member PRs by joining
|
|
394
|
+
// `lineage_threads.root_request_key` → `pull_requests.root_request_key`, and a self-rooted
|
|
395
|
+
// thread's key IS the `pr_key`, so leaving the PR row NULL would render an empty PR list for it.
|
|
396
|
+
// Persisting `pr_key` keeps that join honest (the projection self-roots the same key either way).
|
|
397
|
+
const effectiveRoot = rootRequestKey ?? existing?.root_request_key ?? parsed.prKey;
|
|
450
398
|
if (existing) {
|
|
451
399
|
// A prior run (cancelled, converged, or otherwise superseded) may have left an OPEN
|
|
452
400
|
// escalation row. A fresh convergence run must not inherit that stale answer — the
|
|
@@ -474,6 +422,7 @@ export async function submitPr(
|
|
|
474
422
|
converged_at: null,
|
|
475
423
|
merged_at: null,
|
|
476
424
|
abandon_token: abandonToken,
|
|
425
|
+
root_request_key: effectiveRoot,
|
|
477
426
|
updated_at: ts,
|
|
478
427
|
});
|
|
479
428
|
} else {
|
|
@@ -488,6 +437,7 @@ export async function submitPr(
|
|
|
488
437
|
status: "converging",
|
|
489
438
|
current_round: 1,
|
|
490
439
|
abandon_token: abandonToken,
|
|
440
|
+
root_request_key: effectiveRoot,
|
|
491
441
|
created_at: ts,
|
|
492
442
|
updated_at: ts,
|
|
493
443
|
});
|
|
@@ -503,6 +453,10 @@ export async function submitPr(
|
|
|
503
453
|
round: 1,
|
|
504
454
|
maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
|
|
505
455
|
reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
|
|
456
|
+
// Lineage (issue #245): carry the origin identity onto the convergence instance so every
|
|
457
|
+
// descendant (and any message it correlates) is stitched back to the originating request.
|
|
458
|
+
// A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
|
|
459
|
+
rootRequestKey: effectiveRoot,
|
|
506
460
|
// Per-request review-only override: carried on the instance so `pr.finalize` can stop at
|
|
507
461
|
// `converged` for this PR without handing off to the merge-loop, independent of the global
|
|
508
462
|
// NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
|
|
@@ -539,6 +493,11 @@ export async function startMerge(
|
|
|
539
493
|
if (!existing?.abandon_token) {
|
|
540
494
|
await prs(data).update(pr.prKey, { abandon_token: abandonToken, updated_at: now() });
|
|
541
495
|
}
|
|
496
|
+
// Lineage (issue #245): the origin identity was persisted on the PR row at submit; carry it onto
|
|
497
|
+
// the merge instance too so the merge stage stays stitched to the originating request. A
|
|
498
|
+
// self-rooted PR carries its own `pr_key`; `?? null` only tolerates a legacy row predating the
|
|
499
|
+
// column.
|
|
500
|
+
const rootRequestKey = existing?.root_request_key ?? null;
|
|
542
501
|
const abUrl = abandonUrl(abandonToken);
|
|
543
502
|
// Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
|
|
544
503
|
// out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
|
|
@@ -566,6 +525,8 @@ export async function startMerge(
|
|
|
566
525
|
rebaseRound: 0,
|
|
567
526
|
rebaseMax: MAX_REBASE_ROUNDS,
|
|
568
527
|
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
528
|
+
// Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
|
|
529
|
+
rootRequestKey,
|
|
569
530
|
abandonUrl: abUrl,
|
|
570
531
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
571
532
|
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
@@ -1610,6 +1571,7 @@ export async function pollOnce(
|
|
|
1610
1571
|
await pollWaveGates(data, engine, token);
|
|
1611
1572
|
await pollDelivery(data);
|
|
1612
1573
|
await pollFeatureDelivery(data);
|
|
1574
|
+
await pollLineage(data);
|
|
1613
1575
|
await pollFeatureEscalations(data, engine);
|
|
1614
1576
|
await pollFeatureBlocked(data, engine);
|
|
1615
1577
|
await pollUserTasks(data, engine);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
-- Lineage projection (issue #245): thread user intent → progress as one arc.
|
|
2
|
+
--
|
|
3
|
+
-- The lineage already exists in the data layer (feature_runs.pr_key ↔ pull_requests.pr_key,
|
|
4
|
+
-- plan_tasks.pr_key ↔ pull_requests.pr_key, and message correlation by prKey/planKey) but is not
|
|
5
|
+
-- projected as a single narrative. This migration adds the two pieces the read model needs:
|
|
6
|
+
--
|
|
7
|
+
-- 1. `pull_requests.root_request_key` — the stable ORIGIN identity (the issue = feature_key /
|
|
8
|
+
-- plan_key) threaded onto every PR a request spawns. `submitPr` persists it and passes it as a
|
|
9
|
+
-- `createInstance` variable onto the convergence + merge instances; `startMerge` reads it back
|
|
10
|
+
-- off the row. A human-opened / webhook PR with no originating request is self-rooted by
|
|
11
|
+
-- `submitPr` (`root_request_key = pr_key`); a legacy pre-migration NULL is backfilled below to
|
|
12
|
+
-- the SAME root submitPr would persist (feature/epic origin key, else self-rooted pr_key), and
|
|
13
|
+
-- the projection tolerates any residual NULL by self-rooting on `pr_key`.
|
|
14
|
+
--
|
|
15
|
+
-- 2. `lineage_threads` — a DERIVED read table, one row per `root_request_key` (or a self-rooted
|
|
16
|
+
-- PR's own key), recomputed idempotently each poll pass by `pollLineage` (app/lineage.ts) from
|
|
17
|
+
-- the existing gateway joins. It stitches `request → implementation run → PR(s) → convergence →
|
|
18
|
+
-- merge → outcome` into one ordered thread, exposing the active frontier (`stage`/`stage_label`/
|
|
19
|
+
-- `process_key`) plus whether the whole arc has settled (`active`). Urban's datasource cannot
|
|
20
|
+
-- read a SQL VIEW (gateway.ts schema() whitelists only type='table'), so — following the
|
|
21
|
+
-- codebase convention for read-model projections (`plans.delivery`, `feature_runs.delivery_label`)
|
|
22
|
+
-- — this is a denormalised flat table the schema-driven pages read directly.
|
|
23
|
+
--
|
|
24
|
+
-- Forward-only, additive (expand): a nullable column with no default, a new table/indexes, and an
|
|
25
|
+
-- origin-aware backfill of the new column. Numbered after the current highest prefix (036). The
|
|
26
|
+
-- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
27
|
+
ALTER TABLE pull_requests ADD COLUMN root_request_key TEXT;
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_pr_root ON pull_requests(root_request_key);
|
|
29
|
+
|
|
30
|
+
-- Backfill: every pre-migration PR row has root_request_key = NULL, which breaks the Lineage page's
|
|
31
|
+
-- `lineage_threads.root_request_key → pull_requests.root_request_key` drill-down join (a NULL never
|
|
32
|
+
-- matches a thread key), rendering an empty PR list. Backfill each row with the SAME root `submitPr`
|
|
33
|
+
-- persists going forward, so the join resolves for already-tracked PRs at deploy time too:
|
|
34
|
+
-- 1. a PR spawned by a feature run roots on its origin `feature_runs.feature_key` (submitPr passes
|
|
35
|
+
-- `featureKey` — workers/converge-feature);
|
|
36
|
+
-- 2. a PR spawned by an epic slice roots on its origin `plan_tasks.plan_key` (submitPr passes
|
|
37
|
+
-- `planKey` — workers/record-wave);
|
|
38
|
+
-- 3. any remaining origin-less PR (human/webhook, or an origin row that no longer survives) is
|
|
39
|
+
-- self-rooted on its own `pr_key`, exactly as `submitPr` self-roots a human/webhook PR.
|
|
40
|
+
-- Origin-aware steps run first so a tracked PR keeps its true origin key rather than being self-rooted
|
|
41
|
+
-- (which would orphan it from its feature/epic thread in the drill-down). All idempotent.
|
|
42
|
+
UPDATE pull_requests SET root_request_key = (
|
|
43
|
+
SELECT fr.feature_key FROM feature_runs fr WHERE fr.pr_key = pull_requests.pr_key
|
|
44
|
+
)
|
|
45
|
+
WHERE root_request_key IS NULL
|
|
46
|
+
AND EXISTS (SELECT 1 FROM feature_runs fr WHERE fr.pr_key = pull_requests.pr_key);
|
|
47
|
+
UPDATE pull_requests SET root_request_key = (
|
|
48
|
+
SELECT pt.plan_key FROM plan_tasks pt WHERE pt.pr_key = pull_requests.pr_key
|
|
49
|
+
)
|
|
50
|
+
WHERE root_request_key IS NULL
|
|
51
|
+
AND EXISTS (SELECT 1 FROM plan_tasks pt WHERE pt.pr_key = pull_requests.pr_key);
|
|
52
|
+
UPDATE pull_requests SET root_request_key = pr_key WHERE root_request_key IS NULL;
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS lineage_threads (
|
|
55
|
+
root_request_key TEXT PRIMARY KEY, -- origin issue key (feature_key/plan_key), or a self-rooted pr_key
|
|
56
|
+
kind TEXT NOT NULL, -- feature | epic | pr
|
|
57
|
+
title TEXT, -- best-effort origin/PR title
|
|
58
|
+
issue_url TEXT, -- origin issue URL (NULL for self-rooted PRs)
|
|
59
|
+
stage TEXT NOT NULL, -- active-frontier machine label (implementing|converging|merged|…)
|
|
60
|
+
stage_label TEXT, -- human narrative rollup for the timeline
|
|
61
|
+
process_key TEXT, -- active-frontier process instance (for the processExplorer link)
|
|
62
|
+
pr_keys TEXT, -- JSON array of the member PR keys (fan-out for epics)
|
|
63
|
+
pr_count INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
active INTEGER NOT NULL DEFAULT 1, -- 1 while the arc has an active frontier, 0 once settled
|
|
65
|
+
created_at TEXT NOT NULL,
|
|
66
|
+
updated_at TEXT NOT NULL
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_lineage_active ON lineage_threads(active);
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// End-to-end proof for the durable artifact-readiness wait-gate (ADR 0001 §2, issue #258).
|
|
2
|
+
//
|
|
3
|
+
// Boots this whole Urban app in-process against the WASM engine + virtual clock via `bootTestApp`
|
|
4
|
+
// and drives the real `readiness-gate` process — the reusable primitive: a parallel fork arms the
|
|
5
|
+
// `pr.readiness-probe` service task (an app-hosted worker) alongside an event-based gateway that
|
|
6
|
+
// races the `readiness-ready` message the probe publishes against a bounded timer catch.
|
|
7
|
+
//
|
|
8
|
+
// Two load-bearing behaviours are proven end to end:
|
|
9
|
+
// • READY — a `command` probe that is green immediately (`true`) drives the probe worker to
|
|
10
|
+
// publish `readiness-ready`, the gateway correlates it, and the gate releases through
|
|
11
|
+
// `wait-ready → gate-ready`.
|
|
12
|
+
// • BOUNDED (the red-first "the wait cannot hang" gate) — a `command` probe that is never green
|
|
13
|
+
// (`false`) exhausts the worker's local budget WITHOUT publishing; the wait does not hang, and
|
|
14
|
+
// when the engine timer (the authoritative bound) fires it escalates onto the native
|
|
15
|
+
// `readiness-escalation` userTask. A gate modelled without the timer arm could never satisfy
|
|
16
|
+
// this — the token would sit on `wait-ready` forever.
|
|
17
|
+
//
|
|
18
|
+
// The probes are deterministic shell builtins (`true`/`false`) so the flow is hermetic — no
|
|
19
|
+
// network, no GitHub. GitHub transport is still forced offline to match the sibling e2es.
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
|
+
import { after, before, describe, test } from "node:test";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
27
|
+
|
|
28
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
|
|
30
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
31
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
32
|
+
GITHUB_TOKEN: "",
|
|
33
|
+
};
|
|
34
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
35
|
+
|
|
36
|
+
interface TakenFlow {
|
|
37
|
+
from: string;
|
|
38
|
+
to: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function takenFlows(app: TestApp): string[] {
|
|
42
|
+
const snapshot = app.snapshot();
|
|
43
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
44
|
+
return flows
|
|
45
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
46
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Each scenario boots its own app so `takenSequenceFlows` (engine-global + cumulative) reflects
|
|
50
|
+
// exactly one instance's history.
|
|
51
|
+
async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
52
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-readiness-"));
|
|
53
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
|
|
54
|
+
return { app, dbDir };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", () => {
|
|
58
|
+
before(() => {
|
|
59
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
60
|
+
savedEnv.set(k, process.env[k]);
|
|
61
|
+
process.env[k] = v;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
after(() => {
|
|
66
|
+
for (const [k, v] of savedEnv) {
|
|
67
|
+
if (v === undefined) delete process.env[k];
|
|
68
|
+
else process.env[k] = v;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("READY: a green probe publishes readiness-ready and the gate releases through wait-ready → gate-ready", async () => {
|
|
73
|
+
const { app, dbDir } = await boot();
|
|
74
|
+
try {
|
|
75
|
+
await app.engine.createInstance({
|
|
76
|
+
processDefinitionId: "readiness-gate",
|
|
77
|
+
variables: {
|
|
78
|
+
gateKey: "gate-ready-1",
|
|
79
|
+
probe: { kind: "command", target: "true", poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" } },
|
|
80
|
+
// A long engine timer that must NOT fire — readiness wins the race first.
|
|
81
|
+
probeTimeout: "PT30M",
|
|
82
|
+
onTimeout: "escalate",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
await app.settle();
|
|
86
|
+
|
|
87
|
+
const flows = takenFlows(app);
|
|
88
|
+
assert.ok(
|
|
89
|
+
flows.includes("wait-ready->gate-ready"),
|
|
90
|
+
`the gate released on the readiness signal (flows: ${flows.join(", ")})`,
|
|
91
|
+
);
|
|
92
|
+
assert.ok(
|
|
93
|
+
flows.includes("probe->probe-done"),
|
|
94
|
+
"the probe branch settled after publishing the readiness signal",
|
|
95
|
+
);
|
|
96
|
+
// The gate never timed out — no escalation userTask exists.
|
|
97
|
+
const tasks = await app.engine.searchUserTasks({});
|
|
98
|
+
assert.equal(
|
|
99
|
+
tasks.filter((t) => t.elementId === "readiness-escalation").length,
|
|
100
|
+
0,
|
|
101
|
+
"a probe that went green never escalates",
|
|
102
|
+
);
|
|
103
|
+
} finally {
|
|
104
|
+
await app.stop();
|
|
105
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("BOUNDED: a never-green probe cannot hang — the engine timer fires and escalates onto the userTask", async () => {
|
|
110
|
+
const { app, dbDir } = await boot();
|
|
111
|
+
try {
|
|
112
|
+
const { processInstanceKey } = await app.engine.createInstance({
|
|
113
|
+
processDefinitionId: "readiness-gate",
|
|
114
|
+
variables: {
|
|
115
|
+
gateKey: "gate-timeout-1",
|
|
116
|
+
// `false` is never ready; a tiny local budget makes the worker exhaust fast (real time),
|
|
117
|
+
// leaving the ENGINE timer as the authoritative bound.
|
|
118
|
+
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
119
|
+
probeTimeout: "PT1M",
|
|
120
|
+
onTimeout: "escalate",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
await app.settle();
|
|
124
|
+
|
|
125
|
+
// The wait has NOT hung and has NOT yet escalated: the probe branch settled not-ready, and the
|
|
126
|
+
// gate is parked on the timer catch — no escalation userTask before the timer's duration.
|
|
127
|
+
const beforeTimer = await app.engine.searchUserTasks({ processInstanceKey });
|
|
128
|
+
assert.equal(
|
|
129
|
+
beforeTimer.filter((t) => t.elementId === "readiness-escalation").length,
|
|
130
|
+
0,
|
|
131
|
+
"the gate is still bounded-waiting on the timer, not prematurely escalated",
|
|
132
|
+
);
|
|
133
|
+
const beforeFlows = takenFlows(app);
|
|
134
|
+
assert.ok(!beforeFlows.includes("wait-ready->gate-ready"), "a never-green probe never releases as ready");
|
|
135
|
+
|
|
136
|
+
// Advancing past the engine timer is the ONLY thing that ends the wait — proving the bound is
|
|
137
|
+
// engine-owned. The token races off the timer catch onto the escalation userTask.
|
|
138
|
+
await app.advanceTime(61_000);
|
|
139
|
+
|
|
140
|
+
const afterFlows = takenFlows(app);
|
|
141
|
+
assert.ok(
|
|
142
|
+
afterFlows.includes("wait-timeout->gw-onTimeout") && afterFlows.includes("gw-onTimeout->readiness-escalation"),
|
|
143
|
+
`the timer bounded the wait and routed to escalation (flows: ${afterFlows.join(", ")})`,
|
|
144
|
+
);
|
|
145
|
+
const escalations = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
|
|
146
|
+
(t) => t.elementId === "readiness-escalation",
|
|
147
|
+
);
|
|
148
|
+
assert.equal(escalations.length, 1, "the bounded timeout opened exactly one escalation userTask");
|
|
149
|
+
|
|
150
|
+
// Completing the escalation with `acknowledge` releases the gate to its `escalated` terminal
|
|
151
|
+
// via the resolution gateway (default arm) — the primitive is fully durable.
|
|
152
|
+
await app.engine.completeUserTask(escalations[0].userTaskKey, { resolution: "acknowledge" });
|
|
153
|
+
await app.settle();
|
|
154
|
+
const ackFlows = takenFlows(app);
|
|
155
|
+
assert.ok(
|
|
156
|
+
ackFlows.includes("readiness-escalation->gw-resolution") && ackFlows.includes("gw-resolution->gate-escalated"),
|
|
157
|
+
"acknowledging the escalation routes through the resolution gateway to gate-escalated",
|
|
158
|
+
);
|
|
159
|
+
} finally {
|
|
160
|
+
await app.stop();
|
|
161
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("ABANDON: an operator who abandons the escalation drives the gate to its `failed` terminal, not `escalated`", async () => {
|
|
166
|
+
const { app, dbDir } = await boot();
|
|
167
|
+
try {
|
|
168
|
+
const { processInstanceKey } = await app.engine.createInstance({
|
|
169
|
+
processDefinitionId: "readiness-gate",
|
|
170
|
+
variables: {
|
|
171
|
+
gateKey: "gate-abandon-1",
|
|
172
|
+
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
173
|
+
probeTimeout: "PT1M",
|
|
174
|
+
onTimeout: "escalate",
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
await app.settle();
|
|
178
|
+
await app.advanceTime(61_000);
|
|
179
|
+
|
|
180
|
+
const escalations = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
|
|
181
|
+
(t) => t.elementId === "readiness-escalation",
|
|
182
|
+
);
|
|
183
|
+
assert.equal(escalations.length, 1, "the bounded timeout opened exactly one escalation userTask");
|
|
184
|
+
|
|
185
|
+
// `abandon` ("give up on this gate") must NOT be a no-op: it routes to gate-failed, not gate-escalated.
|
|
186
|
+
await app.engine.completeUserTask(escalations[0].userTaskKey, { resolution: "abandon" });
|
|
187
|
+
await app.settle();
|
|
188
|
+
const flows = takenFlows(app);
|
|
189
|
+
assert.ok(
|
|
190
|
+
flows.includes("gw-resolution->gate-failed"),
|
|
191
|
+
`abandoning the escalation routes to gate-failed (flows: ${flows.join(", ")})`,
|
|
192
|
+
);
|
|
193
|
+
assert.ok(
|
|
194
|
+
!flows.includes("gw-resolution->gate-escalated"),
|
|
195
|
+
"abandon must not reach the escalated terminal",
|
|
196
|
+
);
|
|
197
|
+
} finally {
|
|
198
|
+
await app.stop();
|
|
199
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("onTimeout=continue: a bounded timeout proceeds (no escalation) when the caller declares continue", async () => {
|
|
204
|
+
const { app, dbDir } = await boot();
|
|
205
|
+
try {
|
|
206
|
+
await app.engine.createInstance({
|
|
207
|
+
processDefinitionId: "readiness-gate",
|
|
208
|
+
variables: {
|
|
209
|
+
gateKey: "gate-continue-1",
|
|
210
|
+
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
211
|
+
probeTimeout: "PT1M",
|
|
212
|
+
onTimeout: "continue",
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
await app.settle();
|
|
216
|
+
await app.advanceTime(61_000);
|
|
217
|
+
|
|
218
|
+
const flows = takenFlows(app);
|
|
219
|
+
assert.ok(
|
|
220
|
+
flows.includes("gw-onTimeout->gate-continued"),
|
|
221
|
+
`a continue-on-timeout gate proceeds past the bounded wait (flows: ${flows.join(", ")})`,
|
|
222
|
+
);
|
|
223
|
+
} finally {
|
|
224
|
+
await app.stop();
|
|
225
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("DEFAULT SAFETY: an omitted onTimeout escalates (the gateway default is the safety path, not continue)", async () => {
|
|
230
|
+
const { app, dbDir } = await boot();
|
|
231
|
+
try {
|
|
232
|
+
await app.engine.createInstance({
|
|
233
|
+
processDefinitionId: "readiness-gate",
|
|
234
|
+
variables: {
|
|
235
|
+
gateKey: "gate-default-1",
|
|
236
|
+
// Neither probe.onTimeout nor a top-level onTimeout is declared.
|
|
237
|
+
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
238
|
+
probeTimeout: "PT1M",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
await app.settle();
|
|
242
|
+
await app.advanceTime(61_000);
|
|
243
|
+
|
|
244
|
+
const flows = takenFlows(app);
|
|
245
|
+
assert.ok(
|
|
246
|
+
flows.includes("gw-onTimeout->readiness-escalation"),
|
|
247
|
+
`an omitted onTimeout must default to escalation, never silently continue (flows: ${flows.join(", ")})`,
|
|
248
|
+
);
|
|
249
|
+
assert.ok(
|
|
250
|
+
!flows.includes("gw-onTimeout->gate-continued"),
|
|
251
|
+
"the safety default must not route to continue",
|
|
252
|
+
);
|
|
253
|
+
} finally {
|
|
254
|
+
await app.stop();
|
|
255
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("probe.onTimeout wins: the descriptor field is preferred over a top-level onTimeout (one source of truth)", async () => {
|
|
260
|
+
const { app, dbDir } = await boot();
|
|
261
|
+
try {
|
|
262
|
+
await app.engine.createInstance({
|
|
263
|
+
processDefinitionId: "readiness-gate",
|
|
264
|
+
variables: {
|
|
265
|
+
gateKey: "gate-probe-pref-1",
|
|
266
|
+
// The descriptor asks to continue; a stale top-level onTimeout says escalate. probe wins.
|
|
267
|
+
probe: { kind: "command", target: "false", onTimeout: "continue", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
268
|
+
probeTimeout: "PT1M",
|
|
269
|
+
onTimeout: "escalate",
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
await app.settle();
|
|
273
|
+
await app.advanceTime(61_000);
|
|
274
|
+
|
|
275
|
+
const flows = takenFlows(app);
|
|
276
|
+
assert.ok(
|
|
277
|
+
flows.includes("gw-onTimeout->gate-continued"),
|
|
278
|
+
`probe.onTimeout ("continue") must win over the top-level onTimeout ("escalate") (flows: ${flows.join(", ")})`,
|
|
279
|
+
);
|
|
280
|
+
} finally {
|
|
281
|
+
await app.stop();
|
|
282
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
package/nano.app.json
CHANGED