@nanobpm/nano-workforce 0.188.0 → 0.189.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.
@@ -37,6 +37,23 @@
37
37
  <bpmn:outgoing>ic_escalate</bpmn:outgoing>
38
38
  <bpmn:outgoing>ic_done</bpmn:outgoing>
39
39
  </bpmn:exclusiveGateway>
40
+ <bpmn:serviceTask id="reconcile-implement" name="Reconcile from GitHub">
41
+ <bpmn:extensionElements>
42
+ <zeebe:taskDefinition type="pr.reconcile-implement" />
43
+ <zeebe:ioMapping>
44
+ <zeebe:output source="=reconciled" target="reconciled" />
45
+ <zeebe:output source="=status" target="status" />
46
+ <zeebe:output source="=pr" target="pr" />
47
+ </zeebe:ioMapping>
48
+ </bpmn:extensionElements>
49
+ <bpmn:incoming>ic_escalate</bpmn:incoming>
50
+ <bpmn:outgoing>ic_toReconcileGw</bpmn:outgoing>
51
+ </bpmn:serviceTask>
52
+ <bpmn:exclusiveGateway id="ic_reconcile_gw" name="reconciled?" default="ic_toRecordEscalation">
53
+ <bpmn:incoming>ic_toReconcileGw</bpmn:incoming>
54
+ <bpmn:outgoing>ic_reconciled</bpmn:outgoing>
55
+ <bpmn:outgoing>ic_toRecordEscalation</bpmn:outgoing>
56
+ </bpmn:exclusiveGateway>
40
57
  <bpmn:callActivity id="escalate" name="Human escalation">
41
58
  <bpmn:extensionElements>
42
59
  <zeebe:calledElement processId="human-escalation" />
@@ -61,7 +78,7 @@
61
78
  <zeebe:output source="=question" target="question" />
62
79
  </zeebe:ioMapping>
63
80
  </bpmn:extensionElements>
64
- <bpmn:incoming>ic_escalate</bpmn:incoming>
81
+ <bpmn:incoming>ic_toRecordEscalation</bpmn:incoming>
65
82
  <bpmn:outgoing>ic_toEscalate</bpmn:outgoing>
66
83
  </bpmn:serviceTask>
67
84
  <bpmn:exclusiveGateway id="ic_gw_answer" name="answer?" default="ic_abandon">
@@ -81,6 +98,7 @@
81
98
  </bpmn:serviceTask>
82
99
  <bpmn:endEvent id="ic_end" name="Task done">
83
100
  <bpmn:incoming>ic_done</bpmn:incoming>
101
+ <bpmn:incoming>ic_reconciled</bpmn:incoming>
84
102
  <bpmn:incoming>ic_abandon</bpmn:incoming>
85
103
  </bpmn:endEvent>
86
104
  <bpmn:sequenceFlow id="ic_toImplement" sourceRef="Start" targetRef="implement-task" />
@@ -88,7 +106,12 @@
88
106
  <bpmn:sequenceFlow id="ic_done" name="done" sourceRef="ic_gw" targetRef="ic_end">
89
107
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "opened" or status = "blocked" or status = "skipped"</bpmn:conditionExpression>
90
108
  </bpmn:sequenceFlow>
91
- <bpmn:sequenceFlow id="ic_escalate" name="escalated" sourceRef="ic_gw" targetRef="record-escalation" />
109
+ <bpmn:sequenceFlow id="ic_escalate" name="reconcile" sourceRef="ic_gw" targetRef="reconcile-implement" />
110
+ <bpmn:sequenceFlow id="ic_toReconcileGw" sourceRef="reconcile-implement" targetRef="ic_reconcile_gw" />
111
+ <bpmn:sequenceFlow id="ic_reconciled" name="adopted" sourceRef="ic_reconcile_gw" targetRef="ic_end">
112
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=reconciled = true</bpmn:conditionExpression>
113
+ </bpmn:sequenceFlow>
114
+ <bpmn:sequenceFlow id="ic_toRecordEscalation" name="escalated" sourceRef="ic_reconcile_gw" targetRef="record-escalation" />
92
115
  <bpmn:sequenceFlow id="ic_toEscalate" sourceRef="record-escalation" targetRef="escalate" />
93
116
  <bpmn:sequenceFlow id="ic_toAnswerGw" sourceRef="escalate" targetRef="ic_gw_answer" />
94
117
  <bpmn:sequenceFlow id="ic_answerLoop" name="answer" sourceRef="ic_gw_answer" targetRef="record-implementing">
@@ -114,25 +137,34 @@
114
137
  <dc:Bounds x="406" y="62" width="70" height="28" />
115
138
  </bpmndi:BPMNLabel>
116
139
  </bpmndi:BPMNShape>
140
+ <bpmndi:BPMNShape id="BPMNShape_reconcile-implement" bpmnElement="reconcile-implement">
141
+ <dc:Bounds x="566" y="80" width="100" height="80" />
142
+ </bpmndi:BPMNShape>
143
+ <bpmndi:BPMNShape id="BPMNShape_ic_reconcile_gw" bpmnElement="ic_reconcile_gw" isMarkerVisible="true">
144
+ <dc:Bounds x="766" y="95" width="50" height="50" />
145
+ <bpmndi:BPMNLabel>
146
+ <dc:Bounds x="751" y="76" width="81" height="14" />
147
+ </bpmndi:BPMNLabel>
148
+ </bpmndi:BPMNShape>
117
149
  <bpmndi:BPMNShape id="BPMNShape_escalate" bpmnElement="escalate">
118
- <dc:Bounds x="766" y="80" width="100" height="80" />
150
+ <dc:Bounds x="1116" y="80" width="100" height="80" />
119
151
  </bpmndi:BPMNShape>
120
152
  <bpmndi:BPMNShape id="BPMNShape_record-escalation" bpmnElement="record-escalation">
121
- <dc:Bounds x="566" y="80" width="100" height="80" />
153
+ <dc:Bounds x="916" y="80" width="100" height="80" />
122
154
  </bpmndi:BPMNShape>
123
155
  <bpmndi:BPMNShape id="BPMNShape_ic_gw_answer" bpmnElement="ic_gw_answer" isMarkerVisible="true">
124
- <dc:Bounds x="966" y="95" width="50" height="50" />
156
+ <dc:Bounds x="1316" y="95" width="50" height="50" />
125
157
  <bpmndi:BPMNLabel>
126
- <dc:Bounds x="963" y="76" width="56" height="14" />
158
+ <dc:Bounds x="1313" y="76" width="56" height="14" />
127
159
  </bpmndi:BPMNLabel>
128
160
  </bpmndi:BPMNShape>
129
161
  <bpmndi:BPMNShape id="BPMNShape_record-implementing" bpmnElement="record-implementing">
130
- <dc:Bounds x="1116" y="240" width="100" height="80" />
162
+ <dc:Bounds x="1466" y="240" width="100" height="80" />
131
163
  </bpmndi:BPMNShape>
132
164
  <bpmndi:BPMNShape id="BPMNShape_ic_end" bpmnElement="ic_end">
133
- <dc:Bounds x="1148" y="102" width="36" height="36" />
165
+ <dc:Bounds x="1498" y="102" width="36" height="36" />
134
166
  <bpmndi:BPMNLabel>
135
- <dc:Bounds x="1133" y="83" width="66" height="14" />
167
+ <dc:Bounds x="1483" y="83" width="66" height="14" />
136
168
  </bpmndi:BPMNLabel>
137
169
  </bpmndi:BPMNShape>
138
170
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toImplement" bpmnElement="ic_toImplement">
@@ -150,41 +182,61 @@
150
182
  <dc:Bounds x="483" y="128" width="67" height="14" />
151
183
  </bpmndi:BPMNLabel>
152
184
  </bpmndi:BPMNEdge>
153
- <bpmndi:BPMNEdge id="BPMNEdge_ic_toEscalate" bpmnElement="ic_toEscalate">
185
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toReconcileGw" bpmnElement="ic_toReconcileGw">
154
186
  <di:waypoint x="666" y="120" />
155
187
  <di:waypoint x="766" y="120" />
156
188
  </bpmndi:BPMNEdge>
189
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toRecordEscalation" bpmnElement="ic_toRecordEscalation">
190
+ <di:waypoint x="816" y="120" />
191
+ <di:waypoint x="916" y="120" />
192
+ <bpmndi:BPMNLabel>
193
+ <dc:Bounds x="833" y="128" width="67" height="14" />
194
+ </bpmndi:BPMNLabel>
195
+ </bpmndi:BPMNEdge>
196
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toEscalate" bpmnElement="ic_toEscalate">
197
+ <di:waypoint x="1016" y="120" />
198
+ <di:waypoint x="1116" y="120" />
199
+ </bpmndi:BPMNEdge>
157
200
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toAnswerGw" bpmnElement="ic_toAnswerGw">
158
- <di:waypoint x="866" y="120" />
159
- <di:waypoint x="966" y="120" />
201
+ <di:waypoint x="1216" y="120" />
202
+ <di:waypoint x="1316" y="120" />
160
203
  </bpmndi:BPMNEdge>
161
204
  <bpmndi:BPMNEdge id="BPMNEdge_ic_abandon" bpmnElement="ic_abandon">
162
- <di:waypoint x="1016" y="120" />
163
- <di:waypoint x="1148" y="120" />
205
+ <di:waypoint x="1366" y="120" />
206
+ <di:waypoint x="1498" y="120" />
164
207
  <bpmndi:BPMNLabel>
165
- <dc:Bounds x="1056" y="98" width="53" height="14" />
208
+ <dc:Bounds x="1406" y="98" width="53" height="14" />
166
209
  </bpmndi:BPMNLabel>
167
210
  </bpmndi:BPMNEdge>
168
211
  <bpmndi:BPMNEdge id="BPMNEdge_ic_answerLoop" bpmnElement="ic_answerLoop">
169
- <di:waypoint x="991" y="145" />
170
- <di:waypoint x="991" y="280" />
171
- <di:waypoint x="1116" y="280" />
212
+ <di:waypoint x="1341" y="145" />
213
+ <di:waypoint x="1341" y="280" />
214
+ <di:waypoint x="1466" y="280" />
172
215
  <bpmndi:BPMNLabel>
173
- <dc:Bounds x="996" y="206" width="49" height="14" />
216
+ <dc:Bounds x="1346" y="206" width="49" height="14" />
174
217
  </bpmndi:BPMNLabel>
175
218
  </bpmndi:BPMNEdge>
176
219
  <bpmndi:BPMNEdge id="BPMNEdge_ic_done" bpmnElement="ic_done">
177
220
  <di:waypoint x="441" y="145" />
178
- <di:waypoint x="441" y="180" />
179
- <di:waypoint x="1166" y="180" />
180
- <di:waypoint x="1166" y="138" />
221
+ <di:waypoint x="441" y="200" />
222
+ <di:waypoint x="1516" y="200" />
223
+ <di:waypoint x="1516" y="138" />
224
+ <bpmndi:BPMNLabel>
225
+ <dc:Bounds x="963" y="208" width="32" height="14" />
226
+ </bpmndi:BPMNLabel>
227
+ </bpmndi:BPMNEdge>
228
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_reconciled" bpmnElement="ic_reconciled">
229
+ <di:waypoint x="791" y="145" />
230
+ <di:waypoint x="791" y="180" />
231
+ <di:waypoint x="1516" y="180" />
232
+ <di:waypoint x="1516" y="138" />
181
233
  <bpmndi:BPMNLabel>
182
- <dc:Bounds x="788" y="188" width="32" height="14" />
234
+ <dc:Bounds x="1047" y="158" width="53" height="14" />
183
235
  </bpmndi:BPMNLabel>
184
236
  </bpmndi:BPMNEdge>
185
237
  <bpmndi:BPMNEdge id="BPMNEdge_ic_reImplement" bpmnElement="ic_reImplement">
186
- <di:waypoint x="1166" y="320" />
187
- <di:waypoint x="1166" y="340" />
238
+ <di:waypoint x="1516" y="320" />
239
+ <di:waypoint x="1516" y="340" />
188
240
  <di:waypoint x="266" y="340" />
189
241
  <di:waypoint x="266" y="160" />
190
242
  </bpmndi:BPMNEdge>
@@ -443,6 +443,7 @@
443
443
  <zeebe:ioMapping>
444
444
  <zeebe:input source="=task" target="task" />
445
445
  <zeebe:input source="=planKey" target="subjectKey" />
446
+ <zeebe:input source="=baseBranch" target="baseBranch" />
446
447
  <zeebe:input source="=escalationSlaTimeout" target="escalationSlaTimeout" />
447
448
  <zeebe:input source="=if (is defined(escalationAssignee)) then escalationAssignee else null" target="escalationAssignee" />
448
449
  <zeebe:input source="=if (is defined(baseBranchBrief)) then baseBranchBrief else null" target="baseBranchBrief" />
package/test/worldDb.ts CHANGED
@@ -69,12 +69,18 @@ function gateway(db: DatabaseSync, name: string, pk: string) {
69
69
  * that decorates `table` on the returned source sees its decoration inside the transaction too. */
70
70
  type MemDataSource = {
71
71
  table: (name: string, pk?: string) => ReturnType<typeof gateway>;
72
+ query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
72
73
  tx<T>(fn: (t: MemDataSource) => Promise<T>): Promise<T>;
73
74
  };
74
75
 
75
76
  function openDataSource(db: DatabaseSync): MemDataSource {
76
77
  const ds: MemDataSource = {
77
78
  table: (name, pk = "id") => gateway(db, name, pk),
79
+ // The row-returning raw-SQL surface (mirrors the runtime `DataSource.query`) so tests exercise a
80
+ // handler's bounded `WHERE … IN (…)` reads against genuine SQLite rather than a mock.
81
+ async query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
82
+ return db.prepare(sql).all(...params.map(coerce)) as T[];
83
+ },
78
84
  async tx(fn) {
79
85
  db.exec("BEGIN");
80
86
  try {
@@ -0,0 +1,87 @@
1
+ // Unit coverage for pr.reconcile-implement — the implement-cell's reconcile-before-escalate step
2
+ // (issue #801). It wraps the canonical `reconcileImplement` decision with the injected GitHub read;
3
+ // here we assert the handler wires job variables through and re-emits the decision (the gate variable
4
+ // `reconciled` plus the adopted `status`/`pr`). The decision logic itself is exhaustively covered in
5
+ // app/implementReconcile.test.ts.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import { noopLog } from "../../test/log.ts";
9
+ import handler from "./worker.ts";
10
+
11
+ // biome-ignore lint/suspicious/noExplicitAny: tiny app double — the handler only touches app.log.
12
+ const fakeApp: any = { log: noopLog() };
13
+
14
+ test("adopts an open PR on the branch: emits reconciled + status=opened + pr", async () => {
15
+ const prevToken = process.env.GITHUB_TOKEN;
16
+ const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
17
+ const prevFetch = globalThis.fetch;
18
+ process.env.GITHUB_TOKEN = "t";
19
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token";
20
+ // Token transport (never shells out to `gh`): stub the pulls listing so `listPrsForHead` returns one
21
+ // open PR opened from `feat/issue-801`.
22
+ globalThis.fetch = (async () =>
23
+ new Response(JSON.stringify([{ number: 801, html_url: "https://github.com/owner/repo/pull/801", state: "open", base: { ref: "main" } }]), {
24
+ status: 200,
25
+ })) as typeof fetch;
26
+ try {
27
+ const out = await handler(
28
+ { jobKey: "j1", variables: { subjectKey: "owner/repo#801", task: { id: "issue-801" }, status: null } } as never,
29
+ fakeApp,
30
+ );
31
+ assertEquals(out, { reconciled: true, status: "opened", pr: "owner/repo#801" });
32
+ } finally {
33
+ globalThis.fetch = prevFetch;
34
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
35
+ else process.env.GITHUB_TOKEN = prevToken;
36
+ if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
37
+ else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
38
+ }
39
+ });
40
+
41
+ test("wires baseBranch through: a wrong-base open PR is NOT adopted → escalate", async () => {
42
+ const prevToken = process.env.GITHUB_TOKEN;
43
+ const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
44
+ const prevFetch = globalThis.fetch;
45
+ process.env.GITHUB_TOKEN = "t";
46
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token";
47
+ // The only open PR on the head branch targets a different base than the run's pinned `baseBranch`.
48
+ globalThis.fetch = (async () =>
49
+ new Response(JSON.stringify([{ number: 55, html_url: "https://github.com/owner/repo/pull/55", state: "open", base: { ref: "stale-base" } }]), {
50
+ status: 200,
51
+ })) as typeof fetch;
52
+ try {
53
+ const out = await handler(
54
+ { jobKey: "j3", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null, baseBranch: "epic/feat-x" } } as never,
55
+ fakeApp,
56
+ );
57
+ assertEquals(out, { reconciled: false, status: null, pr: null });
58
+ } finally {
59
+ globalThis.fetch = prevFetch;
60
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
61
+ else process.env.GITHUB_TOKEN = prevToken;
62
+ if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
63
+ else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
64
+ }
65
+ });
66
+
67
+ test("no open PR on the branch: falls through to escalate (reconciled=false)", async () => {
68
+ const prevToken = process.env.GITHUB_TOKEN;
69
+ const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
70
+ const prevFetch = globalThis.fetch;
71
+ process.env.GITHUB_TOKEN = "t";
72
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token";
73
+ globalThis.fetch = (async () => new Response(JSON.stringify([]), { status: 200 })) as typeof fetch;
74
+ try {
75
+ const out = await handler(
76
+ { jobKey: "j2", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null } } as never,
77
+ fakeApp,
78
+ );
79
+ assertEquals(out, { reconciled: false, status: null, pr: null });
80
+ } finally {
81
+ globalThis.fetch = prevFetch;
82
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
83
+ else process.env.GITHUB_TOKEN = prevToken;
84
+ if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
85
+ else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
86
+ }
87
+ });
@@ -0,0 +1,60 @@
1
+ // pr.reconcile-implement — reconcile the implement-cell result from GitHub before escalating (issue #801).
2
+ //
3
+ // Runs on the shared `implement-cell`'s escalate arm (`ic_gw` "clean terminal?" → here), IMMEDIATELY
4
+ // before the human escalation, for EVERY caller that composes the cell: a standalone `feature` run and
5
+ // a plan-fanout wave slice. When the implement step returned no machine-readable `status` (a harness
6
+ // that opened a green PR but reported nothing — #796's implement-stage twin), it looks for an OPEN PR
7
+ // on the cell's deterministic `feat/<task.id>` branch and, when one exists, ADOPTS it — emitting
8
+ // `reconciled = true` (the `ic_reconcile_gw` gate routes straight to the cell's done end) plus
9
+ // `status = "opened"` and a `pr` key so the caller converges the adopted PR exactly as if the agent
10
+ // had reported it. Only when nothing is observable does it fall through (`reconciled = false`) to the
11
+ // human escalation as today.
12
+ //
13
+ // The decision + the injected GitHub read (`listPrsForHead`) live in the canonical, exhaustively
14
+ // tested `app/implementReconcile.ts` mirror — this handler is the thin engine seam. `taskId` is
15
+ // derived from the in-scope `task` process variable (`task.id`) — `implement-cell.bpmn` defines no
16
+ // `<zeebe:ioMapping>` input for this step; the engine populates `task` (and `subjectKey`, `status`,
17
+ // `pr`, `baseBranch`) from process scope. `pr` is passed through so the reconcile step's `pr` output
18
+ // never wipes a PR key the implement harness already set. `baseBranch` (the run's pinned base) is
19
+ // passed through so an open PR on the deterministic head branch is only adopted when it targets that
20
+ // base — never a stale/unrelated PR sharing the head branch but aimed at a different base.
21
+ import type { AppJobHandler } from "@nanobpm/urban";
22
+ import { listPrsForHead } from "../../app/github.ts";
23
+ import { type ReconcileImplementResult, reconcileImplement } from "../../app/implementReconcile.ts";
24
+
25
+ interface In extends Record<string, unknown> {
26
+ subjectKey?: unknown;
27
+ task?: unknown;
28
+ status?: unknown;
29
+ pr?: unknown;
30
+ baseBranch?: unknown;
31
+ }
32
+
33
+ /** The cell's deterministic branch is `feat/<task.id>`; `task` is the implement-cell's slice object.
34
+ * Read it defensively (the process variable is untyped at the engine seam). */
35
+ function taskId(task: unknown): unknown {
36
+ if (task !== null && typeof task === "object" && "id" in task) return task.id;
37
+ return undefined;
38
+ }
39
+
40
+ const handler: AppJobHandler<In, ReconcileImplementResult> = async (job, app) => {
41
+ const res = await reconcileImplement(
42
+ {
43
+ status: job.variables.status,
44
+ subjectKey: job.variables.subjectKey,
45
+ taskId: taskId(job.variables.task),
46
+ pr: job.variables.pr,
47
+ baseBranch: job.variables.baseBranch,
48
+ },
49
+ listPrsForHead,
50
+ process.env.GITHUB_TOKEN ?? "",
51
+ );
52
+ app.log.info("reconcile-implement", {
53
+ subjectKey: job.variables.subjectKey ?? null,
54
+ reconciled: res.reconciled,
55
+ pr: res.pr,
56
+ });
57
+ return res;
58
+ };
59
+
60
+ export default handler;