@nanobpm/nano-workforce 0.190.0 → 0.191.1
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/.github/workflows/mirror-install-dispatch.yml +6 -6
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraph.test.ts +447 -0
- package/app/deliveryGraph.ts +356 -1
- 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 +67 -4
- package/app/deliveryGraphRun.ts +62 -10
- package/app/deliveryHuman.test.ts +79 -2
- package/app/deliveryHuman.ts +92 -0
- package/app/deliveryRunner.test.ts +21 -0
- package/app/deliveryRunner.ts +9 -2
- package/app/mergeProtocol.ts +1 -1
- package/app/pollUserTasks.test.ts +39 -0
- package/app/readiness.test.ts +72 -0
- package/app/readiness.ts +234 -8
- package/app/service.ts +8 -3
- 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/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
- package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
- package/docs/adr/0003-epic-base-branch-admission.md +1 -1
- package/docs/adr/0004-shared-contract-coordination.md +1 -1
- package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
- package/docs/adr/0006-delivery-units-one-representation.md +1 -1
- package/operations/dispatchDeliveryGraph.test.ts +255 -0
- package/operations/dispatchDeliveryGraph.ts +156 -5
- package/package.json +1 -1
- package/workers/merge/worker.test.ts +2 -2
- package/workers/merge/worker.ts +1 -1
|
@@ -62,6 +62,15 @@ export interface DeliveryGraphProposal {
|
|
|
62
62
|
created_at: string;
|
|
63
63
|
updated_at: string;
|
|
64
64
|
expires_at: string;
|
|
65
|
+
/** A strictly-monotonic per-write stage revision (migration 114), reassigned to `MAX(stage_seq)+1`
|
|
66
|
+
* on every stage write (insert AND re-stage). It is the supersede reconcile's same-millisecond
|
|
67
|
+
* tie-break, AND the version token the dispatch door threads through so `markProposalDispatched`
|
|
68
|
+
* can guard its terminal flip against a concurrent re-stage that overwrote this same-digest row with
|
|
69
|
+
* a newer (e.g. credential-differing) graph after this dispatch snapshotted it (issue #778 review —
|
|
70
|
+
* thread deliveryGraphProposals.ts:243). Persisted rows carry a value ≥ 1; a pre-migration row reads
|
|
71
|
+
* `0` until its next stage re-sequences it. Assigned atomically in the `stageProposal` SQL, never
|
|
72
|
+
* from a row object — a freshly-`buildProposalRow`'d row's `0` here is a never-persisted placeholder. */
|
|
73
|
+
stage_seq: number;
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/** The rendered preview a staged proposal carries — the operator-facing view of WHAT the graph does
|
|
@@ -150,6 +159,9 @@ export function buildProposalRow(input: {
|
|
|
150
159
|
created_at: createdAt,
|
|
151
160
|
updated_at: at,
|
|
152
161
|
expires_at: proposalExpiry(createdAt),
|
|
162
|
+
// Placeholder only: the persisted `stage_seq` is assigned atomically as `MAX(stage_seq)+1` inside
|
|
163
|
+
// the `stageProposal` INSERT/UPSERT (never bound from this object), so this `0` is never written.
|
|
164
|
+
stage_seq: 0,
|
|
153
165
|
};
|
|
154
166
|
}
|
|
155
167
|
|
|
@@ -190,55 +202,77 @@ export interface StageOutcome {
|
|
|
190
202
|
* retire. */
|
|
191
203
|
export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<StageOutcome> {
|
|
192
204
|
const table = deliveryGraphProposals(data);
|
|
193
|
-
const existing = await table.get(row.digest);
|
|
194
205
|
// Capture the same-logical-key staged siblings that exist BEFORE this stage writes (excluding our own
|
|
195
206
|
// digest) — the supersede reconcile below normally flips them all to `superseded`; we re-read them
|
|
196
207
|
// after to report exactly which digests this stage retired.
|
|
197
208
|
const priorSameKey = (await table.find({ status: "staged" })).filter(
|
|
198
209
|
(r) => r.logical_key === row.logical_key && r.digest !== row.digest && isLiveStaged(r),
|
|
199
210
|
);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
.
|
|
239
|
-
|
|
211
|
+
// Persist the write, its strictly-monotonic stage sequence, AND the supersede reconcile as ONE
|
|
212
|
+
// transaction (issue #778 review — threads deliveryGraphProposals.ts:237/:239). Two properties matter:
|
|
213
|
+
// • `stage_seq` is assigned ATOMICALLY inside the write itself — `MAX(stage_seq)+1` in the
|
|
214
|
+
// INSERT/UPDATE, NOT a separate stamp statement — so there is never a "written but unstamped
|
|
215
|
+
// (seq 0)" window in which a rival same-millisecond stage's reconcile could read the half-written
|
|
216
|
+
// sibling at its DEFAULT 0 and supersede it out of order (the inversion the old two-statement
|
|
217
|
+
// stamp+reconcile allowed). Assigning inside the serialized transaction keeps `MAX(...)` atomic, so
|
|
218
|
+
// `+1` is strictly greater than every committed seq and reflects true write order.
|
|
219
|
+
// • wrapping the reconcile in the SAME transaction means no concurrent stage observes an intermediate
|
|
220
|
+
// state between the write and the reconcile.
|
|
221
|
+
// `MAX(stage_seq)` reads the pre-write table via the `stage_seq` index (migration 114), an O(1)
|
|
222
|
+
// reverse-index seek rather than the full-history scan a bare aggregate over the retained terminal rows
|
|
223
|
+
// would cost.
|
|
224
|
+
const at = now();
|
|
225
|
+
const atExpiry = proposalExpiry(at);
|
|
226
|
+
const nextSeq = `(SELECT COALESCE(MAX("stage_seq"), 0) + 1 FROM "delivery_graph_proposals")`;
|
|
227
|
+
await data.open().tx(async (t) => {
|
|
228
|
+
// Single idempotent UPSERT rather than a read-`existing`-then-branch INSERT/UPDATE: reading the row
|
|
229
|
+
// BEFORE the transaction meant two concurrent FIRST stages of the same digest both saw `null` and
|
|
230
|
+
// would both take an INSERT — the second hitting a primary-key conflict that fails the stage and drops
|
|
231
|
+
// its reconciliation. `ON CONFLICT("digest") DO UPDATE` collapses the raced insert into an in-place
|
|
232
|
+
// update instead, so a same-digest stage is idempotent regardless of arrival order (issue #778 review —
|
|
233
|
+
// thread deliveryGraphProposals.ts:245). `stage_seq` is still assigned ATOMICALLY inside the write
|
|
234
|
+
// (`MAX(stage_seq)+1`, materialised once as `excluded."stage_seq"` so the insert and the on-conflict
|
|
235
|
+
// update use the exact same value), preserving the strictly-monotonic ordering the supersede reconcile
|
|
236
|
+
// below tie-breaks on.
|
|
237
|
+
//
|
|
238
|
+
// The TTL anchor (`created_at`) and horizon (`expires_at`) are ALSO derived inside this atomic write,
|
|
239
|
+
// from the row as it exists AT WRITE TIME — NOT from a pre-transaction `table.get` snapshot (issue #778
|
|
240
|
+
// review — thread deliveryGraphProposals.ts:247). A pre-read snapshot is a TOCTOU: a concurrent re-stage
|
|
241
|
+
// can commit a fresh TTL between the read and this write, and the stale caller — committing LAST, so it
|
|
242
|
+
// wins the newest `stage_seq` — would overwrite that fresh horizon with the one it computed from the OLD
|
|
243
|
+
// `created_at`, restoring an already-expired `expires_at` on the newest row and leaving the proposal
|
|
244
|
+
// non-dispatchable. On a fresh INSERT the caller's `created_at`/`expires_at` stand (first stage anchors
|
|
245
|
+
// the TTL). On a CONFLICT the `CASE` re-reads the CURRENT committed row: if it is still LIVE
|
|
246
|
+
// (`expires_at` in the future) the original anchor and horizon are PRESERVED (an idempotent re-stage
|
|
247
|
+
// keeps the TTL pinned to the first stage); once it has aged out (past, blank, or NULL `expires_at`,
|
|
248
|
+
// matching `isProposalExpired`'s fail-closed rule) the TTL is RE-ANCHORED to now so a re-proposed digest
|
|
249
|
+
// is genuinely live again. All timestamps are ISO-8601 UTC, so the lexical `>` comparison is a valid
|
|
250
|
+
// chronological test.
|
|
251
|
+
await t.exec(
|
|
252
|
+
`INSERT INTO "delivery_graph_proposals" ("digest", "logical_key", "title", "graph", "preview", "node_count", "human_node_count", "side_effect_count", "side_effecting", "status", "created_at", "updated_at", "expires_at", "stage_seq") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${nextSeq}) ON CONFLICT("digest") DO UPDATE SET "logical_key" = excluded."logical_key", "title" = excluded."title", "graph" = excluded."graph", "preview" = excluded."preview", "node_count" = excluded."node_count", "human_node_count" = excluded."human_node_count", "side_effect_count" = excluded."side_effect_count", "side_effecting" = excluded."side_effecting", "status" = excluded."status", "created_at" = CASE WHEN "delivery_graph_proposals"."expires_at" > ? THEN "delivery_graph_proposals"."created_at" ELSE ? END, "updated_at" = ?, "expires_at" = CASE WHEN "delivery_graph_proposals"."expires_at" > ? THEN "delivery_graph_proposals"."expires_at" ELSE ? END, "stage_seq" = excluded."stage_seq"`,
|
|
253
|
+
[row.digest, row.logical_key, row.title, row.graph, row.preview, row.node_count, row.human_node_count, row.side_effect_count, row.side_effecting, row.status, row.created_at, at, row.expires_at, at, at, at, at, atExpiry],
|
|
254
|
+
);
|
|
255
|
+
// Reconcile to EXACTLY ONE live proposal per logical graph: supersede every `staged` row for this
|
|
256
|
+
// `logical_key` that has a strictly-NEWER staged sibling (by `updated_at`, with a deterministic
|
|
257
|
+
// `stage_seq` tie-breaker), leaving only the globally-newest live. This is ORDER-INDEPENDENT
|
|
258
|
+
// — it never references the just-written digest, so it converges to a single live row regardless of the
|
|
259
|
+
// interleaving of concurrent stages. A supersede pass keyed to "only flip rows older than the one *I*
|
|
260
|
+
// just wrote" leaves TWO live proposals when an OLDER stage's pass runs AFTER a newer stage already
|
|
261
|
+
// committed (the older pass won't flip the newer row, and the newer pass ran before the older row
|
|
262
|
+
// existed) — and an unordered supersede-all leaves ZERO. Anchoring on the newest staged sibling avoids
|
|
263
|
+
// both: the newest row is never superseded (no newer sibling), so it stays staged throughout the
|
|
264
|
+
// statement and every older row's `EXISTS` is satisfied by it. Idempotent: a no-op once one row remains.
|
|
265
|
+
// The tie-break is the monotonic `stage_seq` (atomically assigned in the write above), NOT the content
|
|
266
|
+
// `digest` NOR the frozen `rowid`: two stages landing in the SAME millisecond tie on `updated_at`, and a
|
|
267
|
+
// `digest`-string tie-break is arbitrary w.r.t. submission order while `rowid` does not advance on a
|
|
268
|
+
// re-stage UPDATE — so either could pick the LATER-submitted graph as the one superseded, inverting
|
|
269
|
+
// "the latest stage wins". `stage_seq` advances on every stage (insert AND re-stage alike), so the
|
|
270
|
+
// most-recently-written row deterministically wins the tie.
|
|
271
|
+
await t.exec(
|
|
272
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."stage_seq" > "delivery_graph_proposals"."stage_seq")))`,
|
|
240
273
|
[now(), row.logical_key],
|
|
241
274
|
);
|
|
275
|
+
});
|
|
242
276
|
|
|
243
277
|
// Report what the stage did to siblings (issue #740). `superseded` = the same-logical-key proposals
|
|
244
278
|
// this stage retired (re-read post-reconcile so a concurrent newer stage that kept ITS row live —
|
|
@@ -259,12 +293,13 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
|
|
|
259
293
|
}
|
|
260
294
|
const siblingsStaged = (await listStagedProposals(data)).filter((r) => r.logical_key !== row.logical_key).length;
|
|
261
295
|
|
|
262
|
-
// Re-read the just-written row AFTER the reconcile so
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// that doesn't match the DB
|
|
266
|
-
//
|
|
267
|
-
|
|
296
|
+
// Re-read the just-written row AFTER the reconcile so the returned row reports the ACTUALLY-persisted
|
|
297
|
+
// state: a concurrent newer stage of a different digest can flip THIS digest to `superseded` in the
|
|
298
|
+
// reconcile above, so returning the pre-reconcile input `row` (still `staged`) would report a status
|
|
299
|
+
// that doesn't match the DB — and the persisted `created_at`/`expires_at`/`stage_seq` are derived inside
|
|
300
|
+
// the write, not on `row`. Fall back to the input `row` only if the persisted row somehow vanished (it
|
|
301
|
+
// shouldn't — we just wrote it), keeping the return non-null.
|
|
302
|
+
const persisted = (await table.get(row.digest)) ?? row;
|
|
268
303
|
|
|
269
304
|
return { row: persisted, superseded, siblingsStaged };
|
|
270
305
|
}
|
|
@@ -321,9 +356,36 @@ export async function listStagedProposals(
|
|
|
321
356
|
}
|
|
322
357
|
|
|
323
358
|
/** Mark a staged proposal `dispatched` once the operator launches it — it drops out of the cockpit's
|
|
324
|
-
* staged list (the run then shows in the in-flight grid).
|
|
325
|
-
|
|
326
|
-
|
|
359
|
+
* staged list (the run then shows in the in-flight grid).
|
|
360
|
+
*
|
|
361
|
+
* Like `markProposalExpired`/`markProposalDismissed`/`sweepExpiredProposals`, this is a GUARDED UPDATE
|
|
362
|
+
* (`... WHERE digest=? AND status='staged'`), not a blind update-by-key. The dispatch door's
|
|
363
|
+
* `getStagedProposal` liveness read, the launch, and this terminal flip are separate statements, so the
|
|
364
|
+
* row can leave `staged` in the window between them (a concurrent dispatch consumed it, or a
|
|
365
|
+
* dismiss/supersede/expiry sweep retired it); a blind `table.update(digest, …)` would clobber that
|
|
366
|
+
* newer terminal status back to `dispatched`, breaking monotonic lifecycle transitions.
|
|
367
|
+
*
|
|
368
|
+
* When `expectedStageSeq` is supplied the guard ALSO pins `stage_seq` to the value the dispatch door
|
|
369
|
+
* snapshotted when it loaded the proposal (issue #778 review — thread deliveryGraphProposals.ts:243). A
|
|
370
|
+
* concurrent RE-STAGE of the SAME digest overwrites the row in place with a newer graph (two
|
|
371
|
+
* credential-differing graphs share a semantic digest) and bumps `stage_seq`; without the seq pin, this
|
|
372
|
+
* flip would retire that newer, never-launched graph — the dispatch launched the STALE snapshot it read
|
|
373
|
+
* yet marks the fresh row consumed. Pinning `stage_seq` makes the flip a no-op in that race, leaving the
|
|
374
|
+
* re-staged graph `staged` for its own dispatch. Omit `expectedStageSeq` (e.g. a pre-migration row whose
|
|
375
|
+
* seq is `0`) to fall back to the `status='staged'`-only guard.
|
|
376
|
+
*
|
|
377
|
+
* Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring
|
|
378
|
+
* `markProposalDismissed`/`sweepExpiredProposals`. A `false` return means the row was no longer the
|
|
379
|
+
* `staged` revision this dispatch snapshotted (a concurrent dispatch/re-stage/retire won) — the launched
|
|
380
|
+
* run still stands, but the mark was intentionally skipped so the newer row is not clobbered. */
|
|
381
|
+
export async function markProposalDispatched(data: DataLayer, digest: string, expectedStageSeq?: number): Promise<boolean> {
|
|
382
|
+
const db = data.open();
|
|
383
|
+
const guardSeq = typeof expectedStageSeq === "number";
|
|
384
|
+
const res = await db.exec(
|
|
385
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'dispatched', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'${guardSeq ? ` AND "stage_seq" = ?` : ""}`,
|
|
386
|
+
guardSeq ? [now(), digest, expectedStageSeq] : [now(), digest],
|
|
387
|
+
);
|
|
388
|
+
return res.changed > 0;
|
|
327
389
|
}
|
|
328
390
|
|
|
329
391
|
/** Retire a proposal by flipping it to `expired` — its `graph` payload is unusable (e.g. corrupt JSON
|
|
@@ -342,12 +404,18 @@ export async function markProposalDispatched(data: DataLayer, digest: string): P
|
|
|
342
404
|
* Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring
|
|
343
405
|
* `markProposalDismissed`/`sweepExpiredProposals`. A `false` return means the row was no longer `staged`
|
|
344
406
|
* at write time (a dismiss/supersede/dispatch race won), so the caller must not treat the retirement as
|
|
345
|
-
* having happened.
|
|
346
|
-
|
|
407
|
+
* having happened.
|
|
408
|
+
*
|
|
409
|
+
* When an `expectedStageSeq` is supplied (the dispatch door's corrupt-graph retirement), the flip is
|
|
410
|
+
* ADDITIONALLY guarded on `stage_seq = ?`, exactly like `markProposalDispatched`: a concurrent re-stage
|
|
411
|
+
* of the same digest bumps `stage_seq`, so guarding on the revision we inspected keeps the expiry from
|
|
412
|
+
* retiring a newer graph that landed in the read→write window (issue #778 review). */
|
|
413
|
+
export async function markProposalExpired(data: DataLayer, digest: string, expectedStageSeq?: number): Promise<boolean> {
|
|
347
414
|
const db = data.open();
|
|
415
|
+
const guardSeq = typeof expectedStageSeq === "number";
|
|
348
416
|
const res = await db.exec(
|
|
349
|
-
`UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
|
|
350
|
-
[now(), digest],
|
|
417
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'${guardSeq ? ` AND "stage_seq" = ?` : ""}`,
|
|
418
|
+
guardSeq ? [now(), digest, expectedStageSeq] : [now(), digest],
|
|
351
419
|
);
|
|
352
420
|
return res.changed > 0;
|
|
353
421
|
}
|
|
@@ -404,9 +472,15 @@ export async function sweepExpiredProposals(data: DataLayer, at: Date = new Date
|
|
|
404
472
|
let swept = 0;
|
|
405
473
|
for (const row of staged) {
|
|
406
474
|
if (isProposalExpired(row.expires_at, at)) {
|
|
475
|
+
// Pin `stage_seq` (like `markProposalDispatched`/`markProposalExpired`): the `find` liveness read and
|
|
476
|
+
// this flip are separate statements, so a concurrent RE-STAGE can overwrite the row in place with a
|
|
477
|
+
// newer graph — bumping `stage_seq` and REFRESHING `expires_at` — in the window between them. A blind
|
|
478
|
+
// `digest + status='staged'` update would then expire that fresh, un-expired revision. Guarding on the
|
|
479
|
+
// `stage_seq` we inspected makes the flip a no-op in that race, leaving the re-staged graph `staged`
|
|
480
|
+
// (issue #778 review — thread deliveryGraphProposals.ts:417).
|
|
407
481
|
const res = await db.exec(
|
|
408
|
-
`UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'
|
|
409
|
-
[ts, row.digest],
|
|
482
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged' AND "stage_seq" = ?`,
|
|
483
|
+
[ts, row.digest, row.stage_seq],
|
|
410
484
|
);
|
|
411
485
|
swept += res.changed;
|
|
412
486
|
}
|
|
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
import { test } from "node:test";
|
|
11
|
-
import { assertEquals } from "#test-assert";
|
|
11
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
12
12
|
import type { DataLayer } from "@nanobpm/urban";
|
|
13
13
|
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
14
14
|
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
computeRunKey,
|
|
20
20
|
deriveDeliveryPhase,
|
|
21
21
|
DELIVERY_PHASE,
|
|
22
|
+
deliveryGraphRunIdentities,
|
|
22
23
|
deliveryGraphRuns,
|
|
23
24
|
humanTaskElementId,
|
|
24
25
|
parseHumanLabels,
|
|
@@ -107,6 +108,25 @@ test("claimRunForLaunch: re-running a terminal row clears the PRIOR instance key
|
|
|
107
108
|
});
|
|
108
109
|
});
|
|
109
110
|
|
|
111
|
+
test("claimRunForLaunch: winning a re-run claim ATOMICALLY invalidates the prior run's identity side-row — a concurrent loser's short-circuit read sees a MISSING (unprovable) fingerprint, not the stale prior one (issue #778 review — thread deliveryGraphDispatch.ts:180)", async () => {
|
|
112
|
+
await withData(async (data) => {
|
|
113
|
+
const runs = deliveryGraphRuns(data);
|
|
114
|
+
const identities = deliveryGraphRunIdentities(data);
|
|
115
|
+
// A terminal prior run still carrying its stamped lossless identity fingerprint.
|
|
116
|
+
await runs.insert({ ...claimRow("running"), status: "failed" });
|
|
117
|
+
await identities.insert({ run_key: "rk", graph_fingerprint: "PRIOR-FP", created_at: "2020-01-01T00:00:00.000Z" });
|
|
118
|
+
// The winner re-runs (conceptually a DIFFERENT graph that shares the digest-derived run_key but not
|
|
119
|
+
// the credential-sensitive fingerprint). The CAS flips the row to running…
|
|
120
|
+
assertEquals(await claimRunForLaunch(data, true, claimRow("running")), true);
|
|
121
|
+
// …and the stale identity row must be GONE the instant the row is `running`. Otherwise a racing loser
|
|
122
|
+
// reading between this flip and the winner's post-launch `upsertRunIdentity` re-stamp would read
|
|
123
|
+
// "PRIOR-FP" and, if that matched ITS graph, wrongly confirm identity for a credential-different run.
|
|
124
|
+
// Missing → dispatch's identityConfirmed is false (the safe 409) throughout the window.
|
|
125
|
+
const after = await identities.get("rk");
|
|
126
|
+
assert(after == null, `the stale identity row must be gone, got: ${JSON.stringify(after)}`);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
110
130
|
// ── pollDeliveryGraphPhase: engine-key coercion ───────────────────────────────
|
|
111
131
|
test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches the string process_key, so a COMPLETED instance reconciles to done", async () => {
|
|
112
132
|
await withData(async (data) => {
|
|
@@ -181,7 +201,7 @@ test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back
|
|
|
181
201
|
});
|
|
182
202
|
|
|
183
203
|
// ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
|
|
184
|
-
test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction
|
|
204
|
+
test("buildHumanLabels: maps each human node's compiled user-task element id → its FULL instruction", async () => {
|
|
185
205
|
const graph = {
|
|
186
206
|
nodes: [
|
|
187
207
|
{ id: "open-b", kind: "agent", agent: { jobType: "j" } },
|
|
@@ -196,16 +216,38 @@ test("buildHumanLabels: maps each human node's compiled user-task element id →
|
|
|
196
216
|
const labels = buildHumanLabels(compiled);
|
|
197
217
|
const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
|
|
198
218
|
const ackEl = compiled.resolved.nodes.find((n) => n.id === "ack")?.element ?? "";
|
|
199
|
-
|
|
219
|
+
// The FULL prompt is stored (issue #813) — not a clamped first line — so the Tasks "Decision
|
|
220
|
+
// context" can show the whole instruction. The phase pill clamps this at display (deriveDeliveryPhase).
|
|
221
|
+
assertEquals(labels[humanTaskElementId(publishEl)], "run the manual OTP publish\nsecond line");
|
|
200
222
|
assertEquals(labels[humanTaskElementId(ackEl)], "ack"); // fallback to node id
|
|
201
223
|
});
|
|
202
224
|
|
|
225
|
+
test("buildHumanLabels: redacts a credential-bearing human prompt before it lands in human_labels (issue #778 review)", async () => {
|
|
226
|
+
const graph = {
|
|
227
|
+
nodes: [
|
|
228
|
+
{ id: "publish", kind: "human", human: { prompt: "deploy via https://user:s3cr3t@registry.example.com then confirm" } },
|
|
229
|
+
],
|
|
230
|
+
edges: [],
|
|
231
|
+
};
|
|
232
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
233
|
+
assertEquals(compiled.ok, true);
|
|
234
|
+
if (!compiled.ok) return;
|
|
235
|
+
const labels = buildHumanLabels(compiled);
|
|
236
|
+
const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
|
|
237
|
+
const label = labels[humanTaskElementId(publishEl)];
|
|
238
|
+
assert(!label.includes("s3cr3t") && !label.includes("user:s3cr3t"), "the credential must not survive into the denormalised inbox label");
|
|
239
|
+
assertStringIncludes(label, "//***@");
|
|
240
|
+
});
|
|
241
|
+
|
|
203
242
|
test("parseHumanLabels: round-trips a stored map and tolerates null/blank/corrupt", () => {
|
|
204
|
-
assertEquals(parseHumanLabels(JSON.stringify({ a: "x" })), { a: "x" });
|
|
205
243
|
assertEquals(parseHumanLabels(null), {});
|
|
206
244
|
assertEquals(parseHumanLabels(""), {});
|
|
207
245
|
assertEquals(parseHumanLabels(" "), {});
|
|
208
246
|
assertEquals(parseHumanLabels("{not json"), {});
|
|
247
|
+
assertEquals(parseHumanLabels(JSON.stringify({ n1: "manual OTP publish", n2: "confirm deploy" })), {
|
|
248
|
+
n1: "manual OTP publish",
|
|
249
|
+
n2: "confirm deploy",
|
|
250
|
+
}); // round-trips a valid string→string map
|
|
209
251
|
assertEquals(parseHumanLabels(JSON.stringify(["a"])), {}); // non-object
|
|
210
252
|
assertEquals(parseHumanLabels(JSON.stringify({ a: 1, b: "y" })), { b: "y" }); // drops non-string values
|
|
211
253
|
});
|
|
@@ -224,6 +266,16 @@ test("deriveDeliveryPhase: ACTIVE with an open human task → parked on that nod
|
|
|
224
266
|
assertEquals(p.phase_node_id, el);
|
|
225
267
|
});
|
|
226
268
|
|
|
269
|
+
test("deriveDeliveryPhase: clamps a long/multi-line stored instruction to a first-line phase pill (#813)", () => {
|
|
270
|
+
const el = humanTaskElementId("n2");
|
|
271
|
+
const full = `Review the console dependency-adopt PR opened by adopt-console (bumps the ranges).\nSecond line.`;
|
|
272
|
+
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], { [el]: full });
|
|
273
|
+
// Full instruction is stored for the Decision context, but the phase pill shows only the clamped
|
|
274
|
+
// first line (77 chars + …), never the second line.
|
|
275
|
+
assertEquals(p.phase, "Parked on human node: Review the console dependency-adopt PR opened by adopt-console (bumps the ran…");
|
|
276
|
+
assert(!p.phase.includes("Second line"));
|
|
277
|
+
});
|
|
278
|
+
|
|
227
279
|
test("deriveDeliveryPhase: a parked node with no stored label falls back to the element id", () => {
|
|
228
280
|
const el = humanTaskElementId("n5");
|
|
229
281
|
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], {});
|
|
@@ -231,6 +283,17 @@ test("deriveDeliveryPhase: a parked node with no stored label falls back to the
|
|
|
231
283
|
assertEquals(p.phase_node_id, el);
|
|
232
284
|
});
|
|
233
285
|
|
|
286
|
+
test("deliveryHuman: a parked bounded-timeout twin (`…__esc`) resolves the base node's stored label in the phase pill (#813)", () => {
|
|
287
|
+
// The twin's exact `…__esc` id is never stamped into `human_labels` (only the base id is), so the
|
|
288
|
+
// phase-pill lookup must strip `__esc` — the same fallback the Tasks Decision-context helpers use —
|
|
289
|
+
// else a parked timeout twin shows the raw element id instead of the instruction's first line.
|
|
290
|
+
const base = humanTaskElementId("n2");
|
|
291
|
+
const twin = `${base}__esc`;
|
|
292
|
+
const p = deriveDeliveryPhase("ACTIVE", [{ elementId: twin }], { [base]: "manual OTP publish" });
|
|
293
|
+
assertEquals(p.phase, "Parked on human node: manual OTP publish");
|
|
294
|
+
assertEquals(p.phase_node_id, twin);
|
|
295
|
+
});
|
|
296
|
+
|
|
234
297
|
test("deriveDeliveryPhase: ACTIVE with only a non-human open task (or none) → a bare Running", () => {
|
|
235
298
|
assertEquals(deriveDeliveryPhase("ACTIVE", [], {}), { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null });
|
|
236
299
|
assertEquals(deriveDeliveryPhase("ACTIVE", [{ elementId: "some-service-task" }], {}), {
|
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import type { DataLayer, ProcessInstanceState } from "@nanobpm/urban";
|
|
27
27
|
import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
|
|
28
28
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
29
|
+
import { redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
29
30
|
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
30
31
|
|
|
31
32
|
const now = () => new Date().toISOString();
|
|
@@ -97,6 +98,24 @@ export const DELIVERY_PHASE = {
|
|
|
97
98
|
export const deliveryGraphRuns = (data: DataLayer) =>
|
|
98
99
|
data.table<DeliveryGraphRun>("delivery_graph_runs", "run_key");
|
|
99
100
|
|
|
101
|
+
/** One row of the `delivery_graph_run_identity` SIDE table (migration 113) — the run's LOSSLESS
|
|
102
|
+
* content-identity fingerprint, kept OFF the heavily-projected `delivery_graph_runs` base row so no
|
|
103
|
+
* drift-guarded read-model/compat view has to carry it. Keyed by `run_key`. */
|
|
104
|
+
export interface DeliveryGraphRunIdentity {
|
|
105
|
+
run_key: string;
|
|
106
|
+
/** `sha256(digest \0 canonicalJson(digestInvisibleRawValues(graph)))`, captured at launch — the same
|
|
107
|
+
* lossless identity `stableProposalRunKey` folds into the keyless run key. An explicit-`idempotencyKey`
|
|
108
|
+
* short-circuit compares this to prove the running run is THIS exact graph (a same-payload retry) vs. a
|
|
109
|
+
* credential-different graph re-staged under the same key. */
|
|
110
|
+
graph_fingerprint: string;
|
|
111
|
+
created_at: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The `delivery_graph_run_identity` side-table accessor — the run's lossless identity fingerprint,
|
|
115
|
+
* keyed by `run_key` (issue #778 review — thread dispatchDeliveryGraph.ts:332). */
|
|
116
|
+
export const deliveryGraphRunIdentities = (data: DataLayer) =>
|
|
117
|
+
data.table<DeliveryGraphRunIdentity>("delivery_graph_run_identity", "run_key");
|
|
118
|
+
|
|
100
119
|
/** Atomically claim a run for LAUNCH — the at-most-once dispatch fence. Returns `true` iff THIS caller
|
|
101
120
|
* won the claim and must proceed to `runDeliveryGraph`; `false` iff a concurrent submit already claimed
|
|
102
121
|
* it (the caller must short-circuit as `alreadyRunning` instead of double-launching). Two fences, one
|
|
@@ -119,7 +138,9 @@ export const deliveryGraphRuns = (data: DataLayer) =>
|
|
|
119
138
|
* the row briefly visible as `running` while still pointing at the OLD instance key, so the
|
|
120
139
|
* `process_key`-keyed instance-tracking reconciler / poller could act on (and mis-reconcile against)
|
|
121
140
|
* the stale instance before the winner's follow-up metadata write lands. Clearing them atomically with
|
|
122
|
-
* the flip means a claimed `running` row can never be observed with a stale instance key.
|
|
141
|
+
* the flip means a claimed `running` row can never be observed with a stale instance key. A winning
|
|
142
|
+
* relaunch also DELETES the run's prior `delivery_graph_run_identity` side-row in the same transaction,
|
|
143
|
+
* for the same reason — see the inline note (issue #778 review). */
|
|
123
144
|
export async function claimRunForLaunch(
|
|
124
145
|
data: DataLayer,
|
|
125
146
|
existing: boolean,
|
|
@@ -134,9 +155,8 @@ export async function claimRunForLaunch(
|
|
|
134
155
|
return false;
|
|
135
156
|
}
|
|
136
157
|
}
|
|
137
|
-
const res = await data
|
|
138
|
-
.
|
|
139
|
-
.exec(
|
|
158
|
+
const res = await data.open().tx(async (t) => {
|
|
159
|
+
const flip = await t.exec(
|
|
140
160
|
`UPDATE "delivery_graph_runs" SET "status" = ?, "process_key" = ?, "process_definition_id" = ?, "phase" = ?, "phase_node_id" = ?, "updated_at" = ? WHERE "run_key" = ? AND "status" <> 'running'`,
|
|
141
161
|
[
|
|
142
162
|
claim.status,
|
|
@@ -148,7 +168,21 @@ export async function claimRunForLaunch(
|
|
|
148
168
|
claim.run_key,
|
|
149
169
|
],
|
|
150
170
|
);
|
|
151
|
-
|
|
171
|
+
if (flip.changed !== 1) return false;
|
|
172
|
+
// Invalidate the PRIOR run's lossless identity fingerprint IN THE SAME transaction as the flip, so a
|
|
173
|
+
// claimed `running` row can never be observed alongside the previous run's stale `graph_fingerprint`.
|
|
174
|
+
// dispatch's `identityConfirmed` short-circuit reads this side-row to prove an already-running run is
|
|
175
|
+
// THIS exact graph (a same-payload retry) vs. a credential-different graph re-staged under the same
|
|
176
|
+
// key; between this flip and the winner's post-launch re-stamp (`upsertRunIdentity`) the prior row
|
|
177
|
+
// would otherwise still match a relaunch that shares only the digest, WRONGLY confirming identity for
|
|
178
|
+
// a different-credential run. Deleting it here makes the fingerprint UNPROVABLE (missing →
|
|
179
|
+
// identityConfirmed:false, the safe pre-existing 409) throughout that window, atomically with the
|
|
180
|
+
// status flip — the same "no stale side-state observable on a claimed row" invariant the instance-key
|
|
181
|
+
// clear above enforces (issue #778 review — thread deliveryGraphDispatch.ts:180).
|
|
182
|
+
await t.exec(`DELETE FROM "delivery_graph_run_identity" WHERE "run_key" = ?`, [claim.run_key]);
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
return res;
|
|
152
186
|
}
|
|
153
187
|
|
|
154
188
|
/** The idempotency key for a submitted graph: a caller-supplied `idempotencyKey` (trimmed) when
|
|
@@ -172,16 +206,28 @@ function firstLine(text: string | undefined | null): string {
|
|
|
172
206
|
return line.length > 80 ? `${line.slice(0, 77)}…` : line;
|
|
173
207
|
}
|
|
174
208
|
|
|
175
|
-
/** Map each human node's compiled user-task element id →
|
|
176
|
-
*
|
|
177
|
-
*
|
|
209
|
+
/** Map each human node's compiled user-task element id → its FULL instruction, from the S1 compile
|
|
210
|
+
* result. Stamped on the run row at dispatch so the poller can (a) render the parked-node phase label
|
|
211
|
+
* — clamped at display by {@link deriveDeliveryPhase} — and (b) surface the WHOLE instruction as the
|
|
212
|
+
* Tasks-inbox "Decision context" (issue #813). Storing the full prompt (not a clamped first line) is
|
|
213
|
+
* what lets the operator read the entire instruction, and lets a URL embedded in the prompt reach the
|
|
214
|
+
* task's clickable link. Falls back to the node id when a human node declares no prompt. */
|
|
178
215
|
export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<string, string> {
|
|
179
216
|
const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
|
|
180
217
|
const labels: Record<string, string> = {};
|
|
181
218
|
for (const stop of compiled.humanNodes) {
|
|
182
219
|
const element = elementByNodeId.get(stop.nodeId);
|
|
183
220
|
if (element === undefined) continue;
|
|
184
|
-
|
|
221
|
+
// Redact the prompt with the SAME display-safe helper `nodeDisplay` uses BEFORE stamping it: this
|
|
222
|
+
// label is denormalised into the run row's `human_labels` and surfaced to the operator as the parked
|
|
223
|
+
// task's Decision context in the Tasks inbox, so a URL credential in a human prompt (`//user:pass@…`)
|
|
224
|
+
// must be stripped here too — else it is persisted and shown unredacted even though the BPMN display
|
|
225
|
+
// path redacts it. The RAW prompt still reaches the runtime user task unmodified (issue #778 review).
|
|
226
|
+
// The FULL redacted prompt is stored (not a clamped first line) so the Tasks "Decision context" can
|
|
227
|
+
// show the whole instruction and surface an embedded link; the phase pill clamps it at display via
|
|
228
|
+
// `firstLine` in `deriveDeliveryPhase` below (issue #813).
|
|
229
|
+
const safePrompt = typeof stop.prompt === "string" ? redactFreeText(stop.prompt).trim() : "";
|
|
230
|
+
labels[humanTaskElementId(element)] = safePrompt || stop.nodeId;
|
|
185
231
|
}
|
|
186
232
|
return labels;
|
|
187
233
|
}
|
|
@@ -227,7 +273,13 @@ export function deriveDeliveryPhase(
|
|
|
227
273
|
.filter((id): id is string => typeof id === "string" && isDeliveryHumanElement(id))
|
|
228
274
|
.sort()[0];
|
|
229
275
|
if (parkedOn !== undefined) {
|
|
230
|
-
|
|
276
|
+
// Clamp the compact phase label to the instruction's first line (the full prompt is stored in
|
|
277
|
+
// `human_labels` for the Tasks "Decision context" — issue #813 — but the phase pill wants a short
|
|
278
|
+
// one-liner). Use the SAME exact-then-`__esc`-stripped lookup as `deliveryHumanContext*` so a
|
|
279
|
+
// parked bounded-timeout twin (`…__esc`, whose exact id is never stamped) resolves to its base
|
|
280
|
+
// node's label instead of showing the raw element id. A missing label falls back to the raw id.
|
|
281
|
+
const stored = humanLabels[parkedOn] ?? humanLabels[parkedOn.replace(/__esc$/, "")];
|
|
282
|
+
const label = firstLine(stored) || parkedOn;
|
|
231
283
|
return { status: "running", phase: `Parked on human node: ${label}`, phase_node_id: parkedOn };
|
|
232
284
|
}
|
|
233
285
|
return { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null };
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
bindHumanEmits,
|
|
12
12
|
DELIVERY_HUMAN_ELEMENT,
|
|
13
13
|
deliveryHumanContextQuestion,
|
|
14
|
+
deliveryHumanContextUrl,
|
|
14
15
|
deriveHumanCategory,
|
|
16
|
+
firstHttpUrl,
|
|
15
17
|
GENERIC_HUMAN_FORM,
|
|
16
18
|
HUMAN_ACK_FORM,
|
|
17
19
|
HUMAN_PUBLISH_FORM,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
renderHumanEmitBrief,
|
|
22
24
|
resolveHumanForm,
|
|
23
25
|
} from "./deliveryHuman.ts";
|
|
26
|
+
import { redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
24
27
|
import { ESCALATION_TASK_ELEMENTS } from "./agentCompletion.ts";
|
|
25
28
|
import { USER_TASK_KIND_LABELS } from "./userTasks.ts";
|
|
26
29
|
|
|
@@ -322,8 +325,82 @@ test("deliveryHumanContextQuestion: derives the node instruction from human_labe
|
|
|
322
325
|
);
|
|
323
326
|
});
|
|
324
327
|
|
|
325
|
-
test("
|
|
326
|
-
|
|
328
|
+
test("firstHttpUrl: extracts the first http(s) URL and trims trailing prose punctuation (#813)", () => {
|
|
329
|
+
assertEquals(firstHttpUrl("see https://github.com/o/r/pull/5 for details"), "https://github.com/o/r/pull/5");
|
|
330
|
+
// Trailing sentence punctuation / closing bracket is stripped so the link resolves.
|
|
331
|
+
assertEquals(firstHttpUrl("Review the PR (https://github.com/o/r/pull/5)."), "https://github.com/o/r/pull/5");
|
|
332
|
+
assertEquals(firstHttpUrl("http://example.test/a,"), "http://example.test/a");
|
|
333
|
+
// A balanced closing bracket is part of the URL and preserved (not truncated into a broken link).
|
|
334
|
+
assertEquals(
|
|
335
|
+
firstHttpUrl("see https://en.wikipedia.org/wiki/Nano_(technology) here"),
|
|
336
|
+
"https://en.wikipedia.org/wiki/Nano_(technology)",
|
|
337
|
+
);
|
|
338
|
+
// A balanced bracket followed by prose punctuation: strip only the punctuation, keep the pair.
|
|
339
|
+
assertEquals(
|
|
340
|
+
firstHttpUrl("ref https://en.wikipedia.org/wiki/Nano_(technology)."),
|
|
341
|
+
"https://en.wikipedia.org/wiki/Nano_(technology)",
|
|
342
|
+
);
|
|
343
|
+
// An UNBALANCED wrapping bracket is prose and still stripped.
|
|
344
|
+
assertEquals(firstHttpUrl("(https://github.com/o/r/pull/9)"), "https://github.com/o/r/pull/9");
|
|
345
|
+
// Multiple/nested UNBALANCED trailing closers + prose punctuation are all peeled off in one linear
|
|
346
|
+
// pass (guards the O(n) trim refactor — #813 review), while an inner balanced pair is preserved.
|
|
347
|
+
assertEquals(
|
|
348
|
+
firstHttpUrl("(see [https://en.wikipedia.org/wiki/Nano_(technology)])."),
|
|
349
|
+
"https://en.wikipedia.org/wiki/Nano_(technology)",
|
|
350
|
+
);
|
|
351
|
+
assertEquals(firstHttpUrl("look: {[(https://example.test/a)]}"), "https://example.test/a");
|
|
352
|
+
// An apostrophe is a valid URL sub-delimiter and preserved WITHIN the URL (not truncated at it)…
|
|
353
|
+
assertEquals(
|
|
354
|
+
firstHttpUrl("see https://en.wikipedia.org/wiki/It's_a_Wonderful_Life here"),
|
|
355
|
+
"https://en.wikipedia.org/wiki/It's_a_Wonderful_Life",
|
|
356
|
+
);
|
|
357
|
+
// …while a TRAILING apostrophe (a prose quote closer) is still trimmed.
|
|
358
|
+
assertEquals(firstHttpUrl("the link 'https://example.test/a' works"), "https://example.test/a");
|
|
359
|
+
// Scheme match is case-insensitive (RFC 3986 schemes are case-insensitive).
|
|
360
|
+
assertEquals(firstHttpUrl("see HTTPS://github.com/o/r/pull/5 now"), "HTTPS://github.com/o/r/pull/5");
|
|
361
|
+
assertEquals(firstHttpUrl("Http://example.test/b"), "Http://example.test/b");
|
|
362
|
+
// First wins when several are present.
|
|
363
|
+
assertEquals(firstHttpUrl("a https://one.test b https://two.test"), "https://one.test");
|
|
364
|
+
// No URL / non-string → null (preserves today's linkless behaviour).
|
|
365
|
+
assertEquals(firstHttpUrl("merge the PR opened by adopt-console"), null);
|
|
366
|
+
assertEquals(firstHttpUrl(undefined), null);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("deliveryHumanContextUrl: lifts a prompt-embedded URL onto the task link (base + __esc twin), else null (#813)", () => {
|
|
370
|
+
const labels = {
|
|
371
|
+
"delivery-human-task__n7": "Review the adopt PR at https://github.com/nanobpm/nano-ide/pull/42 and merge it.",
|
|
372
|
+
"delivery-human-task__n8": "Confirm CI is green, then merge — no link here.",
|
|
373
|
+
};
|
|
374
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7"), "https://github.com/nanobpm/nano-ide/pull/42");
|
|
375
|
+
// The bounded-timeout twin resolves the same node instruction.
|
|
376
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7__esc"), "https://github.com/nanobpm/nano-ide/pull/42");
|
|
377
|
+
// A node whose instruction names no URL yields null (the linkless default).
|
|
378
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n8"), null);
|
|
379
|
+
assertEquals(deliveryHumanContextUrl(undefined, "delivery-human-task__n1"), null);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("deliveryHumanContextUrl: declines to link a redaction-altered URL, links a credential-free one (#814)", () => {
|
|
383
|
+
// The stored `human_labels` are display-REDACTED via the SAME `redactFreeText` `buildHumanLabels` uses,
|
|
384
|
+
// so build the labels through it to mirror production exactly.
|
|
385
|
+
const withQuery = redactFreeText("Review the checks tab at https://example.test/o/r/pull/5?tab=checks now");
|
|
386
|
+
const withCred = redactFreeText("Open https://user:pass@example.test/o/r/pull/5 to review");
|
|
387
|
+
const withFragment = redactFreeText("Jump to https://example.test/o/r/pull/5#files section");
|
|
388
|
+
const clean = redactFreeText("Review the PR at https://example.test/o/r/pull/5 and merge it");
|
|
389
|
+
const labels = {
|
|
390
|
+
"delivery-human-task__q": withQuery,
|
|
391
|
+
"delivery-human-task__c": withCred,
|
|
392
|
+
"delivery-human-task__f": withFragment,
|
|
393
|
+
"delivery-human-task__ok": clean,
|
|
394
|
+
};
|
|
395
|
+
// A redacted query/userinfo/fragment would publish a DIFFERENT, broken link → decline (null).
|
|
396
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__q"), null);
|
|
397
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__c"), null);
|
|
398
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__f"), null);
|
|
399
|
+
// A credential-free URL is untouched by redaction and links faithfully.
|
|
400
|
+
assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__ok"), "https://example.test/o/r/pull/5");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => { // A parked human step must never render a blank Decision context — an untracked/absent label still
|
|
327
404
|
// yields actionable guidance rather than null (which would leave the panel empty, issue #772).
|
|
328
405
|
assertEquals(
|
|
329
406
|
deliveryHumanContextQuestion({}, "delivery-human-task__n1"),
|