@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
|
@@ -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.44.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/prompts/fix-ci.md
CHANGED
|
@@ -56,9 +56,20 @@ the PR's checks yourself (`gh pr checks`, `gh run view`).
|
|
|
56
56
|
Return a structured result:
|
|
57
57
|
|
|
58
58
|
- `status: "fixed"` — you pushed a fix you believe makes the failing checks pass.
|
|
59
|
-
- `status: "
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
- `status: "waiting-on-pr"` — the PR cannot merge yet because **another PR must land
|
|
60
|
+
first**, and this is an ordering constraint, not a defect: e.g. the failing check
|
|
61
|
+
is a required linked-issue / "closes #N" gate that a sibling PR will satisfy, the
|
|
62
|
+
PR is stacked on a base PR that has not merged, or the PR body / an issue it
|
|
63
|
+
references says it depends on another PR. This is a **wait, not an escalation** — do
|
|
64
|
+
**not** ask a human to babysit it. Set `dependsOn` to the PR(s) that must merge
|
|
65
|
+
first, as `owner/repo#N` refs (or PR URLs), separated by commas or spaces. The
|
|
66
|
+
process records the dependency and automatically re-attempts the merge once every
|
|
67
|
+
named PR has landed.
|
|
68
|
+
- `status: "blocked"` — you could **not** fix it and it genuinely needs a human
|
|
69
|
+
**decision** (a secret, an upstream change, or a judgement call). Set `question` to a
|
|
70
|
+
concise, specific description of what is blocking and what a human must decide.
|
|
71
|
+
Reserve this for a real decision — if the PR is merely waiting on another PR, use
|
|
72
|
+
`waiting-on-pr` instead so no human is pulled in.
|
|
62
73
|
|
|
63
74
|
Never report `fixed` unless you actually pushed a change. If nothing was wrong on
|
|
64
75
|
the branch (the failure was transient infrastructure), say so in `summary` and
|
package/prompts/rebase.md
CHANGED
|
@@ -67,10 +67,20 @@ Return a structured result:
|
|
|
67
67
|
- `status: "rebased"` — the branch tip now contains the latest base: you resolved
|
|
68
68
|
any conflicts mechanically and pushed, **or** it was already up to date. The
|
|
69
69
|
process will re-attempt the merge.
|
|
70
|
+
- `status: "waiting-on-pr"` — the PR cannot merge yet because **another PR must land
|
|
71
|
+
first**, and this is an ordering constraint, not a conflict you can resolve: e.g.
|
|
72
|
+
the branch is stacked on a base PR that has not merged, or the PR body / an issue
|
|
73
|
+
it references says it depends on another PR that must close a blocking issue first.
|
|
74
|
+
This is a **wait, not an escalation** — do **not** ask a human to babysit it. Set
|
|
75
|
+
`dependsOn` to the PR(s) that must merge first, as `owner/repo#N` refs (or PR URLs),
|
|
76
|
+
separated by commas or spaces. The process records the dependency and automatically
|
|
77
|
+
re-attempts the merge once every named PR has landed.
|
|
70
78
|
- `status: "blocked"` — you could **not** resolve it mechanically (a genuine
|
|
71
79
|
semantic conflict where two changes contradict and a human must decide which
|
|
72
80
|
behaviour wins, or the branch is un-rebaseable). Set `question` to a concise,
|
|
73
81
|
specific description of the conflicting intent and the decision a human must make.
|
|
82
|
+
Reserve this for a real decision — if the PR is merely waiting on another PR to land
|
|
83
|
+
first, use `waiting-on-pr` instead so no human is pulled in.
|
|
74
84
|
|
|
75
85
|
Report `rebased` when the branch tip now contains the latest base — either
|
|
76
86
|
because you pushed a resolved update, or because it was **already up to date**
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
<nano:extend name="status" type="string" />
|
|
77
77
|
<nano:extend name="summary" type="string" optional="true" />
|
|
78
78
|
<nano:extend name="question" type="string" optional="true" />
|
|
79
|
+
<nano:extend name="dependsOn" type="string" optional="true" />
|
|
79
80
|
</nano:shape>
|
|
80
81
|
<nano:shape id="RebaseIn" name="Rebase — input">
|
|
81
82
|
<nano:extend name="prKey" type="string" />
|
|
@@ -88,6 +89,11 @@
|
|
|
88
89
|
<nano:extend name="status" type="string" />
|
|
89
90
|
<nano:extend name="summary" type="string" optional="true" />
|
|
90
91
|
<nano:extend name="question" type="string" optional="true" />
|
|
92
|
+
<nano:extend name="dependsOn" type="string" optional="true" />
|
|
93
|
+
</nano:shape>
|
|
94
|
+
<nano:shape id="RecordDepIn" name="Record discovered dependency — input">
|
|
95
|
+
<nano:extend name="prKey" type="string" />
|
|
96
|
+
<nano:extend name="dependsOn" type="string" optional="true" />
|
|
91
97
|
</nano:shape>
|
|
92
98
|
<nano:shape id="MergeEscalationAnswered" name="escalation-answered message payload">
|
|
93
99
|
<nano:extend name="answer" type="string" />
|
|
@@ -100,6 +106,7 @@
|
|
|
100
106
|
</bpmn:startEvent>
|
|
101
107
|
<bpmn:intermediateCatchEvent id="wait-deps" name="Wait: dependencies merged">
|
|
102
108
|
<bpmn:incoming>f_m_start</bpmn:incoming>
|
|
109
|
+
<bpmn:incoming>f_dep_rewait</bpmn:incoming>
|
|
103
110
|
<bpmn:outgoing>f_m_deps</bpmn:outgoing>
|
|
104
111
|
<bpmn:messageEventDefinition id="med_depsCleared" messageRef="Message_depsCleared" />
|
|
105
112
|
</bpmn:intermediateCatchEvent>
|
|
@@ -237,6 +244,7 @@
|
|
|
237
244
|
<bpmn:exclusiveGateway id="gw-ci-result" name="fixed?" default="f_ci_blocked">
|
|
238
245
|
<bpmn:incoming>f_ci_done</bpmn:incoming>
|
|
239
246
|
<bpmn:outgoing>f_ci_fixed</bpmn:outgoing>
|
|
247
|
+
<bpmn:outgoing>f_ci_wait</bpmn:outgoing>
|
|
240
248
|
<bpmn:outgoing>f_ci_blocked</bpmn:outgoing>
|
|
241
249
|
</bpmn:exclusiveGateway>
|
|
242
250
|
<bpmn:exclusiveGateway id="gw-rebase" name="auto-rebase?" default="f_reb_giveup">
|
|
@@ -265,8 +273,20 @@
|
|
|
265
273
|
<bpmn:exclusiveGateway id="gw-rebase-result" name="rebased?" default="f_reb_blocked">
|
|
266
274
|
<bpmn:incoming>f_reb_done</bpmn:incoming>
|
|
267
275
|
<bpmn:outgoing>f_reb_rebased</bpmn:outgoing>
|
|
276
|
+
<bpmn:outgoing>f_reb_wait</bpmn:outgoing>
|
|
268
277
|
<bpmn:outgoing>f_reb_blocked</bpmn:outgoing>
|
|
269
278
|
</bpmn:exclusiveGateway>
|
|
279
|
+
<bpmn:serviceTask id="record-merge-dep" name="Wait on another PR">
|
|
280
|
+
<bpmn:extensionElements>
|
|
281
|
+
<zeebe:taskDefinition type="pr.record-dependency" />
|
|
282
|
+
<zeebe:properties>
|
|
283
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordDepIn" />
|
|
284
|
+
</zeebe:properties>
|
|
285
|
+
</bpmn:extensionElements>
|
|
286
|
+
<bpmn:incoming>f_ci_wait</bpmn:incoming>
|
|
287
|
+
<bpmn:incoming>f_reb_wait</bpmn:incoming>
|
|
288
|
+
<bpmn:outgoing>f_dep_rewait</bpmn:outgoing>
|
|
289
|
+
</bpmn:serviceTask>
|
|
270
290
|
<bpmn:sequenceFlow id="f_m_start" sourceRef="MergeStart" targetRef="wait-deps" />
|
|
271
291
|
<bpmn:sequenceFlow id="f_m_deps" sourceRef="wait-deps" targetRef="arm-merge" />
|
|
272
292
|
<bpmn:sequenceFlow id="f_m_arm" sourceRef="arm-merge" targetRef="wait-mergeable" />
|
|
@@ -290,6 +310,9 @@
|
|
|
290
310
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "fixed"</bpmn:conditionExpression>
|
|
291
311
|
</bpmn:sequenceFlow>
|
|
292
312
|
<bpmn:sequenceFlow id="f_ci_blocked" name="could not fix" sourceRef="gw-ci-result" targetRef="merge-esc-attempt" />
|
|
313
|
+
<bpmn:sequenceFlow id="f_ci_wait" name="waits on another PR" sourceRef="gw-ci-result" targetRef="record-merge-dep">
|
|
314
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
|
|
315
|
+
</bpmn:sequenceFlow>
|
|
293
316
|
<bpmn:sequenceFlow id="f_reb_go" name="within budget" sourceRef="gw-rebase" targetRef="rebase">
|
|
294
317
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=rebaseRound < rebaseMax</bpmn:conditionExpression>
|
|
295
318
|
</bpmn:sequenceFlow>
|
|
@@ -299,6 +322,10 @@
|
|
|
299
322
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "rebased"</bpmn:conditionExpression>
|
|
300
323
|
</bpmn:sequenceFlow>
|
|
301
324
|
<bpmn:sequenceFlow id="f_reb_blocked" name="could not resolve" sourceRef="gw-rebase-result" targetRef="merge-esc-attempt" />
|
|
325
|
+
<bpmn:sequenceFlow id="f_reb_wait" name="waits on another PR" sourceRef="gw-rebase-result" targetRef="record-merge-dep">
|
|
326
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
|
|
327
|
+
</bpmn:sequenceFlow>
|
|
328
|
+
<bpmn:sequenceFlow id="f_dep_rewait" sourceRef="record-merge-dep" targetRef="wait-deps" />
|
|
302
329
|
<bpmn:sequenceFlow id="f_m_attempt" sourceRef="attempt-merge" targetRef="gw-merge" />
|
|
303
330
|
<bpmn:sequenceFlow id="f_m_gMerged" name="merged" sourceRef="gw-merge" targetRef="mark-merged">
|
|
304
331
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "merged"</bpmn:conditionExpression>
|
|
@@ -327,7 +354,7 @@
|
|
|
327
354
|
<bpmndi:BPMNShape id="BPMNShape_wait-deps" bpmnElement="wait-deps">
|
|
328
355
|
<dc:Bounds x="216" y="102" width="36" height="36" />
|
|
329
356
|
<bpmndi:BPMNLabel>
|
|
330
|
-
<dc:Bounds x="190" y="
|
|
357
|
+
<dc:Bounds x="190" y="55" width="88" height="42" />
|
|
331
358
|
</bpmndi:BPMNLabel>
|
|
332
359
|
</bpmndi:BPMNShape>
|
|
333
360
|
<bpmndi:BPMNShape id="BPMNShape_arm-merge" bpmnElement="arm-merge">
|
|
@@ -415,14 +442,17 @@
|
|
|
415
442
|
</bpmndi:BPMNLabel>
|
|
416
443
|
</bpmndi:BPMNShape>
|
|
417
444
|
<bpmndi:BPMNShape id="BPMNShape_rebase" bpmnElement="rebase">
|
|
418
|
-
<dc:Bounds x="1038" y="
|
|
445
|
+
<dc:Bounds x="1038" y="1200" width="100" height="80" />
|
|
419
446
|
</bpmndi:BPMNShape>
|
|
420
447
|
<bpmndi:BPMNShape id="BPMNShape_gw-rebase-result" bpmnElement="gw-rebase-result" isMarkerVisible="true">
|
|
421
|
-
<dc:Bounds x="1238" y="
|
|
448
|
+
<dc:Bounds x="1238" y="1215" width="50" height="50" />
|
|
422
449
|
<bpmndi:BPMNLabel>
|
|
423
|
-
<dc:Bounds x="
|
|
450
|
+
<dc:Bounds x="1173" y="1233" width="60" height="14" />
|
|
424
451
|
</bpmndi:BPMNLabel>
|
|
425
452
|
</bpmndi:BPMNShape>
|
|
453
|
+
<bpmndi:BPMNShape id="BPMNShape_record-merge-dep" bpmnElement="record-merge-dep">
|
|
454
|
+
<dc:Bounds x="1388" y="1040" width="100" height="80" />
|
|
455
|
+
</bpmndi:BPMNShape>
|
|
426
456
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_start" bpmnElement="f_m_start">
|
|
427
457
|
<di:waypoint x="116" y="120" />
|
|
428
458
|
<di:waypoint x="216" y="120" />
|
|
@@ -473,7 +503,7 @@
|
|
|
473
503
|
<di:waypoint x="1438" y="920" />
|
|
474
504
|
<di:waypoint x="1438" y="640" />
|
|
475
505
|
<bpmndi:BPMNLabel>
|
|
476
|
-
<dc:Bounds x="1319" y="
|
|
506
|
+
<dc:Bounds x="1319" y="928" width="89" height="14" />
|
|
477
507
|
</bpmndi:BPMNLabel>
|
|
478
508
|
</bpmndi:BPMNEdge>
|
|
479
509
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_giveup" bpmnElement="f_reb_giveup">
|
|
@@ -485,11 +515,12 @@
|
|
|
485
515
|
</bpmndi:BPMNLabel>
|
|
486
516
|
</bpmndi:BPMNEdge>
|
|
487
517
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
|
|
488
|
-
<di:waypoint x="
|
|
489
|
-
<di:waypoint x="
|
|
518
|
+
<di:waypoint x="1263" y="1215" />
|
|
519
|
+
<di:waypoint x="1263" y="1020" />
|
|
520
|
+
<di:waypoint x="1438" y="1020" />
|
|
490
521
|
<di:waypoint x="1438" y="640" />
|
|
491
522
|
<bpmndi:BPMNLabel>
|
|
492
|
-
<dc:Bounds x="
|
|
523
|
+
<dc:Bounds x="1319" y="987" width="64" height="28" />
|
|
493
524
|
</bpmndi:BPMNLabel>
|
|
494
525
|
</bpmndi:BPMNEdge>
|
|
495
526
|
<bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
|
|
@@ -552,17 +583,33 @@
|
|
|
552
583
|
<dc:Bounds x="893" y="839" width="49" height="28" />
|
|
553
584
|
</bpmndi:BPMNLabel>
|
|
554
585
|
</bpmndi:BPMNEdge>
|
|
586
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
|
|
587
|
+
<di:waypoint x="1288" y="920" />
|
|
588
|
+
<di:waypoint x="1438" y="920" />
|
|
589
|
+
<di:waypoint x="1438" y="1040" />
|
|
590
|
+
<bpmndi:BPMNLabel>
|
|
591
|
+
<dc:Bounds x="1326" y="887" width="75" height="28" />
|
|
592
|
+
</bpmndi:BPMNLabel>
|
|
593
|
+
</bpmndi:BPMNEdge>
|
|
555
594
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_go" bpmnElement="f_reb_go">
|
|
556
595
|
<di:waypoint x="913" y="280" />
|
|
557
596
|
<di:waypoint x="933" y="280" />
|
|
558
597
|
<di:waypoint x="933" y="305" />
|
|
559
598
|
<di:waypoint x="1158" y="305" />
|
|
560
|
-
<di:waypoint x="1158" y="
|
|
561
|
-
<di:waypoint x="1138" y="
|
|
599
|
+
<di:waypoint x="1158" y="1240" />
|
|
600
|
+
<di:waypoint x="1138" y="1240" />
|
|
562
601
|
<bpmndi:BPMNLabel>
|
|
563
602
|
<dc:Bounds x="1101" y="310" width="49" height="28" />
|
|
564
603
|
</bpmndi:BPMNLabel>
|
|
565
604
|
</bpmndi:BPMNEdge>
|
|
605
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_wait" bpmnElement="f_reb_wait">
|
|
606
|
+
<di:waypoint x="1263" y="1215" />
|
|
607
|
+
<di:waypoint x="1263" y="1080" />
|
|
608
|
+
<di:waypoint x="1388" y="1080" />
|
|
609
|
+
<bpmndi:BPMNLabel>
|
|
610
|
+
<dc:Bounds x="1288" y="1047" width="75" height="28" />
|
|
611
|
+
</bpmndi:BPMNLabel>
|
|
612
|
+
</bpmndi:BPMNEdge>
|
|
566
613
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gQueued" bpmnElement="f_m_gQueued">
|
|
567
614
|
<di:waypoint x="1088" y="145" />
|
|
568
615
|
<di:waypoint x="1088" y="280" />
|
|
@@ -593,12 +640,12 @@
|
|
|
593
640
|
<di:waypoint x="1238" y="920" />
|
|
594
641
|
</bpmndi:BPMNEdge>
|
|
595
642
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_done" bpmnElement="f_reb_done">
|
|
596
|
-
<di:waypoint x="1138" y="
|
|
597
|
-
<di:waypoint x="1158" y="
|
|
598
|
-
<di:waypoint x="1158" y="
|
|
599
|
-
<di:waypoint x="1238" y="
|
|
600
|
-
<di:waypoint x="1263" y="
|
|
601
|
-
<di:waypoint x="1263" y="
|
|
643
|
+
<di:waypoint x="1138" y="1260" />
|
|
644
|
+
<di:waypoint x="1158" y="1260" />
|
|
645
|
+
<di:waypoint x="1158" y="1285" />
|
|
646
|
+
<di:waypoint x="1238" y="1285" />
|
|
647
|
+
<di:waypoint x="1263" y="1285" />
|
|
648
|
+
<di:waypoint x="1263" y="1265" />
|
|
602
649
|
</bpmndi:BPMNEdge>
|
|
603
650
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
|
|
604
651
|
<di:waypoint x="1263" y="945" />
|
|
@@ -610,14 +657,20 @@
|
|
|
610
657
|
</bpmndi:BPMNLabel>
|
|
611
658
|
</bpmndi:BPMNEdge>
|
|
612
659
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_rebased" bpmnElement="f_reb_rebased">
|
|
613
|
-
<di:waypoint x="1263" y="
|
|
614
|
-
<di:waypoint x="1263" y="
|
|
615
|
-
<di:waypoint x="402" y="
|
|
660
|
+
<di:waypoint x="1263" y="1265" />
|
|
661
|
+
<di:waypoint x="1263" y="1300" />
|
|
662
|
+
<di:waypoint x="402" y="1300" />
|
|
616
663
|
<di:waypoint x="402" y="160" />
|
|
617
664
|
<bpmndi:BPMNLabel>
|
|
618
|
-
<dc:Bounds x="806" y="
|
|
665
|
+
<dc:Bounds x="806" y="1278" width="53" height="14" />
|
|
619
666
|
</bpmndi:BPMNLabel>
|
|
620
667
|
</bpmndi:BPMNEdge>
|
|
668
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
|
|
669
|
+
<di:waypoint x="1438" y="1120" />
|
|
670
|
+
<di:waypoint x="1438" y="1320" />
|
|
671
|
+
<di:waypoint x="234" y="1320" />
|
|
672
|
+
<di:waypoint x="234" y="138" />
|
|
673
|
+
</bpmndi:BPMNEdge>
|
|
621
674
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
|
|
622
675
|
<di:waypoint x="1438" y="458" />
|
|
623
676
|
<di:waypoint x="1438" y="478" />
|
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,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// pr.record-dependency: a merge-stage agent that discovers "this PR must wait for another PR to
|
|
2
|
+
// merge first" turns that into a durable dependency wait (parking the PR back in `waiting_deps`)
|
|
3
|
+
// instead of a human escalation — the wait, not the escalation, is the correct outcome.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
6
|
+
import handler from "./worker.ts";
|
|
7
|
+
|
|
8
|
+
interface DepRow {
|
|
9
|
+
pr_key: string;
|
|
10
|
+
depends_on_key: string;
|
|
11
|
+
created_at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fakeApp(seedDeps: DepRow[] = []) {
|
|
15
|
+
const logs: { level: string; msg: string }[] = [];
|
|
16
|
+
const stores: Record<string, Record<string, unknown>[]> = {
|
|
17
|
+
pr_dependencies: seedDeps as unknown as Record<string, unknown>[],
|
|
18
|
+
pull_requests: [{ pr_key: "o/r#1", status: "waiting_merge" }],
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
app: {
|
|
22
|
+
data: {
|
|
23
|
+
table(name: string, key: string) {
|
|
24
|
+
const store = (stores[name] ??= []);
|
|
25
|
+
return {
|
|
26
|
+
get: (k: unknown) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
27
|
+
find: (q: Record<string, unknown>) =>
|
|
28
|
+
Promise.resolve(
|
|
29
|
+
store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
|
|
30
|
+
),
|
|
31
|
+
insert: (row: Record<string, unknown>) => {
|
|
32
|
+
store.push(row);
|
|
33
|
+
return Promise.resolve(store.length);
|
|
34
|
+
},
|
|
35
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
36
|
+
const row = store.find((r) => r[key] === k);
|
|
37
|
+
if (row) Object.assign(row, patch);
|
|
38
|
+
return Promise.resolve(row);
|
|
39
|
+
},
|
|
40
|
+
delete: (k: unknown) => {
|
|
41
|
+
for (let i = store.length - 1; i >= 0; i--) if (store[i][key] === k) store.splice(i, 1);
|
|
42
|
+
return Promise.resolve(undefined);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
48
|
+
engine: {},
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal test double for AppContext
|
|
50
|
+
} as any,
|
|
51
|
+
stores,
|
|
52
|
+
logs,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const job = (variables: Record<string, unknown>) => ({ variables }) as never;
|
|
57
|
+
|
|
58
|
+
test("records a discovered dependency from a string ref and parks the PR in waiting_deps", async () => {
|
|
59
|
+
const { app, stores } = fakeApp();
|
|
60
|
+
await handler(job({ prKey: "o/r#1", dependsOn: "o/r#2" }), app);
|
|
61
|
+
|
|
62
|
+
assertEquals(stores.pr_dependencies.length, 1);
|
|
63
|
+
assertEquals(stores.pr_dependencies[0].pr_key, "o/r#1");
|
|
64
|
+
assertEquals(stores.pr_dependencies[0].depends_on_key, "o/r#2");
|
|
65
|
+
assertEquals(stores.pull_requests[0].status, "waiting_deps");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("parses several refs (commas/spaces/URLs), dedupes, and never waits on itself", async () => {
|
|
69
|
+
const { app, stores } = fakeApp();
|
|
70
|
+
await handler(
|
|
71
|
+
job({
|
|
72
|
+
prKey: "o/r#1",
|
|
73
|
+
dependsOn: "o/r#2, o/r#2 https://github.com/o/r/pull/3 o/r#1",
|
|
74
|
+
}),
|
|
75
|
+
app,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const keys = stores.pr_dependencies.map((d) => d.depends_on_key).sort();
|
|
79
|
+
assertEquals(keys, ["o/r#2", "o/r#3"]); // #2 deduped, self #1 dropped
|
|
80
|
+
assertEquals(stores.pull_requests[0].status, "waiting_deps");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("appends to existing edges without wiping them and skips already-recorded ones", async () => {
|
|
84
|
+
const { app, stores } = fakeApp([
|
|
85
|
+
{ pr_key: "o/r#1", depends_on_key: "o/r#9", created_at: "t0" },
|
|
86
|
+
]);
|
|
87
|
+
await handler(job({ prKey: "o/r#1", dependsOn: ["o/r#9", "o/r#2"] }), app);
|
|
88
|
+
|
|
89
|
+
const keys = stores.pr_dependencies.map((d) => d.depends_on_key).sort();
|
|
90
|
+
assertEquals(keys, ["o/r#2", "o/r#9"]); // pre-existing #9 preserved, only #2 added
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("no parseable ref still parks in waiting_deps and logs the miswiring loudly", async () => {
|
|
94
|
+
const { app, stores, logs } = fakeApp();
|
|
95
|
+
await handler(job({ prKey: "o/r#1", dependsOn: "not-a-pr" }), app);
|
|
96
|
+
|
|
97
|
+
assertEquals(stores.pr_dependencies.length, 0);
|
|
98
|
+
assertEquals(stores.pull_requests[0].status, "waiting_deps");
|
|
99
|
+
assertEquals(logs.some((l) => l.level === "error"), true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("heals a missing pull_requests parent row before parking so the merge poller can watch it", async () => {
|
|
103
|
+
const { app, stores } = fakeApp();
|
|
104
|
+
stores.pull_requests.length = 0; // engine/app.db desync: no parent row for o/r#1
|
|
105
|
+
|
|
106
|
+
await handler(job({ prKey: "o/r#1", dependsOn: "o/r#2" }), app);
|
|
107
|
+
|
|
108
|
+
// ensurePr reconstructed the row, and the subsequent update landed on it (not a silent no-op).
|
|
109
|
+
assertEquals(stores.pull_requests.length, 1);
|
|
110
|
+
const healed = stores.pull_requests[0];
|
|
111
|
+
assertEquals(healed.pr_key, "o/r#1");
|
|
112
|
+
assertEquals(healed.repo, "o/r");
|
|
113
|
+
assertEquals(healed.number, 1);
|
|
114
|
+
assertEquals(healed.status, "waiting_deps");
|
|
115
|
+
assertEquals(stores.pr_dependencies.length, 1);
|
|
116
|
+
});
|