@nanobpm/nano-workforce 0.80.0 → 0.82.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/SPEC.md +11 -0
- package/app/contracts.ts +23 -0
- package/app/epicPhase.test.ts +62 -0
- package/app/epicPhase.ts +125 -0
- package/app/plan.ts +12 -0
- package/app/readiness.test.ts +300 -0
- package/app/readiness.ts +493 -0
- package/app/reviewWait.test.ts +19 -0
- package/app/reviewWait.ts +20 -0
- package/db/migrations/038_plan_epic_phase.sql +12 -0
- package/e2e/readiness-gate.e2e.ts +285 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/resources/forms/readiness-escalation.form +29 -0
- package/resources/processes/readiness-gate.bpmn +343 -0
- package/workers/readiness-probe/worker.test.ts +236 -0
- package/workers/readiness-probe/worker.ts +146 -0
- package/workers/record-plan/worker.ts +6 -0
- package/workers/record-results/worker.ts +8 -0
- package/workers/record-wave/worker.test.ts +5 -0
- package/workers/record-wave/worker.ts +12 -0
- package/workers/select-wave/worker.test.ts +4 -1
- package/workers/select-wave/worker.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.82.0](https://github.com/nanobpm/nano-workforce/compare/v0.81.0...v0.82.0) (2026-08-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* reify epic domain lifecycle as derived plans.epic_phase ([#261](https://github.com/nanobpm/nano-workforce/issues/261)) ([#265](https://github.com/nanobpm/nano-workforce/issues/265)) ([4cc9dee](https://github.com/nanobpm/nano-workforce/commit/4cc9deee86b800206d264d96269e6a98e8753883)), closes [#266](https://github.com/nanobpm/nano-workforce/issues/266) [nwf#245](https://github.com/nwf/issues/245) [nano-ide#254](https://github.com/nano-ide/issues/254)
|
|
7
|
+
|
|
8
|
+
# [0.81.0](https://github.com/nanobpm/nano-workforce/compare/v0.80.0...v0.81.0) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* durable artifact-readiness wait-gate primitive (ADR 0001 §2) ([#260](https://github.com/nanobpm/nano-workforce/issues/260)) ([e787488](https://github.com/nanobpm/nano-workforce/commit/e787488c074b9e4e44946a4144b814b34caffe57)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258) [#259](https://github.com/nanobpm/nano-workforce/issues/259) [#258](https://github.com/nanobpm/nano-workforce/issues/258)
|
|
14
|
+
|
|
1
15
|
# [0.80.0](https://github.com/nanobpm/nano-workforce/compare/v0.79.0...v0.80.0) (2026-08-17)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -530,6 +530,17 @@ History: done/failed/abandoned) with a `plan_tasks` child grid showing each task
|
|
|
530
530
|
status and the PR it produced (`pr_key` cross-references the Pull requests grid for
|
|
531
531
|
convergence status).
|
|
532
532
|
|
|
533
|
+
**Epic domain phase** (issue #261): `plans.status` only distinguishes the process-instance
|
|
534
|
+
terminal (`dispatched` = "fan-out job done"), not the epic's *domain* lifecycle. The read model
|
|
535
|
+
therefore also carries a derived, display-only `plans.epic_phase` — **Planning → Reviewing →
|
|
536
|
+
Implementing (wave n/t) → Trial merging → Finalizing → Dispatched** — projected at write time from
|
|
537
|
+
`plan-fanout.bpmn`'s named activities via each spine worker's BPMN element id (`app/epicPhase.ts`,
|
|
538
|
+
the single binding; nwf is the first consumer of the urban phase-projection primitive, nano-ide#266).
|
|
539
|
+
The `Implementing` band is wave-labelled from the levelize records (`plan_tasks` waves). The epic /
|
|
540
|
+
epic-detail pages surface it as a **Phase** column. It never gates control flow (that stays driven by
|
|
541
|
+
the process `currentWave`/`waveCount`/`gate_wave`); a post-dispatch cross-instance rollup into
|
|
542
|
+
Converging/Merging is a later seam (nwf#245 / nano-ide#254).
|
|
543
|
+
|
|
533
544
|
### 13.1 Dependency waves + merge barrier (issues #20, #26, release-notes-concierge)
|
|
534
545
|
|
|
535
546
|
The flat `implement → record-results` shape above evolved into a **wave loop**. The
|
package/app/contracts.ts
CHANGED
|
@@ -208,6 +208,22 @@ export const ENV_CONTRACTS = {
|
|
|
208
208
|
"SLA timeout for an agent (service) task before its boundary timer fires and the PR escalates for human attention (ISO-8601 duration). A malformed value falls back to the default.",
|
|
209
209
|
default: "PT2H",
|
|
210
210
|
},
|
|
211
|
+
NANO_READINESS_POLL_TIMEOUT: {
|
|
212
|
+
category: "env",
|
|
213
|
+
name: "NANO_READINESS_POLL_TIMEOUT",
|
|
214
|
+
owner: "app/readiness.ts",
|
|
215
|
+
semantics:
|
|
216
|
+
"Default bounded timeout (FEEL/ISO-8601 duration) for a ReadinessProbe wait-gate when the probe descriptor declares no poll.timeoutMs. The gate's event-based-gateway timer arm fires after it and escalates, so a probe that never goes green can never wedge a plan. A malformed value falls back to the default.",
|
|
217
|
+
default: "PT30M",
|
|
218
|
+
},
|
|
219
|
+
NANO_READINESS_POLL_EVERY_MS: {
|
|
220
|
+
category: "env",
|
|
221
|
+
name: "NANO_READINESS_POLL_EVERY_MS",
|
|
222
|
+
owner: "workers/readiness-probe/worker.ts",
|
|
223
|
+
semantics:
|
|
224
|
+
"Default interval in milliseconds between ReadinessProbe attempts when the probe descriptor declares no poll.everyMs.",
|
|
225
|
+
default: "15000",
|
|
226
|
+
},
|
|
211
227
|
NANO_APP_DB_URL: {
|
|
212
228
|
category: "env",
|
|
213
229
|
name: "NANO_APP_DB_URL",
|
|
@@ -259,6 +275,13 @@ export const ENV_CONTRACTS = {
|
|
|
259
275
|
/** The set of declared config-key names — the single typed vocabulary of env keys. */
|
|
260
276
|
export type EnvKey = keyof typeof ENV_CONTRACTS;
|
|
261
277
|
|
|
278
|
+
/** Whether `name` is a declared {@link EnvKey}. A runtime-narrowing guard so a value carried in as
|
|
279
|
+
* a plain string (e.g. a probe descriptor's `credentialEnv`) can be validated against the ONE
|
|
280
|
+
* schema before it is read through {@link readEnv} — an undeclared key is rejected, never read. */
|
|
281
|
+
export function isEnvKey(name: string): name is EnvKey {
|
|
282
|
+
return Object.hasOwn(ENV_CONTRACTS, name);
|
|
283
|
+
}
|
|
284
|
+
|
|
262
285
|
/** Every declared env contract, widened to {@link EnvContract} (assignment-widening — no `as`), so
|
|
263
286
|
* callers can read the optional `default`/`rejectedSynonyms`/`secret` fields on any entry. */
|
|
264
287
|
export function envContracts(): EnvContract[] {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Read-model derivation test for the epic domain phase (issue #261). `deriveEpicPhase` /
|
|
2
|
+
// `implementingPhase` are the single source of truth for the write-time projection each spine
|
|
3
|
+
// worker stamps onto `plans.epic_phase`. The projection binds structurally to plan-fanout.bpmn's
|
|
4
|
+
// named activities via the job's BPMN element id (mirroring the urban #266 phase primitive), so the
|
|
5
|
+
// epic view can show WHICH phase an epic is in — not only the process-instance terminal status.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import { deriveEpicPhase, EPIC_PHASE, implementingPhase } from "./epicPhase.ts";
|
|
9
|
+
|
|
10
|
+
test("deriveEpicPhase maps each spine element to its domain phase", () => {
|
|
11
|
+
// Planning genesis + hand-off into Reviewing when the plan is recorded.
|
|
12
|
+
assertEquals(deriveEpicPhase("plan"), EPIC_PHASE.PLANNING);
|
|
13
|
+
assertEquals(deriveEpicPhase("ensure-base-branch"), EPIC_PHASE.PLANNING);
|
|
14
|
+
assertEquals(deriveEpicPhase("record-plan"), EPIC_PHASE.REVIEWING);
|
|
15
|
+
assertEquals(deriveEpicPhase("review-plan"), EPIC_PHASE.REVIEWING);
|
|
16
|
+
assertEquals(deriveEpicPhase("record-plan-review"), EPIC_PHASE.REVIEWING);
|
|
17
|
+
assertEquals(deriveEpicPhase("plan-review-decision"), EPIC_PHASE.REVIEWING);
|
|
18
|
+
// Trial-merge band.
|
|
19
|
+
assertEquals(deriveEpicPhase("trial-merge"), EPIC_PHASE.TRIAL_MERGING);
|
|
20
|
+
assertEquals(deriveEpicPhase("record-trial-merge"), EPIC_PHASE.TRIAL_MERGING);
|
|
21
|
+
assertEquals(deriveEpicPhase("trial-merge-decision"), EPIC_PHASE.TRIAL_MERGING);
|
|
22
|
+
assertEquals(deriveEpicPhase("resolve-trial-attention"), EPIC_PHASE.TRIAL_MERGING);
|
|
23
|
+
// Finalize step's lasting result is the "Fleet dispatched" terminal.
|
|
24
|
+
assertEquals(deriveEpicPhase("record-results"), EPIC_PHASE.DISPATCHED);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("deriveEpicPhase wave-labels the Implementing band from the levelize records", () => {
|
|
28
|
+
// select-wave / record-wave / the implement MI + wait-wave-merged all read as Implementing,
|
|
29
|
+
// labelled with the 1-based wave from the wave/levelize records (0-based `current`).
|
|
30
|
+
assertEquals(
|
|
31
|
+
deriveEpicPhase("select-wave", { current: 0, total: 3 }),
|
|
32
|
+
"Implementing (wave 1/3)",
|
|
33
|
+
);
|
|
34
|
+
assertEquals(
|
|
35
|
+
deriveEpicPhase("record-wave", { current: 2, total: 3 }),
|
|
36
|
+
"Implementing (wave 3/3)",
|
|
37
|
+
);
|
|
38
|
+
assertEquals(
|
|
39
|
+
deriveEpicPhase("wait-wave-merged", { current: 1, total: 3 }),
|
|
40
|
+
"Implementing (wave 2/3)",
|
|
41
|
+
);
|
|
42
|
+
assertEquals(deriveEpicPhase("implement-task", { current: 0, total: 1 }), "Implementing (wave 1/1)");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("deriveEpicPhase returns null for a non-spine element so a stray write never clobbers", () => {
|
|
46
|
+
assertEquals(deriveEpicPhase(undefined), null);
|
|
47
|
+
assertEquals(deriveEpicPhase(null), null);
|
|
48
|
+
assertEquals(deriveEpicPhase(""), null);
|
|
49
|
+
assertEquals(deriveEpicPhase("some-unrelated-element"), null);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("implementingPhase clamps the 1-based label to the total and degrades gracefully", () => {
|
|
53
|
+
assertEquals(implementingPhase(0, 2), "Implementing (wave 1/2)");
|
|
54
|
+
// A `current` at/over the last index (record-wave pins current_wave to waveCount-1 on the final
|
|
55
|
+
// wave) never reads past n/n.
|
|
56
|
+
assertEquals(implementingPhase(5, 3), "Implementing (wave 3/3)");
|
|
57
|
+
// Unusable wave numbers (taskless plan / NaN counter) degrade to a bare Implementing — never
|
|
58
|
+
// "wave NaN/…".
|
|
59
|
+
assertEquals(implementingPhase(0, 0), "Implementing");
|
|
60
|
+
assertEquals(implementingPhase(undefined, undefined), "Implementing");
|
|
61
|
+
assertEquals(implementingPhase("x", "y"), "Implementing");
|
|
62
|
+
});
|
package/app/epicPhase.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// app/epicPhase.ts — reify the epic's own domain lifecycle as a derived `epic_phase` (issue #261).
|
|
2
|
+
//
|
|
3
|
+
// `plans.status` only distinguishes `planning` / `dispatched` / `done` / `failed` / `abandoned` —
|
|
4
|
+
// and `dispatched` is the `plan-fanout.bpmn` PROCESS-INSTANCE terminal ("fan-out job done"), not the
|
|
5
|
+
// epic's domain phase. `plan-fanout.bpmn` already models the rich lifecycle as named activities
|
|
6
|
+
// (Ensure base branch → Plan → Review plan → Select wave → Implement task → Trial merge → Finalize
|
|
7
|
+
// → "Fleet dispatched"); this module reifies that lifecycle as a stored, display-only projection so
|
|
8
|
+
// the epic view can show which phase the epic is in.
|
|
9
|
+
//
|
|
10
|
+
// Convention over declaration: the phases ARE the activities plan-fanout.bpmn already names. Each
|
|
11
|
+
// spine worker derives its projection from its OWN BPMN element id (`job.elementId`) — no annotation
|
|
12
|
+
// map on the model, no second reconciliation pass — mirroring the urban structural phase-projection
|
|
13
|
+
// primitive (nano-ide#266), which derives the phase from the furthest element reached in
|
|
14
|
+
// write-provenance. This module is the single binding (nwf is #266's first consumer).
|
|
15
|
+
//
|
|
16
|
+
// Write-time projection: because the phase only advances when a worker writes, each spine worker
|
|
17
|
+
// stamps the phase the epic is ENTERING as a result of its write — the write points ARE the phase
|
|
18
|
+
// boundaries. Two structural defaults are coarsened where the raw activity label would mislead
|
|
19
|
+
// (documented on `ELEMENT_PHASE` below): `select-wave` reads as `Implementing (wave n/t)` because it
|
|
20
|
+
// dispatches and durably marks the (write-silent) `implement` multi-instance subProcess, and
|
|
21
|
+
// `record-results` reads as the `Dispatched` terminal ("Fleet dispatched").
|
|
22
|
+
//
|
|
23
|
+
// Cross-instance rollup (later): post-dispatch, the epic's effective phase extends into the
|
|
24
|
+
// convergence/merge loops carried on separate top-level instances correlated by lineage
|
|
25
|
+
// (`rootRequestKey`, nwf#245 / nano-ide#254). Once #266's Tier-2 rollup lands, `epic_phase` can
|
|
26
|
+
// advance past `Dispatched` into Converging/Merging with no new wiring here — the seam is this
|
|
27
|
+
// module's derivation staying the single source.
|
|
28
|
+
|
|
29
|
+
/** The epic's domain phases — the vocabulary the derivation projects onto `plans.epic_phase`.
|
|
30
|
+
* Shared with the feature-view stage vocabulary (nwf#254), which uses the same stored-projection
|
|
31
|
+
* pattern. `Implementing` is wave-labelled at derivation time (see {@link implementingPhase}). */
|
|
32
|
+
export const EPIC_PHASE = {
|
|
33
|
+
PLANNING: "Planning",
|
|
34
|
+
REVIEWING: "Reviewing",
|
|
35
|
+
IMPLEMENTING: "Implementing",
|
|
36
|
+
TRIAL_MERGING: "Trial merging",
|
|
37
|
+
FINALIZING: "Finalizing",
|
|
38
|
+
DISPATCHED: "Dispatched",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** Coerce a wave index/count to a non-negative integer, or null when it isn't one. Mirrors the
|
|
42
|
+
* `toWave` coercion the wave workers already apply, so a NaN/absent counter degrades to an
|
|
43
|
+
* unlabelled `Implementing` rather than emitting `wave NaN/…`. */
|
|
44
|
+
const toWave = (v: unknown): number | null => {
|
|
45
|
+
const n = Math.trunc(Number(v));
|
|
46
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `Implementing (wave n/t)` — special-cased from the wave/levelize records (`plan_tasks` waves),
|
|
51
|
+
* NOT the raw multi-instance counter. `current` is the 0-based wave index carried on the process
|
|
52
|
+
* (`currentWave` / the projected `current_wave`); the label is 1-based and clamped to `total` so a
|
|
53
|
+
* final wave reads `n/n`. Falls back to a bare `Implementing` when the wave numbers aren't usable
|
|
54
|
+
* (e.g. a taskless plan with `total` 0), so the phase never renders `wave NaN`.
|
|
55
|
+
*/
|
|
56
|
+
export function implementingPhase(current: unknown, total: unknown): string {
|
|
57
|
+
const t = toWave(total);
|
|
58
|
+
const c = toWave(current);
|
|
59
|
+
if (t !== null && t > 0 && c !== null) {
|
|
60
|
+
const n = Math.min(c + 1, t);
|
|
61
|
+
return `${EPIC_PHASE.IMPLEMENTING} (wave ${n}/${t})`;
|
|
62
|
+
}
|
|
63
|
+
return EPIC_PHASE.IMPLEMENTING;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Structural binding: `plan-fanout.bpmn` element id → the domain phase the epic is IN while that
|
|
68
|
+
* element (or the write-silent agent step it hands off to) runs. Complete over the epic's spine, so
|
|
69
|
+
* the projection is derivable from provenance alone (the urban #266 semantics). Two entries are
|
|
70
|
+
* deliberately COARSENED from their raw activity label because the structural default misleads:
|
|
71
|
+
* • `record-plan` ("Record plan & levelize") → Reviewing: recording the plan hands the epic to the
|
|
72
|
+
* `review-plan` agent, so the review phase should already read while that (write-silent) agent
|
|
73
|
+
* runs. `record-plan-review` re-affirms Reviewing on each round/escalation.
|
|
74
|
+
* • `select-wave` ("Select wave") → Implementing: it dispatches the wave and is the last host write
|
|
75
|
+
* before the write-silent `implement` MI, so it durably marks the implementation phase for the
|
|
76
|
+
* wave it launches (wave-labelled via {@link implementingPhase} at the call site).
|
|
77
|
+
* • `record-results` ("Finalize plan") → Dispatched: the finalize step's lasting result is the
|
|
78
|
+
* "Fleet dispatched" terminal end event.
|
|
79
|
+
* `record-wave`'s next phase is data-dependent (trial-merge vs. next wave vs. finalize), so it is
|
|
80
|
+
* resolved at its call site rather than from the element id alone; its structural fallback here is
|
|
81
|
+
* the wave it just landed.
|
|
82
|
+
*/
|
|
83
|
+
const ELEMENT_PHASE: Readonly<Record<string, string>> = {
|
|
84
|
+
"ensure-base-branch": EPIC_PHASE.PLANNING,
|
|
85
|
+
"plan": EPIC_PHASE.PLANNING,
|
|
86
|
+
"record-plan": EPIC_PHASE.REVIEWING,
|
|
87
|
+
"review-plan": EPIC_PHASE.REVIEWING,
|
|
88
|
+
"record-plan-review": EPIC_PHASE.REVIEWING,
|
|
89
|
+
"plan-review-decision": EPIC_PHASE.REVIEWING,
|
|
90
|
+
"select-wave": EPIC_PHASE.IMPLEMENTING,
|
|
91
|
+
"implement": EPIC_PHASE.IMPLEMENTING,
|
|
92
|
+
"implement-task": EPIC_PHASE.IMPLEMENTING,
|
|
93
|
+
"feature-escalation": EPIC_PHASE.IMPLEMENTING,
|
|
94
|
+
"record-wave": EPIC_PHASE.IMPLEMENTING,
|
|
95
|
+
"wait-wave-merged": EPIC_PHASE.IMPLEMENTING,
|
|
96
|
+
"trial-merge": EPIC_PHASE.TRIAL_MERGING,
|
|
97
|
+
"record-trial-merge": EPIC_PHASE.TRIAL_MERGING,
|
|
98
|
+
"trial-merge-decision": EPIC_PHASE.TRIAL_MERGING,
|
|
99
|
+
"resolve-trial-attention": EPIC_PHASE.TRIAL_MERGING,
|
|
100
|
+
"record-results": EPIC_PHASE.DISPATCHED,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Optional wave context for a wave-bearing phase, sourced from the wave/levelize records. */
|
|
104
|
+
export interface WaveContext {
|
|
105
|
+
current?: unknown;
|
|
106
|
+
total?: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Derive the epic phase for a spine element from its BPMN element id, or `null` when the element
|
|
111
|
+
* doesn't mark a phase — so a non-spine write (e.g. a poller reconcile pass) never clobbers
|
|
112
|
+
* `epic_phase`. A wave-bearing phase (`Implementing`) is wave-labelled from {@link WaveContext} when
|
|
113
|
+
* supplied. This is the single structural deriver; workers pass `job.elementId` so the phase name is
|
|
114
|
+
* never hardcoded at the call site.
|
|
115
|
+
*/
|
|
116
|
+
export function deriveEpicPhase(
|
|
117
|
+
elementId: string | undefined | null,
|
|
118
|
+
wave?: WaveContext,
|
|
119
|
+
): string | null {
|
|
120
|
+
if (!elementId) return null;
|
|
121
|
+
const base = ELEMENT_PHASE[elementId];
|
|
122
|
+
if (base === undefined) return null;
|
|
123
|
+
if (base === EPIC_PHASE.IMPLEMENTING) return implementingPhase(wave?.current, wave?.total);
|
|
124
|
+
return base;
|
|
125
|
+
}
|
package/app/plan.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// hand-written SQL — matching app/service.ts.
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
|
+
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
14
15
|
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
|
|
15
16
|
import { coalesceTitle, ensureBaseBranch, fetchDefaultBranch, fetchIssueTitle } from "./github.ts";
|
|
16
17
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
@@ -82,6 +83,12 @@ export interface Plan {
|
|
|
82
83
|
// in app/service.ts); `delivery_label` is the human rollup for the epic detail view. Display-only.
|
|
83
84
|
delivery: string | null;
|
|
84
85
|
delivery_label: string | null;
|
|
86
|
+
// Derived epic domain phase (038_plan_epic_phase.sql, #261): the epic's own lifecycle phase —
|
|
87
|
+
// Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
|
|
88
|
+
// projected at write time from plan-fanout.bpmn's named activities (app/epicPhase.ts), so the epic
|
|
89
|
+
// view can show which phase the epic is IN rather than only the process-instance terminal status.
|
|
90
|
+
// Display-only; NULL until the lifecycle first stamps it (grandfathers pre-#261 rows).
|
|
91
|
+
epic_phase: string | null;
|
|
85
92
|
created_at: string;
|
|
86
93
|
updated_at: string;
|
|
87
94
|
}
|
|
@@ -436,6 +443,9 @@ export async function startPlan(
|
|
|
436
443
|
issue_url: parsed.url,
|
|
437
444
|
title,
|
|
438
445
|
outcome: null,
|
|
446
|
+
// Genesis of the domain lifecycle (#261): the epic re-enters Planning. Cleared of any stale
|
|
447
|
+
// terminal phase from the prior run so the re-plan reads correctly from the first pass.
|
|
448
|
+
epic_phase: EPIC_PHASE.PLANNING,
|
|
439
449
|
blackboard_token: token,
|
|
440
450
|
base_branch: base,
|
|
441
451
|
updated_at: ts,
|
|
@@ -449,6 +459,8 @@ export async function startPlan(
|
|
|
449
459
|
title,
|
|
450
460
|
status: "planning",
|
|
451
461
|
task_count: 0,
|
|
462
|
+
// Genesis of the domain lifecycle (#261): a fresh epic starts in Planning.
|
|
463
|
+
epic_phase: EPIC_PHASE.PLANNING,
|
|
452
464
|
blackboard_token: token,
|
|
453
465
|
base_branch: base,
|
|
454
466
|
created_at: ts,
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Unit coverage for the ReadinessProbe core (app/readiness.ts, issue #258 / ADR 0001 §2).
|
|
2
|
+
//
|
|
3
|
+
// The gate's engine model is proven end-to-end in e2e/readiness-gate.e2e.ts; these tests pin the
|
|
4
|
+
// pure surface: descriptor parse/validation, each kind's matcher, the injectable `probeOnce`
|
|
5
|
+
// dispatch (no network / subprocess), backoff, the ms→ISO timeout derivation, and log redaction.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "#test-assert";
|
|
8
|
+
import {
|
|
9
|
+
type CommandResult,
|
|
10
|
+
DEFAULT_ATTEMPT_TIMEOUT_MS,
|
|
11
|
+
DEFAULT_EVERY_MS,
|
|
12
|
+
DEFAULT_TIMEOUT_MS,
|
|
13
|
+
defaultProbeExec,
|
|
14
|
+
type HttpResponse,
|
|
15
|
+
MAX_EVERY_MS,
|
|
16
|
+
matchCommand,
|
|
17
|
+
matchGithubCheck,
|
|
18
|
+
matchHttp,
|
|
19
|
+
matchNpm,
|
|
20
|
+
msToIsoDuration,
|
|
21
|
+
nextDelay,
|
|
22
|
+
normalizePoll,
|
|
23
|
+
parseProbe,
|
|
24
|
+
parseRepoRef,
|
|
25
|
+
probeBudgetMs,
|
|
26
|
+
probeOnce,
|
|
27
|
+
type ProbeExec,
|
|
28
|
+
readinessTimeout,
|
|
29
|
+
readinessTimeoutMs,
|
|
30
|
+
redactString,
|
|
31
|
+
redactTarget,
|
|
32
|
+
} from "./readiness.ts";
|
|
33
|
+
|
|
34
|
+
// A ProbeExec stub: canned http/command responses, capturing the last command it was asked to run.
|
|
35
|
+
function stubExec(opts: { http?: HttpResponse; command?: CommandResult; capture?: { cmd?: string; headers?: Record<string, string> } }): ProbeExec {
|
|
36
|
+
return {
|
|
37
|
+
async httpGet(_url, headers) {
|
|
38
|
+
if (opts.capture) opts.capture.headers = headers;
|
|
39
|
+
return opts.http ?? { status: 0, body: "" };
|
|
40
|
+
},
|
|
41
|
+
async run(command) {
|
|
42
|
+
if (opts.capture) opts.capture.cmd = command;
|
|
43
|
+
return opts.command ?? { code: 0, stdout: "", stderr: "" };
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── parseProbe ──────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
test("parseProbe: accepts a minimal http probe and defaults onTimeout to escalate", () => {
|
|
50
|
+
const p = parseProbe({ kind: "http", target: "https://x/health" });
|
|
51
|
+
assertEquals(p.kind, "http");
|
|
52
|
+
assertEquals(p.target, "https://x/health");
|
|
53
|
+
assertEquals(p.onTimeout, "escalate");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("parseProbe: rejects an unknown kind", () => {
|
|
57
|
+
assertThrows(() => parseProbe({ kind: "oci", target: "img:tag" }), Error, "unknown kind");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("parseProbe: rejects a blank target", () => {
|
|
61
|
+
assertThrows(() => parseProbe({ kind: "command", target: " " }), Error, "'target' is required");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("parseProbe: rejects an invalid onTimeout", () => {
|
|
65
|
+
assertThrows(() => parseProbe({ kind: "http", target: "x", onTimeout: "retry" }), Error, "invalid onTimeout");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("parseProbe: rejects an invalid poll.backoff (a malformed probe must fail loudly, never silently default)", () => {
|
|
69
|
+
assertThrows(
|
|
70
|
+
() => parseProbe({ kind: "http", target: "x", poll: { backoff: "linear" } }),
|
|
71
|
+
Error,
|
|
72
|
+
"invalid backoff",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("parseProbe: rejects an undeclared credentialEnv (a probe must never inline a secret)", () => {
|
|
77
|
+
assertThrows(
|
|
78
|
+
() => parseProbe({ kind: "http", target: "x", credentialEnv: "MY_SECRET" }),
|
|
79
|
+
Error,
|
|
80
|
+
"not a declared env-contract key",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("parseProbe: rejects a credentialEnv on a non-http kind (a subprocess probe never consumes it)", () => {
|
|
85
|
+
assertThrows(
|
|
86
|
+
() => parseProbe({ kind: "github-check", target: "o/r@abc", credentialEnv: "GITHUB_TOKEN" }),
|
|
87
|
+
Error,
|
|
88
|
+
"only supported for the 'http' kind",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("parseProbe: accepts a declared credentialEnv (http) and parses nested match/poll", () => {
|
|
93
|
+
const p = parseProbe({
|
|
94
|
+
kind: "http",
|
|
95
|
+
target: "https://x/health",
|
|
96
|
+
credentialEnv: "GITHUB_TOKEN",
|
|
97
|
+
match: { status: 200, checkName: "build" },
|
|
98
|
+
poll: { everyMs: 1000, timeoutMs: 60000, backoff: "fixed" },
|
|
99
|
+
});
|
|
100
|
+
assertEquals(p.credentialEnv, "GITHUB_TOKEN");
|
|
101
|
+
assertEquals(p.match?.checkName, "build");
|
|
102
|
+
assertEquals(p.poll?.backoff, "fixed");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── matchers ────────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
test("matchHttp: any 2xx is ready by default; a 503 is not", () => {
|
|
107
|
+
assert(matchHttp(undefined, { status: 204, body: "" }).ready);
|
|
108
|
+
assert(!matchHttp(undefined, { status: 503, body: "" }).ready);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("matchHttp: an explicit status + bodyIncludes are both required", () => {
|
|
112
|
+
const m = { status: 200, bodyIncludes: "OK" };
|
|
113
|
+
assert(matchHttp(m, { status: 200, body: "all OK here" }).ready);
|
|
114
|
+
assert(!matchHttp(m, { status: 200, body: "degraded" }).ready);
|
|
115
|
+
assert(!matchHttp(m, { status: 201, body: "OK" }).ready);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("matchCommand: exit 0 is ready by default; stdoutIncludes narrows it", () => {
|
|
119
|
+
assert(matchCommand(undefined, { code: 0, stdout: "", stderr: "" }).ready);
|
|
120
|
+
assert(!matchCommand(undefined, { code: 1, stdout: "", stderr: "" }).ready);
|
|
121
|
+
assert(matchCommand({ stdoutIncludes: "ready" }, { code: 0, stdout: "svc ready", stderr: "" }).ready);
|
|
122
|
+
assert(!matchCommand({ stdoutIncludes: "ready" }, { code: 0, stdout: "starting", stderr: "" }).ready);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("matchNpm: a printed version means published; a failed view is not-ready", () => {
|
|
126
|
+
assert(matchNpm(undefined, "pkg@1.2.3", { code: 0, stdout: "1.2.3\n", stderr: "" }).ready);
|
|
127
|
+
assert(!matchNpm(undefined, "pkg@1.2.3", { code: 1, stdout: "", stderr: "E404" }).ready);
|
|
128
|
+
assert(!matchNpm(undefined, "pkg@1.2.3", { code: 0, stdout: "", stderr: "" }).ready);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("matchNpm: the version in pkg@version must match the printed version", () => {
|
|
132
|
+
assert(!matchNpm(undefined, "pkg@2.0.0", { code: 0, stdout: "1.9.9", stderr: "" }).ready);
|
|
133
|
+
assert(matchNpm(undefined, "pkg@2.0.0", { code: 0, stdout: "2.0.0", stderr: "" }).ready);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("matchGithubCheck: all runs must be completed+success; a pending run is not-ready", () => {
|
|
137
|
+
const green = { check_runs: [{ name: "build", status: "completed", conclusion: "success" }] };
|
|
138
|
+
const pending = { check_runs: [{ name: "build", status: "in_progress", conclusion: "" }] };
|
|
139
|
+
assert(matchGithubCheck(undefined, green).ready);
|
|
140
|
+
assert(!matchGithubCheck(undefined, pending).ready);
|
|
141
|
+
assert(!matchGithubCheck(undefined, { check_runs: [] }).ready);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("matchGithubCheck: checkName restricts the predicate to that run", () => {
|
|
145
|
+
const payload = {
|
|
146
|
+
check_runs: [
|
|
147
|
+
{ name: "build", status: "completed", conclusion: "success" },
|
|
148
|
+
{ name: "flaky", status: "completed", conclusion: "failure" },
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
assert(matchGithubCheck({ checkName: "build" }, payload).ready);
|
|
152
|
+
assert(!matchGithubCheck({ checkName: "flaky" }, payload).ready);
|
|
153
|
+
assert(!matchGithubCheck({ checkName: "missing" }, payload).ready);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── probeOnce dispatch (injected exec — no I/O) ───────────────────────────────────────────────
|
|
157
|
+
test("probeOnce http: injects a Bearer credential from the declared env-contract, redacting nothing into the target", async () => {
|
|
158
|
+
const cap: { headers?: Record<string, string> } = {};
|
|
159
|
+
const exec = stubExec({ http: { status: 200, body: "ok" }, capture: cap });
|
|
160
|
+
const p = parseProbe({ kind: "http", target: "https://x/health", credentialEnv: "GITHUB_TOKEN" });
|
|
161
|
+
const res = await probeOnce(p, exec, { GITHUB_TOKEN: "tkn" });
|
|
162
|
+
assert(res.ready);
|
|
163
|
+
assertEquals(cap.headers?.authorization, "Bearer tkn");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("probeOnce npm: builds a quoted `npm view … version` command", async () => {
|
|
167
|
+
const cap: { cmd?: string } = {};
|
|
168
|
+
const exec = stubExec({ command: { code: 0, stdout: "1.0.0", stderr: "" }, capture: cap });
|
|
169
|
+
const res = await probeOnce(parseProbe({ kind: "npm", target: "@scope/pkg@1.0.0" }), exec, {});
|
|
170
|
+
assert(res.ready);
|
|
171
|
+
assertStringIncludes(cap.cmd ?? "", "npm view '@scope/pkg@1.0.0' version");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("probeOnce github-check: parses gh api JSON and requires success", async () => {
|
|
175
|
+
const cap: { cmd?: string } = {};
|
|
176
|
+
const exec = stubExec({
|
|
177
|
+
command: { code: 0, stdout: JSON.stringify({ check_runs: [{ name: "ci", status: "completed", conclusion: "success" }] }), stderr: "" },
|
|
178
|
+
capture: cap,
|
|
179
|
+
});
|
|
180
|
+
const res = await probeOnce(parseProbe({ kind: "github-check", target: "o/r@main" }), exec, {});
|
|
181
|
+
assert(res.ready);
|
|
182
|
+
assertStringIncludes(cap.cmd ?? "", "repos/o/r/commits/main/check-runs");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("probeOnce github-check: a failed gh api call is not-ready (never throws)", async () => {
|
|
186
|
+
const exec = stubExec({ command: { code: 1, stdout: "", stderr: "not found" } });
|
|
187
|
+
const res = await probeOnce(parseProbe({ kind: "github-check", target: "o/r@main" }), exec, {});
|
|
188
|
+
assert(!res.ready);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── backoff + poll normalisation ──────────────────────────────────────────────────────────────
|
|
192
|
+
test("normalizePoll: fills defaults and clamps everyMs to the ceiling", () => {
|
|
193
|
+
const d = normalizePoll(undefined);
|
|
194
|
+
assertEquals(d.everyMs, DEFAULT_EVERY_MS);
|
|
195
|
+
assertEquals(d.timeoutMs, DEFAULT_TIMEOUT_MS);
|
|
196
|
+
assertEquals(d.backoff, "exponential");
|
|
197
|
+
assertEquals(normalizePoll({ everyMs: 10 * 60_000 }).everyMs, MAX_EVERY_MS);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("nextDelay: fixed returns everyMs; exponential doubles and clamps", () => {
|
|
201
|
+
const fixed = normalizePoll({ everyMs: 1000, backoff: "fixed" });
|
|
202
|
+
assertEquals(nextDelay(1, fixed), 1000);
|
|
203
|
+
assertEquals(nextDelay(5, fixed), 1000);
|
|
204
|
+
const exp = normalizePoll({ everyMs: 1000, backoff: "exponential" });
|
|
205
|
+
assertEquals(nextDelay(1, exp), 1000);
|
|
206
|
+
assertEquals(nextDelay(3, exp), 4000);
|
|
207
|
+
assertEquals(nextDelay(30, exp), MAX_EVERY_MS);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── timeout derivation ────────────────────────────────────────────────────────────────────────
|
|
211
|
+
test("msToIsoDuration: rounds up to whole seconds, never zero", () => {
|
|
212
|
+
assertEquals(msToIsoDuration(1000), "PT1S");
|
|
213
|
+
assertEquals(msToIsoDuration(1500), "PT2S");
|
|
214
|
+
assertEquals(msToIsoDuration(1), "PT1S");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("readinessTimeout: derives from poll.timeoutMs, else the env default, else PT30M", () => {
|
|
218
|
+
assertEquals(readinessTimeout(parseProbe({ kind: "http", target: "x", poll: { timeoutMs: 60000 } }), {}), "PT60S");
|
|
219
|
+
assertEquals(readinessTimeout(parseProbe({ kind: "http", target: "x" }), {}), "PT30M");
|
|
220
|
+
assertEquals(
|
|
221
|
+
readinessTimeout(parseProbe({ kind: "http", target: "x" }), { NANO_READINESS_POLL_TIMEOUT: "PT5M" }),
|
|
222
|
+
"PT5M",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("readinessTimeoutMs: the ms twin of readinessTimeout — same precedence, no drift with the gate timer", () => {
|
|
227
|
+
// Declared budget is taken verbatim in ms (the gate rounds it up to whole seconds for its ISO timer).
|
|
228
|
+
assertEquals(readinessTimeoutMs(parseProbe({ kind: "http", target: "x", poll: { timeoutMs: 60000 } }), {}), 60000);
|
|
229
|
+
// Omitted + no env → the built-in default (30m), matching readinessTimeout's PT30M.
|
|
230
|
+
assertEquals(readinessTimeoutMs(parseProbe({ kind: "http", target: "x" }), {}), 1_800_000);
|
|
231
|
+
// Omitted + env → the env budget in ms. Regression: this used to fall back to the hard-coded 30m,
|
|
232
|
+
// stranding the worker while the gate timer waited the full env budget.
|
|
233
|
+
assertEquals(
|
|
234
|
+
readinessTimeoutMs(parseProbe({ kind: "http", target: "x" }), { NANO_READINESS_POLL_TIMEOUT: "PT2H" }),
|
|
235
|
+
7_200_000,
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("probeBudgetMs: prefers the seeded probeTimeout (the gate timer's bound), falling back to the env twin", () => {
|
|
240
|
+
const probe = parseProbe({ kind: "http", target: "x" });
|
|
241
|
+
// The seeded probeTimeout wins over the ambient env — binding worker and engine to ONE per-instance
|
|
242
|
+
// value: a stale env can't shorten the worker while the engine timer waits the seeded budget.
|
|
243
|
+
assertEquals(probeBudgetMs("PT45M", probe, { NANO_READINESS_POLL_TIMEOUT: "PT1M" }), 2_700_000);
|
|
244
|
+
// Absent/blank probeTimeout → fall back to the env-derived twin (readinessTimeoutMs).
|
|
245
|
+
assertEquals(probeBudgetMs(undefined, probe, { NANO_READINESS_POLL_TIMEOUT: "PT2H" }), 7_200_000);
|
|
246
|
+
assertEquals(probeBudgetMs(" ", probe, {}), 1_800_000);
|
|
247
|
+
// A malformed seeded value degrades to the built-in default (30m), matching isoDurationToMs.
|
|
248
|
+
assertEquals(probeBudgetMs("nonsense", probe, { NANO_READINESS_POLL_TIMEOUT: "PT1M" }), 1_800_000);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ── repo/ref parse + redaction ──────────────────────────────────────────────────────────────
|
|
252
|
+
test("parseRepoRef: splits owner/repo@ref and defaults the ref to HEAD", () => {
|
|
253
|
+
assertEquals(parseRepoRef("o/r@abc123"), { repo: "o/r", ref: "abc123" });
|
|
254
|
+
assertEquals(parseRepoRef("o/r"), { repo: "o/r", ref: "HEAD" });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("redactString/redactTarget: strip userinfo and query (a token often rides either)", () => {
|
|
258
|
+
assertEquals(redactString("https://user:pass@host/path?token=abc"), "https://***@host/path?***");
|
|
259
|
+
assertStringIncludes(redactTarget(parseProbe({ kind: "http", target: "https://h/p?tok=s3cr3t" })), "?***");
|
|
260
|
+
const t = redactTarget(parseProbe({ kind: "http", target: "https://h/p?tok=s3cr3t" }));
|
|
261
|
+
assert(!t.includes("s3cr3t"), "the secret must not survive redaction");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("redactTarget: a command target is never logged — only the kind + a fixed placeholder", () => {
|
|
265
|
+
const ct = redactTarget(parseProbe({ kind: "command", target: "curl -H 'Authorization: Bearer s3cr3t' https://h/p" }));
|
|
266
|
+
assertEquals(ct, "command:<redacted>");
|
|
267
|
+
assert(!ct.includes("s3cr3t"), "an arbitrary shell snippet's secrets must never survive to a log line");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── default ProbeExec: every attempt is bounded (a stuck probe can never hang the worker) ─────
|
|
271
|
+
test("defaultProbeExec.run: a command that outlives the attempt timeout resolves bounded, non-zero", async () => {
|
|
272
|
+
const exec = defaultProbeExec(50);
|
|
273
|
+
const start = Date.now();
|
|
274
|
+
const out = await exec.run("sleep 5", process.env);
|
|
275
|
+
const elapsed = Date.now() - start;
|
|
276
|
+
assert(out.code !== 0, "a killed (timed-out) command must report a non-zero exit code, i.e. not ready");
|
|
277
|
+
assert(elapsed < 4000, `the attempt must resolve in bounded time, not run to completion (took ${elapsed}ms)`);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("defaultProbeExec.httpGet: a hung endpoint aborts at the attempt timeout instead of hanging forever", async () => {
|
|
281
|
+
const { createServer } = await import("node:http");
|
|
282
|
+
const server = createServer(() => {
|
|
283
|
+
/* never responds — the request hangs until the client aborts */
|
|
284
|
+
});
|
|
285
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
|
286
|
+
const addr = server.address();
|
|
287
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
288
|
+
try {
|
|
289
|
+
const exec = defaultProbeExec(50);
|
|
290
|
+
const start = Date.now();
|
|
291
|
+
await assertRejects(() => exec.httpGet(`http://127.0.0.1:${port}/`, {}));
|
|
292
|
+
assert(Date.now() - start < 4000, "the fetch must abort at the attempt deadline, not hang");
|
|
293
|
+
} finally {
|
|
294
|
+
server.close();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("DEFAULT_ATTEMPT_TIMEOUT_MS is a sane bounded default", () => {
|
|
299
|
+
assert(DEFAULT_ATTEMPT_TIMEOUT_MS > 0 && DEFAULT_ATTEMPT_TIMEOUT_MS <= 5 * 60_000);
|
|
300
|
+
});
|