@nanobpm/nano-workforce 0.128.0 → 0.129.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/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.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",
@@ -59,17 +59,18 @@
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",
71
71
  "@semantic-release/npm": "^13.1.5",
72
72
  "@types/node": "^22",
73
+ "conventional-changelog-conventionalcommits": "^8.0.0",
73
74
  "semantic-release": "^24.2.9",
74
75
  "typescript": "^5.6.0"
75
76
  },