@nanobpm/nano-workforce 0.200.5 → 0.200.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/app/deliveryGraphDispatch.test.ts +177 -11
- package/app/deliveryGraphDispatch.ts +72 -3
- package/app/deliveryGraphDispatchLayoutOnce.test.ts +13 -0
- package/app/deliveryGraphRun.test.ts +324 -1
- package/app/deliveryGraphRun.ts +194 -3
- package/app/deliveryRunner.test.ts +22 -0
- package/app/deliveryRunner.ts +15 -3
- package/app/service.ts +52 -3
- package/operations/dispatchDeliveryGraph.test.ts +23 -1
- package/package.json +2 -2
- package/pages/delivery-graphs/staged.mount.js +17 -6
- package/test/delivery-graphs-staged-slow-dispatch.test.ts +85 -0
package/app/deliveryRunner.ts
CHANGED
|
@@ -122,6 +122,10 @@ export interface PreparedDeliveryGraph {
|
|
|
122
122
|
processDefinitionId: string;
|
|
123
123
|
bpmn: string;
|
|
124
124
|
nodeInputs: Record<string, NodeInput>;
|
|
125
|
+
/** The per-run token this prepare resolved (`options.runKey` or a fresh random). Surfaced so
|
|
126
|
+
* `runDeliveryGraph` seeds the SAME value as a run-root process variable — the reconciliation
|
|
127
|
+
* handle a stale-claim relaunch matches a still-running original instance on (issue #852). */
|
|
128
|
+
runKey: string;
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
export type PrepareDeliveryResult =
|
|
@@ -193,7 +197,7 @@ export async function prepareDeliveryGraph(
|
|
|
193
197
|
if (element === undefined) continue; // unreachable — resolved covers every node — but keep total.
|
|
194
198
|
nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts, requiredEmits: requiredEmitsByNodeId.get(node.id) ?? EMPTY_REQUIRED_EMITS });
|
|
195
199
|
}
|
|
196
|
-
return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs } };
|
|
200
|
+
return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs, runKey } };
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
/** Deploy + start a compiled graph as a running engine-native instance. Idempotent at the DEFINITION
|
|
@@ -207,7 +211,7 @@ export async function runDeliveryGraph(
|
|
|
207
211
|
): Promise<RunDeliveryResult> {
|
|
208
212
|
const prep = await prepareDeliveryGraph(graph, options);
|
|
209
213
|
if (!prep.ok) return prep;
|
|
210
|
-
const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
|
|
214
|
+
const { processDefinitionId, bpmn, nodeInputs, runKey } = prep.prepared;
|
|
211
215
|
|
|
212
216
|
await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
|
|
213
217
|
// Per-node repository isolation (#739): the `io.nanobpm.agentTask.repository` envelope is now seeded
|
|
@@ -218,10 +222,18 @@ export async function runDeliveryGraph(
|
|
|
218
222
|
// repository variable is seeded here; the resolution + loud-failure invariant (unresolved agent cell
|
|
219
223
|
// on a non-`repoless` run) and the `repoless`/repo-base conflict guard all live in
|
|
220
224
|
// `injectAgentRepoEnvelopes`, which the prepare step above already ran (throwing before deploy).
|
|
225
|
+
//
|
|
226
|
+
// `runKey` IS seeded as a run-root variable (unlike the repo envelope): it is the durable
|
|
227
|
+
// reconciliation handle a stale-claim relaunch uses to find a still-running ORIGINAL instance of this
|
|
228
|
+
// run before starting a second one (issue #852 review — thread deliveryGraphRun.ts:180). Every
|
|
229
|
+
// instance of this content-addressed definition carries its run key, so
|
|
230
|
+
// `searchProcessInstances({ processDefinitionId, state:"ACTIVE" })` + a `runKey` variable match
|
|
231
|
+
// identifies the live duplicate a timeout-only reclaim cannot distinguish from a dead claim.
|
|
221
232
|
const { processInstanceKey } = await engine.createInstance({
|
|
222
233
|
processDefinitionId,
|
|
223
234
|
variables: {
|
|
224
235
|
nodeInputs,
|
|
236
|
+
runKey,
|
|
225
237
|
// Stage 0 transcript correlation (#543): the transcript-endpoint base every agent node's
|
|
226
238
|
// completing worker appends its jobKey-scoped stream to, to emit `transcriptUrl` (see the agent
|
|
227
239
|
// node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
|
|
@@ -233,7 +245,7 @@ export async function runDeliveryGraph(
|
|
|
233
245
|
// downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
|
|
234
246
|
return {
|
|
235
247
|
ok: true,
|
|
236
|
-
handle: { processDefinitionId, bpmn, nodeInputs, processInstanceKey: String(processInstanceKey) },
|
|
248
|
+
handle: { processDefinitionId, bpmn, nodeInputs, runKey, processInstanceKey: String(processInstanceKey) },
|
|
237
249
|
};
|
|
238
250
|
}
|
|
239
251
|
|
package/app/service.ts
CHANGED
|
@@ -31,8 +31,9 @@ import { envContract, readEnvOr } from "./contracts.ts";
|
|
|
31
31
|
import { makeDefaultReadHead } from "./currentHead.ts";
|
|
32
32
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
33
33
|
import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
|
|
34
|
+
import { DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
34
35
|
import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
|
|
35
|
-
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
36
|
+
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels, reconcileOriginalInstanceBeforeRelaunch, reconcileStaleLaunchClaim } from "./deliveryGraphRun.ts";
|
|
36
37
|
import { deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
37
38
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
38
39
|
import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
|
|
@@ -3049,10 +3050,58 @@ export async function pollTasklessPlanTermination(
|
|
|
3049
3050
|
* Scoped to `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
|
|
3050
3051
|
export async function pollDeliveryGraphPhase(
|
|
3051
3052
|
data: DataLayer,
|
|
3052
|
-
engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks">,
|
|
3053
|
+
engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks" | "searchVariables" | "cancelInstance">,
|
|
3053
3054
|
) {
|
|
3054
3055
|
for (const run of await deliveryGraphRuns(data).find({ status: "running" })) {
|
|
3055
|
-
if (!run.process_key)
|
|
3056
|
+
if (!run.process_key) {
|
|
3057
|
+
// #852: a launch claim that never got an instance key within the TTL died mid-launch (the dispatch
|
|
3058
|
+
// process went down before deploy, so its own `markClaimFailed` never ran). Reconcile it to
|
|
3059
|
+
// `failed` so the cockpit stops showing a phantom in-flight run; a re-dispatch re-claims it. The
|
|
3060
|
+
// flip is a CAS on the snapshot we read (via `reconcileStaleLaunchClaim`), so a claim a concurrent
|
|
3061
|
+
// dispatch has already re-claimed between our `find()` and here is left untouched, never clobbered.
|
|
3062
|
+
// Reconcile-before-retire (issue #852 review — thread deliveryGraphRun.ts:180): the crash may have
|
|
3063
|
+
// happened AFTER `createInstance` succeeded but BEFORE the key was stamped, leaving a LIVE instance
|
|
3064
|
+
// executing this graph's side-effecting nodes. Retiring the row to `failed` and letting a
|
|
3065
|
+
// re-dispatch relaunch would then double-run those nodes. Cancel any still-ACTIVE instance of THIS
|
|
3066
|
+
// run (matched by its seeded `runKey` variable) BEFORE retiring the claim, so the recovery stays
|
|
3067
|
+
// at-most-once. Best-effort: a reconcile read failure must not wedge the pass — log and skip the
|
|
3068
|
+
// retire this pass (the row stays stale-claim `running` and is retried next pass). The definition
|
|
3069
|
+
// id is derived from the row's `digest` (`delivery-graph-<digest>`), NOT the `process_definition_id`
|
|
3070
|
+
// column — that column is stamped only AFTER a successful launch, so it is NULL on exactly the
|
|
3071
|
+
// crashed-mid-launch claim this path exists to recover.
|
|
3072
|
+
//
|
|
3073
|
+
// Fence the engine cancellation with the stale-claim CAS lease (issue #853 review — thread
|
|
3074
|
+
// service.ts:3077): the cancel is passed to `reconcileStaleLaunchClaim` as `finalize`, run INSIDE
|
|
3075
|
+
// the CAS transaction ONLY after the lease flip lands. Two races close:
|
|
3076
|
+
// • a FRESH `running`/NULL-key claim (still within its TTL) is non-stale, so
|
|
3077
|
+
// `reconcileStaleLaunchClaim` returns before the flip and the cancel never runs — a legitimate
|
|
3078
|
+
// just-launched instance (createInstance succeeded, key stamp pending) is never cancelled; and
|
|
3079
|
+
// • a concurrent dispatch that RE-CLAIMS the row first (refreshing `updated_at`) makes the CAS
|
|
3080
|
+
// match zero rows, so `finalize` never runs and the live replacement is never cancelled — the
|
|
3081
|
+
// retire is finalized only for the lease owner, and the lease is non-reclaimable for the
|
|
3082
|
+
// cancel's duration (the flipped `failed` row fails the dispatch's `status <> 'running'` guard).
|
|
3083
|
+
// Best-effort: a reconcile read failure must not wedge the pass — `finalize` throws, the
|
|
3084
|
+
// transaction rolls back (the row stays stale-claim `running`), we log and skip the retire this
|
|
3085
|
+
// pass, and the next pass retries. A fail-closed `ReconcileConflictError` (an ACTIVE
|
|
3086
|
+
// same-definition instance with no readable run-root `runKey`, e.g. a pre-upgrade legacy original)
|
|
3087
|
+
// takes this same path: the retire is deferred rather than relaunching a possible duplicate.
|
|
3088
|
+
const cancelOriginal = async () => {
|
|
3089
|
+
if (!run.digest) return;
|
|
3090
|
+
const cancelled = await reconcileOriginalInstanceBeforeRelaunch(engine, {
|
|
3091
|
+
runKey: run.run_key,
|
|
3092
|
+
processDefinitionId: `${DELIVERY_GRAPH_PROCESS_ID}-${run.digest}`,
|
|
3093
|
+
});
|
|
3094
|
+
if (cancelled.length > 0) {
|
|
3095
|
+
console.error(`[poller] delivery graph ${run.run_key}: cancelled a still-running original instance before retiring the stale claim: ${cancelled.join(", ")}`);
|
|
3096
|
+
}
|
|
3097
|
+
};
|
|
3098
|
+
try {
|
|
3099
|
+
await reconcileStaleLaunchClaim(data, run, cancelOriginal);
|
|
3100
|
+
} catch (err) {
|
|
3101
|
+
console.error(`[poller] delivery graph ${run.run_key}: reconcile-before-retire failed, leaving the claim for next pass: ${err}`);
|
|
3102
|
+
}
|
|
3103
|
+
continue;
|
|
3104
|
+
}
|
|
3056
3105
|
const processKey = run.process_key;
|
|
3057
3106
|
try {
|
|
3058
3107
|
const [snapshots, parks] = await Promise.all([
|
|
@@ -15,7 +15,7 @@ import assert from "node:assert/strict";
|
|
|
15
15
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
16
16
|
import { redactFreeText } from "../app/deliveryGraphCompiler.ts";
|
|
17
17
|
import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
|
|
18
|
-
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
18
|
+
import { deliveryGraphRuns, LAUNCH_CLAIM_TTL_MS } from "../app/deliveryGraphRun.ts";
|
|
19
19
|
import { isEquivalentReStage, stableProposalRunKey } from "./dispatchDeliveryGraph.ts";
|
|
20
20
|
|
|
21
21
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
@@ -190,6 +190,28 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
|
|
|
190
190
|
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
test("#852: a launch claim stranded by a crash mid-launch (running, no process key, past the TTL) does not wedge the proposal — a re-dispatch LAUNCHES it", async () => {
|
|
194
|
+
const app = await boot();
|
|
195
|
+
assert.ok(app.api);
|
|
196
|
+
const api = app.api;
|
|
197
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
198
|
+
const digest = staged.body.digest;
|
|
199
|
+
// The merlin state: the claim row + identity were written, then the process died before deploy.
|
|
200
|
+
const stale = new Date(Date.now() - LAUNCH_CLAIM_TTL_MS - 60_000).toISOString();
|
|
201
|
+
await deliveryGraphRuns(app.db).insert({
|
|
202
|
+
run_key: digest, process_key: null, process_definition_id: null, digest, status: "running", side_effecting: 0,
|
|
203
|
+
node_count: 1, human_node_count: 1, side_effect_count: 0, title: "manual gate", phase: "Running", phase_node_id: null,
|
|
204
|
+
human_labels: null, created_at: stale, updated_at: stale, acknowledged_at: null,
|
|
205
|
+
});
|
|
206
|
+
const res = await api.call<{ ok: boolean; alreadyRunning: boolean; processInstanceKey?: string }>("dispatchDeliveryGraph", {
|
|
207
|
+
body: { digest, repoless: true },
|
|
208
|
+
});
|
|
209
|
+
assert.equal(res.status, 202, JSON.stringify(res.body));
|
|
210
|
+
assert.equal(res.body.alreadyRunning, false, "must not short-circuit onto the phantom run");
|
|
211
|
+
assert.ok(res.body.processInstanceKey, "a real instance was started");
|
|
212
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "dispatched");
|
|
213
|
+
});
|
|
214
|
+
|
|
193
215
|
test("re-dispatching an ALREADY-dispatched digest → 400 (the proposal is consumed; the run shows in the in-flight grid)", async () => {
|
|
194
216
|
const app = await boot();
|
|
195
217
|
assert.ok(app.api);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.200.
|
|
3
|
+
"version": "0.200.6",
|
|
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",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@biomejs/biome": "^2.4.11",
|
|
73
|
-
"@nanobpm/urban-testkit": "^1.
|
|
73
|
+
"@nanobpm/urban-testkit": "^1.6.0",
|
|
74
74
|
"@nanobpm/workflow": "^0.15.0",
|
|
75
75
|
"@semantic-release/changelog": "^7.0.0",
|
|
76
76
|
"@semantic-release/git": "^11.0.0",
|
|
@@ -53,6 +53,12 @@ const DEFAULT_REFRESH_MS = 5000;
|
|
|
53
53
|
// forever, so the busy() lock never clears and the UI is stranded; on timeout the AbortController
|
|
54
54
|
// rejects the fetch, surfacing as an error banner and re-enabling the controls via the finally blocks.
|
|
55
55
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
56
|
+
// The dispatch POST gets its own, much longer budget (#852): the server lays out + deploys the graph
|
|
57
|
+
// before it answers, which for a large graph legitimately takes minutes (54 nodes ≈ 105 s on merlin).
|
|
58
|
+
// Aborting at 30 s showed the operator a failure while the launch was still in flight. Stays bounded so
|
|
59
|
+
// a genuinely hung door still releases the controls. Kept under the server's launch-claim TTL
|
|
60
|
+
// (`LAUNCH_CLAIM_TTL_MS`, 15 min) so the UI never gives up AFTER a crashed launch became re-claimable.
|
|
61
|
+
const DISPATCH_TIMEOUT_MS = 10 * 60_000;
|
|
56
62
|
|
|
57
63
|
// The confirm shown before a dispatch — dispatching authorises every side-effecting node, so the
|
|
58
64
|
// operator acknowledges that the launch (and its side effects) is content-addressed to this graph.
|
|
@@ -273,9 +279,9 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
273
279
|
|
|
274
280
|
/** Fetch JSON from a door and return { status, body } (never throws on an HTTP error). Rejects
|
|
275
281
|
* (AbortError) if the request outlives REQUEST_TIMEOUT_MS so a hung door can't wedge the busy lock. */
|
|
276
|
-
async function request(url, init) {
|
|
282
|
+
async function request(url, init, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
277
283
|
const controller = new AbortController();
|
|
278
|
-
const timer = setTimeout(() => controller.abort(),
|
|
284
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
279
285
|
try {
|
|
280
286
|
const res = await fetch(url, { ...init, headers: headers(url), signal: controller.signal });
|
|
281
287
|
let body = {};
|
|
@@ -291,7 +297,7 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
291
297
|
}
|
|
292
298
|
|
|
293
299
|
const get = (url) => request(url, { method: "GET" });
|
|
294
|
-
const post = (url, payload) => request(url, { method: "POST", body: JSON.stringify(payload) });
|
|
300
|
+
const post = (url, payload, timeoutMs) => request(url, { method: "POST", body: JSON.stringify(payload) }, timeoutMs);
|
|
295
301
|
|
|
296
302
|
let disposed = false;
|
|
297
303
|
// True while the last completed load failed — so a subsequent successful load knows to clear its own
|
|
@@ -372,9 +378,9 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
372
378
|
// the list on the next poll — refresh immediately so the operator sees it leave.
|
|
373
379
|
async function doDispatch(payload) {
|
|
374
380
|
busy(true);
|
|
375
|
-
setStatus("Dispatching…");
|
|
381
|
+
setStatus("Dispatching… laying out and deploying the graph — a large graph can take a few minutes.");
|
|
376
382
|
try {
|
|
377
|
-
const { status, body } = await post(dispatchUrl, payload);
|
|
383
|
+
const { status, body } = await post(dispatchUrl, payload, DISPATCH_TIMEOUT_MS);
|
|
378
384
|
if ((status === 202 || status === 200) && body.ok) {
|
|
379
385
|
setStatus("\u2713 Dispatched — the run is now in flight.", "ok");
|
|
380
386
|
await refresh();
|
|
@@ -382,7 +388,12 @@ export function mountStagedProposals(host, config = {}) {
|
|
|
382
388
|
setStatus(body && body.error ? body.error : "Dispatch failed.", "err");
|
|
383
389
|
}
|
|
384
390
|
} catch (err) {
|
|
385
|
-
setStatus(
|
|
391
|
+
setStatus(
|
|
392
|
+
err && err.name === "AbortError"
|
|
393
|
+
? "Dispatch is taking longer than expected — it may still be launching. Check Active Delivery Graphs before dispatching again."
|
|
394
|
+
: err && err.message ? err.message : "Dispatch request failed.",
|
|
395
|
+
"err",
|
|
396
|
+
);
|
|
386
397
|
} finally {
|
|
387
398
|
busy(false);
|
|
388
399
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// #852 — a large graph's dispatch legitimately takes minutes (layout + deploy: ~105 s for 54 nodes on
|
|
2
|
+
// merlin). The staged view aborted EVERY request at a 30 s timeout and rendered the AbortError as the
|
|
3
|
+
// dispatch failure — the operator saw a "crash" while the server was still launching. The dispatch POST
|
|
4
|
+
// must outlive a slow-but-healthy launch (bounded by its own, much longer budget) and tell the operator
|
|
5
|
+
// it is still working.
|
|
6
|
+
import { mock, test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import { parseHTML } from "linkedom";
|
|
9
|
+
import { mountStagedProposals } from "../pages/delivery-graphs/staged.mount.js";
|
|
10
|
+
|
|
11
|
+
const STAGED_URL = "https://app.test/app/api/delivery-graph/staged";
|
|
12
|
+
const DISPATCH_URL = "https://app.test/app/api/actions/delivery-graph/dispatch";
|
|
13
|
+
const PROPOSAL = {
|
|
14
|
+
digest: "abc123def456",
|
|
15
|
+
title: "Big graph",
|
|
16
|
+
sideEffecting: true,
|
|
17
|
+
nodeCount: 54,
|
|
18
|
+
humanNodeCount: 2,
|
|
19
|
+
sideEffectCount: 34,
|
|
20
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
21
|
+
expiresAt: "2026-01-02T00:00:00.000Z",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const flush = async () => {
|
|
25
|
+
for (let i = 0; i < 6; i++) await new Promise((resolve) => setImmediate(resolve));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
test("#852: a dispatch slower than the generic 30 s request timeout is NOT aborted — it completes and reports success", async () => {
|
|
29
|
+
const { window: domWindow, document } = parseHTML("<!doctype html><html><body><div id='host'></div></body></html>");
|
|
30
|
+
const host = document.getElementById("host");
|
|
31
|
+
assert(host, "host");
|
|
32
|
+
let resolveDispatch: (() => void) | undefined;
|
|
33
|
+
let aborted = false;
|
|
34
|
+
const origFetch = globalThis.fetch;
|
|
35
|
+
globalThis.fetch = async (input, init) => {
|
|
36
|
+
const url = String(input);
|
|
37
|
+
if (url === STAGED_URL) return new Response(JSON.stringify({ proposals: [PROPOSAL] }), { status: 200 });
|
|
38
|
+
if (url === DISPATCH_URL) {
|
|
39
|
+
return await new Promise<Response>((resolve, reject) => {
|
|
40
|
+
init?.signal?.addEventListener("abort", () => {
|
|
41
|
+
aborted = true;
|
|
42
|
+
reject(new DOMException("signal is aborted without reason", "AbortError"));
|
|
43
|
+
});
|
|
44
|
+
resolveDispatch = () => resolve(new Response(JSON.stringify({ ok: true, status: "running" }), { status: 202 }));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return new Response("{}", { status: 404 });
|
|
48
|
+
};
|
|
49
|
+
const win = { confirm: () => false, prompt: () => null, location: { href: "https://app.test/delivery-graphs/", origin: "https://app.test" }, parent: undefined as unknown };
|
|
50
|
+
win.parent = win;
|
|
51
|
+
const origWindow = Reflect.get(globalThis, "window");
|
|
52
|
+
Reflect.set(globalThis, "window", win);
|
|
53
|
+
mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
|
|
54
|
+
const dispose = mountStagedProposals(host, { stagedUrl: STAGED_URL, dispatchUrl: DISPATCH_URL, refreshMs: 1_000_000_000 });
|
|
55
|
+
const click = (sel: string) => {
|
|
56
|
+
const el = host.querySelector(sel);
|
|
57
|
+
assert(el, `missing ${sel}`);
|
|
58
|
+
el.dispatchEvent(new domWindow.Event("click", { bubbles: true, cancelable: true }));
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
await flush();
|
|
62
|
+
click("[data-dispatch]");
|
|
63
|
+
await flush();
|
|
64
|
+
click("[data-dispatch-confirm]");
|
|
65
|
+
await flush();
|
|
66
|
+
assert(resolveDispatch, "the dispatch POST was sent");
|
|
67
|
+
// Two minutes of a healthy-but-slow launch (layout + deploy) — well past the old 30 s abort.
|
|
68
|
+
for (let s = 0; s < 120; s++) {
|
|
69
|
+
mock.timers.tick(1000);
|
|
70
|
+
await flush();
|
|
71
|
+
}
|
|
72
|
+
assertEquals(aborted, false, "the dispatch request must not be aborted while the server is still launching");
|
|
73
|
+
const working = host.querySelector(".status")?.textContent ?? "";
|
|
74
|
+
assert(/dispatching/i.test(working), `the operator is told it is still working, got: ${working}`);
|
|
75
|
+
resolveDispatch();
|
|
76
|
+
await flush();
|
|
77
|
+
const status = host.querySelector(".status")?.textContent ?? "";
|
|
78
|
+
assert(/dispatched/i.test(status), `expected success, got: ${status}`);
|
|
79
|
+
} finally {
|
|
80
|
+
dispose();
|
|
81
|
+
mock.timers.reset();
|
|
82
|
+
globalThis.fetch = origFetch;
|
|
83
|
+
Reflect.set(globalThis, "window", origWindow);
|
|
84
|
+
}
|
|
85
|
+
});
|