@nanobpm/nano-workforce 0.41.0 → 0.43.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 (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/app/retro.ts +6 -6
  3. package/openapi.yaml +85 -14
  4. package/operations/answerFeatureEscalation.test.ts +12 -0
  5. package/operations/answerFeatureEscalation.ts +25 -7
  6. package/operations/appendBlackboard.ts +10 -1
  7. package/operations/blackboard.test.ts +2 -1
  8. package/operations/checkAbandon.test.ts +2 -1
  9. package/operations/checkAbandon.ts +4 -1
  10. package/operations/getAgentInstructions.test.ts +2 -1
  11. package/operations/getAgentInstructions.ts +2 -1
  12. package/operations/getVersion.test.ts +2 -1
  13. package/operations/getVersion.ts +2 -1
  14. package/operations/listActivePrs.test.ts +2 -1
  15. package/operations/listActivePrs.ts +1 -0
  16. package/operations/postMessage.ts +9 -1
  17. package/operations/readBlackboard.ts +4 -1
  18. package/operations/startAndMessage.test.ts +39 -7
  19. package/operations/startConvergenceLoop.ts +25 -13
  20. package/operations/startPlanFanout.ts +17 -4
  21. package/package.json +2 -2
  22. package/pages/home.page.json +2 -1
  23. package/test/log.ts +12 -0
  24. package/workers/finalize/worker.test.ts +2 -1
  25. package/workers/finalize/worker.ts +2 -2
  26. package/workers/merge/worker.test.ts +2 -1
  27. package/workers/record-plan/worker.ts +1 -1
  28. package/workers/record-plan-review/worker.test.ts +2 -1
  29. package/workers/record-plan-review/worker.ts +2 -2
  30. package/workers/record-results/worker.test.ts +2 -1
  31. package/workers/record-results/worker.ts +1 -1
  32. package/workers/record-trial-merge/worker.test.ts +2 -1
  33. package/workers/record-trial-merge/worker.ts +1 -1
  34. package/workers/record-wave/worker.test.ts +2 -1
  35. package/workers/record-wave/worker.ts +7 -7
  36. package/workers/retro-gather/worker.test.ts +3 -2
  37. package/workers/retro-gather/worker.ts +1 -1
  38. package/workers/retro-record/worker.test.ts +2 -1
  39. package/workers/retro-record/worker.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.43.0](https://github.com/nanobpm/nano-workforce/compare/v0.42.0...v0.43.0) (2026-08-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * **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)
7
+
8
+ # [0.42.0](https://github.com/nanobpm/nano-workforce/compare/v0.41.0...v0.42.0) (2026-08-11)
9
+
10
+
11
+ ### Features
12
+
13
+ * **ui:** checkbox to run convergence without auto-merge ([#121](https://github.com/nanobpm/nano-workforce/issues/121)) ([8883026](https://github.com/nanobpm/nano-workforce/commit/8883026d552855c8a25c526d22ef2aa83ac9f298)), closes [#115](https://github.com/nanobpm/nano-workforce/issues/115)
14
+
1
15
  # [0.41.0](https://github.com/nanobpm/nano-workforce/compare/v0.40.2...v0.41.0) (2026-08-11)
2
16
 
3
17
 
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/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;
@@ -5,28 +5,40 @@
5
5
  //
6
6
  // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, convergeOnly? }`), not wrapped in a
7
7
  // `variables` envelope: this is a purpose-built operation, not a generic engine "start process" call,
8
- // so it does not leak the engine's variable-map concept to callers. The runtime validates the body
9
- // against openapi.yaml; this delegate keeps the PR-parse guard because the reference format
10
- // (owner/repo#123 or a URL) is app logic, not something the JSON schema can express an unparseable
11
- // reference is a 400.
8
+ // so it does not leak the engine's variable-map concept to callers. The body is a `oneOf` — EXACTLY
9
+ // ONE of `pr` or `url` — so the runtime rejects an empty or ambiguous target at the edge (a 400 that
10
+ // names the allowed shapes); this delegate no longer coalesces `pr ?? url`, it just narrows the
11
+ // validated variant. It keeps the PR-parse guard because the reference FORMAT (owner/repo#123 or a
12
+ // URL) is app logic the JSON schema can't express — an unparseable reference is a 400.
12
13
 
13
14
  import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
14
15
  import { defineOperation } from "../nano-generated/operations.ts";
15
16
 
16
17
  export default defineOperation("startConvergenceLoop", async ({ body }, app) => {
17
- const b = body ?? {};
18
- const raw = String(b.pr ?? b.url ?? "").trim();
18
+ // The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
19
+ // (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500 from `in`.
20
+ if (!body || typeof body !== "object") {
21
+ app.log.warn("start-convergence rejected: missing request body");
22
+ return { status: 400, body: { error: "request body is required (owner/repo#123 or a PR URL)" } };
23
+ }
24
+ const raw = ("pr" in body ? body.pr : body.url).trim();
19
25
  const parsed = parsePr(raw);
20
26
  if (!parsed) {
27
+ app.log.warn("start-convergence rejected: unparseable PR reference", { raw });
21
28
  return { status: 400, body: { error: "could not parse PR (use owner/repo#123 or a PR URL)" } };
22
29
  }
23
- const dependsOn = Array.isArray(b.dependsOn) ? b.dependsOn.map((d) => String(d)) : [];
24
- const maxRounds = clampRounds(b.maxRounds, MAX_ROUNDS);
30
+ const dependsOn = body.dependsOn ?? [];
31
+ const maxRounds = clampRounds(body.maxRounds, MAX_ROUNDS);
25
32
  // Per-request review-only override: when true the PR stops at `converged` and is never
26
33
  // handed to the merge-loop, regardless of the global NANO_PR_AUTO_MERGE default.
27
- const convergeOnly = b.convergeOnly === true;
28
- return {
29
- status: 202,
30
- body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly),
31
- };
34
+ const convergeOnly = body.convergeOnly === true;
35
+ const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly);
36
+ app.log.info("convergence loop started", {
37
+ prKey: parsed.prKey,
38
+ alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
39
+ dependsOn: dependsOn.length,
40
+ maxRounds,
41
+ convergeOnly,
42
+ });
43
+ return { status: 202, body: result };
32
44
  });
@@ -6,17 +6,30 @@
6
6
  // short-circuits.
7
7
  //
8
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.
9
+ // purpose-built operation, not a generic engine "start process" call. The body is a `oneOf` — EXACTLY
10
+ // ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at the edge; this delegate just
11
+ // narrows the validated variant and keeps the issue-FORMAT parse guard (schema can't express it).
10
12
 
11
13
  import { parseIssue, startPlan } from "../app/plan.ts";
12
14
  import { defineOperation } from "../nano-generated/operations.ts";
13
15
 
14
16
  export default defineOperation("startPlanFanout", async ({ body }, app) => {
15
- const b = body ?? {};
16
- const raw = String(b.issue ?? b.url ?? "").trim();
17
+ // The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
18
+ // (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500 from `in`.
19
+ if (!body || typeof body !== "object") {
20
+ app.log.warn("start-plan rejected: missing request body");
21
+ return { status: 400, body: { error: "request body is required (owner/repo#123 or an issue URL)" } };
22
+ }
23
+ const raw = ("issue" in body ? body.issue : body.url).trim();
17
24
  const parsed = parseIssue(raw);
18
25
  if (!parsed) {
26
+ app.log.warn("start-plan rejected: unparseable issue reference", { raw });
19
27
  return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
20
28
  }
21
- return { status: 202, body: await startPlan(app.data, app.engine, parsed) };
29
+ const result = await startPlan(app.data, app.engine, parsed);
30
+ app.log.info("plan fan-out started", {
31
+ planKey: parsed.planKey,
32
+ alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
33
+ });
34
+ return { status: 202, body: result };
22
35
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.41.0",
3
+ "version": "0.43.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",
@@ -45,7 +45,7 @@
45
45
  "lint:fix": "biome check --write app operations workers pages components scripts main.ts"
46
46
  },
47
47
  "dependencies": {
48
- "@nanobpm/urban": "^0.40.1"
48
+ "@nanobpm/urban": "^0.42.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@biomejs/biome": "^2.4.11",
@@ -36,7 +36,8 @@
36
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
- { "key": "maxRounds", "label": "Max review rounds (blank = fleet default)", "type": "number" }
39
+ { "key": "maxRounds", "label": "Max review rounds (blank = fleet default)", "type": "number" },
40
+ { "key": "convergeOnly", "label": "Converge only — stop at review consensus, do not merge", "type": "checkbox" }
40
41
  ]
41
42
  }
42
43
  },
package/test/log.ts ADDED
@@ -0,0 +1,12 @@
1
+ // A no-op `Logger` for test doubles. The runtime injects `app.log` into every operation delegate
2
+ // and worker handler, but the in-memory `app` fakes the suite builds don't carry one — so a
3
+ // delegate that logs on a covered path would throw on `app.log.info(...)`. `createLogger` (exported
4
+ // from urban's runtime barrel since 0.42.0) builds a spec-correct Logger over a discarding sink, so
5
+ // tests exercise the logging code paths without asserting on them and stay correct if the Logger
6
+ // interface grows. Spread into the fake `app`: `{ ...data, log: noopLog() } as unknown as AppApi`.
7
+ import { createLogger, type Logger } from "@nanobpm/urban/runtime";
8
+
9
+ /** A `Logger` that silently discards every record (and whose `child()` does the same). */
10
+ export function noopLog(): Logger {
11
+ return createLogger(() => {});
12
+ }
@@ -6,6 +6,7 @@
6
6
  // AND the request did not force convergence-only.
7
7
  import { test } from "node:test";
8
8
  import { assertEquals } from "#test-assert";
9
+ import { noopLog } from "../../test/log.ts";
9
10
  import handler from "./worker.ts";
10
11
  import { MERGE_PROCESS_ID } from "../../app/service.ts";
11
12
 
@@ -48,7 +49,7 @@ function fakeApp() {
48
49
  return Promise.resolve({ processInstanceKey: "MERGE-1" });
49
50
  },
50
51
  },
51
- log: () => undefined,
52
+ log: noopLog(),
52
53
  },
53
54
  };
54
55
  }
@@ -83,10 +83,10 @@ const handler: AppJobHandler<In> = async (job, app) => {
83
83
  if (mergeProcessKey != null) {
84
84
  status = "waiting_deps";
85
85
  } else {
86
- app.log("error", `finalize: merge-loop start returned no process key for ${prKey}; leaving PR converged`);
86
+ app.log.error(`finalize: merge-loop start returned no process key for ${prKey}; leaving PR converged`);
87
87
  }
88
88
  } catch (err) {
89
- app.log("error", `finalize: could not start merge-loop for ${prKey}; leaving PR converged`, {
89
+ app.log.error(`finalize: could not start merge-loop for ${prKey}; leaving PR converged`, {
90
90
  err: String(err),
91
91
  });
92
92
  }
@@ -6,6 +6,7 @@
6
6
  // token transport and stubs `globalThis.fetch` so the single-PR GET reports `merged: true`.
7
7
  import { test } from "node:test";
8
8
  import { assertEquals } from "#test-assert";
9
+ import { noopLog } from "../../test/log.ts";
9
10
  import handler from "./worker.ts";
10
11
 
11
12
  function fakeApp() {
@@ -36,7 +37,7 @@ function fakeApp() {
36
37
  };
37
38
  },
38
39
  },
39
- log: () => undefined,
40
+ log: noopLog(),
40
41
  engine: {},
41
42
  } as any,
42
43
  stores,
@@ -83,7 +83,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
83
83
  });
84
84
  waveOf = new Map();
85
85
  for (const t of tasks) waveOf.set(t.id, 0);
86
- app.log("warn", `record-plan: ${planKey} plan not levelizable, running flat`, {
86
+ app.log.warn(`record-plan: ${planKey} plan not levelizable, running flat`, {
87
87
  err: err.message,
88
88
  });
89
89
  }
@@ -8,6 +8,7 @@
8
8
  import { test } from "node:test";
9
9
  import { assertEquals, assertRejects } from "#test-assert";
10
10
  import { BpmnError } from "@nanobpm/urban";
11
+ import { noopLog } from "../../test/log.ts";
11
12
  import handler from "./worker.ts";
12
13
  import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
13
14
 
@@ -28,7 +29,7 @@ function fakeApp(existing: PlanReview[] = []) {
28
29
  };
29
30
  },
30
31
  },
31
- log: () => {},
32
+ log: noopLog(),
32
33
  _rows: rows,
33
34
  } as any;
34
35
  }
@@ -92,7 +92,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
92
92
  // `PLAN_REJECTED`, so the engine parks the instance on an incident rather than dispatching an
93
93
  // un-approved plan. The round is 0-based, so `round + 1 >= cap` is the last permitted round.
94
94
  if (round + 1 >= MAX_PLAN_REVIEW_ROUNDS) {
95
- app.log("error", `record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
95
+ app.log.error(`record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
96
96
  round,
97
97
  });
98
98
  throw new BpmnError(
@@ -102,7 +102,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
102
102
  }
103
103
 
104
104
  // Otherwise loop: the planner revises against this round's findings.
105
- app.log("info", `record-plan-review: ${planKey} round ${round} — revise`, { approved: false });
105
+ app.log.info(`record-plan-review: ${planKey} round ${round} — revise`, { approved: false });
106
106
  return { planApproved: false, planFindings: roundFindings };
107
107
  };
108
108
 
@@ -9,6 +9,7 @@
9
9
  import { test } from "node:test";
10
10
  import { assertEquals, assertRejects } from "#test-assert";
11
11
  import { BpmnError } from "@nanobpm/urban";
12
+ import { noopLog } from "../../test/log.ts";
12
13
  import handler from "./worker.ts";
13
14
  import type { PlanTaskStatus } from "../../app/plan.ts";
14
15
 
@@ -45,7 +46,7 @@ function fakeApp(rows: Row[]) {
45
46
  };
46
47
  },
47
48
  },
48
- log: () => {},
49
+ log: noopLog(),
49
50
  _plans: plans,
50
51
  } as any;
51
52
  }
@@ -43,7 +43,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
43
43
  outcome,
44
44
  updated_at: ts,
45
45
  });
46
- app.log("error", `record-results: ${planKey} finalized with 0 opened PRs`, {
46
+ app.log.error(`record-results: ${planKey} finalized with 0 opened PRs`, {
47
47
  taskCount: rows.length,
48
48
  });
49
49
  throw new BpmnError("NO_WORK_DISPATCHED", `${planKey}: ${outcome}`);
@@ -1,5 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import { assertEquals, assertStringIncludes } from "#test-assert";
3
+ import { noopLog } from "../../test/log.ts";
3
4
  import handler, { parseResult } from "./worker.ts";
4
5
 
5
6
  function fakeApp() {
@@ -19,7 +20,7 @@ function fakeApp() {
19
20
  };
20
21
  },
21
22
  },
22
- log: () => undefined,
23
+ log: noopLog(),
23
24
  };
24
25
  return { app, inserts };
25
26
  }
@@ -67,7 +67,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
67
67
  jobKey,
68
68
  });
69
69
  } catch (err) {
70
- app.log("error", `record-trial-merge: audit persist failed for ${planKey} wave ${wave}`, { err: String(err) });
70
+ app.log.error(`record-trial-merge: audit persist failed for ${planKey} wave ${wave}`, { err: String(err) });
71
71
  }
72
72
 
73
73
  const trialMergeRed = trialMergeDecision(result) === "escalate";
@@ -3,6 +3,7 @@
3
3
  // finish the plan with a still-pending task.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals } from "#test-assert";
6
+ import { noopLog } from "../../test/log.ts";
6
7
  import handler from "./worker.ts";
7
8
  import type { PlanTaskStatus } from "../../app/plan.ts";
8
9
  import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
@@ -61,7 +62,7 @@ function fakeApp(rows: Row[]) {
61
62
  };
62
63
  },
63
64
  },
64
- log: () => undefined,
65
+ log: noopLog(),
65
66
  engine: {
66
67
  createInstance: () => Promise.resolve({ processInstanceKey: "pi" }),
67
68
  },
@@ -164,7 +164,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
164
164
  });
165
165
  }
166
166
  } catch (err) {
167
- app.log("error", `record-wave: recording delta for ${taskId} failed`, {
167
+ app.log.error(`record-wave: recording delta for ${taskId} failed`, {
168
168
  err: String(err),
169
169
  });
170
170
  }
@@ -194,7 +194,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
194
194
  try {
195
195
  await submitPr(app.data, app.engine, parsed, depPrKeys);
196
196
  } catch (err) {
197
- app.log("error", `record-wave: handoff failed for ${parsed.prKey}`, {
197
+ app.log.error(`record-wave: handoff failed for ${parsed.prKey}`, {
198
198
  err: String(err),
199
199
  });
200
200
  }
@@ -220,7 +220,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
220
220
  if (meta?.headRef) head.headRef = meta.headRef;
221
221
  if (meta?.headSha) head.headSha = meta.headSha;
222
222
  } catch (err) {
223
- app.log("error", `record-wave: pr head fetch failed for ${head.repo}#${head.prNumber}`, { err: String(err) });
223
+ app.log.error(`record-wave: pr head fetch failed for ${head.repo}#${head.prNumber}`, { err: String(err) });
224
224
  }
225
225
  return head;
226
226
  }));
@@ -253,7 +253,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
253
253
  for (const f of files) set.add(f);
254
254
  touchesByTask.set(o.taskId, set);
255
255
  } catch (err) {
256
- app.log("error", `record-wave: pr files fetch failed for ${o.repo}#${o.number}`, {
256
+ app.log.error(`record-wave: pr files fetch failed for ${o.repo}#${o.number}`, {
257
257
  err: String(err),
258
258
  });
259
259
  }
@@ -261,7 +261,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
261
261
  const edges = deriveExclusions(touchesByTask);
262
262
  if (edges.length > 0) {
263
263
  const { inserted, updated } = await recordExclusions(app.data, planKey, edges);
264
- app.log("info", `record-wave: merge-exclusion scan wave ${currentWave}`, {
264
+ app.log.info(`record-wave: merge-exclusion scan wave ${currentWave}`, {
265
265
  planKey,
266
266
  edges: edges.length,
267
267
  inserted,
@@ -269,7 +269,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
269
269
  });
270
270
  }
271
271
  } catch (err) {
272
- app.log("error", `record-wave: merge-exclusion scan failed for ${planKey}`, {
272
+ app.log.error(`record-wave: merge-exclusion scan failed for ${planKey}`, {
273
273
  err: String(err),
274
274
  });
275
275
  }
@@ -291,7 +291,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
291
291
  updated_at: ts,
292
292
  });
293
293
  } catch (err) {
294
- app.log("error", `record-wave: arming wave gate failed for ${planKey}`, { err: String(err) });
294
+ app.log.error(`record-wave: arming wave gate failed for ${planKey}`, { err: String(err) });
295
295
  }
296
296
 
297
297
  return {
@@ -1,6 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import { assert, assertEquals, assertStringIncludes } from "#test-assert";
3
3
  import type { DataLayer } from "@nanobpm/urban";
4
+ import { noopLog } from "../../test/log.ts";
4
5
  import { appendEntry } from "../../app/blackboard.ts";
5
6
  import handler from "./worker.ts";
6
7
 
@@ -38,7 +39,7 @@ test("retro-gather: emits a digest brief + learning count for the plan", async (
38
39
  await appendEntry(data, "o/r#3", { author_task: "t1", kind: "learning", body: "regen before build" });
39
40
  await appendEntry(data, "o/r#3", { author_task: "t2", kind: "learning", body: "use nextest" });
40
41
 
41
- const app = { data, log: () => undefined };
42
+ const app = { data, log: noopLog() };
42
43
  const out = await handler(
43
44
  { variables: { planKey: "o/r#3" } } as any,
44
45
  app as any,
@@ -53,7 +54,7 @@ test("retro-gather: emits a digest brief + learning count for the plan", async (
53
54
  test("retro-gather: an epic with no learnings still renders a valid brief", async () => {
54
55
  const { data, stores } = memData();
55
56
  stores["plans"] = [{ plan_key: "o/r#4", repo: "o/r", issue_url: "", title: null }];
56
- const app = { data, log: () => undefined };
57
+ const app = { data, log: noopLog() };
57
58
  const out = await handler(
58
59
  { variables: { planKey: "o/r#4" } } as any,
59
60
  app as any,
@@ -18,7 +18,7 @@ interface Out extends Record<string, unknown> {
18
18
  const handler: AppJobHandler<In, Out> = async (job, app) => {
19
19
  const planKey = job.variables.planKey;
20
20
  const digest = await gatherRetro(app.data, planKey);
21
- app.log("info", `retro-gather: ${planKey} — ${digest.counts.learnings} learnings, ${digest.counts.deltas} deltas`);
21
+ app.log.info(`retro-gather: ${planKey} — ${digest.counts.learnings} learnings, ${digest.counts.deltas} deltas`);
22
22
  return {
23
23
  retroDigest: renderRetroBrief(digest),
24
24
  retroLearnings: digest.counts.learnings,
@@ -1,5 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import { assertEquals } from "#test-assert";
3
+ import { noopLog } from "../../test/log.ts";
3
4
  import handler from "./worker.ts";
4
5
 
5
6
  function fakeApp() {
@@ -29,7 +30,7 @@ function fakeApp() {
29
30
  },
30
31
  };
31
32
  }
32
- const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: () => undefined };
33
+ const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: noopLog() };
33
34
  return { app, stores };
34
35
  }
35
36
 
@@ -53,7 +53,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
53
53
  report,
54
54
  });
55
55
 
56
- app.log("info", `retro-record: ${planKey} — status=${status}${prKey ? ` pr=${prKey}` : ""}`);
56
+ app.log.info(`retro-record: ${planKey} — status=${status}${prKey ? ` pr=${prKey}` : ""}`);
57
57
  return {};
58
58
  };
59
59