@nanobpm/nano-workforce 0.189.3 → 0.191.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 +12 -0
- package/README.md +5 -4
- package/SPEC.md +5 -5
- package/app/agentGuide.ts +1 -1
- package/app/deliveryGraph.test.ts +447 -0
- package/app/deliveryGraph.ts +355 -0
- package/app/deliveryGraphCompiler.test.ts +1465 -3
- package/app/deliveryGraphCompiler.ts +833 -36
- package/app/deliveryGraphDispatch.test.ts +118 -2
- package/app/deliveryGraphDispatch.ts +138 -10
- package/app/deliveryGraphProposals.test.ts +243 -0
- package/app/deliveryGraphProposals.ts +130 -56
- package/app/deliveryGraphRun.test.ts +42 -2
- package/app/deliveryGraphRun.ts +46 -6
- package/app/deliveryGraphVocabulary.ts +6 -6
- package/app/deliveryRunner.test.ts +21 -0
- package/app/deliveryRunner.ts +9 -2
- package/app/readiness.test.ts +72 -0
- package/app/readiness.ts +234 -8
- package/db/migrations/113_delivery_graph_run_identity.sql +37 -0
- package/db/migrations/114_delivery_graph_proposals_stage_seq.sql +21 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
- package/docs/agent-guide.md +9 -9
- package/openapi.yaml +29 -17
- package/operations/dispatchDeliveryGraph.test.ts +255 -0
- package/operations/dispatchDeliveryGraph.ts +156 -5
- package/operations/getAgentInstructions.test.ts +2 -1
- package/operations/startAndMessage.test.ts +44 -2
- package/operations/startConvergenceLoop.ts +13 -10
- package/package.json +1 -1
- package/pages/home.page.json +2 -2
- package/workers/delivery-connector/worker.test.ts +8 -0
- package/workers/delivery-connector/worker.ts +13 -6
|
@@ -13,8 +13,10 @@ import { join, resolve } from "node:path";
|
|
|
13
13
|
import { after, describe, test } from "node:test";
|
|
14
14
|
import assert from "node:assert/strict";
|
|
15
15
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
16
|
+
import { redactFreeText } from "../app/deliveryGraphCompiler.ts";
|
|
16
17
|
import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
|
|
17
18
|
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
19
|
+
import { isEquivalentReStage, stableProposalRunKey } from "./dispatchDeliveryGraph.ts";
|
|
18
20
|
|
|
19
21
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
20
22
|
const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
|
|
@@ -32,6 +34,74 @@ const SIDE_EFFECTING = {
|
|
|
32
34
|
edges: [{ from: "open-b", to: "publish" }],
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
test("stableProposalRunKey: digest identity + secret disambiguation (issue #778 review)", () => {
|
|
38
|
+
// The run key is derived from the proposal's semantic `digest` (which the compiler already normalises
|
|
39
|
+
// — collapsing omitted-vs-default fields and top-level node/edge REORDER) PLUS a fingerprint of the
|
|
40
|
+
// digest-invisible raw values. So a re-stage of the SAME logical graph in ANY encoding sharing the
|
|
41
|
+
// digest short-circuits, while a credential-differing graph (same digest, different secret) diverges.
|
|
42
|
+
const D1 = "sha-aaa";
|
|
43
|
+
const D2 = "sha-bbb";
|
|
44
|
+
const credA = {
|
|
45
|
+
name: "g",
|
|
46
|
+
nodes: [{ id: "n", kind: "connector", connector: { target: "//user:pass@host" } }],
|
|
47
|
+
edges: [],
|
|
48
|
+
};
|
|
49
|
+
// Same digest + identical graph → same key (a double-click / re-dispatch short-circuits).
|
|
50
|
+
assert.equal(stableProposalRunKey(D1, credA), stableProposalRunKey(D1, credA));
|
|
51
|
+
// Same digest but a DIFFERENT redacted-away credential — both redact to the same `//***@host` display
|
|
52
|
+
// so they SHARE the digest — must yield a DIFFERENT key, else the second dispatch collapses onto the
|
|
53
|
+
// first's still-running instance.
|
|
54
|
+
const credB = { name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//other:secret@host" } }], edges: [] };
|
|
55
|
+
assert.notEqual(stableProposalRunKey(D1, credA), stableProposalRunKey(D1, credB));
|
|
56
|
+
// A genuinely different semantic graph carries a DIFFERENT digest → a different key even with no secrets.
|
|
57
|
+
const plain = { name: "g", nodes: [{ id: "n", kind: "human", human: { prompt: "x" } }], edges: [] };
|
|
58
|
+
assert.notEqual(stableProposalRunKey(D1, plain), stableProposalRunKey(D2, plain));
|
|
59
|
+
// A graph with NO digest-invisible content has an EMPTY fingerprint, so its key is a pure function of
|
|
60
|
+
// the digest: a re-stage in a DIFFERENT object-key encoding (same digest) short-circuits.
|
|
61
|
+
const plainReordered = { edges: [], name: "g", nodes: [{ human: { prompt: "x" }, kind: "human", id: "n" }] };
|
|
62
|
+
assert.equal(stableProposalRunKey(D1, plain), stableProposalRunKey(D1, plainReordered));
|
|
63
|
+
assert.ok(stableProposalRunKey(D1, credA).startsWith("staged-"), "the key is self-describing");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("stableProposalRunKey: node-ORDER of digest-invisible content does not fork the key (issue #778 review)", () => {
|
|
67
|
+
// The compiler SORTS nodes before emitting `semanticBpmn`, so two graphs differing ONLY in top-level
|
|
68
|
+
// node order share one digest. Their digest-invisible raw values (`digestInvisibleRawValues`) must
|
|
69
|
+
// therefore fingerprint identically too — else a re-stage of the same logical graph in a different
|
|
70
|
+
// node encoding forks a DISTINCT run-key and double-launches instead of short-circuiting. The
|
|
71
|
+
// fingerprint is code-unit sorted at its source, making it reorder-invariant like the digest.
|
|
72
|
+
const D = "sha-order";
|
|
73
|
+
const ab = {
|
|
74
|
+
name: "g",
|
|
75
|
+
nodes: [
|
|
76
|
+
{ id: "a", kind: "connector", connector: { target: "//user:pass@host" } },
|
|
77
|
+
{ id: "b", kind: "connector", connector: { target: "//other:secret@host" } },
|
|
78
|
+
],
|
|
79
|
+
edges: [],
|
|
80
|
+
};
|
|
81
|
+
const ba = {
|
|
82
|
+
name: "g",
|
|
83
|
+
nodes: [
|
|
84
|
+
{ id: "b", kind: "connector", connector: { target: "//other:secret@host" } },
|
|
85
|
+
{ id: "a", kind: "connector", connector: { target: "//user:pass@host" } },
|
|
86
|
+
],
|
|
87
|
+
edges: [],
|
|
88
|
+
};
|
|
89
|
+
assert.equal(stableProposalRunKey(D, ab), stableProposalRunKey(D, ba), "a top-level node reorder must not fork the run-key");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("isEquivalentReStage: only a live staged row whose RAW graph JSON is byte-identical to the launched graph is an equivalent re-stage — a credential-different re-stage (same digest) is NOT, and a retired/consumed row (null) is NOT (issue #778 review — thread dispatchDeliveryGraph.ts:320)", () => {
|
|
93
|
+
const launched = JSON.stringify({ name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//user:pass@host" } }], edges: [] });
|
|
94
|
+
// A byte-identical re-stage IS equivalent — the door consumes the duplicate at its bumped revision.
|
|
95
|
+
assert.equal(isEquivalentReStage({ graph: launched }, launched), true);
|
|
96
|
+
// A credential-different re-stage shares the redacted digest but its RAW graph differs, so it is a
|
|
97
|
+
// newer, never-launched revision the door must leave `staged`.
|
|
98
|
+
const credDifferent = JSON.stringify({ name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//other:secret@host" } }], edges: [] });
|
|
99
|
+
assert.equal(isEquivalentReStage({ graph: credDifferent }, launched), false);
|
|
100
|
+
// No live staged row (consumed/dismissed/superseded/expired) → not equivalent, nothing to consume.
|
|
101
|
+
assert.equal(isEquivalentReStage(null, launched), false);
|
|
102
|
+
assert.equal(isEquivalentReStage(undefined, launched), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
35
105
|
describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest", () => {
|
|
36
106
|
const dirs: string[] = [];
|
|
37
107
|
const apps: TestApp[] = [];
|
|
@@ -165,6 +235,138 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
|
|
|
165
235
|
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
166
236
|
});
|
|
167
237
|
|
|
238
|
+
test("a SAME-payload retry of a secret-bearing graph under the SAME idempotencyKey (a lost response / double-click) → 202 short-circuit, the proposal IS consumed and NO second run launches — the persisted identity fingerprint PROVES the running run is this exact graph, so the idempotency contract holds instead of a spurious 409 (issue #778 review — thread dispatchDeliveryGraph.ts:332)", async () => {
|
|
239
|
+
const app = await boot();
|
|
240
|
+
assert.ok(app.api);
|
|
241
|
+
const api = app.api;
|
|
242
|
+
let agentFired = 0;
|
|
243
|
+
await app.engine.registerWorker("senior:demo", async () => {
|
|
244
|
+
agentFired++;
|
|
245
|
+
return {};
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Stage + dispatch a SECRET-BEARING graph (SIDE_EFFECTING carries redacted-away secret material —
|
|
249
|
+
// the same graph the cross-graph 409 case above refuses) under an explicit idempotencyKey. It
|
|
250
|
+
// launches and parks on its human node, so its run stays `running`.
|
|
251
|
+
const first = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
|
|
252
|
+
const dispatched = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
253
|
+
body: { digest: first.body.digest, idempotencyKey: "retry-key", repoless: true },
|
|
254
|
+
});
|
|
255
|
+
assert.equal(dispatched.status, 202);
|
|
256
|
+
await app.settle();
|
|
257
|
+
assert.equal(agentFired, 1, "the side effect fired exactly once on the first dispatch");
|
|
258
|
+
assert.equal((await deliveryGraphProposals(app.db).get(first.body.digest))?.status, "dispatched");
|
|
259
|
+
|
|
260
|
+
// Re-stage the IDENTICAL graph (byte-for-byte the same → same digest) and re-dispatch under the
|
|
261
|
+
// SAME key — the caller retrying a lost/duplicated request. The running run's persisted fingerprint
|
|
262
|
+
// matches this graph, so the door short-circuits onto it (202, alreadyRunning) and CONSUMES the
|
|
263
|
+
// re-staged proposal — it does NOT 409 as a cross-graph collision would.
|
|
264
|
+
const restaged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
|
|
265
|
+
assert.equal(restaged.body.digest, first.body.digest);
|
|
266
|
+
assert.equal((await deliveryGraphProposals(app.db).get(restaged.body.digest))?.status, "staged");
|
|
267
|
+
const retry = await api.call<{ ok: boolean; status: string; alreadyRunning?: boolean }>("dispatchDeliveryGraph", {
|
|
268
|
+
body: { digest: restaged.body.digest, idempotencyKey: "retry-key", repoless: true },
|
|
269
|
+
});
|
|
270
|
+
assert.equal(retry.status, 202);
|
|
271
|
+
assert.equal(retry.body.ok, true);
|
|
272
|
+
assert.equal(retry.body.alreadyRunning, true);
|
|
273
|
+
await app.settle();
|
|
274
|
+
// The retry short-circuited onto the existing run — the side effect did NOT fire a second time and
|
|
275
|
+
// no second run launched.
|
|
276
|
+
assert.equal(agentFired, 1, "a same-payload retry does not re-fire the side effect");
|
|
277
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
278
|
+
// The re-staged proposal was consumed (marked dispatched), not left lingering.
|
|
279
|
+
assert.equal((await deliveryGraphProposals(app.db).get(restaged.body.digest))?.status, "dispatched");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("an explicit idempotencyKey short-circuit onto a running SECRET-BEARING run, re-dispatched from a graph authored AS the redacted form (graphCarriesRedactedSecrets === false) that SHARES the digest → 409; the proposal is NOT consumed (the refusal must not be gated on the INCOMING graph's redaction side — identity is unprovable whenever !identityConfirmed) (thread dispatchDeliveryGraph.ts:320)", async () => {
|
|
283
|
+
const app = await boot();
|
|
284
|
+
assert.ok(app.api);
|
|
285
|
+
const api = app.api;
|
|
286
|
+
|
|
287
|
+
// Graph 1 CARRIES a redacted-away credential in its human prompt — its content-addressed digest is
|
|
288
|
+
// over the REDACTED display, so it is NOT a faithful identity. It parks on its human node and stays
|
|
289
|
+
// running under an explicit idempotencyKey.
|
|
290
|
+
const secretPrompt = "click done: https://user:pass@host";
|
|
291
|
+
const SECRET = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: secretPrompt } }] };
|
|
292
|
+
// Graph 2 is authored LITERALLY as the redacted form of graph 1's prompt. Redaction is a no-op on it,
|
|
293
|
+
// so `graphCarriesRedactedSecrets` is FALSE, yet it SHARES graph 1's digest (both digest over the same
|
|
294
|
+
// redacted display). It is a DIFFERENT graph from the running secret-bearing run.
|
|
295
|
+
const redactedTwin = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: redactFreeText(secretPrompt) } }] };
|
|
296
|
+
|
|
297
|
+
// Stage + dispatch the SECRET-bearing graph FIRST (before the twin re-stages over its same-digest
|
|
298
|
+
// row) under an explicit key — it launches and parks (running), stamping its LOSSLESS fingerprint.
|
|
299
|
+
const g1 = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
|
|
300
|
+
const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
301
|
+
body: { digest: g1.body.digest, idempotencyKey: "shared-key", repoless: true },
|
|
302
|
+
});
|
|
303
|
+
assert.equal(first.status, 202);
|
|
304
|
+
await app.settle();
|
|
305
|
+
assert.equal((await deliveryGraphProposals(app.db).get(g1.body.digest))?.status, "dispatched");
|
|
306
|
+
|
|
307
|
+
// Now re-stage the redacted-twin at the SAME digest (a DIFFERENT graph re-staged over the consumed
|
|
308
|
+
// row) and dispatch it under the SAME key. It short-circuits onto the still-running SECRET run — but
|
|
309
|
+
// that run is a different graph, so its persisted fingerprint does NOT match (identityConfirmed ===
|
|
310
|
+
// false). The digest COLLISION is the crux: the twin shares graph 1's digest yet carries no redacted
|
|
311
|
+
// secret. The refusal must fire even though the INCOMING graph carries no redacted secret; otherwise
|
|
312
|
+
// the twin's proposal is marked `dispatched` while it never launched.
|
|
313
|
+
const twin = await api.call<{ digest: string }>("compileDeliveryGraph", { body: redactedTwin });
|
|
314
|
+
assert.equal(twin.body.digest, g1.body.digest);
|
|
315
|
+
assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
|
|
316
|
+
const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
317
|
+
body: { digest: twin.body.digest, idempotencyKey: "shared-key", repoless: true },
|
|
318
|
+
});
|
|
319
|
+
assert.equal(second.status, 409);
|
|
320
|
+
assert.equal(second.body.ok, false);
|
|
321
|
+
await app.settle();
|
|
322
|
+
// The twin proposal is still staged — it was never launched (the running run is the secret graph).
|
|
323
|
+
assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
|
|
324
|
+
// Only the single secret run exists — the twin dispatch launched nothing.
|
|
325
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("a KEYLESS short-circuit onto a running graph whose run key equals the shared digest (an earlier explicit `idempotencyKey === digest`), re-dispatched from a redacted-twin that SHARES the digest → 409; the proposal is NOT consumed. The ambiguous-identity refusal must gate EVERY `alreadyRunning && !identityConfirmed`, not only explicit-key requests — a keyless faithful-digest dispatch collides on the digest run key just the same (thread dispatchDeliveryGraph.ts:325)", async () => {
|
|
329
|
+
const app = await boot();
|
|
330
|
+
assert.ok(app.api);
|
|
331
|
+
const api = app.api;
|
|
332
|
+
|
|
333
|
+
// Graph 1 CARRIES a redacted-away credential — its digest is over the REDACTED display, NOT a faithful
|
|
334
|
+
// identity. Dispatch it under an explicit `idempotencyKey` set to its OWN digest, so the run is keyed
|
|
335
|
+
// by the digest itself and parks running, stamping graph 1's LOSSLESS fingerprint.
|
|
336
|
+
const secretPrompt = "click done: https://user:pass@host";
|
|
337
|
+
const SECRET = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: secretPrompt } }] };
|
|
338
|
+
// Graph 2 is authored LITERALLY as the redacted form — redaction is a no-op on it, so
|
|
339
|
+
// `graphCarriesRedactedSecrets` is FALSE. Because it is faithful, a KEYLESS dispatch leaves the run key
|
|
340
|
+
// undefined and the core falls back to the digest — which it SHARES with graph 1, colliding onto that
|
|
341
|
+
// still-running secret run even though NO explicit key was supplied.
|
|
342
|
+
const redactedTwin = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: redactFreeText(secretPrompt) } }] };
|
|
343
|
+
|
|
344
|
+
const g1 = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
|
|
345
|
+
const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
346
|
+
body: { digest: g1.body.digest, idempotencyKey: g1.body.digest, repoless: true },
|
|
347
|
+
});
|
|
348
|
+
assert.equal(first.status, 202);
|
|
349
|
+
await app.settle();
|
|
350
|
+
assert.equal((await deliveryGraphProposals(app.db).get(g1.body.digest))?.status, "dispatched");
|
|
351
|
+
|
|
352
|
+
// Re-stage the twin at the SAME digest and dispatch it WITHOUT a key. It short-circuits onto the
|
|
353
|
+
// running secret run (same digest run key), but that run is a different graph → identityConfirmed is
|
|
354
|
+
// false. The refusal must fire despite the request being keyless; otherwise the twin is marked
|
|
355
|
+
// `dispatched` while it never launched.
|
|
356
|
+
const twin = await api.call<{ digest: string }>("compileDeliveryGraph", { body: redactedTwin });
|
|
357
|
+
assert.equal(twin.body.digest, g1.body.digest);
|
|
358
|
+
assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
|
|
359
|
+
const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
360
|
+
body: { digest: twin.body.digest, repoless: true },
|
|
361
|
+
});
|
|
362
|
+
assert.equal(second.status, 409);
|
|
363
|
+
assert.equal(second.body.ok, false);
|
|
364
|
+
await app.settle();
|
|
365
|
+
// The twin proposal is still staged — nothing new launched.
|
|
366
|
+
assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
|
|
367
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
368
|
+
});
|
|
369
|
+
|
|
168
370
|
test("a proposal whose stored graph is corrupt JSON → 400 AND the proposal is retired (expired), never lingering staged", async () => {
|
|
169
371
|
const app = await boot();
|
|
170
372
|
assert.ok(app.api);
|
|
@@ -251,6 +453,59 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
|
|
|
251
453
|
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
252
454
|
});
|
|
253
455
|
|
|
456
|
+
test("a SECRET-BEARING staged graph dispatches through the cockpit UI WITHOUT an idempotencyKey — the door supplies a stable server-side key (#778)", async () => {
|
|
457
|
+
// Regression: Option C's dispatch gate refuses a keyless dispatch of a graph whose digest is lossy,
|
|
458
|
+
// but the cockpit staged-proposals UI posts no idempotency-key field. The door must therefore derive
|
|
459
|
+
// a stable server-side key from the stored proposal so these proposals remain launchable.
|
|
460
|
+
const app = await boot();
|
|
461
|
+
assert.ok(app.api);
|
|
462
|
+
const api = app.api;
|
|
463
|
+
const SECRET = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:pass@host.example/repo" } }] };
|
|
464
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
|
|
465
|
+
// No idempotencyKey in the body — exactly what the staged UI posts.
|
|
466
|
+
const res = await api.call<{ ok: boolean; status: string; runKey: string }>("dispatchDeliveryGraph", {
|
|
467
|
+
body: { digest: staged.body.digest, repoless: true },
|
|
468
|
+
});
|
|
469
|
+
assert.equal(res.status, 202);
|
|
470
|
+
assert.equal(res.body.ok, true);
|
|
471
|
+
assert.equal(res.body.status, "running");
|
|
472
|
+
assert.ok(res.body.runKey.startsWith("staged-"), `expected a server-side staged- key, got ${res.body.runKey}`);
|
|
473
|
+
await app.settle();
|
|
474
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
475
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("an explicit idempotencyKey re-used across two SECRET-differing graphs that SHARE a digest is refused at short-circuit → 409; the re-staged proposal is NOT consumed and nothing new launches (#778 review — thread dispatchDeliveryGraph.ts:282)", async () => {
|
|
479
|
+
const app = await boot();
|
|
480
|
+
assert.ok(app.api);
|
|
481
|
+
const api = app.api;
|
|
482
|
+
// Two graphs differing ONLY in a redacted-away credential redact to the SAME `semanticBpmn`, so they
|
|
483
|
+
// share ONE content digest and therefore ONE digest-keyed proposal row.
|
|
484
|
+
const SECRET_A = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:AAAsecret@host.example/repo" } }] };
|
|
485
|
+
const SECRET_B = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:BBBsecret@host.example/repo" } }] };
|
|
486
|
+
const a = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET_A });
|
|
487
|
+
// Dispatch A under an explicit shared key — it launches and parks (running).
|
|
488
|
+
const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
489
|
+
body: { digest: a.body.digest, idempotencyKey: "shared-key", repoless: true },
|
|
490
|
+
});
|
|
491
|
+
assert.equal(first.status, 202);
|
|
492
|
+
await app.settle();
|
|
493
|
+
// B re-stages the SAME digest-keyed proposal row (its credential differs, but the digest is identical).
|
|
494
|
+
const b = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET_B });
|
|
495
|
+
assert.equal(b.body.digest, a.body.digest, "the two secret-differing graphs must share one redacted digest");
|
|
496
|
+
// Dispatch B under the SAME shared key — it short-circuits onto A's still-running run. Because the
|
|
497
|
+
// digest cannot prove the running run is THIS (secret-bearing) graph, B must NOT be consumed: 409.
|
|
498
|
+
const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
499
|
+
body: { digest: b.body.digest, idempotencyKey: "shared-key", repoless: true },
|
|
500
|
+
});
|
|
501
|
+
assert.equal(second.status, 409);
|
|
502
|
+
assert.equal(second.body.ok, false);
|
|
503
|
+
await app.settle();
|
|
504
|
+
// The proposal is still staged (never falsely consumed) and only A's single run exists.
|
|
505
|
+
assert.equal((await deliveryGraphProposals(app.db).get(b.body.digest))?.status, "staged");
|
|
506
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
507
|
+
});
|
|
508
|
+
|
|
254
509
|
test("a malformed `repository` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
|
|
255
510
|
const app = await boot();
|
|
256
511
|
assert.ok(app.api);
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
// re-dispatch of an already-running run short-circuits with `alreadyRunning`. An unknown / expired /
|
|
11
11
|
// superseded / already-dispatched digest is a clean 400.
|
|
12
12
|
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
13
14
|
import { isPlausibleBranchName } from "../app/baseBranch.ts";
|
|
14
|
-
import { validateDeliveryGraph } from "../app/deliveryGraph.ts";
|
|
15
|
+
import { canonicalJson, validateDeliveryGraph } from "../app/deliveryGraph.ts";
|
|
16
|
+
import { digestInvisibleRawValues, graphCarriesRedactedSecrets } from "../app/deliveryGraphCompiler.ts";
|
|
15
17
|
import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
|
|
16
18
|
import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
|
|
17
19
|
import { unresolvedAgentRepoNodes } from "../app/deliveryRunner.ts";
|
|
@@ -28,6 +30,46 @@ function truncateForEcho(value: string): string {
|
|
|
28
30
|
return value.length > MAX_ECHO_LEN ? `${value.slice(0, MAX_ECHO_LEN)}… (${value.length} chars)` : value;
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/** A STABLE, server-side dispatch run key for a secret-bearing staged proposal (issue #778, Option C).
|
|
34
|
+
* The graph's identity is content-addressed over the REDACTED `semanticBpmn` digest, so two graphs
|
|
35
|
+
* differing ONLY in a redacted-away credential (or other {@link digestInvisibleRawValues} content) share
|
|
36
|
+
* one digest — a keyless dispatch would collapse the second onto the first's still-running instance. The
|
|
37
|
+
* dispatch core therefore REQUIRES an explicit key for such a graph, but the cockpit's staged-proposals
|
|
38
|
+
* UI posts no idempotency-key field. The operator clicking Dispatch on THIS specific stored proposal is
|
|
39
|
+
* unambiguous, so we derive a key from TWO parts:
|
|
40
|
+
* • the proposal's semantic `digest` — which the compiler already normalises: it collapses every
|
|
41
|
+
* omitted-vs-default field (`edges`/`emits` → `[]`, a `false` boolean vs an absent one, …) and any
|
|
42
|
+
* top-level node/edge REORDER, so a re-stage of the same logical graph in a different encoding — which
|
|
43
|
+
* shares the digest and OVERWRITES `proposal.graph` — yields the SAME key and short-circuits as
|
|
44
|
+
* `alreadyRunning` instead of double-launching (issue #778 review, thread on omitted-vs-empty), and
|
|
45
|
+
* • a fingerprint of {@link digestInvisibleRawValues} — the exact raw content the digest CANNOT see —
|
|
46
|
+
* so a credential-differing proposal (identical digest) gets a DISTINCT key. That traversal now emits
|
|
47
|
+
* its entries in a canonical (code-unit sorted) order, so it too collapses a top-level node reorder:
|
|
48
|
+
* the fingerprint is reorder-invariant, matching the digest, so the whole key short-circuits a
|
|
49
|
+
* re-stage of the same logical graph regardless of node encoding order (issue #778 review).
|
|
50
|
+
* Deriving both parts from the digest + the shared invisible-values traversal means the key needs NO
|
|
51
|
+
* per-field enumeration of the compiler's defaults (which would drift), while still disambiguating on
|
|
52
|
+
* every secret the digest drops. Prefixed so it is self-describing in run listings. */
|
|
53
|
+
export function stableProposalRunKey(digest: string, graph: DeliveryGraph): string {
|
|
54
|
+
const fingerprint = canonicalJson(digestInvisibleRawValues(graph));
|
|
55
|
+
return `staged-${createHash("sha256").update(`${digest}\u0000${fingerprint}`).digest("hex").slice(0, 16)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** After the terminal `markProposalDispatched` no-oped — the digest row left the `stage_seq` this dispatch
|
|
59
|
+
* snapshotted between its `getStagedProposal` load and its mark — decide whether the still-`staged` row is
|
|
60
|
+
* an EQUIVALENT re-stage of the graph we just launched: a BYTE-IDENTICAL graph re-staged into the same
|
|
61
|
+
* digest row in that window (which bumps `stage_seq`). It is equivalent iff a live staged row is still
|
|
62
|
+
* present AND its raw graph JSON is byte-identical to the launched one. We compare the LOSSLESS raw
|
|
63
|
+
* `proposal.graph` (not the lossy semantic `digest`, which collapses credential-different graphs onto one
|
|
64
|
+
* key), so a genuinely different (e.g. credential-different) re-stage is NOT mistaken for equivalent. When
|
|
65
|
+
* true the door consumes the duplicate at its current revision — otherwise it would linger as an
|
|
66
|
+
* undismissable `staged` row every future dispatch short-circuits onto the already-running run without
|
|
67
|
+
* consuming; when false the row is a newer, never-launched revision the door must leave `staged` (issue
|
|
68
|
+
* #778 review — thread dispatchDeliveryGraph.ts:320). */
|
|
69
|
+
export function isEquivalentReStage(reloaded: { graph: string } | null | undefined, launchedGraphJson: string): boolean {
|
|
70
|
+
return !!reloaded && reloaded.graph === launchedGraphJson;
|
|
71
|
+
}
|
|
72
|
+
|
|
31
73
|
/** Door-level cap on a duration override, mirroring the `maxLength: 64` on these fields in `openapi.yaml`.
|
|
32
74
|
* Re-enforced here so a syntactically-valid-but-oversized duration is still rejected when the edge
|
|
33
75
|
* validator is bypassed (internal calls/tests), keeping seeded process variables and error/log output bounded. */
|
|
@@ -155,14 +197,23 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
155
197
|
}
|
|
156
198
|
|
|
157
199
|
// The stored graph was validated at stage time; dispatch re-compiles it to derive the run-row shape.
|
|
200
|
+
// Snapshot the proposal's stage revision NOW so the terminal `markProposalDispatched` can guard its
|
|
201
|
+
// flip against a concurrent re-stage that overwrites this same-digest row with a newer graph after we
|
|
202
|
+
// read it (issue #778 review — thread dispatchDeliveryGraph.ts:243).
|
|
203
|
+
const stageSeq = proposal.stage_seq;
|
|
158
204
|
let graph: unknown;
|
|
159
205
|
try {
|
|
160
206
|
graph = JSON.parse(proposal.graph);
|
|
161
207
|
} catch (err) {
|
|
162
208
|
app.log.error("dispatch-delivery-graph: stored graph is corrupt", { digest });
|
|
163
209
|
// Fail closed: a corrupt graph can never launch, so retire the proposal (→ `expired`) instead of
|
|
164
|
-
// leaving an undismissable `staged` row that fails every dispatch attempt the same way.
|
|
165
|
-
|
|
210
|
+
// leaving an undismissable `staged` row that fails every dispatch attempt the same way. Guard the
|
|
211
|
+
// expiry on the SAME `stage_seq` we read the corrupt graph at — a concurrent re-stage that overwrites
|
|
212
|
+
// this same-digest row with a fresh (valid) graph in the window between `getStagedProposal` and this
|
|
213
|
+
// update bumps `stage_seq`, so the guard makes the expiry a no-op rather than retiring a revision we
|
|
214
|
+
// never inspected (issue #778 review — thread dispatchDeliveryGraph.ts:188, same revision guard as the
|
|
215
|
+
// terminal `markProposalDispatched` below).
|
|
216
|
+
await markProposalExpired(app.data, digest, stageSeq);
|
|
166
217
|
return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
|
|
167
218
|
}
|
|
168
219
|
|
|
@@ -196,7 +247,27 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
196
247
|
}
|
|
197
248
|
}
|
|
198
249
|
|
|
199
|
-
|
|
250
|
+
// Derive the dispatch run key (issue #778, Option C). Honour an explicit operator-supplied
|
|
251
|
+
// `idempotencyKey` verbatim — a BLANK/whitespace-only key is already normalised to `undefined` at the
|
|
252
|
+
// request edge (`idemRaw`/`idempotencyKey` above), so `=== undefined` here covers both "omitted" and
|
|
253
|
+
// "blank" (issue #778 review — thread dispatchDeliveryGraph.ts:243, already handled upstream). When the
|
|
254
|
+
// staged graph carries credential-bearing values that redaction strips from the content-addressed
|
|
255
|
+
// digest, that digest is NOT a faithful identity — two credential-differing graphs share it — so a
|
|
256
|
+
// keyless dispatch is refused by the dispatch core. The cockpit UI posts no idempotency-key field, so
|
|
257
|
+
// supply a STABLE server-side key derived from THIS proposal's parsed graph in canonical form
|
|
258
|
+
// (`stableProposalRunKey`); a faithful-digest graph keeps its digest identity (key left undefined). A
|
|
259
|
+
// malformed graph falls through to the core's own validation.
|
|
260
|
+
let dispatchRunKey: string | undefined = idempotencyKey;
|
|
261
|
+
if (dispatchRunKey === undefined) {
|
|
262
|
+
const graphErrors = validateDeliveryGraph(graph);
|
|
263
|
+
if (graphErrors.length === 0) {
|
|
264
|
+
// biome-ignore lint/plugin: validated staged graph narrowed to its contract after validateDeliveryGraph
|
|
265
|
+
const typedGraph = graph as DeliveryGraph;
|
|
266
|
+
if (graphCarriesRedactedSecrets(typedGraph)) dispatchRunKey = stableProposalRunKey(digest, typedGraph);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: dispatchRunKey, title: proposal.title, repository, baseBranch, repoless, expectedDigest: digest, ...timeouts });
|
|
200
271
|
if (!dispatched.ok) {
|
|
201
272
|
app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
|
|
202
273
|
const outBody: DeliveryGraphTextResult = {
|
|
@@ -226,7 +297,87 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
226
297
|
},
|
|
227
298
|
};
|
|
228
299
|
}
|
|
229
|
-
|
|
300
|
+
|
|
301
|
+
// Even when `dispatched.digest === digest`, that equality does NOT prove the short-circuited run is
|
|
302
|
+
// THIS graph: the digest is content-addressed over the REDACTED `semanticBpmn`, so two graphs whose
|
|
303
|
+
// AUTHORED values differ only in redacted-away material share it (the very reason a keyless secret-
|
|
304
|
+
// bearing dispatch is refused upstream). An EXPLICIT `idempotencyKey` bypasses the lossless server-side
|
|
305
|
+
// `stableProposalRunKey`, so a caller who re-uses one key across two such digest-colliding graphs (which
|
|
306
|
+
// occupy the SAME digest-keyed proposal row, one re-staged over the other) would short-circuit onto the
|
|
307
|
+
// FIRST graph's still-running run — `dispatched.alreadyRunning` — while the re-staged graph never
|
|
308
|
+
// launched. Consuming the proposal then marks a graph `dispatched` that never ran. The run row now
|
|
309
|
+
// persists a LOSSLESS identity fingerprint (migration 113), and the dispatch core reports
|
|
310
|
+
// `identityConfirmed` — true when the running run's fingerprint MATCHES this graph's, i.e. a legitimate
|
|
311
|
+
// SAME-payload retry (a lost response / operator double-click of the identical proposal).
|
|
312
|
+
//
|
|
313
|
+
// The proof is the fingerprint MATCH, NOT the redaction side of the INCOMING graph: the ambiguity is
|
|
314
|
+
// symmetric. A secret-bearing running run collides in digest with a graph authored LITERALLY as its
|
|
315
|
+
// redacted form (`https://***@host`) — that incoming graph has `graphCarriesRedactedSecrets === false`,
|
|
316
|
+
// yet it is a DIFFERENT graph from the running run, so short-circuiting onto it is just as unprovable.
|
|
317
|
+
// Gating the refusal on the incoming graph's own redaction let that authored-as-redacted twin slip
|
|
318
|
+
// through and consume the running secret run's proposal (issue #778 review — thread
|
|
319
|
+
// dispatchDeliveryGraph.ts:320). So refuse the genuinely ambiguous CROSS-graph short-circuit whenever
|
|
320
|
+
// `!identityConfirmed` (which also covers a NULL pre-migration fingerprint we cannot prove), regardless
|
|
321
|
+
// of which side carries the redacted value; a same-payload retry (`identityConfirmed`) falls through and
|
|
322
|
+
// consumes the proposal, preserving the idempotency contract rather than 409-ing an identical retry
|
|
323
|
+
// (issue #778 review — thread dispatchDeliveryGraph.ts:332).
|
|
324
|
+
//
|
|
325
|
+
// The gate must fire for a KEYLESS request too, not only an explicit `idempotencyKey`. A faithful-digest
|
|
326
|
+
// graph (no redacted secret) dispatched WITHOUT a key leaves `dispatchRunKey` undefined, so the core
|
|
327
|
+
// falls back to the DIGEST as the run key — which a redacted twin SHARES with an earlier run that was
|
|
328
|
+
// itself keyed by that digest (e.g. an explicit `idempotencyKey === digest`). The keyless twin then
|
|
329
|
+
// short-circuits onto that different graph's run with `identityConfirmed === false`, so scoping the
|
|
330
|
+
// refusal to explicit-key requests would mark the twin `dispatched` though it never ran. `identityConfirmed`
|
|
331
|
+
// is derived from the running run's persisted fingerprint, NOT from whether a key was supplied, so a
|
|
332
|
+
// legitimate keyless same-payload retry still confirms and falls through — only a genuinely unprovable
|
|
333
|
+
// short-circuit is refused (issue #778 review — thread dispatchDeliveryGraph.ts:325).
|
|
334
|
+
if (dispatched.alreadyRunning && !dispatched.identityConfirmed) {
|
|
335
|
+
app.log.warn("dispatch-delivery-graph refused: already-running short-circuit cannot prove the running run is this graph", {
|
|
336
|
+
digest,
|
|
337
|
+
runKey: dispatched.runKey,
|
|
338
|
+
});
|
|
339
|
+
return {
|
|
340
|
+
status: 409,
|
|
341
|
+
body: {
|
|
342
|
+
ok: false,
|
|
343
|
+
error:
|
|
344
|
+
`this graph short-circuited onto an already-running delivery graph whose identity fingerprint does not match it, ` +
|
|
345
|
+
`so its content-addressed digest (${digest}) cannot prove the running run is this exact graph; the staged proposal was NOT ` +
|
|
346
|
+
"dispatched — re-dispatch with a distinct idempotencyKey per graph (or none, to use the lossless server-side key)",
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const marked = await markProposalDispatched(app.data, digest, stageSeq);
|
|
351
|
+
if (!marked) {
|
|
352
|
+
// The row left the `staged` revision we snapshotted between our load and this flip. Distinguish the
|
|
353
|
+
// two reasons it moved, because they need OPPOSITE handling:
|
|
354
|
+
// (a) an EQUIVALENT re-stage bumped `stage_seq` on a BYTE-IDENTICAL graph in our window — the run
|
|
355
|
+
// we launched IS that graph, so the still-`staged` row is a duplicate of what we just
|
|
356
|
+
// dispatched. Leaving it `staged` wedges an undismissable proposal that every future dispatch
|
|
357
|
+
// short-circuits onto our already-running run without ever consuming (issue #778 review — thread
|
|
358
|
+
// dispatchDeliveryGraph.ts:320). Consume it at its CURRENT revision.
|
|
359
|
+
// (b) a re-stage overwrote it with a DIFFERENT (credential-different) graph, or a
|
|
360
|
+
// dispatch/dismiss/supersede/expiry retired it — a newer, never-launched revision we must NOT
|
|
361
|
+
// clobber to `dispatched`.
|
|
362
|
+
// Re-load and let `isEquivalentReStage` compare the LOSSLESS raw graph JSON (not the lossy digest,
|
|
363
|
+
// which collapses credential-different graphs): byte-identical ⇒ equivalent re-stage (a), else (b).
|
|
364
|
+
const restaged = await getStagedProposal(app.data, digest);
|
|
365
|
+
if (isEquivalentReStage(restaged, proposal.graph)) {
|
|
366
|
+
const consumed = await markProposalDispatched(app.data, digest, restaged?.stage_seq);
|
|
367
|
+
app.log.warn("dispatch-delivery-graph: equivalent re-stage consumed at bumped revision", {
|
|
368
|
+
digest,
|
|
369
|
+
stageSeq,
|
|
370
|
+
restagedSeq: restaged?.stage_seq,
|
|
371
|
+
consumed,
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
// A concurrent dispatch consumed it, or a re-stage overwrote it with a newer graph (bumping
|
|
375
|
+
// stage_seq), or a dismiss/supersede/expiry retired it. The run we launched still stands; we
|
|
376
|
+
// intentionally skip the mark so a newer, never-launched revision is not clobbered to `dispatched`
|
|
377
|
+
// (issue #778 review).
|
|
378
|
+
app.log.warn("dispatch-delivery-graph: staged revision changed before consume; mark skipped", { digest, stageSeq });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
230
381
|
|
|
231
382
|
const outBody: DeliveryGraphTextResult = {
|
|
232
383
|
ok: true,
|
|
@@ -40,7 +40,8 @@ test("the guide covers every capability the endpoint promises", async () => {
|
|
|
40
40
|
const md = ((await handler(input(), app)) as any).body.instructions as string;
|
|
41
41
|
// Submit a PR (converge vs. merge), submit an epic, answer escalations…
|
|
42
42
|
assert(md.includes("start/convergence-loop"), "covers submitting a PR for convergence");
|
|
43
|
-
assert(md.includes("
|
|
43
|
+
assert(md.includes("autoMerge"), "documents the per-request merge control");
|
|
44
|
+
assert(!md.includes("convergeOnly"), "does not expose the legacy merge control in agent guidance");
|
|
44
45
|
assert(md.includes("start/plan-fanout"), "covers submitting an epic");
|
|
45
46
|
assert(md.includes("complete-user-task"), "covers answering escalations via the native user-task door");
|
|
46
47
|
// …debug the system.
|
|
@@ -143,15 +143,57 @@ test("startConvergenceLoop forwards convergeOnly:true to the loop", async () =>
|
|
|
143
143
|
});
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
test("startConvergenceLoop maps autoMerge:true to merge-enabled convergence", async () => {
|
|
147
|
+
await withGithubOff(async () => {
|
|
148
|
+
const { app: capApp, get } = captureApp();
|
|
149
|
+
const res = await startConvergenceLoop(input({ pr: "owner/repo#9", autoMerge: true }), capApp);
|
|
150
|
+
assertEquals((res as any).status, 202);
|
|
151
|
+
assertEquals(get(), false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("startConvergenceLoop maps autoMerge:false to review-only convergence", async () => {
|
|
156
|
+
await withGithubOff(async () => {
|
|
157
|
+
const { app: capApp, get } = captureApp();
|
|
158
|
+
const res = await startConvergenceLoop(input({ pr: "owner/repo#10", autoMerge: false }), capApp);
|
|
159
|
+
assertEquals((res as any).status, 202);
|
|
160
|
+
assertEquals(get(), true);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("startConvergenceLoop lets autoMerge override the legacy convergeOnly alias", async () => {
|
|
165
|
+
await withGithubOff(async () => {
|
|
166
|
+
const merge = captureApp();
|
|
167
|
+
const mergeRes = await startConvergenceLoop(
|
|
168
|
+
input({ pr: "owner/repo#13", autoMerge: true, convergeOnly: true }),
|
|
169
|
+
merge.app,
|
|
170
|
+
);
|
|
171
|
+
assertEquals((mergeRes as any).status, 202);
|
|
172
|
+
assertEquals(merge.get(), false);
|
|
173
|
+
|
|
174
|
+
const review = captureApp();
|
|
175
|
+
const reviewRes = await startConvergenceLoop(
|
|
176
|
+
input({ pr: "owner/repo#14", autoMerge: false, convergeOnly: false }),
|
|
177
|
+
review.app,
|
|
178
|
+
);
|
|
179
|
+
assertEquals((reviewRes as any).status, 202);
|
|
180
|
+
assertEquals(review.get(), true);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
146
184
|
test("startConvergenceLoop defaults convergeOnly to false and does not truthy-coerce a non-boolean", async () => {
|
|
147
185
|
await withGithubOff(async () => {
|
|
148
186
|
const omitted = captureApp();
|
|
149
|
-
await startConvergenceLoop(input({ pr: "owner/repo#
|
|
187
|
+
await startConvergenceLoop(input({ pr: "owner/repo#11" }), omitted.app);
|
|
150
188
|
assertEquals(omitted.get(), false);
|
|
151
189
|
|
|
152
190
|
const stringy = captureApp();
|
|
153
|
-
await startConvergenceLoop(input({ pr: "owner/repo#
|
|
191
|
+
await startConvergenceLoop(input({ pr: "owner/repo#12", convergeOnly: "true" }), stringy.app);
|
|
154
192
|
assertEquals(stringy.get(), false);
|
|
193
|
+
|
|
194
|
+
const stringyAutoMerge = captureApp();
|
|
195
|
+
await startConvergenceLoop(input({ pr: "owner/repo#15", autoMerge: "true" }), stringyAutoMerge.app);
|
|
196
|
+
assertEquals(stringyAutoMerge.get(), false);
|
|
155
197
|
});
|
|
156
198
|
});
|
|
157
199
|
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
// external webhook relay, a CI job, and Swagger all POST here. Parse the PR reference and
|
|
4
4
|
// register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
|
|
5
5
|
//
|
|
6
|
-
// The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, convergeOnly? }`), not
|
|
7
|
-
// `variables` envelope: this is a purpose-built operation, not a generic engine "start
|
|
8
|
-
// so it does not leak the engine's variable-map concept to callers.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, autoMerge?, convergeOnly? }`), not
|
|
7
|
+
// wrapped in a `variables` envelope: this is a purpose-built operation, not a generic engine "start
|
|
8
|
+
// process" call, so it does not leak the engine's variable-map concept to callers. `autoMerge` is
|
|
9
|
+
// the positive, preferred form for UI/API callers; `convergeOnly` remains a legacy negative alias.
|
|
10
|
+
// When both are present, `autoMerge` wins. The body is a `oneOf` — EXACTLY ONE of `pr` or `url` — so
|
|
11
|
+
// the runtime rejects an empty or ambiguous target at the edge (a 400 that names the allowed
|
|
12
|
+
// shapes); this delegate no longer coalesces `pr ?? url`, it just narrows the validated variant. It
|
|
13
|
+
// keeps the PR-parse guard because the reference FORMAT (owner/repo#123 or a URL) is app logic the
|
|
14
|
+
// JSON schema can't express — an unparseable reference is a 400.
|
|
13
15
|
|
|
14
16
|
import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
|
|
15
17
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -29,9 +31,10 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
|
|
|
29
31
|
}
|
|
30
32
|
const dependsOn = body.dependsOn ?? [];
|
|
31
33
|
const maxRounds = clampRounds(body.maxRounds, MAX_ROUNDS);
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
const
|
|
34
|
+
// Prefer the positive autoMerge form so an unchecked UI box is explicitly review-only. Legacy
|
|
35
|
+
// callers that omit autoMerge retain the existing convergeOnly/global-default behavior.
|
|
36
|
+
const hasAutoMerge = typeof body.autoMerge === "boolean";
|
|
37
|
+
const convergeOnly = hasAutoMerge ? body.autoMerge !== true : body.convergeOnly === true;
|
|
35
38
|
const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly);
|
|
36
39
|
app.log.info("convergence loop started", {
|
|
37
40
|
prKey: parsed.prKey,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.191.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/pages/home.page.json
CHANGED
|
@@ -105,8 +105,8 @@
|
|
|
105
105
|
"type": "number"
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
|
-
"key": "
|
|
109
|
-
"label": "
|
|
108
|
+
"key": "autoMerge",
|
|
109
|
+
"label": "Auto-merge \u2014 after convergence, drive the merge-loop",
|
|
110
110
|
"type": "checkbox"
|
|
111
111
|
}
|
|
112
112
|
]
|