@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.
@@ -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 one-shot feature-stage backfill
113
- // runs before the UI is relied upon the Feature Runs grid/tabs filter on the stored `list_bucket`
114
- // projection, which is NULL on legacy rows until `backfillFeatureStages()` runs inside `pollOnce()`.
115
- // Deferring the first pass would leave those rows missing from Active/History for up to POLL_MS.
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` via the
3
- // feature_runs gateway, which recomputes `list_bucket` to 'history', dropping the run from Active into
4
- // History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is not parked). Mirrors
5
- // the acknowledge-blocked twin's shape.
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 { featureRuns } from "../app/feature.ts";
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 REAL featureRuns gateway proxy, so the test exercises the
14
- // gateway's list_bucket projection exactly as production does.
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 flips list_bucket to 'history' on a terminal row", async () => {
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
- // Seed the projection as the gateway would have on the last write (terminal, unacknowledged → active).
55
- await featureRuns(app.data).update("o/r#1", { status: "merged" });
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
- assertEquals(rows[0].list_bucket, "history");
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].list_bucket, "history");
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].list_bucket, "history");
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
- assertEquals(rows[0].list_bucket, "active");
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 gateway. It rejects (409) a run that
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 gateway (app/feature.ts) recomputes `list_bucket` on that write a terminal row with
10
- // `acknowledged_at` set flips to 'history' so this op NEVER hand-sets `list_bucket` (or any other
11
- // projection). Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps
12
- // the timestamp and keeps the row in History.
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. The gateway recomputes `list_bucket` from the merged row (→ 'history' for a
46
- // terminal run), so we never hand-set it here. Idempotent: re-acknowledging re-stamps and stays in
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` via the plans gateway, which recomputes `list_bucket` to 'history' (and
5
- // `ack_open` to 0), dropping the resolved epic from Active into History. Unlike acknowledge-blocked
6
- // it completes NO user task (a resolved epic is not parked). The epic twin of acknowledge-done.
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 { plans } from "../app/plan.ts";
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 REAL plans gateway proxy, so the test exercises the
20
- // gateway's list_bucket/ack_open projection exactly as production does. `extra` seeds the join
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
- /** Fire a projecting gateway write (re-writing `status`) so `list_bucket`/`ack_open` reflect the
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
- await project(app, "o/r#1", "done");
77
- assertEquals(rows[0].list_bucket, "active");
78
- assertEquals(rows[0].ack_open, 1);
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
- assertEquals(rows[0].list_bucket, "history");
86
- assertEquals(rows[0].ack_open, 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, still Active.
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].list_bucket, "active");
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
- await project(app, "o/r#2b", "done");
129
- assertEquals(rows[0].list_bucket, "active");
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].list_bucket, "history");
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].list_bucket, "history");
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].list_bucket, "history");
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 via the plans gateway.
7
+ // simply stamps `acknowledged_at` on the `plans` row.
8
8
  //
9
- // The gateway (app/plan.ts) recomputes `list_bucket`/`ack_open` on that write a landed, now-
10
- // acknowledged epic flips `list_bucket` to 'history' and `ack_open` to 0 so this op NEVER hand-sets
11
- // a derived projection. Keyed on the row's `plan_key`. Idempotent-safe: re-acknowledging re-stamps
12
- // the timestamp and keeps the row in History.
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` signala 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. The gateway recomputes `list_bucket` (→ 'history') and `ack_open` (→ 0) from
60
- // the merged row, so we never hand-set them here. Idempotent: re-acknowledging re-stamps and stays
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.122.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",
@@ -99,7 +99,7 @@
99
99
  "data": {
100
100
  "kind": "datasource",
101
101
  "source": "app",
102
- "table": "plans",
102
+ "table": "plan_read_model",
103
103
  "orderBy": { "field": "updated_at", "dir": "desc" },
104
104
  "filter": [{ "field": "list_bucket", "in": ["active"] }]
105
105
  },
@@ -102,7 +102,7 @@
102
102
  "data": {
103
103
  "kind": "datasource",
104
104
  "source": "app",
105
- "table": "feature_runs",
105
+ "table": "feature_read_model",
106
106
  "orderBy": { "field": "updated_at", "dir": "desc" },
107
107
  "filter": [{ "field": "list_bucket", "in": ["active"] }]
108
108
  },
@@ -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 = defaultProbeExec();
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
- // The gateway projects the bucket: a failed epic settles to History (no tick-off needed).
81
- assertEquals(plan.list_bucket, "history");
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 projected) stays in Active it must not vanish (#298).
106
- assertEquals(plan.list_bucket, "active");
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
  });