@lostgradient/weft 0.6.0 → 0.7.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.
Files changed (92) hide show
  1. package/README.md +4 -4
  2. package/dist/cli/generated/operation-client.generated.d.ts +1 -0
  3. package/dist/cli-main.js +63 -63
  4. package/dist/client/event-stream-options.d.ts +58 -0
  5. package/dist/client/event-stream-options.js +0 -0
  6. package/dist/client/event-stream.d.ts +27 -44
  7. package/dist/client/event-stream.js +21 -56
  8. package/dist/client/event-tail-lifecycle.d.ts +21 -0
  9. package/dist/client/event-tail-lifecycle.js +71 -0
  10. package/dist/client/event-tail.d.ts +3 -1
  11. package/dist/client/http-client.d.ts +4 -5
  12. package/dist/client/http-client.js +4 -1
  13. package/dist/client/http-handle.d.ts +4 -5
  14. package/dist/client/http-request.d.ts +7 -0
  15. package/dist/client/index.d.ts +2 -1
  16. package/dist/client/interface.d.ts +23 -7
  17. package/dist/client/local-handles.d.ts +2 -0
  18. package/dist/client/local-handles.js +10 -1
  19. package/dist/client/local.d.ts +3 -3
  20. package/dist/client/open-event-subscription.d.ts +3 -2
  21. package/dist/client/open-event-subscription.js +28 -1
  22. package/dist/client/sse-event-stream.d.ts +27 -0
  23. package/dist/client/sse-event-stream.js +276 -0
  24. package/dist/client/start-body.d.ts +2 -2
  25. package/dist/client/start-body.js +1 -0
  26. package/dist/core/engine/construction.js +3 -1
  27. package/dist/core/engine/engine-internal-types.d.ts +2 -0
  28. package/dist/core/engine/errors.d.ts +6 -6
  29. package/dist/core/engine/errors.js +1 -1
  30. package/dist/core/engine/handles.js +1 -1
  31. package/dist/core/engine/index.d.ts +6 -5
  32. package/dist/core/engine/index.js +1 -0
  33. package/dist/core/engine/lifecycle/recovered-services.js +8 -1
  34. package/dist/core/engine/lifecycle/start-or-signal-create.d.ts +2 -2
  35. package/dist/core/engine/lifecycle/start-or-signal-create.js +17 -15
  36. package/dist/core/engine/lifecycle/start-or-signal-resolution.d.ts +1 -1
  37. package/dist/core/engine/lifecycle/start-or-signal-resolution.js +23 -5
  38. package/dist/core/engine/lifecycle/start-or-signal.d.ts +6 -4
  39. package/dist/core/engine/lifecycle/start-or-signal.js +21 -4
  40. package/dist/core/engine/schedule-run.js +12 -3
  41. package/dist/core/engine/storage-io.js +1 -1
  42. package/dist/core/engine/termination/complete.js +3 -3
  43. package/dist/core/scheduler/scheduler-class.js +2 -1
  44. package/dist/core/start-workflow-validation.d.ts +3 -4
  45. package/dist/core/start-workflow-validation.js +1 -1
  46. package/dist/core/timeouts.d.ts +13 -1
  47. package/dist/core/timeouts.js +4 -1
  48. package/dist/core/types/constants.d.ts +16 -0
  49. package/dist/core/types/options.d.ts +60 -12
  50. package/dist/core/types/services-resolution.d.ts +49 -3
  51. package/dist/core/weft-error.d.ts +1 -1
  52. package/dist/core/weft-error.js +1 -0
  53. package/dist/index.d.ts +3 -3
  54. package/dist/index.js +1 -0
  55. package/dist/json-schema.js +1 -1
  56. package/dist/mcp/cli.js +17 -17
  57. package/dist/server/asyncapi-channels.js +82 -16
  58. package/dist/server/handler/auth-context-principal.d.ts +19 -0
  59. package/dist/server/handler/auth-context-principal.js +25 -0
  60. package/dist/server/handler/index.d.ts +2 -1
  61. package/dist/server/handler/index.js +35 -18
  62. package/dist/server/handler/route-dispatch.d.ts +12 -22
  63. package/dist/server/handler/route-dispatch.js +30 -36
  64. package/dist/server/handler/sse-route-dispatch.d.ts +25 -0
  65. package/dist/server/handler/sse-route-dispatch.js +45 -0
  66. package/dist/server/handler.js +21 -21
  67. package/dist/server/index.d.ts +8 -7
  68. package/dist/server/index.js +15 -15
  69. package/dist/server/operations/fleet-events-sse.d.ts +11 -0
  70. package/dist/server/operations/fleet-events-sse.js +164 -0
  71. package/dist/server/operations/sse-stream.d.ts +43 -0
  72. package/dist/server/operations/sse-stream.js +152 -7
  73. package/dist/server/operations/start-or-signal-workflow.d.ts +2 -0
  74. package/dist/server/operations/start-or-signal-workflow.js +25 -14
  75. package/dist/server/operations/start-workflow-options.d.ts +9 -6
  76. package/dist/server/operations/start-workflow-options.js +13 -0
  77. package/dist/server/operations/workflow-events-sse.d.ts +17 -0
  78. package/dist/server/operations/workflow-events-sse.js +200 -0
  79. package/dist/server/rest-bindings.js +12 -0
  80. package/dist/server/runtime/authentication-bridge.js +4 -0
  81. package/dist/server/runtime/cors.d.ts +1 -1
  82. package/dist/server/runtime/cors.js +6 -1
  83. package/dist/server/runtime/websocket-stream.d.ts +4 -0
  84. package/dist/server/runtime/websocket-stream.js +20 -3
  85. package/dist/server/workflow-event-feed.d.ts +1 -0
  86. package/dist/server/workflow-event-feed.js +17 -6
  87. package/dist/service-worker/index.js +28 -28
  88. package/dist/storage/typed-storage.js +1 -1
  89. package/dist/testing/index.js +17 -17
  90. package/dist/version.d.ts +1 -1
  91. package/dist/version.js +1 -1
  92. package/package.json +1 -1
@@ -10,7 +10,8 @@
10
10
  * @module client/open-event-subscription
11
11
  */
12
12
  import type { WorkflowEvent } from '../core/types.ts';
13
- import { WorkflowEventSubscription, type WorkflowEventStreamOptions } from './event-stream.ts';
13
+ import type { WorkflowEventStreamOptions } from './event-stream-options.ts';
14
+ import type { WorkflowEventTail } from './event-tail.ts';
14
15
  /**
15
16
  * The streaming-relevant view of an HTTP client: the fields a workflow event
16
17
  * subscription needs to open its `/watch` socket and run `getEvents` catch-up.
@@ -30,4 +31,4 @@ export type WorkflowEventStreamHost = {
30
31
  * consumers (`tail()`) so the connect catch-up is buffered for the async
31
32
  * iterator rather than dropped.
32
33
  */
33
- export declare function openClientEventSubscription(host: WorkflowEventStreamHost, streamOptions: WorkflowEventStreamOptions, workflowId: string, onEvent: (event: WorkflowEvent) => void, bufferForIteration?: boolean): WorkflowEventSubscription;
34
+ export declare function openClientEventSubscription(host: WorkflowEventStreamHost, streamOptions: WorkflowEventStreamOptions, workflowId: string, onEvent: (event: WorkflowEvent) => void, bufferForIteration?: boolean): WorkflowEventTail;
@@ -1,5 +1,32 @@
1
+ import { detectRuntime } from "../runtime/portable.js";
1
2
  import { workflowWatchWebSocketUrl } from "./event-stream-transport.js";
2
3
  import { WorkflowEventSubscription } from "./event-stream.js";
4
+ import { SseWorkflowEventSubscription, workflowEventsSseUrl } from "./sse-event-stream.js";
3
5
  export function openClientEventSubscription(host, streamOptions, workflowId, onEvent, bufferForIteration = !1) {
4
- return new WorkflowEventSubscription(workflowWatchWebSocketUrl(host.baseUrl, workflowId), host.headers, workflowId, (id) => host.getEvents(id), onEvent, bufferForIteration ? { ...streamOptions, bufferForIteration } : streamOptions);
6
+ const options = bufferForIteration ? { ...streamOptions, bufferForIteration } : streamOptions, transport = options.eventTransport ?? "auto";
7
+ if (transport === "sse")
8
+ return openSseEventSubscription(host, workflowId, onEvent, options);
9
+ if (shouldPreferServerSentEventsFallback(host, options, transport))
10
+ return openSseEventSubscription(host, workflowId, onEvent, options);
11
+ try {
12
+ return new WorkflowEventSubscription(workflowWatchWebSocketUrl(host.baseUrl, workflowId), host.headers, workflowId, (id) => host.getEvents(id), onEvent, options);
13
+ } catch (error) {
14
+ if (transport !== "auto")
15
+ throw error;
16
+ if (!isWebSocketTransportUnavailable(error))
17
+ throw error;
18
+ return openSseEventSubscription(host, workflowId, onEvent, options);
19
+ }
20
+ }
21
+ function shouldPreferServerSentEventsFallback(host, options, transport) {
22
+ return transport === "auto" && options.webSocketFactory === void 0 && detectRuntime() !== "bun" && Object.keys(host.headers).length > 0;
23
+ }
24
+ function isWebSocketTransportUnavailable(error) {
25
+ if (!(error instanceof Error))
26
+ return !1;
27
+ const message = error.message.toLowerCase();
28
+ return message.includes("no global websocket") || message.includes("cannot send the configured auth headers over websocket") || message.includes("websocket headers unavailable") || message.includes("websocket constructor has no header support") || message.includes("provide httpclientoptions.websocketfactory");
29
+ }
30
+ function openSseEventSubscription(host, workflowId, onEvent, options) {
31
+ return new SseWorkflowEventSubscription(workflowEventsSseUrl(host.baseUrl, workflowId), host.headers, workflowId, onEvent, options);
5
32
  }
@@ -0,0 +1,27 @@
1
+ import type { WorkflowEvent } from '../core/types.ts';
2
+ import type { StreamCloseReason } from './event-stream.ts';
3
+ import type { WorkflowEventTail } from './event-tail.ts';
4
+ export type ServerSentEventFrame = {
5
+ readonly id?: string;
6
+ readonly event?: string;
7
+ readonly data: string;
8
+ };
9
+ export type ServerSentEventParseResult = {
10
+ readonly frames: ReadonlyArray<ServerSentEventFrame>;
11
+ readonly remainder: string;
12
+ };
13
+ export type SseWorkflowEventStreamOptions = {
14
+ readonly maxReconnectAttempts?: number;
15
+ readonly reconnectBackoffMs?: number;
16
+ readonly bufferForIteration?: boolean;
17
+ };
18
+ export declare function workflowEventsSseUrl(baseUrl: string, workflowId: string): string;
19
+ export declare function parseServerSentEventChunk(text: string, previousRemainder?: string): ServerSentEventParseResult;
20
+ export declare class SseWorkflowEventSubscription implements WorkflowEventTail {
21
+ #private;
22
+ constructor(url: string, headers: Record<string, string>, workflowId: string, onEvent: (event: WorkflowEvent) => void, options?: SseWorkflowEventStreamOptions);
23
+ get closeReason(): StreamCloseReason | null;
24
+ whenConnected(): Promise<void>;
25
+ close(): void;
26
+ [Symbol.asyncIterator](): AsyncIterator<WorkflowEvent>;
27
+ }
@@ -0,0 +1,276 @@
1
+ import { WORKFLOW_TERMINAL_EVENT_TYPES } from "../core/events/workflow-events.js";
2
+ import { WorkflowEventTailLifecycle } from "./event-tail-lifecycle.js";
3
+ const DEFAULT_MAX_RECONNECT_ATTEMPTS = 5, DEFAULT_RECONNECT_BACKOFF_MS = 50;
4
+ export function workflowEventsSseUrl(baseUrl, workflowId) {
5
+ return `${baseUrl.replace(/\/+$/, "")}/v1/workflows/${encodeURIComponent(workflowId)}/events/sse`;
6
+ }
7
+ export function parseServerSentEventChunk(text, previousRemainder = "") {
8
+ const parts = `${previousRemainder}${text}`.replaceAll(`\r
9
+ `, `
10
+ `).replaceAll("\r", `
11
+ `).split(`
12
+
13
+ `), remainder = parts.pop() ?? "";
14
+ return { frames: parts.map(parseServerSentEventFrame).filter((frame) => frame !== null), remainder };
15
+ }
16
+ function parseServerSentEventFrame(block) {
17
+ const parts = {
18
+ id: void 0,
19
+ event: void 0,
20
+ dataLines: []
21
+ };
22
+ for (const rawLine of block.split(`
23
+ `))
24
+ applyServerSentEventLine(parts, rawLine);
25
+ return frameFromParts(parts);
26
+ }
27
+ function applyServerSentEventLine(parts, rawLine) {
28
+ if (rawLine === "" || rawLine.startsWith(":"))
29
+ return;
30
+ const { field, value } = splitServerSentEventLine(rawLine);
31
+ if (field === "id")
32
+ parts.id = value;
33
+ else if (field === "event")
34
+ parts.event = value;
35
+ else if (field === "data")
36
+ parts.dataLines.push(value);
37
+ }
38
+ function splitServerSentEventLine(rawLine) {
39
+ const separator = rawLine.indexOf(":"), field = separator === -1 ? rawLine : rawLine.slice(0, separator), rawValue = separator === -1 ? "" : rawLine.slice(separator + 1);
40
+ return {
41
+ field,
42
+ value: rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue
43
+ };
44
+ }
45
+ function frameFromParts(parts) {
46
+ if (parts.id === void 0 && parts.event === void 0 && parts.dataLines.length === 0)
47
+ return null;
48
+ return {
49
+ ...parts.id === void 0 ? {} : { id: parts.id },
50
+ ...parts.event === void 0 ? {} : { event: parts.event },
51
+ data: parts.dataLines.join(`
52
+ `)
53
+ };
54
+ }
55
+ function isRecord(value) {
56
+ return typeof value === "object" && value !== null && !Array.isArray(value);
57
+ }
58
+ function parseWorkflowEventEnvelope(data) {
59
+ const parsed = parseJson(data);
60
+ if (!isRecord(parsed))
61
+ return null;
62
+ return workflowEventEnvelopeFromRecord(parsed);
63
+ }
64
+ function isReplayCompletePing(data) {
65
+ const parsed = parseJson(data);
66
+ return isRecord(parsed) && parsed.replayComplete === !0;
67
+ }
68
+ function parseJson(data) {
69
+ try {
70
+ return JSON.parse(data);
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ function workflowEventEnvelopeFromRecord(parsed) {
76
+ if (typeof parsed.kind !== "string")
77
+ return null;
78
+ if (typeof parsed.workflowId !== "string")
79
+ return null;
80
+ if (!isWorkflowEventSelector(parsed.selector))
81
+ return null;
82
+ const sequence = finiteNumber(parsed.sequence);
83
+ if (sequence === null)
84
+ return null;
85
+ if (typeof parsed.cursor !== "string")
86
+ return null;
87
+ const emittedAtMs = finiteNumber(parsed.emittedAtMs);
88
+ if (emittedAtMs === null)
89
+ return null;
90
+ if (!Object.hasOwn(parsed, "payload"))
91
+ return null;
92
+ return {
93
+ kind: parsed.kind,
94
+ workflowId: parsed.workflowId,
95
+ selector: parsed.selector,
96
+ sequence,
97
+ cursor: parsed.cursor,
98
+ emittedAtMs,
99
+ payload: parsed.payload
100
+ };
101
+ }
102
+ function isWorkflowEventSelector(value) {
103
+ return value === "events" || value === "tokens";
104
+ }
105
+ function finiteNumber(value) {
106
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
107
+ }
108
+ function envelopeToWorkflowEvent(envelope) {
109
+ return {
110
+ type: envelope.kind,
111
+ timestamp: envelope.emittedAtMs,
112
+ data: isRecord(envelope.payload) ? envelope.payload : { value: envelope.payload }
113
+ };
114
+ }
115
+ function isEventStreamResponse(response) {
116
+ const contentType = response.headers.get("Content-Type");
117
+ if (contentType === null)
118
+ return !1;
119
+ return contentType.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream";
120
+ }
121
+
122
+ export class SseWorkflowEventSubscription {
123
+ #url;
124
+ #headers;
125
+ #workflowId;
126
+ #onEvent;
127
+ #maxReconnectAttempts;
128
+ #reconnectBackoffMs;
129
+ #lifecycle;
130
+ #connected = Promise.withResolvers();
131
+ #abortController = null;
132
+ #reconnectAttempts = 0;
133
+ #reconnectTimer = null;
134
+ #lastEventId = null;
135
+ constructor(url, headers, workflowId, onEvent, options) {
136
+ this.#url = url;
137
+ this.#headers = headers;
138
+ this.#workflowId = workflowId;
139
+ this.#onEvent = onEvent;
140
+ this.#maxReconnectAttempts = options?.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
141
+ this.#reconnectBackoffMs = options?.reconnectBackoffMs ?? DEFAULT_RECONNECT_BACKOFF_MS;
142
+ this.#lifecycle = new WorkflowEventTailLifecycle({
143
+ bufferForIteration: options?.bufferForIteration ?? !1,
144
+ onClose: (reason) => this.#onLifecycleClose(reason)
145
+ });
146
+ this.#connect();
147
+ }
148
+ get closeReason() {
149
+ return this.#lifecycle.closeReason;
150
+ }
151
+ whenConnected() {
152
+ return this.#connected.promise;
153
+ }
154
+ async#connect() {
155
+ if (this.#lifecycle.closed)
156
+ return;
157
+ const abortController = new AbortController;
158
+ this.#abortController = abortController;
159
+ try {
160
+ const response = await fetch(this.#url, {
161
+ headers: this.#requestHeaders(),
162
+ signal: abortController.signal
163
+ });
164
+ if (!response.ok || response.body === null) {
165
+ this.#terminate("server-error");
166
+ return;
167
+ }
168
+ if (!isEventStreamResponse(response)) {
169
+ this.#terminate("server-error");
170
+ return;
171
+ }
172
+ await this.#readResponseBody(response.body);
173
+ if (!this.#lifecycle.closed)
174
+ this.#scheduleReconnect();
175
+ } catch {
176
+ if (!this.#lifecycle.closed)
177
+ this.#scheduleReconnect();
178
+ } finally {
179
+ if (this.#abortController === abortController)
180
+ this.#abortController = null;
181
+ }
182
+ }
183
+ #requestHeaders() {
184
+ const headers = new Headers(this.#headers);
185
+ headers.set("Accept", "text/event-stream");
186
+ headers.set("Cache-Control", "no-cache");
187
+ if (this.#lastEventId !== null)
188
+ headers.set("Last-Event-ID", this.#lastEventId);
189
+ return headers;
190
+ }
191
+ async#readResponseBody(body) {
192
+ const reader = body.getReader(), decoder = new TextDecoder;
193
+ let remainder = "";
194
+ try {
195
+ while (!this.#lifecycle.closed) {
196
+ const chunk = await reader.read();
197
+ if (chunk.done)
198
+ return;
199
+ const parsed = parseServerSentEventChunk(decoder.decode(chunk.value, { stream: !0 }), remainder);
200
+ remainder = parsed.remainder;
201
+ for (const frame of parsed.frames) {
202
+ const handling = this.#handleFrame(frame);
203
+ if (this.#lifecycle.closed || handling === "server-error")
204
+ return;
205
+ }
206
+ }
207
+ } finally {
208
+ reader.releaseLock();
209
+ }
210
+ }
211
+ #handleFrame(frame) {
212
+ if (frame.event === "ping") {
213
+ this.#markHealthyFrame();
214
+ if (isReplayCompletePing(frame.data))
215
+ this.#markConnected();
216
+ return "healthy";
217
+ }
218
+ if (frame.event === "error") {
219
+ this.#terminate("server-error");
220
+ return "server-error";
221
+ }
222
+ const envelope = parseWorkflowEventEnvelope(frame.data);
223
+ if (envelope === null)
224
+ return "ignored";
225
+ if (envelope.workflowId !== this.#workflowId)
226
+ return "ignored";
227
+ this.#lastEventId = frame.id ?? envelope.cursor;
228
+ this.#markHealthyFrame();
229
+ this.#emit(envelopeToWorkflowEvent(envelope));
230
+ return "healthy";
231
+ }
232
+ #emit(event) {
233
+ if (!this.#lifecycle.emit(event, this.#onEvent))
234
+ return;
235
+ if (WORKFLOW_TERMINAL_EVENT_TYPES.has(event.type))
236
+ this.#terminate("workflow-terminal");
237
+ }
238
+ #scheduleReconnect() {
239
+ if (this.#lifecycle.closed || this.#reconnectTimer !== null)
240
+ return;
241
+ if (this.#reconnectAttempts >= this.#maxReconnectAttempts) {
242
+ this.#terminate("reconnect-exhausted");
243
+ return;
244
+ }
245
+ this.#reconnectAttempts += 1;
246
+ const delay = this.#reconnectBackoffMs * this.#reconnectAttempts;
247
+ this.#reconnectTimer = setTimeout(() => {
248
+ this.#reconnectTimer = null;
249
+ this.#connect();
250
+ }, delay);
251
+ }
252
+ #markConnected() {
253
+ this.#connected.resolve();
254
+ }
255
+ #markHealthyFrame() {
256
+ this.#reconnectAttempts = 0;
257
+ }
258
+ #terminate(reason) {
259
+ this.#lifecycle.terminate(reason);
260
+ }
261
+ #onLifecycleClose(_reason) {
262
+ this.#markConnected();
263
+ if (this.#reconnectTimer !== null) {
264
+ clearTimeout(this.#reconnectTimer);
265
+ this.#reconnectTimer = null;
266
+ }
267
+ this.#abortController?.abort();
268
+ this.#abortController = null;
269
+ }
270
+ close() {
271
+ this.#lifecycle.close();
272
+ }
273
+ [Symbol.asyncIterator]() {
274
+ return this.#lifecycle[Symbol.asyncIterator]();
275
+ }
276
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ScheduleSpec, StartOrSignalSignal } from '../core/types.ts';
2
- import type { ClientStartOptions } from './interface.ts';
2
+ import type { ClientStartOptions, ClientStartOrSignalOptions } from './interface.ts';
3
3
  export declare function setIfDefined(body: Record<string, unknown>, key: string, value: unknown): void;
4
4
  /**
5
5
  * Translate a schedule recurrence specification into the wire body fields the
@@ -13,4 +13,4 @@ export declare function buildStartBody(type: string, input: unknown, options?: C
13
13
  * spec into `signalName` / `signalPayload` / `signalId` and carries the same
14
14
  * start options as {@link buildStartBody}.
15
15
  */
16
- export declare function buildStartOrSignalBody(type: string, input: unknown, signal: StartOrSignalSignal, options?: ClientStartOptions): Record<string, unknown>;
16
+ export declare function buildStartOrSignalBody(type: string, input: unknown, signal: StartOrSignalSignal, options?: ClientStartOrSignalOptions): Record<string, unknown>;
@@ -28,5 +28,6 @@ export function buildStartOrSignalBody(type, input, signal, options) {
28
28
  setIfDefined(body, "signalPayload", signal.payload);
29
29
  setIfDefined(body, "signalId", signal.signalId);
30
30
  applyStartOptionsToBody(body, options);
31
+ setIfDefined(body, "onTerminalConflict", options?.onTerminalConflict);
31
32
  return body;
32
33
  }
@@ -6,6 +6,7 @@ import { WorkerPool } from "../../workers/pool.js";
6
6
  import { optionalInlineDependencies } from "../inline-execution-strategy.context-options.js";
7
7
  import { InlineExecutionStrategy } from "../inline-execution-strategy.js";
8
8
  import {
9
+ DEFAULT_POLL_INTERVAL_MS,
9
10
  DEFAULT_RETENTION_SWEEP_BATCH_SIZE,
10
11
  DEFAULT_RETENTION_SWEEP_INTERVAL_MS
11
12
  } from "../types.js";
@@ -96,7 +97,8 @@ function resolveNumericDefaults(options) {
96
97
  return {
97
98
  checkpointHistory: defaultTo(options?.checkpointHistory, 10),
98
99
  checkpointSizeWarningThreshold: defaultTo(options?.checkpointSizeWarningThreshold, 65536),
99
- maxNestingDepth: defaultTo(options?.maxNestingDepth, 10)
100
+ maxNestingDepth: defaultTo(options?.maxNestingDepth, 10),
101
+ schedulerPollIntervalMs: normalizePositiveSafeIntegerOption(options?.schedulerPollIntervalMs, "options.schedulerPollIntervalMs", DEFAULT_POLL_INTERVAL_MS) ?? DEFAULT_POLL_INTERVAL_MS
100
102
  };
101
103
  }
102
104
  function resolveHistoryFields(options) {
@@ -27,6 +27,8 @@ export interface ResolvedOptions {
27
27
  checkpointHistory: number;
28
28
  checkpointSizeWarningThreshold: number;
29
29
  maxNestingDepth: number;
30
+ /** Durable-timer scheduler poll interval (ms). */
31
+ schedulerPollIntervalMs: number;
30
32
  broadcastEvents: boolean;
31
33
  retention: NormalizedRetentionPolicy | null;
32
34
  retentionSweepIntervalMs: number;
@@ -289,14 +289,14 @@ export declare class ActivityResolutionError extends WeftError<'ActivityResoluti
289
289
  /**
290
290
  * Thrown by {@link Engine.startOrSignal} when the target workflow already exists
291
291
  * and is in a terminal state (completed, failed, cancelled, or timed out). A
292
- * terminal run cannot accept a signal and must not be silently replaced, so
293
- * `startOrSignal` surfaces this conflict instead of starting a fresh run under
294
- * the same id or dropping the signal.
292
+ * terminal run cannot accept a signal. By default `startOrSignal` surfaces this
293
+ * conflict instead of starting a fresh run under the same id or dropping the
294
+ * signal.
295
295
  *
296
296
  * Inspect `workflowId` to identify the conflicting run and `status` to see why
297
- * it was rejected. To deliberately start a new run, choose a different id (or
298
- * idempotency key); to deliver to a fresh run, terminal-state reuse is not a
299
- * supported policy.
297
+ * it was rejected. To deliberately reuse a stable id, call `startOrSignal` with
298
+ * `onTerminalConflict: 'start-new'`, an explicit `id`, and a deterministic
299
+ * `signalId`; otherwise choose a different id or idempotency key.
300
300
  *
301
301
  * @example
302
302
  * ```ts
@@ -118,7 +118,7 @@ export class StartOrSignalConflictError extends WeftError {
118
118
  workflowId;
119
119
  status;
120
120
  constructor(workflowId, status) {
121
- super("StartOrSignalConflictError", `Workflow "${workflowId}" is already in terminal state "${status}" and cannot accept a startOrSignal: a terminal run cannot be signalled and is not replaced. Use a different id or idempotency key to start a new run.`);
121
+ super("StartOrSignalConflictError", `Workflow "${workflowId}" is already in terminal state "${status}" and cannot accept a startOrSignal. Use options.onTerminalConflict: 'start-new' with an explicit id and signalId, or choose a different id or idempotency key to start a new run.`);
122
122
  this.workflowId = workflowId;
123
123
  this.status = status;
124
124
  }
@@ -129,7 +129,7 @@ export class WorkflowHandle extends EventTarget {
129
129
  observer.error?.(event.error);
130
130
  } else if (event instanceof WorkflowTimedOutEvent) {
131
131
  terminalDelivered = !0;
132
- observer.error?.(new WorkflowTimeoutError(event.workflowId, event.timeoutType, event.elapsed));
132
+ observer.error?.(new WorkflowTimeoutError(event.workflowId, event.timeoutType, event.elapsed, event.reason));
133
133
  }
134
134
  };
135
135
  this.addEventListener("workflow:failed", errorHandler, { signal: controller.signal });
@@ -5,7 +5,7 @@ import type { TypedEventTarget, WeftEventMap } from '../events.ts';
5
5
  import type { Interceptor } from '../interceptor.ts';
6
6
  import { type ReviewRequest } from '../review/index.ts';
7
7
  import { Scheduler } from '../scheduler.ts';
8
- import { type AnyActivityDefinition, type AnyWorkflowDefinition, type AttributeFilterKey, type BulkCancelResult, type BulkDeleteResult, type BulkOperationCommitOptions, type BulkOperationDryRunOptions, type BulkOperationDryRunResult, type BulkRetryFailedResult, type BulkSignalAllCommitOptions, type BulkSignalAllDryRunOptions, type BulkSignalResult, type BulkTagResult, type CheckpointState, type CheckpointSummary, type CoordinatedUpdateResult, type DefaultActivityTypes, type DefaultWorkflowRegistry, type ForkOptions, type InferActivityEntries, type InferActivityEntry, type InferWorkflowEntries, type InferWorkflowEntry, type ListFilter, type ListOptions, type PaginatedResult, type PurgeResult, type QueryDefinition, type RegisteredWorkflowDefinition, type RetentionOverview, type ReviewListEntry, type ReviewListFilter, type ScheduleDefinition, type ScheduleFilter, type ScheduleOptions, type ScheduleSpec, type ScheduleSummary, type SearchAttributeValue, type SignalDefinition, type SignalDeliveryOptions, type StartOptions, type StartOrSignalSignal, type StartWorkflowOptions, type SubmitReviewOptions, type TypedListFilter, type UpdateDefinition, type WorkflowEvent, type WorkflowInput, type WorkflowOutput, type WorkflowReplay, type WorkflowServices, type WorkflowServicesUnion, type WorkflowState, type WorkflowSummary, type WorkflowTimelineEntry } from '../types.ts';
8
+ import { type AnyActivityDefinition, type AnyWorkflowDefinition, type AttributeFilterKey, type BulkCancelResult, type BulkDeleteResult, type BulkOperationCommitOptions, type BulkOperationDryRunOptions, type BulkOperationDryRunResult, type BulkRetryFailedResult, type BulkSignalAllCommitOptions, type BulkSignalAllDryRunOptions, type BulkSignalResult, type BulkTagResult, type CheckpointState, type CheckpointSummary, type CoordinatedUpdateResult, type DefaultActivityTypes, type DefaultWorkflowRegistry, type ForkOptions, type InferActivityEntries, type InferActivityEntry, type InferWorkflowEntries, type InferWorkflowEntry, type ListFilter, type ListOptions, type PaginatedResult, type PurgeResult, type QueryDefinition, type RegisteredWorkflowDefinition, type RetentionOverview, type ReviewListEntry, type ReviewListFilter, type ScheduleDefinition, type ScheduleFilter, type ScheduleOptions, type ScheduleSpec, type ScheduleSummary, type SearchAttributeValue, type SignalDefinition, type SignalDeliveryOptions, type StartOrSignalOptions, type StartOrSignalSignal, type StartWorkflowOptions, type SubmitReviewOptions, type TypedListFilter, type UpdateDefinition, type WorkflowEvent, type WorkflowInput, type WorkflowOutput, type WorkflowReplay, type WorkflowServices, type WorkflowServicesUnion, type WorkflowState, type WorkflowSummary, type WorkflowTimelineEntry } from '../types.ts';
9
9
  import type { TimerEntry } from '../types/checkpoint.ts';
10
10
  import type { WorkflowAlreadyRegistered } from '../types/workflow-builder.ts';
11
11
  import { type AggregateOptions, type AggregateResult } from './aggregate.ts';
@@ -202,8 +202,9 @@ export declare class Engine<TWorkflows extends object = DefaultWorkflowRegistry,
202
202
  * first signal commit in one batch and the freshly-launched run consumes the
203
203
  * signal on its first drive. A non-terminal target (running, pending, or
204
204
  * suspended) is signalled through the normal signal path; a terminal target
205
- * throws {@link StartOrSignalConflictError} rather than starting a new run or
206
- * dropping the signal.
205
+ * throws {@link StartOrSignalConflictError} unless
206
+ * `options.onTerminalConflict: 'start-new'` is supplied with an explicit
207
+ * workflow id and deterministic `signal.signalId`.
207
208
  *
208
209
  * Concurrent callers converge on one workflow and one delivered signal. Pass
209
210
  * `options.idempotencyKey` to dedup independent callers (e.g. retried
@@ -213,8 +214,8 @@ export declare class Engine<TWorkflows extends object = DefaultWorkflowRegistry,
213
214
  * are `options.id` and `options.idempotencyKey`. Requires a storage backend
214
215
  * with `conditionalBatch`.
215
216
  */
216
- startOrSignal<TName extends KnownWorkflowNames<TWorkflows>>(type: TName, input: WorkflowInput<TWorkflows, TName>, signal: StartOrSignalSignal, options?: StartOptions<WorkflowServices<TWorkflows, TName>>): Promise<StartOrSignalResult<WorkflowOutput<TWorkflows, TName>>>;
217
- startOrSignal<TName extends string>(type: UnknownWorkflowNameWhenDefaultRegistryIsEmpty<TWorkflows, TName>, input: unknown, signal: StartOrSignalSignal, options?: StartOptions): Promise<StartOrSignalResult>;
217
+ startOrSignal<TName extends KnownWorkflowNames<TWorkflows>>(type: TName, input: WorkflowInput<TWorkflows, TName>, signal: StartOrSignalSignal, options?: StartOrSignalOptions<WorkflowServices<TWorkflows, TName>>): Promise<StartOrSignalResult<WorkflowOutput<TWorkflows, TName>>>;
218
+ startOrSignal<TName extends string>(type: UnknownWorkflowNameWhenDefaultRegistryIsEmpty<TWorkflows, TName>, input: unknown, signal: StartOrSignalSignal, options?: StartOrSignalOptions): Promise<StartOrSignalResult>;
218
219
  getHandle(workflowId: string): WorkflowHandle;
219
220
  list<const TAttributeKeys extends readonly AttributeFilterKey[] = readonly AttributeFilterKey[]>(filter?: TypedListFilter<TAttributeKeys>, options?: ListOptions): Promise<PaginatedResult<WorkflowSummary>>;
220
221
  aggregate(filter: ListFilter | undefined, options: AggregateOptions): Promise<AggregateResult>;
@@ -289,6 +289,7 @@ export class Engine extends EventTarget {
289
289
  getInternals(this).scheduler = new Scheduler({
290
290
  storage,
291
291
  onTimerFired: (entry) => handleTimerFiredFromInternals(getInternals(this), entry, this.#createTimeOperationCallbacks()),
292
+ pollIntervalMs: resolvedOptions.schedulerPollIntervalMs,
292
293
  getNow,
293
294
  commitTimerCleanup: (operations) => commitFencedEngineWrite(getInternals(this), operations, [], () => Error("Timer cleanup lost its fenced CAS race"))
294
295
  });
@@ -24,7 +24,8 @@ export async function reprovideRecoveredServices(internals, state, failRun, onCo
24
24
  const resolution = await resolver({
25
25
  workflowId: state.id,
26
26
  workflowType: state.type,
27
- input: state.input
27
+ input: state.input,
28
+ launchOptions: launchOptionsFromWorkflowState(state)
28
29
  });
29
30
  if (resolution.status === "available") {
30
31
  internals.workflowServices.set(state.id, resolution.services);
@@ -47,3 +48,9 @@ export function unavailableServicesError(workflowId, reason) {
47
48
  function missingServicesResolverReason(workflowId) {
48
49
  return `Workflow "${workflowId}" was started with services, but this engine has no resolveWorkflowServices option. Configure EngineOptions.resolveWorkflowServices before recovery so ctx.services can be re-provided.`;
49
50
  }
51
+ function launchOptionsFromWorkflowState(state) {
52
+ return {
53
+ id: state.id,
54
+ ...state.tags !== void 0 && state.tags.length > 0 ? { tags: [...state.tags] } : {}
55
+ };
56
+ }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module core/engine/lifecycle/start-or-signal-create
9
9
  */
10
- import type { StartOptions, StartOrSignalSignal } from '../../types.ts';
10
+ import type { StartOrSignalOptions, StartOrSignalSignal } from '../../types.ts';
11
11
  import { type StartOrSignalOutcome, type WorkflowHandle } from '../handles.ts';
12
12
  import type { EngineInternals } from '../internals.ts';
13
13
  import { type BuildIdempotentStartOperations } from './start-commit.ts';
@@ -55,4 +55,4 @@ export type StartOrSignalResult<TResult = unknown> = {
55
55
  * (scan-then-park), and the caller's payload loses to the pre-buffered one by
56
56
  * the same first-wins dedup.
57
57
  */
58
- export declare function createWithSignalOrFallback(internals: EngineInternals, type: string, input: unknown, signalSpec: StartOrSignalSignal, signalId: string, options: StartOptions | undefined, callbacks: StartOrSignalCallbacks): Promise<StartOrSignalResult>;
58
+ export declare function createWithSignalOrFallback(internals: EngineInternals, type: string, input: unknown, signalSpec: StartOrSignalSignal, signalId: string, options: StartOrSignalOptions | undefined, callbacks: StartOrSignalCallbacks): Promise<StartOrSignalResult>;
@@ -44,23 +44,25 @@ export async function createWithSignalOrFallback(internals, type, input, signalS
44
44
  });
45
45
  if (outcome.kind === "created")
46
46
  return { handle: outcome.handle, outcome: "started" };
47
- if (outcome.kind === "lost-keyed")
48
- return {
49
- handle: await resolveWinnerWithSignal(internals, outcome.id, signalSpec, signalId, callbacks, outcome.idempotencyKey),
50
- outcome: "signalled"
51
- };
52
- if (outcome.kind === "lost-caller-id") {
53
- const resolved = await resolveCallerIdWinnerOrRetry(internals, outcome.id, signalSpec, signalId, callbacks);
54
- if (resolved !== void 0)
55
- return { handle: resolved, outcome: "signalled" };
56
- } else {
57
- const resolved = await plainCreateBufferedSignalOrResolve(internals, type, input, signalSpec, signalId, options, callbacks);
58
- if (resolved !== void 0)
59
- return resolved;
60
- }
47
+ const resolved = await resolveCreateFallbackOutcome(internals, outcome, type, input, signalSpec, signalId, options, callbacks);
48
+ if (resolved !== void 0)
49
+ return resolved;
61
50
  }
62
51
  throw Error(`startOrSignal could not create workflow "${options?.id ?? "<generated>"}" after ${CALLER_ID_CREATE_MAX_ATTEMPTS} attempts: each concurrent same-id winner aborted before its durable commit.`);
63
52
  }
53
+ async function resolveCreateFallbackOutcome(internals, outcome, type, input, signalSpec, signalId, options, callbacks) {
54
+ if (outcome.kind === "lost-keyed")
55
+ return {
56
+ handle: await resolveWinnerWithSignal(internals, outcome.id, signalSpec, signalId, callbacks, outcome.idempotencyKey),
57
+ outcome: "signalled"
58
+ };
59
+ const allowTerminalRestart = options?.onTerminalConflict === "start-new";
60
+ if (outcome.kind === "lost-caller-id") {
61
+ const resolved = await resolveCallerIdWinnerOrRetry(internals, outcome.id, signalSpec, signalId, callbacks, allowTerminalRestart);
62
+ return resolved === void 0 ? void 0 : { handle: resolved, outcome: "signalled" };
63
+ }
64
+ return plainCreateBufferedSignalOrResolve(internals, type, input, signalSpec, signalId, options, callbacks);
65
+ }
64
66
  async function plainCreateBufferedSignalOrResolve(internals, type, input, signalSpec, signalId, options, callbacks) {
65
67
  try {
66
68
  return {
@@ -70,7 +72,7 @@ async function plainCreateBufferedSignalOrResolve(internals, type, input, signal
70
72
  } catch (error) {
71
73
  if (!(error instanceof WorkflowAlreadyExistsError))
72
74
  throw error;
73
- const resolved = await resolveCallerIdWinnerOrRetry(internals, error.workflowId, signalSpec, signalId, callbacks);
75
+ const resolved = await resolveCallerIdWinnerOrRetry(internals, error.workflowId, signalSpec, signalId, callbacks, options?.onTerminalConflict === "start-new");
74
76
  return resolved === void 0 ? void 0 : { handle: resolved, outcome: "signalled" };
75
77
  }
76
78
  }
@@ -47,7 +47,7 @@ export declare function resolveExistingRunOrThrowPurged(internals: EngineInterna
47
47
  * committing (storage failure, oversized payload, throwing start interceptor): no
48
48
  * run exists, so return `undefined` and let the caller retry its own create.
49
49
  */
50
- export declare function resolveCallerIdWinnerOrRetry(internals: EngineInternals, winnerId: string, signalSpec: StartOrSignalSignal, signalId: string, callbacks: StartOrSignalCallbacks): Promise<WorkflowHandle | undefined>;
50
+ export declare function resolveCallerIdWinnerOrRetry(internals: EngineInternals, winnerId: string, signalSpec: StartOrSignalSignal, signalId: string, callbacks: StartOrSignalCallbacks, allowTerminalRestart?: boolean): Promise<WorkflowHandle | undefined>;
51
51
  /**
52
52
  * For a workflow that already exists: signal it if non-terminal, conflict if
53
53
  * terminal. Returns the handle on a successful signal, or `undefined` when the
@@ -23,12 +23,30 @@ async function awaitReservationCleared(internals, workflowId) {
23
23
  await sleep(RESERVATION_CLEAR_RETRY_DELAY_MS);
24
24
  }
25
25
  }
26
- export async function resolveCallerIdWinnerOrRetry(internals, winnerId, signalSpec, signalId, callbacks) {
27
- const resolved = await signalOrConflictExistingWorkflow(internals, winnerId, signalSpec, signalId, callbacks);
28
- if (resolved !== void 0)
29
- return resolved;
26
+ export async function resolveCallerIdWinnerOrRetry(internals, winnerId, signalSpec, signalId, callbacks, allowTerminalRestart = !1) {
27
+ const state = await loadWorkflowState(internals, winnerId);
28
+ if (state !== null) {
29
+ if (!isTerminalWorkflowStatus(state.status)) {
30
+ await callbacks.signalExistingWorkflow(winnerId, signalSpec.name, signalSpec.payload, signalId);
31
+ return callbacks.getHandle(winnerId);
32
+ }
33
+ if (!allowTerminalRestart)
34
+ throw new StartOrSignalConflictError(winnerId, state.status);
35
+ }
30
36
  await awaitReservationCleared(internals, winnerId);
31
- return signalOrConflictExistingWorkflow(internals, winnerId, signalSpec, signalId, callbacks);
37
+ const stateAfterReservation = await loadWorkflowState(internals, winnerId);
38
+ if (stateAfterReservation === null)
39
+ return;
40
+ if (isTerminalWorkflowStatus(stateAfterReservation.status)) {
41
+ if (state !== null && isSameTerminalRun(state, stateAfterReservation))
42
+ return;
43
+ throw new StartOrSignalConflictError(winnerId, stateAfterReservation.status);
44
+ }
45
+ await callbacks.signalExistingWorkflow(winnerId, signalSpec.name, signalSpec.payload, signalId);
46
+ return callbacks.getHandle(winnerId);
47
+ }
48
+ function isSameTerminalRun(before, after) {
49
+ return before.id === after.id && before.type === after.type && before.status === after.status && before.createdAt === after.createdAt && before.updatedAt === after.updatedAt && before.terminalCleanupToken === after.terminalCleanupToken;
32
50
  }
33
51
  export async function signalOrConflictExistingWorkflow(internals, workflowId, signalSpec, signalId, callbacks) {
34
52
  const state = await loadWorkflowState(internals, workflowId);
@@ -1,4 +1,4 @@
1
- import type { StartOptions, StartOrSignalSignal } from '../../types.ts';
1
+ import type { StartOptions, StartOrSignalOptions, StartOrSignalSignal } from '../../types.ts';
2
2
  import { type WorkflowHandle } from '../handles.ts';
3
3
  import type { EngineInternals } from '../internals.ts';
4
4
  import { type LifecycleCallbacks } from './shared.ts';
@@ -28,8 +28,10 @@ export declare function startWithIdempotency(internals: EngineInternals, type: s
28
28
  * - **Non-terminal** (running, pending, suspended) → deliver the signal through
29
29
  * the standard engine signal path with the same `signalId`, so it dedups
30
30
  * against a create-batch signal a concurrent winner may have written.
31
- * - **Terminal** → throw {@link StartOrSignalConflictError}: a finished run
32
- * cannot be signalled and is not silently replaced.
31
+ * - **Terminal** → throw {@link StartOrSignalConflictError} by default. With
32
+ * `options.onTerminalConflict: 'start-new'`, purge the prior terminal run
33
+ * through the shared terminal-replacement path, start a fresh run, and deliver
34
+ * the initial signal in the create batch.
33
35
  *
34
36
  * Convergence requires a SHARED workflow identity. Concurrent callers converge on
35
37
  * one workflow and one signal only when they share an `options.idempotencyKey`
@@ -43,4 +45,4 @@ export declare function startWithIdempotency(internals: EngineInternals, type: s
43
45
  * convergence primitive. Use `idempotencyKey` (id-free convergence) or
44
46
  * `id` + `signalId` when concurrent callers must converge.
45
47
  */
46
- export declare function startOrSignal(internals: EngineInternals, type: string, input: unknown, signalSpec: StartOrSignalSignal, options: StartOptions | undefined, callbacks: StartOrSignalCallbacks): Promise<StartOrSignalResult>;
48
+ export declare function startOrSignal(internals: EngineInternals, type: string, input: unknown, signalSpec: StartOrSignalSignal, options: StartOrSignalOptions | undefined, callbacks: StartOrSignalCallbacks): Promise<StartOrSignalResult>;