@nanobpm/nano-workforce 0.42.0 → 0.44.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/SPEC.md +14 -4
  3. package/app/retro.ts +6 -6
  4. package/nano.app.json +4 -0
  5. package/openapi.yaml +85 -14
  6. package/operations/answerFeatureEscalation.test.ts +12 -0
  7. package/operations/answerFeatureEscalation.ts +25 -7
  8. package/operations/appendBlackboard.ts +10 -1
  9. package/operations/blackboard.test.ts +2 -1
  10. package/operations/checkAbandon.test.ts +2 -1
  11. package/operations/checkAbandon.ts +4 -1
  12. package/operations/getAgentInstructions.test.ts +2 -1
  13. package/operations/getAgentInstructions.ts +2 -1
  14. package/operations/getVersion.test.ts +2 -1
  15. package/operations/getVersion.ts +2 -1
  16. package/operations/listActivePrs.test.ts +2 -1
  17. package/operations/listActivePrs.ts +1 -0
  18. package/operations/postMessage.ts +9 -1
  19. package/operations/readBlackboard.ts +4 -1
  20. package/operations/startAndMessage.test.ts +39 -7
  21. package/operations/startConvergenceLoop.ts +25 -13
  22. package/operations/startPlanFanout.ts +17 -4
  23. package/package.json +1 -1
  24. package/prompts/fix-ci.md +14 -3
  25. package/prompts/rebase.md +10 -0
  26. package/resources/processes/merge-loop.bpmn +73 -20
  27. package/test/log.ts +12 -0
  28. package/workers/finalize/worker.test.ts +2 -1
  29. package/workers/finalize/worker.ts +2 -2
  30. package/workers/merge/worker.test.ts +2 -1
  31. package/workers/record-dependency/worker.test.ts +116 -0
  32. package/workers/record-dependency/worker.ts +109 -0
  33. package/workers/record-plan/worker.ts +1 -1
  34. package/workers/record-plan-review/worker.test.ts +2 -1
  35. package/workers/record-plan-review/worker.ts +2 -2
  36. package/workers/record-results/worker.test.ts +2 -1
  37. package/workers/record-results/worker.ts +1 -1
  38. package/workers/record-trial-merge/worker.test.ts +2 -1
  39. package/workers/record-trial-merge/worker.ts +1 -1
  40. package/workers/record-wave/worker.test.ts +2 -1
  41. package/workers/record-wave/worker.ts +7 -7
  42. package/workers/retro-gather/worker.test.ts +3 -2
  43. package/workers/retro-gather/worker.ts +1 -1
  44. package/workers/retro-record/worker.test.ts +2 -1
  45. package/workers/retro-record/worker.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.44.0](https://github.com/nanobpm/nano-workforce/compare/v0.43.0...v0.44.0) (2026-08-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * **merge-loop:** wait on a blocking PR instead of escalating to a human ([#122](https://github.com/nanobpm/nano-workforce/issues/122)) ([aec738f](https://github.com/nanobpm/nano-workforce/commit/aec738f165df5a216b9094b4eb9b5b6d4a7ac419))
7
+
8
+ # [0.43.0](https://github.com/nanobpm/nano-workforce/compare/v0.42.0...v0.43.0) (2026-08-11)
9
+
10
+
11
+ ### Features
12
+
13
+ * **api:** oneOf request bodies + structured logging (urban 0.42.0) ([#120](https://github.com/nanobpm/nano-workforce/issues/120)) ([ea7549b](https://github.com/nanobpm/nano-workforce/commit/ea7549bd4acb7cdd874fde0a8ccccbf1563b8e53)), closes [#119](https://github.com/nanobpm/nano-workforce/issues/119)
14
+
1
15
  # [0.42.0](https://github.com/nanobpm/nano-workforce/compare/v0.41.0...v0.42.0) (2026-08-11)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -368,11 +368,21 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
368
368
  exhausted, the agent reports `blocked`, or the branch is in `conflict` does it fall
369
369
  through to the human escalation path.
370
370
 
371
+ - **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
372
+ the PR cannot land because **another PR must merge first** (a required linked-issue
373
+ gate a sibling PR will close, a stacked base PR, or a `Depends-on:` the agent read
374
+ from the PR/issue text). That is an ordering **wait, not a human decision**: the
375
+ agent returns `status: "waiting-on-pr"` with a `dependsOn` list of `owner/repo#N`
376
+ refs. `pr.record-dependency` appends those edges to `pr_dependencies` and parks the
377
+ PR back at *wait: deps merged*, so the same poller pass lands it automatically once
378
+ the named PRs merge — no escalation is opened.
379
+
371
380
  - **Dependencies** — `pr_dependencies(pr_key, depends_on_key)` (migration 004).
372
- Declared two ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
373
- submit) and/or a `dependsOn` array on the submit request. `merge-loop` parks at
374
- *wait: deps merged*; the poller checks each dependency (own tracked row first,
375
- else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
381
+ Declared three ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
382
+ submit), a `dependsOn` array on the submit request, and/or **discovered at merge
383
+ time** by a `fix-ci`/`rebase` agent (`status: "waiting-on-pr"`, above). `merge-loop`
384
+ parks at *wait: deps merged*; the poller checks each dependency (own tracked row
385
+ first, else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
376
386
  - **Mergeability** — the poller classifies GitHub's `mergeStateStatus`:
377
387
  `CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND` → `ready`; `DIRTY` → `conflict`;
378
388
  `BLOCKED` → `blocked` if a required check is failing, else keep waiting;
package/app/retro.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
15
15
  // app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
16
- import type { DataLayer, EngineClient } from "@nanobpm/urban";
16
+ import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
17
17
  import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
18
18
  import { planReviews, planTasks } from "./plan.ts";
19
19
  import { TERMINAL_STATUSES } from "./service.ts";
@@ -296,7 +296,7 @@ export async function maybeStartRetro(
296
296
  data: DataLayer,
297
297
  engine: EngineClient,
298
298
  prKey: string,
299
- log?: (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => void,
299
+ log?: Logger,
300
300
  ): Promise<{ started: boolean; planKey?: string; reason?: string }> {
301
301
  if (!autoRetroEnabled()) return { started: false, reason: "disabled" };
302
302
  try {
@@ -340,7 +340,7 @@ export async function maybeStartRetro(
340
340
  // record, the epic surface would show a plan that "started a retro" with nothing to show and
341
341
  // no way to retry. Persist a `blocked` retro instead so the failure stays visible and the
342
342
  // system state is consistent. Recording must not mask the original error in the log.
343
- log?.("error", `retro: could not start process for epic ${planKey}`, { err: String(err) });
343
+ log?.error(`retro: could not start process for epic ${planKey}`, { err: String(err) });
344
344
  // Persisting the blocked record is best-effort: recordRetro rethrows non-unique DB errors, and
345
345
  // if that escaped here it would fall through to the outer catch and return `error` instead of
346
346
  // `start-failed` — reintroducing the very silent-gap failure this path guards against (guard
@@ -352,16 +352,16 @@ export async function maybeStartRetro(
352
352
  summary: `Retro process could not be started: ${String(err)}`,
353
353
  });
354
354
  } catch (persistErr) {
355
- log?.("error", `retro: could not persist blocked retro for epic ${planKey}`, {
355
+ log?.error(`retro: could not persist blocked retro for epic ${planKey}`, {
356
356
  err: String(persistErr),
357
357
  });
358
358
  }
359
359
  return { started: false, planKey, reason: "start-failed" };
360
360
  }
361
- log?.("info", `retro: started for epic ${planKey}`, { processInstanceKey, learnings: digest.counts.learnings });
361
+ log?.info(`retro: started for epic ${planKey}`, { processInstanceKey, learnings: digest.counts.learnings });
362
362
  return { started: true, planKey };
363
363
  } catch (err) {
364
- log?.("error", `retro: could not start for PR ${prKey}`, { err: String(err) });
364
+ log?.error(`retro: could not start for PR ${prKey}`, { err: String(err) });
365
365
  return { started: false, reason: "error" };
366
366
  }
367
367
  }
package/nano.app.json CHANGED
@@ -87,6 +87,10 @@
87
87
  "taskType": "pr.mark-merged",
88
88
  "handler": "workers/mark-merged/worker.ts"
89
89
  },
90
+ {
91
+ "taskType": "pr.record-dependency",
92
+ "handler": "workers/record-dependency/worker.ts"
93
+ },
90
94
  {
91
95
  "taskType": "pr.record-plan",
92
96
  "handler": "workers/record-plan/worker.ts"
package/openapi.yaml CHANGED
@@ -201,22 +201,46 @@ components:
201
201
  alreadyRunning:
202
202
  type: boolean
203
203
  description: True when a non-terminal plan for this issue already exists; no new instance was started.
204
- StartVariables:
204
+ ConvergenceStart:
205
+ description: The start-convergence request body. Names the target PR by EXACTLY ONE of `pr`
206
+ (an `owner/repo#123` reference) or `url` (a bare PR URL) — never both, never neither — with
207
+ the optional convergence knobs. Modeled as `oneOf` named variants (ADR — Camunda REST v2
208
+ pattern) so the runtime rejects an ambiguous or empty target at the edge with a 400 that
209
+ names the allowed shapes, rather than the delegate silently coalescing `pr ?? url`.
210
+ oneOf:
211
+ - $ref: "#/components/schemas/ConvergenceStartByPr"
212
+ - $ref: "#/components/schemas/ConvergenceStartByUrl"
213
+ ConvergenceStartByPr:
205
214
  type: object
206
- description: The start request body. `pr`/`url` (convergence) or `issue`/`url` (planning) name the
207
- target. Only the fields below are read by the delegate; any extra keys are accepted but
208
- ignored (they are NOT forwarded to the engine as process variables).
209
- additionalProperties: true
215
+ additionalProperties: false
216
+ required:
217
+ - pr
210
218
  properties:
211
219
  pr:
212
220
  type: string
213
- description: "PR reference: owner/repo#123 or a PR URL."
214
- issue:
215
- type: string
216
- description: "Issue reference: owner/repo#123 or an issue URL."
221
+ description: "PR reference: owner/repo#123."
222
+ dependsOn:
223
+ type: array
224
+ items:
225
+ type: string
226
+ maxRounds:
227
+ type: integer
228
+ minimum: 1
229
+ description: Values above 100 are accepted and clamped to 100 by the delegate.
230
+ convergeOnly:
231
+ type: boolean
232
+ description: When true, run convergence only and stop at `converged` — the PR is never
233
+ handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
234
+ per-request review-only override; defaults to false (the global auto-merge default applies).
235
+ ConvergenceStartByUrl:
236
+ type: object
237
+ additionalProperties: false
238
+ required:
239
+ - url
240
+ properties:
217
241
  url:
218
242
  type: string
219
- description: Alias for pr/issue when a bare URL is supplied.
243
+ description: A bare PR URL, when no `owner/repo#123` reference is supplied.
220
244
  dependsOn:
221
245
  type: array
222
246
  items:
@@ -230,6 +254,32 @@ components:
230
254
  description: When true, run convergence only and stop at `converged` — the PR is never
231
255
  handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
232
256
  per-request review-only override; defaults to false (the global auto-merge default applies).
257
+ PlanStart:
258
+ description: The start-plan-fanout request body. Names the target issue by EXACTLY ONE of
259
+ `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL). Modeled as `oneOf`
260
+ named variants (Camunda REST v2 pattern) so an ambiguous or empty target is a 400 at the
261
+ edge, not a silent `issue ?? url` coalesce in the delegate.
262
+ oneOf:
263
+ - $ref: "#/components/schemas/PlanStartByIssue"
264
+ - $ref: "#/components/schemas/PlanStartByUrl"
265
+ PlanStartByIssue:
266
+ type: object
267
+ additionalProperties: false
268
+ required:
269
+ - issue
270
+ properties:
271
+ issue:
272
+ type: string
273
+ description: "Issue reference: owner/repo#123."
274
+ PlanStartByUrl:
275
+ type: object
276
+ additionalProperties: false
277
+ required:
278
+ - url
279
+ properties:
280
+ url:
281
+ type: string
282
+ description: A bare issue URL, when no `owner/repo#123` reference is supplied.
233
283
  MessageResult:
234
284
  type: object
235
285
  description: The result of publishing a message / answering an escalation. Shape varies by message
@@ -241,15 +291,36 @@ components:
241
291
  ok:
242
292
  type: boolean
243
293
  FeatureAnswerRequest:
294
+ description: "Answer an implementation-phase task escalation (issue #25). Supply the target by
295
+ EXACTLY ONE of `corrKey`, or the `plan`+`task` pair the delegate derives it from; `answer`
296
+ is always required. Modeled as `oneOf` named variants (Camunda REST v2 pattern) so a body
297
+ that supplies neither addressing form — or mixes them — is a 400 at the edge that names the
298
+ allowed shapes, instead of the delegate re-deriving the precedence by hand."
299
+ oneOf:
300
+ - $ref: "#/components/schemas/FeatureAnswerByCorrKey"
301
+ - $ref: "#/components/schemas/FeatureAnswerByPlanTask"
302
+ FeatureAnswerByCorrKey:
244
303
  type: object
245
- description: "Answer an implementation-phase task escalation (issue #25). Supply either `corrKey`,
246
- or both `plan` and `task`; `answer` is always required."
304
+ additionalProperties: false
247
305
  required:
306
+ - corrKey
248
307
  - answer
249
308
  properties:
250
309
  corrKey:
251
310
  type: string
252
311
  description: The task correlation key, `<plan_key>:<task_id>` (e.g. owner/repo#12:task-3).
312
+ answer:
313
+ type: string
314
+ minLength: 1
315
+ description: The operator's answer that resumes the parked implementation agent.
316
+ FeatureAnswerByPlanTask:
317
+ type: object
318
+ additionalProperties: false
319
+ required:
320
+ - plan
321
+ - task
322
+ - answer
323
+ properties:
253
324
  plan:
254
325
  type: string
255
326
  description: Plan reference (owner/repo#N); combined with `task` to derive the corrKey.
@@ -453,7 +524,7 @@ paths:
453
524
  content:
454
525
  application/json:
455
526
  schema:
456
- $ref: "#/components/schemas/StartVariables"
527
+ $ref: "#/components/schemas/ConvergenceStart"
457
528
  responses:
458
529
  "202":
459
530
  description: The loop was started (or refreshed).
@@ -476,7 +547,7 @@ paths:
476
547
  content:
477
548
  application/json:
478
549
  schema:
479
- $ref: "#/components/schemas/StartVariables"
550
+ $ref: "#/components/schemas/PlanStart"
480
551
  responses:
481
552
  "202":
482
553
  description: The plan fan-out was started (or was already running).
@@ -1,6 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import { assertEquals } from "#test-assert";
3
3
  import type { AppApi } from "@nanobpm/urban";
4
+ import { noopLog } from "../test/log.ts";
4
5
 
5
6
  const hadSecret = Object.prototype.hasOwnProperty.call(process.env, "NANO_PR_WEBHOOK_SECRET");
6
7
  const previousSecret = process.env.NANO_PR_WEBHOOK_SECRET;
@@ -42,6 +43,7 @@ function memApp(escalations: any[] = []) {
42
43
  return Promise.resolve();
43
44
  },
44
45
  },
46
+ log: noopLog(),
45
47
  } as any as AppApi;
46
48
  return { app, published };
47
49
  }
@@ -98,3 +100,13 @@ test("maps an unmatched corrKey to 404", async () => {
98
100
  assertEquals(result.status, 404);
99
101
  assertEquals(result.body.ok, false);
100
102
  });
103
+
104
+ test("rejects a missing request body with 400 (not 500)", async () => {
105
+ const { app } = memApp();
106
+ const result = await answerFeatureEscalation(
107
+ { ...input({}, "test-secret"), body: undefined },
108
+ app,
109
+ ) as any;
110
+ assertEquals(result.status, 400);
111
+ assertEquals(result.body.ok, false);
112
+ });
@@ -5,9 +5,11 @@
5
5
  // external system (a chat relay, a CI job, a human via curl) resume a parked implementation agent
6
6
  // without the page. Same idempotent `answerTaskEscalation` path the page's answer form uses.
7
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:
8
+ // The runtime validates the body shape against openapi.yaml a `oneOf` of EXACTLY ONE addressing
9
+ // form (`{ corrKey, answer }` OR `{ plan, task, answer }`), so a body that supplies neither form (or
10
+ // mixes them) is rejected at the edge with a 400 that names the allowed shapes. This delegate narrows
11
+ // the validated variant and keeps the semantic normalization the schema can't express (an answer /
12
+ // correlation key that is present but blank-after-trim) plus the shared-secret guard.
11
13
  // { "corrKey": "owner/repo#12:task-3", "answer": "…" }
12
14
  // { "plan": "owner/repo#12", "task": "task-3", "answer": "…" }
13
15
 
@@ -21,14 +23,28 @@ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
21
23
 
22
24
  export default defineOperation("answerFeatureEscalation", async ({ req, body }, app) => {
23
25
  if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
26
+ app.log.warn("feature-answer rejected: missing/invalid shared secret");
24
27
  return { status: 401, body: { ok: false, error: "unauthorized" } };
25
28
  }
26
- const b = body ?? {};
27
- const answer = str(b.answer);
28
- if (!answer) return { status: 400, body: { ok: false, error: "answer is required" } };
29
+ // The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
30
+ // (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500.
31
+ if (!body || typeof body !== "object") {
32
+ app.log.warn("feature-answer rejected: missing request body");
33
+ return { status: 400, body: { ok: false, error: "answer is required" } };
34
+ }
35
+ const answer = str(body.answer);
36
+ if (!answer) {
37
+ app.log.warn("feature-answer rejected: blank answer");
38
+ return { status: 400, body: { ok: false, error: "answer is required" } };
39
+ }
29
40
 
30
- const corrKey = str(b.corrKey) || (str(b.plan) && str(b.task) ? featureCorrKey(str(b.plan), str(b.task)) : "");
41
+ const corrKey = "corrKey" in body
42
+ ? str(body.corrKey)
43
+ : str(body.plan) && str(body.task)
44
+ ? featureCorrKey(str(body.plan), str(body.task))
45
+ : "";
31
46
  if (!corrKey) {
47
+ app.log.warn("feature-answer rejected: unresolvable correlation key");
32
48
  return {
33
49
  status: 400,
34
50
  body: { ok: false, error: "provide corrKey, or both plan (owner/repo#N) and task" },
@@ -36,5 +52,7 @@ export default defineOperation("answerFeatureEscalation", async ({ req, body },
36
52
  }
37
53
 
38
54
  const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
55
+ if (r.ok) app.log.info("feature escalation answered", { corrKey });
56
+ else app.log.warn("feature-answer: no open escalation to answer", { corrKey });
39
57
  return { status: r.ok ? 200 : 404, body: r };
40
58
  });
@@ -20,7 +20,10 @@ export default defineOperation("appendBlackboard", async ({ req, body }, app) =>
20
20
  const token = (req.query.get("token") ?? req.headers.get("x-blackboard-token") ?? "").trim();
21
21
  if (!token) return { status: 400, body: { error: "missing blackboard token" } };
22
22
  const planKey = await planKeyForToken(app.data, token);
23
- if (!planKey) return { status: 404, body: { error: "unknown blackboard token" } };
23
+ if (!planKey) {
24
+ app.log.warn("appendBlackboard: unknown blackboard token");
25
+ return { status: 404, body: { error: "unknown blackboard token" } };
26
+ }
24
27
 
25
28
  const b = body ?? {};
26
29
  const text = typeof b.body === "string" ? b.body.trim() : "";
@@ -51,6 +54,12 @@ export default defineOperation("appendBlackboard", async ({ req, body }, app) =>
51
54
  beforeId: Number(res.id),
52
55
  })
53
56
  : [];
57
+ app.log.info("blackboard entry appended", {
58
+ planKey,
59
+ kind,
60
+ inserted: res.inserted,
61
+ conflicts: conflicts.length,
62
+ });
54
63
  return {
55
64
  status: res.inserted ? 201 : 200,
56
65
  body: { id: Number(res.id), inserted: res.inserted, conflicts },
@@ -3,6 +3,7 @@
3
3
  import { test } from "node:test";
4
4
  import { assertEquals } from "#test-assert";
5
5
  import type { AppApi } from "@nanobpm/urban";
6
+ import { noopLog } from "../test/log.ts";
6
7
  import readBlackboard from "./readBlackboard.ts";
7
8
  import appendBlackboard from "./appendBlackboard.ts";
8
9
 
@@ -29,7 +30,7 @@ function memApp(): { app: AppApi; stores: Record<string, any[]> } {
29
30
  },
30
31
  };
31
32
  }
32
- const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) } } as any as AppApi;
33
+ const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: noopLog() } as any as AppApi;
33
34
  return { app, stores };
34
35
  }
35
36
 
@@ -2,6 +2,7 @@
2
2
  import { test } from "node:test";
3
3
  import { assertEquals } from "#test-assert";
4
4
  import type { AppApi } from "@nanobpm/urban";
5
+ import { noopLog } from "../test/log.ts";
5
6
  import handler from "./checkAbandon.ts";
6
7
 
7
8
  function memApp(): { app: AppApi } {
@@ -18,7 +19,7 @@ function memApp(): { app: AppApi } {
18
19
  },
19
20
  };
20
21
  }
21
- const app = { data: { table: (n: string) => tbl(n) } } as any as AppApi;
22
+ const app = { data: { table: (n: string) => tbl(n) }, log: noopLog() } as any as AppApi;
22
23
  return { app };
23
24
  }
24
25
 
@@ -17,6 +17,9 @@ export default defineOperation("checkAbandon", async ({ req }, app) => {
17
17
  const token = (req.query.get("token") ?? req.headers.get("x-abandon-token") ?? "").trim();
18
18
  if (!token) return { status: 400, body: { error: "missing abandon token" } };
19
19
  const state = await abandonStatusForToken(app.data, token);
20
- if (!state) return { status: 404, body: { error: "unknown abandon token" } };
20
+ if (!state) {
21
+ app.log.warn("checkAbandon: unknown abandon token");
22
+ return { status: 404, body: { error: "unknown abandon token" } };
23
+ }
21
24
  return { status: 200, body: state };
22
25
  });
@@ -5,9 +5,10 @@
5
5
  import { test } from "node:test";
6
6
  import { assert, assertEquals } from "#test-assert";
7
7
  import type { AppApi } from "@nanobpm/urban";
8
+ import { noopLog } from "../test/log.ts";
8
9
  import handler from "./getAgentInstructions.ts";
9
10
 
10
- const app = {} as any as AppApi;
11
+ const app = { log: noopLog() } as any as AppApi;
11
12
 
12
13
  function input(headers: Record<string, string> = {}, path = "/app/api/agent") {
13
14
  return {
@@ -33,8 +33,9 @@ function resolveApiBase(req: { path: string; headers: Headers }): string {
33
33
  return host ? `${proto}://${host}${basePath}` : `http://localhost:3000${basePath}`;
34
34
  }
35
35
 
36
- export default defineOperation("getAgentInstructions", ({ req }) => {
36
+ export default defineOperation("getAgentInstructions", ({ req }, app) => {
37
37
  if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
38
+ app.log.warn("getAgentInstructions rejected: missing/invalid shared secret");
38
39
  return { status: 401, body: { error: "unauthorized" } };
39
40
  }
40
41
  const baseUrl = resolveApiBase(req);
@@ -4,9 +4,10 @@
4
4
  import { test } from "node:test";
5
5
  import { assert, assertEquals } from "#test-assert";
6
6
  import type { AppApi } from "@nanobpm/urban";
7
+ import { noopLog } from "../test/log.ts";
7
8
  import handler from "./getVersion.ts";
8
9
 
9
- const app = {} as any as AppApi;
10
+ const app = { log: noopLog() } as any as AppApi;
10
11
 
11
12
  function input(headers: Record<string, string> = {}) {
12
13
  return {
@@ -14,8 +14,9 @@ import { defineOperation } from "../nano-generated/operations.ts";
14
14
  // the x-hook-secret header. Captured once, at module load.
15
15
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
16
16
 
17
- export default defineOperation("getVersion", ({ req }) => {
17
+ export default defineOperation("getVersion", ({ req }, app) => {
18
18
  if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
19
+ app.log.warn("getVersion rejected: missing/invalid shared secret");
19
20
  return { status: 401, body: { error: "unauthorized" } };
20
21
  }
21
22
  return { status: 200, body: buildVersionInfo() };
@@ -4,6 +4,7 @@
4
4
  import { test } from "node:test";
5
5
  import { assert, assertEquals } from "#test-assert";
6
6
  import type { AppApi } from "@nanobpm/urban";
7
+ import { noopLog } from "../test/log.ts";
7
8
  import handler from "./listActivePrs.ts";
8
9
 
9
10
  function memApp(rows: any[]): AppApi {
@@ -12,7 +13,7 @@ function memApp(rows: any[]): AppApi {
12
13
  return rows;
13
14
  },
14
15
  };
15
- return { data: { table: () => tbl } } as any as AppApi;
16
+ return { data: { table: () => tbl }, log: noopLog() } as any as AppApi;
16
17
  }
17
18
 
18
19
  function input(headers: Record<string, string> = {}) {
@@ -17,6 +17,7 @@ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
17
17
 
18
18
  export default defineOperation("listActivePrs", async ({ req }, app) => {
19
19
  if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
20
+ app.log.warn("listActivePrs rejected: missing/invalid shared secret");
20
21
  return { status: 401, body: { error: "unauthorized" } };
21
22
  }
22
23
  const prs = await activePrs(app.data);
@@ -16,7 +16,10 @@ import { defineOperation } from "../nano-generated/operations.ts";
16
16
  export default defineOperation("postMessage", async ({ body }, app) => {
17
17
  const b = body ?? {};
18
18
  const name = String(b.name ?? "");
19
- if (!name) return { status: 400, body: { error: "name is required" } };
19
+ if (!name) {
20
+ app.log.warn("postMessage rejected: missing name");
21
+ return { status: 400, body: { error: "name is required" } };
22
+ }
20
23
 
21
24
  if (name === "escalation-answered") {
22
25
  const prKey = String(b.correlationKey ?? "");
@@ -24,6 +27,8 @@ export default defineOperation("postMessage", async ({ body }, app) => {
24
27
  if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
25
28
  if (!answer) return { status: 400, body: { error: "answer is required" } };
26
29
  const r = await answerEscalation(app.data, app.engine, prKey, answer);
30
+ if (r.ok) app.log.info("review escalation answered", { name, prKey });
31
+ else app.log.warn("postMessage: no open review escalation to answer", { name, prKey });
27
32
  return { status: r.ok ? 200 : 404, body: r };
28
33
  }
29
34
 
@@ -36,6 +41,8 @@ export default defineOperation("postMessage", async ({ body }, app) => {
36
41
  if (!corrKey) return { status: 400, body: { error: "correlationKey is required" } };
37
42
  if (!answer) return { status: 400, body: { error: "answer is required" } };
38
43
  const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
44
+ if (r.ok) app.log.info("feature escalation answered", { name, corrKey });
45
+ else app.log.warn("postMessage: no open feature escalation to answer", { name, corrKey });
39
46
  return { status: r.ok ? 200 : 404, body: r };
40
47
  }
41
48
 
@@ -44,5 +51,6 @@ export default defineOperation("postMessage", async ({ body }, app) => {
44
51
  correlationKey: b.correlationKey != null ? String(b.correlationKey) : undefined,
45
52
  variables: b.variables,
46
53
  });
54
+ app.log.info("message published", { name });
47
55
  return { status: 200, body: { ok: true } };
48
56
  });
@@ -17,7 +17,10 @@ export default defineOperation("readBlackboard", async ({ req }, app) => {
17
17
  const token = (req.query.get("token") ?? req.headers.get("x-blackboard-token") ?? "").trim();
18
18
  if (!token) return { status: 400, body: { error: "missing blackboard token" } };
19
19
  const planKey = await planKeyForToken(app.data, token);
20
- if (!planKey) return { status: 404, body: { error: "unknown blackboard token" } };
20
+ if (!planKey) {
21
+ app.log.warn("readBlackboard: unknown blackboard token");
22
+ return { status: 404, body: { error: "unknown blackboard token" } };
23
+ }
21
24
 
22
25
  const rawSince = req.query.get("since");
23
26
  const since = rawSince != null && /^\d+$/.test(rawSince) ? Number(rawSince) : undefined;
@@ -1,17 +1,19 @@
1
1
  // Tests for the start/message operation delegates (ADR 0058 OpenAPI surface).
2
- // These cover the app-logic guards the JSON schema can't express (reference parsing, message-name
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
6
- // own api runtime tests.
2
+ // These cover the app-logic guards the JSON schema can't express (reference FORMAT parsing,
3
+ // message-name dispatch) and the derived-union narrowing. The start bodies are now `oneOf` shapes
4
+ // (`ConvergenceStart` = pr | url, `PlanStart` = issue | url) exactly-one-of enforcement and
5
+ // extra-key rejection are the runtime's job (exercised by urban's own api runtime tests); here we
6
+ // drive the delegate directly with each validated variant and assert it narrows correctly, still
7
+ // rejecting an unparseable reference with a 400.
7
8
  import { test } from "node:test";
8
9
  import { assertEquals } from "#test-assert";
9
10
  import type { AppApi } from "@nanobpm/urban";
11
+ import { noopLog } from "../test/log.ts";
10
12
  import startConvergenceLoop from "./startConvergenceLoop.ts";
11
13
  import startPlanFanout from "./startPlanFanout.ts";
12
14
  import postMessage from "./postMessage.ts";
13
15
 
14
- const app = {} as any as AppApi;
16
+ const app = { log: noopLog() } as any as AppApi;
15
17
 
16
18
  function input(body: any) {
17
19
  return {
@@ -62,7 +64,7 @@ function captureApp() {
62
64
  return Promise.resolve({ processInstanceKey: "PI-1" });
63
65
  },
64
66
  };
65
- return { app: { data, engine } as any as AppApi, get: () => captured };
67
+ return { app: { data, engine, log: noopLog() } as any as AppApi, get: () => captured };
66
68
  }
67
69
 
68
70
  function withGithubOff(run: () => Promise<void>): Promise<void> {
@@ -77,6 +79,13 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
77
79
  });
78
80
  }
79
81
 
82
+ test("startConvergenceLoop → 400 (not 500) on a missing request body", async () => {
83
+ const res = await startConvergenceLoop(input(undefined), app);
84
+ const r = res as any;
85
+ assertEquals(r.status, 400);
86
+ assertEquals(typeof r.body.error, "string");
87
+ });
88
+
80
89
  test("startConvergenceLoop forwards convergeOnly:true to the loop", async () => {
81
90
  await withGithubOff(async () => {
82
91
  const { app: capApp, get } = captureApp();
@@ -105,6 +114,29 @@ test("startPlanFanout → 400 on an unparseable issue reference", async () => {
105
114
  assertEquals(typeof r.body.error, "string");
106
115
  });
107
116
 
117
+ test("startPlanFanout → 400 (not 500) on a missing request body", async () => {
118
+ const res = await startPlanFanout(input(undefined), app);
119
+ const r = res as any;
120
+ assertEquals(r.status, 400);
121
+ assertEquals(typeof r.body.error, "string");
122
+ });
123
+
124
+ test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () => {
125
+ await withGithubOff(async () => {
126
+ const { app: capApp } = captureApp();
127
+ const res = await startConvergenceLoop(input({ url: "https://github.com/owner/repo/pull/11" }), capApp);
128
+ assertEquals((res as any).status, 202);
129
+ });
130
+ });
131
+
132
+ test("startPlanFanout narrows the `url` variant (no `issue` key)", async () => {
133
+ await withGithubOff(async () => {
134
+ const { app: capApp } = captureApp();
135
+ const res = await startPlanFanout(input({ url: "https://github.com/owner/repo/issues/12" }), capApp);
136
+ assertEquals((res as any).status, 202);
137
+ });
138
+ });
139
+
108
140
  test("postMessage → 400 when name is blank", async () => {
109
141
  const res = await postMessage(input({ name: "" }), app);
110
142
  const r = res as any;