@nanobpm/nano-workforce 0.132.0 → 0.133.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 +6 -0
- package/app/deliveryConnector.test.ts +19 -0
- package/app/deliveryConnector.ts +50 -10
- package/app/deliveryGraphCompiler.test.ts +23 -0
- package/app/service.test.ts +28 -1
- package/app/service.ts +15 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/agent-guide.md +47 -1
- package/package.json +1 -1
- package/workers/delivery-connector/worker.test.ts +239 -1
- package/workers/delivery-connector/worker.ts +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.133.0](https://github.com/nanobpm/nano-workforce/compare/v0.132.0...v0.133.0) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** connector `converge`/`converge-merge` target enrolls a PR via submitPr (retire the manual land gate) ([#501](https://github.com/nanobpm/nano-workforce/issues/501)) ([a24562e](https://github.com/nanobpm/nano-workforce/commit/a24562eb12db8e6f81e6f382e94886a537f16378)), closes [#500](https://github.com/nanobpm/nano-workforce/issues/500)
|
|
6
|
+
|
|
1
7
|
## [0.132.0](https://github.com/nanobpm/nano-workforce/compare/v0.131.1...v0.132.0) (2026-08-24)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -15,10 +15,14 @@ import { join, resolve } from "node:path";
|
|
|
15
15
|
import type { DataLayer } from "@nanobpm/urban";
|
|
16
16
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
17
17
|
import {
|
|
18
|
+
CONVERGE_MERGE_TARGET,
|
|
19
|
+
CONVERGE_TARGET,
|
|
18
20
|
connectorDedupeKey,
|
|
21
|
+
convergeOnlyForTarget,
|
|
19
22
|
type DeliveryConnectorDispatchRow,
|
|
20
23
|
deliveryConnectorDispatches,
|
|
21
24
|
dispatchConnector,
|
|
25
|
+
isConvergeTarget,
|
|
22
26
|
} from "./deliveryConnector.ts";
|
|
23
27
|
|
|
24
28
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
@@ -84,6 +88,21 @@ test("connectorDedupeKey: author key wins; else derives <processInstanceKey>:<el
|
|
|
84
88
|
assertEquals(connectorDedupeKey({ processInstanceKey: 12345, elementId: "n3" }), "12345:n3");
|
|
85
89
|
});
|
|
86
90
|
|
|
91
|
+
test("converge targets: `converge`/`converge-merge` are the enrollment targets; `converge` is review-only", () => {
|
|
92
|
+
assertEquals(CONVERGE_TARGET, "converge");
|
|
93
|
+
assertEquals(CONVERGE_MERGE_TARGET, "converge-merge");
|
|
94
|
+
// Only the two converge literals route into `submitPr`; any other target stays a stub dispatch.
|
|
95
|
+
assert(isConvergeTarget("converge"));
|
|
96
|
+
assert(isConvergeTarget("converge-merge"));
|
|
97
|
+
assert(!isConvergeTarget("slack"));
|
|
98
|
+
assert(!isConvergeTarget("Converge"));
|
|
99
|
+
assert(!isConvergeTarget(""));
|
|
100
|
+
// `convergeOnly` default maps onto `submitPr`'s arg: `converge` stops at converged (true),
|
|
101
|
+
// `converge-merge` drives the merge loop (false) — mirroring converge-feature's autoMerge inversion.
|
|
102
|
+
assertEquals(convergeOnlyForTarget("converge"), true);
|
|
103
|
+
assertEquals(convergeOnlyForTarget("converge-merge"), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
87
106
|
test("first dispatch delivers exactly once; a redelivery on the same key dedupes and never re-acts", async () => {
|
|
88
107
|
await withApp(async (app) => {
|
|
89
108
|
const at = "2025-01-01T00:00:00.000Z";
|
package/app/deliveryConnector.ts
CHANGED
|
@@ -33,6 +33,33 @@ export const DELIVERY_CONNECTOR_TASK_TYPE = "pr.delivery-connector";
|
|
|
33
33
|
export const OUTCOME_CLAIMED = "claimed";
|
|
34
34
|
export const OUTCOME_DELIVERED = "delivered";
|
|
35
35
|
|
|
36
|
+
/** The two connector `target`s that enroll an agent-opened PR into the app's SHARED convergence /
|
|
37
|
+
* merge doors via `submitPr` (issue #500) — the delivery-graph side of the exact seam the feature
|
|
38
|
+
* cell reuses (`workers/converge-feature`), no duplicated machinery. `converge-merge` drives review
|
|
39
|
+
* convergence AND the merge loop; `converge` stops at `converged` (converge-only). This is the "real
|
|
40
|
+
* target dispatch" ADR 0005 deferred as a later slice for the connector I/O surface: a `converge`/
|
|
41
|
+
* `converge-merge` connector IS the "automated, side-effecting outbound action" a connector is
|
|
42
|
+
* defined to be. Named constants so the worker's dispatch branch and the docs/preview can never drift
|
|
43
|
+
* on the literal. */
|
|
44
|
+
export const CONVERGE_TARGET = "converge";
|
|
45
|
+
export const CONVERGE_MERGE_TARGET = "converge-merge";
|
|
46
|
+
|
|
47
|
+
/** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge`)? The single
|
|
48
|
+
* predicate the worker branches on to route a dispatch into `submitPr` instead of the forward-declared
|
|
49
|
+
* stub. */
|
|
50
|
+
export function isConvergeTarget(target: string): boolean {
|
|
51
|
+
return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
|
|
55
|
+
* `converged`), `converge-merge` drives the merge loop too (`false`). Maps directly onto `submitPr`'s
|
|
56
|
+
* `convergeOnly` argument (mirroring how `converge-feature` inverts `autoMerge`). An author may still
|
|
57
|
+
* override it per-dispatch via the connector payload's `convergeOnly`. Only ever consulted behind
|
|
58
|
+
* `isConvergeTarget`, so a non-converge target's `false` is unreachable. */
|
|
59
|
+
export function convergeOnlyForTarget(target: string): boolean {
|
|
60
|
+
return target === CONVERGE_TARGET;
|
|
61
|
+
}
|
|
62
|
+
|
|
36
63
|
/** One durable dispatch-claim row — the at-most-once ledger entry a connector writes before it acts. */
|
|
37
64
|
export interface DeliveryConnectorDispatchRow extends Record<string, unknown> {
|
|
38
65
|
id?: number;
|
|
@@ -77,16 +104,27 @@ export function connectorDedupeKey(input: {
|
|
|
77
104
|
return null;
|
|
78
105
|
}
|
|
79
106
|
|
|
80
|
-
/** The
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
107
|
+
/** The side effect a connector dispatch performs EXACTLY ONCE per dedupe key. It runs only on the claim
|
|
108
|
+
* winner (or a resumed crashed claim), never on a `deduped` settled redelivery, so a real, non-idempotent
|
|
109
|
+
* side effect (e.g. `submitPr`, which deliberately re-opens a TERMINAL PR) is fenced by the ledger and
|
|
110
|
+
* can never double-fire — the reason the enrollment lives HERE rather than unconditionally around the
|
|
111
|
+
* dispatch. Returns the `detail` recorded on the ledger row. May be async (the real converge enrollment
|
|
112
|
+
* awaits `submitPr`). Must be idempotent so a resumed crashed claim can safely re-perform it — including
|
|
113
|
+
* terminal-safe against a NON-idempotent target (the converge action no-ops when its PR already settled,
|
|
114
|
+
* so a resume can never regress a terminal PR by re-opening it). */
|
|
115
|
+
export type ConnectorAction = (input: {
|
|
84
116
|
target: string;
|
|
85
117
|
payload: Record<string, unknown> | null;
|
|
86
118
|
boundFacts: readonly BoundFact[];
|
|
87
|
-
})
|
|
119
|
+
}) => { detail: string } | Promise<{ detail: string }>;
|
|
120
|
+
|
|
121
|
+
/** The forward-declared connector I/O surface (ADR non-goal — the concrete scheme is deferred). The
|
|
122
|
+
* DEFAULT `ConnectorAction`: a STUB that "performs" the action by returning a deterministic
|
|
123
|
+
* acknowledgement; a caller with a real side effect (the converge worker's `submitPr` enrollment) injects
|
|
124
|
+
* its own action into `dispatchConnector` instead, without touching the idempotency envelope around it. */
|
|
125
|
+
const performConnectorAction: ConnectorAction = (_input) => {
|
|
88
126
|
return { detail: "connector stub — I/O surface forward-declared (ADR 0005 non-goal)" };
|
|
89
|
-
}
|
|
127
|
+
};
|
|
90
128
|
|
|
91
129
|
/** The result of one connector dispatch attempt. `delivered` — the claim was won and the action fired
|
|
92
130
|
* exactly once; `deduped` — the key was already claimed (an at-least-once redelivery), so the recorded
|
|
@@ -107,13 +145,14 @@ async function resumeOrDedupe(
|
|
|
107
145
|
ledger: ReturnType<typeof deliveryConnectorDispatches>,
|
|
108
146
|
row: DeliveryConnectorDispatchRow,
|
|
109
147
|
input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
|
|
148
|
+
perform: ConnectorAction,
|
|
110
149
|
): Promise<ConnectorDispatchResult> {
|
|
111
150
|
if (row.outcome === OUTCOME_DELIVERED) {
|
|
112
151
|
return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: row.detail ?? "" };
|
|
113
152
|
}
|
|
114
153
|
// Still `claimed` — a prior attempt (sequential or the concurrent-race winner) claimed the key but
|
|
115
154
|
// never recorded delivery. Resume on the existing row rather than dedupe forever on an un-acted claim.
|
|
116
|
-
const { detail } =
|
|
155
|
+
const { detail } = await perform({
|
|
117
156
|
target: input.target,
|
|
118
157
|
payload: input.payload ?? null,
|
|
119
158
|
boundFacts: input.boundFacts ?? [],
|
|
@@ -149,6 +188,7 @@ export async function dispatchConnector(
|
|
|
149
188
|
data: DataLayer,
|
|
150
189
|
input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
|
|
151
190
|
at: string,
|
|
191
|
+
perform: ConnectorAction = performConnectorAction,
|
|
152
192
|
): Promise<ConnectorDispatchResult> {
|
|
153
193
|
const ledger = deliveryConnectorDispatches(data);
|
|
154
194
|
const existing = await ledger.findOne({ dedupe_key: input.dedupeKey });
|
|
@@ -167,7 +207,7 @@ export async function dispatchConnector(
|
|
|
167
207
|
if (existing) {
|
|
168
208
|
// A prior attempt recorded (`delivered`) or claimed-but-crashed (`claimed`) this key. Dedupe or
|
|
169
209
|
// resume it on the existing row — the ONE decision shared with the fence-loser path below.
|
|
170
|
-
return resumeOrDedupe(ledger, existing, input);
|
|
210
|
+
return resumeOrDedupe(ledger, existing, input, perform);
|
|
171
211
|
}
|
|
172
212
|
let claimId: number | bigint;
|
|
173
213
|
try {
|
|
@@ -196,11 +236,11 @@ export async function dispatchConnector(
|
|
|
196
236
|
// as the sequential path. Deduping a still-`claimed` winner here would complete the job on our ack,
|
|
197
237
|
// so a winner that then crashed would strand the side effect forever (the engine won't redeliver an
|
|
198
238
|
// acked job); resuming closes that gap and is safe because the action is idempotent.
|
|
199
|
-
if (won) return resumeOrDedupe(ledger, won, input);
|
|
239
|
+
if (won) return resumeOrDedupe(ledger, won, input, perform);
|
|
200
240
|
return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: "" };
|
|
201
241
|
}
|
|
202
242
|
// We alone won the claim — perform the side effect exactly once and record its outcome on our row.
|
|
203
|
-
const { detail } =
|
|
243
|
+
const { detail } = await perform({
|
|
204
244
|
target: input.target,
|
|
205
245
|
payload: input.payload ?? null,
|
|
206
246
|
boundFacts: input.boundFacts ?? [],
|
|
@@ -280,6 +280,29 @@ test("sideEffects: agent + connector only; connector carries its dedupeKey", asy
|
|
|
280
280
|
assert(!r.sideEffects.some((s) => s.nodeId === "publish"));
|
|
281
281
|
});
|
|
282
282
|
|
|
283
|
+
test("converge-merge worked graph: agent → connector[converge-merge] → wait[pr,merged] compiles with NO human node (retires the manual land gate, #500)", async () => {
|
|
284
|
+
const graph = {
|
|
285
|
+
name: "open → converge+merge → wait merged",
|
|
286
|
+
nodes: [
|
|
287
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "Implement the change and open a PR." } },
|
|
288
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "acme/repo#123" } } },
|
|
289
|
+
{ id: "merged", kind: "wait", wait: { kind: "pr", target: "acme/repo#123", match: { prState: "merged" }, onTimeout: "escalate" } },
|
|
290
|
+
],
|
|
291
|
+
edges: [
|
|
292
|
+
{ from: "open", to: "land" },
|
|
293
|
+
{ from: "land", to: "merged" },
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
const r = await compileOk(graph);
|
|
297
|
+
// The canonical shape has NO human land-* gate — convergence is driven by the connector itself.
|
|
298
|
+
assertEquals(r.humanNodes.length, 0, "no human node bridges the PR to convergence");
|
|
299
|
+
// The connector is a side effect, naming its converge-merge target; the wait gate is read-only.
|
|
300
|
+
const connector = r.sideEffects.find((s) => s.nodeId === "land");
|
|
301
|
+
assertEquals(connector?.kind, "connector");
|
|
302
|
+
assert(connector?.description.includes("converge-merge"), "the side-effect names the converge-merge target");
|
|
303
|
+
assert(!r.sideEffects.some((s) => s.nodeId === "merged"), "the wait gate is not a side effect");
|
|
304
|
+
});
|
|
305
|
+
|
|
283
306
|
test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
|
|
284
307
|
const r = await compileOk(RELEASE_RUNBOOK);
|
|
285
308
|
const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
|
package/app/service.test.ts
CHANGED
|
@@ -11,7 +11,9 @@ import { memDataFor } from "../test/worldDb.ts";
|
|
|
11
11
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
13
13
|
import { WorldStore } from "./world/index.ts";
|
|
14
|
-
import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
14
|
+
import { abandonClosedPr, isPrSettled, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
15
|
+
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
16
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
15
17
|
|
|
16
18
|
function memTable(rows: any[], key: string) {
|
|
17
19
|
return {
|
|
@@ -49,6 +51,31 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
|
49
51
|
});
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
test("isPrSettled reads the derived tracking view — an out-of-band-abandoned PR (base row still converging) is settled", async () => {
|
|
55
|
+
// The base `pull_requests` row still reads `converging`, but the ADR-0065 derived tracking VIEW
|
|
56
|
+
// folds the reconciler's out-of-band terminal edge into `derived_status: "abandoned"`. Terminal-edge
|
|
57
|
+
// classification must read `derived_status`, not the stale base `status`, or a crash-window RESUME
|
|
58
|
+
// of the delivery-connector enrollment action re-runs `submitPr` against a PR that has actually
|
|
59
|
+
// already settled (and the ledger detail falsely claims it enrolled).
|
|
60
|
+
const PR_KEY = "owner/repo#7";
|
|
61
|
+
const view = trackingTargetFor("pull_requests").view;
|
|
62
|
+
const base = { pr_key: PR_KEY, status: "converging" };
|
|
63
|
+
function make(derived: string) {
|
|
64
|
+
return {
|
|
65
|
+
table(name: string) {
|
|
66
|
+
if (name === "pull_requests") return { get: async (k: string) => (k === PR_KEY ? { ...base } : null) };
|
|
67
|
+
if (name === view) return { get: async (k: string) => (k === PR_KEY ? { ...base, derived_status: derived } : null) };
|
|
68
|
+
throw new Error(`unexpected table ${name}`);
|
|
69
|
+
},
|
|
70
|
+
} as any as DataLayer;
|
|
71
|
+
}
|
|
72
|
+
assertEquals(await isPrSettled(make("abandoned"), PR_KEY), true, "out-of-band-abandoned PR is settled via derived_status");
|
|
73
|
+
assertEquals(await isPrSettled(make("converging"), PR_KEY), false, "a genuinely live PR is not settled");
|
|
74
|
+
const empty = { table: () => ({ get: async () => null }) } as any as DataLayer;
|
|
75
|
+
assertEquals(await isPrSettled(empty, PR_KEY), false, "an absent PR row is not settled");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
|
|
52
79
|
test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
53
80
|
await withGithubOff(async () => {
|
|
54
81
|
const PR_KEY = "owner/repo#42";
|
package/app/service.ts
CHANGED
|
@@ -482,6 +482,21 @@ export async function worldRestoreSha(data: DataLayer, prKey: string): Promise<s
|
|
|
482
482
|
return lastPushedSha(data, prKey);
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
/** Whether the `pull_requests` row for `prKey` already exists AND is in a TERMINAL state
|
|
486
|
+
* (`converged`/`merged`/`abandoned`). Reads the ADR-0065 derived tracking VIEW's `derived_status`
|
|
487
|
+
* (via `prsTracking`), NOT the base `status`, so an out-of-band-terminated PR — whose base row is
|
|
488
|
+
* still `converging` but whose reconciled edge is `abandoned` — is correctly seen as settled. The
|
|
489
|
+
* delivery-connector's converge-enrollment action guards on this so a crash-window RESUME (a
|
|
490
|
+
* `claimed`-but-not-`delivered` ledger row whose first attempt already enrolled the PR and let it
|
|
491
|
+
* settle) never re-runs `submitPr` against a settled PR — `submitPr` deliberately RE-OPENS a terminal
|
|
492
|
+
* row, so an unconditional re-perform would flip the PR back to `converging`, regressing a settled PR.
|
|
493
|
+
* Reuses the canonical `TERMINAL_STATUSES` so the terminal-safety check can't drift from the one the
|
|
494
|
+
* loop/incident logic uses. */
|
|
495
|
+
export async function isPrSettled(data: DataLayer, prKey: string): Promise<boolean> {
|
|
496
|
+
const existing = await prsTracking(data).get(prKey);
|
|
497
|
+
return !!existing && TERMINAL_STATUSES.includes(existing.derived_status);
|
|
498
|
+
}
|
|
499
|
+
|
|
485
500
|
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
486
501
|
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
487
502
|
* recorded as the PR's merge-stage dependency set. */
|
|
@@ -99,6 +99,24 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
|
|
|
99
99
|
- **`human`** — a scheduled user task + form (§4).
|
|
100
100
|
- **`connector`** — an automated, side-effecting outbound action (the connector I/O surface).
|
|
101
101
|
|
|
102
|
+
> **Amendment (issue #500): the connector's first REAL target landed — `converge` / `converge-merge`.**
|
|
103
|
+
> The connector I/O surface shipped in slice S4 with a deliberately forward-declared STUB action
|
|
104
|
+
> (`performConnectorAction`), the real target dispatch deferred to a later slice. That slice is this:
|
|
105
|
+
> a `connector` node whose `target` is **`converge-merge`** (or **`converge`** for converge-only)
|
|
106
|
+
> enrolls its `payload.pr` into the app's shared convergence (+ merge) loop via `submitPr` — the SAME
|
|
107
|
+
> seam the feature cell reuses (`workers/converge-feature`), no duplicated machinery. Enrollment is
|
|
108
|
+
> defined in the worker (it has `app.data`/`app.engine`) but **injected into `dispatchConnector` as the
|
|
109
|
+
> connector's action**, so the existing at-most-once ledger fence wraps the enrollment itself: it fires
|
|
110
|
+
> only on the claim winner (or a resumed crashed claim), and a `deduped` redelivery — a restart / lost
|
|
111
|
+
> ack / graph resume that lands AFTER the PR settled — never re-runs it. That matters because `submitPr`
|
|
112
|
+
> deliberately RE-OPENS a terminal PR; an unfenced re-call would flip a `merged`/`converged`/`abandoned`
|
|
113
|
+
> PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
|
|
114
|
+
> double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
|
|
115
|
+
> yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
|
|
116
|
+
> wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the MVP
|
|
117
|
+
> sources `pr` as a literal (auto-emitting it from the `agent` node as a typed `pr` fact is a deferred
|
|
118
|
+
> follow-up). Other connector targets remain the forward-declared stub.
|
|
119
|
+
|
|
102
120
|
Crucially, **execution stays engine-native**: each node kind is a real, already-deployed
|
|
103
121
|
sub-process / call activity (`readiness-gate`, a user task, the implementation task, a connector
|
|
104
122
|
invocation). The graph layer owns **scheduling** (which nodes' edges are satisfied → dispatch), not a
|
package/docs/agent-guide.md
CHANGED
|
@@ -435,7 +435,7 @@ layer schedules, it does not re-implement execution):
|
|
|
435
435
|
| `agent` | `agent: { jobType, prompt? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** | yes |
|
|
436
436
|
| `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`. Read-only. | yes (binds observed facts) |
|
|
437
437
|
| `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
|
|
438
|
-
| `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe).
|
|
438
|
+
| `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Two **real targets** ship today — **`converge`** and **`converge-merge`** (§9.4); other targets are a forward-declared stub. | yes |
|
|
439
439
|
|
|
440
440
|
A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
|
|
441
441
|
intake uses): `{ kind, target, onTimeout?, match?, poll? }`. The **`pr` kind** watches an
|
|
@@ -559,3 +559,49 @@ To swap the manual PR-#303 path for a **capability** edge instead of a raw `pr`
|
|
|
559
559
|
the consumer a `wait` node with `kind: "capability"` (resolving *which published
|
|
560
560
|
`pkg@version` first carries the change*) fed by the same `manual-publish.publishedVersion`
|
|
561
561
|
fact — the fact-edge syntax is identical.
|
|
562
|
+
|
|
563
|
+
### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge`)
|
|
564
|
+
|
|
565
|
+
A `connector` node with **`target: "converge-merge"`** (or **`"converge"`**) enrolls an
|
|
566
|
+
agent-opened PR into the app's **shared convergence loop** — the *same* enrollment §1 (a
|
|
567
|
+
standalone submit) and a feature run use (`submitPr`), no duplicated machinery. This replaces
|
|
568
|
+
the old habit of bridging an `agent`-opened PR to review with a **human `land-*` gate** whose
|
|
569
|
+
only job was "go run convergence yourself".
|
|
570
|
+
|
|
571
|
+
- **`converge-merge`** — drive review convergence **and then the merge loop** (the PR merges
|
|
572
|
+
once converged + green). Equivalent to a submit with `convergeOnly: false`.
|
|
573
|
+
- **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
|
|
574
|
+
handing off to the merge loop (equivalent to `convergeOnly: true`).
|
|
575
|
+
|
|
576
|
+
**Payload:** `{ pr: "owner/repo#123", convergeOnly?: boolean, dependsOn?: string[] }`. `pr` is
|
|
577
|
+
required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
|
|
578
|
+
`convergeOnly` defaults from the target and may be overridden per-node; `dependsOn` is unioned
|
|
579
|
+
into the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
|
|
580
|
+
at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
|
|
581
|
+
redelivery never double-enrolls.
|
|
582
|
+
|
|
583
|
+
**Canonical shape** — the agent opens the PR, the connector enrolls it, and a `wait[pr, merged]`
|
|
584
|
+
gate binds `mergedSha` when it lands, with **no human node**:
|
|
585
|
+
|
|
586
|
+
```json
|
|
587
|
+
{
|
|
588
|
+
"name": "open → converge+merge → wait merged",
|
|
589
|
+
"nodes": [
|
|
590
|
+
{ "id": "open", "kind": "agent",
|
|
591
|
+
"agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." } },
|
|
592
|
+
{ "id": "land", "kind": "connector",
|
|
593
|
+
"connector": { "target": "converge-merge", "payload": { "pr": "acme/repo#123" } } },
|
|
594
|
+
{ "id": "merged", "kind": "wait",
|
|
595
|
+
"wait": { "kind": "pr", "target": "acme/repo#123", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
|
|
596
|
+
],
|
|
597
|
+
"edges": [
|
|
598
|
+
{ "from": "open", "to": "land" },
|
|
599
|
+
{ "from": "land", "to": "merged" }
|
|
600
|
+
]
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
> **Follow-up (not shipped):** the MVP sources the connector's `pr` as a **literal**. Auto-emitting
|
|
605
|
+
> the opened PR from the `agent` node as a typed `pr` fact (so the connector/`wait` bind it instead of
|
|
606
|
+
> a literal) is a later slice — not required for the graph above.
|
|
607
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.133.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",
|
|
@@ -4,7 +4,83 @@
|
|
|
4
4
|
// `payload`/`boundFacts` is coerced to null with a surfaced warning rather than passed through.
|
|
5
5
|
import { test } from "node:test";
|
|
6
6
|
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
7
|
-
import {
|
|
7
|
+
import { PROCESS_ID } from "../../app/service.ts";
|
|
8
|
+
import { withTrackingViews } from "../../test/trackingViews.ts";
|
|
9
|
+
import handler, { readConnectorInput, readConvergeInput, safeStringify } from "./worker.ts";
|
|
10
|
+
|
|
11
|
+
function memTable(rows: Record<string, unknown>[], key: string) {
|
|
12
|
+
return {
|
|
13
|
+
get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
14
|
+
all: () => Promise.resolve([...rows]),
|
|
15
|
+
find: (q: Record<string, unknown>) =>
|
|
16
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
17
|
+
findOne: (q: Record<string, unknown>) =>
|
|
18
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
19
|
+
insert: (r: Record<string, unknown>) => {
|
|
20
|
+
rows.push(r);
|
|
21
|
+
return Promise.resolve(r);
|
|
22
|
+
},
|
|
23
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
24
|
+
const r = rows.find((x) => x[key] === k);
|
|
25
|
+
if (r) Object.assign(r, patch);
|
|
26
|
+
return Promise.resolve(r);
|
|
27
|
+
},
|
|
28
|
+
delete: (k: unknown) => {
|
|
29
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
30
|
+
return Promise.resolve();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A hermetic `app` over in-memory tables + a createInstance-capturing engine, with the GitHub
|
|
36
|
+
* transport forced off so `submitPr`'s best-effort meta fetch is skipped. Returns the created
|
|
37
|
+
* convergence-loop instances so a test can assert the exact enrollment `submitPr` performed. */
|
|
38
|
+
function fakeApp() {
|
|
39
|
+
const stores: Record<string, { rows: Record<string, unknown>[]; key: string }> = {
|
|
40
|
+
pull_requests: { rows: [], key: "pr_key" },
|
|
41
|
+
escalations: { rows: [], key: "id" },
|
|
42
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
43
|
+
delivery_connector_dispatches: { rows: [], key: "id" },
|
|
44
|
+
};
|
|
45
|
+
const created: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
|
|
46
|
+
let nextId = 1;
|
|
47
|
+
const data = {
|
|
48
|
+
table: withTrackingViews((name: string, key: string) => {
|
|
49
|
+
const store = stores[name] ?? { rows: [], key };
|
|
50
|
+
stores[name] ??= store;
|
|
51
|
+
// The ledger PK is auto-assigned on insert (mimics the RAD Table<T> autoincrement).
|
|
52
|
+
if (name === "delivery_connector_dispatches") {
|
|
53
|
+
const base = memTable(store.rows, store.key);
|
|
54
|
+
return { ...base, insert: (r: Record<string, unknown>) => {
|
|
55
|
+
const id = nextId++;
|
|
56
|
+
store.rows.push({ ...r, id });
|
|
57
|
+
return Promise.resolve(id);
|
|
58
|
+
} } as ReturnType<typeof memTable>;
|
|
59
|
+
}
|
|
60
|
+
return memTable(store.rows, store.key);
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
const engine = {
|
|
64
|
+
createInstance: (req: { processDefinitionId?: string; variables?: Record<string, unknown> }) => {
|
|
65
|
+
created.push(req);
|
|
66
|
+
return Promise.resolve({ processInstanceKey: `PI-${created.length}` });
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const app = { data, engine, log: { info() {}, warn() {}, error() {} } };
|
|
70
|
+
return { app: app as unknown as Parameters<typeof handler>[1], stores, created };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
74
|
+
const prevMode = process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
75
|
+
const prevTok = process.env.GITHUB_TOKEN;
|
|
76
|
+
process.env.NANO_PR_GITHUB_TRANSPORT = "token"; // no token below -> fetchPrMeta returns null
|
|
77
|
+
delete process.env.GITHUB_TOKEN;
|
|
78
|
+
return run().finally(() => {
|
|
79
|
+
if (prevMode !== undefined) process.env.NANO_PR_GITHUB_TRANSPORT = prevMode;
|
|
80
|
+
else delete process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
81
|
+
if (prevTok !== undefined) process.env.GITHUB_TOKEN = prevTok;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
8
84
|
|
|
9
85
|
test("readConnectorInput: a blank/missing target fails closed (a connector with no destination is meaningless)", () => {
|
|
10
86
|
for (const target of [undefined, "", " "]) {
|
|
@@ -42,3 +118,165 @@ test("readConnectorInput: an array payload is rejected (arrays are not plain obj
|
|
|
42
118
|
assertEquals(r.payload, null);
|
|
43
119
|
assertEquals(r.warnings.length, 1);
|
|
44
120
|
});
|
|
121
|
+
|
|
122
|
+
// --- converge / converge-merge targets: enroll a PR into the shared convergence loop (issue #500) ---
|
|
123
|
+
|
|
124
|
+
test("readConvergeInput: parses pr; convergeOnly defaults from the target; dependsOn is optional", () => {
|
|
125
|
+
// `converge-merge` drives the merge loop → convergeOnly defaults false.
|
|
126
|
+
const merge = readConvergeInput("converge-merge", { pr: "owner/repo#7" });
|
|
127
|
+
assertEquals(merge.parsed.prKey, "owner/repo#7");
|
|
128
|
+
assertEquals(merge.convergeOnly, false);
|
|
129
|
+
assertEquals(merge.dependsOn, []);
|
|
130
|
+
// `converge` is review-only → convergeOnly defaults true.
|
|
131
|
+
const conv = readConvergeInput("converge", { pr: "owner/repo#7" });
|
|
132
|
+
assertEquals(conv.convergeOnly, true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("readConvergeInput: an explicit payload.convergeOnly overrides the target default; dependsOn threads through", () => {
|
|
136
|
+
const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] });
|
|
137
|
+
assertEquals(r.convergeOnly, true, "the explicit boolean wins over the target default");
|
|
138
|
+
assertEquals(r.dependsOn, ["owner/repo#5"], "non-string dependsOn entries are dropped");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("readConvergeInput: a missing / unparseable pr fails CLOSED (a converge connector with no target PR is meaningless)", () => {
|
|
142
|
+
assertThrows(() => readConvergeInput("converge-merge", null), Error, "payload.pr");
|
|
143
|
+
assertThrows(() => readConvergeInput("converge-merge", {}), Error, "payload.pr");
|
|
144
|
+
assertThrows(() => readConvergeInput("converge", { pr: "not-a-pr" }), Error, "payload.pr");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("readConvergeInput: an unparseable pr whose value is not JSON-serializable still fails CLOSED with the intended error (not a serializer TypeError)", () => {
|
|
148
|
+
// `p.pr` is user-controlled payload data; a BigInt (or a circular object) makes JSON.stringify
|
|
149
|
+
// throw, which must NOT mask the intended "requires payload.pr" error.
|
|
150
|
+
assertThrows(() => readConvergeInput("converge", { pr: 10n as unknown as string }), Error, "payload.pr");
|
|
151
|
+
const circular: Record<string, unknown> = {};
|
|
152
|
+
circular.self = circular;
|
|
153
|
+
assertThrows(() => readConvergeInput("converge", { pr: circular as unknown as string }), Error, "payload.pr");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("safeStringify: always returns a string, even for values JSON.stringify serializes to undefined (Symbol/undefined/function)", () => {
|
|
157
|
+
// JSON.stringify returns `undefined` (WITHOUT throwing) for a Symbol, a bare undefined, or a
|
|
158
|
+
// function. safeStringify is typed `: string`, so it must fall back to String(value) rather than
|
|
159
|
+
// leak that `undefined` through and violate its own contract.
|
|
160
|
+
assertEquals(typeof safeStringify(Symbol("x")), "string");
|
|
161
|
+
assertEquals(typeof safeStringify(undefined), "string");
|
|
162
|
+
assertEquals(typeof safeStringify(() => 0), "string");
|
|
163
|
+
// A normal serializable value still round-trips through JSON.stringify.
|
|
164
|
+
assertEquals(safeStringify({ a: 1 }), '{"a":1}');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("handler: a `converge-merge` connector enrolls the PR into the convergence loop via submitPr (row + started convergence-loop instance)", async () => {
|
|
168
|
+
await withGithubOff(async () => {
|
|
169
|
+
const { app, stores, created } = fakeApp();
|
|
170
|
+
await handler(
|
|
171
|
+
{ variables: { target: "converge-merge", payload: { pr: "owner/repo#7" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
|
|
172
|
+
app,
|
|
173
|
+
);
|
|
174
|
+
// The identical row + loop a `converge-feature` enrollment produces.
|
|
175
|
+
assertEquals(stores.pull_requests.rows.length, 1, "exactly one pull_requests row is registered");
|
|
176
|
+
const pr = stores.pull_requests.rows[0];
|
|
177
|
+
assertEquals(pr.pr_key, "owner/repo#7");
|
|
178
|
+
assertEquals(pr.status, "converging");
|
|
179
|
+
assertEquals(created.length, 1, "the convergence-loop instance was started");
|
|
180
|
+
assertEquals(created[0]?.processDefinitionId, PROCESS_ID);
|
|
181
|
+
// converge-merge → not converge-only, so the merge loop is authorised.
|
|
182
|
+
assertEquals(created[0]?.variables?.convergeOnly, false);
|
|
183
|
+
assertEquals(created[0]?.variables?.prKey, "owner/repo#7");
|
|
184
|
+
// Lineage roots on the stable per-node dedupe key (graph-derived here).
|
|
185
|
+
assertEquals(created[0]?.variables?.rootRequestKey, "PI-graph:n2");
|
|
186
|
+
// The connector ledger still recorded the dispatch (the at-most-once fence around the stub).
|
|
187
|
+
assertEquals(stores.delivery_connector_dispatches.rows.length, 1);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("handler: a `converge` connector enrolls converge-ONLY (stops at converged, never hands to the merge loop)", async () => {
|
|
192
|
+
await withGithubOff(async () => {
|
|
193
|
+
const { app, created } = fakeApp();
|
|
194
|
+
await handler(
|
|
195
|
+
{ variables: { target: "converge", payload: { pr: "owner/repo#8" } }, processInstanceKey: "PI-g", elementId: "n1" } as never,
|
|
196
|
+
app,
|
|
197
|
+
);
|
|
198
|
+
assertEquals(created.length, 1);
|
|
199
|
+
assertEquals(created[0]?.variables?.convergeOnly, true);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("handler: re-dispatch (at-least-once redelivery) does NOT double-enroll (ledger fence + submitPr prKey idempotency)", async () => {
|
|
204
|
+
await withGithubOff(async () => {
|
|
205
|
+
const { app, stores, created } = fakeApp();
|
|
206
|
+
const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#9" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
|
|
207
|
+
await handler(job, app);
|
|
208
|
+
await handler(job, app); // the graph resumes / the job is redelivered
|
|
209
|
+
assertEquals(stores.pull_requests.rows.length, 1, "still exactly one PR row");
|
|
210
|
+
assertEquals(created.length, 1, "the convergence-loop is started exactly once (submitPr collapses the repeat)");
|
|
211
|
+
assertEquals(stores.delivery_connector_dispatches.rows.length, 1, "one ledger row — the dispatch fence deduped");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("handler: a redelivery AFTER the PR reached a terminal state does NOT re-enroll (the connector's at-most-once fence, not submitPr's short-circuit)", async () => {
|
|
216
|
+
await withGithubOff(async () => {
|
|
217
|
+
const { app, stores, created } = fakeApp();
|
|
218
|
+
const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#11" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
|
|
219
|
+
await handler(job, app);
|
|
220
|
+
assertEquals(created.length, 1, "the first delivery enrolls the PR");
|
|
221
|
+
// The convergence (+ merge) loop ran to completion; the PR row is now TERMINAL.
|
|
222
|
+
stores.pull_requests.rows[0].status = "merged";
|
|
223
|
+
// An at-least-once redelivery (worker restart / lost ack / graph resume) lands AFTER settlement.
|
|
224
|
+
// `submitPr` deliberately RE-OPENS a terminal row, so the connector must not call it again — the
|
|
225
|
+
// node instance already fired exactly once, and the ledger fence must suppress the redelivery.
|
|
226
|
+
await handler(job, app);
|
|
227
|
+
assertEquals(created.length, 1, "the settled PR is NOT re-enrolled — no second convergence-loop instance");
|
|
228
|
+
assertEquals(stores.pull_requests.rows[0].status, "merged", "the terminal PR is never flipped back to converging");
|
|
229
|
+
assertEquals(stores.delivery_connector_dispatches.rows.length, 1, "still one ledger row — the connector fence deduped the redelivery");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("handler: a converge redelivery whose prior claim CRASHED before recording delivery still enrolls (resume, not lost)", async () => {
|
|
234
|
+
await withGithubOff(async () => {
|
|
235
|
+
const { app, stores, created } = fakeApp();
|
|
236
|
+
const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#12" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
|
|
237
|
+
// Simulate a crash BETWEEN claiming the ledger row and recording delivery: a `claimed` row with no
|
|
238
|
+
// enrollment yet. The redelivery must RESUME (perform the enrollment), never dedupe on the un-acted claim.
|
|
239
|
+
stores.delivery_connector_dispatches.rows.push({ id: 1, dedupe_key: "PI-graph:n2", target: "converge-merge", outcome: "claimed", detail: null, dispatched_at: "t0" });
|
|
240
|
+
await handler(job, app);
|
|
241
|
+
assertEquals(created.length, 1, "the crashed claim is resumed — the enrollment fires exactly once now");
|
|
242
|
+
assertEquals(stores.pull_requests.rows.length, 1, "the PR was enrolled on resume");
|
|
243
|
+
assertEquals(stores.delivery_connector_dispatches.rows[0].outcome, "delivered", "the resumed claim is recorded delivered");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("handler: a crash-window RESUME whose PR already SETTLED to terminal does NOT re-open it (the enrollment action is terminal-safe)", async () => {
|
|
248
|
+
await withGithubOff(async () => {
|
|
249
|
+
const { app, stores, created } = fakeApp();
|
|
250
|
+
const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#13" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
|
|
251
|
+
// The first attempt claimed the ledger AND enrolled the PR, which then ran the convergence (+ merge)
|
|
252
|
+
// loop to completion (`merged`) — but the worker crashed BEFORE recording `delivered`, leaving a
|
|
253
|
+
// still-`claimed` row. A redelivery RESUMES that claim (perform-again, since it never recorded done).
|
|
254
|
+
stores.delivery_connector_dispatches.rows.push({ id: 1, dedupe_key: "PI-graph:n2", target: "converge-merge", outcome: "claimed", detail: null, dispatched_at: "t0" });
|
|
255
|
+
stores.pull_requests.rows.push({ pr_key: "owner/repo#13", status: "merged" });
|
|
256
|
+
// `submitPr` deliberately RE-OPENS a terminal PR (it only short-circuits a NON-terminal row), so a
|
|
257
|
+
// resumed re-perform would flip the settled PR back to `converging`. The enrollment action must be
|
|
258
|
+
// terminal-safe: on resume against an already-settled PR it no-ops (no submitPr, no new instance).
|
|
259
|
+
await handler(job, app);
|
|
260
|
+
assertEquals(created.length, 0, "the settled PR is NOT re-enrolled on resume — no new convergence-loop instance");
|
|
261
|
+
assertEquals(stores.pull_requests.rows[0].status, "merged", "the terminal PR stays terminal (never flipped back to converging)");
|
|
262
|
+
assertEquals(stores.delivery_connector_dispatches.rows[0].outcome, "delivered", "the resumed claim is recorded delivered (the dispatch is done, no side effect was needed)");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("handler: a misconfigured converge connector (no parseable pr) fails CLOSED and writes NO ledger row", async () => {
|
|
267
|
+
await withGithubOff(async () => {
|
|
268
|
+
const { app, stores, created } = fakeApp();
|
|
269
|
+
let threw = false;
|
|
270
|
+
try {
|
|
271
|
+
await handler(
|
|
272
|
+
{ variables: { target: "converge-merge", payload: { pr: "garbage" } }, processInstanceKey: "PI", elementId: "n1" } as never,
|
|
273
|
+
app,
|
|
274
|
+
);
|
|
275
|
+
} catch {
|
|
276
|
+
threw = true;
|
|
277
|
+
}
|
|
278
|
+
assert(threw, "a converge connector with no target PR fails closed");
|
|
279
|
+
assertEquals(created.length, 0, "no convergence-loop instance is started");
|
|
280
|
+
assertEquals(stores.delivery_connector_dispatches.rows.length, 0, "no junk ledger row is claimed");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -12,8 +12,11 @@ import {
|
|
|
12
12
|
type BoundFact,
|
|
13
13
|
type ConnectorDispatchResult,
|
|
14
14
|
connectorDedupeKey,
|
|
15
|
+
convergeOnlyForTarget,
|
|
15
16
|
dispatchConnector,
|
|
17
|
+
isConvergeTarget,
|
|
16
18
|
} from "../../app/deliveryConnector.ts";
|
|
19
|
+
import { isPrSettled, MAX_ROUNDS, type ParsedPr, parsePr, submitPr } from "../../app/service.ts";
|
|
17
20
|
|
|
18
21
|
// Typed off the compiled `connector` subProcess ioMapping (a RUNTIME-generated definition, so there is
|
|
19
22
|
// no static data-envelope to derive from): `target`/`dedupeKey`/`payload` are seeded from the node's
|
|
@@ -58,8 +61,52 @@ export function readConnectorInput(vars: In): {
|
|
|
58
61
|
return { target, payload, boundFacts, warnings };
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/** JSON-stringify a user-controlled value for an error message, falling back to `String(value)` when
|
|
65
|
+
* the value is not JSON-serializable (e.g. a `BigInt` or a circular object throws, or a `Symbol` /
|
|
66
|
+
* `undefined` / function that `JSON.stringify` serializes to `undefined`) so the intended validation
|
|
67
|
+
* error is never masked by a serializer `TypeError` and the function always honours its `string`
|
|
68
|
+
* return type. */
|
|
69
|
+
export function safeStringify(value: unknown): string {
|
|
70
|
+
try {
|
|
71
|
+
const s = JSON.stringify(value);
|
|
72
|
+
return s === undefined ? String(value) : s;
|
|
73
|
+
} catch {
|
|
74
|
+
return String(value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse + validate the converge connector's payload (`{ pr, convergeOnly?, dependsOn? }`) for a
|
|
79
|
+
* `converge` / `converge-merge` target. `pr` is REQUIRED and must parse to a canonical `owner/repo#N`
|
|
80
|
+
* (fail CLOSED — a converge connector with no target PR is meaningless and could never enroll).
|
|
81
|
+
* `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge` → drive
|
|
82
|
+
* the merge loop `false`) and may be overridden per-dispatch by an explicit boolean. `dependsOn` is an
|
|
83
|
+
* optional list of PR refs unioned into the enrolled PR's merge-stage dependency set (only non-string
|
|
84
|
+
* entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage — the
|
|
85
|
+
* MVP sources `pr` as a literal (identical to how the `wait: pr` node targets a known PR), so no new
|
|
86
|
+
* fact plumbing is needed to ship. */
|
|
87
|
+
export function readConvergeInput(
|
|
88
|
+
target: string,
|
|
89
|
+
payload: Record<string, unknown> | null,
|
|
90
|
+
): { parsed: ParsedPr; convergeOnly: boolean; dependsOn: string[] } {
|
|
91
|
+
const p = payload ?? {};
|
|
92
|
+
const parsed = parsePr(p.pr);
|
|
93
|
+
if (!parsed) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`delivery-connector: '${target}' target requires payload.pr as a parseable "owner/repo#N" ` +
|
|
96
|
+
`(got ${safeStringify(p.pr ?? null)})`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const convergeOnly = typeof p.convergeOnly === "boolean" ? p.convergeOnly : convergeOnlyForTarget(target);
|
|
100
|
+
const dependsOn = Array.isArray(p.dependsOn) ? p.dependsOn.filter((d): d is string => typeof d === "string") : [];
|
|
101
|
+
return { parsed, convergeOnly, dependsOn };
|
|
102
|
+
}
|
|
103
|
+
|
|
61
104
|
const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) => {
|
|
62
105
|
const { target, payload, boundFacts, warnings } = readConnectorInput(job.variables);
|
|
106
|
+
// A `converge`/`converge-merge` target enrolls a PR into the shared convergence (+ merge) loop.
|
|
107
|
+
// Parse + validate its payload BEFORE claiming a ledger row, so a misconfigured converge node (no
|
|
108
|
+
// parseable `pr`) fails CLOSED without writing a junk dispatch row it could never act on.
|
|
109
|
+
const converge = isConvergeTarget(target) ? readConvergeInput(target, payload) : null;
|
|
63
110
|
const dedupeKey = connectorDedupeKey({
|
|
64
111
|
dedupeKey: job.variables.dedupeKey ?? null,
|
|
65
112
|
processInstanceKey: job.processInstanceKey ?? null,
|
|
@@ -75,6 +122,46 @@ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) =>
|
|
|
75
122
|
app.data,
|
|
76
123
|
{ dedupeKey, target, payload, boundFacts },
|
|
77
124
|
new Date().toISOString(),
|
|
125
|
+
// For a `converge`/`converge-merge` target, the connector's REAL side effect is enrolling the PR
|
|
126
|
+
// into the shared convergence (+ merge) loop via `submitPr` — the SAME enrollment
|
|
127
|
+
// `workers/converge-feature` uses, no duplicated machinery. It is injected as the dispatch's
|
|
128
|
+
// action so it lives INSIDE the at-most-once + resume envelope (`dispatchConnector`): it fires only
|
|
129
|
+
// on the claim winner (or a resumed crashed claim), and a `deduped` redelivery — a worker restart /
|
|
130
|
+
// lost ack / graph resume that lands AFTER the PR has settled — NEVER re-runs it. This is what
|
|
131
|
+
// preserves the connector's at-most-once semantics against `submitPr`, which deliberately RE-OPENS a
|
|
132
|
+
// terminal PR (it only short-circuits a non-terminal row); an unconditional call outside the fence
|
|
133
|
+
// would flip a `merged`/`converged`/`abandoned` PR back to `converging` on redelivery.
|
|
134
|
+
//
|
|
135
|
+
// The action is ALSO terminal-safe (idempotent on RESUME). A still-`claimed` crashed claim is
|
|
136
|
+
// re-performed by `dispatchConnector` (it never recorded delivery), and its first attempt may have
|
|
137
|
+
// already enrolled the PR AND let the convergence/merge loop settle it. Because `submitPr` re-opens a
|
|
138
|
+
// terminal row, re-performing blindly would regress that settled PR — so the action first checks
|
|
139
|
+
// `isPrSettled` and NO-OPS when the PR row is already terminal. On a LIVE (non-terminal) row
|
|
140
|
+
// `submitPr`'s own `prKey` short-circuit (`alreadyRunning`) already makes the resume double-safe.
|
|
141
|
+
// `rootRequestKey` is the stable per-node `dedupeKey` (authored, else `<processInstanceKey>:<elementId>`),
|
|
142
|
+
// so the enrolled PR's lineage is deterministic across redeliveries.
|
|
143
|
+
converge
|
|
144
|
+
? async () => {
|
|
145
|
+
if (await isPrSettled(app.data, converge.parsed.prKey)) {
|
|
146
|
+
// Resume against an already-settled PR: the enrollment already ran its course. Record the
|
|
147
|
+
// dispatch delivered WITHOUT re-opening the terminal PR (never regress a settled PR).
|
|
148
|
+
app.log.info("delivery-connector: enrollment skipped — PR already terminal (resume-safe)", {
|
|
149
|
+
target,
|
|
150
|
+
dedupeKey,
|
|
151
|
+
prKey: converge.parsed.prKey,
|
|
152
|
+
});
|
|
153
|
+
return { detail: `${converge.parsed.prKey} already terminal — enrollment skipped (at-most-once resume-safe)` };
|
|
154
|
+
}
|
|
155
|
+
await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey);
|
|
156
|
+
app.log.info("delivery-connector: enrolled PR into convergence loop", {
|
|
157
|
+
target,
|
|
158
|
+
dedupeKey,
|
|
159
|
+
prKey: converge.parsed.prKey,
|
|
160
|
+
convergeOnly: converge.convergeOnly,
|
|
161
|
+
});
|
|
162
|
+
return { detail: `enrolled ${converge.parsed.prKey} into convergence loop (convergeOnly=${converge.convergeOnly})` };
|
|
163
|
+
}
|
|
164
|
+
: undefined,
|
|
78
165
|
);
|
|
79
166
|
app.log.info("delivery-connector", { target, dedupeKey, outcome: result.connectorOutcome });
|
|
80
167
|
return result;
|