@lostgradient/weft 0.2.1 → 0.3.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 (188) hide show
  1. package/README.md +47 -22
  2. package/dist/cli/generated/operation-client.generated.d.ts +28 -1
  3. package/dist/cli/generated/operation-client.generated.js +2 -0
  4. package/dist/cli-main.js +79 -79
  5. package/dist/client/handle-delegation.d.ts +4 -0
  6. package/dist/client/handle-delegation.js +6 -0
  7. package/dist/client/http-client-requests.d.ts +2 -0
  8. package/dist/client/http-client-requests.js +3 -0
  9. package/dist/client/http-client.d.ts +4 -1
  10. package/dist/client/http-client.js +9 -1
  11. package/dist/client/interface.d.ts +57 -2
  12. package/dist/client/local.d.ts +4 -1
  13. package/dist/client/local.js +7 -0
  14. package/dist/client/start-body.d.ts +7 -1
  15. package/dist/client/start-body.js +13 -4
  16. package/dist/core/codec/extension-codec.js +4 -2
  17. package/dist/core/codec/index.d.ts +1 -0
  18. package/dist/core/codec/index.js +1 -0
  19. package/dist/core/codec/serializer-registry.d.ts +122 -0
  20. package/dist/core/codec/serializer-registry.js +51 -0
  21. package/dist/core/context/index.d.ts +9 -0
  22. package/dist/core/context/internals.d.ts +9 -0
  23. package/dist/core/context/internals.js +3 -0
  24. package/dist/core/context/run-operation.d.ts +16 -3
  25. package/dist/core/context/run-operation.js +16 -7
  26. package/dist/core/engine/bulk-operations.js +1 -1
  27. package/dist/core/engine/construction.d.ts +0 -1
  28. package/dist/core/engine/construction.js +10 -1
  29. package/dist/core/engine/disposal.js +12 -0
  30. package/dist/core/engine/engine-create-types.d.ts +0 -14
  31. package/dist/core/engine/engine-internal-types.d.ts +12 -0
  32. package/dist/core/engine/engine-leak-warnings.d.ts +6 -0
  33. package/dist/core/engine/engine-leak-warnings.js +4 -0
  34. package/dist/core/engine/engine-runtime-helpers.d.ts +17 -0
  35. package/dist/core/engine/engine-runtime-helpers.js +26 -5
  36. package/dist/core/engine/errors.d.ts +74 -0
  37. package/dist/core/engine/errors.js +25 -1
  38. package/dist/core/engine/handle-result.js +1 -1
  39. package/dist/core/engine/handles.d.ts +89 -40
  40. package/dist/core/engine/handles.js +25 -27
  41. package/dist/core/engine/index.d.ts +96 -4
  42. package/dist/core/engine/index.js +75 -4
  43. package/dist/core/engine/inline-launch-queue.d.ts +14 -0
  44. package/dist/core/engine/inline-launch-queue.js +32 -7
  45. package/dist/core/engine/internals.d.ts +18 -10
  46. package/dist/core/engine/lifecycle/fork-helpers.js +1 -7
  47. package/dist/core/engine/lifecycle/persist.js +5 -20
  48. package/dist/core/engine/lifecycle/resume.js +25 -4
  49. package/dist/core/engine/lifecycle/start-commit.d.ts +47 -0
  50. package/dist/core/engine/lifecycle/start-commit.js +27 -0
  51. package/dist/core/engine/lifecycle/start-exec.d.ts +30 -2
  52. package/dist/core/engine/lifecycle/start-exec.js +38 -0
  53. package/dist/core/engine/lifecycle/start-or-signal-resolution.d.ts +79 -0
  54. package/dist/core/engine/lifecycle/start-or-signal-resolution.js +60 -0
  55. package/dist/core/engine/lifecycle/start-or-signal.d.ts +45 -0
  56. package/dist/core/engine/lifecycle/start-or-signal.js +141 -0
  57. package/dist/core/engine/lifecycle/start.d.ts +3 -3
  58. package/dist/core/engine/lifecycle/start.js +31 -37
  59. package/dist/core/engine/lifecycle.d.ts +3 -2
  60. package/dist/core/engine/lifecycle.js +9 -2
  61. package/dist/core/engine/listing.js +1 -1
  62. package/dist/core/engine/persisted-data-version.d.ts +5 -9
  63. package/dist/core/engine/persisted-data-version.js +4 -5
  64. package/dist/core/engine/schedule-handle.d.ts +45 -0
  65. package/dist/core/engine/schedule-handle.js +26 -0
  66. package/dist/core/engine/schedules.d.ts +1 -1
  67. package/dist/core/engine/schedules.js +7 -3
  68. package/dist/core/engine/second-instance-detector.d.ts +96 -0
  69. package/dist/core/engine/second-instance-detector.js +108 -0
  70. package/dist/core/engine/signals.d.ts +22 -0
  71. package/dist/core/engine/signals.js +15 -0
  72. package/dist/core/engine/termination/cleanup.d.ts +25 -0
  73. package/dist/core/engine/termination/cleanup.js +19 -1
  74. package/dist/core/engine/termination/complete.js +4 -3
  75. package/dist/core/engine/termination/suspend.d.ts +68 -0
  76. package/dist/core/engine/termination/suspend.js +41 -0
  77. package/dist/core/engine/termination.d.ts +4 -2
  78. package/dist/core/engine/termination.js +2 -0
  79. package/dist/core/engine/validation.js +25 -1
  80. package/dist/core/engine/workflow-feed.d.ts +5 -3
  81. package/dist/core/events/event-map.d.ts +2 -1
  82. package/dist/core/events/workflow-events.d.ts +23 -0
  83. package/dist/core/events/workflow-events.js +9 -0
  84. package/dist/core/list-filter-validation.js +2 -1
  85. package/dist/core/start-workflow-validation.d.ts +22 -0
  86. package/dist/core/start-workflow-validation.js +11 -1
  87. package/dist/core/step-context.d.ts +10 -6
  88. package/dist/core/step-context.js +7 -15
  89. package/dist/core/types/activity.d.ts +6 -3
  90. package/dist/core/types/identity.d.ts +8 -1
  91. package/dist/core/types/launch-metadata.d.ts +33 -0
  92. package/dist/core/types/launch-metadata.js +0 -0
  93. package/dist/core/types/message-handles.d.ts +25 -0
  94. package/dist/core/types/options.d.ts +48 -54
  95. package/dist/core/types/reviews.d.ts +2 -1
  96. package/dist/core/types/services-resolution.d.ts +47 -0
  97. package/dist/core/types/services-resolution.js +0 -0
  98. package/dist/core/types/state.d.ts +11 -11
  99. package/dist/core/types/workflow-builder.d.ts +5 -4
  100. package/dist/core/types/workflow-function.d.ts +17 -0
  101. package/dist/core/types/workflow-snapshot.d.ts +29 -0
  102. package/dist/core/types/workflow-snapshot.js +0 -0
  103. package/dist/core/types.d.ts +3 -0
  104. package/dist/core/types.js +3 -0
  105. package/dist/core/weft-error.d.ts +1 -1
  106. package/dist/core/weft-error.js +3 -1
  107. package/dist/diagnostics/doctor.js +6 -3
  108. package/dist/diagnostics/format.js +2 -2
  109. package/dist/diagnostics/types.d.ts +1 -0
  110. package/dist/diagnostics/version-check.js +6 -4
  111. package/dist/index.d.ts +4 -4
  112. package/dist/index.js +10 -1
  113. package/dist/json-schema.js +1 -1
  114. package/dist/mcp/cli.js +35 -35
  115. package/dist/mcp/list-filter.js +2 -1
  116. package/dist/mcp/session.js +1 -0
  117. package/dist/observability/index.js +2 -2
  118. package/dist/server/handler.js +30 -30
  119. package/dist/server/index.js +33 -33
  120. package/dist/server/interactive-operations.js +1 -0
  121. package/dist/server/operations/resume-workflow.js +2 -2
  122. package/dist/server/operations/start-or-signal-workflow.d.ts +39 -0
  123. package/dist/server/operations/start-or-signal-workflow.js +140 -0
  124. package/dist/server/operations/start-workflow-options.d.ts +32 -0
  125. package/dist/server/operations/start-workflow-options.js +63 -0
  126. package/dist/server/operations/start-workflow.js +7 -69
  127. package/dist/server/operations/suspend-workflow.d.ts +13 -0
  128. package/dist/server/operations/suspend-workflow.js +36 -0
  129. package/dist/server/rest-binding.d.ts +18 -7
  130. package/dist/server/rest-bindings.js +12 -0
  131. package/dist/server/runtime/task-dispatch.js +5 -3
  132. package/dist/server/runtime/task-polling.d.ts +16 -2
  133. package/dist/server/runtime/task-polling.js +20 -5
  134. package/dist/server/runtime/websocket-worker.js +8 -0
  135. package/dist/server/serve-internals.d.ts +8 -0
  136. package/dist/server/serve-internals.js +4 -2
  137. package/dist/server/task-state.d.ts +8 -0
  138. package/dist/service-worker/index.js +28 -28
  139. package/dist/storage/capabilities.d.ts +10 -2
  140. package/dist/storage/capabilities.js +2 -2
  141. package/dist/storage/http.js +2 -2
  142. package/dist/storage/index.d.ts +6 -1
  143. package/dist/storage/indexeddb.js +1 -1
  144. package/dist/storage/interface.d.ts +26 -0
  145. package/dist/storage/interface.js +1 -1
  146. package/dist/storage/key-prefixes.d.ts +1 -1
  147. package/dist/storage/key-prefixes.js +2 -0
  148. package/dist/storage/lmdb.js +1 -1
  149. package/dist/storage/memory.js +1 -1
  150. package/dist/storage/neon-value-mapping.d.ts +47 -0
  151. package/dist/storage/neon-value-mapping.js +11 -0
  152. package/dist/storage/neon.d.ts +108 -0
  153. package/dist/storage/neon.js +10 -0
  154. package/dist/storage/node-sqlite-loader.d.ts +71 -0
  155. package/dist/storage/node-sqlite-loader.js +41 -0
  156. package/dist/storage/node-sqlite.d.ts +1 -19
  157. package/dist/storage/node-sqlite.js +38 -32
  158. package/dist/storage/postgres-key-value-queries.d.ts +79 -0
  159. package/dist/storage/postgres-key-value-queries.js +63 -0
  160. package/dist/storage/resolve.d.ts +2 -165
  161. package/dist/storage/resolve.js +1 -1
  162. package/dist/storage/scoped-storage.js +1 -1
  163. package/dist/storage/storage-configuration.d.ts +209 -0
  164. package/dist/storage/storage-configuration.js +0 -0
  165. package/dist/storage/text-value-store.d.ts +9 -9
  166. package/dist/storage/turso.js +2 -2
  167. package/dist/storage/typed-storage.js +1 -1
  168. package/dist/storage/web-extension.js +1 -1
  169. package/dist/testing/index.js +33 -33
  170. package/dist/version.d.ts +1 -1
  171. package/dist/version.js +1 -1
  172. package/dist/worker/index.js +9 -5
  173. package/dist/worker/long-poll.js +4 -0
  174. package/dist/worker/protocol-messages.d.ts +20 -0
  175. package/dist/worker/protocol-schemas.d.ts +32 -0
  176. package/dist/worker/protocol-schemas.js +8 -4
  177. package/dist/worker/protocol-task-result.d.ts +28 -0
  178. package/dist/worker/protocol-task-result.js +76 -0
  179. package/dist/worker/protocol.d.ts +4 -15
  180. package/dist/worker/protocol.js +1 -1
  181. package/dist/worker/registry/fair-share.d.ts +29 -0
  182. package/dist/worker/registry/fair-share.js +30 -0
  183. package/dist/worker/registry/routing.d.ts +18 -0
  184. package/dist/worker/registry/routing.js +14 -0
  185. package/dist/worker/registry/types.d.ts +7 -0
  186. package/dist/worker/registry.d.ts +16 -1
  187. package/dist/worker/registry.js +24 -36
  188. package/package.json +17 -4
@@ -8,7 +8,11 @@ export const TERMINAL_WORKFLOW_STATUSES = new Set([
8
8
  "failed",
9
9
  "cancelled",
10
10
  "timed-out"
11
- ]);
11
+ ]), FORCIBLY_TERMINABLE_STATUSES = [
12
+ "running",
13
+ "pending",
14
+ "suspended"
15
+ ];
12
16
  function selectTrackedWaiterMaps(internals, kind) {
13
17
  return {
14
18
  signal: () => ({
@@ -48,6 +52,14 @@ function cleanupSleepResolvers(internals, workflowId) {
48
52
  }
49
53
  internals.sleepResolversByWorkflow.delete(workflowId);
50
54
  }
55
+ function evictSleepResolversWithoutResolving(internals, workflowId) {
56
+ const sleepOps = internals.sleepResolversByWorkflow.get(workflowId);
57
+ if (!sleepOps)
58
+ return;
59
+ for (const operationId of sleepOps)
60
+ internals.sleepResolvers.delete(`${workflowId}:${operationId}`);
61
+ internals.sleepResolversByWorkflow.delete(workflowId);
62
+ }
51
63
  function cleanupReviewEscalations(internals, workflowId, callbacks) {
52
64
  const reviewIds = internals.workflowReviewIds.get(workflowId);
53
65
  if (!reviewIds)
@@ -74,6 +86,12 @@ export function cleanupWaiters(internals, workflowId, callbacks) {
74
86
  internals.workflowServices.delete(workflowId);
75
87
  internals.workflowTypeByWorkflowId.delete(workflowId);
76
88
  }
89
+ export function evictSuspendedWorkflowWaiters(internals, workflowId, callbacks) {
90
+ for (const kind of TRACKED_WAITER_KINDS)
91
+ cleanupTrackedWaiter(internals, workflowId, kind);
92
+ evictSleepResolversWithoutResolving(internals, workflowId);
93
+ cleanupReviewEscalations(internals, workflowId, callbacks);
94
+ }
77
95
  export async function cleanupWorkflowStorage(internals, workflowId, includeOutputArtifacts) {
78
96
  const encodedWorkflowId = encodeStorageKeyComponent(workflowId), prefixes = [
79
97
  KEYS.activityReconciliationPrefix(workflowId),
@@ -26,7 +26,8 @@ import { buildWorkflowVisibilityIndexTransition } from "../workflow-indexes.js";
26
26
  import {
27
27
  cleanupTerminalWorkflowImmediately,
28
28
  cleanupTerminalWorkflowSynchronously,
29
- finalizeScheduledWorkflowTerminal
29
+ finalizeScheduledWorkflowTerminal,
30
+ FORCIBLY_TERMINABLE_STATUSES
30
31
  } from "./cleanup.js";
31
32
  async function runCancelHandlers(handlers, callbacks, workflowId) {
32
33
  for (const handler of handlers)
@@ -54,7 +55,7 @@ export async function terminateWorkflow(internals, workflowId, status, callbacks
54
55
  internals.strategy.cancelWorkflow(workflowId);
55
56
  try {
56
57
  const attributeBytes = await internals.storage.get(KEYS.attribute(workflowId)), attributes = attributeBytes ? decode(attributeBytes) : {}, retainedAttributes = buildRetainedTerminalSearchAttributes(attributes), terminationMessage = status === "timed-out" ? "Workflow timed out" : "Workflow cancelled", terminationResult = await updateWorkflowState(internals, workflowId, { status, ...reason !== void 0 ? { terminationReason: reason } : {} }, {
57
- allowedStatuses: ["running", "pending"],
58
+ allowedStatuses: FORCIBLY_TERMINABLE_STATUSES,
58
59
  buildAdditionalOperations: (_previousState, updatedAt) => {
59
60
  finalizePendingTimelineEntry(internals, workflowId, status, terminationMessage, updatedAt);
60
61
  const pendingTimelineOperation = buildPendingTimelineOperation(internals, workflowId);
@@ -182,7 +183,7 @@ export async function failWorkflow(internals, workflowId, error, callbacks, fail
182
183
  if (error.stack !== void 0)
183
184
  stateUpdate.errorStack = error.stack;
184
185
  if (!await updateWorkflowState(internals, workflowId, stateUpdate, {
185
- allowedStatuses: ["running", "pending"],
186
+ allowedStatuses: FORCIBLY_TERMINABLE_STATUSES,
186
187
  buildAdditionalOperations: (_previousState, updatedAt) => {
187
188
  finalizePendingTimelineEntry(internals, workflowId, "failed", error.message, updatedAt);
188
189
  const pendingTimelineOperation = buildPendingTimelineOperation(internals, workflowId);
@@ -0,0 +1,68 @@
1
+ import type { EngineInternals } from '../internals.ts';
2
+ import { type TerminationCallbacks } from './cleanup.ts';
3
+ /**
4
+ * Suspend a running workflow without terminating it: a non-terminal cousin of
5
+ * {@link terminateWorkflow}. The workflow's status transitions `running →
6
+ * suspended`, its durable checkpoint is preserved, and it becomes resumable via
7
+ * `engine.resume(id)` / `handle.resume()`. Suspension is client-driven
8
+ * preemption, so — unlike a fault — a suspended workflow is NOT auto-recovered
9
+ * by `engine.recoverAll()`.
10
+ *
11
+ * Contrast with cancel/timeout, which this deliberately does NOT do:
12
+ * - does NOT abort the workflow's `AbortController` — suspend is a pause, not a
13
+ * cancellation, so user code observing `ctx.signal.aborted` or registered
14
+ * abort listeners must not fire. The live inline run is *parked*
15
+ * (`parkWorkflow`: evict execution state without aborting), the same primitive
16
+ * the engine uses for signal-parking,
17
+ * - does NOT run cancel handlers,
18
+ * - does NOT settle the result promise (`handle.result()` stays pending until a
19
+ * later `resume()` drives the run to completion, or a `cancel()` terminates it),
20
+ * - does NOT clean up durable output artifacts or in-memory services (the
21
+ * `services` value is preserved so an in-process `resume()` can reuse it),
22
+ * - does NOT schedule terminal cleanup.
23
+ *
24
+ * The CAS status flip and the in-memory teardown both run inside one serialized
25
+ * per-workflow write section with `allowedStatuses: ['running']`. If the
26
+ * workflow already left `running` (it completed, failed, or a concurrent cancel
27
+ * won the race), the flip is skipped and suspend is a no-op — and because the
28
+ * teardown is gated on the flip succeeding, a workflow that lost the race keeps
29
+ * its execution state intact.
30
+ *
31
+ * The teardown evicts every piece of in-memory execution state that could let a
32
+ * post-suspend operation drive the parked run: the inline context/generator (via
33
+ * `parkWorkflow`), the in-memory checkpoint, the parked-inline marker, and the
34
+ * in-flight operation waiters (signal/update/sleep/review — deleted, NOT
35
+ * resolved, so a signal arriving after suspend buffers durably and is replayed
36
+ * on resume instead of waking a dormant operation loop against the gone
37
+ * generator). The durable checkpoint, durable buffered signals, durable sleep
38
+ * timers, and `workflowServices` are all left intact for resume.
39
+ *
40
+ * A signal that races the in-lock teardown is benign: `continueWorkflow` no-ops
41
+ * for an evicted generator, and `persistCheckpoint` no-ops when the context and
42
+ * in-memory checkpoint are gone — so no step can commit past the suspend point.
43
+ *
44
+ * `'suspended'` is neither `'running'` nor `'pending'`, and both local-ownership
45
+ * predicates (`isInlineWorkflowLocallyOwned`, `hasLocalCheckpointOwnership`) are
46
+ * gated on those two statuses. So once the status flips, the workflow stops
47
+ * registering as locally owned — which is exactly what makes `recoverAll()` skip
48
+ * it AND what lets `engine.resume()` re-drive it from storage instead of taking
49
+ * its local-ownership early return.
50
+ *
51
+ * The execution deadline is absolute wall-clock time: suspension does NOT extend
52
+ * it. The pending `deadline:` timer is deleted durably IN THE SAME COMMIT BATCH
53
+ * as the status flip, and re-armed at the same absolute fire time on resume (or
54
+ * fires immediately if already past). It is a durable delete rather than a
55
+ * `scheduler.cancel()` call because the scheduler is durable-scan-based and
56
+ * resume's re-arm is likewise durable-only (`buildTimerBatchOperations`); folding
57
+ * the delete into the commit makes it atomic with the flip and ordered before any
58
+ * concurrent resume, so an immediate resume cannot have its freshly re-armed
59
+ * deadline deleted by a late fire-and-forget cancel.
60
+ *
61
+ * Worker execution mode is not supported: a worker run cannot be parked without
62
+ * sending it a cancellation. To keep the contract state-dependent (suspend on a
63
+ * non-running workflow is always a no-op), the mode check runs only AFTER the
64
+ * status load confirms the workflow is `running`; a `running` worker workflow
65
+ * throws {@link WorkflowSuspendNotSupportedError}, while a completed or unknown
66
+ * one is a no-op regardless of execution mode.
67
+ */
68
+ export declare function suspendWorkflow(internals: EngineInternals, workflowId: string, callbacks: TerminationCallbacks): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import { KEYS } from "../../../storage/interface.js";
2
+ import { encode } from "../../codec.js";
3
+ import { WorkflowSuspendedEvent } from "../../events.js";
4
+ import { WorkflowSuspendNotSupportedError } from "../errors.js";
5
+ import { dropQueuedInlineWorkflowStart } from "../inline-launch-queue.js";
6
+ import { buildWorkflowVisibilityIndexTransition } from "../workflow-indexes.js";
7
+ import { evictSuspendedWorkflowWaiters } from "./cleanup.js";
8
+ export async function suspendWorkflow(internals, workflowId, callbacks) {
9
+ if (!await callbacks.runSerializedWorkflowStateWrite(workflowId, async () => {
10
+ const state = await callbacks.loadWorkflowState(workflowId);
11
+ if (!state || state.status !== "running")
12
+ return !1;
13
+ if (internals.inlineStrategy === null)
14
+ throw new WorkflowSuspendNotSupportedError("suspend is only supported in inline execution mode; a worker run cannot be paused without cancelling it.");
15
+ internals.inlineStrategy.parkWorkflow(workflowId);
16
+ dropQueuedInlineWorkflowStart(internals, workflowId);
17
+ internals.checkpoints.delete(workflowId);
18
+ internals.parkedInlineWorkflows.delete(workflowId);
19
+ evictSuspendedWorkflowWaiters(internals, workflowId, callbacks);
20
+ const updatedAt = internals.options.getNow(), updatedState = { ...state, status: "suspended", updatedAt };
21
+ await callbacks.commitWorkflowStateOperations(state, [
22
+ { type: "put", key: KEYS.workflow(workflowId), value: encode(updatedState) },
23
+ ...buildWorkflowVisibilityIndexTransition(workflowId, state, updatedState).batchOps,
24
+ ...buildDeadlineTimerDeleteOperations(workflowId, state.executionDeadline)
25
+ ]);
26
+ return !0;
27
+ }))
28
+ return;
29
+ const event = new WorkflowSuspendedEvent(workflowId);
30
+ callbacks.dispatchEvent(event);
31
+ callbacks.forwardEventToHandle(workflowId, event);
32
+ }
33
+ function buildDeadlineTimerDeleteOperations(workflowId, executionDeadline) {
34
+ if (executionDeadline === void 0)
35
+ return [];
36
+ const timerId = `deadline:${workflowId}`;
37
+ return [
38
+ { type: "delete", key: KEYS.deadline(executionDeadline, timerId) },
39
+ { type: "delete", key: `timer-idx:${timerId}` }
40
+ ];
41
+ }
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Thin barrel over the split termination modules. Keeps existing import paths
3
3
  * (`./termination.ts`) working while the implementation lives in
4
- * `./termination/cleanup.ts` and `./termination/complete.ts`.
4
+ * `./termination/cleanup.ts`, `./termination/complete.ts`, and
5
+ * `./termination/suspend.ts`.
5
6
  */
6
- export { TERMINAL_WORKFLOW_STATUSES, cleanupTerminalWorkflowDurableState, cleanupTerminalWorkflowImmediately, cleanupTerminalWorkflowMemory, cleanupTerminalWorkflowSynchronously, cleanupWaiters, cleanupWorkflowStorage, finalizeScheduledWorkflowTerminal, handleCleanupError, runDeferredTerminalCleanup, type TerminationCallbacks, } from './termination/cleanup.ts';
7
+ export { TERMINAL_WORKFLOW_STATUSES, cleanupTerminalWorkflowDurableState, cleanupTerminalWorkflowImmediately, cleanupTerminalWorkflowMemory, cleanupTerminalWorkflowSynchronously, cleanupWaiters, cleanupWorkflowStorage, evictSuspendedWorkflowWaiters, finalizeScheduledWorkflowTerminal, handleCleanupError, runDeferredTerminalCleanup, type TerminationCallbacks, } from './termination/cleanup.ts';
7
8
  export { buildPendingTimelineOperation, buildTerminalCleanupTimerOperations, cancelWorkflow, completeWorkflow, ensureTerminalCleanupTracked, failWorkflow, finalizePendingTimelineEntry, terminateWorkflow, timeoutWorkflow, } from './termination/complete.ts';
9
+ export { suspendWorkflow } from './termination/suspend.ts';
@@ -6,6 +6,7 @@ export {
6
6
  cleanupTerminalWorkflowSynchronously,
7
7
  cleanupWaiters,
8
8
  cleanupWorkflowStorage,
9
+ evictSuspendedWorkflowWaiters,
9
10
  finalizeScheduledWorkflowTerminal,
10
11
  handleCleanupError,
11
12
  runDeferredTerminalCleanup
@@ -21,3 +22,4 @@ export {
21
22
  terminateWorkflow,
22
23
  timeoutWorkflow
23
24
  } from "./termination/complete.js";
25
+ export { suspendWorkflow } from "./termination/suspend.js";
@@ -2,6 +2,7 @@ import { decode } from "../codec.js";
2
2
  import { isRecord } from "../debug-output.js";
3
3
  import { normalizeFailureCategory } from "../failure-categories.js";
4
4
  import { coerceStartWorkflowId, parseStartWorkflowDuration } from "../start-workflow-validation.js";
5
+ import { DEFAULT_WORKFLOW_VERSION } from "../versioning.js";
5
6
  import { isWorkflowTagArray } from "../workflow-tags.js";
6
7
  const WORKFLOW_TIMELINE_STATUSES = new Set([
7
8
  "running",
@@ -55,7 +56,10 @@ export function isValidDecodedTags(value) {
55
56
  return value === void 0 || isWorkflowTagArray(value);
56
57
  }
57
58
  export function decodeWorkflowState(bytes) {
58
- const state = decode(bytes);
59
+ const decoded = decode(bytes);
60
+ if (isRecord(decoded))
61
+ liftFlatVersionTuple(decoded);
62
+ const state = decoded;
59
63
  if ("tenant" in state)
60
64
  delete state.tenant;
61
65
  if (!isValidDecodedTags(state.tags)) {
@@ -78,6 +82,26 @@ export function decodeWorkflowState(bytes) {
78
82
  }
79
83
  return state;
80
84
  }
85
+ function liftFlatVersionTuple(record) {
86
+ const flatVersion = record.version, hasFlatVersion = typeof flatVersion === "string";
87
+ if (isWorkflowVersionTuple(record.versionTuple)) {
88
+ delete record.version;
89
+ delete record.agentVersion;
90
+ delete record.toolVersions;
91
+ return;
92
+ }
93
+ const { agentVersion: flatAgentVersion, toolVersions: flatToolVersions } = record, versionTuple = {
94
+ workflowVersion: hasFlatVersion ? flatVersion : DEFAULT_WORKFLOW_VERSION,
95
+ ...typeof flatAgentVersion === "string" && { agentVersion: flatAgentVersion },
96
+ ...Array.isArray(flatToolVersions) && flatToolVersions.every((entry) => typeof entry === "string") && {
97
+ toolVersions: flatToolVersions
98
+ }
99
+ };
100
+ record.versionTuple = versionTuple;
101
+ delete record.version;
102
+ delete record.agentVersion;
103
+ delete record.toolVersions;
104
+ }
81
105
  export function normalizeRetentionDuration(value, fieldName) {
82
106
  if (value === void 0)
83
107
  return;
@@ -7,9 +7,11 @@ import type { EngineInternals } from './internals.ts';
7
7
  */
8
8
  export type WorkflowFeedSelector = 'events' | 'tokens';
9
9
  /**
10
- * Hard-coded stream key for the `tokens` selector. Matches the
11
- * legacy REST SSE endpoint's key so resumption cursors round-trip
12
- * across transports.
10
+ * Hard-coded stream key for the `tokens` selector. Every transport that
11
+ * exposes the token feed `engine.getStreamChunks(id, 'tokens')` and the REST
12
+ * SSE / WebSocket stream-chunk routes layered on it — keys its stored chunks
13
+ * under this single value, so a resumption cursor issued by one transport
14
+ * round-trips through another.
13
15
  */
14
16
  export declare const TOKENS_STREAM_KEY = "tokens";
15
17
  /** Record `kind` for every token stream chunk emitted by the feed. */
@@ -4,7 +4,7 @@ import type { AttributesChangedEvent } from './attribute-events.ts';
4
4
  import type { SignalDeliveredEvent, SignalReceivedEvent } from './signal-events.ts';
5
5
  import type { AlertFiredEvent, AlertResolvedEvent, CheckpointSizeWarningEvent, CleanupWarningEvent, ConstraintViolatedEvent, DevelopmentWarningEvent, StorageSizeReportedEvent } from './system-events.ts';
6
6
  import type { UpdateCompletedEvent, UpdateReceivedEvent } from './update-events.ts';
7
- import type { WorkflowCancelledEvent, WorkflowCompletedEvent, WorkflowFailedEvent, WorkflowRecoverySkippedEvent, WorkflowResumedEvent, WorkflowStartedEvent, WorkflowTimedOutEvent } from './workflow-events.ts';
7
+ import type { WorkflowCancelledEvent, WorkflowCompletedEvent, WorkflowFailedEvent, WorkflowRecoverySkippedEvent, WorkflowResumedEvent, WorkflowStartedEvent, WorkflowSuspendedEvent, WorkflowTimedOutEvent } from './workflow-events.ts';
8
8
  /**
9
9
  * Record mapping each event-name string the {@link Engine} dispatches to its
10
10
  * corresponding typed `Event` subclass. Use this as the type parameter for
@@ -31,6 +31,7 @@ export type WeftEventMap = {
31
31
  'workflow:cancelled': WorkflowCancelledEvent;
32
32
  'workflow:timed-out': WorkflowTimedOutEvent;
33
33
  'workflow:resumed': WorkflowResumedEvent;
34
+ 'workflow:suspended': WorkflowSuspendedEvent;
34
35
  'workflow:recovery-skipped': WorkflowRecoverySkippedEvent;
35
36
  'activity:started': ActivityStartedEvent;
36
37
  'activity:completed': ActivityCompletedEvent;
@@ -160,6 +160,29 @@ export declare class WorkflowResumedEvent extends Event {
160
160
  readonly fromStep: number;
161
161
  constructor(workflowId: string, fromStep: number);
162
162
  }
163
+ /**
164
+ * Fired when a running workflow is explicitly suspended via
165
+ * `handle.suspend()` / `engine.suspend(id)`. Suspension is a non-terminal pause:
166
+ * the workflow keeps its checkpoint and is later resumable. This event is
167
+ * intentionally NOT in {@link WORKFLOW_TERMINAL_EVENT_TYPES} — a suspended
168
+ * workflow has not ended, and `handle.result()` stays pending.
169
+ *
170
+ * @example
171
+ * ```ts
172
+ * import { Engine, WorkflowSuspendedEvent } from '@lostgradient/weft';
173
+ *
174
+ * const engine = new Engine();
175
+ * engine.addEventListener('workflow:suspended', (e: Event) => {
176
+ * const ev = e as WorkflowSuspendedEvent;
177
+ * console.log('suspended', ev.workflowId);
178
+ * });
179
+ * ```
180
+ */
181
+ export declare class WorkflowSuspendedEvent extends Event {
182
+ static readonly type: "workflow:suspended";
183
+ readonly workflowId: string;
184
+ constructor(workflowId: string);
185
+ }
163
186
  /**
164
187
  * Reason carried by {@link WorkflowRecoverySkippedEvent}.
165
188
  *
@@ -77,6 +77,15 @@ export class WorkflowResumedEvent extends Event {
77
77
  }
78
78
  }
79
79
 
80
+ export class WorkflowSuspendedEvent extends Event {
81
+ static type = "workflow:suspended";
82
+ workflowId;
83
+ constructor(workflowId) {
84
+ super(WorkflowSuspendedEvent.type);
85
+ this.workflowId = workflowId;
86
+ }
87
+ }
88
+
80
89
  export class WorkflowRecoverySkippedEvent extends Event {
81
90
  static type = "workflow:recovery-skipped";
82
91
  workflowId;
@@ -7,7 +7,8 @@ const WORKFLOW_STATUSES = [
7
7
  "completed",
8
8
  "failed",
9
9
  "cancelled",
10
- "timed-out"
10
+ "timed-out",
11
+ "suspended"
11
12
  ], ID_PREFIX_PATTERN = /^[A-Za-z0-9_-]+$/, workflowStatusSchema = z.enum(WORKFLOW_STATUSES), failureCategorySchema = z.enum(FAILURE_CATEGORIES), timeRangeSchema = z.object({
12
13
  gte: z.number().optional(),
13
14
  lte: z.number().optional(),
@@ -2,11 +2,33 @@ import type { Duration } from './types.ts';
2
2
  import { WeftError } from './weft-error.ts';
3
3
  export declare const MAX_WORKFLOW_TAGS = 32;
4
4
  export declare const MAX_WORKFLOW_TAG_BYTES = 128;
5
+ /**
6
+ * Upper bound on a start `idempotencyKey`, in UTF-8 bytes. `startOrSignal`
7
+ * derives a signal id of `start-idem:${key}` (an 11-byte prefix) from the key,
8
+ * and `validateSignalId` caps a signal id at 128 bytes — so the raw key must fit
9
+ * in `128 - 11 = 117` bytes for the derived id to stay within that ceiling.
10
+ */
11
+ export declare const MAX_IDEMPOTENCY_KEY_BYTES = 117;
5
12
  export declare class StartWorkflowValidationError extends WeftError<'StartWorkflowValidationError'> {
6
13
  constructor(message: string);
7
14
  }
8
15
  export declare const assertExclusiveStartWorkflowOptions: (startAt: unknown, startAfter: unknown) => void;
9
16
  export declare const coerceStartWorkflowId: (value: unknown, fieldName: string) => string;
17
+ /**
18
+ * Coerce a transport-supplied idempotency key to a non-empty string. The key is
19
+ * a caller-chosen dedup token (it becomes part of a `start-idem:` storage key),
20
+ * so an empty string — which would collide across unrelated starts — is rejected.
21
+ */
22
+ export declare const coerceStartWorkflowIdempotencyKey: (value: unknown, fieldName: string) => string;
23
+ /**
24
+ * Validate an already-string `idempotencyKey`: non-empty (an empty key would
25
+ * collide across unrelated starts under the shared `start-idem:` mapping) and at
26
+ * most {@link MAX_IDEMPOTENCY_KEY_BYTES} UTF-8 bytes (so the derived
27
+ * `start-idem:${key}` signal id stays within the signal-id ceiling). Shared by
28
+ * the transport coercion and the engine boundary so a direct `engine.start`
29
+ * caller gets the same guarantees as an HTTP caller.
30
+ */
31
+ export declare const assertValidIdempotencyKey: (value: string, fieldName: string) => string;
10
32
  export declare function coerceStartWorkflowTimestamp(value: unknown, fieldName: string): number;
11
33
  export declare function parseStartWorkflowDuration(duration: Duration, fieldName: string): number;
12
34
  export declare function coerceStartWorkflowDuration(value: unknown, fieldName: string): Duration;
@@ -1,7 +1,7 @@
1
1
  import { parseDuration } from "./scheduler.js";
2
2
  import { WeftError } from "./weft-error.js";
3
3
  import { assertValidWorkflowId } from "./workflow-identifiers.js";
4
- export const MAX_WORKFLOW_TAGS = 32, MAX_WORKFLOW_TAG_BYTES = 128;
4
+ export const MAX_WORKFLOW_TAGS = 32, MAX_WORKFLOW_TAG_BYTES = 128, MAX_IDEMPOTENCY_KEY_BYTES = 117;
5
5
  const textEncoder = new TextEncoder, EXCLUSIVE_START_WORKFLOW_OPTIONS_ERROR = "Provide only one of startAt or startAfter";
6
6
 
7
7
  export class StartWorkflowValidationError extends WeftError {
@@ -22,6 +22,16 @@ export const assertExclusiveStartWorkflowOptions = (startAt, startAfter) => {
22
22
  const message = error instanceof Error ? error.message : String(error);
23
23
  throw new StartWorkflowValidationError(message);
24
24
  }
25
+ }, coerceStartWorkflowIdempotencyKey = (value, fieldName) => {
26
+ if (typeof value !== "string")
27
+ throw new StartWorkflowValidationError(`${fieldName} must be a string`);
28
+ return assertValidIdempotencyKey(value, fieldName);
29
+ }, assertValidIdempotencyKey = (value, fieldName) => {
30
+ if (value.length === 0)
31
+ throw new StartWorkflowValidationError(`${fieldName} must not be empty`);
32
+ if (textEncoder.encode(value).byteLength > MAX_IDEMPOTENCY_KEY_BYTES)
33
+ throw new StartWorkflowValidationError(`${fieldName} must be at most ${MAX_IDEMPOTENCY_KEY_BYTES} UTF-8 bytes`);
34
+ return value;
25
35
  };
26
36
  export function coerceStartWorkflowTimestamp(value, fieldName) {
27
37
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
@@ -7,19 +7,23 @@
7
7
  *
8
8
  * @module core/step-context
9
9
  */
10
- import type { ContextOperationRequest } from './context.ts';
11
10
  import type { StepWorkflowContext, WorkflowFunction } from './types.ts';
12
11
  interface QueuedOperation {
13
- request: ContextOperationRequest;
12
+ /** Explicit, user-supplied step name. Used as the durable activity label. */
13
+ name: string;
14
+ /** The zero-argument step body. Executed by the engine as an inline activity. */
15
+ fn: () => unknown;
14
16
  resolve: (value: unknown) => void;
15
17
  reject: (reason: unknown) => void;
16
18
  }
17
19
  /**
18
20
  * Internal implementation of {@link StepWorkflowContext} for step-based
19
- * ("progressive disclosure") workflows. Wraps the generator protocol in a
20
- * queue so that plain `async function` workflows can be registered on the
21
- * engine without writing explicit generators. Build via
22
- * {@link compileStepWorkflow} rather than constructing directly.
21
+ * ("progressive disclosure") workflows. Each `step()` call is a thin async
22
+ * enqueue; the compiled generator (see {@link compileStepWorkflow}) drains the
23
+ * queue and runs each step through the engine's durable activity machinery, so
24
+ * a completed step is replayed from the checkpoint rather than re-executed
25
+ * after a crash. Build via {@link compileStepWorkflow} rather than constructing
26
+ * directly.
23
27
  *
24
28
  * @example
25
29
  * ```ts
@@ -1,3 +1,5 @@
1
+ import { asConcreteContext, runActivityWithRetry } from "./context/run-operation.js";
2
+
1
3
  export class StepContext {
2
4
  workflowId;
3
5
  signal;
@@ -9,18 +11,8 @@ export class StepContext {
9
11
  this.signal = signal;
10
12
  }
11
13
  async step(name, fn) {
12
- const operationId = crypto.randomUUID(), { promise, resolve, reject } = Promise.withResolvers();
13
- this.#queue.push({
14
- request: {
15
- type: "activity",
16
- operationId,
17
- activityName: name,
18
- fn,
19
- input: void 0
20
- },
21
- resolve,
22
- reject
23
- });
14
+ const { promise, resolve, reject } = Promise.withResolvers();
15
+ this.#queue.push({ name, fn, resolve, reject });
24
16
  this.#notifyQueue?.();
25
17
  return promise;
26
18
  }
@@ -43,8 +35,8 @@ export class StepContext {
43
35
  }
44
36
  }
45
37
  export function compileStepWorkflow(stepFunction) {
46
- return async function* (_rawContext, input) {
47
- const rawContext = _rawContext, stepContext = new StepContext(rawContext.workflowId, rawContext.signal);
38
+ return async function* (publicContext, input) {
39
+ const rawContext = asConcreteContext(publicContext), stepContext = new StepContext(rawContext.workflowId, rawContext.signal);
48
40
  let workflowResult, workflowError;
49
41
  const userPromise = stepFunction(stepContext, input).then((result) => {
50
42
  workflowResult = result;
@@ -59,7 +51,7 @@ export function compileStepWorkflow(stepFunction) {
59
51
  if (queued === null)
60
52
  break;
61
53
  try {
62
- const result = yield queued.request;
54
+ const result = yield* runActivityWithRetry(rawContext, queued.fn, [], queued.name);
63
55
  queued.resolve(result);
64
56
  } catch (error) {
65
57
  queued.reject(error);
@@ -218,12 +218,15 @@ export interface ActivityDefinition<TInput = unknown, TOutput = unknown, TName e
218
218
  /** Activity implementation called by the engine or worker. */
219
219
  execute: ActivityFunction<TInput, TOutput>;
220
220
  /**
221
- * Optional post-execution verifier.
221
+ * Optional verifier, invoked in two phases (see
222
+ * `ActivityVerificationContext.phase`).
222
223
  *
223
224
  * During normal post-execution validation, return `true` to confirm the
224
225
  * activity result or `false` to reject it. During pre-dispatch crash recovery
225
- * for keyed activities, return an explicit reconciliation state; legacy boolean
226
- * answers are not treated as proof of external completion.
226
+ * for keyed activities, return an explicit reconciliation state. A boolean is
227
+ * the post-execution result shape, not a Tier-0 reconciliation state, so a
228
+ * boolean returned during pre-dispatch reconciliation fails closed rather than
229
+ * being treated as proof of external completion.
227
230
  */
228
231
  verify?: ActivityVerifier<TInput, TOutput>;
229
232
  retry?: RetryPolicy;
@@ -51,5 +51,12 @@ export type FailureCategory = 'application' | 'timeout' | 'cancellation' | 'reso
51
51
  * Read this off `(await handle.state()).status` to learn whether a workflow
52
52
  * is still running, finished cleanly, or failed. Pass it to `engine.list()`
53
53
  * filters to scope queries by status.
54
+ *
55
+ * `'suspended'` is a non-terminal, non-running pause: a workflow that was
56
+ * explicitly suspended via {@link WorkflowHandle.suspend} (or
57
+ * `engine.suspend(id)`) keeps its checkpoint and is resumable via
58
+ * {@link WorkflowHandle.resume} (or `engine.resume(id)`), but is NOT
59
+ * auto-recovered by `engine.recoverAll()` — suspension is client-driven
60
+ * preemption, not a fault condition.
54
61
  */
55
- export type WorkflowStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'timed-out';
62
+ export type WorkflowStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'timed-out' | 'suspended';
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Launch context for a workflow, reconstructed from its persisted state by
3
+ * {@link WorkflowHandle.getLaunchMetadata}. Lets a caller — typically after
4
+ * `engine.recoverAll()` — recover the original input and the launch options that
5
+ * survive in durable state, without keeping a side table that correlates a
6
+ * recovered workflow back to how it was started.
7
+ *
8
+ * `launchOptions` carries only the options recoverable *faithfully* from
9
+ * persisted state: `id` (always) and `tags`. Note `tags` is the run's *current*
10
+ * tag set (tags are mutable via `addTags`/`removeTags`), not necessarily the
11
+ * tags passed at launch. Deliberately excluded: `executionTimeout` (state
12
+ * stores the absolute deadline, not the original duration, so it cannot be
13
+ * reproduced exactly); `idempotencyKey` (no durable trace); `searchAttributes`
14
+ * (live in their own durable index — read them via
15
+ * {@link WorkflowHandle.getAttributes}); and `services` (non-serializable,
16
+ * re-provided on recovery by {@link EngineOptions.resolveWorkflowServices}).
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { type LaunchMetadata } from '@lostgradient/weft';
21
+ *
22
+ * function describe(metadata: LaunchMetadata): string {
23
+ * return `input=${JSON.stringify(metadata.input)} id=${metadata.launchOptions.id}`;
24
+ * }
25
+ * ```
26
+ */
27
+ export interface LaunchMetadata {
28
+ input: unknown;
29
+ launchOptions: {
30
+ id: string;
31
+ tags?: string[];
32
+ };
33
+ }
File without changes
@@ -111,6 +111,31 @@ export interface SignalOptions<TInput = void> {
111
111
  export interface SignalDeliveryOptions {
112
112
  readonly signalId?: string;
113
113
  }
114
+ /**
115
+ * The signal half of `startOrSignal` (signal-with-start): a signal `name`, an
116
+ * optional `payload`, and an optional `signalId`. When `signalId` is omitted it
117
+ * is derived from `options.idempotencyKey`. The id is load-bearing for
118
+ * convergence — concurrent callers deliver one signal only when they share it,
119
+ * and independent webhook retries share only the idempotency key — so a
120
+ * caller-supplied `signalId` covers the single-caller case while idempotent
121
+ * convergence relies on the derived-from-key value.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * import type { StartOrSignalSignal } from '@lostgradient/weft';
126
+ *
127
+ * const signal: StartOrSignalSignal = {
128
+ * name: 'webhook',
129
+ * payload: { event: 'payment.succeeded' },
130
+ * };
131
+ * void signal;
132
+ * ```
133
+ */
134
+ export interface StartOrSignalSignal {
135
+ readonly name: string;
136
+ readonly payload?: unknown;
137
+ readonly signalId?: string;
138
+ }
114
139
  /**
115
140
  * Create a typed workflow signal handle. When an `inputSchema` is supplied
116
141
  * via options, the payload type is inferred from the schema's