@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.
@@ -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 endpoint (Tier 1, issues #51 / #49 D4).
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 handler from "./blackboard.ts";
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
- const res = await handler({ req: req(method, query) as any, body }, app);
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 endpoint (issue #76).
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 "./abandon.ts";
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> the cooperative abandon check (issue #76).
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 type { ActionHandler } from "@nanobpm/urban";
12
+ import { defineOperation } from "@nanobpm/urban";
12
13
  import { abandonStatusForToken } from "../app/abandon.ts";
13
14
 
14
- const handler: ActionHandler = async ({ req }, app) => {
15
- if (req.method !== "GET") return { status: 405, body: { error: "method not allowed (use GET)" } };
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.json; the optional shared-secret guard
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.json (`name` is required, so a missing name is a 400
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); the runtime's schema validation (required `variables`/`name`) is exercised by urban's
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({ variables: { pr: "not a pr" } }), app);
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({ variables: { issue: "" } }), app);
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, base /app/api).
2
- // Replaces the hand-rolled action that overrode the generic "start process" palette action: parse the
3
- // PR reference and register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
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 runtime validates the body against openapi.json (a `variables` object is required); this
6
- // delegate keeps the PR-parse guard because the reference format (owner/repo#123 or a URL) is app
7
- // logic, not something the JSON schema can express an unparseable reference is a 400.
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
- variables?: { pr?: string; url?: string; dependsOn?: unknown; maxRounds?: unknown };
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 vars = body?.variables ?? {};
20
- const raw = String(vars.pr ?? vars.url ?? "").trim();
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(vars.dependsOn) ? vars.dependsOn.map((d) => String(d)) : [];
26
- const maxRounds = clampRounds(vars.maxRounds, MAX_ROUNDS);
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 /app/api).
2
- // Replaces the hand-rolled action that overrode the generic "start process" palette action: parse the
3
- // issue reference and register/refresh the plan aggregate (idempotent on planKey) before starting the
4
- // planning fan-out. An unparseable reference is a 400; an already-running plan short-circuits.
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
- variables?: { issue?: string; url?: string };
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 vars = body?.variables ?? {};
21
- const raw = String(vars.issue ?? vars.url ?? "").trim();
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.37.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 actions workers pages components scripts main.ts",
44
- "lint:fix": "biome check --write app operations actions workers pages components scripts main.ts"
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.33.0"
47
+ "@nanobpm/urban": "^0.35.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@biomejs/biome": "^2.4.11",
@@ -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
  ]
@@ -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
@@ -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;