@nanobpm/nano-workforce 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/SPEC.md +14 -4
- package/app/retro.ts +6 -6
- package/nano.app.json +4 -0
- 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/prompts/fix-ci.md +14 -3
- package/prompts/rebase.md +10 -0
- package/resources/processes/merge-loop.bpmn +73 -20
- 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-dependency/worker.test.ts +116 -0
- package/workers/record-dependency/worker.ts +109 -0
- 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,17 @@
|
|
|
1
|
+
# [0.44.0](https://github.com/nanobpm/nano-workforce/compare/v0.43.0...v0.44.0) (2026-08-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **merge-loop:** wait on a blocking PR instead of escalating to a human ([#122](https://github.com/nanobpm/nano-workforce/issues/122)) ([aec738f](https://github.com/nanobpm/nano-workforce/commit/aec738f165df5a216b9094b4eb9b5b6d4a7ac419))
|
|
7
|
+
|
|
8
|
+
# [0.43.0](https://github.com/nanobpm/nano-workforce/compare/v0.42.0...v0.43.0) (2026-08-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **api:** oneOf request bodies + structured logging (urban 0.42.0) ([#120](https://github.com/nanobpm/nano-workforce/issues/120)) ([ea7549b](https://github.com/nanobpm/nano-workforce/commit/ea7549bd4acb7cdd874fde0a8ccccbf1563b8e53)), closes [#119](https://github.com/nanobpm/nano-workforce/issues/119)
|
|
14
|
+
|
|
1
15
|
# [0.42.0](https://github.com/nanobpm/nano-workforce/compare/v0.41.0...v0.42.0) (2026-08-11)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -368,11 +368,21 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
|
|
|
368
368
|
exhausted, the agent reports `blocked`, or the branch is in `conflict` does it fall
|
|
369
369
|
through to the human escalation path.
|
|
370
370
|
|
|
371
|
+
- **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
|
|
372
|
+
the PR cannot land because **another PR must merge first** (a required linked-issue
|
|
373
|
+
gate a sibling PR will close, a stacked base PR, or a `Depends-on:` the agent read
|
|
374
|
+
from the PR/issue text). That is an ordering **wait, not a human decision**: the
|
|
375
|
+
agent returns `status: "waiting-on-pr"` with a `dependsOn` list of `owner/repo#N`
|
|
376
|
+
refs. `pr.record-dependency` appends those edges to `pr_dependencies` and parks the
|
|
377
|
+
PR back at *wait: deps merged*, so the same poller pass lands it automatically once
|
|
378
|
+
the named PRs merge — no escalation is opened.
|
|
379
|
+
|
|
371
380
|
- **Dependencies** — `pr_dependencies(pr_key, depends_on_key)` (migration 004).
|
|
372
|
-
Declared
|
|
373
|
-
submit)
|
|
374
|
-
|
|
375
|
-
|
|
381
|
+
Declared three ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
|
|
382
|
+
submit), a `dependsOn` array on the submit request, and/or **discovered at merge
|
|
383
|
+
time** by a `fix-ci`/`rebase` agent (`status: "waiting-on-pr"`, above). `merge-loop`
|
|
384
|
+
parks at *wait: deps merged*; the poller checks each dependency (own tracked row
|
|
385
|
+
first, else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
|
|
376
386
|
- **Mergeability** — the poller classifies GitHub's `mergeStateStatus`:
|
|
377
387
|
`CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND` → `ready`; `DIRTY` → `conflict`;
|
|
378
388
|
`BLOCKED` → `blocked` if a required check is failing, else keep waiting;
|
package/app/retro.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
15
15
|
// app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
|
|
16
|
-
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
16
|
+
import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
|
|
17
17
|
import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
18
18
|
import { planReviews, planTasks } from "./plan.ts";
|
|
19
19
|
import { TERMINAL_STATUSES } from "./service.ts";
|
|
@@ -296,7 +296,7 @@ export async function maybeStartRetro(
|
|
|
296
296
|
data: DataLayer,
|
|
297
297
|
engine: EngineClient,
|
|
298
298
|
prKey: string,
|
|
299
|
-
log?:
|
|
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/nano.app.json
CHANGED
|
@@ -87,6 +87,10 @@
|
|
|
87
87
|
"taskType": "pr.mark-merged",
|
|
88
88
|
"handler": "workers/mark-merged/worker.ts"
|
|
89
89
|
},
|
|
90
|
+
{
|
|
91
|
+
"taskType": "pr.record-dependency",
|
|
92
|
+
"handler": "workers/record-dependency/worker.ts"
|
|
93
|
+
},
|
|
90
94
|
{
|
|
91
95
|
"taskType": "pr.record-plan",
|
|
92
96
|
"handler": "workers/record-plan/worker.ts"
|
package/openapi.yaml
CHANGED
|
@@ -201,22 +201,46 @@ components:
|
|
|
201
201
|
alreadyRunning:
|
|
202
202
|
type: boolean
|
|
203
203
|
description: True when a non-terminal plan for this issue already exists; no new instance was started.
|
|
204
|
-
|
|
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;
|