@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.
@@ -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
- await markProposalExpired(app.data, digest);
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
- const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, repository, baseBranch, repoless, ...timeouts });
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
- await markProposalDispatched(app.data, digest);
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.190.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",