@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
|
@@ -6,15 +6,27 @@
|
|
|
6
6
|
// graph's side effects. These tests drive it against an in-memory app/data/engine faithful to the run
|
|
7
7
|
// aggregate's PRIMARY KEY fence and the guarded raw UPDATE the claim issues.
|
|
8
8
|
import { test } from "node:test";
|
|
9
|
-
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
10
10
|
import type { AppApi } from "@nanobpm/urban";
|
|
11
11
|
import { dispatchDeliveryGraphRun } from "./deliveryGraphDispatch.ts";
|
|
12
12
|
import { noopLog } from "../test/log.ts";
|
|
13
13
|
|
|
14
|
-
function makeApp() {
|
|
14
|
+
function makeApp(opts: { failIdentityWrite?: boolean } = {}) {
|
|
15
15
|
const tables = new Map<string, Record<string, unknown>[]>();
|
|
16
16
|
const started: { processDefinitionId: string }[] = [];
|
|
17
17
|
const table = (name: string, key: string) => {
|
|
18
|
+
if (opts.failIdentityWrite && name === "delivery_graph_run_identity") {
|
|
19
|
+
// Simulate a transient side-table write error (e.g. a SQLite `disk I/O error`) — NOT a UNIQUE
|
|
20
|
+
// collision, so `upsertRunIdentity` rethrows it rather than folding it into an UPDATE.
|
|
21
|
+
return {
|
|
22
|
+
get: () => Promise.resolve(null),
|
|
23
|
+
find: () => Promise.resolve([]),
|
|
24
|
+
all: () => Promise.resolve([]),
|
|
25
|
+
insert: () => Promise.reject(new Error("disk I/O error")),
|
|
26
|
+
update: () => Promise.reject(new Error("disk I/O error")),
|
|
27
|
+
delete: () => Promise.resolve(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
18
30
|
const rows =
|
|
19
31
|
tables.get(name) ??
|
|
20
32
|
(() => {
|
|
@@ -101,6 +113,39 @@ test("dispatchDeliveryGraphRun: a human-only graph launches straight away (runni
|
|
|
101
113
|
assertEquals(runs()[0].status, "running");
|
|
102
114
|
});
|
|
103
115
|
|
|
116
|
+
test("#778 dispatchDeliveryGraphRun: a post-claim identity-write failure flips the claimed run to `failed`, not a stranded null-process_key `running` — so a phantom run never short-circuits a later dispatch (thread deliveryGraphDispatch.ts:229)", async () => {
|
|
117
|
+
const { app, started, runs } = makeApp({ failIdentityWrite: true });
|
|
118
|
+
// The identity side-table write throws AFTER the durable `running` claim; dispatch must propagate it.
|
|
119
|
+
await assertRejects(() => dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true }), Error, "disk I/O error");
|
|
120
|
+
// The claimed row was flipped to `failed` (never left `running` with a null process key), so a later
|
|
121
|
+
// same-key dispatch does NOT short-circuit onto a phantom run — it can retry cleanly.
|
|
122
|
+
assertEquals(runs().length, 1);
|
|
123
|
+
assertEquals(runs()[0].status, "failed");
|
|
124
|
+
assertEquals(runs()[0].process_key ?? null, null);
|
|
125
|
+
// The side effect never launched.
|
|
126
|
+
assertEquals(started.length, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("#778 dispatchDeliveryGraphRun: a still-running run whose identity row is MISSING (null, pre-migration) short-circuits with identityConfirmed:false instead of throwing (thread deliveryGraphDispatch.ts:175)", async () => {
|
|
130
|
+
const { app, started } = makeApp();
|
|
131
|
+
const first = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
|
|
132
|
+
assert(first.ok);
|
|
133
|
+
if (!first.ok) return;
|
|
134
|
+
assertEquals(first.status, "running");
|
|
135
|
+
// Simulate a run launched BEFORE the identity side-table existed: drop its identity row so
|
|
136
|
+
// `identities.get(runKey)` resolves to null (the test table — like the real `table.get()` — returns
|
|
137
|
+
// null, NOT undefined, for a missing row).
|
|
138
|
+
await app.data.table("delivery_graph_run_identity", "run_key").delete(first.runKey);
|
|
139
|
+
// A second same-key dispatch hits the already-running short-circuit; the null identity row must not
|
|
140
|
+
// throw while building the response — it is unprovable, so identityConfirmed is false.
|
|
141
|
+
const second = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
|
|
142
|
+
assert(second.ok);
|
|
143
|
+
if (!second.ok) return;
|
|
144
|
+
assertEquals(second.alreadyRunning, true);
|
|
145
|
+
assertEquals(second.identityConfirmed, false);
|
|
146
|
+
assertEquals(started.length, 1); // no double launch
|
|
147
|
+
});
|
|
148
|
+
|
|
104
149
|
test("dispatchDeliveryGraphRun: a side-effecting graph dispatches with NO approval token — the operator seam IS the approval", async () => {
|
|
105
150
|
const { app, started, runs } = makeApp();
|
|
106
151
|
const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
|
|
@@ -130,6 +175,35 @@ test("dispatchDeliveryGraphRun: an explicit idempotency key forces a distinct ru
|
|
|
130
175
|
assertEquals(started.length, 2);
|
|
131
176
|
});
|
|
132
177
|
|
|
178
|
+
test("#778 dispatchDeliveryGraphRun: a recompiled digest that drifts from `expectedDigest` is REFUSED before any launch — no run, no engine instance (thread :1605)", async () => {
|
|
179
|
+
// A staged proposal is keyed by its stage-time digest. If the compiler ships a digest-affecting change
|
|
180
|
+
// (this PR adds labels/`<bpmn:documentation>`), the stored graph recompiles to a DIFFERENT digest. The
|
|
181
|
+
// door hands the stage-time digest as `expectedDigest`; a mismatch must refuse cleanly BEFORE the
|
|
182
|
+
// durable launch claim, so we never strand a live run against a proposal left `staged`.
|
|
183
|
+
const { app, started, runs } = makeApp();
|
|
184
|
+
const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true, expectedDigest: "sha256-stale-address" });
|
|
185
|
+
assertEquals(res.ok, false);
|
|
186
|
+
if (res.ok) return;
|
|
187
|
+
assert(res.errors.some((e) => e.path === "digest"), "the refusal is a digest-address error");
|
|
188
|
+
assertEquals(started.length, 0); // nothing launched
|
|
189
|
+
assertEquals(runs().length, 0); // no run row claimed
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("#778 dispatchDeliveryGraphRun: a matching `expectedDigest` dispatches normally (the same-compiler no-op path)", async () => {
|
|
193
|
+
// Compute the graph's true digest first, then pass it as `expectedDigest` — the normal deterministic
|
|
194
|
+
// recompile matches, so the address check is a no-op and the run launches.
|
|
195
|
+
const { app, started } = makeApp();
|
|
196
|
+
const probe = await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { repoless: true });
|
|
197
|
+
assert(probe.ok);
|
|
198
|
+
if (!probe.ok) return;
|
|
199
|
+
const { app: app2, started: started2 } = makeApp();
|
|
200
|
+
const res = await dispatchDeliveryGraphRun(app2, HUMAN_ONLY, { repoless: true, expectedDigest: probe.digest });
|
|
201
|
+
assertEquals(res.ok, true);
|
|
202
|
+
if (!res.ok) return;
|
|
203
|
+
assertEquals(res.status, "running");
|
|
204
|
+
assertEquals(started2.length, 1);
|
|
205
|
+
});
|
|
206
|
+
|
|
133
207
|
test("dispatchDeliveryGraphRun: a malformed graph → ok:false with path-qualified errors, nothing launched", async () => {
|
|
134
208
|
const { app, started } = makeApp();
|
|
135
209
|
const res = await dispatchDeliveryGraphRun(app, { name: "empty", nodes: [] });
|
|
@@ -141,3 +215,45 @@ test("dispatchDeliveryGraphRun: a malformed graph → ok:false with path-qualifi
|
|
|
141
215
|
}
|
|
142
216
|
assertEquals(started.length, 0);
|
|
143
217
|
});
|
|
218
|
+
|
|
219
|
+
// Option C (issue #778): a graph carrying redacted-away credential material has a LOSSY content digest
|
|
220
|
+
// (a sibling differing only in the secret would collide), so a keyless dispatch — which defaults the
|
|
221
|
+
// run identity to that digest — is refused; an explicit `idempotencyKey` disambiguates it.
|
|
222
|
+
const SECRET_BEARING = {
|
|
223
|
+
name: "deploy with a secret",
|
|
224
|
+
nodes: [{ id: "deploy", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:s3cr3t@host.example/repo" } }],
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
test("dispatchDeliveryGraphRun: a secret-bearing graph dispatched KEYLESS is refused, points at idempotencyKey, launches nothing", async () => {
|
|
228
|
+
const { app, started, runs } = makeApp();
|
|
229
|
+
const res = await dispatchDeliveryGraphRun(app, SECRET_BEARING, { repoless: true });
|
|
230
|
+
assertEquals(res.ok, false);
|
|
231
|
+
if (res.ok) return;
|
|
232
|
+
assertEquals(res.errors.length, 1);
|
|
233
|
+
assertEquals(res.errors[0].path, "idempotencyKey");
|
|
234
|
+
assert(res.errors[0].message.includes("idempotencyKey"));
|
|
235
|
+
assertEquals(started.length, 0);
|
|
236
|
+
assertEquals(runs().length, 0); // nothing claimed
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("dispatchDeliveryGraphRun: the SAME secret-bearing graph WITH an explicit idempotencyKey launches", async () => {
|
|
240
|
+
const { app, started, runs } = makeApp();
|
|
241
|
+
const res = await dispatchDeliveryGraphRun(app, SECRET_BEARING, { runKey: "deploy-2024-06-a", repoless: true });
|
|
242
|
+
assertEquals(res.ok, true);
|
|
243
|
+
if (!res.ok) return;
|
|
244
|
+
assertEquals(res.status, "running");
|
|
245
|
+
assertEquals(res.runKey, "deploy-2024-06-a");
|
|
246
|
+
assertEquals(started.length, 1);
|
|
247
|
+
assertEquals(runs()[0].run_key, "deploy-2024-06-a");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("dispatchDeliveryGraphRun: two credential-differing secret graphs with DISTINCT keys get distinct runs (no collision)", async () => {
|
|
251
|
+
const { app, started, runs } = makeApp();
|
|
252
|
+
const graphA = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:AAA@host.example/repo" } }] };
|
|
253
|
+
const graphB = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:BBB@host.example/repo" } }] };
|
|
254
|
+
const a = await dispatchDeliveryGraphRun(app, graphA, { runKey: "run-a", repoless: true });
|
|
255
|
+
const b = await dispatchDeliveryGraphRun(app, graphB, { runKey: "run-b", repoless: true });
|
|
256
|
+
assert(a.ok && b.ok);
|
|
257
|
+
assertEquals(started.length, 2);
|
|
258
|
+
assertEquals(new Set(runs().map((r) => r.run_key)).size, 2);
|
|
259
|
+
});
|
|
@@ -11,16 +11,19 @@
|
|
|
11
11
|
// idempotency short-circuit, so a double-click or a re-dispatch never double-launches a graph's side
|
|
12
12
|
// effects.
|
|
13
13
|
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
14
15
|
import type { AppApi } from "@nanobpm/urban";
|
|
15
16
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
16
|
-
import { validateDeliveryGraph } from "./deliveryGraph.ts";
|
|
17
|
-
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
17
|
+
import { canonicalJson, validateDeliveryGraph } from "./deliveryGraph.ts";
|
|
18
|
+
import { compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets } from "./deliveryGraphCompiler.ts";
|
|
18
19
|
import {
|
|
19
20
|
buildDeliveryGraphRunRow,
|
|
20
21
|
buildHumanLabels,
|
|
21
22
|
claimRunForLaunch,
|
|
22
23
|
computeRunKey,
|
|
23
24
|
DELIVERY_PHASE,
|
|
25
|
+
type DeliveryGraphRunIdentity,
|
|
26
|
+
deliveryGraphRunIdentities,
|
|
24
27
|
deliveryGraphRuns,
|
|
25
28
|
} from "./deliveryGraphRun.ts";
|
|
26
29
|
import type { DeliveryRunTimeouts } from "./deliveryRunner.ts";
|
|
@@ -37,11 +40,47 @@ export type DispatchDeliveryGraphResult =
|
|
|
37
40
|
digest: string;
|
|
38
41
|
sideEffecting: boolean;
|
|
39
42
|
alreadyRunning: boolean;
|
|
43
|
+
/** Whether the returned run is PROVABLY this exact graph. Always true for a fresh launch. For an
|
|
44
|
+
* `alreadyRunning` short-circuit it is true iff the running row's persisted lossless
|
|
45
|
+
* {@link deliveryGraphIdentityFingerprint} matches the incoming graph's — i.e. a same-payload
|
|
46
|
+
* retry, not a credential-different graph re-staged under the same explicit `idempotencyKey` (and
|
|
47
|
+
* false for a pre-migration-113 row whose fingerprint is NULL, an unprovable identity). The
|
|
48
|
+
* dispatch door refuses a secret-bearing short-circuit only when this is false (issue #778 review
|
|
49
|
+
* — thread dispatchDeliveryGraph.ts:332). */
|
|
50
|
+
identityConfirmed: boolean;
|
|
40
51
|
processInstanceKey?: string;
|
|
41
52
|
processDefinitionId?: string;
|
|
42
53
|
}
|
|
43
54
|
| { ok: false; errors: { path: string; message: string }[] };
|
|
44
55
|
|
|
56
|
+
/** The LOSSLESS content-identity fingerprint of a graph — `sha256(digest \0
|
|
57
|
+
* canonicalJson(digestInvisibleRawValues(graph)))`. The `digest` is content-addressed over the REDACTED
|
|
58
|
+
* `semanticBpmn`, so it alone collapses secret-differing graphs onto one identity; folding in
|
|
59
|
+
* {@link digestInvisibleRawValues} (the exact raw content the digest CANNOT see) restores a FULL identity
|
|
60
|
+
* that distinguishes them — the SAME `(digest, invisible-values)` pair `stableProposalRunKey` keys the
|
|
61
|
+
* keyless run key on. Persisted on the run row so an explicit-`idempotencyKey` short-circuit can prove
|
|
62
|
+
* the running run is THIS exact graph (issue #778 review — thread dispatchDeliveryGraph.ts:332). */
|
|
63
|
+
export function deliveryGraphIdentityFingerprint(graph: DeliveryGraph, digest: string): string {
|
|
64
|
+
const invisible = canonicalJson(digestInvisibleRawValues(graph));
|
|
65
|
+
return createHash("sha256").update(`${digest}\u0000${invisible}`).digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Persist a run's lossless identity fingerprint (upsert on `run_key`). A relaunch off a persisted row
|
|
69
|
+
* under an explicit `idempotencyKey` may carry a DIFFERENT graph, so a PRIMARY-KEY fence collision folds
|
|
70
|
+
* into an UPDATE that overwrites the stored fingerprint rather than surfacing — the same intended
|
|
71
|
+
* outcome as the insert branch (mirrors `DurableResumeRegistry.recordEnrolment`). */
|
|
72
|
+
async function upsertRunIdentity(
|
|
73
|
+
identities: ReturnType<typeof deliveryGraphRunIdentities>,
|
|
74
|
+
row: DeliveryGraphRunIdentity,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
try {
|
|
77
|
+
await identities.insert(row);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (!(err instanceof Error) || !/UNIQUE constraint failed/i.test(err.message)) throw err;
|
|
80
|
+
await identities.update(row.run_key, { graph_fingerprint: row.graph_fingerprint });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
45
84
|
/** Dispatch a delivery graph as a running engine-native process — the operator action. Re-validates
|
|
46
85
|
* and re-compiles the (already-staged) graph to derive its content digest + run-row shape, then
|
|
47
86
|
* launches it through the durable at-most-once fence. Idempotent: a re-dispatch of an already-running
|
|
@@ -49,7 +88,7 @@ export type DispatchDeliveryGraphResult =
|
|
|
49
88
|
export async function dispatchDeliveryGraphRun(
|
|
50
89
|
app: Pick<AppApi, "data" | "engine" | "log">,
|
|
51
90
|
graph: unknown,
|
|
52
|
-
options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null; repoless?: boolean } & DeliveryRunTimeouts = {},
|
|
91
|
+
options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null; repoless?: boolean; expectedDigest?: string | null } & DeliveryRunTimeouts = {},
|
|
53
92
|
): Promise<DispatchDeliveryGraphResult> {
|
|
54
93
|
const validationErrors = validateDeliveryGraph(graph);
|
|
55
94
|
if (validationErrors.length > 0) {
|
|
@@ -64,12 +103,82 @@ export async function dispatchDeliveryGraphRun(
|
|
|
64
103
|
}
|
|
65
104
|
|
|
66
105
|
const digest = deliveryGraphDigest(compiled.semanticBpmn);
|
|
106
|
+
// ADDRESS-BEFORE-LAUNCH (issue #778 review — thread deliveryGraphCompiler.ts:1605): a staged proposal
|
|
107
|
+
// is keyed by the digest computed AT STAGE TIME. The labels/`<bpmn:documentation>` this PR adds are
|
|
108
|
+
// covered by that content digest, so after this compiler ships every proposal staged by the PREVIOUS
|
|
109
|
+
// compiler RECOMPILES to a different digest. The caller (the dispatch door) hands us that stage-time
|
|
110
|
+
// digest as `expectedDigest`; if the fresh recompile no longer matches it, the stored graph pre-dates
|
|
111
|
+
// this compiler and its content address has drifted. REFUSE here — BEFORE the durable launch claim —
|
|
112
|
+
// so we never launch the run and THEN report a post-launch 409 mismatch, which stranded a live run
|
|
113
|
+
// against a proposal left `staged`. A clean refusal leaves nothing running; the operator recompiles to
|
|
114
|
+
// re-stage under the current compiler. (A normal, same-compiler dispatch recompiles deterministically
|
|
115
|
+
// to the same digest, so this is a no-op there.)
|
|
116
|
+
const expectedDigest = typeof options.expectedDigest === "string" && options.expectedDigest.trim() !== "" ? options.expectedDigest.trim() : "";
|
|
117
|
+
if (expectedDigest !== "" && digest !== expectedDigest) {
|
|
118
|
+
app.log.warn("dispatch-delivery-graph refused: recompiled digest drifted from the staged address", { expectedDigest, digest });
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
errors: [
|
|
122
|
+
{
|
|
123
|
+
path: "digest",
|
|
124
|
+
message:
|
|
125
|
+
`this staged proposal was compiled under a different compiler version — it now recompiles to digest ${digest}, ` +
|
|
126
|
+
`not the staged ${expectedDigest} its content address was pinned to, so it was NOT launched; recompile the graph to ` +
|
|
127
|
+
"re-stage it under the current compiler before dispatching (issue #778)",
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const explicitKey = typeof options.runKey === "string" && options.runKey.trim() !== "";
|
|
133
|
+
// Option C (issue #778): the graph's identity is content-addressed over the REDACTED `semanticBpmn`
|
|
134
|
+
// (issue #716), so two graphs differing ONLY in a redacted-away credential (a URL secret/`?query`/
|
|
135
|
+
// `#fragment`, a `command` target, a `verifyCommand`/`bodyIncludes`/`stdoutIncludes` match secret, or
|
|
136
|
+
// a free-form connector `payload`) share one digest — and a KEYLESS dispatch defaults `runKey` to that
|
|
137
|
+
// digest, silently collapsing the second onto the first's still-running instance and reusing its
|
|
138
|
+
// config. We accept the collision as by-design but REQUIRE an explicit `idempotencyKey` to
|
|
139
|
+
// disambiguate such a secret-bearing graph, rather than launch it under an ambiguous identity.
|
|
140
|
+
if (!explicitKey && graphCarriesRedactedSecrets(typedGraph)) {
|
|
141
|
+
app.log.warn("dispatch-delivery-graph refused: secret-bearing graph needs an explicit idempotencyKey", { digest });
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
errors: [
|
|
145
|
+
{
|
|
146
|
+
path: "idempotencyKey",
|
|
147
|
+
message:
|
|
148
|
+
"this graph carries runtime values that redaction/normalisation strips from its content-addressed " +
|
|
149
|
+
"digest — a URL credential/`?query`/`#fragment`, a `command` target, a `verifyCommand`/`bodyIncludes`/" +
|
|
150
|
+
"`stdoutIncludes` match secret, a free-form connector `payload`, an untrimmed-whitespace difference in " +
|
|
151
|
+
"an agent/human `prompt`, or a value that loses characters to XML sanitisation — so its digest cannot " +
|
|
152
|
+
"distinguish it from another graph differing only in those values; supply an explicit `idempotencyKey` " +
|
|
153
|
+
"to dispatch it (issue #778)",
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
67
158
|
const runKey = computeRunKey(options.runKey, digest);
|
|
68
159
|
const sideEffecting = compiled.sideEffects.length > 0;
|
|
69
160
|
const explicitTitle = typeof options.title === "string" && options.title.trim() !== "" ? options.title.trim() : "";
|
|
70
161
|
const graphName = typeof typedGraph.name === "string" && typedGraph.name.trim() !== "" ? typedGraph.name.trim() : "";
|
|
71
162
|
const title = explicitTitle || graphName || runKey;
|
|
72
163
|
const runs = deliveryGraphRuns(app.data);
|
|
164
|
+
const identities = deliveryGraphRunIdentities(app.data);
|
|
165
|
+
// The lossless identity of THIS graph — persisted at launch in the `delivery_graph_run_identity` side
|
|
166
|
+
// table, compared on a short-circuit so the door can distinguish a same-payload retry from a
|
|
167
|
+
// credential-different graph re-staged under the same explicit key (issue #778 review — thread
|
|
168
|
+
// dispatchDeliveryGraph.ts:332).
|
|
169
|
+
const graphFingerprint = deliveryGraphIdentityFingerprint(typedGraph, digest);
|
|
170
|
+
// Whether the run at `runKey` is PROVABLY this exact graph: a stored identity row that MATCHES ours. A
|
|
171
|
+
// MISSING row (a run launched before this table existed, or a not-yet-stamped concurrent claim) is
|
|
172
|
+
// unprovable → false, so the door refuses a secret-bearing short-circuit (the safe pre-existing 409).
|
|
173
|
+
const identityConfirmed = async (): Promise<boolean> => {
|
|
174
|
+
const identity = await identities.get(runKey);
|
|
175
|
+
// `table.get()` resolves to `null` (NOT `undefined`) for a MISSING row — a run launched before this
|
|
176
|
+
// side table existed, or a not-yet-stamped concurrent claim. A bare `!== undefined` admits that
|
|
177
|
+
// `null` and then throws on `.graph_fingerprint`; `!= null` treats both absences as unprovable →
|
|
178
|
+
// false, so the door reaches the safe pre-existing 409 instead of an incident (issue #778 review —
|
|
179
|
+
// thread deliveryGraphDispatch.ts:175).
|
|
180
|
+
return identity != null && identity.graph_fingerprint === graphFingerprint;
|
|
181
|
+
};
|
|
73
182
|
|
|
74
183
|
// Idempotency short-circuit — a re-dispatch onto a still-running run does NOT double-launch.
|
|
75
184
|
const existing = await runs.get(runKey);
|
|
@@ -82,6 +191,7 @@ export async function dispatchDeliveryGraphRun(
|
|
|
82
191
|
digest: existing.digest,
|
|
83
192
|
sideEffecting: existing.side_effecting === 1,
|
|
84
193
|
alreadyRunning: true,
|
|
194
|
+
identityConfirmed: await identityConfirmed(),
|
|
85
195
|
processInstanceKey: existing.process_key ?? undefined,
|
|
86
196
|
processDefinitionId: existing.process_definition_id ?? undefined,
|
|
87
197
|
};
|
|
@@ -113,22 +223,39 @@ export async function dispatchDeliveryGraphRun(
|
|
|
113
223
|
digest: won?.digest ?? digest,
|
|
114
224
|
sideEffecting: won ? won.side_effecting === 1 : sideEffecting,
|
|
115
225
|
alreadyRunning: true,
|
|
226
|
+
identityConfirmed: await identityConfirmed(),
|
|
116
227
|
processInstanceKey: won?.process_key ?? undefined,
|
|
117
228
|
processDefinitionId: won?.process_definition_id ?? undefined,
|
|
118
229
|
};
|
|
119
230
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Launch — deploy + start the compiled definition. On ANY launch failure flip the claimed row to
|
|
126
|
-
// `failed` so no null-process_key `running` row is ever stranded.
|
|
231
|
+
// On ANY post-claim failure — the identity/patch persistence just below OR the launch further down —
|
|
232
|
+
// flip the claimed row to `failed` so no null-process_key `running` row is ever stranded and later
|
|
233
|
+
// dispatches never short-circuit onto a phantom run.
|
|
127
234
|
const markClaimFailed = async () => {
|
|
128
235
|
const failed = buildDeliveryGraphRunRow({ ...rowBase, status: "failed", phase: DELIVERY_PHASE.FAILED, processKey: null });
|
|
129
236
|
const { run_key, created_at, ...patch } = failed;
|
|
130
237
|
await runs.update(runKey, patch);
|
|
131
238
|
};
|
|
239
|
+
// The claim is ours — persist THIS run's lossless identity so a later same-key short-circuit can prove
|
|
240
|
+
// the running run is this exact graph. Upsert: a relaunch off a persisted (e.g. failed) row under an
|
|
241
|
+
// explicit key may carry a different graph, so overwrite the stored fingerprint. This runs AFTER the
|
|
242
|
+
// durable `running` claim but is a SEPARATE write, so a transient failure here would otherwise strand
|
|
243
|
+
// the claimed row as `running` with a null process key — later same-key dispatches would then
|
|
244
|
+
// short-circuit onto that phantom run. Route the failure through the same claim-failure cleanup before
|
|
245
|
+
// rethrowing so the phantom self-heals (issue #778 review — thread deliveryGraphDispatch.ts:229).
|
|
246
|
+
try {
|
|
247
|
+
await upsertRunIdentity(identities, { run_key: runKey, graph_fingerprint: graphFingerprint, created_at: claim.created_at });
|
|
248
|
+
if (existing) {
|
|
249
|
+
const { run_key, created_at, ...patch } = claim;
|
|
250
|
+
await runs.update(runKey, patch);
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
await markClaimFailed();
|
|
254
|
+
app.log.error("dispatch-delivery-graph identity persistence threw", { runKey });
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Launch — deploy + start the compiled definition.
|
|
132
259
|
let launched: Awaited<ReturnType<typeof runDeliveryGraph>>;
|
|
133
260
|
try {
|
|
134
261
|
// Thread the operator-supplied run-level timeouts (#505) so a submission override reaches every
|
|
@@ -180,6 +307,7 @@ export async function dispatchDeliveryGraphRun(
|
|
|
180
307
|
digest,
|
|
181
308
|
sideEffecting,
|
|
182
309
|
alreadyRunning: false,
|
|
310
|
+
identityConfirmed: true,
|
|
183
311
|
processInstanceKey: launched.handle.processInstanceKey,
|
|
184
312
|
processDefinitionId: launched.handle.processDefinitionId,
|
|
185
313
|
};
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
getStagedProposal,
|
|
18
18
|
isLiveStaged,
|
|
19
19
|
isProposalExpired,
|
|
20
|
+
listStagedProposals,
|
|
20
21
|
markProposalDismissed,
|
|
21
22
|
markProposalDispatched,
|
|
22
23
|
markProposalExpired,
|
|
@@ -157,6 +158,35 @@ test("stageProposal: a re-stage of an EXPIRED digest RE-ANCHORS the TTL so it is
|
|
|
157
158
|
});
|
|
158
159
|
});
|
|
159
160
|
|
|
161
|
+
test("stageProposal: a re-stage derives the TTL from the row COMMITTED at write time, so a stale caller cannot clobber a concurrently re-anchored live proposal back to an expired horizon (issue #778 review — thread :247)", async () => {
|
|
162
|
+
await withData(async (data) => {
|
|
163
|
+
const table = deliveryGraphProposals(data);
|
|
164
|
+
// Model the committed state a CONCURRENT re-stage left behind: digest d1 is LIVE with a FRESH TTL
|
|
165
|
+
// (`expires_at` far in the future) but its `created_at` anchor is old — exactly the row a rival stage
|
|
166
|
+
// produced when it re-anchored the TTL. The stale caller below prepared its write from an EARLIER read
|
|
167
|
+
// and now commits LAST (grabbing the newest `stage_seq`). The pre-transaction snapshot bug recomputed
|
|
168
|
+
// `expires_at` from the stale `created_at` (`created_at + TTL`, long past), so the last writer resurrected
|
|
169
|
+
// an EXPIRED horizon over the fresh one and left the newest-`stage_seq` row non-dispatchable. The TTL must
|
|
170
|
+
// instead be derived from the row as committed AT WRITE TIME, inside the same atomic fence as `stage_seq`.
|
|
171
|
+
const seed = row({ digest: "d1", createdAt: "2020-01-01T00:00:00.000Z" });
|
|
172
|
+
seed.expires_at = "2999-01-01T00:00:00.000Z"; // fresh TTL a concurrent re-anchor committed
|
|
173
|
+
seed.stage_seq = 1;
|
|
174
|
+
await table.insert(seed);
|
|
175
|
+
|
|
176
|
+
await stageProposal(data, row({ digest: "d1", createdAt: "2020-01-01T00:00:00.000Z" }));
|
|
177
|
+
|
|
178
|
+
const after = await table.get("d1");
|
|
179
|
+
assert(after, "the re-staged row must still exist");
|
|
180
|
+
assert(
|
|
181
|
+
!isProposalExpired(after?.expires_at),
|
|
182
|
+
"a re-stage must not clobber a live proposal's fresh TTL with an expired horizon recomputed from a stale created_at snapshot",
|
|
183
|
+
);
|
|
184
|
+
const live = await getStagedProposal(data, "d1");
|
|
185
|
+
assert(live, "the re-staged live proposal remains dispatchable");
|
|
186
|
+
assertEquals(live?.status, "staged");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
160
190
|
test("stageProposal: a new digest for the SAME logical key supersedes the prior staged proposal", async () => {
|
|
161
191
|
await withData(async (data) => {
|
|
162
192
|
await stageProposal(data, row({ digest: "d1" }));
|
|
@@ -241,6 +271,60 @@ test("stageProposal: reconciles to EXACTLY ONE live proposal — an older stage
|
|
|
241
271
|
});
|
|
242
272
|
});
|
|
243
273
|
|
|
274
|
+
test("stageProposal: a RE-STAGE wins a same-millisecond `updated_at` tie over an untouched sibling — the monotonic `stage_seq` reflects the re-stage that a frozen `rowid` does not (issue #778 review — thread :245)", async (t) => {
|
|
275
|
+
await withData(async (data) => {
|
|
276
|
+
const table = deliveryGraphProposals(data);
|
|
277
|
+
// Freeze wall-clock time so every `now()` inside `stageProposal` stamps the SAME `updated_at`,
|
|
278
|
+
// reproducing the same-millisecond concurrent-stage race deterministically (only the `Date` API is
|
|
279
|
+
// mocked, so async DB I/O on real timers is unaffected).
|
|
280
|
+
t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2025-06-01T00:00:00.000Z") });
|
|
281
|
+
// Two staged siblings under logical_key "runbook", both stamped the frozen instant. d2 is staged
|
|
282
|
+
// AFTER d1, so it is (correctly) the live winner and d1 is superseded — `rowid` and `stage_seq` agree
|
|
283
|
+
// so far (insertion order == stage order).
|
|
284
|
+
await stageProposal(data, row({ digest: "d1" }));
|
|
285
|
+
await stageProposal(data, row({ digest: "d2" }));
|
|
286
|
+
assertEquals((await table.get("d1"))?.status, "superseded");
|
|
287
|
+
assertEquals((await table.get("d2"))?.status, "staged");
|
|
288
|
+
// Now RE-STAGE d1 (an UPDATE) at the SAME frozen instant. This is the newest write, so it MUST become
|
|
289
|
+
// the sole live proposal. But a re-stage is an `UPDATE`: d1's `rowid` stays frozen BELOW d2's, so the
|
|
290
|
+
// old `rowid` tie-break wrongly keeps d2 live and leaves the just-re-staged d1 superseded. The
|
|
291
|
+
// monotonic `stage_seq` (reassigned MAX+1 on every write, including this re-stage) advances past d2,
|
|
292
|
+
// so the reconcile correctly makes d1 live and supersedes d2.
|
|
293
|
+
await stageProposal(data, row({ digest: "d1" }));
|
|
294
|
+
assertEquals(
|
|
295
|
+
(await table.get("d1"))?.status,
|
|
296
|
+
"staged",
|
|
297
|
+
"the just-re-staged digest must be the live proposal, even against an untouched higher-rowid sibling",
|
|
298
|
+
);
|
|
299
|
+
assertEquals((await table.get("d2"))?.status, "superseded", "the untouched older sibling is superseded by the re-stage");
|
|
300
|
+
const live = (await table.all()).filter((r) => r.status === "staged" && r.logical_key === "runbook");
|
|
301
|
+
assertEquals(live.length, 1, "exactly one live proposal per logical graph after the re-stage");
|
|
302
|
+
assertEquals(live[0]?.digest, "d1", "the re-staged digest wins the same-`updated_at` tie");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("stageProposal: assigns `stage_seq` ATOMICALLY in the write — a fresh stage is strictly > 0 and monotonic, so there is never a DEFAULT-0 window a concurrent reconcile could mis-order (issue #778 review — thread :237/:239)", async () => {
|
|
307
|
+
await withData(async (data) => {
|
|
308
|
+
const seqOf = async (digest: string): Promise<number> => {
|
|
309
|
+
const rows = await data.open().query<{ stage_seq: number }>(
|
|
310
|
+
`SELECT "stage_seq" FROM "delivery_graph_proposals" WHERE "digest" = ?`,
|
|
311
|
+
[digest],
|
|
312
|
+
);
|
|
313
|
+
return rows.length ? Number(rows[0].stage_seq) : -1;
|
|
314
|
+
};
|
|
315
|
+
// A first stage: the seq is assigned in the INSERT itself (MAX+1), never left at the column DEFAULT 0.
|
|
316
|
+
await stageProposal(data, row({ digest: "d1", logicalKey: "k-a" }));
|
|
317
|
+
const s1 = await seqOf("d1");
|
|
318
|
+
assert(s1 > 0, `a fresh stage carries a strictly-positive stage_seq (atomic, no 0 window): ${s1}`);
|
|
319
|
+
// A second, independent stage is strictly greater — the monotonic secondary ordering.
|
|
320
|
+
await stageProposal(data, row({ digest: "d2", logicalKey: "k-b" }));
|
|
321
|
+
assert((await seqOf("d2")) > s1, "a later stage's seq is strictly greater");
|
|
322
|
+
// A RE-STAGE (UPDATE) also advances the seq — it is reassigned on every write, not frozen like rowid.
|
|
323
|
+
await stageProposal(data, row({ digest: "d1", logicalKey: "k-a" }));
|
|
324
|
+
assert((await seqOf("d1")) > (await seqOf("d2")), "a re-stage advances stage_seq past every prior write");
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
244
328
|
test("stageProposal outcome: `row` reflects the POST-reconcile status — a stage immediately superseded by a newer sibling reports its own row as `superseded`, not the pre-reconcile `staged`", async () => {
|
|
245
329
|
await withData(async (data) => {
|
|
246
330
|
const table = deliveryGraphProposals(data);
|
|
@@ -280,6 +364,88 @@ test("markProposalDispatched: a dispatched proposal is no longer live", async ()
|
|
|
280
364
|
});
|
|
281
365
|
});
|
|
282
366
|
|
|
367
|
+
test("markProposalDispatched: flipping a live `staged` row returns true; an already-terminal (dispatched) row returns false (guarded, mirrors markProposalDismissed/Expired)", async () => {
|
|
368
|
+
await withData(async (data) => {
|
|
369
|
+
await stageProposal(data, row());
|
|
370
|
+
assertEquals(await markProposalDispatched(data, "d1"), true);
|
|
371
|
+
assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
|
|
372
|
+
// A second consume of the same digest (already dispatched) must no-op and report the lost race.
|
|
373
|
+
assertEquals(await markProposalDispatched(data, "d1"), false);
|
|
374
|
+
assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("markProposalDispatched: a concurrent RE-STAGE (bumping stage_seq) after the dispatch snapshot makes the seq-guarded mark a no-op — the newer, never-launched graph stays `staged` (#778 review)", async () => {
|
|
379
|
+
await withData(async (data) => {
|
|
380
|
+
// Dispatch snapshots the proposal's stage_seq when it loads the row.
|
|
381
|
+
await stageProposal(data, row());
|
|
382
|
+
const snapshot = await getStagedProposal(data, "d1");
|
|
383
|
+
assert(snapshot, "the freshly staged proposal is live");
|
|
384
|
+
const snapSeq = snapshot.stage_seq;
|
|
385
|
+
// A concurrent re-stage overwrites the SAME digest in place with a newer graph (two credential-
|
|
386
|
+
// differing graphs share a semantic digest), bumping stage_seq past the dispatch's snapshot.
|
|
387
|
+
await stageProposal(data, row());
|
|
388
|
+
const reStaged = await deliveryGraphProposals(data).get("d1");
|
|
389
|
+
assert(reStaged && reStaged.stage_seq > snapSeq, "the re-stage bumped stage_seq past the snapshot");
|
|
390
|
+
// The dispatch marks with the STALE snapshot seq → the `stage_seq` guard fails → no row flips, so the
|
|
391
|
+
// newer graph is NOT retired to `dispatched`; it stays `staged` for its own dispatch.
|
|
392
|
+
assertEquals(await markProposalDispatched(data, "d1", snapSeq), false);
|
|
393
|
+
assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "staged");
|
|
394
|
+
// Marking with the CURRENT seq consumes it as normal.
|
|
395
|
+
assertEquals(await markProposalDispatched(data, "d1", reStaged.stage_seq), true);
|
|
396
|
+
assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("stageProposal: two concurrent FIRST stages of the SAME digest do NOT primary-key-conflict — the raced insert collapses into an idempotent UPSERT (#778 review — thread :245)", async () => {
|
|
401
|
+
await withData(async (data) => {
|
|
402
|
+
// Reproduce the race deterministically: our stage reads `existing = null` (no row yet), then — in the
|
|
403
|
+
// window BEFORE our transaction commits — a COMPETING first-stage of the same digest lands its row.
|
|
404
|
+
// The old read-then-branch INSERT would make our write hit a UNIQUE(digest) conflict and THROW,
|
|
405
|
+
// dropping our reconcile; the `ON CONFLICT("digest") DO UPDATE` upsert must instead collapse it.
|
|
406
|
+
let injected = false;
|
|
407
|
+
const racyData = new Proxy(data, {
|
|
408
|
+
get(target, prop) {
|
|
409
|
+
if (prop === "open") {
|
|
410
|
+
return () => {
|
|
411
|
+
const src = target.open();
|
|
412
|
+
return new Proxy(src, {
|
|
413
|
+
get(s, p) {
|
|
414
|
+
if (p === "tx") {
|
|
415
|
+
return async (fn: (t: unknown) => Promise<void>) => {
|
|
416
|
+
if (!injected) {
|
|
417
|
+
injected = true;
|
|
418
|
+
// A competing first-stage commits its row before ours does (real data layer).
|
|
419
|
+
await stageProposal(data, row());
|
|
420
|
+
}
|
|
421
|
+
// @ts-expect-error the wrapped tx forwards the original callback unchanged
|
|
422
|
+
return s.tx(fn);
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
const v = Reflect.get(s, p, s);
|
|
426
|
+
return typeof v === "function" ? v.bind(s) : v;
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const v = Reflect.get(target, prop, target);
|
|
432
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Must NOT throw despite our `existing = null` read racing the competitor's committed insert.
|
|
437
|
+
await stageProposal(racyData as DataLayer, row());
|
|
438
|
+
assert(injected, "the competing first-stage should have fired");
|
|
439
|
+
const finalRow = await deliveryGraphProposals(data).get("d1");
|
|
440
|
+
assert(finalRow, "exactly one row exists for the digest");
|
|
441
|
+
assertEquals(finalRow.status, "staged");
|
|
442
|
+
// The upsert collapsed onto the competitor's row (an UPDATE, not a second INSERT), so stage_seq
|
|
443
|
+
// advanced past the competitor's — proving no PK conflict and a single reconciled row.
|
|
444
|
+
assert(finalRow.stage_seq >= 2, `the raced insert collapsed into an upsert (stage_seq advanced): ${finalRow.stage_seq}`);
|
|
445
|
+
assertEquals((await listStagedProposals(data)).length, 1);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
283
449
|
test("sweepExpiredProposals: flips aged-out staged proposals to `expired` so they drop out of the cockpit grid", async () => {
|
|
284
450
|
await withData(async (data) => {
|
|
285
451
|
// Two staged proposals with different logical keys so neither supersedes the other.
|
|
@@ -351,6 +517,59 @@ test("sweepExpiredProposals: a dispatch racing between the read and the write is
|
|
|
351
517
|
});
|
|
352
518
|
});
|
|
353
519
|
|
|
520
|
+
test("sweepExpiredProposals: a re-stage racing between the read and the write is NOT expired — the `stage_seq` guard no-ops the stale flip so the fresh revision survives (#778 review — thread deliveryGraphProposals.ts:417)", async () => {
|
|
521
|
+
await withData(async (data) => {
|
|
522
|
+
const seqOf = async (digest: string): Promise<number> => {
|
|
523
|
+
const rows = await data.open().query<{ stage_seq: number }>(`SELECT "stage_seq" FROM "delivery_graph_proposals" WHERE "digest" = ?`, [digest]);
|
|
524
|
+
return rows.length ? Number(rows[0].stage_seq) : -1;
|
|
525
|
+
};
|
|
526
|
+
// One aged-out staged proposal — the sweep's `find()` will see it as `staged` at revision seq0.
|
|
527
|
+
await stageProposal(data, row({ digest: "d1", createdAt: "2024-01-01T00:00:00.000Z" }));
|
|
528
|
+
const seq0 = await seqOf("d1");
|
|
529
|
+
const at = new Date(Date.parse("2024-01-01T00:00:00.000Z") + DELIVERY_PROPOSAL_TTL_MS + 1000);
|
|
530
|
+
|
|
531
|
+
// Wrap the data layer so that, in the window between the sweep's `find()` and its per-row guarded
|
|
532
|
+
// `exec`, a concurrent RE-STAGE overwrites d1 in place with a fresh, future-TTL revision (bumping
|
|
533
|
+
// stage_seq past seq0). A blind `WHERE digest=? AND status='staged'` flip would expire that never-swept
|
|
534
|
+
// fresh revision; the `stage_seq`-guarded UPDATE must instead no-op and leave the row live `staged`.
|
|
535
|
+
let raced = false;
|
|
536
|
+
const racyData = new Proxy(data, {
|
|
537
|
+
get(target, prop, receiver) {
|
|
538
|
+
if (prop === "open") {
|
|
539
|
+
return () => {
|
|
540
|
+
const src = target.open();
|
|
541
|
+
return new Proxy(src, {
|
|
542
|
+
get(s, p) {
|
|
543
|
+
if (p === "exec") {
|
|
544
|
+
return async (sql: string, params?: unknown[]) => {
|
|
545
|
+
if (!raced) {
|
|
546
|
+
raced = true;
|
|
547
|
+
await stageProposal(data, row({ digest: "d1", createdAt: "2030-01-01T00:00:00.000Z" }));
|
|
548
|
+
}
|
|
549
|
+
return s.exec(sql, params);
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const v = Reflect.get(s, p, s);
|
|
553
|
+
return typeof v === "function" ? v.bind(s) : v;
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const v = Reflect.get(target, prop, target);
|
|
559
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const swept = await sweepExpiredProposals(racyData as DataLayer, at);
|
|
564
|
+
assert(raced, "the racing re-stage should have fired");
|
|
565
|
+
assertEquals(swept, 0);
|
|
566
|
+
assert((await seqOf("d1")) > seq0, "the injected re-stage bumped stage_seq past the sweep's snapshot");
|
|
567
|
+
const d1 = await deliveryGraphProposals(data).get("d1");
|
|
568
|
+
assertEquals(d1?.status, "staged");
|
|
569
|
+
assert(!isProposalExpired(d1?.expires_at ?? ""), "the fresh revision retains its future TTL — it was not expired by the stale sweep");
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
354
573
|
test("markProposalDismissed: flipping a live `staged` row returns true and lands it `dismissed`", async () => {
|
|
355
574
|
await withData(async (data) => {
|
|
356
575
|
await stageProposal(data, row());
|
|
@@ -484,3 +703,27 @@ test("markProposalExpired: a dismiss racing between the dispatch door's liveness
|
|
|
484
703
|
assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dismissed");
|
|
485
704
|
});
|
|
486
705
|
});
|
|
706
|
+
|
|
707
|
+
test("markProposalExpired: an `expectedStageSeq` guard NO-OPs when a concurrent re-stage bumped the revision (corrupt-graph retirement does not retire a newer graph)", async () => {
|
|
708
|
+
await withData(async (data) => {
|
|
709
|
+
const seqOf = async (digest: string): Promise<number> => {
|
|
710
|
+
const rows = await data.open().query<{ stage_seq: number }>(`SELECT "stage_seq" FROM "delivery_graph_proposals" WHERE "digest" = ?`, [digest]);
|
|
711
|
+
return rows.length ? Number(rows[0].stage_seq) : -1;
|
|
712
|
+
};
|
|
713
|
+
// The dispatch door reads a `staged` proposal at revision `seq0` (its graph is corrupt).
|
|
714
|
+
await stageProposal(data, row());
|
|
715
|
+
const seq0 = await seqOf("d1");
|
|
716
|
+
// Before the corrupt-graph retirement fires, a concurrent re-stage overwrites the SAME digest with a
|
|
717
|
+
// fresh (valid) graph, bumping `stage_seq`.
|
|
718
|
+
await stageProposal(data, row());
|
|
719
|
+
const seq1 = await seqOf("d1");
|
|
720
|
+
assert(seq1 > seq0, "the re-stage bumped stage_seq");
|
|
721
|
+
// The retirement guarded on the STALE revision must no-op (return false) and leave the newer graph staged.
|
|
722
|
+
const flipped = await markProposalExpired(data, "d1", seq0);
|
|
723
|
+
assertEquals(flipped, false);
|
|
724
|
+
assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "staged");
|
|
725
|
+
// Guarded on the CURRENT revision, it retires as usual.
|
|
726
|
+
assertEquals(await markProposalExpired(data, "d1", seq1), true);
|
|
727
|
+
assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "expired");
|
|
728
|
+
});
|
|
729
|
+
});
|