@voyant-travel/workflows-orchestrator 0.107.10

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.
Files changed (61) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +52 -0
  3. package/README.md +76 -0
  4. package/dist/abort-registry.d.ts +6 -0
  5. package/dist/abort-registry.d.ts.map +1 -0
  6. package/dist/abort-registry.js +37 -0
  7. package/dist/concurrency.d.ts +31 -0
  8. package/dist/concurrency.d.ts.map +1 -0
  9. package/dist/concurrency.js +145 -0
  10. package/dist/drive.d.ts +67 -0
  11. package/dist/drive.d.ts.map +1 -0
  12. package/dist/drive.js +373 -0
  13. package/dist/driver-inmemory.d.ts +30 -0
  14. package/dist/driver-inmemory.d.ts.map +1 -0
  15. package/dist/driver-inmemory.js +394 -0
  16. package/dist/event-router.d.ts +51 -0
  17. package/dist/event-router.d.ts.map +1 -0
  18. package/dist/event-router.js +68 -0
  19. package/dist/http-step-handler.d.ts +25 -0
  20. package/dist/http-step-handler.d.ts.map +1 -0
  21. package/dist/http-step-handler.js +78 -0
  22. package/dist/in-memory-store.d.ts +5 -0
  23. package/dist/in-memory-store.d.ts.map +1 -0
  24. package/dist/in-memory-store.js +41 -0
  25. package/dist/index.d.ts +13 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +22 -0
  28. package/dist/journal-helpers.d.ts +3 -0
  29. package/dist/journal-helpers.d.ts.map +1 -0
  30. package/dist/journal-helpers.js +9 -0
  31. package/dist/orchestrator.d.ts +116 -0
  32. package/dist/orchestrator.d.ts.map +1 -0
  33. package/dist/orchestrator.js +411 -0
  34. package/dist/resume-run.d.ts +40 -0
  35. package/dist/resume-run.d.ts.map +1 -0
  36. package/dist/resume-run.js +119 -0
  37. package/dist/schedule.d.ts +51 -0
  38. package/dist/schedule.d.ts.map +1 -0
  39. package/dist/schedule.js +243 -0
  40. package/dist/testing/driver-compliance.d.ts +58 -0
  41. package/dist/testing/driver-compliance.d.ts.map +1 -0
  42. package/dist/testing/driver-compliance.js +667 -0
  43. package/dist/types.d.ts +182 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +4 -0
  46. package/package.json +51 -0
  47. package/src/__tests__/orchestrator-test-support.ts +18 -0
  48. package/src/abort-registry.ts +41 -0
  49. package/src/concurrency.ts +217 -0
  50. package/src/drive.ts +477 -0
  51. package/src/driver-inmemory.ts +511 -0
  52. package/src/event-router.ts +120 -0
  53. package/src/http-step-handler.ts +112 -0
  54. package/src/in-memory-store.ts +44 -0
  55. package/src/index.ts +73 -0
  56. package/src/journal-helpers.ts +11 -0
  57. package/src/orchestrator.ts +527 -0
  58. package/src/resume-run.ts +162 -0
  59. package/src/schedule.ts +310 -0
  60. package/src/testing/driver-compliance.ts +800 -0
  61. package/src/types.ts +201 -0
@@ -0,0 +1,13 @@
1
+ export { registerRunAbort, signalRunAbort, unregisterRunAbort, } from "./abort-registry.js";
2
+ export { type ConcurrencyRunHooks, createInProcessConcurrencyCoordinator, type InProcessConcurrencyCoordinator, type RuntimeConcurrencyPolicy, resolveConcurrencyKey, WorkflowConcurrencyRejectedError, } from "./concurrency.js";
3
+ export { applyWaitpointInjection, type DriveOptions, driveUntilPaused } from "./drive.js";
4
+ export { createInMemoryDriver, type InMemoryDriverOptions, } from "./driver-inmemory.js";
5
+ export { type RouteEventArgs, type RouterMatch, routeEvent, } from "./event-router.js";
6
+ export { createHttpStepHandler, type HttpStepHandlerDeps, type HttpStepTarget, } from "./http-step-handler.js";
7
+ export { createInMemoryRunStore } from "./in-memory-store.js";
8
+ export { emptyJournal } from "./journal-helpers.js";
9
+ export { type CancelArgs, cancel, type OrchestratorDeps, type ResumeArgs, type ResumeDueAlarmsArgs, resume, resumeDueAlarms, type TriggerArgs, trigger, } from "./orchestrator.js";
10
+ export { type BuildResumeJournalInput, type BuildResumeJournalResult, type BuildSeededResumeJournalInput, buildResumeJournal, buildSeededResumeJournal, } from "./resume-run.js";
11
+ export { type CronSpec, computeNextFire, createScheduler, manifestScheduleSources, nextCronFire, parseCron, type SchedulableDeclaration, type SchedulerDeps, type SchedulerHandle, type ScheduleSource, toMs, } from "./schedule.js";
12
+ export * from "./types.js";
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,kBAAkB,GACnB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,KAAK,mBAAmB,EACxB,qCAAqC,EACrC,KAAK,+BAA+B,EACpC,KAAK,wBAAwB,EAC7B,qBAAqB,EACrB,gCAAgC,GACjC,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,uBAAuB,EAAE,KAAK,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AACzF,OAAO,EACL,oBAAoB,EACpB,KAAK,qBAAqB,GAC3B,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,UAAU,GACX,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,cAAc,GACpB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAA;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACnD,OAAO,EACL,KAAK,UAAU,EACf,MAAM,EACN,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,mBAAmB,EACxB,MAAM,EACN,eAAe,EACf,KAAK,WAAW,EAChB,OAAO,GACR,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,KAAK,uBAAuB,EAC5B,KAAK,wBAAwB,EAC7B,KAAK,6BAA6B,EAClC,kBAAkB,EAClB,wBAAwB,GACzB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,KAAK,QAAQ,EACb,eAAe,EACf,eAAe,EACf,uBAAuB,EACvB,YAAY,EACZ,SAAS,EACT,KAAK,sBAAsB,EAC3B,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,IAAI,GACL,MAAM,eAAe,CAAA;AACtB,cAAc,YAAY,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ // @voyant-travel/workflows-orchestrator
2
+ //
3
+ // Reference orchestrator for Voyant Workflows. Drives runs through
4
+ // the tenant step handler over the v1 wire protocol. Transport- and
5
+ // storage-agnostic: compose with a RunRecordStore of your choice
6
+ // (in-memory for tests, Postgres-backed for production).
7
+ //
8
+ // See docs/runtime-protocol.md §2 + §5 for the contract this
9
+ // implements and docs/design.md §6 for the broader orchestrator
10
+ // state-machine design.
11
+ export { registerRunAbort, signalRunAbort, unregisterRunAbort, } from "./abort-registry.js";
12
+ export { createInProcessConcurrencyCoordinator, resolveConcurrencyKey, WorkflowConcurrencyRejectedError, } from "./concurrency.js";
13
+ export { applyWaitpointInjection, driveUntilPaused } from "./drive.js";
14
+ export { createInMemoryDriver, } from "./driver-inmemory.js";
15
+ export { routeEvent, } from "./event-router.js";
16
+ export { createHttpStepHandler, } from "./http-step-handler.js";
17
+ export { createInMemoryRunStore } from "./in-memory-store.js";
18
+ export { emptyJournal } from "./journal-helpers.js";
19
+ export { cancel, resume, resumeDueAlarms, trigger, } from "./orchestrator.js";
20
+ export { buildResumeJournal, buildSeededResumeJournal, } from "./resume-run.js";
21
+ export { computeNextFire, createScheduler, manifestScheduleSources, nextCronFire, parseCron, toMs, } from "./schedule.js";
22
+ export * from "./types.js";
@@ -0,0 +1,3 @@
1
+ import type { JournalSlice } from "@voyant-travel/workflows/protocol";
2
+ export declare function emptyJournal(): JournalSlice;
3
+ //# sourceMappingURL=journal-helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"journal-helpers.d.ts","sourceRoot":"","sources":["../src/journal-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAA;AAErE,wBAAgB,YAAY,IAAI,YAAY,CAQ3C"}
@@ -0,0 +1,9 @@
1
+ export function emptyJournal() {
2
+ return {
3
+ stepResults: {},
4
+ waitpointsResolved: {},
5
+ compensationsRun: {},
6
+ metadataState: {},
7
+ streamsCompleted: {},
8
+ };
9
+ }
@@ -0,0 +1,116 @@
1
+ import type { Duration } from "@voyant-travel/workflows";
2
+ import { type DriveOptions } from "./drive.js";
3
+ import type { JournalSlice, RunRecord, RunRecordStore, RunTrigger, StepHandler, WaitpointInjection } from "./types.js";
4
+ export interface TriggerArgs {
5
+ workflowId: string;
6
+ workflowVersion: string;
7
+ input: unknown;
8
+ tenantMeta: RunRecord["tenantMeta"];
9
+ environment?: RunRecord["environment"];
10
+ triggeredBy?: RunTrigger;
11
+ tags?: string[];
12
+ runNumber?: number;
13
+ /** Optional id to use; defaults to `run_` + crypto random. */
14
+ runId?: string;
15
+ /**
16
+ * Caller-supplied idempotency token. When set and the caller did not
17
+ * explicitly pass `runId`, the orchestrator derives a deterministic
18
+ * runId from `(workflowId, idempotencyKey)` so retries of the same
19
+ * trigger return the same run record without re-driving. The key is
20
+ * also persisted onto `RunRecord.idempotencyKey` so persistent stores
21
+ * can populate dedup columns / unique indexes natively.
22
+ *
23
+ * See architecture doc §15.2 for the full ingest-side derivation
24
+ * (eventId → idempotencyKey via `${filterId}:${eventId}`).
25
+ */
26
+ idempotencyKey?: string;
27
+ /**
28
+ * Optional trigger-time delay. When set to a future instant, the
29
+ * orchestrator persists the run in `waiting` on a synthetic DATETIME
30
+ * waitpoint and leaves execution to the normal wakeup/time-wheel path.
31
+ */
32
+ delay?: Duration | Date;
33
+ /** Higher values are claimed first by scheduler/time-wheel stores. */
34
+ priority?: number;
35
+ /**
36
+ * Optional journal seed. Used by external replay/resume callers
37
+ * that need a new run to skip steps already completed by a parent
38
+ * run.
39
+ */
40
+ initialJournal?: JournalSlice;
41
+ /**
42
+ * Metadata cursor paired with `initialJournal`. Resume callers that
43
+ * seed a journal with an existing `metadataState` must also seed the
44
+ * positional cursor so replayed metadata mutations are not applied
45
+ * twice.
46
+ */
47
+ initialMetadataAppliedCount?: number;
48
+ /**
49
+ * Compute-time budget in ms, typically from `WorkflowConfig.timeout`.
50
+ * Parked time on waitpoints does not count against this. When the
51
+ * cumulative invocation time exceeds it, the run ends `failed`
52
+ * with `code: "WORKFLOW_TIMEOUT"`. Undefined / 0 = no limit.
53
+ */
54
+ timeoutMs?: number;
55
+ /**
56
+ * For child runs spawned by `ctx.invoke` on a parent that may park.
57
+ * When set, the orchestrator records it on the child's record so
58
+ * the child's terminal status cascade-resumes the parent.
59
+ */
60
+ parent?: {
61
+ runId: string;
62
+ waitpointId: string;
63
+ };
64
+ /**
65
+ * Internal lifecycle hook used by driver-level coordinators that need
66
+ * the persisted run id before the first invocation starts.
67
+ */
68
+ onRunRecordCreated?: (record: RunRecord) => void;
69
+ }
70
+ export interface OrchestratorDeps extends DriveOptions {
71
+ store: RunRecordStore;
72
+ handler: StepHandler;
73
+ /** id generator; defaults to `run_<random>`. */
74
+ idGenerator?: () => string;
75
+ }
76
+ export declare function trigger(args: TriggerArgs, deps: OrchestratorDeps): Promise<RunRecord>;
77
+ export interface ResumeArgs {
78
+ runId: string;
79
+ injection: WaitpointInjection;
80
+ }
81
+ export declare function resume(args: ResumeArgs, deps: OrchestratorDeps): Promise<{
82
+ ok: true;
83
+ record: RunRecord;
84
+ } | {
85
+ ok: false;
86
+ status: "not_found" | "not_parked" | "no_match";
87
+ message: string;
88
+ }>;
89
+ export interface ResumeDueAlarmsArgs {
90
+ runId: string;
91
+ }
92
+ /**
93
+ * Resolve every DATETIME waitpoint whose `wakeAt` has passed, drive
94
+ * the run forward, and persist. Returns the saved record, or null
95
+ * when the run isn't in `waiting` state (already terminal / running
96
+ * elsewhere), or when no DATETIME waitpoints are actually due yet —
97
+ * both are no-ops that the caller can treat as "nothing to do."
98
+ *
99
+ * Callers (local serve loop, CF DO alarm handler) are responsible for
100
+ * scheduling the actual wake-up timer. This function is transport-
101
+ * agnostic: given `now()`, it does the resolve + drive + save.
102
+ */
103
+ export declare function resumeDueAlarms(args: ResumeDueAlarmsArgs, deps: OrchestratorDeps): Promise<RunRecord | null>;
104
+ export interface CancelArgs {
105
+ runId: string;
106
+ reason?: string;
107
+ }
108
+ export declare function cancel(args: CancelArgs, deps: OrchestratorDeps): Promise<{
109
+ ok: true;
110
+ record: RunRecord;
111
+ } | {
112
+ ok: false;
113
+ status: "not_found" | "already_terminal";
114
+ message: string;
115
+ }>;
116
+ //# sourceMappingURL=orchestrator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../src/orchestrator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,411 @@
1
+ // agent-quality: file-size exception -- owner: workflows-orchestrator; existing module stays co-located until a dedicated split preserves behavior and tests.
2
+ // Public entry points for the reference orchestrator.
3
+ //
4
+ // `trigger()` creates a RunRecord, drives it forward through the
5
+ // tenant handler, and persists the resulting record.
6
+ // `resume()` accepts a waitpoint injection for a parked run, applies
7
+ // it, drives forward, and persists.
8
+ // `cancel()` closes out a run without running compensations (they
9
+ // must come from the tenant handler, not the orchestrator).
10
+ import { registerRunAbort, signalRunAbort, unregisterRunAbort } from "./abort-registry.js";
11
+ import { applyWaitpointInjection, driveUntilPaused } from "./drive.js";
12
+ import { emptyJournal } from "./journal-helpers.js";
13
+ export async function trigger(args, deps) {
14
+ const now = deps.now ?? (() => Date.now());
15
+ const createdAt = now();
16
+ // Idempotency: caller-supplied `runId` wins (explicit). Otherwise, when
17
+ // `idempotencyKey` is supplied, derive a deterministic runId from
18
+ // `(workflowId, idempotencyKey)`. We then route through `store.tryInsert`
19
+ // — atomic check-or-insert — so concurrent triggers with the same
20
+ // derived runId never both proceed to drive (architecture doc §15.2,
21
+ // closes the race the get-then-save pattern leaves open).
22
+ const explicitRunId = args.runId;
23
+ const idempotencyKey = args.idempotencyKey;
24
+ const derivedRunId = idempotencyKey !== undefined ? `idem-${args.workflowId}-${idempotencyKey}` : undefined;
25
+ const id = explicitRunId ?? derivedRunId ?? deps.idGenerator?.() ?? defaultRunId(now);
26
+ const isIdempotent = explicitRunId !== undefined || derivedRunId !== undefined;
27
+ const delayWakeAt = resolveDelayWakeAt(args.delay, createdAt);
28
+ const record = {
29
+ id,
30
+ workflowId: args.workflowId,
31
+ workflowVersion: args.workflowVersion,
32
+ status: delayWakeAt !== undefined ? "waiting" : "running",
33
+ input: args.input,
34
+ journal: args.initialJournal ? cloneJournal(args.initialJournal) : emptyJournal(),
35
+ invocationCount: 0,
36
+ metadataAppliedCount: args.initialMetadataAppliedCount ?? 0,
37
+ computeTimeMs: 0,
38
+ timeoutMs: args.timeoutMs,
39
+ priority: args.priority,
40
+ parent: args.parent,
41
+ pendingWaitpoints: delayWakeAt !== undefined
42
+ ? [
43
+ {
44
+ clientWaitpointId: triggerDelayWaitpointId(id),
45
+ kind: "DATETIME",
46
+ meta: {
47
+ wakeAt: delayWakeAt,
48
+ durationMs: Math.max(0, delayWakeAt - createdAt),
49
+ source: "trigger.delay",
50
+ },
51
+ },
52
+ ]
53
+ : [],
54
+ streams: {},
55
+ startedAt: delayWakeAt ?? createdAt,
56
+ triggeredBy: args.triggeredBy ?? { kind: "api" },
57
+ tags: args.tags ?? [],
58
+ environment: args.environment ?? "development",
59
+ tenantMeta: args.tenantMeta,
60
+ runMeta: { number: args.runNumber ?? 1, attempt: 1 },
61
+ idempotencyKey,
62
+ };
63
+ // Idempotent path uses tryInsert; non-idempotent (auto-generated) ids
64
+ // can't collide so the existing save() is sufficient.
65
+ if (isIdempotent) {
66
+ const result = await deps.store.tryInsert(record);
67
+ if (!result.created) {
68
+ // Another caller raced in first. Return their record without
69
+ // re-driving — drive() is what actually fires side effects.
70
+ return result.record;
71
+ }
72
+ args.onRunRecordCreated?.(record);
73
+ }
74
+ else {
75
+ // Persist up-front so concurrent `cancel(runId)` calls can find the
76
+ // run before any invocation has completed.
77
+ await deps.store.save(record);
78
+ args.onRunRecordCreated?.(record);
79
+ }
80
+ if (delayWakeAt !== undefined) {
81
+ return record;
82
+ }
83
+ const abortCtrl = registerRunAbort(id);
84
+ try {
85
+ await driveUntilPaused(record, {
86
+ ...driveOptionsFor(deps),
87
+ signal: abortCtrl.signal,
88
+ });
89
+ }
90
+ finally {
91
+ unregisterRunAbort(id);
92
+ }
93
+ // If an external cancel fired during drive, the aborted step may
94
+ // have surfaced as `failed` (the step threw on abort). The user's
95
+ // intent was cancel, so adopt the store's `cancelled` status.
96
+ if (abortCtrl.signal.aborted) {
97
+ const latest = await deps.store.get(id);
98
+ if (latest?.status === "cancelled") {
99
+ record.status = "cancelled";
100
+ record.completedAt = latest.completedAt ?? now();
101
+ record.pendingWaitpoints = [];
102
+ record.error = latest.error;
103
+ }
104
+ }
105
+ return deps.store.save(record);
106
+ }
107
+ function resolveDelayWakeAt(delay, now) {
108
+ if (delay === undefined)
109
+ return undefined;
110
+ const wakeAt = delay instanceof Date ? delay.getTime() : now + durationToMs(delay);
111
+ return wakeAt > now ? wakeAt : undefined;
112
+ }
113
+ function durationToMs(duration) {
114
+ if (typeof duration === "number")
115
+ return duration;
116
+ const m = /^(\d+)(ms|s|m|h|d|w)$/.exec(duration);
117
+ if (!m)
118
+ throw new Error(`invalid duration: ${String(duration)}`);
119
+ const n = Number(m[1]);
120
+ switch (m[2]) {
121
+ case "ms":
122
+ return n;
123
+ case "s":
124
+ return n * 1_000;
125
+ case "m":
126
+ return n * 60_000;
127
+ case "h":
128
+ return n * 3_600_000;
129
+ case "d":
130
+ return n * 86_400_000;
131
+ case "w":
132
+ return n * 604_800_000;
133
+ default:
134
+ throw new Error(`invalid duration unit: ${m[2]}`);
135
+ }
136
+ }
137
+ function triggerDelayWaitpointId(runId) {
138
+ return `trigger-delay:${runId}`;
139
+ }
140
+ function cloneJournal(journal) {
141
+ return structuredClone(journal);
142
+ }
143
+ /**
144
+ * Build DriveOptions that carry a `triggerChild` hook bound to the
145
+ * same deps — so `ctx.invoke(child, ...)` recursively runs the child
146
+ * through the same orchestrator, store, and handler — plus a
147
+ * `beforeInvocation` hook that persists mid-flight progress and
148
+ * honors concurrent cancellations.
149
+ */
150
+ function driveOptionsFor(deps) {
151
+ const now = deps.now ?? (() => Date.now());
152
+ return {
153
+ ...deps,
154
+ triggerChild: async ({ parent, waitpoint }) => {
155
+ const childWorkflowId = String(waitpoint.meta.childWorkflowId);
156
+ const childInput = waitpoint.meta.childInput;
157
+ const detach = waitpoint.meta.detach === true;
158
+ return trigger({
159
+ workflowId: childWorkflowId,
160
+ // Children inherit the parent's workflow version slot unless
161
+ // lockToVersion is set; for v1 we always inherit.
162
+ workflowVersion: parent.workflowVersion,
163
+ input: childInput,
164
+ tenantMeta: parent.tenantMeta,
165
+ environment: parent.environment,
166
+ // Inherit the parent's trigger kind for run-tree observability.
167
+ triggeredBy: parent.triggeredBy,
168
+ tags: Array.isArray(waitpoint.meta.tags) ? waitpoint.meta.tags : [],
169
+ // Lineage pointer: if this child parks, its terminal status
170
+ // cascade-resumes the parent at this specific waitpoint.
171
+ parent: detach
172
+ ? undefined
173
+ : { runId: parent.id, waitpointId: waitpoint.clientWaitpointId },
174
+ }, deps);
175
+ },
176
+ beforeInvocation: async (rec) => {
177
+ // Read-first: a concurrent `cancel()` may have flipped the
178
+ // stored status. If we saved first, we'd overwrite it.
179
+ const latest = await deps.store.get(rec.id);
180
+ if (latest && latest.status === "cancelled") {
181
+ rec.status = "cancelled";
182
+ rec.completedAt = latest.completedAt ?? now();
183
+ rec.pendingWaitpoints = [];
184
+ if (latest.error)
185
+ rec.error = latest.error;
186
+ return false;
187
+ }
188
+ // Persist mid-flight progress so the dashboard sees updates and
189
+ // the next concurrent cancel() has an up-to-date target.
190
+ await deps.store.save(rec);
191
+ return true;
192
+ },
193
+ };
194
+ }
195
+ export async function resume(args, deps) {
196
+ const existing = await deps.store.get(args.runId);
197
+ if (!existing) {
198
+ return { ok: false, status: "not_found", message: `run ${args.runId} not found` };
199
+ }
200
+ if (existing.status !== "waiting") {
201
+ return {
202
+ ok: false,
203
+ status: "not_parked",
204
+ message: `run ${args.runId} is not parked (status: ${existing.status})`,
205
+ };
206
+ }
207
+ const ok = applyWaitpointInjection(existing, args.injection, deps.now);
208
+ if (!ok.ok) {
209
+ return { ok: false, status: "no_match", message: ok.message };
210
+ }
211
+ const abortCtrl = registerRunAbort(existing.id);
212
+ try {
213
+ await driveUntilPaused(existing, {
214
+ ...driveOptionsFor(deps),
215
+ signal: abortCtrl.signal,
216
+ });
217
+ }
218
+ finally {
219
+ unregisterRunAbort(existing.id);
220
+ }
221
+ if (abortCtrl.signal.aborted) {
222
+ const latest = await deps.store.get(existing.id);
223
+ if (latest?.status === "cancelled") {
224
+ const now = deps.now ?? (() => Date.now());
225
+ existing.status = "cancelled";
226
+ existing.completedAt = latest.completedAt ?? now();
227
+ existing.pendingWaitpoints = [];
228
+ existing.error = latest.error;
229
+ }
230
+ }
231
+ const saved = await deps.store.save(existing);
232
+ // If this resume drove the run into a terminal state and it's a
233
+ // child of some parent, cascade the resolution.
234
+ if (isTerminalStatus(saved.status)) {
235
+ await cascadeResumeParent(saved, deps);
236
+ }
237
+ return { ok: true, record: saved };
238
+ }
239
+ /**
240
+ * Resolve every DATETIME waitpoint whose `wakeAt` has passed, drive
241
+ * the run forward, and persist. Returns the saved record, or null
242
+ * when the run isn't in `waiting` state (already terminal / running
243
+ * elsewhere), or when no DATETIME waitpoints are actually due yet —
244
+ * both are no-ops that the caller can treat as "nothing to do."
245
+ *
246
+ * Callers (local serve loop, CF DO alarm handler) are responsible for
247
+ * scheduling the actual wake-up timer. This function is transport-
248
+ * agnostic: given `now()`, it does the resolve + drive + save.
249
+ */
250
+ export async function resumeDueAlarms(args, deps) {
251
+ const record = await deps.store.get(args.runId);
252
+ if (!record)
253
+ return null;
254
+ if (record.status !== "waiting")
255
+ return null;
256
+ const now = deps.now ?? (() => Date.now());
257
+ const at = now();
258
+ const stillPending = [];
259
+ let resolvedAny = false;
260
+ for (const wp of record.pendingWaitpoints) {
261
+ const wakeAt = typeof wp.meta.wakeAt === "number" ? wp.meta.wakeAt : undefined;
262
+ if (wp.kind === "DATETIME" && wakeAt !== undefined && wakeAt <= at) {
263
+ record.journal.waitpointsResolved[wp.clientWaitpointId] = {
264
+ kind: "DATETIME",
265
+ resolvedAt: at,
266
+ source: "replay",
267
+ };
268
+ resolvedAny = true;
269
+ }
270
+ else {
271
+ stillPending.push(wp);
272
+ }
273
+ }
274
+ if (!resolvedAny)
275
+ return null;
276
+ record.pendingWaitpoints = stillPending;
277
+ if (record.pendingWaitpoints.length === 0)
278
+ record.status = "running";
279
+ const abortCtrl = registerRunAbort(record.id);
280
+ try {
281
+ await driveUntilPaused(record, {
282
+ ...driveOptionsFor(deps),
283
+ signal: abortCtrl.signal,
284
+ });
285
+ }
286
+ finally {
287
+ unregisterRunAbort(record.id);
288
+ }
289
+ const saved = await deps.store.save(record);
290
+ if (isTerminalStatus(saved.status)) {
291
+ await cascadeResumeParent(saved, deps);
292
+ }
293
+ return saved;
294
+ }
295
+ export async function cancel(args, deps) {
296
+ const existing = await deps.store.get(args.runId);
297
+ if (!existing) {
298
+ return { ok: false, status: "not_found", message: `run ${args.runId} not found` };
299
+ }
300
+ if (existing.status !== "waiting" && existing.status !== "running") {
301
+ return {
302
+ ok: false,
303
+ status: "already_terminal",
304
+ message: `run ${args.runId} is already terminal (status: ${existing.status})`,
305
+ };
306
+ }
307
+ const now = deps.now ?? (() => Date.now());
308
+ existing.status = "cancelled";
309
+ existing.completedAt = now();
310
+ existing.pendingWaitpoints = [];
311
+ if (args.reason) {
312
+ existing.error = {
313
+ category: "USER_ERROR",
314
+ code: "CANCELLED",
315
+ message: args.reason,
316
+ };
317
+ }
318
+ const saved = await deps.store.save(existing);
319
+ // Best-effort mid-step abort: if the run is in-flight in this
320
+ // process, fire its AbortSignal so step bodies that observe
321
+ // `ctx.signal` (fetches, sleeps, etc.) stop immediately. Returns
322
+ // `false` when no controller is registered (run is in another
323
+ // process, or drive has already exited) — that's fine; the
324
+ // status flip + between-invocation recheck cover that path.
325
+ signalRunAbort(existing.id, args.reason);
326
+ // If this cancel was on a child with a parked parent, surface the
327
+ // cancellation to the parent as a RUN-waitpoint error.
328
+ if (isTerminalStatus(saved.status)) {
329
+ await cascadeResumeParent(saved, deps);
330
+ }
331
+ return { ok: true, record: saved };
332
+ }
333
+ /**
334
+ * When a child run reaches a terminal state, look up its `parent`
335
+ * pointer and resume the parent's matching RUN waitpoint with the
336
+ * child's output / error. Best-effort: if the parent can't be found
337
+ * or is no longer parked, silently drop (the parent's own drive will
338
+ * observe the child's state on replay via a subsequent trigger).
339
+ */
340
+ async function cascadeResumeParent(child, deps) {
341
+ if (!child.parent)
342
+ return;
343
+ const parent = await deps.store.get(child.parent.runId);
344
+ if (!parent)
345
+ return;
346
+ if (parent.status !== "waiting")
347
+ return;
348
+ const wpIdx = parent.pendingWaitpoints.findIndex((w) => w.clientWaitpointId === child.parent.waitpointId);
349
+ if (wpIdx < 0)
350
+ return;
351
+ const now = deps.now ?? (() => Date.now());
352
+ const at = now();
353
+ if (child.status === "completed") {
354
+ parent.journal.waitpointsResolved[child.parent.waitpointId] = {
355
+ kind: "RUN",
356
+ resolvedAt: at,
357
+ payload: child.output,
358
+ source: "replay",
359
+ };
360
+ }
361
+ else {
362
+ parent.journal.waitpointsResolved[child.parent.waitpointId] = {
363
+ kind: "RUN",
364
+ resolvedAt: at,
365
+ source: "replay",
366
+ error: {
367
+ category: child.error?.category ?? "USER_ERROR",
368
+ code: child.error?.code ?? "CHILD_RUN_ENDED",
369
+ message: child.error?.message ?? `child run ended with status ${child.status}`,
370
+ },
371
+ };
372
+ }
373
+ parent.pendingWaitpoints.splice(wpIdx, 1);
374
+ if (parent.pendingWaitpoints.length === 0) {
375
+ parent.status = "running";
376
+ }
377
+ // Re-drive the parent. This nested drive goes through the same
378
+ // handler / store / hooks, so the parent's own parent (if any)
379
+ // will also cascade-resume when appropriate.
380
+ const abortCtrl = registerRunAbort(parent.id);
381
+ try {
382
+ await driveUntilPaused(parent, {
383
+ ...driveOptionsFor(deps),
384
+ signal: abortCtrl.signal,
385
+ });
386
+ }
387
+ finally {
388
+ unregisterRunAbort(parent.id);
389
+ }
390
+ await deps.store.save(parent);
391
+ // The parent might itself have a parent — recurse.
392
+ if (isTerminalStatus(parent.status)) {
393
+ await cascadeResumeParent(parent, deps);
394
+ }
395
+ }
396
+ function isTerminalStatus(s) {
397
+ return (s === "completed" ||
398
+ s === "failed" ||
399
+ s === "cancelled" ||
400
+ s === "compensated" ||
401
+ s === "compensation_failed");
402
+ }
403
+ function defaultRunId(now) {
404
+ const ts = now().toString(36);
405
+ // Non-cryptographic; orchestrator core exposes `idGenerator` for
406
+ // callers that want stronger guarantees.
407
+ const rand = Math.floor(Math.random() * 1_000_000)
408
+ .toString(36)
409
+ .padStart(4, "0");
410
+ return `run_${ts}_${rand}`;
411
+ }
@@ -0,0 +1,40 @@
1
+ import type { JournalSlice, RunRecord } from "./types.js";
2
+ export interface BuildResumeJournalInput {
3
+ parent: RunRecord;
4
+ resumeFromStep?: string;
5
+ seedResults?: Record<string, unknown>;
6
+ now?: () => number;
7
+ }
8
+ export interface BuildResumeJournalResult {
9
+ resumeFromStep: string;
10
+ journal: JournalSlice;
11
+ metadataAppliedCount: number;
12
+ }
13
+ export interface BuildSeededResumeJournalInput {
14
+ parentRunId: string;
15
+ resumeFromStep: string;
16
+ seedResults: Record<string, unknown>;
17
+ metadataState?: Record<string, unknown>;
18
+ metadataAppliedCount?: number;
19
+ now?: () => number;
20
+ }
21
+ export declare function buildResumeJournal(input: BuildResumeJournalInput): BuildResumeJournalResult;
22
+ export declare function buildSeededResumeJournal(input: BuildSeededResumeJournalInput): BuildResumeJournalResult;
23
+ export type SeedResultsValidation = {
24
+ ok: true;
25
+ seedResults: Record<string, unknown>;
26
+ } | {
27
+ ok: false;
28
+ message: string;
29
+ };
30
+ /**
31
+ * Strict structural validation for caller-supplied `seedResults`
32
+ * (`POST /api/runs/:id/resume`). Seeded entries are written verbatim
33
+ * into the new run's journal as already-completed steps, so they let
34
+ * the caller assert "this step ran and produced this output" — they
35
+ * must be gated behind an operator credential AND shape-checked:
36
+ * a record of bounded, control-character-free step ids to
37
+ * JSON-serializable values, bounded in count and total size.
38
+ */
39
+ export declare function validateSeedResults(value: unknown): SeedResultsValidation;
40
+ //# sourceMappingURL=resume-run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resume-run.d.ts","sourceRoot":"","sources":["../src/resume-run.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAoB,MAAM,YAAY,CAAA;AAE3E,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,SAAS,CAAA;IACjB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,wBAAwB;IACvC,cAAc,EAAE,MAAM,CAAA;IACtB,OAAO,EAAE,YAAY,CAAA;IACrB,oBAAoB,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,WAAW,6BAA6B;IAC5C,WAAW,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACpC,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACvC,oBAAoB,CAAC,EAAE,MAAM,CAAA;IAC7B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,GAAG,wBAAwB,CAwC3F;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,6BAA6B,GACnC,wBAAwB,CAgB1B;AAED,MAAM,MAAM,qBAAqB,GAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAClD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAA;AAQlC;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,qBAAqB,CAsCzE"}