@nanobpm/nano-workforce 0.32.2 → 0.33.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/CHANGELOG.md +20 -0
- package/SPEC.md +5 -2
- package/actions/abandon.ts +1 -1
- package/actions/status.ts +1 -1
- package/actions/version.test.ts +85 -0
- package/actions/version.ts +25 -0
- package/app/abandon.ts +2 -1
- package/app/service.test.ts +1 -74
- package/app/service.ts +0 -64
- package/app/version.ts +205 -0
- package/deno.json +1 -1
- package/deno.lock +5 -5
- package/nano.app.json +9 -5
- package/package.json +2 -2
- package/pages/epic.page.json +14 -2
- package/pages/home.page.json +2 -14
- package/actions/cancel.ts +0 -29
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## [0.33.1](https://github.com/nanobpm/nano-workforce/compare/v0.33.0...v0.33.1) (2026-08-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* adopt Urban's built-in reconcile-aware cancel primitive ([#100](https://github.com/nanobpm/nano-workforce/issues/100)) ([e1aed94](https://github.com/nanobpm/nano-workforce/commit/e1aed94c29fc1ebd8ec0f5ca15d73e6ee723d276)), closes [nanobpm/nano-ide#144](https://github.com/nanobpm/nano-ide/issues/144)
|
|
7
|
+
|
|
8
|
+
# [0.33.0](https://github.com/nanobpm/nano-workforce/compare/v0.32.2...v0.33.0) (2026-08-09)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **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))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **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))
|
|
19
|
+
* **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))
|
|
20
|
+
|
|
1
21
|
## [0.32.2](https://github.com/nanobpm/nano-workforce/compare/v0.32.1...v0.32.2) (2026-08-09)
|
|
2
22
|
|
|
3
23
|
|
package/SPEC.md
CHANGED
|
@@ -267,12 +267,15 @@ carry app-specific business logic:
|
|
|
267
267
|
| method | route | purpose |
|
|
268
268
|
|---|---|---|
|
|
269
269
|
| `POST` | `/app/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process |
|
|
270
|
-
| `POST` | `/app/actions/cancel` | cancel the engine instance + mark the PR `abandoned` |
|
|
271
270
|
| `POST` | `/app/actions/message` (`escalation-answered`) | answer an open escalation → publish `escalation-answered` |
|
|
272
271
|
| `POST` | `/hooks/submit` | webhook submit (shared-secret auth) → start the process |
|
|
273
272
|
|
|
274
273
|
Everything else (`GET /`, `GET /app/pages/*`, `GET /app/data/*`, the renderer) is
|
|
275
|
-
served by the runtime
|
|
274
|
+
served by the runtime — including `POST /app/actions/cancel`, which is Urban's
|
|
275
|
+
built-in reconcile-aware cancel primitive (there is **no** local handler in this
|
|
276
|
+
repo): it terminates the engine instance, verifies the termination, and flips the
|
|
277
|
+
tracked row to `abandoned` via the `instanceTracking` `onTerminated.set` patch.
|
|
278
|
+
`deno task purge` wipes and re-migrates the app db (used
|
|
276
279
|
when the engine data is purged, to keep app state and engine state consistent).
|
|
277
280
|
|
|
278
281
|
## 9. Prompt delivery — model-authored template headers
|
package/actions/abandon.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// unknown token is a 404 (never leaks which PRs exist).
|
|
8
8
|
//
|
|
9
9
|
// GET → { prKey, status, abandoned } — `abandoned` is derived from `pull_requests.status`,
|
|
10
|
-
// which
|
|
10
|
+
// which Urban's cancel primitive sets to 'abandoned' on cancel. `true` ⇒ the agent must stop.
|
|
11
11
|
import type { ActionHandler } from "@nanobpm/urban";
|
|
12
12
|
import { abandonStatusForToken } from "../app/abandon.ts";
|
|
13
13
|
|
package/actions/status.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GET /app/status — list the PRs currently in flight (every tracked PR not converged/abandoned).
|
|
2
2
|
// A read-only projection over the app datasource so an operator or an external automation
|
|
3
|
-
// harness can see active work — and grab a
|
|
3
|
+
// harness can see active work — and grab a `processKey` to cancel — without opening the DB or the UI.
|
|
4
4
|
//
|
|
5
5
|
// Optional shared-secret guard, mirroring /hooks/submit: when NANO_PR_WEBHOOK_SECRET is set,
|
|
6
6
|
// callers must present it via the x-hook-secret header. Unset → open (unchanged default). The
|
|
@@ -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.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
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
13
|
// - DERIVED, not a separate marker. `abandoned` is read straight off `pull_requests.status`,
|
|
14
|
-
// which
|
|
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
|
|
15
16
|
// keep in sync.
|
|
16
17
|
// - ADVISORY. Like the blackboard, this never hard-locks; it narrows an unavoidable
|
|
17
18
|
// check-then-push (TOCTOU) window to near-zero. Job fencing in the harness (issue #76 layer 2)
|
package/app/service.test.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
8
|
import { assertEquals } from "jsr:@std/assert@1";
|
|
9
|
-
import {
|
|
9
|
+
import { pollIncidentsImpl, submitPr } from "./service.ts";
|
|
10
10
|
|
|
11
11
|
// deno-lint-ignore no-explicit-any
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
@@ -252,76 +252,3 @@ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a mi
|
|
|
252
252
|
assertEquals(row.incident_message, "the first fault");
|
|
253
253
|
});
|
|
254
254
|
|
|
255
|
-
|
|
256
|
-
// Bug: the Epic cancel button (Nano Workforce UI) POSTs the plan row's `process_key` to
|
|
257
|
-
// /app/actions/cancel → cancelRun. cancelRun only knows the `pull_requests` table, so for a plan
|
|
258
|
-
// instance it terminated the engine instance but returned `not_found` (a 404 the UI surfaces as an
|
|
259
|
-
// error), and never reconciled the `plans` row — so "cancel didn't cancel the epic". The instance
|
|
260
|
-
// IS torn down; the declarative instanceTracking reconciler flips the plans row. cancelRun must
|
|
261
|
-
// therefore report success for a raw instance key it terminated.
|
|
262
|
-
Deno.test("cancelRun terminates a non-PR (Epic/plan) instance and reports success", async () => {
|
|
263
|
-
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
264
|
-
pull_requests: { rows: [], key: "pr_key" }, // no PR tracks this key — it's a plan instance
|
|
265
|
-
};
|
|
266
|
-
const data = {
|
|
267
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
268
|
-
// deno-lint-ignore no-explicit-any
|
|
269
|
-
} as any;
|
|
270
|
-
const cancelled: string[] = [];
|
|
271
|
-
const engine = {
|
|
272
|
-
// deno-lint-ignore no-explicit-any
|
|
273
|
-
cancelInstance: (input: any) => {
|
|
274
|
-
cancelled.push(String(input.processInstanceKey));
|
|
275
|
-
return Promise.resolve();
|
|
276
|
-
},
|
|
277
|
-
// deno-lint-ignore no-explicit-any
|
|
278
|
-
} as any;
|
|
279
|
-
|
|
280
|
-
const r = await cancelRun(data, engine, { processInstanceKey: "PI-EPIC-1" });
|
|
281
|
-
|
|
282
|
-
assertEquals(cancelled, ["PI-EPIC-1"]); // the engine instance was terminated …
|
|
283
|
-
assertEquals(r.ok, true); // … and cancel is reported successful (no misleading 404).
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
// The tracked-PR path must still flip the row abandoned SYNCHRONOUSLY: app/abandon.ts derives the
|
|
287
|
-
// agent-abort signal straight off pull_requests.status, so a deferred (reconciler-only) write would
|
|
288
|
-
// widen the check-then-push window a side-effecting agent races against.
|
|
289
|
-
Deno.test("cancelRun flips a tracked PR to abandoned immediately and clears the escalation pointer", async () => {
|
|
290
|
-
const PR_KEY = "owner/repo#7";
|
|
291
|
-
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
292
|
-
pull_requests: {
|
|
293
|
-
rows: [{
|
|
294
|
-
pr_key: PR_KEY,
|
|
295
|
-
repo: "owner/repo",
|
|
296
|
-
number: 7,
|
|
297
|
-
status: "escalated",
|
|
298
|
-
process_key: "PI-PR-7",
|
|
299
|
-
open_escalation_id: 3,
|
|
300
|
-
open_escalation_question: "why?",
|
|
301
|
-
}],
|
|
302
|
-
key: "pr_key",
|
|
303
|
-
},
|
|
304
|
-
};
|
|
305
|
-
const data = {
|
|
306
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
307
|
-
// deno-lint-ignore no-explicit-any
|
|
308
|
-
} as any;
|
|
309
|
-
const cancelled: string[] = [];
|
|
310
|
-
const engine = {
|
|
311
|
-
// deno-lint-ignore no-explicit-any
|
|
312
|
-
cancelInstance: (input: any) => {
|
|
313
|
-
cancelled.push(String(input.processInstanceKey));
|
|
314
|
-
return Promise.resolve();
|
|
315
|
-
},
|
|
316
|
-
// deno-lint-ignore no-explicit-any
|
|
317
|
-
} as any;
|
|
318
|
-
|
|
319
|
-
const r = await cancelRun(data, engine, { prKey: PR_KEY });
|
|
320
|
-
|
|
321
|
-
assertEquals(r.ok, true);
|
|
322
|
-
assertEquals(cancelled, ["PI-PR-7"]);
|
|
323
|
-
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
324
|
-
assertEquals(pr.status, "abandoned");
|
|
325
|
-
assertEquals(pr.open_escalation_id, null);
|
|
326
|
-
assertEquals(pr.open_escalation_question, null);
|
|
327
|
-
});
|
package/app/service.ts
CHANGED
|
@@ -438,70 +438,6 @@ export async function answerEscalation(
|
|
|
438
438
|
return { ok: true, escalationId: open.id };
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
-
/** How a caller identifies the run to cancel: by its engine `processInstanceKey` or, more
|
|
442
|
-
* ergonomically, by the `prKey` the status endpoint reports. The cancel action rejects a
|
|
443
|
-
* request that supplies both, so exactly one selector reaches here. */
|
|
444
|
-
export interface CancelSelector {
|
|
445
|
-
processInstanceKey?: string;
|
|
446
|
-
prKey?: string;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/** Cancel a run and mark its read-model row abandoned. Two paths converge here:
|
|
450
|
-
*
|
|
451
|
-
* - **Tracked PR** (a `pull_requests` row): the engine instance is terminated (which emits no
|
|
452
|
-
* completion event — no worker runs) and this function flips the PR row to `abandoned`
|
|
453
|
-
* *synchronously*. The immediacy matters: the agent-abort capability (`app/abandon.ts`) derives
|
|
454
|
-
* `abandoned` straight off `pull_requests.status`, so a deferred write would widen the
|
|
455
|
-
* check-then-push window a side-effecting agent races against.
|
|
456
|
-
* - **Any other instance** (e.g. the cancel button on an Epic/plan row, which POSTs the row's
|
|
457
|
-
* `process_key`): the instance is terminated here, and the declarative `instanceTracking`
|
|
458
|
-
* reconciler (`nano.app.json`) flips the owning row (`plans`) to abandoned on its next poll.
|
|
459
|
-
* That same reconciler is also the safety net for terminations that never reach this function
|
|
460
|
-
* at all — an operator terminating the instance directly, or a crash.
|
|
461
|
-
*
|
|
462
|
-
* Accepts either selector; a PR already in a terminal state is left untouched so a stale cancel
|
|
463
|
-
* can't overwrite a `converged` outcome with `abandoned`. */
|
|
464
|
-
export async function cancelRun(data: DataLayer, engine: EngineClient, selector: CancelSelector) {
|
|
465
|
-
const { processInstanceKey, prKey } = selector;
|
|
466
|
-
const table = prs(data);
|
|
467
|
-
const pr = prKey
|
|
468
|
-
? await table.get(prKey)
|
|
469
|
-
: processInstanceKey
|
|
470
|
-
? (await table.find({ process_key: processInstanceKey }))[0]
|
|
471
|
-
: undefined;
|
|
472
|
-
if (pr && TERMINAL_STATUSES.includes(pr.status)) {
|
|
473
|
-
return { ok: false, kind: "terminal", reason: `PR already ${pr.status}`, prKey: pr.pr_key };
|
|
474
|
-
}
|
|
475
|
-
const instanceKey = pr?.process_key ?? processInstanceKey ?? null;
|
|
476
|
-
if (instanceKey) {
|
|
477
|
-
try {
|
|
478
|
-
await engine.cancelInstance({ processInstanceKey: instanceKey });
|
|
479
|
-
} catch (err) {
|
|
480
|
-
// The instance may already be gone (converged/cancelled) — still reconcile the app row so
|
|
481
|
-
// a stale "converging" PR can't linger in the UI.
|
|
482
|
-
console.warn(`[cancel] engine cancel for ${instanceKey}: ${err}`);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
if (pr) {
|
|
486
|
-
await table.update(pr.pr_key, {
|
|
487
|
-
status: "abandoned",
|
|
488
|
-
updated_at: now(),
|
|
489
|
-
open_escalation_id: null,
|
|
490
|
-
open_escalation_question: null,
|
|
491
|
-
});
|
|
492
|
-
return { ok: true, prKey: pr.pr_key };
|
|
493
|
-
}
|
|
494
|
-
// No tracked PR for this key. If we were handed a raw instance key (e.g. the cancel button
|
|
495
|
-
// on an Epic/plan row, which POSTs the row's `process_key`), the instance has still been
|
|
496
|
-
// terminated above — the declarative `instanceTracking` reconciler (nano.app.json) flips the
|
|
497
|
-
// owning row (`plans`) to abandoned on its next poll. Report success so the UI does not
|
|
498
|
-
// surface a misleading 404 for a cancel that actually took effect.
|
|
499
|
-
if (instanceKey) {
|
|
500
|
-
return { ok: true, processInstanceKey: instanceKey };
|
|
501
|
-
}
|
|
502
|
-
return { ok: false, kind: "not_found", reason: "no PR for that selector" };
|
|
503
|
-
}
|
|
504
|
-
|
|
505
441
|
/** A PR currently in flight, as reported by the status endpoint. */
|
|
506
442
|
export interface ActivePr {
|
|
507
443
|
prKey: string;
|
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/deno.json
CHANGED
package/deno.lock
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"specifiers": {
|
|
4
4
|
"jsr:@std/assert@1": "1.0.19",
|
|
5
5
|
"jsr:@std/internal@^1.0.12": "1.0.14",
|
|
6
|
-
"npm:@nanobpm/urban@0.
|
|
6
|
+
"npm:@nanobpm/urban@0.30": "0.30.0",
|
|
7
7
|
"npm:@semantic-release/changelog@^6.0.3": "6.0.3_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
|
|
8
8
|
"npm:@semantic-release/git@^10.0.1": "10.0.1_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
|
|
9
9
|
"npm:@semantic-release/npm@^13.1.5": "13.1.5_semantic-release@24.2.9__typescript@5.9.3",
|
|
@@ -82,8 +82,8 @@
|
|
|
82
82
|
"ws"
|
|
83
83
|
]
|
|
84
84
|
},
|
|
85
|
-
"@nanobpm/urban@0.
|
|
86
|
-
"integrity": "sha512-
|
|
85
|
+
"@nanobpm/urban@0.30.0": {
|
|
86
|
+
"integrity": "sha512-jaxVFwmWk/dLyA/Owgyons7tc8Yx0iJfotY7mN63hCQS7jenak9d/h3wQmevphF+JDq7E8Ovo+9Wq2K1CGy+yw==",
|
|
87
87
|
"dependencies": [
|
|
88
88
|
"@nanobpm/nano-app-schema",
|
|
89
89
|
"@nanobpm/nano-sdk",
|
|
@@ -1757,11 +1757,11 @@
|
|
|
1757
1757
|
},
|
|
1758
1758
|
"workspace": {
|
|
1759
1759
|
"dependencies": [
|
|
1760
|
-
"npm:@nanobpm/urban@0.
|
|
1760
|
+
"npm:@nanobpm/urban@0.30"
|
|
1761
1761
|
],
|
|
1762
1762
|
"packageJson": {
|
|
1763
1763
|
"dependencies": [
|
|
1764
|
-
"npm:@nanobpm/urban@0.
|
|
1764
|
+
"npm:@nanobpm/urban@0.30",
|
|
1765
1765
|
"npm:@semantic-release/changelog@^6.0.3",
|
|
1766
1766
|
"npm:@semantic-release/git@^10.0.1",
|
|
1767
1767
|
"npm:@semantic-release/npm@^13.1.5",
|
package/nano.app.json
CHANGED
|
@@ -50,7 +50,10 @@
|
|
|
50
50
|
"table": "plans",
|
|
51
51
|
"keyField": "process_key",
|
|
52
52
|
"statusField": "status",
|
|
53
|
-
"activeStatuses": [
|
|
53
|
+
"activeStatuses": [
|
|
54
|
+
"planning",
|
|
55
|
+
"dispatched"
|
|
56
|
+
],
|
|
54
57
|
"onTerminated": {
|
|
55
58
|
"set": {
|
|
56
59
|
"status": "abandoned"
|
|
@@ -145,15 +148,16 @@
|
|
|
145
148
|
"path": "/app/actions/start/plan-fanout",
|
|
146
149
|
"module": "actions/plan-start.ts"
|
|
147
150
|
},
|
|
148
|
-
{
|
|
149
|
-
"path": "/app/actions/cancel",
|
|
150
|
-
"module": "actions/cancel.ts"
|
|
151
|
-
},
|
|
152
151
|
{
|
|
153
152
|
"path": "/app/status",
|
|
154
153
|
"module": "actions/status.ts",
|
|
155
154
|
"method": "GET"
|
|
156
155
|
},
|
|
156
|
+
{
|
|
157
|
+
"path": "/app/version",
|
|
158
|
+
"module": "actions/version.ts",
|
|
159
|
+
"method": "GET"
|
|
160
|
+
},
|
|
157
161
|
{
|
|
158
162
|
"path": "/app/actions/message",
|
|
159
163
|
"module": "actions/message.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"test": "deno test -A"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@nanobpm/urban": "^0.
|
|
43
|
+
"@nanobpm/urban": "^0.30.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@semantic-release/changelog": "^6.0.3",
|
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
|
],
|
package/actions/cancel.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
// POST /app/actions/cancel — override the generic row-cancel action. Terminating the engine
|
|
2
|
-
// instance emits no completion event, so reconcile the app row (status='abandoned', clear the
|
|
3
|
-
// open escalation) here. Accepts either `processInstanceKey` or the `prKey` the status endpoint
|
|
4
|
-
// reports, so a caller can cancel a run it discovered via GET /app/status.
|
|
5
|
-
import type { ActionHandler } from "@nanobpm/urban";
|
|
6
|
-
import { cancelRun } from "../app/service.ts";
|
|
7
|
-
|
|
8
|
-
const str = (v: unknown): string | undefined => {
|
|
9
|
-
if (v == null) return undefined;
|
|
10
|
-
const s = String(v).trim();
|
|
11
|
-
return s === "" ? undefined : s;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const handler: ActionHandler = async ({ body }, app) => {
|
|
15
|
-
const b = (body ?? {}) as { processInstanceKey?: unknown; prKey?: unknown };
|
|
16
|
-
const processInstanceKey = str(b.processInstanceKey);
|
|
17
|
-
const prKey = str(b.prKey);
|
|
18
|
-
if (!processInstanceKey && !prKey) {
|
|
19
|
-
return { status: 400, body: { error: "processInstanceKey or prKey is required" } };
|
|
20
|
-
}
|
|
21
|
-
if (processInstanceKey && prKey) {
|
|
22
|
-
return { status: 400, body: { error: "provide exactly one of processInstanceKey or prKey" } };
|
|
23
|
-
}
|
|
24
|
-
const r = await cancelRun(app.data, app.engine, { processInstanceKey, prKey });
|
|
25
|
-
if (r.ok) return { status: 200, body: r };
|
|
26
|
-
return { status: r.kind === "terminal" ? 409 : 404, body: r };
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
export default handler;
|