@lostgradient/weft 0.5.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 (94) 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.d.ts +1 -0
  27. package/dist/core/engine/construction.js +3 -1
  28. package/dist/core/engine/engine-create-types.d.ts +14 -0
  29. package/dist/core/engine/engine-internal-types.d.ts +2 -0
  30. package/dist/core/engine/errors.d.ts +6 -6
  31. package/dist/core/engine/errors.js +1 -1
  32. package/dist/core/engine/handles.js +1 -1
  33. package/dist/core/engine/index.d.ts +6 -5
  34. package/dist/core/engine/index.js +3 -2
  35. package/dist/core/engine/lifecycle/recovered-services.js +8 -1
  36. package/dist/core/engine/lifecycle/start-or-signal-create.d.ts +2 -2
  37. package/dist/core/engine/lifecycle/start-or-signal-create.js +17 -15
  38. package/dist/core/engine/lifecycle/start-or-signal-resolution.d.ts +1 -1
  39. package/dist/core/engine/lifecycle/start-or-signal-resolution.js +23 -5
  40. package/dist/core/engine/lifecycle/start-or-signal.d.ts +6 -4
  41. package/dist/core/engine/lifecycle/start-or-signal.js +21 -4
  42. package/dist/core/engine/schedule-run.js +12 -3
  43. package/dist/core/engine/storage-io.js +1 -1
  44. package/dist/core/engine/termination/complete.js +3 -3
  45. package/dist/core/scheduler/scheduler-class.js +2 -1
  46. package/dist/core/start-workflow-validation.d.ts +3 -4
  47. package/dist/core/start-workflow-validation.js +1 -1
  48. package/dist/core/timeouts.d.ts +13 -1
  49. package/dist/core/timeouts.js +4 -1
  50. package/dist/core/types/constants.d.ts +16 -0
  51. package/dist/core/types/options.d.ts +60 -12
  52. package/dist/core/types/services-resolution.d.ts +49 -3
  53. package/dist/core/weft-error.d.ts +1 -1
  54. package/dist/core/weft-error.js +1 -0
  55. package/dist/index.d.ts +3 -3
  56. package/dist/index.js +1 -0
  57. package/dist/json-schema.js +1 -1
  58. package/dist/mcp/cli.js +17 -17
  59. package/dist/server/asyncapi-channels.js +82 -16
  60. package/dist/server/handler/auth-context-principal.d.ts +19 -0
  61. package/dist/server/handler/auth-context-principal.js +25 -0
  62. package/dist/server/handler/index.d.ts +2 -1
  63. package/dist/server/handler/index.js +35 -18
  64. package/dist/server/handler/route-dispatch.d.ts +12 -22
  65. package/dist/server/handler/route-dispatch.js +30 -36
  66. package/dist/server/handler/sse-route-dispatch.d.ts +25 -0
  67. package/dist/server/handler/sse-route-dispatch.js +45 -0
  68. package/dist/server/handler.js +21 -21
  69. package/dist/server/index.d.ts +8 -7
  70. package/dist/server/index.js +15 -15
  71. package/dist/server/operations/fleet-events-sse.d.ts +11 -0
  72. package/dist/server/operations/fleet-events-sse.js +164 -0
  73. package/dist/server/operations/sse-stream.d.ts +43 -0
  74. package/dist/server/operations/sse-stream.js +152 -7
  75. package/dist/server/operations/start-or-signal-workflow.d.ts +2 -0
  76. package/dist/server/operations/start-or-signal-workflow.js +25 -14
  77. package/dist/server/operations/start-workflow-options.d.ts +9 -6
  78. package/dist/server/operations/start-workflow-options.js +13 -0
  79. package/dist/server/operations/workflow-events-sse.d.ts +17 -0
  80. package/dist/server/operations/workflow-events-sse.js +200 -0
  81. package/dist/server/rest-bindings.js +12 -0
  82. package/dist/server/runtime/authentication-bridge.js +4 -0
  83. package/dist/server/runtime/cors.d.ts +1 -1
  84. package/dist/server/runtime/cors.js +6 -1
  85. package/dist/server/runtime/websocket-stream.d.ts +4 -0
  86. package/dist/server/runtime/websocket-stream.js +20 -3
  87. package/dist/server/workflow-event-feed.d.ts +1 -0
  88. package/dist/server/workflow-event-feed.js +17 -6
  89. package/dist/service-worker/index.js +28 -28
  90. package/dist/storage/typed-storage.js +1 -1
  91. package/dist/testing/index.js +17 -17
  92. package/dist/version.d.ts +1 -1
  93. package/dist/version.js +1 -1
  94. 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
  }
@@ -19,6 +19,7 @@ export type EngineCreateRuntimeOptions = EngineConstructorOptions & {
19
19
  workflows?: Record<string, AnyWorkflowDefinition> | undefined;
20
20
  recover?: boolean | undefined;
21
21
  acknowledgeUnknownWorkflowTypes?: boolean | undefined;
22
+ startScheduler?: boolean | undefined;
22
23
  };
23
24
  export type NormalizedWorkerExecutionConfiguration = {
24
25
  mode: 'inline';
@@ -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) {
@@ -29,6 +29,20 @@ export type EngineCreateOptions<TWorkflowDefinitions extends Record<string, AnyW
29
29
  workflows?: TWorkflowDefinitions;
30
30
  /** Activity definitions to register before workflows. */
31
31
  activities?: TActivityDefinitions;
32
+ /**
33
+ * Start the scheduler's durable-timer polling loop after registration so
34
+ * `ctx.sleep(...)` and `engine.schedule(...)` timers fire in a long-lived
35
+ * in-process host. This is independent of `recover`: it controls *whether
36
+ * timers fire*, not *who drives `recoverAll`*.
37
+ *
38
+ * Defaults to `recover !== false`. A host that opts out of auto-recovery
39
+ * with `recover: false` (because it drives its own `recoverAll()` to capture
40
+ * the recovered handles) can still arm the poller by passing
41
+ * `startScheduler: true`. Pass `startScheduler: false` to keep the poller
42
+ * stopped even when recovery runs (tests / `ScopedStorage` engines that tick
43
+ * the scheduler deterministically).
44
+ */
45
+ startScheduler?: boolean;
32
46
  } & ({
33
47
  /**
34
48
  * Recover stored running workflows after registration. Defaults to
@@ -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>;
@@ -226,10 +226,10 @@ export class Engine extends EventTarget {
226
226
  engine.register(definition);
227
227
  }
228
228
  await engine.#acquireLeaseIfConfigured();
229
- if (options.recover !== !1) {
229
+ if (options.recover !== !1)
230
230
  await engine.recoverAll(options.acknowledgeUnknownWorkflowTypes !== void 0 ? { acknowledgeUnknownWorkflowTypes: options.acknowledgeUnknownWorkflowTypes } : {});
231
+ if (options.startScheduler ?? options.recover !== !1)
231
232
  getInternals(engine).scheduler.start();
232
- }
233
233
  } catch (error) {
234
234
  await engine[Symbol.asyncDispose]();
235
235
  throw error;
@@ -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