@nanobpm/nano-workforce 0.190.0 → 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 +6 -0
- 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/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/operations/dispatchDeliveryGraph.test.ts +255 -0
- package/operations/dispatchDeliveryGraph.ts +156 -5
- package/package.json +1 -1
|
@@ -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,
|
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",
|