@adonis-agora/durable 0.47.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/assets/spa/assets/{index-DU6cGcJz.css → index-DtGMBUDm.css} +1 -1
- package/dist/assets/spa/assets/{index-CL6q2kMB.js → index-_VISH9Xg.js} +4 -4
- package/dist/assets/spa/assets/index-_VISH9Xg.js.map +1 -0
- package/dist/assets/spa/index.html +2 -2
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +1 -0
- package/dist/configure.js.map +1 -1
- package/dist/providers/durable_provider.d.ts +0 -25
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +29 -5
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/completion.d.ts +11 -0
- package/dist/src/completion.d.ts.map +1 -1
- package/dist/src/completion.js +18 -1
- package/dist/src/completion.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/engine.d.ts +45 -0
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +222 -11
- package/dist/src/engine.js.map +1 -1
- package/dist/src/errors.d.ts +18 -11
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +9 -7
- package/dist/src/errors.js.map +1 -1
- package/dist/src/fan-join.d.ts +4 -3
- package/dist/src/fan-join.d.ts.map +1 -1
- package/dist/src/fan-join.js +5 -4
- package/dist/src/fan-join.js.map +1 -1
- package/dist/src/handshake/announced.d.ts +94 -0
- package/dist/src/handshake/announced.d.ts.map +1 -0
- package/dist/src/handshake/announced.js +113 -0
- package/dist/src/handshake/announced.js.map +1 -0
- package/dist/src/handshake/descriptor.d.ts +59 -0
- package/dist/src/handshake/descriptor.d.ts.map +1 -1
- package/dist/src/handshake/descriptor.js +38 -0
- package/dist/src/handshake/descriptor.js.map +1 -1
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +39 -4
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/metrics.d.ts +5 -0
- package/dist/src/metrics.d.ts.map +1 -1
- package/dist/src/metrics.js +14 -1
- package/dist/src/metrics.js.map +1 -1
- package/dist/src/on-error.d.ts +59 -0
- package/dist/src/on-error.d.ts.map +1 -0
- package/dist/src/on-error.js +71 -0
- package/dist/src/on-error.js.map +1 -0
- package/dist/src/otel/durable-metrics.d.ts +6 -0
- package/dist/src/otel/durable-metrics.d.ts.map +1 -1
- package/dist/src/otel/durable-metrics.js +22 -2
- package/dist/src/otel/durable-metrics.js.map +1 -1
- package/dist/src/otel/durable-otel.d.ts.map +1 -1
- package/dist/src/otel/durable-otel.js +15 -0
- package/dist/src/otel/durable-otel.js.map +1 -1
- package/dist/src/protocol.d.ts.map +1 -1
- package/dist/src/protocol.js +1 -0
- package/dist/src/protocol.js.map +1 -1
- package/dist/src/stores/lucid.js +1 -1
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/transports/db-schema.d.ts +12 -0
- package/dist/src/transports/db-schema.d.ts.map +1 -1
- package/dist/src/transports/db-schema.js +24 -0
- package/dist/src/transports/db-schema.js.map +1 -1
- package/dist/src/transports/db.d.ts +14 -0
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +34 -0
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/event-emitter.d.ts +8 -0
- package/dist/src/transports/event-emitter.d.ts.map +1 -1
- package/dist/src/transports/event-emitter.js +27 -0
- package/dist/src/transports/event-emitter.js.map +1 -1
- package/dist/src/transports/factory.d.ts +12 -0
- package/dist/src/transports/factory.d.ts.map +1 -1
- package/dist/src/transports/factory.js +24 -0
- package/dist/src/transports/factory.js.map +1 -1
- package/dist/src/transports/queue.d.ts +19 -0
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +17 -0
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/transports/worker-keys-db.d.ts +17 -0
- package/dist/src/transports/worker-keys-db.d.ts.map +1 -0
- package/dist/src/transports/worker-keys-db.js +43 -0
- package/dist/src/transports/worker-keys-db.js.map +1 -0
- package/dist/src/transports/worker-keys.d.ts +56 -0
- package/dist/src/transports/worker-keys.d.ts.map +1 -0
- package/dist/src/transports/worker-keys.js +92 -0
- package/dist/src/transports/worker-keys.js.map +1 -0
- package/dist/src/worker-runtime/index.d.ts +1 -1
- package/dist/src/worker-runtime/index.d.ts.map +1 -1
- package/dist/src/worker-runtime/index.js.map +1 -1
- package/dist/src/worker-runtime/worker-runtime.d.ts +7 -2
- package/dist/src/worker-runtime/worker-runtime.d.ts.map +1 -1
- package/dist/src/worker-runtime/worker-runtime.js +22 -20
- package/dist/src/worker-runtime/worker-runtime.js.map +1 -1
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +19 -5
- package/dist/src/workflow-ctx.js.map +1 -1
- package/dist/src/workflow-discovery.d.ts.map +1 -1
- package/dist/src/workflow-discovery.js +2 -1
- package/dist/src/workflow-discovery.js.map +1 -1
- package/dist/src/workflow-ref.d.ts +31 -0
- package/dist/src/workflow-ref.d.ts.map +1 -1
- package/dist/src/workflow-ref.js +22 -0
- package/dist/src/workflow-ref.js.map +1 -1
- package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +24 -0
- package/package.json +4 -4
- package/skills/durable-workflows/SKILL.md +4 -1
- package/dist/assets/spa/assets/index-CL6q2kMB.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { InMemoryAdmissionBackend } from './admission.js';
|
|
2
2
|
import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
|
|
3
3
|
import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
|
|
4
|
-
import { isFailedCompletion } from './completion.js';
|
|
4
|
+
import { completionError, failedCompletion, isFailedCompletion, } from './completion.js';
|
|
5
5
|
import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } from './concurrency.js';
|
|
6
6
|
import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
|
|
7
7
|
import { parseDuration } from './duration.js';
|
|
@@ -10,8 +10,10 @@ import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspende
|
|
|
10
10
|
import { EventAccumulators } from './event-accumulators.js';
|
|
11
11
|
import { eventMatches, eventMatchOf, eventPrefix } from './events.js';
|
|
12
12
|
import { FOLDED_PREFIX, fanOfWaiter, foldedChildIds } from './fan-join.js';
|
|
13
|
+
import { aggregateAnnouncements } from './handshake/announced.js';
|
|
13
14
|
import { isNonEmptyRunScope, TERMINAL_RUN_STATUSES } from './interfaces.js';
|
|
14
15
|
import { asHeartbeat } from './observability-scope.js';
|
|
16
|
+
import { assertRoutes, codeAndDetails, HANDOFF_SEQ_BASE, handoffHandlerId, handoffName, liveHandoff, onErrorRunId, retiredHandoffName, routeFor, } from './on-error.js';
|
|
15
17
|
import { breakpointToken, stepId } from './protocol.js';
|
|
16
18
|
import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
|
|
17
19
|
import { scanRunValueFacets } from './run-value-facets.js';
|
|
@@ -58,6 +60,8 @@ const SETTLE_ELIGIBLE_STATUSES = [
|
|
|
58
60
|
'blocked',
|
|
59
61
|
'failed',
|
|
60
62
|
];
|
|
63
|
+
/** How long a read of the onError routes a fleet announces is reused. */
|
|
64
|
+
const ANNOUNCED_ROUTES_TTL_MS = 5_000;
|
|
61
65
|
const versionKey = (name, version) => `${name}@${version}`;
|
|
62
66
|
/**
|
|
63
67
|
* First checkpoint seq reserved for saga compensations: compensation `idx` (registration order)
|
|
@@ -198,6 +202,8 @@ export class WorkflowEngine {
|
|
|
198
202
|
updateValidators = new Map();
|
|
199
203
|
/** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
|
|
200
204
|
cancelRequested = new Set();
|
|
205
|
+
/** {@link announcedRoutes} reads, by routing token + workflow + version. */
|
|
206
|
+
announcedRoutesCache = new Map();
|
|
201
207
|
/** The merged schedule set the worker loop ticks with (see {@link adoptSchedules}). */
|
|
202
208
|
#allSchedules = [];
|
|
203
209
|
/** Runtime pause overrides by schedule key (console-issued; win over the config's `paused`). */
|
|
@@ -545,6 +551,7 @@ export class WorkflowEngine {
|
|
|
545
551
|
name,
|
|
546
552
|
version,
|
|
547
553
|
fn,
|
|
554
|
+
onError: this.checkedRoutes(name, opts?.onError),
|
|
548
555
|
tags: opts?.tags,
|
|
549
556
|
origin: opts?.origin,
|
|
550
557
|
singleton: opts?.singleton,
|
|
@@ -575,6 +582,7 @@ export class WorkflowEngine {
|
|
|
575
582
|
const registered = {
|
|
576
583
|
name,
|
|
577
584
|
version,
|
|
585
|
+
onError: this.checkedRoutes(name, opts.onError),
|
|
578
586
|
// A remote workflow has no in-process body; execute() branches on `remote` before this is read.
|
|
579
587
|
fn: () => {
|
|
580
588
|
throw new Error(`workflow ${name} is remote — it has no in-process body`);
|
|
@@ -613,6 +621,9 @@ export class WorkflowEngine {
|
|
|
613
621
|
group: name,
|
|
614
622
|
executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
|
|
615
623
|
});
|
|
624
|
+
const registered = this.workflows.get(versionKey(name, '1'));
|
|
625
|
+
if (registered)
|
|
626
|
+
registered.convention = true;
|
|
616
627
|
return true;
|
|
617
628
|
}
|
|
618
629
|
/**
|
|
@@ -1689,6 +1700,24 @@ export class WorkflowEngine {
|
|
|
1689
1700
|
if (run.status === 'failed' || run.status === 'dead') {
|
|
1690
1701
|
const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
|
|
1691
1702
|
for (const cp of await this.store.listCheckpoints(runId)) {
|
|
1703
|
+
// A retry runs the body again, so a failure it handed to an onError handler is retired: kept in
|
|
1704
|
+
// the timeline under another name, never followed again, and the handler is not retried. Its
|
|
1705
|
+
// `onError:<code>` tag goes too: the tag says how the run ended, and that is being redone.
|
|
1706
|
+
const handlerId = handoffHandlerId(runId, cp);
|
|
1707
|
+
if (handlerId !== undefined) {
|
|
1708
|
+
await this.store.takeSignalWaiter(`child:${handlerId}`);
|
|
1709
|
+
await this.store.saveCheckpoint({
|
|
1710
|
+
...cp,
|
|
1711
|
+
name: retiredHandoffName(handlerId),
|
|
1712
|
+
status: 'completed',
|
|
1713
|
+
});
|
|
1714
|
+
const tags = run.tags?.filter((t) => !t.startsWith('onError:'));
|
|
1715
|
+
if (tags && tags.length !== run.tags?.length) {
|
|
1716
|
+
await this.store.updateRun(runId, { tags, updatedAt: new Date() });
|
|
1717
|
+
run.tags = tags;
|
|
1718
|
+
}
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1692
1721
|
if (cp.status === 'failed') {
|
|
1693
1722
|
await this.store.saveCheckpoint({
|
|
1694
1723
|
...cp,
|
|
@@ -2208,7 +2237,7 @@ export class WorkflowEngine {
|
|
|
2208
2237
|
? buffered.payload
|
|
2209
2238
|
: settled.status === 'completed'
|
|
2210
2239
|
? { ok: true, value: settled.output }
|
|
2211
|
-
:
|
|
2240
|
+
: failedCompletion(settled.error ?? { message: settled.status });
|
|
2212
2241
|
await this.signal(token, completion);
|
|
2213
2242
|
}
|
|
2214
2243
|
/**
|
|
@@ -2597,6 +2626,169 @@ export class WorkflowEngine {
|
|
|
2597
2626
|
* {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
|
|
2598
2627
|
* us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
|
|
2599
2628
|
*/
|
|
2629
|
+
/**
|
|
2630
|
+
* The registered onError routes whose handler workflow nothing can run: not registered on this
|
|
2631
|
+
* engine, and no live worker group serves it. Meant for a boot-time warning: a typo in a handler's
|
|
2632
|
+
* name would otherwise surface only when the first run fails.
|
|
2633
|
+
*/
|
|
2634
|
+
async unservedOnErrorRoutes() {
|
|
2635
|
+
const unserved = [];
|
|
2636
|
+
let groups;
|
|
2637
|
+
for (const registered of this.workflows.values()) {
|
|
2638
|
+
for (const route of registered.onError ?? []) {
|
|
2639
|
+
if (this.latest.has(route.workflow))
|
|
2640
|
+
continue;
|
|
2641
|
+
groups ??= await this.pool.listWorkerGroups().catch(() => []);
|
|
2642
|
+
if (groups.some((g) => g === route.workflow || g.startsWith(`${route.workflow}@`)))
|
|
2643
|
+
continue;
|
|
2644
|
+
unserved.push({ workflow: registered.name, route });
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
return unserved;
|
|
2648
|
+
}
|
|
2649
|
+
/**
|
|
2650
|
+
* The onError routes the live workers on `token` announce for a workflow the engine serves by
|
|
2651
|
+
* convention: the one set they agree on, or none when they state none or cannot be read. Workers
|
|
2652
|
+
* that announce different sets (a rollout in progress) route nothing, and the run's failure is
|
|
2653
|
+
* reported as a `run.unrouted` event. Announcers of another version of the workflow are ignored. The
|
|
2654
|
+
* read is cached for {@link ANNOUNCED_ROUTES_TTL_MS}, so a burst of failures costs one read.
|
|
2655
|
+
*/
|
|
2656
|
+
async announcedRoutes(run, token, error) {
|
|
2657
|
+
const key = `${token}\n${run.workflow}\n${run.workflowVersion}`;
|
|
2658
|
+
const now = this.clock();
|
|
2659
|
+
let cached = this.announcedRoutesCache.get(key);
|
|
2660
|
+
if (!cached || now - cached.at > ANNOUNCED_ROUTES_TTL_MS) {
|
|
2661
|
+
const sets = new Map();
|
|
2662
|
+
for (const announced of aggregateAnnouncements(await this.liveDescriptorsFor(token))) {
|
|
2663
|
+
if (announced.name !== run.workflow)
|
|
2664
|
+
continue;
|
|
2665
|
+
if (announced.version !== undefined && announced.version !== run.workflowVersion)
|
|
2666
|
+
continue;
|
|
2667
|
+
for (const routes of announced.onError)
|
|
2668
|
+
sets.set(JSON.stringify(routes), routes);
|
|
2669
|
+
}
|
|
2670
|
+
cached = { at: now, sets: [...sets.values()] };
|
|
2671
|
+
this.announcedRoutesCache.set(key, cached);
|
|
2672
|
+
}
|
|
2673
|
+
if (cached.sets.length > 1) {
|
|
2674
|
+
this.emit({
|
|
2675
|
+
type: 'run.unrouted',
|
|
2676
|
+
runId: run.id,
|
|
2677
|
+
workflow: run.workflow,
|
|
2678
|
+
namespace: run.namespace,
|
|
2679
|
+
error,
|
|
2680
|
+
reason: `live workers announce ${cached.sets.length} different onError route sets`,
|
|
2681
|
+
});
|
|
2682
|
+
return undefined;
|
|
2683
|
+
}
|
|
2684
|
+
return cached.sets[0];
|
|
2685
|
+
}
|
|
2686
|
+
/** Validate a workflow's {@link OnErrorRoute}s at registration. */
|
|
2687
|
+
checkedRoutes(name, routes) {
|
|
2688
|
+
if (!routes?.length)
|
|
2689
|
+
return undefined;
|
|
2690
|
+
assertRoutes(name, routes);
|
|
2691
|
+
return routes;
|
|
2692
|
+
}
|
|
2693
|
+
/**
|
|
2694
|
+
* Route a failed run to its {@link OnErrorRoute} handler. Starts the handler run and waits for it the
|
|
2695
|
+
* way a parent waits for a `ctx.child`: a `child:<handlerId>` waiter plus a `running` placeholder
|
|
2696
|
+
* checkpoint carrying the handler's name and input, so the handler's terminal state wakes the run
|
|
2697
|
+
* (buffered and reconciled like any child). From then on every execution of the run only follows the
|
|
2698
|
+
* handler ({@link followHandoff}); the body never runs again. Undefined when no route matches: the
|
|
2699
|
+
* run fails as usual.
|
|
2700
|
+
*
|
|
2701
|
+
* The placeholder sits past every seq the body used and is left out of {@link remoteHistory}.
|
|
2702
|
+
*/
|
|
2703
|
+
async routeFailure(run, error) {
|
|
2704
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
2705
|
+
const routes = registered?.convention
|
|
2706
|
+
? await this.announcedRoutes(run, registered.remote?.group ?? run.workflow, error)
|
|
2707
|
+
: registered?.onError;
|
|
2708
|
+
const route = routeFor(routes, error);
|
|
2709
|
+
if (!route)
|
|
2710
|
+
return undefined;
|
|
2711
|
+
const checkpoints = await this.handoffCheckpoints(run.id);
|
|
2712
|
+
const live = liveHandoff(run.id, checkpoints);
|
|
2713
|
+
if (live)
|
|
2714
|
+
return this.followHandoff(run, live);
|
|
2715
|
+
// One handoff per attempt: a retry retires the previous one, so the next sits past it.
|
|
2716
|
+
const attempt = checkpoints.length;
|
|
2717
|
+
const seq = HANDOFF_SEQ_BASE + attempt;
|
|
2718
|
+
const handlerId = onErrorRunId(run.id, attempt);
|
|
2719
|
+
const handler = {
|
|
2720
|
+
workflow: route.workflow,
|
|
2721
|
+
input: { runId: run.id, workflow: run.workflow, input: run.input, error },
|
|
2722
|
+
};
|
|
2723
|
+
await this.store.putSignalWaiter({ token: `child:${handlerId}`, runId: run.id, seq });
|
|
2724
|
+
await this.store.saveCheckpoint({
|
|
2725
|
+
...instantCheckpoint({
|
|
2726
|
+
runId: run.id,
|
|
2727
|
+
seq,
|
|
2728
|
+
name: handoffName(handlerId),
|
|
2729
|
+
kind: 'signal',
|
|
2730
|
+
status: 'running',
|
|
2731
|
+
}),
|
|
2732
|
+
input: handler,
|
|
2733
|
+
});
|
|
2734
|
+
// Tagged, so the runs whose failures were handed off are a filter away in the console.
|
|
2735
|
+
const tag = `onError:${route.code ?? '*'}`;
|
|
2736
|
+
if (!run.tags?.includes(tag)) {
|
|
2737
|
+
const tags = [...(run.tags ?? []), tag];
|
|
2738
|
+
await this.store.updateRun(run.id, { tags, updatedAt: new Date() });
|
|
2739
|
+
run.tags = tags;
|
|
2740
|
+
}
|
|
2741
|
+
this.emit({
|
|
2742
|
+
type: 'run.routed',
|
|
2743
|
+
runId: run.id,
|
|
2744
|
+
workflow: run.workflow,
|
|
2745
|
+
namespace: run.namespace,
|
|
2746
|
+
name: route.workflow,
|
|
2747
|
+
error,
|
|
2748
|
+
});
|
|
2749
|
+
this.startChildDeferred(handler.workflow, handler.input, handlerId, {
|
|
2750
|
+
namespace: run.namespace,
|
|
2751
|
+
});
|
|
2752
|
+
return { kind: 'suspended' };
|
|
2753
|
+
}
|
|
2754
|
+
/** A run's handoff checkpoints, live and retired: the only ones at {@link HANDOFF_SEQ_BASE} and up. */
|
|
2755
|
+
async handoffCheckpoints(runId) {
|
|
2756
|
+
const handoffs = await this.store.listCheckpointsByNamePrefix?.(runId, [
|
|
2757
|
+
`signal:child:${runId}.onError.`,
|
|
2758
|
+
`onError:retried:${runId}.onError.`,
|
|
2759
|
+
]);
|
|
2760
|
+
return (handoffs ?? (await this.store.listCheckpoints(runId))).filter((cp) => cp.seq >= HANDOFF_SEQ_BASE);
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* Where a run that handed its failure off stands: the handler's outcome once it settled (from the
|
|
2764
|
+
* wake's checkpoint, or off the handler run when the wake never arrived), else still waiting. Starts
|
|
2765
|
+
* the handler if the turn that handed off crashed before its deferred start ran.
|
|
2766
|
+
*/
|
|
2767
|
+
async followHandoff(run, handoff) {
|
|
2768
|
+
if (handoff.status === 'completed') {
|
|
2769
|
+
const outcome = handoff.output;
|
|
2770
|
+
return outcome.ok
|
|
2771
|
+
? { kind: 'completed', output: outcome.value }
|
|
2772
|
+
: { kind: 'failed', error: completionError(outcome) };
|
|
2773
|
+
}
|
|
2774
|
+
const handlerId = handoffHandlerId(run.id, handoff);
|
|
2775
|
+
const handlerRun = await this.store.getRun(handlerId);
|
|
2776
|
+
if (handlerRun?.status === 'completed')
|
|
2777
|
+
return { kind: 'completed', output: handlerRun.output };
|
|
2778
|
+
if (handlerRun && TERMINAL_RUN_STATUSES.includes(handlerRun.status)) {
|
|
2779
|
+
return {
|
|
2780
|
+
kind: 'failed',
|
|
2781
|
+
error: handlerRun.error ?? { message: `onError handler ${handlerId} ${handlerRun.status}` },
|
|
2782
|
+
};
|
|
2783
|
+
}
|
|
2784
|
+
if (!handlerRun) {
|
|
2785
|
+
const handler = handoff.input;
|
|
2786
|
+
this.startChildDeferred(handler.workflow, handler.input, handlerId, {
|
|
2787
|
+
namespace: run.namespace,
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
return { kind: 'suspended' };
|
|
2791
|
+
}
|
|
2600
2792
|
/**
|
|
2601
2793
|
* Apply a terminal/suspended transition once: persist the new run status, emit the matching
|
|
2602
2794
|
* lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
|
|
@@ -2639,6 +2831,10 @@ export class WorkflowEngine {
|
|
|
2639
2831
|
return { runId: run.id, status: 'completed', output: outcome.output };
|
|
2640
2832
|
}
|
|
2641
2833
|
if (outcome.kind === 'failed') {
|
|
2834
|
+
const handed = await this.routeFailure(run, outcome.error);
|
|
2835
|
+
if (handed && handed.kind !== 'failed')
|
|
2836
|
+
return this.settleRun(run, handed);
|
|
2837
|
+
const error = handed?.error ?? outcome.error;
|
|
2642
2838
|
// Same race as the `completed` branch above — guard identically. ADDITIONALLY exclude
|
|
2643
2839
|
// `failed`: a stray resume of an already-failed run (a redelivered signal, the settle-recheck
|
|
2644
2840
|
// re-drive) replays and deterministically re-fails — re-applying the settle would re-emit
|
|
@@ -2649,7 +2845,7 @@ export class WorkflowEngine {
|
|
|
2649
2845
|
// status), so it is unaffected.
|
|
2650
2846
|
const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES.filter((s) => s !== 'failed'), {
|
|
2651
2847
|
status: 'failed',
|
|
2652
|
-
error
|
|
2848
|
+
error,
|
|
2653
2849
|
updatedAt,
|
|
2654
2850
|
});
|
|
2655
2851
|
if (!applied)
|
|
@@ -2659,10 +2855,10 @@ export class WorkflowEngine {
|
|
|
2659
2855
|
runId: run.id,
|
|
2660
2856
|
workflow: run.workflow,
|
|
2661
2857
|
namespace: run.namespace,
|
|
2662
|
-
error
|
|
2858
|
+
error,
|
|
2663
2859
|
});
|
|
2664
|
-
this.notifyParent(run.id,
|
|
2665
|
-
return { runId: run.id, status: 'failed', error
|
|
2860
|
+
this.notifyParent(run.id, failedCompletion(error));
|
|
2861
|
+
return { runId: run.id, status: 'failed', error };
|
|
2666
2862
|
}
|
|
2667
2863
|
// This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
|
|
2668
2864
|
// was cancelled WHILE that turn was still executing (e.g. `ctx.all`'s failFast cancelling a
|
|
@@ -2746,7 +2942,13 @@ export class WorkflowEngine {
|
|
|
2746
2942
|
const routePlan = await this.planRoute(remote.group, registered.requires);
|
|
2747
2943
|
if (routePlan?.status === 'blocked')
|
|
2748
2944
|
return this.parkBlocked(run, routePlan);
|
|
2749
|
-
const
|
|
2945
|
+
const checkpoints = await this.store.listCheckpoints(run.id);
|
|
2946
|
+
const handoff = liveHandoff(run.id, checkpoints);
|
|
2947
|
+
if (handoff) {
|
|
2948
|
+
// A run that handed its failure to an onError handler only follows the handler from here on.
|
|
2949
|
+
return this.settleRun(run, await this.followHandoff(run, handoff));
|
|
2950
|
+
}
|
|
2951
|
+
const history = await this.remoteHistory(run.id, checkpoints);
|
|
2750
2952
|
let decision;
|
|
2751
2953
|
try {
|
|
2752
2954
|
decision = await remote.executor.advance(run, history);
|
|
@@ -3007,8 +3209,8 @@ export class WorkflowEngine {
|
|
|
3007
3209
|
}
|
|
3008
3210
|
}
|
|
3009
3211
|
/** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
|
|
3010
|
-
async remoteHistory(runId) {
|
|
3011
|
-
const checkpoints = await this.store.listCheckpoints(runId);
|
|
3212
|
+
async remoteHistory(runId, listed) {
|
|
3213
|
+
const checkpoints = listed ?? (await this.store.listCheckpoints(runId));
|
|
3012
3214
|
const kindOf = {
|
|
3013
3215
|
remote: 'call',
|
|
3014
3216
|
local: 'step',
|
|
@@ -3021,13 +3223,16 @@ export class WorkflowEngine {
|
|
|
3021
3223
|
// A child run resolves THROUGH the signal machinery (a `child:<id>` waiter notified on the
|
|
3022
3224
|
// child's terminal state), so its checkpoint is kind `signal` with a `signal:child:` name and
|
|
3023
3225
|
// a Completion payload. Surface it as a `child` event with the value/error unwrapped.
|
|
3226
|
+
// onError handoffs sit past every op a body can reach; no replay is told about them.
|
|
3227
|
+
if (cp.seq >= HANDOFF_SEQ_BASE)
|
|
3228
|
+
continue;
|
|
3024
3229
|
if (cp.kind === 'signal' && cp.name.startsWith('signal:child:')) {
|
|
3025
3230
|
const completion = cp.output;
|
|
3026
3231
|
events.push({
|
|
3027
3232
|
seq: cp.seq,
|
|
3028
3233
|
kind: 'child',
|
|
3029
3234
|
output: completion?.ok ? completion.value : undefined,
|
|
3030
|
-
error: completion && completion.ok === false ?
|
|
3235
|
+
error: completion && completion.ok === false ? completionError(completion) : undefined,
|
|
3031
3236
|
});
|
|
3032
3237
|
continue;
|
|
3033
3238
|
}
|
|
@@ -3270,7 +3475,7 @@ export class WorkflowEngine {
|
|
|
3270
3475
|
child.status === 'dead') {
|
|
3271
3476
|
await deliverChild(child.status === 'completed'
|
|
3272
3477
|
? { ok: true, value: child.output }
|
|
3273
|
-
:
|
|
3478
|
+
: failedCompletion(child.error ?? { message: child.status }));
|
|
3274
3479
|
}
|
|
3275
3480
|
}
|
|
3276
3481
|
}
|
|
@@ -3330,6 +3535,11 @@ export class WorkflowEngine {
|
|
|
3330
3535
|
// counted an attempt (or dead-lettered it), flipped it to `pending` and re-enqueued a second
|
|
3331
3536
|
// execution. The lease must outlive the state it protects.
|
|
3332
3537
|
try {
|
|
3538
|
+
// A run that handed its failure to an onError handler only follows the handler from here on.
|
|
3539
|
+
const handoff = liveHandoff(run.id, snapshot);
|
|
3540
|
+
if (handoff && !this.cancelRequested.has(run.id)) {
|
|
3541
|
+
return await this.settleRun(run, await this.followHandoff(run, handoff));
|
|
3542
|
+
}
|
|
3333
3543
|
// Establish the ambient ctx for the duration of this body turn so context-aware statics
|
|
3334
3544
|
// (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
|
|
3335
3545
|
// `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
|
|
@@ -3439,6 +3649,7 @@ export class WorkflowEngine {
|
|
|
3439
3649
|
const error = {
|
|
3440
3650
|
message: err instanceof Error ? err.message : String(err),
|
|
3441
3651
|
stack: err instanceof Error ? err.stack : undefined,
|
|
3652
|
+
...codeAndDetails(err),
|
|
3442
3653
|
};
|
|
3443
3654
|
// Saga: undo completed steps in reverse, each retried up to `compensationRetries`. Outcomes
|
|
3444
3655
|
// are emitted as `compensate:<step>` step events so a stranded undo is VISIBLE (not silently
|