@nanobpm/nano-workforce 0.128.0 → 0.129.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.129.0](https://github.com/nanobpm/nano-workforce/compare/v0.128.0...v0.129.0) (2026-08-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * adopt ADR-0065 derived instanceTracking read-models (urban 0.81.0) ([#489](https://github.com/nanobpm/nano-workforce/issues/489)) ([ce25501](https://github.com/nanobpm/nano-workforce/commit/ce255013b383eb4d593526c5f34e74aaab535d25)), closes [#318](https://github.com/nanobpm/nano-workforce/issues/318) [nano-workforce#422](https://github.com/nano-workforce/issues/422) [#76](https://github.com/nanobpm/nano-workforce/issues/76)
7
+
1
8
  # [0.128.0](https://github.com/nanobpm/nano-workforce/compare/v0.127.0...v0.128.0) (2026-08-23)
2
9
 
3
10
 
@@ -1,7 +1,8 @@
1
1
  // Tests for the cooperative abandon-check helpers (issue #76).
2
2
  import { test } from "node:test";
3
- import { assertEquals, assertNotEquals } from "#test-assert";
3
+ import { assertEquals, assertNotEquals, assertRejects } from "#test-assert";
4
4
  import type { DataLayer } from "@nanobpm/urban";
5
+ import { withTrackingViews } from "../test/trackingViews.ts";
5
6
  import {
6
7
  abandonStatusForToken,
7
8
  abandonTokenFromUrl,
@@ -26,7 +27,7 @@ function memData(): DataLayer {
26
27
  },
27
28
  };
28
29
  }
29
- return { table: (n: string) => tbl(n) } as any as DataLayer;
30
+ return { table: withTrackingViews((n: string) => tbl(n)) } as any as DataLayer;
30
31
  }
31
32
 
32
33
  async function seedPr(data: DataLayer, pr_key: string, abandon_token: string, status: string) {
@@ -106,3 +107,16 @@ test("abandonStatusForToken derives abandoned from the row status", async () =>
106
107
  });
107
108
  assertEquals(await abandonStatusForToken(data, "nope"), undefined);
108
109
  });
110
+
111
+ test("abandonStatusForToken fails CLOSED on a non-string derived status", async () => {
112
+ // A malformed/missing derived_status must never be reported as `abandoned:false`
113
+ // (that would let a cancelled run proceed with irreversible side effects). It throws
114
+ // instead, which the operation dispatcher maps to a 500 so the agent's `curl -f` aborts.
115
+ const data = memData();
116
+ await data.table("pull_requests", "pr_key").insert({
117
+ pr_key: "o/r#3",
118
+ abandon_token: "weird",
119
+ status: 123,
120
+ });
121
+ await assertRejects(() => abandonStatusForToken(data, "weird"), Error, "is not a string");
122
+ });
package/app/abandon.ts CHANGED
@@ -10,23 +10,28 @@
10
10
  // Design invariants (mirroring the blackboard, app/blackboard.ts):
11
11
  // - CAPABILITY URL. The per-PR token IS the credential; the agent curls the exact URL it was
12
12
  // handed in its prompt. An unknown token is a 404 (never leaks which PRs exist).
13
- // - DERIVED, not a separate marker. `abandoned` is read straight off `pull_requests.status`,
14
- // which Urban's cancel primitive sets to 'abandoned' on cancel (via the `instanceTracking`
15
- // `onTerminated.set` patch, applied the instant the instance terminates). No new state to
16
- // keep in sync.
13
+ // - DERIVED, not a separate marker. `abandoned` is read off the PR's ADR-0065 derived tracking
14
+ // VIEW (`pull_requests__tracking.derived_status`). Since urban 0.81.0 the `instanceTracking`
15
+ // reconciler no longer WRITES 'abandoned' onto the base row on cancel; it feeds urban's instance
16
+ // projection and the `onTerminated.set` edge is recomputed on every read as `derived_status`. So
17
+ // an out-of-band cancel leaves the base `status` at its transient (e.g. `converging`) but the
18
+ // derived view reports 'abandoned' immediately — reading the view keeps the abort-check correct
19
+ // with no new state to sync. (`abandonClosedPr`, #352, still writes 'abandoned' onto the base
20
+ // row directly; the view passes that worker-written terminal through unchanged.)
17
21
  // - ADVISORY. Like the blackboard, this never hard-locks; it narrows an unavoidable
18
22
  // check-then-push (TOCTOU) window to near-zero. Job fencing in the harness (issue #76 layer 2)
19
23
  // is what makes it airtight.
20
24
  import type { DataLayer } from "@nanobpm/urban";
21
25
  import { publicBaseUrl } from "./blackboard.ts";
26
+ import { trackingTargetFor } from "./instanceTracking.ts";
22
27
 
23
- /** The one app-row status meaning a PR is terminally abandoned — the run must not be worked on
24
- * further. Two disjoint producers flip a row here, and both are non-completion terminals that must
28
+ /** The one derived-status value meaning a PR is terminally abandoned — the run must not be worked on
29
+ * further. Two disjoint producers surface a row here, and both are non-completion terminals that must
25
30
  * stop a servicing agent:
26
- * 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive, via the
27
- * `instanceTracking` `onTerminated.set` patch), and
31
+ * 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive terminates
32
+ * the instance; the `instanceTracking` `onTerminated.set` edge derives `abandoned` on read), and
28
33
  * 2. **`abandonClosedPr`** reconciling a wave-member PR that was **closed on GitHub without
29
- * merging** (#352) — for both `pull_requests` and its `plan_tasks`.
34
+ * merging** (#352) — for both `pull_requests` and its `plan_tasks` — by writing the base row.
30
35
  * Convergence/merge terminal states `converged`/`merged` are NOT abandonment. In either abandoned
31
36
  * case a servicing agent should stop, so the abandon-check endpoint treating both as `abandoned:
32
37
  * true` is correct. */
@@ -69,32 +74,49 @@ export function abandonTokenFromUrl(url: string | null | undefined): string | un
69
74
  }
70
75
  }
71
76
 
72
- /** Resolve an abandon token back to its PR key, or undefined when the token is unknown. */
77
+ /** Resolve an abandon token back to its PR key, or undefined when the token is unknown. Reads the
78
+ * derived tracking VIEW (a strict superset of the base row) so this stays valid post-ADR-0065. */
73
79
  export async function prKeyForAbandonToken(
74
80
  data: DataLayer,
75
81
  token: string,
76
82
  ): Promise<string | undefined> {
77
83
  if (!token) return undefined;
78
84
  const row = await data
79
- .table<{ pr_key: string; abandon_token: string | null }>("pull_requests", "pr_key")
85
+ .table<{ pr_key: string; abandon_token: string | null }>(
86
+ trackingTargetFor("pull_requests").view,
87
+ "pr_key",
88
+ )
80
89
  .findOne({ abandon_token: token });
81
90
  return row?.pr_key;
82
91
  }
83
92
 
84
- /** The abandon status of a PR, or undefined when the token is unknown. */
93
+ /** The abandon status of a PR, or undefined when the token is unknown. Reads the ADR-0065 derived
94
+ * tracking VIEW's `derived_status`, so an out-of-band-cancelled run (whose base row is still
95
+ * `converging`) is correctly reported `abandoned: true` the instant the instance terminates. */
85
96
  export async function abandonStatusForToken(
86
97
  data: DataLayer,
87
98
  token: string,
88
99
  ): Promise<{ prKey: string; status: string; abandoned: boolean } | undefined> {
89
100
  if (!token) return undefined;
101
+ const target = trackingTargetFor("pull_requests");
90
102
  const row = await data
91
- .table<{ pr_key: string; abandon_token: string | null; status: string }>(
92
- "pull_requests",
93
- "pr_key",
94
- )
103
+ .table<
104
+ { pr_key: string; abandon_token: string | null } & Record<string, unknown>
105
+ >(target.view, "pr_key")
95
106
  .findOne({ abandon_token: token });
96
107
  if (!row) return undefined;
97
- return { prKey: row.pr_key, status: row.status, abandoned: isAbandoned(row.status) };
108
+ const rawStatus = row[target.statusColumn];
109
+ if (typeof rawStatus !== "string") {
110
+ // Fail CLOSED: a missing/non-string derived_status must never be reported as
111
+ // `abandoned:false`. The abort brief tells agents to proceed on a 200 with
112
+ // `abandoned:false`, so surfacing this as a thrown error (→ 500, which trips the
113
+ // agent's `curl -f` and aborts) is the safe direction for a cancelled run.
114
+ throw new Error(
115
+ `abandonStatusForToken: ${target.view}.${target.statusColumn} is not a string`,
116
+ );
117
+ }
118
+ const status = rawStatus;
119
+ return { prKey: row.pr_key, status, abandoned: isAbandoned(status) };
98
120
  }
99
121
 
100
122
  /** The instruction block appended (verbatim, via `appendPrompt`) to each side-effecting agent's
@@ -3,6 +3,7 @@ import { test } from "node:test";
3
3
  import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
4
4
  import type { DataLayer } from "@nanobpm/urban";
5
5
  import { memBlackboardSource } from "../test/blackboardDb.ts";
6
+ import { withTrackingViews } from "../test/trackingViews.ts";
6
7
  import { appendEntry } from "./blackboard.ts";
7
8
  import {
8
9
  acknowledgeConformance,
@@ -45,7 +46,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
45
46
  },
46
47
  };
47
48
  }
48
- const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
49
+ const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
49
50
  return { data, stores };
50
51
  }
51
52
 
@@ -17,6 +17,7 @@
17
17
  import type { DataLayer } from "@nanobpm/urban";
18
18
  import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
19
19
  import { TERMINAL_STATUSES } from "./delivery.ts";
20
+ import { derivedTrackingTable } from "./instanceTracking.ts";
20
21
  import { planTasks } from "./plan.ts";
21
22
 
22
23
  const now = () => new Date().toISOString();
@@ -45,15 +46,20 @@ interface PlanRow extends Record<string, unknown> {
45
46
 
46
47
  const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
47
48
  const prsTbl = (data: DataLayer) =>
48
- data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
49
+ derivedTrackingTable<{ pr_key: string; derived_status: string }>(
50
+ data,
51
+ "pull_requests",
52
+ "pr_key",
53
+ );
49
54
 
50
55
  /** A slice's PR "landed" iff it exists and reached a non-abandoned terminal status. The single
51
56
  * predicate both {@link gatherConformance} and {@link hasDeliveredImplementationForPlan} apply, so
52
- * the full digest and the cheap trigger check can't disagree about what counts as landed. */
57
+ * the full digest and the cheap trigger check can't disagree about what counts as landed. Reads the
58
+ * ADR-0065 derived edge so an out-of-band-terminated PR is correctly excluded from "landed". */
53
59
  async function isLanded(data: DataLayer, prKey: string | null | undefined): Promise<boolean> {
54
60
  if (!prKey) return false;
55
61
  const pr = await prsTbl(data).get(prKey);
56
- return !!pr && LANDED_PR_STATUSES.has(pr.status);
62
+ return !!pr && LANDED_PR_STATUSES.has(pr.derived_status);
57
63
  }
58
64
  const conformanceTbl = (data: DataLayer) =>
59
65
  data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
@@ -6,6 +6,7 @@
6
6
  import { test } from "node:test";
7
7
  import { assertEquals } from "#test-assert";
8
8
  import type { DataLayer } from "@nanobpm/urban";
9
+ import { withTrackingViews } from "../test/trackingViews.ts";
9
10
  import { deriveFeatureDelivery } from "./feature.ts";
10
11
  import { pollFeatureDelivery } from "./service.ts";
11
12
 
@@ -34,7 +35,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
34
35
  },
35
36
  };
36
37
  }
37
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
38
+ const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
38
39
  return { data, stores };
39
40
  }
40
41
 
@@ -0,0 +1,97 @@
1
+ // nano-workforce — the app's single accessor for the `instanceTracking` derived read models
2
+ // (ADR 0065, the writer→source inversion adopted with `@nanobpm/urban@0.81.0`).
3
+ //
4
+ // Since ADR 0065 the `instanceTracking` reconciler is a SOURCE, not a writer: on each poll it feeds
5
+ // engine truth into urban's canonical projections (`urban_instance_state`, `urban_open_user_tasks`)
6
+ // and NO LONGER writes the terminal (`onTerminated.set`) / wait-on-human (`onWaitingHuman.set`)
7
+ // edges onto the app's base row. Those edges are now DERIVED — recomputed on every read — by an
8
+ // auto-provisioned managed VIEW `<table>__tracking` whose `derived_status` column is
9
+ // `CASE WHEN terminated THEN <onTerminated value> WHEN waiting-human THEN <onWaitingHuman value>
10
+ // ELSE base.<statusField> END`. So the base `statusField` keeps only the worker-owned transient
11
+ // status, and any reader that used to rely on the reconciler having written the terminal status
12
+ // onto the base row must read `derived_status` off the VIEW instead.
13
+ //
14
+ // This module is the ONE place that:
15
+ // - parses the `instanceTracking` bindings from `nano.app.json` (the single source of truth), and
16
+ // - resolves each binding's derived VIEW name + `derived_status` column via urban's OWN target
17
+ // resolver (`instanceTrackingReadModelTarget`), so the app can never drift from the framework's
18
+ // view naming.
19
+ //
20
+ // Writers are unchanged: a service-task worker that owns a terminal outcome (`converged`, `merged`,
21
+ // `done`, …) still writes it to the base `data.table(<table>)`. Only readers that classify on the
22
+ // RECONCILER-derived edge (terminated → abandoned/failed/reviewed, or waiting-human →
23
+ // awaiting_operator) route through the derived VIEW here.
24
+
25
+ import { readFileSync } from "node:fs";
26
+ import {
27
+ type AppManifest,
28
+ type DataLayer,
29
+ type InstanceTracking,
30
+ instanceTrackingReadModelTarget,
31
+ type Table,
32
+ } from "@nanobpm/urban";
33
+
34
+ /** The app manifest, parsed exactly ONCE at module load, typed by urban's own `AppManifest` so the
35
+ * binding shape can never drift from the framework's schema. */
36
+ const APP_MANIFEST: AppManifest = JSON.parse(
37
+ readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"),
38
+ );
39
+
40
+ /** The app manifest's `instanceTracking` bindings — the single source of truth for the derived
41
+ * read-model registry. */
42
+ const INSTANCE_TRACKING_BINDINGS: readonly InstanceTracking[] = APP_MANIFEST.instanceTracking ?? [];
43
+
44
+ /** The single `instanceTracking` binding for a base table, or throw if the manifest has none. */
45
+ export function trackingBindingFor(table: string): InstanceTracking {
46
+ const binding = INSTANCE_TRACKING_BINDINGS.find((b) => b.table === table);
47
+ if (!binding) {
48
+ throw new Error(`nano.app.json: no instanceTracking binding for table "${table}"`);
49
+ }
50
+ return binding;
51
+ }
52
+
53
+ /** A tracked table's parked-and-active statuses, from the single source of truth
54
+ * (`instanceTracking.<table>.activeStatuses` in nano.app.json), so an app-side scan can never drift
55
+ * from the reconciler's notion of "in-flight". Throws if the binding is missing/empty. */
56
+ export function activeStatusesFor(table: string): readonly string[] {
57
+ const binding = trackingBindingFor(table);
58
+ if (!binding.activeStatuses?.length) {
59
+ throw new Error(
60
+ `nano.app.json: instanceTracking[table="${table}"].activeStatuses is missing or empty`,
61
+ );
62
+ }
63
+ return binding.activeStatuses;
64
+ }
65
+
66
+ /** The managed derived read-model VIEW name + effective-status column for a base table, resolved by
67
+ * urban's OWN target resolver so the app never drifts from the framework's `<table>__tracking` /
68
+ * `derived_status` naming (ADR 0065). */
69
+ export function trackingTargetFor(table: string): { view: string; statusColumn: string } {
70
+ return instanceTrackingReadModelTarget(trackingBindingFor(table));
71
+ }
72
+
73
+ /** The base table a derived tracking VIEW projects, or undefined when `view` is not a tracking view.
74
+ * The inverse of {@link trackingTargetFor}, resolved off the same binding registry so it can't drift
75
+ * from the framework's view naming. */
76
+ export function baseTableForTrackingView(view: string): string | undefined {
77
+ return INSTANCE_TRACKING_BINDINGS.find((b) => trackingTargetFor(b.table).view === view)?.table;
78
+ }
79
+
80
+ /** The base `statusField` a binding's derived edge falls through to when no terminal/wait edge
81
+ * applies (the VIEW's `ELSE base.<statusField>` branch). Defaults to `"status"`, mirroring urban. */
82
+ export function baseStatusFieldFor(table: string): string {
83
+ return trackingBindingFor(table).statusField ?? "status";
84
+ }
85
+
86
+ /** A read-only typed gateway over a tracked table's derived VIEW (`<table>__tracking`). The VIEW
87
+ * re-exports `base.*` plus the derived `derived_status` column, so a row carries BOTH the base
88
+ * transient `<statusField>` and the effective (ADR-0065-derived) `derived_status`. Read
89
+ * `derived_status` to classify on the terminal / wait-on-human edge; urban forbids writing a VIEW,
90
+ * so use `data.table(<table>)` for writes. `T` should include `derived_status: string`. */
91
+ export function derivedTrackingTable<T extends object>(
92
+ data: DataLayer,
93
+ table: string,
94
+ pk: string,
95
+ ): Table<T> {
96
+ return data.table<T>(trackingTargetFor(table).view, pk);
97
+ }
@@ -6,6 +6,7 @@
6
6
  import { test } from "node:test";
7
7
  import { assert, assertEquals } from "#test-assert";
8
8
  import type { DataLayer } from "@nanobpm/urban";
9
+ import { withTrackingViews } from "../test/trackingViews.ts";
9
10
  import {
10
11
  deriveLineage,
11
12
  type LineagePr,
@@ -176,7 +177,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
176
177
  },
177
178
  };
178
179
  }
179
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
180
+ const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
180
181
  return { data, stores };
181
182
  }
182
183
 
package/app/lineage.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import type { DataLayer } from "@nanobpm/urban";
22
22
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
23
23
  import { type FeatureRun, featureRuns } from "./feature.ts";
24
+ import { derivedTrackingTable } from "./instanceTracking.ts";
24
25
  import { type Plan, type PlanTask, plans, planTasks } from "./plan.ts";
25
26
 
26
27
  const now = () => new Date().toISOString();
@@ -307,9 +308,19 @@ interface PrRow {
307
308
  // Epic-phase projection this module maintains (issue #304, migration 043): the parent epic's phase
308
309
  // label for an epic slice PR, NULL otherwise. Read here only to keep the write idempotent.
309
310
  epic_phase_label: string | null;
311
+ // The ADR-0065 derived tracking edge (`pull_requests__tracking.derived_status`). Present ONLY on
312
+ // rows read through the derived VIEW (`prRowsRead`); undefined on base-table reads/writes. The
313
+ // frontier stage is derived from THIS, not the base transient `status`, so an out-of-band-
314
+ // terminated slice reads `abandoned` rather than a stale `converging`.
315
+ derived_status?: string;
310
316
  }
311
317
 
312
318
  const prRows = (data: DataLayer) => data.table<PrRow>("pull_requests", "pr_key");
319
+ /** Read-only accessor over the PR derived tracking VIEW (`pull_requests__tracking`). The lineage
320
+ * frontier classifies on the reconciler-derived edge, so `collectThreads` reads through this and
321
+ * `toLineagePr` folds `derived_status` onto `LineagePr.status`. Writes stay on `prRows`. */
322
+ const prRowsRead = (data: DataLayer) =>
323
+ derivedTrackingTable<PrRow & { derived_status: string }>(data, "pull_requests", "pr_key");
313
324
 
314
325
  /** The `lineage_thread_view` VIEW row (migration 064) — the read shape the Lineage page binds. The
315
326
  * view PASSES THROUGH the procedural frontier columns from `lineage_threads` and DERIVES the
@@ -382,7 +393,9 @@ function toLineagePr(row: PrRow): LineagePr {
382
393
  prKey: row.pr_key,
383
394
  title: row.title,
384
395
  url: row.url,
385
- status: row.status,
396
+ // Classify the frontier on the ADR-0065 derived edge when the row came through the tracking VIEW
397
+ // (`prRowsRead`); fall back to the base transient for any base-table row.
398
+ status: row.derived_status ?? row.status,
386
399
  round: row.current_round,
387
400
  processKey: row.process_key,
388
401
  outcome: row.outcome,
@@ -394,7 +407,7 @@ function toLineagePr(row: PrRow): LineagePr {
394
407
  async function collectThreads(
395
408
  data: DataLayer,
396
409
  ): Promise<{ threads: Map<string, LineageThread>; allPrs: PrRow[] }> {
397
- const allPrs = await prRows(data).all();
410
+ const allPrs = await prRowsRead(data).all();
398
411
  const prByKey = new Map<string, PrRow>();
399
412
  for (const pr of allPrs) prByKey.set(pr.pr_key, pr);
400
413
 
@@ -6,6 +6,7 @@
6
6
  // recording engine: open exactly one PR, never a duplicate on re-run, never for a converging epic,
7
7
  // and never for a `main`-based epic.
8
8
  import { test } from "node:test";
9
+ import { withTrackingViews } from "../test/trackingViews.ts";
9
10
  import { assert, assertEquals } from "#test-assert";
10
11
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
11
12
  import { resetDefaultBranchCache } from "./github.ts";
@@ -41,7 +42,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
41
42
  },
42
43
  };
43
44
  }
44
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
45
+ const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
45
46
  return { data, stores };
46
47
  }
47
48
 
package/app/retro.test.ts CHANGED
@@ -3,6 +3,7 @@ import { test } from "node:test";
3
3
  import { assert, assertEquals, assertStringIncludes } from "#test-assert";
4
4
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
5
5
  import { memBlackboardSource } from "../test/blackboardDb.ts";
6
+ import { withTrackingViews } from "../test/trackingViews.ts";
6
7
  import { appendEntry } from "./blackboard.ts";
7
8
  import { recordTaskDelta } from "./taskDelta.ts";
8
9
  import {
@@ -47,7 +48,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
47
48
  },
48
49
  };
49
50
  }
50
- const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
51
+ const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
51
52
  return { data, stores };
52
53
  }
53
54
 
package/app/retro.ts CHANGED
@@ -17,6 +17,7 @@ import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
17
17
  import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
18
18
  import { hasDeliveredImplementationForPlan } from "./conformance.ts";
19
19
  import { TERMINAL_STATUSES } from "./delivery.ts";
20
+ import { derivedTrackingTable } from "./instanceTracking.ts";
20
21
  import { planReviews, planTasks } from "./plan.ts";
21
22
  import { aggregateEpicDeltas } from "./taskDelta.ts";
22
23
 
@@ -55,7 +56,11 @@ interface PlanRow extends Record<string, unknown> {
55
56
 
56
57
  const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
57
58
  const prsTbl = (data: DataLayer) =>
58
- data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
59
+ derivedTrackingTable<{ pr_key: string; derived_status: string }>(
60
+ data,
61
+ "pull_requests",
62
+ "pr_key",
63
+ );
59
64
  const retroStartsTbl = (data: DataLayer) =>
60
65
  data.table<{ plan_key: string; started_at: string }>("plan_retro_starts", "plan_key");
61
66
 
@@ -77,8 +82,10 @@ export async function isPlanComplete(data: DataLayer, planKey: string): Promise<
77
82
  if (SETTLED_TASKLESS.has(t.status)) continue;
78
83
  // Any task that is meant to yield a PR must have a terminal PR to be settled.
79
84
  if (!t.pr_key) return false; // pending/escalated/etc. with no PR yet → still in flight
85
+ // Any task that is meant to yield a PR must have a terminal PR to be settled. Read the ADR-0065
86
+ // derived edge so an out-of-band-terminated (`abandoned`) PR is recognised as terminal here.
80
87
  const pr = await prsTbl(data).get(t.pr_key);
81
- if (!pr || !TERMINAL_PR_STATUSES.has(pr.status)) return false;
88
+ if (!pr || !TERMINAL_PR_STATUSES.has(pr.derived_status)) return false;
82
89
  }
83
90
  return true;
84
91
  }
@@ -8,6 +8,7 @@
8
8
  import { test } from "node:test";
9
9
  import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
10
10
  import { memDataFor } from "../test/worldDb.ts";
11
+ import { withTrackingViews } from "../test/trackingViews.ts";
11
12
  import { DurableResumeRegistry } from "./durableResume.ts";
12
13
  import { WorldStore } from "./world/index.ts";
13
14
  import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
@@ -71,7 +72,7 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
71
72
  pr_dependencies: { rows: [], key: "pr_key" },
72
73
  };
73
74
  const data = {
74
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
75
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
75
76
  } as any;
76
77
  const engine = {
77
78
  createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
@@ -137,7 +138,7 @@ test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it,
137
138
  pull_requests: { rows: [row], key: "pr_key" },
138
139
  };
139
140
  const data = {
140
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
141
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
141
142
  } as any;
142
143
  const headers = { "content-type": "application/json" };
143
144
 
@@ -190,7 +191,7 @@ test("pollIncidents never queries a PR with no live instance and clears any stal
190
191
  pull_requests: { rows: [noKey, terminal], key: "pr_key" },
191
192
  };
192
193
  const data = {
193
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
194
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
194
195
  } as any;
195
196
  const headers = { "content-type": "application/json" };
196
197
 
@@ -223,7 +224,7 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
223
224
  pull_requests: { rows: [row], key: "pr_key" },
224
225
  };
225
226
  const data = {
226
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
227
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
227
228
  } as any;
228
229
  const headers = { "content-type": "application/json" };
229
230
 
@@ -262,7 +263,7 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
262
263
  pr_dependencies: { rows: [], key: "pr_key" },
263
264
  };
264
265
  const data = {
265
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
266
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
266
267
  } as any;
267
268
  const engine = {
268
269
  // A large key delivered as a JS number — the exact case that breaks dev response validation
@@ -296,7 +297,7 @@ function captureConvergeOnly() {
296
297
  pr_dependencies: { rows: [], key: "pr_key" },
297
298
  };
298
299
  const data = {
299
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
300
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
300
301
  } as any;
301
302
  let captured: unknown;
302
303
  const engine = {
@@ -347,7 +348,7 @@ function captureRoot() {
347
348
  pr_dependencies: { rows: [], key: "pr_key" },
348
349
  };
349
350
  const data = {
350
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
351
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
351
352
  } as any;
352
353
  let captured: unknown;
353
354
  const engine = {
@@ -629,7 +630,7 @@ test("pollWaveGatesImpl is level-triggered: PRs merged before the token arrives
629
630
  },
630
631
  };
631
632
  const data = {
632
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
633
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
633
634
  } as any;
634
635
 
635
636
  const published: { name: string; correlationKey?: string }[] = [];
@@ -718,7 +719,7 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
718
719
  },
719
720
  };
720
721
  const data = {
721
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
722
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
722
723
  } as any;
723
724
 
724
725
  const published: { name: string; correlationKey?: string }[] = [];
@@ -822,7 +823,7 @@ test("pollWaveGatesImpl releases the wave when a member PR is closed-unmerged an
822
823
  merges: { rows: [], key: "id" },
823
824
  };
824
825
  const data = {
825
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
826
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
826
827
  } as any;
827
828
 
828
829
  const published: { name: string; correlationKey?: string }[] = [];
@@ -878,7 +879,7 @@ test("abandonClosedPr is idempotent — the terminal merges audit row is written
878
879
  merges: { rows: [], key: "id" },
879
880
  };
880
881
  const data = {
881
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
882
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
882
883
  } as any;
883
884
 
884
885
  await abandonClosedPr(data, "owner/repo#70", "closed without merging");
@@ -906,7 +907,7 @@ test("abandonClosedPr self-heals a missing pull_requests parent row before the F
906
907
  merges: { rows: [], key: "id" },
907
908
  };
908
909
  const data = {
909
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
910
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
910
911
  } as any;
911
912
 
912
913
  await abandonClosedPr(data, "owner/repo#71", "closed without merging");
@@ -932,7 +933,7 @@ test("abandonClosedPr rejects a malformed prKey with a clear error before any FK
932
933
  merges: { rows: [], key: "id" },
933
934
  };
934
935
  const data = {
935
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
936
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
936
937
  } as any;
937
938
 
938
939
  const err = await assertRejects(() => abandonClosedPr(data, "not-a-valid-pr-key", "closed without merging"));
@@ -1004,7 +1005,7 @@ function capsProbeExec(ready: boolean) {
1004
1005
 
1005
1006
  function capsDataLayer(stores: Record<string, { rows: any[]; key: string }>) {
1006
1007
  return {
1007
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
1008
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
1008
1009
  } as any;
1009
1010
  }
1010
1011
 
package/app/service.ts CHANGED
@@ -7,7 +7,6 @@
7
7
  //
8
8
  // Data access goes through the record-oriented gateway (`data.table<T>(name, pk)` — the RAD
9
9
  // `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
10
- import { readFileSync } from "node:fs";
11
10
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
11
  import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
13
12
  import { escalationFormId } from "./agentCompletion.ts";
@@ -51,6 +50,7 @@ import {
51
50
  type PrState,
52
51
  requestCopilotReview,
53
52
  } from "./github.ts";
53
+ import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
54
54
  import { pollLineage } from "./lineage.ts";
55
55
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
56
56
  import {
@@ -253,6 +253,16 @@ interface Escalation {
253
253
  }
254
254
 
255
255
  const prs = (data: DataLayer) => data.table<PullRequest>("pull_requests", "pr_key");
256
+ /** A PR row as seen through its derived tracking VIEW (`pull_requests__tracking`): the base columns
257
+ * plus urban's ADR-0065 `derived_status`, which folds the reconciler's terminal edge
258
+ * (out-of-band terminate → `abandoned`) over the worker-owned transient. */
259
+ type TrackedPullRequest = PullRequest & { derived_status: string };
260
+ /** Read-only accessor over the PR derived tracking VIEW. Use this — and read `derived_status`, not
261
+ * `status` — for any terminal-edge classification (delivery/promotion/feature reconciliation), so an
262
+ * out-of-band-terminated PR (whose base row is still `converging`) is correctly seen as `abandoned`.
263
+ * Worker-written terminals (`merged`/`converged`) pass through unchanged. Writes stay on `prs`. */
264
+ const prsTracking = (data: DataLayer) =>
265
+ derivedTrackingTable<TrackedPullRequest>(data, "pull_requests", "pr_key");
256
266
  const escs = (data: DataLayer) => data.table<Escalation>("escalations", "id");
257
267
  const deps = (data: DataLayer) => data.table<PrDependency>("pr_dependencies", "pr_key");
258
268
 
@@ -1948,7 +1958,8 @@ export async function derivePlanDelivery(
1948
1958
  let status = statusByPrKey?.get(t.pr_key);
1949
1959
  if (status === undefined && !statusByPrKey) {
1950
1960
  // On-demand caller: fetch just this slice's PR row rather than loading the whole table.
1951
- status = (await prs(data).get(t.pr_key))?.status;
1961
+ // Read the ADR-0065 derived edge so an out-of-band-terminated slice reads `abandoned`.
1962
+ status = (await prsTracking(data).get(t.pr_key))?.derived_status;
1952
1963
  }
1953
1964
  prStatuses.push(status ?? MISSING_PR_STATUS);
1954
1965
  }
@@ -2028,8 +2039,9 @@ export async function pollWaitGate(data: DataLayer) {
2028
2039
  * default branch, so there is nothing to promote. Best-effort + per-plan isolated. */
2029
2040
  export async function pollPromotion(data: DataLayer, engine: EngineClient, token: string) {
2030
2041
  // Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
2042
+ // Read the ADR-0065 derived edge (`derived_status`) so a terminated slice reads `abandoned`.
2031
2043
  const statusByPrKey = new Map<string, string>();
2032
- for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
2044
+ for (const pr of await prsTracking(data).all()) statusByPrKey.set(pr.pr_key, pr.derived_status);
2033
2045
  for (const plan of await plans(data).all()) {
2034
2046
  const base = plan.base_branch;
2035
2047
  // A non-`epic/*` base is never promotable — short-circuit before the per-plan delivery join.
@@ -2107,8 +2119,9 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
2107
2119
  * Never touches a run that isn't `converging` — additive/derived only, idempotent, best-effort. */
2108
2120
  export async function pollFeatureDelivery(data: DataLayer) {
2109
2121
  // Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
2122
+ // Read the ADR-0065 derived edge (`derived_status`) so a terminated run reads `abandoned`.
2110
2123
  const statusByPrKey = new Map<string, string>();
2111
- for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
2124
+ for (const pr of await prsTracking(data).all()) statusByPrKey.set(pr.pr_key, pr.derived_status);
2112
2125
  // Only `converging` runs are ever reconciled — query them via the `feature_runs(status)` index
2113
2126
  // (db/migrations/028) instead of scanning all history, so this pass stays O(in-flight), not
2114
2127
  // O(total runs), as the table grows.
@@ -2130,26 +2143,6 @@ export async function pollFeatureDelivery(data: DataLayer) {
2130
2143
  }
2131
2144
  }
2132
2145
 
2133
- /** The app manifest, read and parsed exactly ONCE at module load. `activeStatusesFor` is invoked
2134
- * three times during module initialization (the PR/plan/feature constants below); parsing here keeps
2135
- * that to a single synchronous `readFileSync` + `JSON.parse` instead of one per lookup. */
2136
- const APP_MANIFEST: { instanceTracking?: { table: string; activeStatuses?: string[] }[] } = JSON.parse(
2137
- readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"),
2138
- );
2139
-
2140
- /** Read a tracked table's parked-and-active statuses from the single source of truth
2141
- * (`instanceTracking.<table>.activeStatuses` in nano.app.json), so an app-side scan can never drift
2142
- * from the reconciler's notion of "in-flight". Throws if the binding is missing/empty. */
2143
- function activeStatusesFor(table: string): readonly string[] {
2144
- const binding = APP_MANIFEST.instanceTracking?.find((b) => b.table === table);
2145
- if (!binding?.activeStatuses?.length) {
2146
- throw new Error(
2147
- `nano.app.json: instanceTracking[table="${table}"].activeStatuses is missing or empty`,
2148
- );
2149
- }
2150
- return binding.activeStatuses;
2151
- }
2152
-
2153
2146
  /** The `pull_requests` statuses a PR instance can be parked-and-active on, DERIVED from the single
2154
2147
  * source of truth (`instanceTracking.pull_requests.activeStatuses` in nano.app.json) so the app-side
2155
2148
  * scan can never drift from the reconciler's notion of "in-flight". `pollUserTasks` scans only these
@@ -22,6 +22,7 @@ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { after, before, describe, test } from "node:test";
24
24
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
25
+ import { trackingTargetFor } from "../app/instanceTracking.ts";
25
26
 
26
27
  // The app root is this repo's root (one level up from `e2e/`) — where nano.app.json + openapi.yaml
27
28
  // + db/migrations + resources/processes live.
@@ -160,16 +161,20 @@ describe("nano-workforce e2e (urban-testkit pilot)", () => {
160
161
 
161
162
  // The operation registered the PR aggregate (instanceTracking table) and started a real engine
162
163
  // instance — synchronously, before any worker ran (we never settled).
163
- const prs = app.db.table<{ pr_key: string; status: string; process_key: string | null }>(
164
- "pull_requests",
165
- "pr_key",
166
- );
164
+ const prs = app.db.table<{
165
+ pr_key: string;
166
+ status: string;
167
+ process_key: string | null;
168
+ abandon_token: string | null;
169
+ }>("pull_requests", "pr_key");
167
170
  const row = await prs.findOne({ pr_key: prKey });
168
171
  assert.ok(row, "a pull_requests row was registered");
169
172
  assert.equal(row?.status, "converging", "the PR is tracked as actively converging");
170
173
  assert.ok(row?.process_key, "the row carries the engine process-instance key");
174
+ assert.ok(row?.abandon_token, "the row carries a #76 abandon-check capability token");
171
175
 
172
176
  const processInstanceKey = row!.process_key!;
177
+ const abandonToken = row!.abandon_token!;
173
178
  const before = await app.engine.searchProcessInstances({
174
179
  processInstanceKeys: [processInstanceKey],
175
180
  });
@@ -182,11 +187,39 @@ describe("nano-workforce e2e (urban-testkit pilot)", () => {
182
187
  assert.equal(stillActive?.status, "converging", "row not yet reconciled before any poll fires");
183
188
 
184
189
  // Advance past the instanceTracking pollMs (derived from nano.app.json above, plus a margin):
185
- // the reconciler observes TERMINATED and applies the manifest `onTerminated.set` status
186
- // `abandoned`.
190
+ // the reconciler observes TERMINATED and feeds urban's instance projection. Under ADR-0065
191
+ // (urban 0.81.0, the writer→source inversion) it NO LONGER writes `abandoned` onto the base row;
192
+ // the terminal edge is DERIVED on read via the managed `pull_requests__tracking` VIEW.
187
193
  await app.advanceTime(PR_POLL_MS + 1000);
188
- const reconciled = await prs.findOne({ pr_key: prKey });
189
- assert.equal(reconciled?.status, "abandoned", "reconciler abandoned the terminated PR's row");
194
+
195
+ // (1) The base row keeps only the worker-owned transient — it stays `converging`, NOT rewritten.
196
+ const baseRow = await prs.findOne({ pr_key: prKey });
197
+ assert.equal(
198
+ baseRow?.status,
199
+ "converging",
200
+ "ADR-0065: the reconciler no longer writes the terminal edge onto the base row",
201
+ );
202
+
203
+ // (2) The derived tracking VIEW reports the terminal edge (`abandoned`) via `derived_status`. Its
204
+ // name/column are resolved by the app's SSOT helper, which defers to urban's own target resolver.
205
+ const target = trackingTargetFor("pull_requests");
206
+ const view = app.db.table<{ pr_key: string } & Record<string, unknown>>(target.view, "pr_key");
207
+ const derived = await view.findOne({ pr_key: prKey });
208
+ assert.equal(
209
+ derived?.[target.statusColumn],
210
+ "abandoned",
211
+ "the derived read-model reports the terminated PR as abandoned",
212
+ );
213
+
214
+ // (3) End-to-end, the #76 cooperative abandon-check endpoint (which a servicing agent curls
215
+ // before any irreversible action) now reports `abandoned: true` — the whole point of the edge.
216
+ const abandonCheck = await api.call<{ prKey: string; status: string; abandoned: boolean }>(
217
+ "checkAbandon",
218
+ { query: { token: abandonToken } },
219
+ );
220
+ assert.equal(abandonCheck.status, 200, "the abandon check resolves the known token");
221
+ assert.equal(abandonCheck.body.abandoned, true, "a servicing agent is told to abort the run");
222
+ assert.equal(abandonCheck.body.status, "abandoned", "the reported status is the derived edge");
190
223
  });
191
224
 
192
225
  test("coverage gate: every operation the pilot claims to own was exercised", () => {
@@ -18,6 +18,7 @@ import { assertEquals } from "#test-assert";
18
18
  import type { AppApi } from "@nanobpm/urban";
19
19
  import { deriveEpicBucket, epicIsAcknowledgeable } from "../app/delivery.ts";
20
20
  import { noopLog } from "../test/log.ts";
21
+ import { withTrackingViews } from "../test/trackingViews.ts";
21
22
  import handler from "./acknowledgeEpic.ts";
22
23
 
23
24
  // An in-memory data layer wired through the `plans` gateway (now a plain record table). `extra` seeds
@@ -52,7 +53,7 @@ function memApp(
52
53
  };
53
54
  }
54
55
  const app = {
55
- data: { table: (n: string, pk?: string) => tbl(n, pk) },
56
+ data: { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) },
56
57
  log: noopLog(),
57
58
  } as any as AppApi;
58
59
  return { app, rows: stores.plans };
@@ -3,6 +3,7 @@ import { test } from "node:test";
3
3
  import { assertEquals } from "#test-assert";
4
4
  import type { AppApi } from "@nanobpm/urban";
5
5
  import { noopLog } from "../test/log.ts";
6
+ import { withTrackingViews } from "../test/trackingViews.ts";
6
7
  import handler from "./checkAbandon.ts";
7
8
 
8
9
  function memApp(): { app: AppApi } {
@@ -19,7 +20,7 @@ function memApp(): { app: AppApi } {
19
20
  },
20
21
  };
21
22
  }
22
- const app = { data: { table: (n: string) => tbl(n) }, log: noopLog() } as any as AppApi;
23
+ const app = { data: { table: withTrackingViews((n: string) => tbl(n)) }, log: noopLog() } as any as AppApi;
23
24
  return { app };
24
25
  }
25
26
 
@@ -6,6 +6,7 @@ import { test } from "node:test";
6
6
  import { assert, assertEquals } from "#test-assert";
7
7
  import type { AppApi } from "@nanobpm/urban";
8
8
  import { noopLog } from "../test/log.ts";
9
+ import { withTrackingViews } from "../test/trackingViews.ts";
9
10
  import handler from "./getLineage.ts";
10
11
 
11
12
  function memApp(stores: Record<string, any[]>): AppApi {
@@ -20,7 +21,7 @@ function memApp(stores: Record<string, any[]>): AppApi {
20
21
  },
21
22
  };
22
23
  };
23
- return { data: { table }, log: noopLog() } as any as AppApi;
24
+ return { data: { table: withTrackingViews(table) }, log: noopLog() } as any as AppApi;
24
25
  }
25
26
 
26
27
  function input(query: Record<string, string> = {}, headers: Record<string, string> = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.128.0",
3
+ "version": "0.129.0",
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",
@@ -59,12 +59,12 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@nanobpm/agentic": "^0.4.0",
62
- "@nanobpm/urban": "^0.80.0",
62
+ "@nanobpm/urban": "^0.81.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@biomejs/biome": "^2.4.11",
67
- "@nanobpm/urban-testkit": "^0.12.16",
67
+ "@nanobpm/urban-testkit": "^0.13.1",
68
68
  "@nanobpm/workflow": "^0.14.0",
69
69
  "@semantic-release/changelog": "^6.0.3",
70
70
  "@semantic-release/git": "^10.0.1",
@@ -0,0 +1,50 @@
1
+ // Test-only helper: make a hand-rolled fake `DataLayer.table(name)` also serve the ADR-0065 derived
2
+ // tracking VIEWs (`<table>__tracking`) that the real `@nanobpm/urban` runtime auto-provisions.
3
+ //
4
+ // The app's terminal-edge readers (app/abandon.ts, app/lineage.ts, app/conformance.ts, app/retro.ts,
5
+ // app/service.ts) route through `<table>__tracking` and read `derived_status`. The in-memory fakes in
6
+ // the unit tests only model base tables keyed by name, so a read of `pull_requests__tracking` would
7
+ // hit an empty store. Wrapping a fake's `table` resolver with `withTrackingViews` transparently
8
+ // aliases any tracking VIEW onto its base store and projects the derived column.
9
+ //
10
+ // Fidelity: a fake has no engine instance-state, so it can only compute the VIEW's `ELSE
11
+ // base.<statusField>` fall-through branch — which is exactly how these tests model an already-settled
12
+ // PR (they seed the terminal directly onto the base row). So `derived_status := base.<statusField>`
13
+ // reproduces the real view's pass-through semantics for the states unit tests exercise, with zero
14
+ // duplicated view-naming logic (it defers to the app's SSOT resolvers in app/instanceTracking.ts).
15
+ import { baseStatusFieldFor, baseTableForTrackingView, trackingTargetFor } from "../app/instanceTracking.ts";
16
+
17
+ // biome-ignore lint/suspicious/noExplicitAny: test-only fake `table` resolver over dynamic row shapes.
18
+ type TableFn = (name: string, pk?: string) => any;
19
+
20
+ /** Decorate a fake `table(name, pk)` resolver so it also serves every `<table>__tracking` derived
21
+ * VIEW off the corresponding base store, projecting `derived_status := base.<statusField>` onto each
22
+ * row the read methods (`findOne`/`get`/`find`/`all`) return. A non-tracking name is passed through
23
+ * untouched, and the VIEW is read-only (writes are never wrapped). */
24
+ export function withTrackingViews<F extends TableFn>(base: F): F {
25
+ return ((name: string, pk?: string) => {
26
+ const baseName = baseTableForTrackingView(name);
27
+ if (!baseName) return base(name, pk);
28
+ const inner = base(baseName, pk);
29
+ const derivedColumn = trackingTargetFor(baseName).statusColumn;
30
+ const statusField = baseStatusFieldFor(baseName);
31
+ // biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
32
+ const project = (row: any) =>
33
+ row == null ? row : { ...row, [derivedColumn]: row[statusField] };
34
+ // biome-ignore lint/suspicious/noExplicitAny: test-only Proxy over a dynamic DataLayer table.
35
+ return new Proxy(inner, {
36
+ get(target: any, prop: string) {
37
+ const value = target[prop];
38
+ if (typeof value !== "function") return value;
39
+ const bound = value.bind(target);
40
+ if (prop === "findOne" || prop === "get") {
41
+ return async (...args: unknown[]) => project(await bound(...args));
42
+ }
43
+ if (prop === "find" || prop === "all") {
44
+ return async (...args: unknown[]) => (await bound(...args)).map(project);
45
+ }
46
+ return bound;
47
+ },
48
+ });
49
+ }) as F;
50
+ }
package/test/worldDb.ts CHANGED
@@ -7,6 +7,7 @@ import { DatabaseSync, type SQLInputValue } from "node:sqlite";
7
7
  import { afterEach } from "node:test";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import type { DataLayer } from "@nanobpm/urban";
10
+ import { withTrackingViews } from "./trackingViews.ts";
10
11
 
11
12
  const openDbs = new Set<DatabaseSync>();
12
13
  afterEach(() => {
@@ -103,7 +104,7 @@ export function memDataFor(migrationFiles: readonly string[]): { data: DataLayer
103
104
  db.exec(sql);
104
105
  }
105
106
  const data = {
106
- table: (name: string, pk = "id") => gateway(db, name, pk),
107
+ table: withTrackingViews((name: string, pk = "id") => gateway(db, name, pk)),
107
108
  open: () => openDataSource(db),
108
109
  } as unknown as DataLayer;
109
110
  return { data, db };
@@ -3,6 +3,7 @@ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
3
3
  import type { DataLayer } from "@nanobpm/urban";
4
4
  import { noopLog } from "../../test/log.ts";
5
5
  import { memBlackboardSource } from "../../test/blackboardDb.ts";
6
+ import { withTrackingViews } from "../../test/trackingViews.ts";
6
7
  import { appendEntry } from "../../app/blackboard.ts";
7
8
  import handler from "./worker.ts";
8
9
 
@@ -30,7 +31,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
30
31
  async update() {},
31
32
  };
32
33
  }
33
- const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
34
+ const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
34
35
  return { data, stores };
35
36
  }
36
37