@nanobpm/nano-workforce 0.37.0 → 0.38.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 +7 -0
- package/README.md +5 -6
- package/SPEC.md +15 -13
- package/app/abandon.test.ts +4 -4
- package/app/abandon.ts +2 -2
- package/app/blackboard.test.ts +3 -3
- package/app/blackboard.ts +2 -2
- package/app/persist-escalation.test.ts +1 -1
- package/app/persist-round.test.ts +1 -1
- package/app/service.ts +1 -1
- package/biome.json +0 -2
- package/main.ts +3 -3
- package/nano.app.json +2 -25
- package/openapi.yaml +649 -0
- package/operations/answerFeatureEscalation.test.ts +100 -0
- package/operations/answerFeatureEscalation.ts +49 -0
- package/operations/appendBlackboard.ts +61 -0
- package/{actions → operations}/blackboard.test.ts +16 -10
- package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +5 -11
- package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
- package/operations/listActivePrs.ts +1 -1
- package/operations/postMessage.ts +1 -1
- package/operations/readBlackboard.ts +29 -0
- package/operations/startAndMessage.test.ts +5 -3
- package/operations/startConvergenceLoop.ts +17 -11
- package/operations/startPlanFanout.ts +13 -7
- package/package.json +4 -4
- package/pages/epic.page.json +1 -1
- package/pages/home.page.json +1 -1
- package/workers/persist-task-escalation/worker.ts +1 -1
- package/actions/blackboard.ts +0 -77
- package/actions/feature-answer-hook.ts +0 -45
- package/actions/plan-hook.ts +0 -20
- package/actions/webhook-submit.ts +0 -22
- package/openapi.json +0 -248
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { assertEquals } from "#test-assert";
|
|
3
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
4
|
+
|
|
5
|
+
const hadSecret = Object.prototype.hasOwnProperty.call(process.env, "NANO_PR_WEBHOOK_SECRET");
|
|
6
|
+
const previousSecret = process.env.NANO_PR_WEBHOOK_SECRET;
|
|
7
|
+
let answerFeatureEscalation: typeof import("./answerFeatureEscalation.ts").default;
|
|
8
|
+
try {
|
|
9
|
+
process.env.NANO_PR_WEBHOOK_SECRET = " test-secret ";
|
|
10
|
+
answerFeatureEscalation = (await import("./answerFeatureEscalation.ts")).default;
|
|
11
|
+
} finally {
|
|
12
|
+
if (hadSecret && previousSecret !== undefined) process.env.NANO_PR_WEBHOOK_SECRET = previousSecret;
|
|
13
|
+
else delete process.env.NANO_PR_WEBHOOK_SECRET;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function memTable(rows: any[], key: string) {
|
|
17
|
+
return {
|
|
18
|
+
find: (where: Record<string, unknown>) =>
|
|
19
|
+
Promise.resolve(rows.filter((row) => Object.entries(where).every(([field, value]) => row[field] === value))),
|
|
20
|
+
update: (value: unknown, patch: Record<string, unknown>) => {
|
|
21
|
+
const row = rows.find((candidate) => candidate[key] === value);
|
|
22
|
+
if (row) Object.assign(row, patch);
|
|
23
|
+
return Promise.resolve(row);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function memApp(escalations: any[] = []) {
|
|
29
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
30
|
+
plans: { rows: [{ plan_key: "owner/repo#9" }], key: "plan_key" },
|
|
31
|
+
plan_escalations: { rows: escalations, key: "id" },
|
|
32
|
+
plan_tasks: { rows: [{ id: 1, plan_key: "owner/repo#9", task_id: "task-1" }], key: "id" },
|
|
33
|
+
};
|
|
34
|
+
const published: Record<string, unknown>[] = [];
|
|
35
|
+
const app = {
|
|
36
|
+
data: {
|
|
37
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
38
|
+
},
|
|
39
|
+
engine: {
|
|
40
|
+
publishMessage: (message: Record<string, unknown>) => {
|
|
41
|
+
published.push(message);
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
} as any as AppApi;
|
|
46
|
+
return { app, published };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function input(body: Record<string, unknown>, secret?: string) {
|
|
50
|
+
const headers = new Headers();
|
|
51
|
+
if (secret !== undefined) headers.set("x-hook-secret", secret);
|
|
52
|
+
return {
|
|
53
|
+
req: {
|
|
54
|
+
method: "POST",
|
|
55
|
+
path: "/app/api/hooks/feature-answer",
|
|
56
|
+
query: new URLSearchParams(),
|
|
57
|
+
headers,
|
|
58
|
+
text: async () => "",
|
|
59
|
+
} as any,
|
|
60
|
+
params: {},
|
|
61
|
+
query: {},
|
|
62
|
+
body,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("rejects a request without the configured hook secret", async () => {
|
|
67
|
+
const { app } = memApp();
|
|
68
|
+
const result = await answerFeatureEscalation(input({ corrKey: "owner/repo#9:task-1", answer: "yes" }), app) as any;
|
|
69
|
+
assertEquals(result.status, 401);
|
|
70
|
+
assertEquals(result.body, { ok: false, error: "unauthorized" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("derives corrKey from plan + task and maps an answered escalation to 200", async () => {
|
|
74
|
+
const { app, published } = memApp([{
|
|
75
|
+
id: 1,
|
|
76
|
+
plan_key: "owner/repo#9",
|
|
77
|
+
task_id: "task-1",
|
|
78
|
+
corr_key: "owner/repo#9:task-1",
|
|
79
|
+
question: "Proceed?",
|
|
80
|
+
status: "open",
|
|
81
|
+
}]);
|
|
82
|
+
const result = await answerFeatureEscalation(
|
|
83
|
+
input({ plan: "owner/repo#9", task: "task-1", answer: " yes " }, "test-secret"),
|
|
84
|
+
app,
|
|
85
|
+
) as any;
|
|
86
|
+
assertEquals(result.status, 200);
|
|
87
|
+
assertEquals(result.body.ok, true);
|
|
88
|
+
assertEquals(published[0]?.correlationKey, "owner/repo#9:task-1");
|
|
89
|
+
assertEquals((published[0]?.variables as Record<string, unknown>).answer, "yes");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("maps an unmatched corrKey to 404", async () => {
|
|
93
|
+
const { app } = memApp();
|
|
94
|
+
const result = await answerFeatureEscalation(
|
|
95
|
+
input({ corrKey: "owner/repo#9:missing", answer: "yes" }, "test-secret"),
|
|
96
|
+
app,
|
|
97
|
+
) as any;
|
|
98
|
+
assertEquals(result.status, 404);
|
|
99
|
+
assertEquals(result.body.ok, false);
|
|
100
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// POST /app/api/hooks/feature-answer → operationId `answerFeatureEscalation` (ADR 0059 webhook
|
|
2
|
+
// operation; was the `/hooks/feature-answer` action). Answers an implementation-phase task
|
|
3
|
+
// escalation out of band (optional shared-secret guard via X-Hook-Secret, enforced only when
|
|
4
|
+
// NANO_PR_WEBHOOK_SECRET is set — mirrors the operator control surface), issue #25. Lets an
|
|
5
|
+
// external system (a chat relay, a CI job, a human via curl) resume a parked implementation agent
|
|
6
|
+
// without the page. Same idempotent `answerTaskEscalation` path the page's answer form uses.
|
|
7
|
+
//
|
|
8
|
+
// The runtime validates the body shape against openapi.yaml; this delegate keeps the semantic
|
|
9
|
+
// checks (an answer is required; a correlation key must be resolvable) and the shared-secret guard.
|
|
10
|
+
// Body accepts either the raw correlation key or a plan+task pair:
|
|
11
|
+
// { "corrKey": "owner/repo#12:task-3", "answer": "…" }
|
|
12
|
+
// { "plan": "owner/repo#12", "task": "task-3", "answer": "…" }
|
|
13
|
+
import { defineOperation } from "@nanobpm/urban";
|
|
14
|
+
import { answerTaskEscalation, featureCorrKey } from "../app/plan.ts";
|
|
15
|
+
import { envVar } from "../app/version.ts";
|
|
16
|
+
|
|
17
|
+
const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
18
|
+
|
|
19
|
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
20
|
+
|
|
21
|
+
interface Body {
|
|
22
|
+
corrKey?: unknown;
|
|
23
|
+
plan?: unknown;
|
|
24
|
+
task?: unknown;
|
|
25
|
+
answer?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default defineOperation<
|
|
29
|
+
{ params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: Body },
|
|
30
|
+
{ ok: boolean } & Record<string, unknown>
|
|
31
|
+
>("answerFeatureEscalation", async ({ req, body }, app) => {
|
|
32
|
+
if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
|
|
33
|
+
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
|
34
|
+
}
|
|
35
|
+
const b = body ?? {};
|
|
36
|
+
const answer = str(b.answer);
|
|
37
|
+
if (!answer) return { status: 400, body: { ok: false, error: "answer is required" } };
|
|
38
|
+
|
|
39
|
+
const corrKey = str(b.corrKey) || (str(b.plan) && str(b.task) ? featureCorrKey(str(b.plan), str(b.task)) : "");
|
|
40
|
+
if (!corrKey) {
|
|
41
|
+
return {
|
|
42
|
+
status: 400,
|
|
43
|
+
body: { ok: false, error: "provide corrKey, or both plan (owner/repo#N) and task" },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
|
|
48
|
+
return { status: r.ok ? 200 : 404, body: r };
|
|
49
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// POST /app/api/hooks/blackboard?token=<capabilityToken> → operationId `appendBlackboard` (ADR 0059
|
|
2
|
+
// webhook operation; was the POST half of the `/hooks/blackboard` action; Tier 1, issues #51 / #49 D4).
|
|
3
|
+
//
|
|
4
|
+
// The per-plan capability token (query string) IS the credential (see readBlackboard). An unknown
|
|
5
|
+
// token is a 404 (never leaks which plans exist).
|
|
6
|
+
//
|
|
7
|
+
// POST → append one entry: { author_task?, kind?, files?, body, wave?, dedupe_key? }. Idempotent
|
|
8
|
+
// on (plan, dedupe_key). Returns { id, inserted, conflicts } — `conflicts` lists prior
|
|
9
|
+
// sibling `file-claim`s on the same file(s) (advisory first-writer-wins; never a lock).
|
|
10
|
+
import { defineOperation } from "@nanobpm/urban";
|
|
11
|
+
import type { ClaimConflict } from "../app/blackboard.ts";
|
|
12
|
+
import {
|
|
13
|
+
appendEntry,
|
|
14
|
+
detectFileClaimConflicts,
|
|
15
|
+
normalizeKind,
|
|
16
|
+
planKeyForToken,
|
|
17
|
+
} from "../app/blackboard.ts";
|
|
18
|
+
|
|
19
|
+
export default defineOperation<
|
|
20
|
+
{ params: Record<string, string>; query: { token?: string }; body: Record<string, unknown> },
|
|
21
|
+
{ id: number; inserted: boolean; conflicts: ClaimConflict[] } | { error: string }
|
|
22
|
+
>("appendBlackboard", async ({ req, body }, app) => {
|
|
23
|
+
const token = (req.query.get("token") ?? req.headers.get("x-blackboard-token") ?? "").trim();
|
|
24
|
+
if (!token) return { status: 400, body: { error: "missing blackboard token" } };
|
|
25
|
+
const planKey = await planKeyForToken(app.data, token);
|
|
26
|
+
if (!planKey) return { status: 404, body: { error: "unknown blackboard token" } };
|
|
27
|
+
|
|
28
|
+
const b = body ?? {};
|
|
29
|
+
const text = typeof b.body === "string" ? b.body.trim() : "";
|
|
30
|
+
if (!text) return { status: 400, body: { error: "'body' (the note text) is required" } };
|
|
31
|
+
const kind = normalizeKind(b.kind);
|
|
32
|
+
const files = Array.isArray(b.files) ? b.files.map(String) : [];
|
|
33
|
+
// Normalize once (trim + default to "system") so the value we send to appendEntry matches the
|
|
34
|
+
// value we send to detectFileClaimConflicts. Otherwise an omitted/blank author_task is stored as
|
|
35
|
+
// "system" but conflict detection sees "", and the caller's own prior "system" claims are wrongly
|
|
36
|
+
// reported as sibling conflicts.
|
|
37
|
+
const author_task = (typeof b.author_task === "string" ? b.author_task.trim() : "") || "system";
|
|
38
|
+
const res = await appendEntry(app.data, planKey, {
|
|
39
|
+
author_task,
|
|
40
|
+
kind,
|
|
41
|
+
files,
|
|
42
|
+
body: text,
|
|
43
|
+
wave: typeof b.wave === "number" ? b.wave : null,
|
|
44
|
+
dedupe_key: typeof b.dedupe_key === "string" ? b.dedupe_key : undefined,
|
|
45
|
+
});
|
|
46
|
+
// Advisory conflict-of-intent: surface prior sibling claims on the same file(s). Computed AFTER
|
|
47
|
+
// the append and filtered to claims strictly before ours (id < res.id), so first-writer-wins is
|
|
48
|
+
// decided by insertion order — a sibling that raced a claim in between is still caught, and our
|
|
49
|
+
// own just-written row is never reported. Never blocks the append — the agent decides how to react.
|
|
50
|
+
const conflicts = kind === "file-claim"
|
|
51
|
+
? await detectFileClaimConflicts(app.data, planKey, {
|
|
52
|
+
author_task,
|
|
53
|
+
files,
|
|
54
|
+
beforeId: Number(res.id),
|
|
55
|
+
})
|
|
56
|
+
: [];
|
|
57
|
+
return {
|
|
58
|
+
status: res.inserted ? 201 : 200,
|
|
59
|
+
body: { id: Number(res.id), inserted: res.inserted, conflicts },
|
|
60
|
+
};
|
|
61
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// Tests for the /hooks/blackboard
|
|
1
|
+
// Tests for the /app/api/hooks/blackboard operations `readBlackboard` (GET) + `appendBlackboard`
|
|
2
|
+
// (POST) (ADR 0059; Tier 1, issues #51 / #49 D4).
|
|
2
3
|
import { test } from "node:test";
|
|
3
4
|
import { assertEquals } from "#test-assert";
|
|
4
5
|
import type { AppApi } from "@nanobpm/urban";
|
|
5
|
-
import
|
|
6
|
+
import readBlackboard from "./readBlackboard.ts";
|
|
7
|
+
import appendBlackboard from "./appendBlackboard.ts";
|
|
6
8
|
|
|
7
9
|
function memApp(): { app: AppApi; stores: Record<string, any[]> } {
|
|
8
10
|
const stores: Record<string, any[]> = {};
|
|
@@ -34,7 +36,7 @@ function memApp(): { app: AppApi; stores: Record<string, any[]> } {
|
|
|
34
36
|
function req(method: string, query: Record<string, string>) {
|
|
35
37
|
return {
|
|
36
38
|
method,
|
|
37
|
-
path: "/hooks/blackboard",
|
|
39
|
+
path: "/app/api/hooks/blackboard",
|
|
38
40
|
query: new URLSearchParams(query),
|
|
39
41
|
headers: new Headers(),
|
|
40
42
|
text: async () => "",
|
|
@@ -47,7 +49,17 @@ async function call(
|
|
|
47
49
|
query: Record<string, string>,
|
|
48
50
|
body?: unknown,
|
|
49
51
|
) {
|
|
50
|
-
|
|
52
|
+
// Method routing is the runtime's job; here we dispatch to the delegate the spec mounts per verb.
|
|
53
|
+
// Be explicit so an unexpected method fails loudly rather than silently running the POST delegate.
|
|
54
|
+
const handler =
|
|
55
|
+
method === "GET"
|
|
56
|
+
? readBlackboard
|
|
57
|
+
: method === "POST"
|
|
58
|
+
? appendBlackboard
|
|
59
|
+
: (() => {
|
|
60
|
+
throw new Error(`blackboard test helper: unsupported method ${method}`);
|
|
61
|
+
})();
|
|
62
|
+
const res = await handler({ req: req(method, query) as any, params: {}, query: {}, body } as any, app);
|
|
51
63
|
return res as any;
|
|
52
64
|
}
|
|
53
65
|
|
|
@@ -179,9 +191,3 @@ test("POST a non-file-claim carries no conflicts", async () => {
|
|
|
179
191
|
const res = await call(app, "POST", { token: "tok" }, { author_task: "t", kind: "note", body: "fyi" });
|
|
180
192
|
assertEquals(res.body.conflicts, []);
|
|
181
193
|
});
|
|
182
|
-
|
|
183
|
-
test("unsupported method → 405", async () => {
|
|
184
|
-
const { app } = memApp();
|
|
185
|
-
await seedPlan(app, "o/r#1", "tok");
|
|
186
|
-
assertEquals((await call(app, "DELETE", { token: "tok" })).status, 405);
|
|
187
|
-
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Tests for the GET /hooks/abandon
|
|
1
|
+
// Tests for the GET /app/api/hooks/abandon operation `checkAbandon` (ADR 0059; issue #76).
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import { assertEquals } from "#test-assert";
|
|
4
4
|
import type { AppApi } from "@nanobpm/urban";
|
|
5
|
-
import handler from "./
|
|
5
|
+
import handler from "./checkAbandon.ts";
|
|
6
6
|
|
|
7
7
|
function memApp(): { app: AppApi } {
|
|
8
8
|
const stores: Record<string, any[]> = {};
|
|
@@ -25,7 +25,7 @@ function memApp(): { app: AppApi } {
|
|
|
25
25
|
function req(method: string, query: Record<string, string>) {
|
|
26
26
|
return {
|
|
27
27
|
method,
|
|
28
|
-
path: "/hooks/abandon",
|
|
28
|
+
path: "/app/api/hooks/abandon",
|
|
29
29
|
query: new URLSearchParams(query),
|
|
30
30
|
headers: new Headers(),
|
|
31
31
|
text: async () => "",
|
|
@@ -33,7 +33,7 @@ function req(method: string, query: Record<string, string>) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
async function call(app: AppApi, method: string, query: Record<string, string>) {
|
|
36
|
-
const res = await handler({ req: req(method, query) as any, body: undefined }, app);
|
|
36
|
+
const res = await handler({ req: req(method, query) as any, params: {}, query: {}, body: undefined }, app);
|
|
37
37
|
return res as any;
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -73,13 +73,7 @@ test("token via header is accepted", async () => {
|
|
|
73
73
|
await seedPr(app, "o/r#1", "tok", "abandoned");
|
|
74
74
|
const r = req("GET", {});
|
|
75
75
|
r.headers.set("x-abandon-token", "tok");
|
|
76
|
-
const res = await handler({ req: r as any, body: undefined }, app) as any;
|
|
76
|
+
const res = await handler({ req: r as any, params: {}, query: {}, body: undefined }, app) as any;
|
|
77
77
|
assertEquals(res.status, 200);
|
|
78
78
|
assertEquals(res.body.abandoned, true);
|
|
79
79
|
});
|
|
80
|
-
|
|
81
|
-
test("non-GET → 405", async () => {
|
|
82
|
-
const { app } = memApp();
|
|
83
|
-
await seedPr(app, "o/r#1", "tok", "converging");
|
|
84
|
-
assertEquals((await call(app, "POST", { token: "tok" })).status, 405);
|
|
85
|
-
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// GET /hooks/abandon?token=<capabilityToken>
|
|
1
|
+
// GET /app/api/hooks/abandon?token=<capabilityToken> → operationId `checkAbandon` (ADR 0059
|
|
2
|
+
// webhook operation; was the `/hooks/abandon` action, issue #76).
|
|
2
3
|
//
|
|
3
4
|
// A DIRECT side-channel for a running `senior:*` agent to learn whether its run was cancelled
|
|
4
5
|
// before it performs an irreversible side effect (push / open PR / request review / merge). The
|
|
@@ -8,16 +9,16 @@
|
|
|
8
9
|
//
|
|
9
10
|
// GET → { prKey, status, abandoned } — `abandoned` is derived from `pull_requests.status`,
|
|
10
11
|
// which Urban's cancel primitive sets to 'abandoned' on cancel. `true` ⇒ the agent must stop.
|
|
11
|
-
import
|
|
12
|
+
import { defineOperation } from "@nanobpm/urban";
|
|
12
13
|
import { abandonStatusForToken } from "../app/abandon.ts";
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
export default defineOperation<
|
|
16
|
+
{ params: Record<string, string>; query: { token?: string }; body: unknown },
|
|
17
|
+
{ prKey: string; status: string; abandoned: boolean } | { error: string }
|
|
18
|
+
>("checkAbandon", async ({ req }, app) => {
|
|
16
19
|
const token = (req.query.get("token") ?? req.headers.get("x-abandon-token") ?? "").trim();
|
|
17
20
|
if (!token) return { status: 400, body: { error: "missing abandon token" } };
|
|
18
21
|
const state = await abandonStatusForToken(app.data, token);
|
|
19
22
|
if (!state) return { status: 404, body: { error: "unknown abandon token" } };
|
|
20
23
|
return { status: 200, body: state };
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export default handler;
|
|
24
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// external automation harness can see active work — and grab a `processKey` to cancel — without
|
|
4
4
|
// opening the DB or the UI. Read-only projection over the datasource.
|
|
5
5
|
//
|
|
6
|
-
// The runtime validates the (empty) request against openapi.
|
|
6
|
+
// The runtime validates the (empty) request against openapi.yaml; the optional shared-secret guard
|
|
7
7
|
// stays HERE (the runtime does not enforce OpenAPI `security`): when NANO_PR_WEBHOOK_SECRET is set,
|
|
8
8
|
// callers must present it via the x-hook-secret header. Unset → open (unchanged default).
|
|
9
9
|
import { defineOperation } from "@nanobpm/urban";
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// token, then re-surface the next open escalation. Any other message falls back to a plain
|
|
6
6
|
// publishMessage.
|
|
7
7
|
//
|
|
8
|
-
// The runtime validates the body against openapi.
|
|
8
|
+
// The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
|
|
9
9
|
// for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
|
|
10
10
|
// is app logic, not something the JSON schema can express.
|
|
11
11
|
import { defineOperation } from "@nanobpm/urban";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// GET /app/api/hooks/blackboard?token=<capabilityToken> → operationId `readBlackboard` (ADR 0059
|
|
2
|
+
// webhook operation; was the GET half of the `/hooks/blackboard` action; Tier 1, issues #51 / #49 D4).
|
|
3
|
+
//
|
|
4
|
+
// A DIRECT side-channel for agents, distinct from the c8ctl-nano activation/completion channel. The
|
|
5
|
+
// per-plan capability token (query string) IS the credential: it scopes every read to exactly one
|
|
6
|
+
// plan, so no shared secret is needed — the agent curls the exact URL it was handed in its prompt.
|
|
7
|
+
// An unknown token is a 404 (never leaks which plans exist).
|
|
8
|
+
//
|
|
9
|
+
// GET → { planKey, entries: [ { id, author_task, kind, files, body, wave, created_at } ], cursor }
|
|
10
|
+
// optional ?since=<id> returns only entries with id > since (incremental poll). `cursor` is
|
|
11
|
+
// the plan's current head id; pass it back as `since` on the next poll (Tier 2).
|
|
12
|
+
import { defineOperation } from "@nanobpm/urban";
|
|
13
|
+
import type { BlackboardPage } from "../app/blackboard.ts";
|
|
14
|
+
import { planKeyForToken, readBlackboardPage } from "../app/blackboard.ts";
|
|
15
|
+
|
|
16
|
+
export default defineOperation<
|
|
17
|
+
{ params: Record<string, string>; query: { token?: string; since?: string }; body: unknown },
|
|
18
|
+
(BlackboardPage & { planKey: string }) | { error: string }
|
|
19
|
+
>("readBlackboard", async ({ req }, app) => {
|
|
20
|
+
const token = (req.query.get("token") ?? req.headers.get("x-blackboard-token") ?? "").trim();
|
|
21
|
+
if (!token) return { status: 400, body: { error: "missing blackboard token" } };
|
|
22
|
+
const planKey = await planKeyForToken(app.data, token);
|
|
23
|
+
if (!planKey) return { status: 404, body: { error: "unknown blackboard token" } };
|
|
24
|
+
|
|
25
|
+
const rawSince = req.query.get("since");
|
|
26
|
+
const since = rawSince != null && /^\d+$/.test(rawSince) ? Number(rawSince) : undefined;
|
|
27
|
+
const { entries, cursor } = await readBlackboardPage(app.data, planKey, { since });
|
|
28
|
+
return { status: 200, body: { planKey, entries, cursor } };
|
|
29
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Tests for the start/message operation delegates (ADR 0058 OpenAPI surface).
|
|
2
2
|
// These cover the app-logic guards the JSON schema can't express (reference parsing, message-name
|
|
3
|
-
// dispatch)
|
|
3
|
+
// dispatch). The delegates reject an unparseable/blank `pr`/`issue` with a 400 (the schema itself
|
|
4
|
+
// marks neither required, since `StartVariables` is shared across convergence and planning); the
|
|
5
|
+
// runtime's schema-level validation (e.g. `postMessage`'s required `name`) is exercised by urban's
|
|
4
6
|
// own api runtime tests.
|
|
5
7
|
import { test } from "node:test";
|
|
6
8
|
import { assertEquals } from "#test-assert";
|
|
@@ -21,14 +23,14 @@ function input(body: any) {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
test("startConvergenceLoop → 400 on an unparseable PR reference", async () => {
|
|
24
|
-
const res = await startConvergenceLoop(input({
|
|
26
|
+
const res = await startConvergenceLoop(input({ pr: "not a pr" }), app);
|
|
25
27
|
const r = res as any;
|
|
26
28
|
assertEquals(r.status, 400);
|
|
27
29
|
assertEquals(typeof r.body.error, "string");
|
|
28
30
|
});
|
|
29
31
|
|
|
30
32
|
test("startPlanFanout → 400 on an unparseable issue reference", async () => {
|
|
31
|
-
const res = await startPlanFanout(input({
|
|
33
|
+
const res = await startPlanFanout(input({ issue: "" }), app);
|
|
32
34
|
const r = res as any;
|
|
33
35
|
assertEquals(r.status, 400);
|
|
34
36
|
assertEquals(typeof r.body.error, "string");
|
|
@@ -1,28 +1,34 @@
|
|
|
1
|
-
// POST /app/api/actions/start/convergence-loop → operationId `startConvergenceLoop` (ADR 0058,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// POST /app/api/actions/start/convergence-loop → operationId `startConvergenceLoop` (ADR 0058/0059,
|
|
2
|
+
// base /app/api). The ONE door for starting a convergence loop — the page's "Start review" form, an
|
|
3
|
+
// external webhook relay, a CI job, and Swagger all POST here. Parse the PR reference and
|
|
4
|
+
// register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
|
|
4
5
|
//
|
|
5
|
-
// The
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// The request body is FLAT (`{ pr | url, dependsOn?, maxRounds? }`), not wrapped in a `variables`
|
|
7
|
+
// envelope: this is a purpose-built operation, not a generic engine "start process" call, so it does
|
|
8
|
+
// not leak the engine's variable-map concept to callers. The runtime validates the body against
|
|
9
|
+
// openapi.yaml; this delegate keeps the PR-parse guard because the reference format (owner/repo#123
|
|
10
|
+
// or a URL) is app logic, not something the JSON schema can express — an unparseable reference is a 400.
|
|
8
11
|
import { defineOperation } from "@nanobpm/urban";
|
|
9
12
|
import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
|
|
10
13
|
|
|
11
14
|
interface Body {
|
|
12
|
-
|
|
15
|
+
pr?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
dependsOn?: unknown;
|
|
18
|
+
maxRounds?: unknown;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export default defineOperation<
|
|
16
22
|
{ params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: Body },
|
|
17
23
|
{ prKey: string; alreadyRunning?: boolean; processKey?: string | null } | { error: string }
|
|
18
24
|
>("startConvergenceLoop", async ({ body }, app) => {
|
|
19
|
-
const
|
|
20
|
-
const raw = String(
|
|
25
|
+
const b = body ?? {};
|
|
26
|
+
const raw = String(b.pr ?? b.url ?? "").trim();
|
|
21
27
|
const parsed = parsePr(raw);
|
|
22
28
|
if (!parsed) {
|
|
23
29
|
return { status: 400, body: { error: "could not parse PR (use owner/repo#123 or a PR URL)" } };
|
|
24
30
|
}
|
|
25
|
-
const dependsOn = Array.isArray(
|
|
26
|
-
const maxRounds = clampRounds(
|
|
31
|
+
const dependsOn = Array.isArray(b.dependsOn) ? b.dependsOn.map((d) => String(d)) : [];
|
|
32
|
+
const maxRounds = clampRounds(b.maxRounds, MAX_ROUNDS);
|
|
27
33
|
return { status: 202, body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds) };
|
|
28
34
|
});
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
// POST /app/api/actions/start/plan-fanout → operationId `startPlanFanout` (ADR 0058, base
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// POST /app/api/actions/start/plan-fanout → operationId `startPlanFanout` (ADR 0058/0059, base
|
|
2
|
+
// /app/api). The ONE door for starting a planning fan-out — the epic page's "Plan & implement" form,
|
|
3
|
+
// an external webhook relay (a GitHub relay on issue open/label), a CI job, and Swagger all POST
|
|
4
|
+
// here. Parse the issue reference and register/refresh the plan aggregate (idempotent on planKey)
|
|
5
|
+
// before starting the planning fan-out. An unparseable reference is a 400; an already-running plan
|
|
6
|
+
// short-circuits.
|
|
7
|
+
//
|
|
8
|
+
// The request body is FLAT (`{ issue | url }`), not wrapped in a `variables` envelope — this is a
|
|
9
|
+
// purpose-built operation, not a generic engine "start process" call.
|
|
5
10
|
import { defineOperation } from "@nanobpm/urban";
|
|
6
11
|
import { parseIssue, startPlan } from "../app/plan.ts";
|
|
7
12
|
|
|
8
13
|
interface Body {
|
|
9
|
-
|
|
14
|
+
issue?: string;
|
|
15
|
+
url?: string;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
type Res =
|
|
@@ -17,8 +23,8 @@ export default defineOperation<
|
|
|
17
23
|
{ params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: Body },
|
|
18
24
|
Res
|
|
19
25
|
>("startPlanFanout", async ({ body }, app) => {
|
|
20
|
-
const
|
|
21
|
-
const raw = String(
|
|
26
|
+
const b = body ?? {};
|
|
27
|
+
const raw = String(b.issue ?? b.url ?? "").trim();
|
|
22
28
|
const parsed = parseIssue(raw);
|
|
23
29
|
if (!parsed) {
|
|
24
30
|
return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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",
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
|
|
41
41
|
"dev": "urban dev",
|
|
42
42
|
"test": "node --experimental-strip-types --test",
|
|
43
|
-
"lint": "biome check app operations
|
|
44
|
-
"lint:fix": "biome check --write app operations
|
|
43
|
+
"lint": "biome check app operations workers pages components scripts main.ts",
|
|
44
|
+
"lint:fix": "biome check --write app operations workers pages components scripts main.ts"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@nanobpm/urban": "^0.
|
|
47
|
+
"@nanobpm/urban": "^0.35.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@biomejs/biome": "^2.4.11",
|
package/pages/epic.page.json
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"props": {
|
|
34
34
|
"title": "Hand an issue to the fleet",
|
|
35
35
|
"submitLabel": "Plan & implement",
|
|
36
|
-
"action": { "path": "/app/api/actions/start/plan-fanout" },
|
|
36
|
+
"action": { "path": "/app/api/actions/start/plan-fanout", "body": "{{form}}" },
|
|
37
37
|
"fields": [
|
|
38
38
|
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" }
|
|
39
39
|
]
|
package/pages/home.page.json
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"props": {
|
|
34
34
|
"title": "Submit a pull request",
|
|
35
35
|
"submitLabel": "Start review",
|
|
36
|
-
"action": { "path": "/app/api/actions/start/convergence-loop" },
|
|
36
|
+
"action": { "path": "/app/api/actions/start/convergence-loop", "body": "{{form}}" },
|
|
37
37
|
"fields": [
|
|
38
38
|
{ "key": "pr", "label": "owner/repo#123 or a GitHub PR URL", "type": "text" },
|
|
39
39
|
{ "key": "maxRounds", "label": "Max review rounds (blank = fleet default)", "type": "number" }
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// The process then parks the child at the `feature-escalation-answered` message
|
|
11
11
|
// catch (correlationKey `<plan_key>:<task_id>`). Answering it (page form or
|
|
12
|
-
// `/hooks/feature-answer`) resumes the child, which re-dispatches the SAME task.
|
|
12
|
+
// `/app/api/hooks/feature-answer`) resumes the child, which re-dispatches the SAME task.
|
|
13
13
|
//
|
|
14
14
|
// Retry-safe: if an open escalation already exists for this corr key (a worker
|
|
15
15
|
// re-activation before the wait subscription opened), it is UPDATED, not
|
package/actions/blackboard.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
// GET/POST /hooks/blackboard?token=<capabilityToken> — the epic coordination blackboard endpoint
|
|
2
|
-
// (Tier 1, issues #51 / #49 D4).
|
|
3
|
-
//
|
|
4
|
-
// This is a DIRECT side-channel for agents, distinct from the c8ctl-nano activation/completion
|
|
5
|
-
// channel. The per-plan capability token (query string) IS the credential: it scopes every read
|
|
6
|
-
// and write to exactly one plan, so no shared secret is needed — the agent curls the exact URL it
|
|
7
|
-
// was handed in its prompt. An unknown token is a 404 (never leaks which plans exist).
|
|
8
|
-
//
|
|
9
|
-
// GET → { planKey, entries: [ { id, author_task, kind, files, body, wave, created_at } ], cursor }
|
|
10
|
-
// optional ?since=<id> returns only entries with id > since (incremental poll). `cursor` is
|
|
11
|
-
// the plan's current head id; pass it back as `since` on the next poll (Tier 2).
|
|
12
|
-
// POST → append one entry: { author_task?, kind?, files?, body, wave?, dedupe_key? }. Idempotent
|
|
13
|
-
// on (plan, dedupe_key). Returns { id, inserted, conflicts } — `conflicts` lists prior
|
|
14
|
-
// sibling `file-claim`s on the same file(s) (advisory first-writer-wins; never a lock).
|
|
15
|
-
import type { ActionHandler } from "@nanobpm/urban";
|
|
16
|
-
import {
|
|
17
|
-
appendEntry,
|
|
18
|
-
detectFileClaimConflicts,
|
|
19
|
-
normalizeKind,
|
|
20
|
-
planKeyForToken,
|
|
21
|
-
readBlackboardPage,
|
|
22
|
-
} from "../app/blackboard.ts";
|
|
23
|
-
|
|
24
|
-
const handler: ActionHandler = async ({ req, body }, app) => {
|
|
25
|
-
const token = (req.query.get("token") ?? req.headers.get("x-blackboard-token") ?? "").trim();
|
|
26
|
-
if (!token) return { status: 400, body: { error: "missing blackboard token" } };
|
|
27
|
-
const planKey = await planKeyForToken(app.data, token);
|
|
28
|
-
if (!planKey) return { status: 404, body: { error: "unknown blackboard token" } };
|
|
29
|
-
|
|
30
|
-
if (req.method === "GET") {
|
|
31
|
-
const rawSince = req.query.get("since");
|
|
32
|
-
const since = rawSince != null && /^\d+$/.test(rawSince) ? Number(rawSince) : undefined;
|
|
33
|
-
const { entries, cursor } = await readBlackboardPage(app.data, planKey, { since });
|
|
34
|
-
return { status: 200, body: { planKey, entries, cursor } };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (req.method === "POST") {
|
|
38
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
39
|
-
const b = (body ?? {}) as Record<string, unknown>;
|
|
40
|
-
const text = typeof b.body === "string" ? b.body.trim() : "";
|
|
41
|
-
if (!text) return { status: 400, body: { error: "'body' (the note text) is required" } };
|
|
42
|
-
const kind = normalizeKind(b.kind);
|
|
43
|
-
const files = Array.isArray(b.files) ? b.files.map(String) : [];
|
|
44
|
-
// Normalize once (trim + default to "system") so the value we send to appendEntry matches the
|
|
45
|
-
// value we send to detectFileClaimConflicts. Otherwise an omitted/blank author_task is stored as
|
|
46
|
-
// "system" but conflict detection sees "", and the caller's own prior "system" claims are wrongly
|
|
47
|
-
// reported as sibling conflicts.
|
|
48
|
-
const author_task = (typeof b.author_task === "string" ? b.author_task.trim() : "") || "system";
|
|
49
|
-
const res = await appendEntry(app.data, planKey, {
|
|
50
|
-
author_task,
|
|
51
|
-
kind,
|
|
52
|
-
files,
|
|
53
|
-
body: text,
|
|
54
|
-
wave: typeof b.wave === "number" ? b.wave : null,
|
|
55
|
-
dedupe_key: typeof b.dedupe_key === "string" ? b.dedupe_key : undefined,
|
|
56
|
-
});
|
|
57
|
-
// Advisory conflict-of-intent: surface prior sibling claims on the same file(s). Computed AFTER
|
|
58
|
-
// the append and filtered to claims strictly before ours (id < res.id), so first-writer-wins is
|
|
59
|
-
// decided by insertion order — a sibling that raced a claim in between is still caught, and our
|
|
60
|
-
// own just-written row is never reported. Never blocks the append — the agent decides how to react.
|
|
61
|
-
const conflicts = kind === "file-claim"
|
|
62
|
-
? await detectFileClaimConflicts(app.data, planKey, {
|
|
63
|
-
author_task,
|
|
64
|
-
files,
|
|
65
|
-
beforeId: Number(res.id),
|
|
66
|
-
})
|
|
67
|
-
: [];
|
|
68
|
-
return {
|
|
69
|
-
status: res.inserted ? 201 : 200,
|
|
70
|
-
body: { id: Number(res.id), inserted: res.inserted, conflicts },
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return { status: 405, body: { error: "method not allowed (use GET or POST)" } };
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export default handler;
|