@nanobpm/nano-workforce 0.122.0 → 0.123.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 +14 -0
- package/app/delivery.test.ts +2 -134
- package/app/feature.ts +30 -128
- package/app/featureReadModel.test.ts +210 -0
- package/app/plan.ts +26 -96
- package/app/plansReadModel.test.ts +112 -5
- package/app/service.ts +2 -71
- package/db/migrations/073_feature_read_model.sql +80 -0
- package/db/migrations/074_plan_read_model_derive_bucket.sql +74 -0
- package/e2e/readiness-gate.e2e.ts +41 -0
- package/main.ts +5 -4
- package/operations/acknowledgeDone.test.ts +18 -16
- package/operations/acknowledgeDone.ts +10 -8
- package/operations/acknowledgeEpic.test.ts +24 -31
- package/operations/acknowledgeEpic.ts +9 -8
- package/package.json +1 -1
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +1 -1
- package/workers/readiness-probe/worker.ts +29 -1
- package/workers/record-results/worker.test.ts +7 -4
- package/app/featureGateway.test.ts +0 -202
- package/app/planGateway.test.ts +0 -112
|
@@ -23,6 +23,34 @@ import { dirname, join, resolve } from "node:path";
|
|
|
23
23
|
import { after, before, describe, test } from "node:test";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
26
|
+
import type { CommandResult, ProbeExec } from "../app/readiness.ts";
|
|
27
|
+
import { __setProbeExecForTest } from "../workers/readiness-probe/worker.ts";
|
|
28
|
+
|
|
29
|
+
// A synchronous, in-memory ProbeExec so the probe resolves WITHIN the testkit's virtual-clock drain
|
|
30
|
+
// fixpoint instead of spawning a REAL subprocess (real-time work `settle()` cannot deterministically
|
|
31
|
+
// await — issue #450). It maps the hermetic shell builtins these scenarios use to a deterministic
|
|
32
|
+
// `CommandResult` — `true` → exit 0 (green), `false` → exit 1 (never green) — mirroring the real
|
|
33
|
+
// commands' semantics exactly, but with zero real time. Any OTHER command, or any HTTP call, is an
|
|
34
|
+
// unintended probe escape: because `probeSingleShot` catches a thrown/rejected probe error and folds
|
|
35
|
+
// it into a silent "not ready", an escape would otherwise be INVISIBLE and could let a bounded
|
|
36
|
+
// not-ready scenario still pass, masking a regression (reviewer note). So we record every escape and
|
|
37
|
+
// assert none occurred in teardown, failing the suite loudly instead of swallowing it.
|
|
38
|
+
const unexpectedProbeIO: string[] = [];
|
|
39
|
+
const deterministicExec: ProbeExec = {
|
|
40
|
+
run(command: string): Promise<CommandResult> {
|
|
41
|
+
const cmd = command.trim();
|
|
42
|
+
if (cmd !== "true" && cmd !== "false") {
|
|
43
|
+
unexpectedProbeIO.push(`command: ${cmd}`);
|
|
44
|
+
return Promise.resolve({ code: 127, stdout: "", stderr: "" });
|
|
45
|
+
}
|
|
46
|
+
const code = cmd === "true" ? 0 : 1;
|
|
47
|
+
return Promise.resolve({ code, stdout: "", stderr: "" });
|
|
48
|
+
},
|
|
49
|
+
httpGet(url: string): Promise<never> {
|
|
50
|
+
unexpectedProbeIO.push(`http: ${url}`);
|
|
51
|
+
return Promise.reject(new Error(`readiness-gate e2e: unexpected real HTTP probe (command probes only)`));
|
|
52
|
+
},
|
|
53
|
+
};
|
|
26
54
|
|
|
27
55
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
56
|
let dbSeq = 0;
|
|
@@ -32,6 +60,7 @@ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
|
32
60
|
GITHUB_TOKEN: "",
|
|
33
61
|
};
|
|
34
62
|
const savedEnv = new Map<string, string | undefined>();
|
|
63
|
+
let savedProbeExec: ProbeExec | undefined;
|
|
35
64
|
|
|
36
65
|
interface TakenFlow {
|
|
37
66
|
from: string;
|
|
@@ -61,6 +90,11 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
61
90
|
savedEnv.set(k, process.env[k]);
|
|
62
91
|
process.env[k] = v;
|
|
63
92
|
}
|
|
93
|
+
// Inject the deterministic exec so the probe never spawns a real subprocess under the virtual
|
|
94
|
+
// clock (issue #450). Scenario-agnostic: it maps each scenario's command by string. Capture the
|
|
95
|
+
// prior override and restore exactly that in teardown, so the seam is restored to its real prior
|
|
96
|
+
// state rather than assuming production.
|
|
97
|
+
savedProbeExec = __setProbeExecForTest(deterministicExec);
|
|
64
98
|
});
|
|
65
99
|
|
|
66
100
|
after(() => {
|
|
@@ -68,6 +102,13 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
|
|
|
68
102
|
if (v === undefined) delete process.env[k];
|
|
69
103
|
else process.env[k] = v;
|
|
70
104
|
}
|
|
105
|
+
// Restore the prior exec — the seam must never outlive this suite.
|
|
106
|
+
__setProbeExecForTest(savedProbeExec);
|
|
107
|
+
// Fail loudly if the probe ever escaped the hermetic `true`/`false` builtins (an unexpected
|
|
108
|
+
// command or any HTTP call). `probeSingleShot` folds a probe error into a silent "not ready", so
|
|
109
|
+
// without this assertion an escape would be invisible and could let a bounded not-ready scenario
|
|
110
|
+
// still pass, masking a regression.
|
|
111
|
+
assert.deepEqual(unexpectedProbeIO, [], `readiness-gate e2e saw unexpected probe I/O: ${unexpectedProbeIO.join(", ")}`);
|
|
71
112
|
});
|
|
72
113
|
|
|
73
114
|
test("READY: a green probe publishes readiness-ready and the gate releases through wait-ready → gate-ready", async () => {
|
package/main.ts
CHANGED
|
@@ -109,10 +109,11 @@ async function pollLoop(): Promise<void> {
|
|
|
109
109
|
}
|
|
110
110
|
if (!shuttingDown) pollTimer = setTimeout(() => void pollLoop(), POLL_MS);
|
|
111
111
|
}
|
|
112
|
-
// Run the first pass immediately at boot (not after POLL_MS) so the
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
112
|
+
// Run the first pass immediately at boot (not after POLL_MS) so the read-model pollers (delivery,
|
|
113
|
+
// wait-gate, promotion, lineage) reconcile before the UI is relied upon rather than after up to
|
|
114
|
+
// POLL_MS. The Feature Runs grid/tabs now filter the `feature_read_model` VIEW's derived `stage`/
|
|
115
|
+
// `list_bucket` (issue #439), computed from each row's own `status`, so no boot-time backfill of a
|
|
116
|
+
// stored projection is required.
|
|
116
117
|
if (app.data) void pollLoop();
|
|
117
118
|
|
|
118
119
|
async function drainAndExit(): Promise<void> {
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
// Tests for the POST /app/api/actions/acknowledge-done operation `acknowledgeDone` (issue #254 §5).
|
|
2
|
-
// The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at
|
|
3
|
-
//
|
|
4
|
-
// History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is
|
|
5
|
-
//
|
|
2
|
+
// The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at`, which
|
|
3
|
+
// the `feature_read_model` VIEW (073, issue #439) derives into `list_bucket` = 'history', dropping the
|
|
4
|
+
// run from Active into History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is
|
|
5
|
+
// not parked). Since `list_bucket` is now a VIEW over `status`/`acknowledged_at` (no stored column),
|
|
6
|
+
// these tests assert the operation's real write — the `acknowledged_at` stamp — and cross-check the
|
|
7
|
+
// resulting bucket through the pure `deriveListBucket` oracle the VIEW mirrors.
|
|
6
8
|
import { test } from "node:test";
|
|
7
9
|
import { assertEquals } from "#test-assert";
|
|
8
10
|
import type { AppApi } from "@nanobpm/urban";
|
|
9
|
-
import {
|
|
11
|
+
import { deriveListBucket } from "../app/stage.ts";
|
|
10
12
|
import { noopLog } from "../test/log.ts";
|
|
11
13
|
import handler from "./acknowledgeDone.ts";
|
|
12
14
|
|
|
13
|
-
// An in-memory data layer wired through the
|
|
14
|
-
//
|
|
15
|
+
// An in-memory data layer wired through the `featureRuns` gateway (now a plain record table), so the
|
|
16
|
+
// test exercises the operation's write path exactly as production does.
|
|
15
17
|
function memApp(seed: any[]): { app: AppApi; rows: any[] } {
|
|
16
18
|
const stores: Record<string, any[]> = { feature_runs: seed };
|
|
17
19
|
function tbl(name: string, pk = "id") {
|
|
@@ -49,30 +51,30 @@ async function call(app: AppApi, body: unknown) {
|
|
|
49
51
|
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
test("acknowledge-done: stamps acknowledged_at and
|
|
54
|
+
test("acknowledge-done: stamps acknowledged_at and (via the VIEW) buckets a terminal row into History", async () => {
|
|
53
55
|
const { app, rows } = memApp([{ feature_key: "o/r#1", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
assertEquals(rows[0].list_bucket, "active");
|
|
56
|
+
// Before dismissal a terminal-but-unacknowledged run reads as Active through the VIEW.
|
|
57
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
|
|
57
58
|
|
|
58
59
|
const res = await call(app, { feature_key: "o/r#1" });
|
|
59
60
|
|
|
60
61
|
assertEquals(res.status, 200);
|
|
61
62
|
assertEquals(res.body.ok, true);
|
|
62
63
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
63
|
-
|
|
64
|
+
// The op's only write is the stamp; the VIEW derives 'history' from (terminal status + acknowledged).
|
|
65
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
|
|
64
66
|
});
|
|
65
67
|
|
|
66
68
|
test("acknowledge-done: idempotent-safe — re-acknowledging keeps the row in History", async () => {
|
|
67
69
|
const { app, rows } = memApp([{ feature_key: "o/r#2", status: "failed", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
68
70
|
const first = await call(app, { feature_key: "o/r#2" });
|
|
69
71
|
assertEquals(first.status, 200);
|
|
70
|
-
assertEquals(rows[0].
|
|
72
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
|
|
71
73
|
const firstStamp = rows[0].acknowledged_at;
|
|
72
74
|
// Re-acknowledge — still 200, still history.
|
|
73
75
|
const second = await call(app, { feature_key: "o/r#2" });
|
|
74
76
|
assertEquals(second.status, 200);
|
|
75
|
-
assertEquals(rows[0].
|
|
77
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
|
|
76
78
|
assertEquals(typeof firstStamp, "string");
|
|
77
79
|
});
|
|
78
80
|
|
|
@@ -92,12 +94,12 @@ test("acknowledge-done: no such feature run → 404", async () => {
|
|
|
92
94
|
|
|
93
95
|
test("acknowledge-done: a non-terminal run → 409, no acknowledged_at stamped", async () => {
|
|
94
96
|
const { app, rows } = memApp([{ feature_key: "o/r#live", status: "running", converge: 1, auto_merge: 1, acknowledged_at: null }]);
|
|
95
|
-
await featureRuns(app.data).update("o/r#live", { status: "running" });
|
|
96
97
|
const res = await call(app, { feature_key: "o/r#live" });
|
|
97
98
|
assertEquals(res.status, 409);
|
|
98
99
|
assertEquals(res.body.ok, false);
|
|
99
100
|
assertEquals(rows[0].acknowledged_at, null);
|
|
100
|
-
|
|
101
|
+
// A live run stays Active through the VIEW (no stamp → not History).
|
|
102
|
+
assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
|
|
101
103
|
});
|
|
102
104
|
|
|
103
105
|
test("acknowledge-done: a converging (redispatch-terminal but live) run → 409", async () => {
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
// run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the primary
|
|
4
4
|
// Active list into History. It is the DONE twin of `acknowledgeBlocked` — but a terminal run is NOT
|
|
5
5
|
// parked at a user task, so this op does NOT complete a user task and touches no engine/ledger: it
|
|
6
|
-
// simply stamps `acknowledged_at` on the row via the feature_runs
|
|
6
|
+
// simply stamps `acknowledged_at` on the row via the plain `feature_runs` record table (the projecting
|
|
7
|
+
// gateway this PR retired). It rejects (409) a run that
|
|
7
8
|
// is not yet truly terminal, so it can never pre-seed the tick-off on a still-live run.
|
|
8
9
|
//
|
|
9
|
-
// The
|
|
10
|
-
// `
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// The `list_bucket` partition is DERIVED by the `feature_read_model` VIEW (073, issue #439) from
|
|
11
|
+
// `status` + `acknowledged_at` — a terminal row with `acknowledged_at` set reads as 'history' — so
|
|
12
|
+
// this op NEVER writes `list_bucket` (or any projection): stamping `acknowledged_at` is the whole
|
|
13
|
+
// contract. Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps the
|
|
14
|
+
// timestamp and keeps the row in History.
|
|
13
15
|
|
|
14
16
|
import { featureRuns } from "../app/feature.ts";
|
|
15
17
|
import { STAGE_DONE_STATUSES } from "../app/stage.ts";
|
|
@@ -42,9 +44,9 @@ export default defineOperation("acknowledgeDone", async ({ body }, app) => {
|
|
|
42
44
|
return { status: 409, body: { ok: false, error: "feature run is not terminal" } };
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
// Stamp the dismissal.
|
|
46
|
-
// terminal
|
|
47
|
-
// History.
|
|
47
|
+
// Stamp the dismissal. `list_bucket` is derived by the `feature_read_model` VIEW (→ 'history' for a
|
|
48
|
+
// terminal, acknowledged row), so we never hand-set it here. Idempotent: re-acknowledging re-stamps
|
|
49
|
+
// and stays in History.
|
|
48
50
|
const now = new Date().toISOString();
|
|
49
51
|
await runs.update(featureKey, { acknowledged_at: now, updated_at: now });
|
|
50
52
|
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
// Tests for the POST /app/api/actions/acknowledge-epic operation `acknowledgeEpic` (issue #298).
|
|
2
2
|
// The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (delivery=landed) or
|
|
3
3
|
// resolved-not-landed (delivery=null); only still-`converging` epics are rejected. It stamps
|
|
4
|
-
// `acknowledged_at
|
|
5
|
-
// `ack_open`
|
|
6
|
-
// it completes NO user task (a resolved epic is not parked). The epic twin of
|
|
4
|
+
// `acknowledged_at`, which the `plan_read_model` VIEW (074, issue #439) derives into `list_bucket` =
|
|
5
|
+
// 'history' and `ack_open` = 0, dropping the resolved epic from Active into History. Unlike
|
|
6
|
+
// acknowledge-blocked it completes NO user task (a resolved epic is not parked). The epic twin of
|
|
7
|
+
// acknowledge-done.
|
|
7
8
|
//
|
|
8
9
|
// Since epic #412 retired the stored `plans.delivery` column, the op derives the delivery signal at
|
|
9
10
|
// READ TIME (`derivePlanDelivery` → the pure `deriveDelivery`) by joining the epic's slice
|
|
10
11
|
// `plan_tasks.pr_key` → `pull_requests.status`. So these tests seed `plan_tasks` + `pull_requests`
|
|
11
|
-
// (not a `plans.delivery` column) to model a landed / converging / resolved-not-landed epic.
|
|
12
|
+
// (not a `plans.delivery` column) to model a landed / converging / resolved-not-landed epic. Because
|
|
13
|
+
// `list_bucket`/`ack_open` are now a VIEW (no stored column), the tests assert the operation's real
|
|
14
|
+
// write — the `acknowledged_at` stamp and the 200/409 gate — and cross-check the resulting bucket
|
|
15
|
+
// through the pure `deriveEpicBucket` / `epicIsAcknowledgeable` oracles the VIEW mirrors.
|
|
12
16
|
import { test } from "node:test";
|
|
13
17
|
import { assertEquals } from "#test-assert";
|
|
14
18
|
import type { AppApi } from "@nanobpm/urban";
|
|
15
|
-
import {
|
|
19
|
+
import { deriveEpicBucket, epicIsAcknowledgeable } from "../app/delivery.ts";
|
|
16
20
|
import { noopLog } from "../test/log.ts";
|
|
17
21
|
import handler from "./acknowledgeEpic.ts";
|
|
18
22
|
|
|
19
|
-
// An in-memory data layer wired through the
|
|
20
|
-
//
|
|
21
|
-
// surfaces (`plan_tasks` / `pull_requests`) the read-time delivery derivation reads.
|
|
23
|
+
// An in-memory data layer wired through the `plans` gateway (now a plain record table). `extra` seeds
|
|
24
|
+
// the join surfaces (`plan_tasks` / `pull_requests`) the read-time delivery derivation reads.
|
|
22
25
|
function memApp(
|
|
23
26
|
seed: any[],
|
|
24
27
|
extra: Record<string, any[]> = {},
|
|
@@ -59,13 +62,7 @@ async function call(app: AppApi, body: unknown) {
|
|
|
59
62
|
return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
* delivery-free gateway projection, mirroring the last real write before the op runs. */
|
|
64
|
-
async function project(app: AppApi, key: string, status: string) {
|
|
65
|
-
await plans(app.data).update(key, { status });
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history' on a landed epic", async () => {
|
|
65
|
+
test("acknowledge-epic: stamps acknowledged_at and (via the VIEW) buckets a landed epic into History", async () => {
|
|
69
66
|
const { app, rows } = memApp(
|
|
70
67
|
[{ plan_key: "o/r#1", status: "done", acknowledged_at: null }],
|
|
71
68
|
{
|
|
@@ -73,17 +70,17 @@ test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history
|
|
|
73
70
|
pull_requests: [{ pr_key: "o/r#100", status: "merged" }], // landed
|
|
74
71
|
},
|
|
75
72
|
);
|
|
76
|
-
|
|
77
|
-
assertEquals(rows[0].
|
|
78
|
-
assertEquals(
|
|
73
|
+
// Before dismissal a landed-but-unacknowledged epic reads as Active with Dismiss open through the VIEW.
|
|
74
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "active");
|
|
75
|
+
assertEquals(epicIsAcknowledgeable("done", "landed"), true);
|
|
79
76
|
|
|
80
77
|
const res = await call(app, { plan_key: "o/r#1" });
|
|
81
78
|
|
|
82
79
|
assertEquals(res.status, 200);
|
|
83
80
|
assertEquals(res.body.ok, true);
|
|
84
81
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
85
|
-
|
|
86
|
-
assertEquals(rows[0].
|
|
82
|
+
// The op's only write is the stamp; the VIEW derives 'history' + ack_open 0 from the acknowledged row.
|
|
83
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
|
|
87
84
|
});
|
|
88
85
|
|
|
89
86
|
test("acknowledge-epic: a still-converging epic is rejected (409) and stays Active", async () => {
|
|
@@ -100,15 +97,14 @@ test("acknowledge-epic: a still-converging epic is rejected (409) and stays Acti
|
|
|
100
97
|
],
|
|
101
98
|
},
|
|
102
99
|
);
|
|
103
|
-
await project(app, "o/r#2", "done");
|
|
104
100
|
|
|
105
101
|
const res = await call(app, { plan_key: "o/r#2" });
|
|
106
102
|
|
|
107
103
|
assertEquals(res.status, 409);
|
|
108
104
|
assertEquals(res.body.ok, false);
|
|
109
|
-
// Untouched: no premature acknowledged_at
|
|
105
|
+
// Untouched: no premature acknowledged_at; still Active through the VIEW (converging → active).
|
|
110
106
|
assertEquals(rows[0].acknowledged_at, null);
|
|
111
|
-
assertEquals(rows[0].
|
|
107
|
+
assertEquals(deriveEpicBucket("done", "converging", rows[0].acknowledged_at), "active");
|
|
112
108
|
});
|
|
113
109
|
|
|
114
110
|
test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (200) and flips to History", async () => {
|
|
@@ -125,17 +121,15 @@ test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (
|
|
|
125
121
|
],
|
|
126
122
|
},
|
|
127
123
|
);
|
|
128
|
-
|
|
129
|
-
assertEquals(
|
|
130
|
-
assertEquals(rows[0].ack_open, 1);
|
|
124
|
+
assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "active");
|
|
125
|
+
assertEquals(epicIsAcknowledgeable("done", null), true);
|
|
131
126
|
|
|
132
127
|
const res = await call(app, { plan_key: "o/r#2b" });
|
|
133
128
|
|
|
134
129
|
assertEquals(res.status, 200);
|
|
135
130
|
assertEquals(res.body.ok, true);
|
|
136
131
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
137
|
-
assertEquals(rows[0].
|
|
138
|
-
assertEquals(rows[0].ack_open, 0);
|
|
132
|
+
assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "history");
|
|
139
133
|
});
|
|
140
134
|
|
|
141
135
|
test("acknowledge-epic: a live (dispatched) epic is rejected (409)", async () => {
|
|
@@ -164,15 +158,14 @@ test("acknowledge-epic: idempotent — re-acknowledging a landed epic keeps it i
|
|
|
164
158
|
pull_requests: [{ pr_key: "o/r#500", status: "merged" }], // landed
|
|
165
159
|
},
|
|
166
160
|
);
|
|
167
|
-
await project(app, "o/r#5", "done");
|
|
168
161
|
|
|
169
162
|
assertEquals((await call(app, { plan_key: "o/r#5" })).status, 200);
|
|
170
163
|
const firstStamp = rows[0].acknowledged_at;
|
|
171
|
-
assertEquals(rows[0].
|
|
164
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
|
|
172
165
|
|
|
173
166
|
const res2 = await call(app, { plan_key: "o/r#5" });
|
|
174
167
|
assertEquals(res2.status, 200);
|
|
175
|
-
assertEquals(rows[0].
|
|
168
|
+
assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
|
|
176
169
|
// Re-stamped (a fresh timestamp) but still resolved.
|
|
177
170
|
assertEquals(typeof rows[0].acknowledged_at, "string");
|
|
178
171
|
void firstStamp;
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// resolved-not-landed) directly from the Epic / Overview pages so it drops out of the Active epic list
|
|
5
5
|
// into History. It is the epic twin of `acknowledgeDone` (the feature-run tick-off) — a resolved epic
|
|
6
6
|
// is NOT parked at a user task, so this op completes no user task and touches no engine/ledger: it
|
|
7
|
-
// simply stamps `acknowledged_at` on the `plans` row
|
|
7
|
+
// simply stamps `acknowledged_at` on the `plans` row.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// the
|
|
9
|
+
// `list_bucket`/`ack_open` are DERIVED by the `plan_read_model` VIEW (074, issue #439) from
|
|
10
|
+
// `status` + `acknowledged_at` + the derived `plan_delivery` signal — a landed, now-acknowledged epic
|
|
11
|
+
// reads `list_bucket` = 'history' and `ack_open` = 0 — so this op NEVER writes a derived projection.
|
|
12
|
+
// Keyed on the row's `plan_key`. Idempotent-safe: re-acknowledging re-stamps the timestamp and keeps
|
|
13
|
+
// the row in History.
|
|
13
14
|
//
|
|
14
15
|
// It rejects (409) an epic that is NOT yet resolved — i.e. anything the `epicIsAcknowledgeable`
|
|
15
16
|
// guard refuses: a non-`done` status (`planning`/`dispatched`), or `done` but still `converging`. A
|
|
@@ -56,9 +57,9 @@ export default defineOperation("acknowledgeEpic", async ({ body }, app) => {
|
|
|
56
57
|
return { status: 409, body: { ok: false, error: "epic is not resolved" } };
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
// Stamp the dismissal.
|
|
60
|
-
// the
|
|
61
|
-
// in History.
|
|
60
|
+
// Stamp the dismissal. `list_bucket` (→ 'history') and `ack_open` (→ 0) are derived by the
|
|
61
|
+
// `plan_read_model` VIEW from the resolved, now-acknowledged row, so we never hand-set them here.
|
|
62
|
+
// Idempotent: re-acknowledging re-stamps and stays in History.
|
|
62
63
|
const now = new Date().toISOString();
|
|
63
64
|
await table.update(planKey, { acknowledged_at: now, updated_at: now });
|
|
64
65
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.123.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",
|
package/pages/epic.page.json
CHANGED
package/pages/feature.page.json
CHANGED
|
@@ -103,10 +103,38 @@ export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }
|
|
|
103
103
|
return { gateKey, probeTimeout };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// ── Deterministic-exec test seam (issue #450) ───────────────────────────────────────────────────
|
|
107
|
+
// The probe's I/O runs through {@link defaultProbeExec} — a REAL `node:child_process` subprocess (for
|
|
108
|
+
// `command` probes) / `fetch` (for `http`). A real subprocess is real-time async work that spans
|
|
109
|
+
// multiple macrotasks, which the urban-testkit's *virtual-clock* `settle()`/`drain()` fixpoint cannot
|
|
110
|
+
// deterministically await: it can return before the probe publishes `readiness-ready`, so a gate-flow
|
|
111
|
+
// e2e races the subprocess (the same fire-and-forget-across-teardown hazard behind the testkit
|
|
112
|
+
// use-after-free, nano-ide#446). An e2e under the virtual clock injects a synchronous, in-memory
|
|
113
|
+
// `ProbeExec` here so the probe resolves *within* the drain fixpoint — no real spawn, no wall-clock
|
|
114
|
+
// race — while production leaves the override unset and uses `defaultProbeExec()`. Deliberately a
|
|
115
|
+
// process-scoped seam (not urban worker DI, which `bootTestApp` does not expose per-worker); the e2e
|
|
116
|
+
// sets it before creating instances and clears it in teardown so it can never leak into production.
|
|
117
|
+
let probeExecOverride: ProbeExec | undefined;
|
|
118
|
+
|
|
119
|
+
/** Test-only seam: inject a deterministic {@link ProbeExec} for e2es driven by the virtual clock, or
|
|
120
|
+
* pass `undefined` to restore the production {@link defaultProbeExec}. Never called in production.
|
|
121
|
+
* Returns the PREVIOUS override so a caller can narrowly scope its change with `try/finally`
|
|
122
|
+
* (restore the prior value rather than assuming production), keeping the seam safe even if the set
|
|
123
|
+
* and clear are not lexically paired. */
|
|
124
|
+
export function __setProbeExecForTest(exec: ProbeExec | undefined): ProbeExec | undefined {
|
|
125
|
+
const previous = probeExecOverride;
|
|
126
|
+
probeExecOverride = exec;
|
|
127
|
+
return previous;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveProbeExec(): ProbeExec {
|
|
131
|
+
return probeExecOverride ?? defaultProbeExec();
|
|
132
|
+
}
|
|
133
|
+
|
|
106
134
|
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
107
135
|
const probe = parseProbe(job.variables.probe);
|
|
108
136
|
const { gateKey } = readGateVars(job.variables);
|
|
109
|
-
const exec =
|
|
137
|
+
const exec = resolveProbeExec();
|
|
110
138
|
const result = await probeSingleShot({
|
|
111
139
|
probe,
|
|
112
140
|
exec,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { test } from "node:test";
|
|
10
10
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
11
11
|
import { BpmnError } from "@nanobpm/urban";
|
|
12
|
+
import { deriveEpicBucket } from "../../app/delivery.ts";
|
|
12
13
|
import { noopLog } from "../../test/log.ts";
|
|
13
14
|
import handler from "./worker.ts";
|
|
14
15
|
import type { PlanTaskStatus } from "../../app/plan.ts";
|
|
@@ -77,8 +78,9 @@ test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () =
|
|
|
77
78
|
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
78
79
|
assertEquals(plan.status, "failed");
|
|
79
80
|
assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
|
|
80
|
-
//
|
|
81
|
-
|
|
81
|
+
// `list_bucket` is derived by the `plan_read_model` VIEW (074): a failed epic reads as History
|
|
82
|
+
// (no tick-off needed). Cross-checked against the pure `deriveEpicBucket` oracle the VIEW mirrors.
|
|
83
|
+
assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "history");
|
|
82
84
|
});
|
|
83
85
|
|
|
84
86
|
test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
|
|
@@ -102,6 +104,7 @@ test("at least one opened PR finalizes cleanly (no throw)", async () => {
|
|
|
102
104
|
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
103
105
|
assertEquals(plan.status, "done");
|
|
104
106
|
assertEquals(plan.outcome, "1 PR(s) dispatched to convergence");
|
|
105
|
-
// A just-`done` epic (delivery not yet
|
|
106
|
-
|
|
107
|
+
// A just-`done` epic (delivery not yet converging, unacknowledged) reads as Active through the VIEW —
|
|
108
|
+
// it must not vanish (#298). Cross-checked against the pure `deriveEpicBucket` oracle.
|
|
109
|
+
assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "active");
|
|
107
110
|
});
|