@nanobpm/nano-workforce 0.36.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/.github/workflows/ci.yml +7 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +14 -0
- package/README.md +6 -27
- package/SPEC.md +15 -13
- package/app/abandon.test.ts +14 -19
- package/app/abandon.ts +2 -2
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +25 -35
- package/app/blackboard.ts +2 -2
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +1 -21
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeRebaseArm.test.ts +8 -6
- package/app/mergeTrain.test.ts +10 -9
- package/app/persist-escalation.test.ts +10 -31
- package/app/persist-round.test.ts +7 -20
- package/app/plan.test.ts +19 -42
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/service.ts +1 -1
- package/app/taskDelta.test.ts +8 -17
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +6 -21
- package/app/waves.test.ts +17 -16
- 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 +28 -30
- package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +12 -26
- package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
- package/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +3 -4
- package/operations/postMessage.ts +1 -1
- package/operations/readBlackboard.ts +29 -0
- package/operations/startAndMessage.test.ts +11 -15
- package/operations/startConvergenceLoop.ts +17 -11
- package/operations/startPlanFanout.ts +13 -7
- package/package.json +9 -7
- package/pages/epic.page.json +1 -1
- package/pages/home.page.json +1 -1
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/layout-bpmn.ts +6 -28
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +1 -1
- package/scripts/upgrade-from-pack.ts +1 -1
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/persist-task-escalation/worker.ts +1 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/select-wave/worker.test.ts +5 -8
- 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/deno.json +0 -24
- package/deno.lock +0 -1777
- 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,18 +1,17 @@
|
|
|
1
|
-
// Tests for the /hooks/blackboard
|
|
2
|
-
|
|
1
|
+
// Tests for the /app/api/hooks/blackboard operations `readBlackboard` (GET) + `appendBlackboard`
|
|
2
|
+
// (POST) (ADR 0059; Tier 1, issues #51 / #49 D4).
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { assertEquals } from "#test-assert";
|
|
3
5
|
import type { AppApi } from "@nanobpm/urban";
|
|
4
|
-
import
|
|
6
|
+
import readBlackboard from "./readBlackboard.ts";
|
|
7
|
+
import appendBlackboard from "./appendBlackboard.ts";
|
|
5
8
|
|
|
6
|
-
// deno-lint-ignore no-explicit-any
|
|
7
9
|
function memApp(): { app: AppApi; stores: Record<string, any[]> } {
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
10
|
const stores: Record<string, any[]> = {};
|
|
10
11
|
const seq: Record<string, number> = {};
|
|
11
12
|
function tbl(name: string, pk = "id") {
|
|
12
|
-
// deno-lint-ignore no-explicit-any
|
|
13
13
|
const rows = (stores[name] ??= [] as any[]);
|
|
14
14
|
return {
|
|
15
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
16
15
|
async insert(row: any) {
|
|
17
16
|
if (pk === "id") {
|
|
18
17
|
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
@@ -22,17 +21,14 @@ function memApp(): { app: AppApi; stores: Record<string, any[]> } {
|
|
|
22
21
|
rows.push({ ...row });
|
|
23
22
|
return row[pk];
|
|
24
23
|
},
|
|
25
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
26
24
|
async find(where: any = {}) {
|
|
27
25
|
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
28
26
|
},
|
|
29
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
30
27
|
async findOne(where: any = {}) {
|
|
31
28
|
return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
32
29
|
},
|
|
33
30
|
};
|
|
34
31
|
}
|
|
35
|
-
// deno-lint-ignore no-explicit-any
|
|
36
32
|
const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) } } as any as AppApi;
|
|
37
33
|
return { app, stores };
|
|
38
34
|
}
|
|
@@ -40,7 +36,7 @@ function memApp(): { app: AppApi; stores: Record<string, any[]> } {
|
|
|
40
36
|
function req(method: string, query: Record<string, string>) {
|
|
41
37
|
return {
|
|
42
38
|
method,
|
|
43
|
-
path: "/hooks/blackboard",
|
|
39
|
+
path: "/app/api/hooks/blackboard",
|
|
44
40
|
query: new URLSearchParams(query),
|
|
45
41
|
headers: new Headers(),
|
|
46
42
|
text: async () => "",
|
|
@@ -53,9 +49,17 @@ async function call(
|
|
|
53
49
|
query: Record<string, string>,
|
|
54
50
|
body?: unknown,
|
|
55
51
|
) {
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
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);
|
|
59
63
|
return res as any;
|
|
60
64
|
}
|
|
61
65
|
|
|
@@ -63,18 +67,18 @@ async function seedPlan(app: AppApi, planKey: string, token: string) {
|
|
|
63
67
|
await app.data.table("plans", "plan_key").insert({ plan_key: planKey, blackboard_token: token });
|
|
64
68
|
}
|
|
65
69
|
|
|
66
|
-
|
|
70
|
+
test("missing token → 400", async () => {
|
|
67
71
|
const { app } = memApp();
|
|
68
72
|
assertEquals((await call(app, "GET", {})).status, 400);
|
|
69
73
|
});
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
test("unknown token → 404 (does not reveal plans)", async () => {
|
|
72
76
|
const { app } = memApp();
|
|
73
77
|
await seedPlan(app, "o/r#1", "good");
|
|
74
78
|
assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
|
|
75
79
|
});
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
test("POST appends then GET reads back, scoped by the token's plan", async () => {
|
|
78
82
|
const { app } = memApp();
|
|
79
83
|
await seedPlan(app, "o/r#1", "tok");
|
|
80
84
|
const post = await call(app, "POST", { token: "tok" }, {
|
|
@@ -94,13 +98,13 @@ Deno.test("POST appends then GET reads back, scoped by the token's plan", async
|
|
|
94
98
|
assertEquals(get.body.entries[0].kind, "file-claim");
|
|
95
99
|
});
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
test("POST with a blank body → 400", async () => {
|
|
98
102
|
const { app } = memApp();
|
|
99
103
|
await seedPlan(app, "o/r#1", "tok");
|
|
100
104
|
assertEquals((await call(app, "POST", { token: "tok" }, { body: " " })).status, 400);
|
|
101
105
|
});
|
|
102
106
|
|
|
103
|
-
|
|
107
|
+
test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async () => {
|
|
104
108
|
const { app, stores } = memApp();
|
|
105
109
|
await seedPlan(app, "o/r#1", "tok");
|
|
106
110
|
const body = { author_task: "t", body: "claim", dedupe_key: "t:claim:1" };
|
|
@@ -111,7 +115,7 @@ Deno.test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", a
|
|
|
111
115
|
assertEquals(stores["plan_blackboard"].length, 1);
|
|
112
116
|
});
|
|
113
117
|
|
|
114
|
-
|
|
118
|
+
test("GET ?since returns only newer entries", async () => {
|
|
115
119
|
const { app } = memApp();
|
|
116
120
|
await seedPlan(app, "o/r#1", "tok");
|
|
117
121
|
await call(app, "POST", { token: "tok" }, { body: "one" });
|
|
@@ -122,7 +126,7 @@ Deno.test("GET ?since returns only newer entries", async () => {
|
|
|
122
126
|
assertEquals(tail.body.entries.map((e: { body: string }) => e.body), ["two"]);
|
|
123
127
|
});
|
|
124
128
|
|
|
125
|
-
|
|
129
|
+
test("GET returns a cursor at the plan head for incremental polling (Tier 2)", async () => {
|
|
126
130
|
const { app } = memApp();
|
|
127
131
|
await seedPlan(app, "o/r#1", "tok");
|
|
128
132
|
await call(app, "POST", { token: "tok" }, { body: "one" });
|
|
@@ -135,7 +139,7 @@ Deno.test("GET returns a cursor at the plan head for incremental polling (Tier 2
|
|
|
135
139
|
assertEquals(caughtUp.body.cursor, all.body.cursor);
|
|
136
140
|
});
|
|
137
141
|
|
|
138
|
-
|
|
142
|
+
test("POST file-claim surfaces a sibling's prior claim as a conflict (advisory)", async () => {
|
|
139
143
|
const { app } = memApp();
|
|
140
144
|
await seedPlan(app, "o/r#1", "tok");
|
|
141
145
|
const first = await call(app, "POST", { token: "tok" }, {
|
|
@@ -158,7 +162,7 @@ Deno.test("POST file-claim surfaces a sibling's prior claim as a conflict (advis
|
|
|
158
162
|
assertEquals(second.body.conflicts[0].file, "engine/state.rs");
|
|
159
163
|
});
|
|
160
164
|
|
|
161
|
-
|
|
165
|
+
test("POST file-claim without author_task does not report the caller's own prior 'system' claim as a conflict", async () => {
|
|
162
166
|
const { app } = memApp();
|
|
163
167
|
await seedPlan(app, "o/r#1", "tok");
|
|
164
168
|
// First claim omits author_task → stored as "system".
|
|
@@ -181,15 +185,9 @@ Deno.test("POST file-claim without author_task does not report the caller's own
|
|
|
181
185
|
assertEquals(second.body.conflicts, [], "own prior 'system' claim is not a conflict");
|
|
182
186
|
});
|
|
183
187
|
|
|
184
|
-
|
|
188
|
+
test("POST a non-file-claim carries no conflicts", async () => {
|
|
185
189
|
const { app } = memApp();
|
|
186
190
|
await seedPlan(app, "o/r#1", "tok");
|
|
187
191
|
const res = await call(app, "POST", { token: "tok" }, { author_task: "t", kind: "note", body: "fyi" });
|
|
188
192
|
assertEquals(res.body.conflicts, []);
|
|
189
193
|
});
|
|
190
|
-
|
|
191
|
-
Deno.test("unsupported method → 405", async () => {
|
|
192
|
-
const { app } = memApp();
|
|
193
|
-
await seedPlan(app, "o/r#1", "tok");
|
|
194
|
-
assertEquals((await call(app, "DELETE", { token: "tok" })).status, 405);
|
|
195
|
-
});
|
|
@@ -1,28 +1,23 @@
|
|
|
1
|
-
// Tests for the GET /hooks/abandon
|
|
2
|
-
import {
|
|
1
|
+
// Tests for the GET /app/api/hooks/abandon operation `checkAbandon` (ADR 0059; issue #76).
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assertEquals } from "#test-assert";
|
|
3
4
|
import type { AppApi } from "@nanobpm/urban";
|
|
4
|
-
import handler from "./
|
|
5
|
+
import handler from "./checkAbandon.ts";
|
|
5
6
|
|
|
6
|
-
// deno-lint-ignore no-explicit-any
|
|
7
7
|
function memApp(): { app: AppApi } {
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
8
|
const stores: Record<string, any[]> = {};
|
|
10
9
|
function tbl(name: string) {
|
|
11
|
-
// deno-lint-ignore no-explicit-any
|
|
12
10
|
const rows = (stores[name] ??= [] as any[]);
|
|
13
11
|
return {
|
|
14
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
15
12
|
async insert(row: any) {
|
|
16
13
|
rows.push({ ...row });
|
|
17
14
|
return row.pr_key;
|
|
18
15
|
},
|
|
19
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
20
16
|
async findOne(where: any = {}) {
|
|
21
17
|
return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
22
18
|
},
|
|
23
19
|
};
|
|
24
20
|
}
|
|
25
|
-
// deno-lint-ignore no-explicit-any
|
|
26
21
|
const app = { data: { table: (n: string) => tbl(n) } } as any as AppApi;
|
|
27
22
|
return { app };
|
|
28
23
|
}
|
|
@@ -30,7 +25,7 @@ function memApp(): { app: AppApi } {
|
|
|
30
25
|
function req(method: string, query: Record<string, string>) {
|
|
31
26
|
return {
|
|
32
27
|
method,
|
|
33
|
-
path: "/hooks/abandon",
|
|
28
|
+
path: "/app/api/hooks/abandon",
|
|
34
29
|
query: new URLSearchParams(query),
|
|
35
30
|
headers: new Headers(),
|
|
36
31
|
text: async () => "",
|
|
@@ -38,9 +33,7 @@ function req(method: string, query: Record<string, string>) {
|
|
|
38
33
|
}
|
|
39
34
|
|
|
40
35
|
async function call(app: AppApi, method: string, query: Record<string, string>) {
|
|
41
|
-
|
|
42
|
-
const res = await handler({ req: req(method, query) as any, body: undefined }, app);
|
|
43
|
-
// deno-lint-ignore no-explicit-any
|
|
36
|
+
const res = await handler({ req: req(method, query) as any, params: {}, query: {}, body: undefined }, app);
|
|
44
37
|
return res as any;
|
|
45
38
|
}
|
|
46
39
|
|
|
@@ -48,18 +41,18 @@ async function seedPr(app: AppApi, prKey: string, token: string, status: string)
|
|
|
48
41
|
await app.data.table("pull_requests", "pr_key").insert({ pr_key: prKey, abandon_token: token, status });
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
|
|
44
|
+
test("missing token → 400", async () => {
|
|
52
45
|
const { app } = memApp();
|
|
53
46
|
assertEquals((await call(app, "GET", {})).status, 400);
|
|
54
47
|
});
|
|
55
48
|
|
|
56
|
-
|
|
49
|
+
test("unknown token → 404 (does not reveal PRs)", async () => {
|
|
57
50
|
const { app } = memApp();
|
|
58
51
|
await seedPr(app, "o/r#1", "good", "converging");
|
|
59
52
|
assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
|
|
60
53
|
});
|
|
61
54
|
|
|
62
|
-
|
|
55
|
+
test("a running PR → { abandoned: false }", async () => {
|
|
63
56
|
const { app } = memApp();
|
|
64
57
|
await seedPr(app, "o/r#1", "tok", "converging");
|
|
65
58
|
const res = await call(app, "GET", { token: "tok" });
|
|
@@ -67,7 +60,7 @@ Deno.test("a running PR → { abandoned: false }", async () => {
|
|
|
67
60
|
assertEquals(res.body, { prKey: "o/r#1", status: "converging", abandoned: false });
|
|
68
61
|
});
|
|
69
62
|
|
|
70
|
-
|
|
63
|
+
test("a cancelled PR → { abandoned: true }", async () => {
|
|
71
64
|
const { app } = memApp();
|
|
72
65
|
await seedPr(app, "o/r#1", "tok", "abandoned");
|
|
73
66
|
const res = await call(app, "GET", { token: "tok" });
|
|
@@ -75,19 +68,12 @@ Deno.test("a cancelled PR → { abandoned: true }", async () => {
|
|
|
75
68
|
assertEquals(res.body.abandoned, true);
|
|
76
69
|
});
|
|
77
70
|
|
|
78
|
-
|
|
71
|
+
test("token via header is accepted", async () => {
|
|
79
72
|
const { app } = memApp();
|
|
80
73
|
await seedPr(app, "o/r#1", "tok", "abandoned");
|
|
81
74
|
const r = req("GET", {});
|
|
82
75
|
r.headers.set("x-abandon-token", "tok");
|
|
83
|
-
|
|
84
|
-
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;
|
|
85
77
|
assertEquals(res.status, 200);
|
|
86
78
|
assertEquals(res.body.abandoned, true);
|
|
87
79
|
});
|
|
88
|
-
|
|
89
|
-
Deno.test("non-GET → 405", async () => {
|
|
90
|
-
const { app } = memApp();
|
|
91
|
-
await seedPr(app, "o/r#1", "tok", "converging");
|
|
92
|
-
assertEquals((await call(app, "POST", { token: "tok" })).status, 405);
|
|
93
|
-
});
|
|
@@ -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
|
+
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Tests for GET /app/api/version → operation `getVersion` (ADR 0058 OpenAPI surface).
|
|
2
2
|
// Ported from the previous actions/version.test.ts. Method handling now belongs to the router
|
|
3
3
|
// (only GET is routed here), so there is no 405 case to test at the delegate level.
|
|
4
|
-
import {
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assert, assertEquals } from "#test-assert";
|
|
5
6
|
import type { AppApi } from "@nanobpm/urban";
|
|
6
7
|
import handler from "./getVersion.ts";
|
|
7
8
|
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
9
|
const app = {} as any as AppApi;
|
|
10
10
|
|
|
11
11
|
function input(headers: Record<string, string> = {}) {
|
|
@@ -16,7 +16,6 @@ function input(headers: Record<string, string> = {}) {
|
|
|
16
16
|
query: new URLSearchParams(),
|
|
17
17
|
headers: new Headers(headers),
|
|
18
18
|
text: async () => "",
|
|
19
|
-
// deno-lint-ignore no-explicit-any
|
|
20
19
|
} as any,
|
|
21
20
|
params: {},
|
|
22
21
|
query: {},
|
|
@@ -24,9 +23,8 @@ function input(headers: Record<string, string> = {}) {
|
|
|
24
23
|
};
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
test("returns 200 with the app identity", async () => {
|
|
28
27
|
const res = await handler(input(), app);
|
|
29
|
-
// deno-lint-ignore no-explicit-any
|
|
30
28
|
const r = res as any;
|
|
31
29
|
assertEquals(r.status, 200);
|
|
32
30
|
assertEquals(r.body.name, "nano-workforce");
|
|
@@ -40,21 +38,19 @@ Deno.test("returns 200 with the app identity", async () => {
|
|
|
40
38
|
assert(typeof r.body.uptimeSeconds === "number");
|
|
41
39
|
});
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
const prev =
|
|
45
|
-
|
|
41
|
+
test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
|
|
42
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
43
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
46
44
|
try {
|
|
47
45
|
// SECRET is bound at import time, so import a cache-busted copy to observe the guard.
|
|
48
46
|
const mod = await import(`./getVersion.ts?guard=${Date.now()}`);
|
|
49
47
|
const guarded = mod.default as typeof handler;
|
|
50
|
-
// deno-lint-ignore no-explicit-any
|
|
51
48
|
const bad = (await guarded(input(), app)) as any;
|
|
52
49
|
assertEquals(bad.status, 401);
|
|
53
|
-
// deno-lint-ignore no-explicit-any
|
|
54
50
|
const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
55
51
|
assertEquals(ok.status, 200);
|
|
56
52
|
} finally {
|
|
57
|
-
if (prev === undefined)
|
|
58
|
-
else
|
|
53
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
54
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
59
55
|
}
|
|
60
56
|
});
|
package/operations/getVersion.ts
CHANGED
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
import { defineOperation } from "@nanobpm/urban";
|
|
10
10
|
import { buildVersionInfo, envVar, type VersionInfo } from "../app/version.ts";
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// the endpoint open even when NANO_PR_WEBHOOK_SECRET is set. Captured once, at module load.
|
|
12
|
+
// The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
|
|
13
|
+
// the x-hook-secret header. Captured once, at module load.
|
|
15
14
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
16
15
|
|
|
17
16
|
export default defineOperation<
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
// Tests for GET /app/api/status → operation `listActivePrs` (ADR 0058 OpenAPI surface).
|
|
2
2
|
// Covers the happy path (count/prs projection) and the optional shared-secret guard. A minimal
|
|
3
3
|
// in-memory DataLayer backs `activePrs` (it reads the `pull_requests` table via `.all()`).
|
|
4
|
-
import {
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assert, assertEquals } from "#test-assert";
|
|
5
6
|
import type { AppApi } from "@nanobpm/urban";
|
|
6
7
|
import handler from "./listActivePrs.ts";
|
|
7
8
|
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
9
|
function memApp(rows: any[]): AppApi {
|
|
10
10
|
const tbl = {
|
|
11
|
-
// deno-lint-ignore require-await
|
|
12
11
|
async all() {
|
|
13
12
|
return rows;
|
|
14
13
|
},
|
|
15
14
|
};
|
|
16
|
-
// deno-lint-ignore no-explicit-any
|
|
17
15
|
return { data: { table: () => tbl } } as any as AppApi;
|
|
18
16
|
}
|
|
19
17
|
|
|
@@ -25,7 +23,6 @@ function input(headers: Record<string, string> = {}) {
|
|
|
25
23
|
query: new URLSearchParams(),
|
|
26
24
|
headers: new Headers(headers),
|
|
27
25
|
text: async () => "",
|
|
28
|
-
// deno-lint-ignore no-explicit-any
|
|
29
26
|
} as any,
|
|
30
27
|
params: {},
|
|
31
28
|
query: {},
|
|
@@ -33,13 +30,12 @@ function input(headers: Record<string, string> = {}) {
|
|
|
33
30
|
};
|
|
34
31
|
}
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
test("returns 200 with a count + projected active PRs", async () => {
|
|
37
34
|
const app = memApp([
|
|
38
35
|
{ pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", title: "t", status: "converging", current_round: 2, process_key: "9", updated_at: "2026-01-02" },
|
|
39
36
|
{ pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", title: null, status: "converged", current_round: 1, process_key: null, updated_at: "2026-01-01" },
|
|
40
37
|
]);
|
|
41
38
|
const res = await handler(input(), app);
|
|
42
|
-
// deno-lint-ignore no-explicit-any
|
|
43
39
|
const r = res as any;
|
|
44
40
|
assertEquals(r.status, 200);
|
|
45
41
|
// `converged` is terminal → filtered out, leaving one active PR.
|
|
@@ -49,22 +45,20 @@ Deno.test("returns 200 with a count + projected active PRs", async () => {
|
|
|
49
45
|
assertEquals(r.body.prs[0].processKey, "9");
|
|
50
46
|
});
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
const prev =
|
|
54
|
-
|
|
48
|
+
test("shared-secret guard rejects a missing secret when configured", async () => {
|
|
49
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
50
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
55
51
|
try {
|
|
56
52
|
const mod = await import(`./listActivePrs.ts?guard=${Date.now()}`);
|
|
57
53
|
const guarded = mod.default as typeof handler;
|
|
58
54
|
const app = memApp([]);
|
|
59
|
-
// deno-lint-ignore no-explicit-any
|
|
60
55
|
const bad = (await guarded(input(), app)) as any;
|
|
61
56
|
assertEquals(bad.status, 401);
|
|
62
|
-
// deno-lint-ignore no-explicit-any
|
|
63
57
|
const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
64
58
|
assertEquals(ok.status, 200);
|
|
65
59
|
assert("count" in ok.body);
|
|
66
60
|
} finally {
|
|
67
|
-
if (prev === undefined)
|
|
68
|
-
else
|
|
61
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
62
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
69
63
|
}
|
|
70
64
|
});
|