@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/.github/workflows/pr-title-lint.yml +5 -3
- package/.releaserc.json +36 -2
- package/AGENTS.md +10 -6
- package/CHANGELOG.md +13 -0
- package/app/abandon.test.ts +16 -2
- package/app/abandon.ts +39 -17
- package/app/conformance.test.ts +2 -1
- package/app/conformance.ts +9 -3
- package/app/featureDelivery.test.ts +2 -1
- package/app/instanceTracking.ts +97 -0
- package/app/lineage.test.ts +2 -1
- package/app/lineage.ts +15 -2
- package/app/mergeEscalationUserTask.test.ts +21 -55
- package/app/mergeLoopBehaviour.test.ts +446 -0
- package/app/promotionPoll.test.ts +2 -1
- package/app/retro.test.ts +2 -1
- package/app/retro.ts +9 -2
- package/app/service.test.ts +15 -14
- package/app/service.ts +17 -24
- package/e2e/convergence-loop.e2e.ts +41 -8
- package/operations/acknowledgeEpic.test.ts +2 -1
- package/operations/checkAbandon.test.ts +2 -1
- package/operations/getLineage.test.ts +2 -1
- package/package.json +4 -3
- package/resources/processes/merge-loop.bpmn +692 -395
- package/test/trackingViews.ts +50 -0
- package/test/worldDb.ts +2 -1
- package/workers/retro-gather/worker.test.ts +2 -1
- package/app/mergeCiReattempt.test.ts +0 -138
- package/app/mergeEscalationQuestion.test.ts +0 -190
- package/app/mergeRebaseArm.test.ts +0 -140
- package/app/mergeRetryArm.test.ts +0 -100
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
|
-
|
|
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
|
|
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
|
|
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<{
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
},
|