@nanobpm/nano-workforce 0.32.1 → 0.33.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 +20 -0
- package/actions/version.test.ts +85 -0
- package/actions/version.ts +25 -0
- package/app/abandon.test.ts +16 -0
- package/app/abandon.ts +15 -0
- package/app/ensure-pr.test.ts +117 -0
- package/app/persist-escalation.test.ts +71 -3
- package/app/persist-round.test.ts +70 -2
- package/app/service.ts +58 -0
- package/app/version.ts +205 -0
- package/nano.app.json +5 -0
- package/package.json +1 -1
- package/pages/epic.page.json +14 -2
- package/pages/home.page.json +2 -14
- package/workers/finalize/worker.ts +16 -2
- package/workers/merge/worker.ts +22 -2
- package/workers/persist-escalation/worker.ts +30 -1
- package/workers/persist-round/worker.ts +30 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
# [0.33.0](https://github.com/nanobpm/nano-workforce/compare/v0.32.2...v0.33.0) (2026-08-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **pages:** move the epic submission form to the Epic tab ([#97](https://github.com/nanobpm/nano-workforce/issues/97)) ([da6db32](https://github.com/nanobpm/nano-workforce/commit/da6db32438d8b9fd9c04fea08ffe1f981728f314))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **ops:** add GET /app/version endpoint for runtime identity ([#98](https://github.com/nanobpm/nano-workforce/issues/98)) ([538cf61](https://github.com/nanobpm/nano-workforce/commit/538cf615cf0f9b34cdb6417346e045fc6131423e))
|
|
12
|
+
* **pages:** link status column to the process explorer ([#99](https://github.com/nanobpm/nano-workforce/issues/99)) ([7f19a29](https://github.com/nanobpm/nano-workforce/commit/7f19a298b4c8c427c593a9d71181192bcac4a896))
|
|
13
|
+
|
|
14
|
+
## [0.32.2](https://github.com/nanobpm/nano-workforce/compare/v0.32.1...v0.32.2) (2026-08-09)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* heal missing pull_requests FK parent before child writes ([#93](https://github.com/nanobpm/nano-workforce/issues/93)) ([896c1f7](https://github.com/nanobpm/nano-workforce/commit/896c1f7a71098463c581816ffc2821af196f779e)), closes [owner/repo#N](https://github.com/owner/repo/issues/N)
|
|
20
|
+
|
|
1
21
|
## [0.32.1](https://github.com/nanobpm/nano-workforce/compare/v0.32.0...v0.32.1) (2026-08-09)
|
|
2
22
|
|
|
3
23
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Tests for GET /app/version (version/identity endpoint).
|
|
2
|
+
import { assert, assertEquals } from "jsr:@std/assert@1";
|
|
3
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
4
|
+
import handler from "./version.ts";
|
|
5
|
+
import { buildVersionInfo } from "../app/version.ts";
|
|
6
|
+
|
|
7
|
+
// deno-lint-ignore no-explicit-any
|
|
8
|
+
const app = {} as any as AppApi;
|
|
9
|
+
|
|
10
|
+
function req(method: string, headers: Record<string, string> = {}) {
|
|
11
|
+
return {
|
|
12
|
+
method,
|
|
13
|
+
path: "/app/version",
|
|
14
|
+
query: new URLSearchParams(),
|
|
15
|
+
headers: new Headers(headers),
|
|
16
|
+
text: async () => "",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function call(method: string, headers: Record<string, string> = {}) {
|
|
21
|
+
// deno-lint-ignore no-explicit-any
|
|
22
|
+
const res = await handler({ req: req(method, headers) as any, body: undefined }, app);
|
|
23
|
+
// deno-lint-ignore no-explicit-any
|
|
24
|
+
return res as any;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Deno.test("GET returns 200 with the app identity", async () => {
|
|
28
|
+
const res = await call("GET");
|
|
29
|
+
assertEquals(res.status, 200);
|
|
30
|
+
assertEquals(res.body.name, "nano-workforce");
|
|
31
|
+
// These are always present; their values are environment-dependent so we only assert shape.
|
|
32
|
+
assert("version" in res.body);
|
|
33
|
+
assert("urbanVersion" in res.body);
|
|
34
|
+
assert("gitSha" in res.body);
|
|
35
|
+
assert("gitBranch" in res.body);
|
|
36
|
+
assert(typeof res.body.runtime === "string" && res.body.runtime.length > 0);
|
|
37
|
+
assert(typeof res.body.startedAt === "string");
|
|
38
|
+
assert(typeof res.body.uptimeSeconds === "number");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
Deno.test("non-GET is rejected with 405", async () => {
|
|
42
|
+
const res = await call("POST");
|
|
43
|
+
assertEquals(res.status, 405);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
Deno.test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
|
|
47
|
+
const prev = Deno.env.get("NANO_PR_WEBHOOK_SECRET");
|
|
48
|
+
Deno.env.set("NANO_PR_WEBHOOK_SECRET", "s3cr3t");
|
|
49
|
+
try {
|
|
50
|
+
// The handler binds SECRET at import time, so a freshly-imported module is needed to observe
|
|
51
|
+
// the guard. Import a cache-busted copy so this test is independent of import order.
|
|
52
|
+
const mod = await import(`./version.ts?guard=${Date.now()}`);
|
|
53
|
+
const guarded = mod.default as typeof handler;
|
|
54
|
+
// deno-lint-ignore no-explicit-any
|
|
55
|
+
const bad = (await guarded({ req: req("GET") as any, body: undefined }, app)) as any;
|
|
56
|
+
assertEquals(bad.status, 401);
|
|
57
|
+
// deno-lint-ignore no-explicit-any
|
|
58
|
+
const ok = (await guarded(
|
|
59
|
+
// deno-lint-ignore no-explicit-any
|
|
60
|
+
{ req: req("GET", { "x-hook-secret": "s3cr3t" }) as any, body: undefined },
|
|
61
|
+
app,
|
|
62
|
+
)) as any;
|
|
63
|
+
assertEquals(ok.status, 200);
|
|
64
|
+
} finally {
|
|
65
|
+
if (prev === undefined) Deno.env.delete("NANO_PR_WEBHOOK_SECRET");
|
|
66
|
+
else Deno.env.set("NANO_PR_WEBHOOK_SECRET", prev);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
Deno.test("buildVersionInfo is side-effect free and stable in shape", () => {
|
|
71
|
+
const a = buildVersionInfo();
|
|
72
|
+
const b = buildVersionInfo();
|
|
73
|
+
assertEquals(a.name, b.name);
|
|
74
|
+
assertEquals(Object.keys(a).sort(), [
|
|
75
|
+
"gitBranch",
|
|
76
|
+
"gitSha",
|
|
77
|
+
"name",
|
|
78
|
+
"pid",
|
|
79
|
+
"runtime",
|
|
80
|
+
"startedAt",
|
|
81
|
+
"uptimeSeconds",
|
|
82
|
+
"urbanVersion",
|
|
83
|
+
"version",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// GET /app/version — the running app's identity (ADR: version endpoint for debugging).
|
|
2
|
+
//
|
|
3
|
+
// Answers "which code is this process actually running?" — the app version, the resolved
|
|
4
|
+
// `@nanobpm/urban` runtime version, the git commit/branch of the working tree, the JS runtime,
|
|
5
|
+
// pid, and how long it has been up. Because the app runs its `.ts` sources directly from a
|
|
6
|
+
// checkout with no build step, restarts alone don't tell you whether the fix you shipped is live;
|
|
7
|
+
// this endpoint does.
|
|
8
|
+
//
|
|
9
|
+
// Read-only and unauthenticated by design (no secrets in the payload); it mirrors the open
|
|
10
|
+
// posture of the pages surface. Optional shared-secret guard when NANO_PR_WEBHOOK_SECRET is set,
|
|
11
|
+
// mirroring /app/status.
|
|
12
|
+
import type { ActionHandler } from "@nanobpm/urban";
|
|
13
|
+
import { buildVersionInfo } from "../app/version.ts";
|
|
14
|
+
|
|
15
|
+
const SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
|
|
16
|
+
|
|
17
|
+
const handler: ActionHandler = ({ req }) => {
|
|
18
|
+
if (req.method !== "GET") return { status: 405, body: { error: "method not allowed (use GET)" } };
|
|
19
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
20
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
21
|
+
}
|
|
22
|
+
return { status: 200, body: buildVersionInfo() };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default handler;
|
package/app/abandon.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { assertEquals, assertNotEquals } from "jsr:@std/assert@1";
|
|
|
3
3
|
import type { DataLayer } from "@nanobpm/urban";
|
|
4
4
|
import {
|
|
5
5
|
abandonStatusForToken,
|
|
6
|
+
abandonTokenFromUrl,
|
|
6
7
|
abandonUrl,
|
|
7
8
|
isAbandoned,
|
|
8
9
|
mintAbandonToken,
|
|
@@ -61,6 +62,21 @@ Deno.test("abandonUrl carries the token on the query string (url-encoded)", () =
|
|
|
61
62
|
);
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
Deno.test("abandonTokenFromUrl round-trips the token minted into an abandonUrl", () => {
|
|
66
|
+
const tok = mintAbandonToken();
|
|
67
|
+
assertEquals(abandonTokenFromUrl(abandonUrl(tok, "https://host")), tok);
|
|
68
|
+
// url-special tokens survive the encode/decode round-trip too.
|
|
69
|
+
assertEquals(abandonTokenFromUrl(abandonUrl("tok+/=", "https://host")), "tok+/=");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
Deno.test("abandonTokenFromUrl returns undefined for absent/tokenless/garbage input", () => {
|
|
73
|
+
assertEquals(abandonTokenFromUrl(undefined), undefined);
|
|
74
|
+
assertEquals(abandonTokenFromUrl(null), undefined);
|
|
75
|
+
assertEquals(abandonTokenFromUrl(""), undefined);
|
|
76
|
+
assertEquals(abandonTokenFromUrl("https://host/hooks/abandon"), undefined, "no token param");
|
|
77
|
+
assertEquals(abandonTokenFromUrl("not a url"), undefined, "unparseable input never throws");
|
|
78
|
+
});
|
|
79
|
+
|
|
64
80
|
Deno.test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
|
|
65
81
|
const brief = renderAbandonBrief("https://host/hooks/abandon?token=tok");
|
|
66
82
|
assertEquals(brief.includes("https://host/hooks/abandon?token=tok"), true);
|
package/app/abandon.ts
CHANGED
|
@@ -44,6 +44,21 @@ export function abandonUrl(token: string, base: string = publicBaseUrl()): strin
|
|
|
44
44
|
return `${base}/hooks/abandon?token=${encodeURIComponent(token)}`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Recover the capability token from an abandon URL — the inverse of `abandonUrl`. Returns
|
|
48
|
+
* undefined when the input is absent or carries no `token` query param. A desync-heal uses this to
|
|
49
|
+
* reconstruct a missing `pull_requests` row with the SAME token the running agent was already
|
|
50
|
+
* handed (via the `abandonUrl` process variable), so its `curl -f "…/hooks/abandon?token=…"` abort
|
|
51
|
+
* check keeps resolving instead of 404-ing on a freshly-minted token and aborting a live run. */
|
|
52
|
+
export function abandonTokenFromUrl(url: string | null | undefined): string | undefined {
|
|
53
|
+
if (!url) return undefined;
|
|
54
|
+
try {
|
|
55
|
+
const t = new URL(url).searchParams.get("token");
|
|
56
|
+
return t || undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
/** Resolve an abandon token back to its PR key, or undefined when the token is unknown. */
|
|
48
63
|
export async function prKeyForAbandonToken(
|
|
49
64
|
data: DataLayer,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Tests for ensurePr — the idempotent, race-safe heal that reconstructs a missing
|
|
2
|
+
// `pull_requests` FK parent before a child (`rounds`/`escalations`/`merges`) insert, so an
|
|
3
|
+
// engine/app.db store desync never parks an opaque `FOREIGN KEY constraint failed` incident
|
|
4
|
+
// (observed on convergence-loop instance 94).
|
|
5
|
+
import { assert, assertEquals } from "jsr:@std/assert@1";
|
|
6
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
7
|
+
import { canonicalPrUrl, ensurePr } from "./service.ts";
|
|
8
|
+
|
|
9
|
+
// A tiny in-memory DataLayer backing only the `.get`/`.insert` surface ensurePr uses. `insert`
|
|
10
|
+
// can be made to throw to exercise the race guard, optionally after seeding the row so the
|
|
11
|
+
// post-throw `.get` sees it.
|
|
12
|
+
function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}): {
|
|
13
|
+
data: DataLayer;
|
|
14
|
+
rows: Map<string, Record<string, unknown>>;
|
|
15
|
+
insertCalls: number;
|
|
16
|
+
} {
|
|
17
|
+
const rows = new Map<string, Record<string, unknown>>();
|
|
18
|
+
let insertCalls = 0;
|
|
19
|
+
function tbl(name: string, key: string) {
|
|
20
|
+
return {
|
|
21
|
+
// deno-lint-ignore require-await
|
|
22
|
+
async get(id: string) {
|
|
23
|
+
return rows.get(id);
|
|
24
|
+
},
|
|
25
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
26
|
+
async insert(row: any) {
|
|
27
|
+
insertCalls++;
|
|
28
|
+
if (opts.throwOnInsert) {
|
|
29
|
+
if (opts.seedOnThrow) rows.set(row[key], { ...row });
|
|
30
|
+
throw new Error("FOREIGN KEY constraint failed");
|
|
31
|
+
}
|
|
32
|
+
rows.set(row[key], { ...row });
|
|
33
|
+
return row[key];
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// deno-lint-ignore no-explicit-any
|
|
38
|
+
const data = { table: (n: string, k: string) => tbl(n, k) } as any as DataLayer;
|
|
39
|
+
return { data, rows, get insertCalls() {
|
|
40
|
+
return insertCalls;
|
|
41
|
+
} };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Deno.test("ensurePr is a no-op when the parent already exists", async () => {
|
|
45
|
+
const mem = memData();
|
|
46
|
+
const { data, rows } = mem;
|
|
47
|
+
rows.set("o/r#1", { pr_key: "o/r#1", status: "converging" });
|
|
48
|
+
const before = { ...rows.get("o/r#1") };
|
|
49
|
+
await ensurePr(data, { prKey: "o/r#1", repo: "o/r", number: 1 });
|
|
50
|
+
assertEquals(rows.size, 1, "no new row is written");
|
|
51
|
+
assertEquals(rows.get("o/r#1"), before, "the existing row is untouched");
|
|
52
|
+
assertEquals(mem.insertCalls, 0, "insert is never attempted — the no-write guarantee holds");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
Deno.test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
|
|
56
|
+
const { data, rows } = memData();
|
|
57
|
+
await ensurePr(data, { prKey: "o/r#2", repo: "o/r", number: 2, round: 3 });
|
|
58
|
+
const row = rows.get("o/r#2")!;
|
|
59
|
+
assertEquals(row.pr_key, "o/r#2");
|
|
60
|
+
assertEquals(row.repo, "o/r");
|
|
61
|
+
assertEquals(row.number, 2);
|
|
62
|
+
assertEquals(row.status, "converging");
|
|
63
|
+
assertEquals(row.current_round, 3, "the round is carried through so the aggregate isn't behind");
|
|
64
|
+
assertEquals(row.url, canonicalPrUrl("o/r", 2), "URL is derived canonically when none is passed");
|
|
65
|
+
assert(typeof row.abandon_token === "string" && (row.abandon_token as string).length > 0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
|
|
69
|
+
const { data, rows } = memData();
|
|
70
|
+
await ensurePr(data, { prKey: "o/r#5", repo: "o/r", number: 5 });
|
|
71
|
+
assertEquals(
|
|
72
|
+
rows.get("o/r#5")!.current_round,
|
|
73
|
+
1,
|
|
74
|
+
"an unknown round heals to 1, not 0, matching submitPr's 1-based invariant",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
|
|
79
|
+
const { data, rows } = memData();
|
|
80
|
+
await ensurePr(data, { prKey: "o/r#7", repo: "o/r", number: 7, abandonToken: "TOK-en_123" });
|
|
81
|
+
assertEquals(
|
|
82
|
+
rows.get("o/r#7")!.abandon_token,
|
|
83
|
+
"TOK-en_123",
|
|
84
|
+
"the running agent's existing token is preserved so its abort check keeps resolving",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
Deno.test("ensurePr mints a token when none is supplied", async () => {
|
|
89
|
+
const { data, rows } = memData();
|
|
90
|
+
await ensurePr(data, { prKey: "o/r#8", repo: "o/r", number: 8 });
|
|
91
|
+
const tok = rows.get("o/r#8")!.abandon_token;
|
|
92
|
+
assert(typeof tok === "string" && (tok as string).length > 0, "a fresh token is minted as a fallback");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
Deno.test("ensurePr prefers an explicit url over the canonical one", async () => {
|
|
96
|
+
const { data, rows } = memData();
|
|
97
|
+
const url = "https://github.com/o/r/pull/9";
|
|
98
|
+
await ensurePr(data, { prKey: "o/r#9", repo: "o/r", number: 9, url });
|
|
99
|
+
assertEquals(rows.get("o/r#9")!.url, url);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
Deno.test("ensurePr swallows an insert race when the row appears anyway", async () => {
|
|
103
|
+
// insert throws (unique-violation / concurrent writer) but the row is now present → healed.
|
|
104
|
+
const { data } = memData({ throwOnInsert: true, seedOnThrow: true });
|
|
105
|
+
await ensurePr(data, { prKey: "o/r#3", repo: "o/r", number: 3 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
Deno.test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
|
|
109
|
+
const { data } = memData({ throwOnInsert: true, seedOnThrow: false });
|
|
110
|
+
let threw = false;
|
|
111
|
+
try {
|
|
112
|
+
await ensurePr(data, { prKey: "o/r#4", repo: "o/r", number: 4 });
|
|
113
|
+
} catch (_e) {
|
|
114
|
+
threw = true;
|
|
115
|
+
}
|
|
116
|
+
assert(threw, "a genuine insert failure must surface, not be silently swallowed");
|
|
117
|
+
});
|
|
@@ -12,13 +12,22 @@ import handler from "../workers/persist-escalation/worker.ts";
|
|
|
12
12
|
function fakeApp() {
|
|
13
13
|
const inserts: Record<string, unknown[]> = { rounds: [], escalations: [] };
|
|
14
14
|
const updates: Record<string, unknown[]> = { pull_requests: [] };
|
|
15
|
+
const rows: Record<string, Map<string, unknown>> = {};
|
|
15
16
|
const app = {
|
|
16
17
|
data: {
|
|
17
18
|
table(name: string, _key: string) {
|
|
19
|
+
const store = (rows[name] ??= new Map());
|
|
18
20
|
return {
|
|
21
|
+
// deno-lint-ignore require-await
|
|
22
|
+
async get(key: string) {
|
|
23
|
+
return store.get(key);
|
|
24
|
+
},
|
|
19
25
|
// deno-lint-ignore require-await
|
|
20
26
|
async insert(row: unknown) {
|
|
21
27
|
(inserts[name] ??= []).push(row);
|
|
28
|
+
const pk = name === "escalations" ? "id" : name === "rounds" ? "id" : "pr_key";
|
|
29
|
+
// deno-lint-ignore no-explicit-any
|
|
30
|
+
store.set((row as any)[pk], row);
|
|
22
31
|
return name === "escalations" ? 42 : 1;
|
|
23
32
|
},
|
|
24
33
|
// deno-lint-ignore require-await
|
|
@@ -29,7 +38,7 @@ function fakeApp() {
|
|
|
29
38
|
},
|
|
30
39
|
},
|
|
31
40
|
};
|
|
32
|
-
return { app, inserts, updates };
|
|
41
|
+
return { app, inserts, updates, rows };
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
Deno.test("stalled arm (recordRound=false) does not insert a duplicate rounds row", async () => {
|
|
@@ -55,8 +64,39 @@ Deno.test("escalation arm without the flag still records the round", async () =>
|
|
|
55
64
|
assertEquals((inserts.rounds[0] as any).round_no, 3);
|
|
56
65
|
});
|
|
57
66
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
67
|
+
// When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
|
|
68
|
+
// desync), persist-escalation reconstructs the `pull_requests` row before the rounds/escalations
|
|
69
|
+
// inserts so opening an escalation never dies with an opaque FOREIGN KEY constraint failure.
|
|
70
|
+
Deno.test("persist-escalation heals a missing pull_requests parent before recording", async () => {
|
|
71
|
+
const { app, inserts, updates } = fakeApp();
|
|
72
|
+
const job = {
|
|
73
|
+
variables: {
|
|
74
|
+
prKey: "o/r#8",
|
|
75
|
+
round: 4,
|
|
76
|
+
status: "blocked",
|
|
77
|
+
question: "max rounds",
|
|
78
|
+
repo: "o/r",
|
|
79
|
+
prNumber: 8,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
// deno-lint-ignore no-explicit-any
|
|
83
|
+
await handler(job as any, app as any);
|
|
84
|
+
assertEquals(inserts.pull_requests?.length, 1, "the missing parent is reconstructed");
|
|
85
|
+
// Assert against the reconstruction insert payload (ensurePr) rather than the stored row: the
|
|
86
|
+
// fake update() doesn't apply patches, so the row would otherwise still read the insert's
|
|
87
|
+
// "converging" status and mask the worker's real final state.
|
|
88
|
+
// deno-lint-ignore no-explicit-any
|
|
89
|
+
const healed = inserts.pull_requests![0] as any;
|
|
90
|
+
assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
|
|
91
|
+
assertEquals(inserts.rounds.length, 1, "the round is still recorded");
|
|
92
|
+
assertEquals(inserts.escalations.length, 1, "the escalation is still opened");
|
|
93
|
+
// And the worker still moves the (now-present) PR to escalated as its final state.
|
|
94
|
+
assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
|
|
95
|
+
// deno-lint-ignore no-explicit-any
|
|
96
|
+
assertEquals((updates.pull_requests![0] as any).patch.status, "escalated");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
|
|
60
100
|
Deno.test("a padded question is persisted trimmed (no whitespace drift)", async () => {
|
|
61
101
|
const { app, inserts, updates } = fakeApp();
|
|
62
102
|
const job = { variables: { prKey: "o/r#1", round: 4, status: "needs_input", question: " needs a decision " } };
|
|
@@ -117,3 +157,31 @@ Deno.test("unclassified status without a question names the status in the fabric
|
|
|
117
157
|
assert(esc.question.includes("in_progress"), "fabricated question references the raw status");
|
|
118
158
|
assertEquals(esc.kind, "blocker", "a non needs_input status is a blocker escalation");
|
|
119
159
|
});
|
|
160
|
+
|
|
161
|
+
// When repo/prNumber process variables are absent the heal still runs by parsing the canonical
|
|
162
|
+
// `owner/repo#N` prKey, so the escalation's FK parent is never left unguarded.
|
|
163
|
+
Deno.test("persist-escalation heals from the prKey when repo/prNumber are absent", async () => {
|
|
164
|
+
const { app, inserts } = fakeApp();
|
|
165
|
+
const job = {
|
|
166
|
+
variables: {
|
|
167
|
+
prKey: "o/r#12",
|
|
168
|
+
round: 2,
|
|
169
|
+
status: "needs_input",
|
|
170
|
+
question: "decide",
|
|
171
|
+
abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
// deno-lint-ignore no-explicit-any
|
|
175
|
+
await handler(job as any, app as any);
|
|
176
|
+
assertEquals(inserts.pull_requests?.length, 1, "the parent is reconstructed from the prKey");
|
|
177
|
+
// deno-lint-ignore no-explicit-any
|
|
178
|
+
const healed = inserts.pull_requests![0] as any;
|
|
179
|
+
assertEquals(healed.repo, "o/r");
|
|
180
|
+
assertEquals(healed.number, 12);
|
|
181
|
+
assertEquals(healed.url, "https://github.com/o/r/pull/12", "URL is derived from the parsed prKey");
|
|
182
|
+
assertEquals(
|
|
183
|
+
healed.abandon_token,
|
|
184
|
+
"TOK-en_123",
|
|
185
|
+
"the running agent's abandon token is preserved from abandonUrl, not re-minted",
|
|
186
|
+
);
|
|
187
|
+
});
|
|
@@ -12,13 +12,22 @@ import handler from "../workers/persist-round/worker.ts";
|
|
|
12
12
|
function fakeApp() {
|
|
13
13
|
const inserts: Record<string, unknown[]> = { rounds: [] };
|
|
14
14
|
const updates: Record<string, unknown[]> = { pull_requests: [] };
|
|
15
|
+
const rows: Record<string, Map<string, unknown>> = {};
|
|
15
16
|
const app = {
|
|
16
17
|
data: {
|
|
17
18
|
table(name: string, _key: string) {
|
|
19
|
+
const store = (rows[name] ??= new Map());
|
|
18
20
|
return {
|
|
21
|
+
// deno-lint-ignore require-await
|
|
22
|
+
async get(key: string) {
|
|
23
|
+
return store.get(key);
|
|
24
|
+
},
|
|
19
25
|
// deno-lint-ignore require-await
|
|
20
26
|
async insert(row: unknown) {
|
|
21
27
|
(inserts[name] ??= []).push(row);
|
|
28
|
+
const pk = name === "rounds" ? "id" : "pr_key";
|
|
29
|
+
// deno-lint-ignore no-explicit-any
|
|
30
|
+
store.set((row as any)[pk], row);
|
|
22
31
|
return 1;
|
|
23
32
|
},
|
|
24
33
|
// deno-lint-ignore require-await
|
|
@@ -29,7 +38,7 @@ function fakeApp() {
|
|
|
29
38
|
},
|
|
30
39
|
},
|
|
31
40
|
};
|
|
32
|
-
return { app, inserts, updates };
|
|
41
|
+
return { app, inserts, updates, rows };
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
for (const status of ["addressed", "waiting"]) {
|
|
@@ -53,7 +62,38 @@ for (const status of ["addressed", "waiting"]) {
|
|
|
53
62
|
});
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
// When the
|
|
65
|
+
// When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
|
|
66
|
+
// desync), persist-round reconstructs the `pull_requests` row before recording the round so the
|
|
67
|
+
// insert never dies with an opaque FOREIGN KEY constraint failure.
|
|
68
|
+
Deno.test("persist-round heals a missing pull_requests parent before recording the round", async () => {
|
|
69
|
+
const { app, inserts, updates } = fakeApp();
|
|
70
|
+
const job = {
|
|
71
|
+
variables: {
|
|
72
|
+
prKey: "o/r#7",
|
|
73
|
+
round: 3,
|
|
74
|
+
status: "addressed",
|
|
75
|
+
repo: "o/r",
|
|
76
|
+
prNumber: 7,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
// deno-lint-ignore no-explicit-any
|
|
80
|
+
await handler(job as any, app as any);
|
|
81
|
+
|
|
82
|
+
assertEquals(inserts.pull_requests?.length, 1, "the missing parent is reconstructed");
|
|
83
|
+
// Assert against the reconstruction insert payload (ensurePr) rather than the stored row: the
|
|
84
|
+
// fake update() doesn't apply patches, so the row would otherwise still read the insert's
|
|
85
|
+
// "converging" status and mask the worker's real final state.
|
|
86
|
+
// deno-lint-ignore no-explicit-any
|
|
87
|
+
const healed = inserts.pull_requests![0] as any;
|
|
88
|
+
assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
|
|
89
|
+
assertEquals(healed.url, "https://github.com/o/r/pull/7", "URL is derived canonically");
|
|
90
|
+
assertEquals(inserts.rounds.length, 1, "the round is still recorded after the heal");
|
|
91
|
+
// And the worker still parks the (now-present) PR in waiting_review as its final state.
|
|
92
|
+
assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
|
|
93
|
+
// deno-lint-ignore no-explicit-any
|
|
94
|
+
assertEquals((updates.pull_requests![0] as any).patch.status, "waiting_review");
|
|
95
|
+
});
|
|
96
|
+
|
|
57
97
|
// rather than writing a NULL status — the round history stays readable.
|
|
58
98
|
Deno.test("persist-round defaults a missing status to 'addressed'", async () => {
|
|
59
99
|
const { app, inserts } = fakeApp();
|
|
@@ -63,3 +103,31 @@ Deno.test("persist-round defaults a missing status to 'addressed'", async () =>
|
|
|
63
103
|
// deno-lint-ignore no-explicit-any
|
|
64
104
|
assertEquals((inserts.rounds[0] as any).status, "addressed");
|
|
65
105
|
});
|
|
106
|
+
|
|
107
|
+
// When repo/prNumber process variables are absent (an older in-flight instance, or a regression)
|
|
108
|
+
// the heal still runs by parsing the canonical `owner/repo#N` prKey, so the FK-child insert is
|
|
109
|
+
// never left unguarded.
|
|
110
|
+
Deno.test("persist-round heals from the prKey when repo/prNumber are absent", async () => {
|
|
111
|
+
const { app, inserts } = fakeApp();
|
|
112
|
+
const job = {
|
|
113
|
+
variables: {
|
|
114
|
+
prKey: "o/r#12",
|
|
115
|
+
round: 2,
|
|
116
|
+
status: "addressed",
|
|
117
|
+
abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
// deno-lint-ignore no-explicit-any
|
|
121
|
+
await handler(job as any, app as any);
|
|
122
|
+
assertEquals(inserts.pull_requests?.length, 1, "the parent is reconstructed from the prKey");
|
|
123
|
+
// deno-lint-ignore no-explicit-any
|
|
124
|
+
const healed = inserts.pull_requests![0] as any;
|
|
125
|
+
assertEquals(healed.repo, "o/r");
|
|
126
|
+
assertEquals(healed.number, 12);
|
|
127
|
+
assertEquals(healed.url, "https://github.com/o/r/pull/12", "URL is derived from the parsed prKey");
|
|
128
|
+
assertEquals(
|
|
129
|
+
healed.abandon_token,
|
|
130
|
+
"TOK-en_123",
|
|
131
|
+
"the running agent's abandon token is preserved from abandonUrl, not re-minted",
|
|
132
|
+
);
|
|
133
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -168,6 +168,64 @@ export interface ParsedPr {
|
|
|
168
168
|
prKey: string;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/** Canonical GitHub PR URL for a repo + number. Matches the `url` `parsePr` derives, so a
|
|
172
|
+
* reconstructed row is indistinguishable from one registered at submit time. */
|
|
173
|
+
export function canonicalPrUrl(repo: string, number: number): string {
|
|
174
|
+
return `https://github.com/${repo}/pull/${number}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Guarantee the `pull_requests` parent row exists before a FK-child row (`rounds`, `escalations`,
|
|
179
|
+
* `merges`) is written. Idempotent and race-safe.
|
|
180
|
+
*
|
|
181
|
+
* The durable engine and the app's `app.db` are two INDEPENDENT stores. If they ever desync — the
|
|
182
|
+
* DB is rebuilt, restored from a stale copy, or simply lags a live process instance while running
|
|
183
|
+
* from a local checkout — a persist/finalize/merge job would otherwise insert a child row whose
|
|
184
|
+
* FK parent is absent and die with an opaque `FOREIGN KEY constraint failed` incident that a human
|
|
185
|
+
* has to hand-resolve (observed on convergence-loop instance 94). The PR's `repo`/`prNumber` are
|
|
186
|
+
* normally carried as process variables; where they may be absent (older in-flight instances) the
|
|
187
|
+
* call sites derive them from the canonical `owner/repo#N` prKey, so the parent can always be
|
|
188
|
+
* reconstructed deterministically: we heal the missing row (a minimal `converging` aggregate) and
|
|
189
|
+
* let the loop continue instead of parking a dead-end incident. Callers pass the instance's
|
|
190
|
+
* `abandonToken` (recovered from its `abandonUrl` var) so the healed row keeps the token the
|
|
191
|
+
* running agent was handed and its cooperative-abort check keeps resolving. When the row already
|
|
192
|
+
* exists this is a no-op, so the healthy path is unchanged.
|
|
193
|
+
*/
|
|
194
|
+
export async function ensurePr(
|
|
195
|
+
data: DataLayer,
|
|
196
|
+
pr: { prKey: string; repo: string; number: number; url?: string; round?: number; abandonToken?: string },
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
const table = prs(data);
|
|
199
|
+
if (await table.get(pr.prKey)) return;
|
|
200
|
+
const ts = now();
|
|
201
|
+
console.warn(
|
|
202
|
+
`[ensurePr] pull_requests row for ${pr.prKey} was missing — reconstructing it so the FK-child ` +
|
|
203
|
+
`write can proceed (engine/app.db desync heal)`,
|
|
204
|
+
);
|
|
205
|
+
try {
|
|
206
|
+
await table.insert({
|
|
207
|
+
pr_key: pr.prKey,
|
|
208
|
+
repo: pr.repo,
|
|
209
|
+
number: pr.number,
|
|
210
|
+
url: pr.url ?? canonicalPrUrl(pr.repo, pr.number),
|
|
211
|
+
status: "converging",
|
|
212
|
+
current_round: pr.round ?? 1,
|
|
213
|
+
// Reuse the token the running agent was already handed (recovered from the instance's
|
|
214
|
+
// `abandonUrl` var) so its abort check keeps resolving; only mint a fresh one when the caller
|
|
215
|
+
// has no token to preserve (e.g. an old instance predating the `abandonUrl` variable).
|
|
216
|
+
abandon_token: pr.abandonToken ?? mintAbandonToken(),
|
|
217
|
+
created_at: ts,
|
|
218
|
+
updated_at: ts,
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// Lost a race with a concurrent writer (submitPr or another job created the row first): the
|
|
222
|
+
// parent now exists, which is exactly the goal, so swallow. Only a still-absent row is a real
|
|
223
|
+
// failure worth surfacing.
|
|
224
|
+
if (await table.get(pr.prKey)) return;
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
171
229
|
/** Parse "owner/repo#123" or a canonical PR URL into its parts. */
|
|
172
230
|
export function parsePr(input: string): ParsedPr | null {
|
|
173
231
|
const s = input.trim();
|
package/app/version.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Runtime version/identity of the running nano-workforce app.
|
|
2
|
+
//
|
|
3
|
+
// The app runs its TypeScript sources DIRECTLY from a checkout (`node --experimental-strip-types
|
|
4
|
+
// main.ts`) with no build/bundle step, so "which code is running" can only be answered by
|
|
5
|
+
// inspecting the working tree at runtime. This module gathers that identity — the app's package
|
|
6
|
+
// version, the resolved `@nanobpm/urban` version, the git commit (read from `.git`, handling both
|
|
7
|
+
// an ordinary `.git` directory and the `gitdir:` file pointer used by worktrees/submodules, with
|
|
8
|
+
// an env override for detached deploys), plus the Node/Deno runtime, pid and start time — so an operator
|
|
9
|
+
// debugging a stuck instance can confirm the process is on the code they think it is.
|
|
10
|
+
//
|
|
11
|
+
// Every probe is best-effort: a missing file or unavailable `.git` yields `null` for that field
|
|
12
|
+
// rather than throwing, so `/app/version` never fails just because one source is absent.
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { dirname, join, resolve, isAbsolute } from "node:path";
|
|
16
|
+
|
|
17
|
+
// Captured once, at module load — i.e. when the running process booted this code.
|
|
18
|
+
const STARTED_AT = new Date();
|
|
19
|
+
|
|
20
|
+
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
21
|
+
|
|
22
|
+
function readText(path: string): string | null {
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(path, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJson(path: string): Record<string, unknown> | null {
|
|
31
|
+
const text = readText(path);
|
|
32
|
+
if (text == null) return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text) as Record<string, unknown>;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read an env var across runtimes: Node exposes `process.env`; Deno may not populate it, so fall
|
|
42
|
+
* back to `Deno.env.get` (guarded — reading env can throw without `--allow-env`).
|
|
43
|
+
*/
|
|
44
|
+
function envVar(name: string): string | null {
|
|
45
|
+
const fromProcess = globalThis.process?.env?.[name];
|
|
46
|
+
if (typeof fromProcess === "string" && fromProcess.trim()) return fromProcess.trim();
|
|
47
|
+
const deno = (globalThis as { Deno?: { env?: { get?(k: string): string | undefined } } }).Deno;
|
|
48
|
+
try {
|
|
49
|
+
const fromDeno = deno?.env?.get?.(name);
|
|
50
|
+
if (typeof fromDeno === "string" && fromDeno.trim()) return fromDeno.trim();
|
|
51
|
+
} catch {
|
|
52
|
+
// Env access denied — treat as unset.
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Locate the repo's git directories. `.git` is usually a directory, but in a `git worktree` (this
|
|
59
|
+
* app is often run from one) or a submodule it is a FILE containing `gitdir: <path>`, so a naive
|
|
60
|
+
* `${REPO_ROOT}/.git/HEAD` read returns null even on a live checkout. Resolve both the (possibly
|
|
61
|
+
* per-worktree) git dir that holds `HEAD` and the COMMON dir that holds loose refs / `packed-refs`.
|
|
62
|
+
*/
|
|
63
|
+
function resolveGitDirs(): { gitDir: string; commonDir: string } | null {
|
|
64
|
+
const dotGit = join(REPO_ROOT, ".git");
|
|
65
|
+
// Ordinary checkout: `.git` is a directory and `HEAD` sits directly inside.
|
|
66
|
+
if (readText(join(dotGit, "HEAD")) != null) {
|
|
67
|
+
return { gitDir: dotGit, commonDir: dotGit };
|
|
68
|
+
}
|
|
69
|
+
// Linked worktree / submodule: `.git` is a file pointing at the real git dir.
|
|
70
|
+
const pointer = readText(dotGit);
|
|
71
|
+
const match = pointer ? /^gitdir:\s*(.+?)\s*$/m.exec(pointer) : null;
|
|
72
|
+
if (!match) return null;
|
|
73
|
+
const target = match[1].trim();
|
|
74
|
+
const gitDir = isAbsolute(target) ? target : resolve(REPO_ROOT, target);
|
|
75
|
+
// A linked worktree keeps its own HEAD in `gitDir` but shares refs via the common dir, named by
|
|
76
|
+
// the `commondir` file (e.g. "../..").
|
|
77
|
+
const common = readText(join(gitDir, "commondir"));
|
|
78
|
+
const commonDir = common?.trim()
|
|
79
|
+
? (isAbsolute(common.trim()) ? common.trim() : resolve(gitDir, common.trim()))
|
|
80
|
+
: gitDir;
|
|
81
|
+
return { gitDir, commonDir };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The app's own `package.json` (read once, reused by name + version). */
|
|
85
|
+
function appPackage(): Record<string, unknown> | null {
|
|
86
|
+
return readJson(join(REPO_ROOT, "package.json"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The app's name from `package.json`, scope-stripped (e.g. "@foo/bar" → "bar"). */
|
|
90
|
+
function appName(pkg: Record<string, unknown> | null): string {
|
|
91
|
+
const raw = typeof pkg?.name === "string" ? pkg.name.trim() : "";
|
|
92
|
+
if (!raw) return "nano-workforce";
|
|
93
|
+
const unscoped = raw.startsWith("@") ? raw.slice(raw.indexOf("/") + 1) : raw;
|
|
94
|
+
return unscoped || "nano-workforce";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The app's own version from its `package.json`. */
|
|
98
|
+
function appVersion(pkg: Record<string, unknown> | null): string | null {
|
|
99
|
+
return typeof pkg?.version === "string" ? pkg.version : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The installed `@nanobpm/urban` version (the runtime that materializes the whole app). */
|
|
103
|
+
function urbanVersion(): string | null {
|
|
104
|
+
const pkg = readJson(join(REPO_ROOT, "node_modules", "@nanobpm", "urban", "package.json"));
|
|
105
|
+
return typeof pkg?.version === "string" ? pkg.version : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The git commit the working tree is on, read straight from `.git` so it reflects the ACTUAL
|
|
110
|
+
* checked-out code — not a value baked at some earlier build. Resolves a symbolic `HEAD`
|
|
111
|
+
* (`ref: refs/heads/…`) via the loose ref file (checking both the per-worktree and common dirs),
|
|
112
|
+
* falling back to `packed-refs`. An explicit `NANO_WORKFORCE_GIT_SHA` env var wins (for deploys
|
|
113
|
+
* that ship without a `.git` directory).
|
|
114
|
+
*/
|
|
115
|
+
function gitSha(): string | null {
|
|
116
|
+
const env = envVar("NANO_WORKFORCE_GIT_SHA");
|
|
117
|
+
if (env) return env;
|
|
118
|
+
|
|
119
|
+
const dirs = resolveGitDirs();
|
|
120
|
+
if (dirs == null) return null;
|
|
121
|
+
const head = readText(join(dirs.gitDir, "HEAD"));
|
|
122
|
+
if (head == null) return null;
|
|
123
|
+
|
|
124
|
+
const ref = head.trim();
|
|
125
|
+
if (!ref.startsWith("ref:")) {
|
|
126
|
+
// Detached HEAD — the file already holds the commit sha.
|
|
127
|
+
return ref || null;
|
|
128
|
+
}
|
|
129
|
+
const refPath = ref.slice(4).trim(); // e.g. "refs/heads/main"
|
|
130
|
+
// A loose ref may live in the per-worktree dir or the common dir; check both.
|
|
131
|
+
const loose = readText(join(dirs.gitDir, refPath)) ?? readText(join(dirs.commonDir, refPath));
|
|
132
|
+
if (loose != null && loose.trim()) return loose.trim();
|
|
133
|
+
|
|
134
|
+
// Packed refs fallback (always in the common dir): lines of "<sha> <refname>".
|
|
135
|
+
const packed = readText(join(dirs.commonDir, "packed-refs"));
|
|
136
|
+
if (packed != null) {
|
|
137
|
+
for (const line of packed.split("\n")) {
|
|
138
|
+
const trimmed = line.trim();
|
|
139
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("^")) continue;
|
|
140
|
+
const [sha, name] = trimmed.split(/\s+/, 2);
|
|
141
|
+
if (name === refPath) return sha;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The current branch name from `HEAD`, or `null` when detached / unavailable. */
|
|
148
|
+
function gitBranch(): string | null {
|
|
149
|
+
const dirs = resolveGitDirs();
|
|
150
|
+
if (dirs == null) return null;
|
|
151
|
+
const head = readText(join(dirs.gitDir, "HEAD"));
|
|
152
|
+
if (head == null) return null;
|
|
153
|
+
const ref = head.trim();
|
|
154
|
+
if (!ref.startsWith("ref:")) return null;
|
|
155
|
+
const refPath = ref.slice(4).trim();
|
|
156
|
+
return refPath.startsWith("refs/heads/") ? refPath.slice("refs/heads/".length) : refPath;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function runtime(): string {
|
|
160
|
+
const proc = globalThis.process;
|
|
161
|
+
// Deno exposes `Deno.version.deno`; Node exposes `process.version` (e.g. "v24.15.0").
|
|
162
|
+
const deno = (globalThis as { Deno?: { version?: { deno?: string } } }).Deno;
|
|
163
|
+
if (deno?.version?.deno) return `deno ${deno.version.deno}`;
|
|
164
|
+
if (proc?.version) return `node ${proc.version}`;
|
|
165
|
+
return "unknown";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface VersionInfo {
|
|
169
|
+
name: string;
|
|
170
|
+
version: string | null;
|
|
171
|
+
urbanVersion: string | null;
|
|
172
|
+
gitSha: string | null;
|
|
173
|
+
gitBranch: string | null;
|
|
174
|
+
runtime: string;
|
|
175
|
+
pid: number | null;
|
|
176
|
+
startedAt: string;
|
|
177
|
+
uptimeSeconds: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Everything except `uptimeSeconds` is fixed for the life of the process, so probe the working
|
|
182
|
+
* tree ONCE at module load rather than re-reading files (`.git`, package.jsons) on every request.
|
|
183
|
+
*/
|
|
184
|
+
const STATIC: Omit<VersionInfo, "uptimeSeconds"> = (() => {
|
|
185
|
+
const proc = globalThis.process;
|
|
186
|
+
const pkg = appPackage();
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
name: appName(pkg),
|
|
189
|
+
version: appVersion(pkg),
|
|
190
|
+
urbanVersion: urbanVersion(),
|
|
191
|
+
gitSha: gitSha(),
|
|
192
|
+
gitBranch: gitBranch(),
|
|
193
|
+
runtime: runtime(),
|
|
194
|
+
pid: typeof proc?.pid === "number" ? proc.pid : null,
|
|
195
|
+
startedAt: STARTED_AT.toISOString(),
|
|
196
|
+
});
|
|
197
|
+
})();
|
|
198
|
+
|
|
199
|
+
/** Gather the running app's identity. Cheap and side-effect-free — safe to call per request. */
|
|
200
|
+
export function buildVersionInfo(): VersionInfo {
|
|
201
|
+
return {
|
|
202
|
+
...STATIC,
|
|
203
|
+
uptimeSeconds: Math.round((Date.now() - STARTED_AT.getTime()) / 1000),
|
|
204
|
+
};
|
|
205
|
+
}
|
package/nano.app.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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",
|
package/pages/epic.page.json
CHANGED
|
@@ -23,10 +23,22 @@
|
|
|
23
23
|
"type": "text",
|
|
24
24
|
"id": "subtitle",
|
|
25
25
|
"props": {
|
|
26
|
-
"text": "
|
|
26
|
+
"text": "Hand an issue to the fleet to plan and implement it, then track each plan's review trace, wave state, merge-exclusion graph, coordination notes, and trial-merge gate results.",
|
|
27
27
|
"variant": "sub"
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
"type": "actionForm",
|
|
32
|
+
"id": "plan-submit",
|
|
33
|
+
"props": {
|
|
34
|
+
"title": "Hand an issue to the fleet",
|
|
35
|
+
"submitLabel": "Plan & implement",
|
|
36
|
+
"action": { "kind": "startProcess", "process": "plan-fanout" },
|
|
37
|
+
"fields": [
|
|
38
|
+
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" }
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
30
42
|
{
|
|
31
43
|
"type": "dataGrid",
|
|
32
44
|
"id": "epic-plans",
|
|
@@ -54,7 +66,7 @@
|
|
|
54
66
|
],
|
|
55
67
|
"columns": [
|
|
56
68
|
{ "field": "plan_key", "header": "Issue", "linkField": "issue_url" },
|
|
57
|
-
{ "field": "status", "header": "Status" },
|
|
69
|
+
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
58
70
|
{ "field": "task_count", "header": "Tasks" },
|
|
59
71
|
{ "field": "open_task_id", "header": "Open escalation" },
|
|
60
72
|
{ "field": "updated_at", "header": "Updated" }
|
package/pages/home.page.json
CHANGED
|
@@ -40,18 +40,6 @@
|
|
|
40
40
|
]
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
|
-
{
|
|
44
|
-
"type": "actionForm",
|
|
45
|
-
"id": "plan-submit",
|
|
46
|
-
"props": {
|
|
47
|
-
"title": "Hand an issue to the fleet",
|
|
48
|
-
"submitLabel": "Plan & implement",
|
|
49
|
-
"action": { "kind": "startProcess", "process": "plan-fanout" },
|
|
50
|
-
"fields": [
|
|
51
|
-
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" }
|
|
52
|
-
]
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
43
|
{
|
|
56
44
|
"type": "dataGrid",
|
|
57
45
|
"id": "prs",
|
|
@@ -83,7 +71,7 @@
|
|
|
83
71
|
],
|
|
84
72
|
"columns": [
|
|
85
73
|
{ "field": "pr_key", "header": "PR", "linkField": "url" },
|
|
86
|
-
{ "field": "status", "header": "Status" },
|
|
74
|
+
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
87
75
|
{ "field": "incident_message", "header": "Incident" },
|
|
88
76
|
{ "field": "current_round", "header": "Round" },
|
|
89
77
|
{ "field": "active_worker", "header": "Agent" },
|
|
@@ -195,7 +183,7 @@
|
|
|
195
183
|
],
|
|
196
184
|
"columns": [
|
|
197
185
|
{ "field": "plan_key", "header": "Issue" },
|
|
198
|
-
{ "field": "status", "header": "Status" },
|
|
186
|
+
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
199
187
|
{ "field": "task_count", "header": "Tasks" },
|
|
200
188
|
{ "field": "updated_at", "header": "Updated" }
|
|
201
189
|
],
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// merge stage (start the `merge-loop` process and park the PR in `waiting_deps`) when auto-merge
|
|
3
3
|
// is on, or (b) close the PR out as `converged` (review-only mode).
|
|
4
4
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
5
|
-
import { AUTO_MERGE, startMerge } from "../../app/service.ts";
|
|
5
|
+
import { AUTO_MERGE, ensurePr, startMerge } from "../../app/service.ts";
|
|
6
|
+
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
6
7
|
import { maybeStartRetro } from "../../app/retro.ts";
|
|
7
8
|
|
|
8
9
|
// Extends Record so the declared fields are typed while the job may still carry
|
|
@@ -14,6 +15,9 @@ interface In extends Record<string, unknown> {
|
|
|
14
15
|
prUrl: string;
|
|
15
16
|
round: number;
|
|
16
17
|
summary?: string;
|
|
18
|
+
// The per-PR abandon capability URL the agent was handed; its token is preserved on a heal so
|
|
19
|
+
// the agent's cooperative-abort check keeps resolving (see ensurePr).
|
|
20
|
+
abandonUrl?: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
|
|
@@ -26,9 +30,19 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
26
30
|
// `summary` is left undefined when absent so the write boundary omits it: the
|
|
27
31
|
// nullable `rounds.summary` stays NULL and `pull_requests.outcome` is untouched
|
|
28
32
|
// rather than being coerced to "".
|
|
29
|
-
const { prKey, repo, prNumber, prUrl, round, summary } = job.variables;
|
|
33
|
+
const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl } = job.variables;
|
|
30
34
|
const now = new Date().toISOString();
|
|
31
35
|
|
|
36
|
+
// Heal a missing FK parent (engine/app.db desync) before the child `rounds` insert.
|
|
37
|
+
await ensurePr(app.data, {
|
|
38
|
+
prKey,
|
|
39
|
+
repo,
|
|
40
|
+
number: prNumber,
|
|
41
|
+
url: prUrl,
|
|
42
|
+
round,
|
|
43
|
+
abandonToken: abandonTokenFromUrl(abandonUrl),
|
|
44
|
+
});
|
|
45
|
+
|
|
32
46
|
await app.data.table("rounds", "id").insert({
|
|
33
47
|
pr_key: prKey,
|
|
34
48
|
round_no: round,
|
package/workers/merge/worker.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// shapes the escalation payload on a block.
|
|
11
11
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
12
12
|
import { enqueueViaComment, mergePr } from "../../app/github.ts";
|
|
13
|
-
import { MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
|
|
13
|
+
import { MERGE_ADMIN, MERGE_METHOD, ensurePr } from "../../app/service.ts";
|
|
14
|
+
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
14
15
|
import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
|
|
15
16
|
import { checkBaseTarget } from "../../app/baseGuard.ts";
|
|
16
17
|
|
|
@@ -18,6 +19,12 @@ interface In extends Record<string, unknown> {
|
|
|
18
19
|
prKey: string;
|
|
19
20
|
repo: string;
|
|
20
21
|
prNumber: number;
|
|
22
|
+
// Carried by the merge-loop instance (startMerge sets it to the converged round); passed to
|
|
23
|
+
// the heal so a reconstructed row reflects the real round, not a 1-based default.
|
|
24
|
+
round?: number;
|
|
25
|
+
// The per-PR abandon capability URL the agent was handed; its token is preserved on a heal so
|
|
26
|
+
// the agent's cooperative-abort check keeps resolving (see ensurePr).
|
|
27
|
+
abandonUrl?: string;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
interface Out extends Record<string, unknown> {
|
|
@@ -27,10 +34,23 @@ interface Out extends Record<string, unknown> {
|
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
30
|
-
const { prKey, repo, prNumber } = job.variables;
|
|
37
|
+
const { prKey, repo, prNumber, round, abandonUrl } = job.variables;
|
|
31
38
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
32
39
|
const now = new Date().toISOString();
|
|
33
40
|
|
|
41
|
+
// Heal a missing FK parent (engine/app.db desync) before any child `merges` insert below so a
|
|
42
|
+
// land attempt never dies with an opaque `FOREIGN KEY constraint failed` incident. The merge-loop
|
|
43
|
+
// instance carries repo+prNumber (and the converged round) but not prUrl; ensurePr derives the
|
|
44
|
+
// canonical URL from them, keeps the healed round faithful, and preserves the agent's abandon
|
|
45
|
+
// token so its cooperative-abort check keeps resolving.
|
|
46
|
+
await ensurePr(app.data, {
|
|
47
|
+
prKey,
|
|
48
|
+
repo,
|
|
49
|
+
number: prNumber,
|
|
50
|
+
round,
|
|
51
|
+
abandonToken: abandonTokenFromUrl(abandonUrl),
|
|
52
|
+
});
|
|
53
|
+
|
|
34
54
|
// Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
|
|
35
55
|
// to the default branch — the merge would land into a dead branch and never reach `main`.
|
|
36
56
|
// GitHub only auto-retargets a PR when its base is *deleted* on merge; a merged-but-undeleted
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// and the MAX_ROUNDS guard (status = blocked, question set by the process). Returns
|
|
4
4
|
// `escalationId` for the UI.
|
|
5
5
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
6
|
+
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
7
|
+
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
6
8
|
|
|
7
9
|
// Extends Record so the declared fields are typed while the job may still carry
|
|
8
10
|
// other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
|
|
@@ -12,6 +14,14 @@ interface In extends Record<string, unknown> {
|
|
|
12
14
|
status?: string;
|
|
13
15
|
summary?: string;
|
|
14
16
|
question?: string;
|
|
17
|
+
// Carried by the convergence-loop instance so a missing `pull_requests` parent can be
|
|
18
|
+
// reconstructed before the FK-child `rounds`/`escalations` inserts.
|
|
19
|
+
repo?: string;
|
|
20
|
+
prNumber?: number;
|
|
21
|
+
prUrl?: string;
|
|
22
|
+
// The per-PR abandon capability URL the agent was handed; its token is preserved on a heal so
|
|
23
|
+
// the agent's cooperative-abort check keeps resolving (see ensurePr).
|
|
24
|
+
abandonUrl?: string;
|
|
15
25
|
// False on the "review stalled" arm: `persist-round` already recorded this `round` as
|
|
16
26
|
// `addressed`, so this escalation must not insert a second `rounds` row for the same
|
|
17
27
|
// `pr_key`/`round_no` (which would record one round as both addressed and blocked). Absent
|
|
@@ -52,7 +62,7 @@ function fabricateQuestion(rawStatus: string | undefined, hasTranscript: boolean
|
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
const handler: AppJobHandler<In> = async (job, app) => {
|
|
55
|
-
const { prKey, round, summary } = job.variables;
|
|
65
|
+
const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
|
|
56
66
|
// `status` drives the escalation kind (control flow); a blank/absent status is an
|
|
57
67
|
// unclassified escalation -> a question needing input. `question` is denormalised
|
|
58
68
|
// onto pull_requests below and bound by the UI answer form, so it must be a
|
|
@@ -71,6 +81,25 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
71
81
|
const kind = status === "needs_input" ? "question" : "blocker";
|
|
72
82
|
const now = new Date().toISOString();
|
|
73
83
|
|
|
84
|
+
// Heal a missing FK parent (engine/app.db desync) before the child `rounds`/`escalations`
|
|
85
|
+
// inserts so this never dies with an opaque `FOREIGN KEY constraint failed` incident. Prefer
|
|
86
|
+
// the carried repo/prNumber; if either is missing (an older in-flight instance, or a
|
|
87
|
+
// process-variable regression) fall back to parsing them out of the canonical `owner/repo#N`
|
|
88
|
+
// prKey so the heal still runs.
|
|
89
|
+
const parsed = parsePr(prKey);
|
|
90
|
+
const healRepo = repo ?? parsed?.repo;
|
|
91
|
+
const healNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
|
|
92
|
+
if (healRepo && typeof healNumber === "number") {
|
|
93
|
+
await ensurePr(app.data, {
|
|
94
|
+
prKey,
|
|
95
|
+
repo: healRepo,
|
|
96
|
+
number: healNumber,
|
|
97
|
+
url: prUrl,
|
|
98
|
+
round,
|
|
99
|
+
abandonToken: abandonTokenFromUrl(abandonUrl),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
// Skip the round insert when the caller already recorded this round (the "review stalled"
|
|
75
104
|
// arm runs after `persist-round`): re-inserting would duplicate the `pr_key`/`round_no` row.
|
|
76
105
|
if (job.variables.recordRound !== false) {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// Data access goes through the injected app datasource gateway (`app.data.table<T>`), the RAD
|
|
6
6
|
// `Table<T>` surface — `rounds.insert(...)` / `pull_requests.update(...)`, not hand-written SQL.
|
|
7
7
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
8
|
+
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
9
|
+
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
8
10
|
|
|
9
11
|
// Extends Record so the declared fields are typed while the job may still carry
|
|
10
12
|
// other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
|
|
@@ -13,6 +15,14 @@ interface In extends Record<string, unknown> {
|
|
|
13
15
|
round: number;
|
|
14
16
|
status?: string;
|
|
15
17
|
summary?: string;
|
|
18
|
+
// Carried by the convergence-loop instance (set at createInstance) so a missing
|
|
19
|
+
// `pull_requests` parent can be reconstructed before the FK-child `rounds` insert.
|
|
20
|
+
repo?: string;
|
|
21
|
+
prNumber?: number;
|
|
22
|
+
prUrl?: string;
|
|
23
|
+
// The per-PR abandon capability URL the review agent was handed; its token is preserved on a
|
|
24
|
+
// heal so the agent's cooperative-abort check keeps resolving (see ensurePr).
|
|
25
|
+
abandonUrl?: string;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
// The harness records the agent's full (byte-capped) stdout on the result envelope; keep it
|
|
@@ -27,9 +37,28 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
27
37
|
// This worker is the "addressed"/"waiting" path, so `status` resolves to one of those
|
|
28
38
|
// domain values. `summary` is left undefined when absent: the write boundary omits it so the
|
|
29
39
|
// nullable `rounds.summary` column stays NULL rather than being coerced to "".
|
|
30
|
-
const { prKey, round, status = "addressed", summary } = job.variables;
|
|
40
|
+
const { prKey, round, status = "addressed", summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
|
|
31
41
|
const now = new Date().toISOString();
|
|
32
42
|
|
|
43
|
+
// Heal a missing FK parent (engine/app.db desync) before the child `rounds` insert so this
|
|
44
|
+
// never dies with an opaque `FOREIGN KEY constraint failed` incident. Prefer the carried
|
|
45
|
+
// repo/prNumber; if either is missing (an older in-flight instance, or a process-variable
|
|
46
|
+
// regression) fall back to parsing them out of the canonical `owner/repo#N` prKey so the heal
|
|
47
|
+
// still runs.
|
|
48
|
+
const parsed = parsePr(prKey);
|
|
49
|
+
const healRepo = repo ?? parsed?.repo;
|
|
50
|
+
const healNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
|
|
51
|
+
if (healRepo && typeof healNumber === "number") {
|
|
52
|
+
await ensurePr(app.data, {
|
|
53
|
+
prKey,
|
|
54
|
+
repo: healRepo,
|
|
55
|
+
number: healNumber,
|
|
56
|
+
url: prUrl,
|
|
57
|
+
round,
|
|
58
|
+
abandonToken: abandonTokenFromUrl(abandonUrl),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
await app.data.table("rounds", "id").insert({
|
|
34
63
|
pr_key: prKey,
|
|
35
64
|
round_no: round,
|