@nanobpm/nano-workforce 0.119.0 → 0.120.1
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 +15 -0
- package/app/capabilityNeed.test.ts +4 -2
- package/app/capabilityNeed.ts +3 -1
- package/app/deliveryGraphCompiler.test.ts +2 -2
- package/app/deliveryGraphCompiler.ts +63 -17
- package/app/deliveryRunner.test.ts +1 -0
- package/app/deliveryRunner.ts +16 -4
- package/app/feature.test.ts +3 -1
- package/app/feature.ts +9 -0
- package/app/featureReadiness.test.ts +7 -4
- package/app/featureReadiness.ts +8 -3
- package/app/lineage.ts +25 -0
- package/app/mergesPerDayView.test.ts +166 -0
- package/app/migration064.test.ts +176 -0
- package/app/plan.test.ts +1 -1
- package/app/plan.ts +9 -0
- package/app/planFanoutPreflight.test.ts +12 -12
- package/app/planLowering.test.ts +2 -0
- package/app/planLowering.ts +7 -2
- package/app/planWaveSummary.test.ts +4 -2
- package/app/plansReadModel.test.ts +262 -0
- package/app/readiness.test.ts +9 -0
- package/app/readiness.ts +25 -0
- package/app/service.ts +8 -1
- package/biome.json +24 -1
- package/db/migrations/060_plan_wave_rollup.sql +52 -0
- package/db/migrations/061_plan_delivery_rollup.sql +98 -0
- package/db/migrations/062_merges_per_day_view.sql +72 -0
- package/db/migrations/064_lineage_thread_view.sql +75 -0
- package/e2e/delivery-graph.e2e.ts +2 -1
- package/e2e/feature-preflight.e2e.ts +2 -0
- package/e2e/inter-epic-dependency.e2e.ts +7 -1
- package/e2e/plan-fanout-preflight.e2e.ts +2 -0
- package/e2e/readiness-gate.e2e.ts +27 -11
- package/package.json +1 -1
- package/pages/epic-detail.page.json +2 -2
- package/pages/lineage.page.json +1 -1
- package/pages/overview.page.json +1 -1
- package/pages/velocity.page.json +1 -1
- package/resources/processes/feature.bpmn +168 -52
- package/resources/processes/plan-fanout.bpmn +168 -52
- package/resources/processes/readiness-gate.bpmn +194 -84
- package/scripts/pages-contract.test.ts +3 -1
- package/workers/readiness-probe/worker.test.ts +82 -238
- package/workers/readiness-probe/worker.ts +46 -128
package/app/service.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
probeOnce,
|
|
71
71
|
READINESS_READY_MESSAGE,
|
|
72
72
|
type ReadinessProbe,
|
|
73
|
+
readinessPollEvery,
|
|
73
74
|
readinessTimeout,
|
|
74
75
|
} from "./readiness.ts";
|
|
75
76
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
@@ -1688,6 +1689,10 @@ function capabilityGateTimeout(env: Record<string, string | undefined>): string
|
|
|
1688
1689
|
return readinessTimeout({ kind: "capability", target: "" } satisfies ReadinessProbe, env);
|
|
1689
1690
|
}
|
|
1690
1691
|
|
|
1692
|
+
function capabilityGatePollEvery(env: Record<string, string | undefined>): string {
|
|
1693
|
+
return readinessPollEvery({ kind: "capability", target: "" } satisfies ReadinessProbe, env);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1691
1696
|
/** Capability-edge reconcile pass (issue #289). The host half of the "consumer readiness edge":
|
|
1692
1697
|
* plan-fanout's per-task fan-out parks at the `wait-caps-resolved` message barrier for any task that
|
|
1693
1698
|
* declared cross-repo capability `needs` (049_plan_task_needs.sql). Here we reconcile, on EVERY pass
|
|
@@ -1721,6 +1726,7 @@ export async function pollCapabilityGatesImpl(
|
|
|
1721
1726
|
) {
|
|
1722
1727
|
const gateTable = capabilityGates(data);
|
|
1723
1728
|
const probeTimeout = capabilityGateTimeout(env);
|
|
1729
|
+
const probePollEvery = capabilityGatePollEvery(env);
|
|
1724
1730
|
for (const plan of await plans(data).all()) {
|
|
1725
1731
|
const planKey = plan.plan_key;
|
|
1726
1732
|
const processKey = plan.process_key;
|
|
@@ -1757,7 +1763,7 @@ export async function pollCapabilityGatesImpl(
|
|
|
1757
1763
|
// need as unresolved (it can only clear once the handle is corrected on a re-plan).
|
|
1758
1764
|
let probeInput: ReturnType<typeof capabilityNeedToProbeInput>;
|
|
1759
1765
|
try {
|
|
1760
|
-
probeInput = capabilityNeedToProbeInput(need, { planKey, taskId, probeTimeout });
|
|
1766
|
+
probeInput = capabilityNeedToProbeInput(need, { planKey, taskId, probeTimeout, probePollEvery });
|
|
1761
1767
|
} catch (err) {
|
|
1762
1768
|
if (err instanceof UnresolvableCapabilityRefError) {
|
|
1763
1769
|
if (!row) {
|
|
@@ -1808,6 +1814,7 @@ export async function pollCapabilityGatesImpl(
|
|
|
1808
1814
|
variables: {
|
|
1809
1815
|
gateKey: probeInput.gateKey,
|
|
1810
1816
|
probeTimeout: probeInput.probeTimeout,
|
|
1817
|
+
probePollEvery: probeInput.probePollEvery,
|
|
1811
1818
|
onTimeout: probeInput.onTimeout,
|
|
1812
1819
|
probe: probeInput.probe,
|
|
1813
1820
|
},
|
package/biome.json
CHANGED
|
@@ -137,5 +137,28 @@
|
|
|
137
137
|
],
|
|
138
138
|
"formatter": {
|
|
139
139
|
"enabled": false
|
|
140
|
-
}
|
|
140
|
+
},
|
|
141
|
+
"overrides": [
|
|
142
|
+
{
|
|
143
|
+
"includes": [
|
|
144
|
+
"workers/readiness-probe/**"
|
|
145
|
+
],
|
|
146
|
+
"linter": {
|
|
147
|
+
"rules": {
|
|
148
|
+
"style": {
|
|
149
|
+
"noRestrictedGlobals": {
|
|
150
|
+
"level": "error",
|
|
151
|
+
"options": {
|
|
152
|
+
"deniedGlobals": {
|
|
153
|
+
"setTimeout": "Use engine BPMN timers; readiness-probe must be single-shot.",
|
|
154
|
+
"setInterval": "Use engine BPMN timers; readiness-probe must be single-shot.",
|
|
155
|
+
"Date": "Use engine BPMN timers; readiness-probe must not read wall-clock time."
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
]
|
|
141
164
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- Wave-progress read model as a derived VIEW (epic #412 — retire worker-maintained projections).
|
|
2
|
+
--
|
|
3
|
+
-- 022_plan_wave_progress.sql denormalised `plans.wave_count` / `plans.current_wave` /
|
|
4
|
+
-- `plans.wave_label` — a per-epic "wave X/N" at-a-glance projection — onto the `plans` row, written
|
|
5
|
+
-- by the wave workers (`record-plan`, `select-wave`, `record-wave`). Its comment cites the sole
|
|
6
|
+
-- reason it was a worker-maintained table rather than a VIEW: "Urban's datasource cannot read a SQL
|
|
7
|
+
-- VIEW". That constraint is gone (nano-ide#424: `gateway.schema()` now introspects
|
|
8
|
+
-- `type IN ('table','view')`), so — exactly like 059 did for the wave-summary rollup — this expresses
|
|
9
|
+
-- the same projection as a DERIVED view: a single source of truth with NO write-path and no drift
|
|
10
|
+
-- from `plan_tasks`.
|
|
11
|
+
--
|
|
12
|
+
-- Reuses 059's `plan_wave_counts` (one row per (plan_key, wave), with the six-way task partition,
|
|
13
|
+
-- including `in_flight`) so the frontier can be derived purely, layered so each view stays a plain
|
|
14
|
+
-- `CREATE VIEW <name> AS SELECT … FROM …` (no CTE / no select-list subquery) — which keeps them
|
|
15
|
+
-- parseable by the static pages↔schema contract guard (scripts/pages-contract.test.ts).
|
|
16
|
+
--
|
|
17
|
+
-- • plan_wave_progress — one row per plan_key with the two numeric projections:
|
|
18
|
+
-- - wave_count = MAX(wave)+1 (the levelizer emits contiguous waves 0..N-1, so this equals
|
|
19
|
+
-- `app/waves.ts` `waveCount`). A plan with no LEVELIZED tasks contributes no
|
|
20
|
+
-- `plan_wave_counts` row, so it is absent here and reads NULL through the
|
|
21
|
+
-- downstream LEFT JOIN — matching the workers, which leave a taskless plan's
|
|
22
|
+
-- wave columns NULL.
|
|
23
|
+
-- - current_wave = the live FRONTIER, derived (not process-state-tracked): the lowest wave
|
|
24
|
+
-- that still has an `in_flight` task (the wave the fleet is actively
|
|
25
|
+
-- implementing / the wave the merge-barrier is gating), else — once every
|
|
26
|
+
-- wave has settled (in_flight = 0 everywhere) — pinned to the last index
|
|
27
|
+
-- MAX(wave). This reproduces the workers' projection: `record-plan` starts it
|
|
28
|
+
-- at 0 (wave 0 is in flight), `record-wave`/`select-wave` advance it to the
|
|
29
|
+
-- next gating wave as each wave's PRs merge, and it pins to wave_count-1 on
|
|
30
|
+
-- completion (a finished epic reads N/N).
|
|
31
|
+
-- • plan_wave_label — the same two numbers PLUS `wave_label`, the PRE-FORMATTED 1-based "X/N"
|
|
32
|
+
-- display string (`(current_wave+1)/wave_count`) the epics-index and epic
|
|
33
|
+
-- banner render, because the dataGrid has no per-cell templating.
|
|
34
|
+
--
|
|
35
|
+
-- Forward-only, additive (a new read model; no schema change to any base table). The runner wraps
|
|
36
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
37
|
+
|
|
38
|
+
CREATE VIEW plan_wave_progress AS
|
|
39
|
+
SELECT
|
|
40
|
+
c.plan_key AS plan_key,
|
|
41
|
+
MAX(c.wave) + 1 AS wave_count,
|
|
42
|
+
COALESCE(MIN(CASE WHEN c.in_flight > 0 THEN c.wave END), MAX(c.wave)) AS current_wave
|
|
43
|
+
FROM plan_wave_counts c
|
|
44
|
+
GROUP BY c.plan_key;
|
|
45
|
+
|
|
46
|
+
CREATE VIEW plan_wave_label AS
|
|
47
|
+
SELECT
|
|
48
|
+
w.plan_key AS plan_key,
|
|
49
|
+
w.wave_count AS wave_count,
|
|
50
|
+
w.current_wave AS current_wave,
|
|
51
|
+
(w.current_wave + 1) || '/' || w.wave_count AS wave_label
|
|
52
|
+
FROM plan_wave_progress w;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
-- Epic delivery read model as a derived VIEW, plus the composite `plans` read model the pages bind
|
|
2
|
+
-- (epic #412 — retire worker-maintained projections).
|
|
3
|
+
--
|
|
4
|
+
-- 029_plan_delivery.sql denormalised `plans.delivery` ('converging'|'landed'|NULL) and
|
|
5
|
+
-- `plans.delivery_label` onto the `plans` row, recomputed each poll pass by `pollDelivery`
|
|
6
|
+
-- (app/service.ts) which joins each `plan_tasks.pr_key` → `pull_requests.status`. The PURE derivation
|
|
7
|
+
-- lives in `deriveDelivery`/`TERMINAL_STATUSES` (app/delivery.ts). Its comment cites the sole reason
|
|
8
|
+
-- it was a poller-maintained table rather than a VIEW: "Urban's datasource cannot read a SQL VIEW".
|
|
9
|
+
-- That constraint is gone (nano-ide#424), so this expresses the SAME `deriveDelivery` logic as a
|
|
10
|
+
-- DERIVED view — a single source of truth with NO write-path and no drift.
|
|
11
|
+
--
|
|
12
|
+
-- Layered so each view stays a plain `CREATE VIEW <name> AS SELECT … FROM …` (no CTE / no
|
|
13
|
+
-- select-list subquery), parseable by scripts/pages-contract.test.ts:
|
|
14
|
+
--
|
|
15
|
+
-- • plan_delivery_counts — one row per plan_key with the three counts `deriveDelivery` folds over
|
|
16
|
+
-- the slice PRs (only tasks that OPENED a PR — `pr_key IS NOT NULL` —
|
|
17
|
+
-- count, mirroring `pollDelivery`'s `if (!t.pr_key) continue`):
|
|
18
|
+
-- - prs_opened = number of slice tasks with a PR.
|
|
19
|
+
-- - prs_merged = those whose PR reached `status = 'merged'`.
|
|
20
|
+
-- - prs_in_flight = those whose PR is NON-terminal (`status` NOT IN
|
|
21
|
+
-- `TERMINAL_STATUSES` = converged/merged/abandoned). A `pr_key` with
|
|
22
|
+
-- no `pull_requests` row (status NULL, the poller's MISSING_PR_STATUS
|
|
23
|
+
-- sentinel) is non-terminal, so it counts as in flight — a DB desync
|
|
24
|
+
-- can never wrongly promote an epic to `landed`.
|
|
25
|
+
-- • plan_delivery — the derived signal + PRE-FORMATTED label, per `deriveDelivery`:
|
|
26
|
+
-- - NULL when the plan is not `done` or opened no PRs (no positive
|
|
27
|
+
-- signal yet), OR every PR is terminal but not all merged
|
|
28
|
+
-- (resolved-not-landed).
|
|
29
|
+
-- - 'converging' + "M/O slices merged, F converging" when ≥1 PR is in
|
|
30
|
+
-- flight.
|
|
31
|
+
-- - 'landed' + "O/O slices merged" when every slice PR merged
|
|
32
|
+
-- (prs_in_flight = 0 AND prs_merged = prs_opened > 0).
|
|
33
|
+
-- • plan_read_model — the `plans` row with its wave (060) and delivery projections DERIVED
|
|
34
|
+
-- from the views instead of read from the denormalised columns. This is
|
|
35
|
+
-- the datasource the operator pages (overview / epic-detail) bind, so that
|
|
36
|
+
-- when the wave-1 cleanup task DROPs plans.wave_label / plans.current_wave
|
|
37
|
+
-- / plans.wave_count / plans.delivery / plans.delivery_label, every page
|
|
38
|
+
-- already reads the single-source-of-truth views. It projects only the
|
|
39
|
+
-- `plans` columns those pages reference, plus the five derived columns.
|
|
40
|
+
--
|
|
41
|
+
-- Forward-only, additive. NO BEGIN/COMMIT — the runner wraps each file in its own transaction.
|
|
42
|
+
|
|
43
|
+
CREATE VIEW plan_delivery_counts AS
|
|
44
|
+
SELECT
|
|
45
|
+
t.plan_key AS plan_key,
|
|
46
|
+
COUNT(t.pr_key) AS prs_opened,
|
|
47
|
+
SUM(CASE WHEN t.pr_key IS NOT NULL AND p.status = 'merged' THEN 1 ELSE 0 END) AS prs_merged,
|
|
48
|
+
SUM(CASE WHEN t.pr_key IS NOT NULL AND (p.status IS NULL OR p.status NOT IN ('converged', 'merged', 'abandoned')) THEN 1 ELSE 0 END) AS prs_in_flight
|
|
49
|
+
FROM plan_tasks t
|
|
50
|
+
LEFT JOIN pull_requests p ON p.pr_key = t.pr_key
|
|
51
|
+
GROUP BY t.plan_key;
|
|
52
|
+
|
|
53
|
+
CREATE VIEW plan_delivery AS
|
|
54
|
+
SELECT
|
|
55
|
+
pl.plan_key AS plan_key,
|
|
56
|
+
CASE
|
|
57
|
+
WHEN pl.status IS NOT 'done' OR COALESCE(c.prs_opened, 0) = 0 THEN NULL
|
|
58
|
+
WHEN COALESCE(c.prs_in_flight, 0) > 0 THEN 'converging'
|
|
59
|
+
WHEN c.prs_merged = c.prs_opened THEN 'landed'
|
|
60
|
+
ELSE NULL
|
|
61
|
+
END AS delivery,
|
|
62
|
+
CASE
|
|
63
|
+
WHEN pl.status IS NOT 'done' OR COALESCE(c.prs_opened, 0) = 0 THEN NULL
|
|
64
|
+
WHEN COALESCE(c.prs_in_flight, 0) > 0 THEN c.prs_merged || '/' || c.prs_opened || ' slices merged, ' || c.prs_in_flight || ' converging'
|
|
65
|
+
WHEN c.prs_merged = c.prs_opened THEN c.prs_opened || '/' || c.prs_opened || ' slices merged'
|
|
66
|
+
ELSE NULL
|
|
67
|
+
END AS delivery_label
|
|
68
|
+
FROM plans pl
|
|
69
|
+
LEFT JOIN plan_delivery_counts c ON c.plan_key = pl.plan_key;
|
|
70
|
+
|
|
71
|
+
CREATE VIEW plan_read_model AS
|
|
72
|
+
SELECT
|
|
73
|
+
pl.plan_key AS plan_key,
|
|
74
|
+
pl.repo AS repo,
|
|
75
|
+
pl.issue_number AS issue_number,
|
|
76
|
+
pl.issue_url AS issue_url,
|
|
77
|
+
pl.title AS title,
|
|
78
|
+
pl.status AS status,
|
|
79
|
+
pl.task_count AS task_count,
|
|
80
|
+
pl.process_key AS process_key,
|
|
81
|
+
pl.outcome AS outcome,
|
|
82
|
+
pl.updated_at AS updated_at,
|
|
83
|
+
pl.epic_phase AS epic_phase,
|
|
84
|
+
pl.base_branch AS base_branch,
|
|
85
|
+
pl.wait_gate_label AS wait_gate_label,
|
|
86
|
+
pl.bound_artifacts AS bound_artifacts,
|
|
87
|
+
pl.promotion_pr AS promotion_pr,
|
|
88
|
+
pl.promotion_state AS promotion_state,
|
|
89
|
+
pl.list_bucket AS list_bucket,
|
|
90
|
+
pl.ack_open AS ack_open,
|
|
91
|
+
wl.wave_count AS wave_count,
|
|
92
|
+
wl.current_wave AS current_wave,
|
|
93
|
+
wl.wave_label AS wave_label,
|
|
94
|
+
d.delivery AS delivery,
|
|
95
|
+
d.delivery_label AS delivery_label
|
|
96
|
+
FROM plans pl
|
|
97
|
+
LEFT JOIN plan_wave_label wl ON wl.plan_key = pl.plan_key
|
|
98
|
+
LEFT JOIN plan_delivery d ON d.plan_key = pl.plan_key;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
-- Merged-per-day throughput / burn-up as a derived SQL VIEW (epic #412, retiring the 051 flat table).
|
|
2
|
+
--
|
|
3
|
+
-- 051_merges_per_day.sql created the DENORMALISED `merges_per_day` read table (day / merged /
|
|
4
|
+
-- cumulative / bar) that `pollMergesPerDay` (app/mergesPerDay.ts) recomputes each poll pass from the
|
|
5
|
+
-- `merges` audit rows (004_merge.sql). Its comment cites the ONE reason it could not simply be a
|
|
6
|
+
-- VIEW: "Urban's page datasource cannot read a SQL VIEW" (gateway.ts `schema()` whitelisted
|
|
7
|
+
-- `type='table'` only). That constraint is GONE — nano-ide#424 made `gateway.schema()` introspect
|
|
8
|
+
-- `type IN ('table','view')` and tag a view read-only, and #411 (059_plan_wave_summary.sql)
|
|
9
|
+
-- established the layered-VIEW pattern. So the aggregate becomes what AGENTS.md always wanted
|
|
10
|
+
-- ("Derivation over duplication"): a VIEW that is a single source of truth with NO write-path and no
|
|
11
|
+
-- possibility of drift from the `merges` audit trail.
|
|
12
|
+
--
|
|
13
|
+
-- This migration is WAVE-0 / PURELY ADDITIVE: it adds the VIEW and the Velocity page is repointed
|
|
14
|
+
-- onto it, but the `merges_per_day` TABLE and its `pollMergesPerDay` write-path are LEFT IN PLACE (a
|
|
15
|
+
-- harmless duplicate) so the surface never goes stale while both coexist. A wave-1 cleanup task
|
|
16
|
+
-- ("retire-projection-writepaths-cleanup") drops the table and deletes the write-path AFTER this
|
|
17
|
+
-- merges.
|
|
18
|
+
--
|
|
19
|
+
-- The VIEW must reproduce the CURRENT projection EXACTLY, including two subtleties beyond the 051
|
|
20
|
+
-- comment's canonical `SELECT date(at) AS day, COUNT(DISTINCT pr_key) …`:
|
|
21
|
+
-- • the day is bucketed in the operator's LOCAL calendar day (issue #361) — `date(at, 'localtime')`,
|
|
22
|
+
-- not UTC — matching `deriveMergesPerDay`, so a merge either side of a local midnight lands on the
|
|
23
|
+
-- day the operator saw it;
|
|
24
|
+
-- • `bar` is the SAME pre-formatted proportional block-character string the `prose` renderer draws
|
|
25
|
+
-- today (the renderer has no per-cell templating / no chart node, so the bar must arrive
|
|
26
|
+
-- ready-to-show): a run of `█` glyphs whose length is `max(1, round((merged / busiest) * 30))`
|
|
27
|
+
-- (min one glyph for any non-zero day; the busiest day is 30 wide), i.e. exactly
|
|
28
|
+
-- `barFor()`/`BAR_WIDTH`/`BAR_FULL` in app/mergesPerDay.ts.
|
|
29
|
+
--
|
|
30
|
+
-- Layered into TWO plain views so each is a `CREATE VIEW <name> AS SELECT … FROM …` with NO CTE and
|
|
31
|
+
-- NO select-list subquery — which keeps them parseable by the static pages↔schema contract guard
|
|
32
|
+
-- (scripts/pages-contract.test.ts), exactly as 059 layers its counts → summary:
|
|
33
|
+
--
|
|
34
|
+
-- • merges_per_day_counts — one row per local calendar day: `day` + `merged`
|
|
35
|
+
-- (COUNT(DISTINCT pr_key) so a PR with several `merged` audit rows on one day — an
|
|
36
|
+
-- already-merged short-circuit or a retry — counts once; `queued`/`blocked` rows are excluded).
|
|
37
|
+
-- • merges_per_day_view — the same rows PLUS the burn-up `cumulative`
|
|
38
|
+
-- (`SUM(merged) OVER (ORDER BY day)` — a window function in the select list, allowed by the
|
|
39
|
+
-- guard) and the pre-formatted `bar`. The bar length uses `MAX(merged) OVER ()` (the busiest
|
|
40
|
+
-- day) as the scale; the block run is built with SQLite string funcs
|
|
41
|
+
-- (`hex(zeroblob(n))` → 2n '0' chars → `substr` to n → `replace` to the `█` glyph), the same
|
|
42
|
+
-- trick 059 uses for its progress bar. Both live in the SELECT list, not a subquery, so the
|
|
43
|
+
-- guard can still read every output column.
|
|
44
|
+
--
|
|
45
|
+
-- Forward-only, additive (a new read model, no change to any base table). The runner wraps each file
|
|
46
|
+
-- in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
47
|
+
|
|
48
|
+
CREATE VIEW merges_per_day_counts AS
|
|
49
|
+
SELECT
|
|
50
|
+
date(m.at, 'localtime') AS day,
|
|
51
|
+
COUNT(DISTINCT m.pr_key) AS merged
|
|
52
|
+
FROM merges m
|
|
53
|
+
WHERE m.outcome = 'merged'
|
|
54
|
+
GROUP BY date(m.at, 'localtime');
|
|
55
|
+
|
|
56
|
+
CREATE VIEW merges_per_day_view AS
|
|
57
|
+
SELECT
|
|
58
|
+
c.day AS day,
|
|
59
|
+
c.merged AS merged,
|
|
60
|
+
SUM(c.merged) OVER (ORDER BY c.day) AS cumulative,
|
|
61
|
+
CASE
|
|
62
|
+
WHEN c.merged <= 0 OR MAX(c.merged) OVER () <= 0 THEN ''
|
|
63
|
+
ELSE replace(
|
|
64
|
+
substr(
|
|
65
|
+
hex(zeroblob(max(1, CAST(round((c.merged * 1.0 / MAX(c.merged) OVER ()) * 30.0) AS INTEGER)))),
|
|
66
|
+
1,
|
|
67
|
+
max(1, CAST(round((c.merged * 1.0 / MAX(c.merged) OVER ()) * 30.0) AS INTEGER))
|
|
68
|
+
),
|
|
69
|
+
'0', '█'
|
|
70
|
+
)
|
|
71
|
+
END AS bar
|
|
72
|
+
FROM merges_per_day_counts c;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- Lineage read-model: derive the view-expressible identity columns of `lineage_threads` (epic
|
|
2
|
+
-- #412 — "Retire worker-maintained denormalized projections in favour of SQL VIEWs").
|
|
3
|
+
--
|
|
4
|
+
-- `lineage_threads` (037_lineage.sql) is a denormalised read table `pollLineage` (app/lineage.ts)
|
|
5
|
+
-- rewrites every poll pass, stitching request → implementation → PR(s) → convergence → merge into
|
|
6
|
+
-- one narrative per `root_request_key`. Its comment cited "Urban's datasource cannot read a SQL
|
|
7
|
+
-- VIEW" as the sole reason to denormalise; nano-ide#424 removed that constraint (gateway.schema()
|
|
8
|
+
-- now introspects `type IN ('table','view')`), so the parts that are plain rollups of data that
|
|
9
|
+
-- ALREADY exists should be DERIVED, not duplicated (AGENTS.md "Derivation over duplication / no
|
|
10
|
+
-- drift surfaces"), exactly as 059_plan_wave_summary.sql did for the plans wave/delivery rollups.
|
|
11
|
+
--
|
|
12
|
+
-- AUDIT — per column, is it a clean rollup or genuinely procedural?
|
|
13
|
+
--
|
|
14
|
+
-- VIEW-EXPRESSIBLE (pure structural function of which origin table the root matches — no frontier
|
|
15
|
+
-- logic, no representative-PR selection, no formatting — so a plain parseable view reproduces them
|
|
16
|
+
-- EXACTLY as `deriveLineage` does):
|
|
17
|
+
-- • `kind` — 'epic' when the root is a `plans.plan_key`, 'feature' when it is a
|
|
18
|
+
-- `feature_runs.feature_key`, else 'pr' (self-rooted human/webhook PR). The
|
|
19
|
+
-- epic-before-feature precedence mirrors `collectThreads`, which sets the plan
|
|
20
|
+
-- thread after the feature thread for the same key.
|
|
21
|
+
-- • `issue_url` — the matched origin's `issue_url` (`plans`/`feature_runs`), NULL for a
|
|
22
|
+
-- self-rooted PR — exactly `deriveLineage`'s `origin.kind === "pr" ? null : …`.
|
|
23
|
+
-- • `title` — the matched origin's `title` for an epic/feature thread. A self-rooted PR's
|
|
24
|
+
-- title is the PROCEDURAL representative-PR pick, so it falls back to the
|
|
25
|
+
-- poller-written `lineage_threads.title` for kind 'pr' (see below).
|
|
26
|
+
--
|
|
27
|
+
-- PROCEDURAL (multi-stage frontier / ordering logic in the pure `deriveLineage`, which selects a
|
|
28
|
+
-- representative PR — "first non-terminal by pr_key, else last by pr_key" — branches on origin
|
|
29
|
+
-- kind + feature pre-hand-off, rolls epic fan-out up via `deriveDelivery`, and formats round /
|
|
30
|
+
-- slice-count label strings; none of this is a plain no-CTE / no-select-list-subquery view, and
|
|
31
|
+
-- forcing it would risk diverging from the tested derivation): `stage`, `stage_label`,
|
|
32
|
+
-- `process_key`, `active`, plus the membership columns `pr_count` / `pr_keys` (which union a
|
|
33
|
+
-- root's threaded PRs with its origin's own `pr_key` / `plan_tasks.pr_key` and dedupe across
|
|
34
|
+
-- roots — not a clean grouped join) and the self-rooted `title`. These stay written by
|
|
35
|
+
-- `pollLineage` for the wave-1 cleanup task to trim; this view PASSES THEM THROUGH from
|
|
36
|
+
-- `lineage_threads` so the single Lineage grid keeps rendering identically.
|
|
37
|
+
--
|
|
38
|
+
-- The view is a plain `CREATE VIEW <name> AS SELECT … FROM …` — no CTE, no select-list subquery,
|
|
39
|
+
-- every column aliased — so the static pages↔schema contract guard (scripts/pages-contract.test.ts)
|
|
40
|
+
-- can introspect its output columns to whitelist the repointed page. CASE / COALESCE in the select
|
|
41
|
+
-- list are fine (they are not subqueries).
|
|
42
|
+
--
|
|
43
|
+
-- Forward-only, additive: a new read model, no schema change to any base table, no DROP. The runner
|
|
44
|
+
-- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. This task owns
|
|
45
|
+
-- the disjoint migration block 064-069; a single view suffices, so 065-069 are left unused.
|
|
46
|
+
|
|
47
|
+
CREATE VIEW lineage_thread_view AS
|
|
48
|
+
SELECT
|
|
49
|
+
lt.root_request_key AS root_request_key,
|
|
50
|
+
CASE
|
|
51
|
+
WHEN pl.plan_key IS NOT NULL THEN 'epic'
|
|
52
|
+
WHEN fr.feature_key IS NOT NULL THEN 'feature'
|
|
53
|
+
ELSE 'pr'
|
|
54
|
+
END AS kind,
|
|
55
|
+
CASE
|
|
56
|
+
WHEN pl.plan_key IS NOT NULL THEN pl.title
|
|
57
|
+
WHEN fr.feature_key IS NOT NULL THEN fr.title
|
|
58
|
+
ELSE lt.title
|
|
59
|
+
END AS title,
|
|
60
|
+
CASE
|
|
61
|
+
WHEN pl.plan_key IS NOT NULL THEN pl.issue_url
|
|
62
|
+
WHEN fr.feature_key IS NOT NULL THEN fr.issue_url
|
|
63
|
+
ELSE NULL
|
|
64
|
+
END AS issue_url,
|
|
65
|
+
lt.stage AS stage,
|
|
66
|
+
lt.stage_label AS stage_label,
|
|
67
|
+
lt.process_key AS process_key,
|
|
68
|
+
lt.pr_keys AS pr_keys,
|
|
69
|
+
lt.pr_count AS pr_count,
|
|
70
|
+
lt.active AS active,
|
|
71
|
+
lt.created_at AS created_at,
|
|
72
|
+
lt.updated_at AS updated_at
|
|
73
|
+
FROM lineage_threads lt
|
|
74
|
+
LEFT JOIN plans pl ON pl.plan_key = lt.root_request_key
|
|
75
|
+
LEFT JOIN feature_runs fr ON fr.feature_key = lt.root_request_key;
|
|
@@ -172,7 +172,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
172
172
|
],
|
|
173
173
|
edges: [],
|
|
174
174
|
};
|
|
175
|
-
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", escalationSlaTimeout: "PT1H" });
|
|
175
|
+
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", probePollEvery: "PT1S", escalationSlaTimeout: "PT1H" });
|
|
176
176
|
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
177
177
|
await app.settle();
|
|
178
178
|
|
|
@@ -188,6 +188,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
188
188
|
// wait is BOUNDED — its poll budget elapses and it escalates onto a human-completable task, parking
|
|
189
189
|
// for a human rather than silently wedging or falsely resolving.
|
|
190
190
|
assert.ok(!takenFlows(app).some((f) => f.endsWith("->End")), "the wait branch never falsely resolves to End");
|
|
191
|
+
await app.advanceTime(2_100);
|
|
191
192
|
const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.endsWith("__esc"));
|
|
192
193
|
assert.ok(
|
|
193
194
|
esc.length >= 1,
|
|
@@ -77,6 +77,7 @@ function featureVars(overrides: Record<string, unknown>): Record<string, unknown
|
|
|
77
77
|
customInstructions: null,
|
|
78
78
|
readinessProbes: null,
|
|
79
79
|
probeTimeout: null,
|
|
80
|
+
probePollEvery: null,
|
|
80
81
|
gateKey: null,
|
|
81
82
|
resolvedArtifacts: null,
|
|
82
83
|
...overrides,
|
|
@@ -129,6 +130,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
129
130
|
},
|
|
130
131
|
],
|
|
131
132
|
probeTimeout: "PT30M",
|
|
133
|
+
probePollEvery: "PT15S",
|
|
132
134
|
gateKey: "feature-readiness:owner/repo#7",
|
|
133
135
|
}),
|
|
134
136
|
});
|
|
@@ -66,6 +66,7 @@ function planVars(overrides: Record<string, unknown>): Record<string, unknown> {
|
|
|
66
66
|
waveCount: 1,
|
|
67
67
|
readinessProbes: null,
|
|
68
68
|
probeTimeout: null,
|
|
69
|
+
probePollEvery: null,
|
|
69
70
|
gateKey: null,
|
|
70
71
|
resolvedArtifacts: null,
|
|
71
72
|
...overrides,
|
|
@@ -115,6 +116,7 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
|
|
|
115
116
|
variables: planVars({
|
|
116
117
|
readinessProbes: [redProbe()],
|
|
117
118
|
probeTimeout: "PT2S",
|
|
119
|
+
probePollEvery: "PT1S",
|
|
118
120
|
gateKey: "preflight:owner/repo#2",
|
|
119
121
|
}),
|
|
120
122
|
});
|
|
@@ -133,7 +135,8 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
|
|
|
133
135
|
!flows.includes("readiness-preflight->ensure-base-branch"),
|
|
134
136
|
"the gate HOLDS wave 0 — a parked dependent never reaches the fan-out head",
|
|
135
137
|
);
|
|
136
|
-
|
|
138
|
+
await app.advanceTime(2_100);
|
|
139
|
+
// The token is parked on the escalation user task after the engine-owned timeout, not lost.
|
|
137
140
|
const tasks = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
|
|
138
141
|
(t) => t.elementId === "readiness-escalation-pf",
|
|
139
142
|
);
|
|
@@ -153,10 +156,12 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
|
|
|
153
156
|
variables: planVars({
|
|
154
157
|
readinessProbes: [redProbe()],
|
|
155
158
|
probeTimeout: "PT2S",
|
|
159
|
+
probePollEvery: "PT1S",
|
|
156
160
|
gateKey: "preflight:owner/repo#2",
|
|
157
161
|
}),
|
|
158
162
|
});
|
|
159
163
|
await app.settle();
|
|
164
|
+
await app.advanceTime(2_100);
|
|
160
165
|
|
|
161
166
|
const escalations = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
|
|
162
167
|
(t) => t.elementId === "readiness-escalation-pf",
|
|
@@ -196,6 +201,7 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
|
|
|
196
201
|
}),
|
|
197
202
|
});
|
|
198
203
|
await app.settle();
|
|
204
|
+
await app.advanceTime(2_100);
|
|
199
205
|
|
|
200
206
|
// Parked on the escalation, no human acts. Before the SLA it has NOT proceeded.
|
|
201
207
|
const before = takenFlows(app);
|
|
@@ -65,6 +65,7 @@ function planVars(overrides: Record<string, unknown>): Record<string, unknown> {
|
|
|
65
65
|
waveCount: 1,
|
|
66
66
|
readinessProbes: null,
|
|
67
67
|
probeTimeout: null,
|
|
68
|
+
probePollEvery: null,
|
|
68
69
|
gateKey: null,
|
|
69
70
|
resolvedArtifacts: null,
|
|
70
71
|
...overrides,
|
|
@@ -116,6 +117,7 @@ describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #
|
|
|
116
117
|
},
|
|
117
118
|
],
|
|
118
119
|
probeTimeout: "PT30M",
|
|
120
|
+
probePollEvery: "PT15S",
|
|
119
121
|
gateKey: "preflight:owner/repo#2",
|
|
120
122
|
}),
|
|
121
123
|
});
|
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
// The probes are deterministic shell builtins (`true`/`false`) so the flow is hermetic — no
|
|
19
19
|
// network, no GitHub. GitHub transport is still forced offline to match the sibling e2es.
|
|
20
20
|
import assert from "node:assert/strict";
|
|
21
|
-
import {
|
|
22
|
-
import { tmpdir } from "node:os";
|
|
21
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
23
22
|
import { dirname, join, resolve } from "node:path";
|
|
24
23
|
import { after, before, describe, test } from "node:test";
|
|
25
24
|
import { fileURLToPath } from "node:url";
|
|
26
25
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
27
26
|
|
|
28
27
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
|
+
let dbSeq = 0;
|
|
29
29
|
|
|
30
30
|
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
31
31
|
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
@@ -49,7 +49,8 @@ function takenFlows(app: TestApp): string[] {
|
|
|
49
49
|
// Each scenario boots its own app so `takenSequenceFlows` (engine-global + cumulative) reflects
|
|
50
50
|
// exactly one instance's history.
|
|
51
51
|
async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
52
|
-
const dbDir =
|
|
52
|
+
const dbDir = join(APP_ROOT, ".test-artifacts", `nwf-readiness-${process.pid}-${dbSeq++}`);
|
|
53
|
+
mkdirSync(dbDir, { recursive: true });
|
|
53
54
|
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
|
|
54
55
|
return { app, dbDir };
|
|
55
56
|
}
|
|
@@ -79,6 +80,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
79
80
|
probe: { kind: "command", target: "true", poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" } },
|
|
80
81
|
// A long engine timer that must NOT fire — readiness wins the race first.
|
|
81
82
|
probeTimeout: "PT30M",
|
|
83
|
+
probePollEvery: "PT15S",
|
|
82
84
|
onTimeout: "escalate",
|
|
83
85
|
},
|
|
84
86
|
});
|
|
@@ -90,8 +92,8 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
90
92
|
`the gate released on the readiness signal (flows: ${flows.join(", ")})`,
|
|
91
93
|
);
|
|
92
94
|
assert.ok(
|
|
93
|
-
flows.includes("probe->probe-done"),
|
|
94
|
-
"the probe branch settled after publishing the readiness signal",
|
|
95
|
+
flows.includes("probe-loop->probe-done"),
|
|
96
|
+
"the probe loop branch settled after publishing the readiness signal",
|
|
95
97
|
);
|
|
96
98
|
// The gate never timed out — no escalation userTask exists.
|
|
97
99
|
const tasks = await app.engine.searchUserTasks({});
|
|
@@ -115,15 +117,16 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
115
117
|
gateKey: "gate-timeout-1",
|
|
116
118
|
// `false` is never ready; a tiny local budget makes the worker exhaust fast (real time),
|
|
117
119
|
// leaving the ENGINE timer as the authoritative bound.
|
|
118
|
-
probe: { kind: "command", target: "false", poll: { everyMs:
|
|
120
|
+
probe: { kind: "command", target: "false", poll: { everyMs: 15_000, timeoutMs: 60_000, backoff: "fixed" } },
|
|
119
121
|
probeTimeout: "PT1M",
|
|
122
|
+
probePollEvery: "PT15S",
|
|
120
123
|
onTimeout: "escalate",
|
|
121
124
|
},
|
|
122
125
|
});
|
|
123
126
|
await app.settle();
|
|
124
127
|
|
|
125
|
-
// The wait has NOT hung and has NOT yet escalated: the probe
|
|
126
|
-
//
|
|
128
|
+
// The wait has NOT hung and has NOT yet escalated: the first single-shot probe returned not-ready,
|
|
129
|
+
// and the retry cadence is parked on the engine-owned poll timer.
|
|
127
130
|
const beforeTimer = await app.engine.searchUserTasks({ processInstanceKey });
|
|
128
131
|
assert.equal(
|
|
129
132
|
beforeTimer.filter((t) => t.elementId === "readiness-escalation").length,
|
|
@@ -133,9 +136,18 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
133
136
|
const beforeFlows = takenFlows(app);
|
|
134
137
|
assert.ok(!beforeFlows.includes("wait-ready->gate-ready"), "a never-green probe never releases as ready");
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
await app.advanceTime(15_000);
|
|
140
|
+
const afterPoll = takenFlows(app);
|
|
141
|
+
assert.ok(afterPoll.includes("wait-poll->probe"), "the engine timer, not a worker sleep loop, schedules the next probe");
|
|
142
|
+
assert.equal(
|
|
143
|
+
(await app.engine.searchUserTasks({ processInstanceKey })).filter((t) => t.elementId === "readiness-escalation").length,
|
|
144
|
+
0,
|
|
145
|
+
"one poll interval only re-probes; it does not consume the timeout",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Advancing past the engine timer is the ONLY thing that ends the wait. The timeout arm routes
|
|
149
|
+
// through one last empirical probe before the event-based gateway timer opens escalation.
|
|
150
|
+
await app.advanceTime(46_000);
|
|
139
151
|
|
|
140
152
|
const afterFlows = takenFlows(app);
|
|
141
153
|
assert.ok(
|
|
@@ -171,6 +183,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
171
183
|
gateKey: "gate-abandon-1",
|
|
172
184
|
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
173
185
|
probeTimeout: "PT1M",
|
|
186
|
+
probePollEvery: "PT15S",
|
|
174
187
|
onTimeout: "escalate",
|
|
175
188
|
},
|
|
176
189
|
});
|
|
@@ -209,6 +222,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
209
222
|
gateKey: "gate-continue-1",
|
|
210
223
|
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
211
224
|
probeTimeout: "PT1M",
|
|
225
|
+
probePollEvery: "PT15S",
|
|
212
226
|
onTimeout: "continue",
|
|
213
227
|
},
|
|
214
228
|
});
|
|
@@ -236,6 +250,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
236
250
|
// Neither probe.onTimeout nor a top-level onTimeout is declared.
|
|
237
251
|
probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
238
252
|
probeTimeout: "PT1M",
|
|
253
|
+
probePollEvery: "PT15S",
|
|
239
254
|
},
|
|
240
255
|
});
|
|
241
256
|
await app.settle();
|
|
@@ -266,6 +281,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
266
281
|
// The descriptor asks to continue; a stale top-level onTimeout says escalate. probe wins.
|
|
267
282
|
probe: { kind: "command", target: "false", onTimeout: "continue", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
|
|
268
283
|
probeTimeout: "PT1M",
|
|
284
|
+
probePollEvery: "PT15S",
|
|
269
285
|
onTimeout: "escalate",
|
|
270
286
|
},
|
|
271
287
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.120.1",
|
|
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",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"data": {
|
|
93
93
|
"kind": "datasource",
|
|
94
94
|
"source": "app",
|
|
95
|
-
"table": "
|
|
95
|
+
"table": "plan_read_model",
|
|
96
96
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
97
97
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
98
98
|
},
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
"data": {
|
|
138
138
|
"kind": "datasource",
|
|
139
139
|
"source": "app",
|
|
140
|
-
"table": "
|
|
140
|
+
"table": "plan_read_model",
|
|
141
141
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
142
142
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
143
143
|
},
|
package/pages/lineage.page.json
CHANGED
package/pages/overview.page.json
CHANGED
package/pages/velocity.page.json
CHANGED