@nanobpm/nano-workforce 0.42.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.
- package/CHANGELOG.md +7 -0
- package/app/retro.ts +6 -6
- package/openapi.yaml +85 -14
- package/operations/answerFeatureEscalation.test.ts +12 -0
- package/operations/answerFeatureEscalation.ts +25 -7
- package/operations/appendBlackboard.ts +10 -1
- package/operations/blackboard.test.ts +2 -1
- package/operations/checkAbandon.test.ts +2 -1
- package/operations/checkAbandon.ts +4 -1
- package/operations/getAgentInstructions.test.ts +2 -1
- package/operations/getAgentInstructions.ts +2 -1
- package/operations/getVersion.test.ts +2 -1
- package/operations/getVersion.ts +2 -1
- package/operations/listActivePrs.test.ts +2 -1
- package/operations/listActivePrs.ts +1 -0
- package/operations/postMessage.ts +9 -1
- package/operations/readBlackboard.ts +4 -1
- package/operations/startAndMessage.test.ts +39 -7
- package/operations/startConvergenceLoop.ts +25 -13
- package/operations/startPlanFanout.ts +17 -4
- package/package.json +1 -1
- package/test/log.ts +12 -0
- package/workers/finalize/worker.test.ts +2 -1
- package/workers/finalize/worker.ts +2 -2
- package/workers/merge/worker.test.ts +2 -1
- package/workers/record-plan/worker.ts +1 -1
- package/workers/record-plan-review/worker.test.ts +2 -1
- package/workers/record-plan-review/worker.ts +2 -2
- package/workers/record-results/worker.test.ts +2 -1
- package/workers/record-results/worker.ts +1 -1
- package/workers/record-trial-merge/worker.test.ts +2 -1
- package/workers/record-trial-merge/worker.ts +1 -1
- package/workers/record-wave/worker.test.ts +2 -1
- package/workers/record-wave/worker.ts +7 -7
- package/workers/retro-gather/worker.test.ts +3 -2
- package/workers/retro-gather/worker.ts +1 -1
- package/workers/retro-record/worker.test.ts +2 -1
- package/workers/retro-record/worker.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
# [0.42.0](https://github.com/nanobpm/nano-workforce/compare/v0.41.0...v0.42.0) (2026-08-11)
|
|
2
9
|
|
|
3
10
|
|
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?:
|
|
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?.(
|
|
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?.(
|
|
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?.(
|
|
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?.(
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
type:
|
|
216
|
-
|
|
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:
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
if (!
|
|
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 =
|
|
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)
|
|
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)
|
|
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 {
|
package/operations/getVersion.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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,
|
|
3
|
-
// dispatch). The
|
|
4
|
-
//
|
|
5
|
-
// runtime's
|
|
6
|
-
//
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// reference
|
|
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
|
-
|
|
18
|
-
|
|
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 =
|
|
24
|
-
const maxRounds = clampRounds(
|
|
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 =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
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: ()
|
|
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(
|
|
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(
|
|
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: ()
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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: ()
|
|
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(
|
|
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: ()
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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: ()
|
|
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: ()
|
|
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(
|
|
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: ()
|
|
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(
|
|
56
|
+
app.log.info(`retro-record: ${planKey} — status=${status}${prKey ? ` pr=${prKey}` : ""}`);
|
|
57
57
|
return {};
|
|
58
58
|
};
|
|
59
59
|
|