@lostgradient/weft 0.17.0 → 0.19.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 (172) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/conformance.js +14 -18
  3. package/dist/cli/generated/operation-catalog.snapshot.json +331 -16
  4. package/dist/cli/generated/operation-client.generated.d.ts +39 -6
  5. package/dist/cli/generated/operation-client.generated.js +6 -0
  6. package/dist/core/context/child-workflow-pipe.d.ts +1 -1
  7. package/dist/core/context/child-workflow-pipe.js +1 -1
  8. package/dist/core/engine/construction.d.ts +2 -0
  9. package/dist/core/engine/construction.js +3 -0
  10. package/dist/core/engine/index.js +1 -0
  11. package/dist/core/engine/listing.d.ts +1 -0
  12. package/dist/core/engine/listing.js +6 -4
  13. package/dist/core/weft-error.d.ts +1 -1
  14. package/dist/core/weft-error.js +2 -1
  15. package/dist/core/worker-execution-dispatcher.d.ts +8 -0
  16. package/dist/core/worker-execution-dispatcher.js +4 -0
  17. package/dist/core/worker-execution-strategy-options.d.ts +16 -0
  18. package/dist/core/worker-execution-strategy.js +71 -89
  19. package/dist/core/worker-fault-handling.d.ts +45 -0
  20. package/dist/core/worker-fault-handling.js +88 -0
  21. package/dist/core/worker-protocol.d.ts +18 -1
  22. package/dist/core/worker-protocol.js +1 -1
  23. package/dist/core/worker-realm-readiness.d.ts +78 -0
  24. package/dist/core/worker-realm-readiness.js +127 -0
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.js +28 -1
  27. package/dist/json-schema.js +1 -1
  28. package/dist/runtime/portable.d.ts +14 -0
  29. package/dist/runtime/portable.js +10 -0
  30. package/dist/server/authorization-scope.d.ts +32 -2
  31. package/dist/server/index.d.ts +73 -8
  32. package/dist/server/index.js +11 -3
  33. package/dist/server/operations/get-principal.d.ts +75 -0
  34. package/dist/server/operations/get-principal.js +41 -0
  35. package/dist/server/operations/get-task-diagnostics.d.ts +43 -18
  36. package/dist/server/operations/get-task-diagnostics.js +87 -126
  37. package/dist/server/operations/get-worker-diagnostics.d.ts +177 -0
  38. package/dist/server/operations/get-worker-diagnostics.js +117 -0
  39. package/dist/server/operations/list-worker-registration-rejections.d.ts +79 -0
  40. package/dist/server/operations/list-worker-registration-rejections.js +60 -0
  41. package/dist/server/operations/list-workers.js +1 -3
  42. package/dist/server/operations/static-registrations.d.ts +28 -0
  43. package/dist/server/operations/static-registrations.js +248 -0
  44. package/dist/server/rest-bindings.d.ts +5 -0
  45. package/dist/server/rest-bindings.js +34 -313
  46. package/dist/server/runtime/authentication-bridge.js +6 -14
  47. package/dist/server/runtime/context.d.ts +23 -0
  48. package/dist/server/runtime/task-dispatch.d.ts +7 -1
  49. package/dist/server/runtime/task-dispatch.js +126 -55
  50. package/dist/server/runtime/task-ledger-completion.d.ts +68 -0
  51. package/dist/server/runtime/task-ledger-completion.js +72 -0
  52. package/dist/server/runtime/task-ledger-recovery.d.ts +55 -0
  53. package/dist/server/runtime/task-ledger-recovery.js +73 -0
  54. package/dist/server/runtime/task-ledger-runtime.d.ts +64 -0
  55. package/dist/server/runtime/task-ledger-runtime.js +34 -0
  56. package/dist/server/runtime/task-metrics.d.ts +6 -4
  57. package/dist/server/runtime/task-polling.d.ts +14 -12
  58. package/dist/server/runtime/task-polling.js +106 -82
  59. package/dist/server/runtime/task-reconciliation.d.ts +35 -8
  60. package/dist/server/runtime/task-reconciliation.js +75 -74
  61. package/dist/server/runtime/task-result-resolution.d.ts +2 -10
  62. package/dist/server/runtime/task-result-resolution.js +0 -67
  63. package/dist/server/runtime/task-result-view.d.ts +82 -0
  64. package/dist/server/runtime/task-result-view.js +48 -0
  65. package/dist/server/runtime/websocket-worker-messaging.d.ts +16 -0
  66. package/dist/server/runtime/websocket-worker-messaging.js +32 -0
  67. package/dist/server/runtime/websocket-worker-registration.d.ts +41 -0
  68. package/dist/server/runtime/websocket-worker-registration.js +134 -0
  69. package/dist/server/runtime/websocket-worker.d.ts +0 -1
  70. package/dist/server/runtime/websocket-worker.js +64 -116
  71. package/dist/server/serve-internals.d.ts +9 -6
  72. package/dist/server/serve-internals.js +14 -40
  73. package/dist/server/task-ledger-codec.d.ts +40 -0
  74. package/dist/server/task-ledger-codec.js +195 -0
  75. package/dist/server/task-ledger-keys.d.ts +13 -0
  76. package/dist/server/task-ledger-keys.js +5 -0
  77. package/dist/server/task-ledger-limits.d.ts +34 -0
  78. package/dist/server/task-ledger-limits.js +5 -0
  79. package/dist/server/task-ledger-transition-helpers.d.ts +38 -0
  80. package/dist/server/task-ledger-transition-helpers.js +42 -0
  81. package/dist/server/task-ledger-transitions-cancellation.d.ts +31 -0
  82. package/dist/server/task-ledger-transitions-cancellation.js +73 -0
  83. package/dist/server/task-ledger-transitions.d.ts +129 -0
  84. package/dist/server/task-ledger-transitions.js +162 -0
  85. package/dist/server/task-ledger-types.d.ts +220 -0
  86. package/dist/server/task-ledger-types.js +1 -0
  87. package/dist/server/task-ledger.d.ts +17 -0
  88. package/dist/server/task-ledger.js +24 -0
  89. package/dist/server/task-state.d.ts +25 -152
  90. package/dist/server/task-state.js +0 -188
  91. package/dist/server/worker-admission-policy.d.ts +70 -0
  92. package/dist/server/worker-admission-policy.js +0 -0
  93. package/dist/storage/compressed-storage.js +1 -1
  94. package/dist/storage/index.d.ts +0 -2
  95. package/dist/storage/interface.d.ts +0 -2
  96. package/dist/storage/interface.js +1 -1
  97. package/dist/storage/lmdb.js +1 -1
  98. package/dist/storage/memory.js +1 -1
  99. package/dist/storage/neon.js +1 -1
  100. package/dist/storage/postgres.js +1 -1
  101. package/dist/storage/resolve.js +1 -1
  102. package/dist/storage/scoped-storage.js +1 -1
  103. package/dist/storage/testing.js +1 -1
  104. package/dist/storage/turso.js +1 -1
  105. package/dist/storage/web-extension.js +2 -2
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/dist/web-extension.js +1 -1
  109. package/dist/worker/index.d.ts +2 -0
  110. package/dist/worker/index.js +30 -4
  111. package/dist/worker/manifest/canonical-json.d.ts +20 -0
  112. package/dist/worker/manifest/canonical-json.js +13 -0
  113. package/dist/worker/manifest/capabilities.d.ts +24 -0
  114. package/dist/worker/manifest/capabilities.js +74 -0
  115. package/dist/worker/manifest/content-digest.d.ts +20 -0
  116. package/dist/worker/manifest/content-digest.js +13 -0
  117. package/dist/worker/manifest/declared-shape-digest.d.ts +14 -0
  118. package/dist/worker/manifest/declared-shape-digest.js +4 -0
  119. package/dist/worker/manifest/digest.d.ts +82 -0
  120. package/dist/worker/manifest/digest.js +9 -0
  121. package/dist/worker/manifest/execution-identity.d.ts +92 -0
  122. package/dist/worker/manifest/execution-identity.js +36 -0
  123. package/dist/worker/manifest/failure.d.ts +59 -0
  124. package/dist/worker/manifest/failure.js +4 -0
  125. package/dist/worker/manifest/index.d.ts +22 -0
  126. package/dist/worker/manifest/index.js +23 -0
  127. package/dist/worker/manifest/internal-realm.d.ts +50 -0
  128. package/dist/worker/manifest/internal-realm.js +39 -0
  129. package/dist/worker/manifest/is-record.d.ts +12 -0
  130. package/dist/worker/manifest/is-record.js +6 -0
  131. package/dist/worker/manifest/json-scan.d.ts +26 -0
  132. package/dist/worker/manifest/json-scan.js +88 -0
  133. package/dist/worker/manifest/limits.d.ts +138 -0
  134. package/dist/worker/manifest/limits.js +1 -0
  135. package/dist/worker/manifest/normalize.d.ts +63 -0
  136. package/dist/worker/manifest/normalize.js +73 -0
  137. package/dist/worker/manifest/parse-json.d.ts +24 -0
  138. package/dist/worker/manifest/parse-json.js +15 -0
  139. package/dist/worker/manifest/parse.d.ts +86 -0
  140. package/dist/worker/manifest/parse.js +188 -0
  141. package/dist/worker/manifest/registry-contract-builder.d.ts +120 -0
  142. package/dist/worker/manifest/registry-contract-builder.js +65 -0
  143. package/dist/worker/manifest/types.d.ts +263 -0
  144. package/dist/worker/manifest/types.js +1 -0
  145. package/dist/worker/manifest/utf8.d.ts +13 -0
  146. package/dist/worker/manifest/utf8.js +7 -0
  147. package/dist/worker/options.d.ts +55 -8
  148. package/dist/worker/options.js +65 -9
  149. package/dist/worker/protocol-internals.d.ts +1 -2
  150. package/dist/worker/protocol-internals.js +0 -5
  151. package/dist/worker/protocol-messages.d.ts +33 -17
  152. package/dist/worker/protocol-schemas.d.ts +36 -74
  153. package/dist/worker/protocol-schemas.js +25 -18
  154. package/dist/worker/protocol-version.d.ts +18 -14
  155. package/dist/worker/protocol-version.js +1 -1
  156. package/dist/worker/protocol.js +1 -1
  157. package/dist/worker/registry/deployment-consistency.d.ts +78 -0
  158. package/dist/worker/registry/deployment-consistency.js +27 -0
  159. package/dist/worker/registry/drain.d.ts +14 -0
  160. package/dist/worker/registry/drain.js +24 -0
  161. package/dist/worker/registry/rejections.d.ts +31 -0
  162. package/dist/worker/registry/rejections.js +10 -0
  163. package/dist/worker/registry/summary.d.ts +0 -3
  164. package/dist/worker/registry/summary.js +2 -5
  165. package/dist/worker/registry/types.d.ts +10 -3
  166. package/dist/worker/registry.d.ts +47 -2
  167. package/dist/worker/registry.js +45 -32
  168. package/dist/workers/workflow-worker-entry.d.ts +12 -7
  169. package/dist/workers/workflow-worker-entry.js +9 -2
  170. package/package.json +3 -3
  171. package/dist/server/task-resolved-record.d.ts +0 -12
  172. package/dist/server/task-resolved-record.js +0 -35
@@ -1,7 +1,5 @@
1
- import { decode } from "../../core/codec.js";
2
1
  import { WorkerDisconnectedEvent } from "../../core/events.js";
3
2
  import { handleMcpHttpRequest } from "../../mcp/http.js";
4
- import { KEYS } from "../../storage/interface.js";
5
3
  import { authContextToPrincipal, handleRequest } from "../handler.js";
6
4
  import { handleJsonRpcHttpRequestSafely } from "../json-rpc-transport-helpers.js";
7
5
  import {
@@ -10,6 +8,7 @@ import {
10
8
  openJsonRpcWebSocketSession
11
9
  } from "../json-rpc-websocket-runtime.js";
12
10
  import { API_PREFIX } from "../route-model.js";
11
+ import { decodeRemoteTaskRecord, taskLedgerKey } from "../task-ledger.js";
13
12
  import { buildPreflightResponse, decorateResponseWithCors, isPreflightRequest } from "./cors.js";
14
13
  import { gateRequest } from "./request-gate.js";
15
14
  import { handleTaskPollRequest, handleTaskResultRequest } from "./task-polling.js";
@@ -25,7 +24,7 @@ import {
25
24
  replayWatchEvents
26
25
  } from "./websocket-stream.js";
27
26
  import { handleWebSocketUpgrade } from "./websocket-upgrade.js";
28
- import { handleWorkerWebSocketMessage, isInflightRecord } from "./websocket-worker.js";
27
+ import { handleWorkerWebSocketMessage } from "./websocket-worker.js";
29
28
  export function deriveSupportedOpenApiSecuritySchemes(auth) {
30
29
  const schemes = new Set;
31
30
  if (auth?.jwt !== void 0)
@@ -206,18 +205,11 @@ function runWorkerDisconnectRequeue(context, options, workerId, _ws, cleanupWork
206
205
  for (const task of inFlightTasks)
207
206
  (async () => {
208
207
  try {
209
- const inflightKey = KEYS.operationInflight(task.operationId), existing = await options.engine.storage.get(inflightKey);
210
- if (existing) {
211
- const record = decode(existing);
212
- if (!isInflightRecord(record)) {
213
- console.error(`[weft] Corrupt inflight record for task "${task.operationId}" \u2014 skipping reassignment`);
214
- return;
215
- }
208
+ const record = decodeRemoteTaskRecord(await options.engine.storage.get(taskLedgerKey(task.operationId)));
209
+ if (record !== null && record.state === "leased")
216
210
  await reassignOrExpireTask(context, options, task.operationId, record, "worker-disconnect");
217
- } else {
218
- console.warn(`[weft] No inflight record found in storage for task "${task.operationId}" \u2014 skipping reassignment`);
219
- await options.engine.storage.delete(inflightKey);
220
- }
211
+ else
212
+ console.warn(`[weft] No leased ledger record found for task "${task.operationId}" \u2014 skipping reassignment`);
221
213
  } catch (error) {
222
214
  console.error(`[weft] Failed to reassign task "${task.operationId}" from worker "${workerId}":`, error);
223
215
  }
@@ -13,6 +13,19 @@ import type { createLiveOperationRegistry, createLiveRestBindings } from '../res
13
13
  import type { TaskQueue } from '../task-queue.ts';
14
14
  import type { WorkflowEventFeed } from '../workflow-event-feed.ts';
15
15
  import type { ResolvedCorsPolicy } from './cors.ts';
16
+ /**
17
+ * Startup task-ledger recovery gate (WFT-23). `ready` resolves once every
18
+ * non-terminal ledger record has been reconstructed into the in-memory
19
+ * registry, deadline tracker, and task queue indexes; it rejects
20
+ * (permanently — a settled promise replays the same outcome to every future
21
+ * awaiter) if the recovery scan itself failed. Task-plane entry points
22
+ * (`dispatchTaskImpl`, long-poll claim/result, and worker registration) await
23
+ * this before touching the ledger, so a scan failure blocks new claims with
24
+ * an actionable error instead of silently continuing with partial indexes.
25
+ */
26
+ export type TaskLedgerRecoveryGate = Readonly<{
27
+ ready: Promise<void>;
28
+ }>;
16
29
  /**
17
30
  * Internal closure state for a single `serve()` invocation.
18
31
  *
@@ -77,4 +90,14 @@ export interface ServerContext {
77
90
  readonly processingOperations: Set<string>;
78
91
  /** Mutex: prevents concurrent reconciliation scans from running simultaneously. */
79
92
  reconciliationRunning: boolean;
93
+ /** Startup task-ledger recovery gate — see {@link TaskLedgerRecoveryGate}. */
94
+ readonly taskLedgerRecovery: TaskLedgerRecoveryGate;
95
+ /**
96
+ * Set once `server.stop()`'s timer-clearing disposer has run. Startup
97
+ * recovery's scan loop and `scheduleDelayedDispatch` both check this before
98
+ * doing further work so a still-running recovery scan (or a reconciliation
99
+ * pass racing shutdown) cannot arm a new timer or issue a durable write
100
+ * after `pendingTimers` has already been cleared.
101
+ */
102
+ stopping: boolean;
80
103
  }
@@ -1,6 +1,12 @@
1
1
  import type { ServeOptions, TaskDispatch } from '../index.ts';
2
2
  import type { ServerContext } from './context.ts';
3
- /** Schedule a delayed dispatch, tracking the timer for cleanup on shutdown. */
3
+ /**
4
+ * Schedule a delayed dispatch, tracking the timer for cleanup on shutdown.
5
+ *
6
+ * No-ops once `context.stopping` is set — `server.stop()`'s timer-clearing
7
+ * disposer has already run by then, so arming a new timer here would leak a
8
+ * callback that fires against a stopped server instead of being cleared.
9
+ */
4
10
  export declare function scheduleDelayedDispatch(context: ServerContext, options: ServeOptions, task: TaskDispatch, delay: number): void;
5
11
  export declare function resolveTaskPriority(_context: ServerContext, _options: ServeOptions, task: TaskDispatch): number | undefined;
6
12
  export declare function dispatchTaskImpl(context: ServerContext, options: ServeOptions, task: TaskDispatch): Promise<boolean>;
@@ -1,5 +1,16 @@
1
+ import { isJSONValue } from "../../core/json.js";
2
+ import { buildWorkerExecutionIdentity } from "../../worker/manifest/execution-identity.js";
1
3
  import { evictOldestAffinityEntries } from "../runtime-helpers.js";
2
- import { markQueued, readQueuedRecord, transitionQueuedToInflight } from "../task-state.js";
4
+ import {
5
+ claimQueued,
6
+ createQueued
7
+ } from "../task-ledger-transitions.js";
8
+ import {
9
+ decodeRemoteTaskRecord,
10
+ REMOTE_TASK_RECORD_VERSION,
11
+ taskLedgerKey
12
+ } from "../task-ledger.js";
13
+ import { commitTaskLedgerTransition } from "./task-ledger-runtime.js";
3
14
  import {
4
15
  recordTaskBacklogMetric,
5
16
  recordTaskQueueLatencyMetric,
@@ -12,6 +23,8 @@ function clampVisibilityTimeout(value) {
12
23
  return Math.min(Math.max(value, MIN_VISIBILITY_TIMEOUT), MAX_VISIBILITY_TIMEOUT);
13
24
  }
14
25
  export function scheduleDelayedDispatch(context, options, task, delay) {
26
+ if (context.stopping)
27
+ return;
15
28
  const timer = setTimeout(() => {
16
29
  context.pendingTimers.delete(timer);
17
30
  dispatchTaskImpl(context, options, task).catch((err) => console.error(`[weft] Delayed redispatch failed for "${task.operationId}":`, err));
@@ -52,6 +65,55 @@ function recordDispatchOutcome(context, task, workerId) {
52
65
  operationIds.add(task.operationId);
53
66
  context.operationToWorkflow.set(task.operationId, task.workflowId);
54
67
  }
68
+ function buildOptionalCreateQueuedFields(task) {
69
+ return {
70
+ ...task.workflowId !== void 0 ? { workflowId: task.workflowId } : {},
71
+ ...task.workflowExecutionToken !== void 0 ? { workflowExecutionToken: task.workflowExecutionToken } : {},
72
+ ...task.priority !== void 0 ? { priority: task.priority } : {},
73
+ ...task.fairShareKey !== void 0 ? { fairShareKey: task.fairShareKey } : {},
74
+ ...task.sticky && task.workflowId !== void 0 ? { stickyWorkflowId: task.workflowId } : {},
75
+ ...task.retryPolicy !== void 0 ? { retryPolicy: task.retryPolicy } : {}
76
+ };
77
+ }
78
+ function buildCreateQueuedInput(task, queue, visibilityTimeout) {
79
+ const input = task.input === void 0 ? null : task.input;
80
+ if (!isJSONValue(input))
81
+ throw Error(`TaskDispatch for operation "${task.operationId}" has a non-JSON-serializable "input" \u2014 the durable task ledger requires JSON-safe input.`);
82
+ return {
83
+ recordVersion: REMOTE_TASK_RECORD_VERSION,
84
+ operationId: task.operationId,
85
+ workflowType: task.workflowType,
86
+ activityName: task.activityName,
87
+ queue,
88
+ input,
89
+ headers: task.headers ?? {},
90
+ visibilityTimeoutMilliseconds: visibilityTimeout,
91
+ createdAt: Date.now(),
92
+ ...buildOptionalCreateQueuedFields(task)
93
+ };
94
+ }
95
+ function buildCreateThenClaimTransition(createInput, claimInput) {
96
+ return (current, now) => {
97
+ let queuedRecord;
98
+ if (current === null) {
99
+ const created = createQueued(null, createInput, now);
100
+ if (!created.ok)
101
+ return created;
102
+ queuedRecord = created.nextRecord;
103
+ } else if (current.state === "queued")
104
+ queuedRecord = current;
105
+ else
106
+ return {
107
+ ok: !1,
108
+ reason: `operation "${createInput.operationId}" is not claimable from state "${current.state}"`
109
+ };
110
+ return claimQueued(queuedRecord, { ...claimInput, expectedGeneration: queuedRecord.generation }, now);
111
+ };
112
+ }
113
+ function bareActivityName(qualifiedActivityName) {
114
+ const dotIndex = qualifiedActivityName.indexOf(".");
115
+ return dotIndex === -1 ? qualifiedActivityName : qualifiedActivityName.slice(dotIndex + 1);
116
+ }
55
117
  async function selectAndReserveWorker(context, options, task, queue, visibilityTimeout) {
56
118
  const routingOptions = buildRoutingOptions(context, task, queue), worker = context.registry.findWorker(task.activityName, routingOptions);
57
119
  if (!worker)
@@ -59,86 +121,85 @@ async function selectAndReserveWorker(context, options, task, queue, visibilityT
59
121
  const ws = context.workerSockets.get(worker.id);
60
122
  if (!ws)
61
123
  return !1;
62
- const now = Date.now(), existingQueuedRecord = await readQueuedRecord(options.engine.storage, task.operationId), attemptToken = crypto.randomUUID();
124
+ const createInput = buildCreateQueuedInput(task, queue, visibilityTimeout), attemptToken = crypto.randomUUID(), executionIdentity = buildWorkerExecutionIdentity({
125
+ manifest: worker.manifest,
126
+ manifestDigest: worker.acceptedManifestDigest,
127
+ workerId: worker.id,
128
+ workflowType: task.workflowType,
129
+ activityName: bareActivityName(task.activityName)
130
+ });
63
131
  context.registry.assignTask(worker.id, task.operationId, visibilityTimeout, task.fairShareKey, attemptToken);
64
- const deadline = now + visibilityTimeout;
65
- context.deadlineTracker.add({ operationId: task.operationId, deadline });
66
- const inflightRecord = {
132
+ let result;
133
+ try {
134
+ result = await commitTaskLedgerTransition(options.engine.storage, task.operationId, buildCreateThenClaimTransition(createInput, {
135
+ attemptToken,
136
+ workerSessionId: worker.id,
137
+ ...executionIdentity !== void 0 ? { executionIdentity } : {},
138
+ leaseDurationMilliseconds: visibilityTimeout
139
+ }), 1);
140
+ } catch (error) {
141
+ context.registry.releaseReservation(task.operationId);
142
+ throw error;
143
+ }
144
+ if (!result.ok) {
145
+ context.registry.releaseReservation(task.operationId);
146
+ return !1;
147
+ }
148
+ context.deadlineTracker.add({
67
149
  operationId: task.operationId,
68
- workerId: worker.id,
69
- deadline,
70
- activityName: task.activityName,
71
- queue,
72
- input: task.input,
73
- attempt: task.attempt ?? 1,
74
- visibilityTimeout,
75
- retryPolicy: task.retryPolicy,
76
- workflowId: task.workflowId,
77
- workflowExecutionToken: task.workflowExecutionToken,
78
- attemptToken
79
- }, normalizedInflightRecord = await transitionQueuedToInflight(options.engine.storage, task.operationId, inflightRecord, {
80
- queuedRecord: existingQueuedRecord,
81
- now
150
+ deadline: result.record.leaseDeadline
82
151
  });
83
152
  ws.send(JSON.stringify({
84
153
  type: "task",
85
154
  operationId: task.operationId,
86
155
  activityName: task.activityName,
87
156
  input: task.input === void 0 ? null : task.input,
88
- attempt: task.attempt ?? 1,
157
+ attempt: result.record.attempt,
89
158
  attemptToken,
90
159
  ...task.workflowExecutionToken !== void 0 && {
91
160
  workflowExecutionToken: task.workflowExecutionToken
92
161
  },
93
162
  ...task.headers ? { headers: task.headers } : {}
94
163
  }));
95
- recordTaskQueueLatencyMetric(context.metricsCollector, normalizedInflightRecord);
164
+ recordTaskQueueLatencyMetric(context.metricsCollector, {
165
+ lastQueuedAt: result.record.lastQueuedAt,
166
+ lastDispatchedAt: Date.now()
167
+ });
96
168
  recordWorkerCapacitySaturationMetric(context.metricsCollector, context.registry);
97
169
  recordDispatchOutcome(context, task, worker.id);
98
170
  return !0;
99
171
  }
100
- function mergeQueuedRecordLifecycle(fresh, existing) {
101
- return {
102
- ...fresh,
103
- firstQueuedAt: existing?.firstQueuedAt ?? fresh.queuedAt,
104
- lastQueuedAt: fresh.queuedAt,
105
- lastDispatchedAt: existing?.lastDispatchedAt,
106
- startedAt: existing?.startedAt,
107
- retryCount: existing?.retryCount ?? fresh.retryCount,
108
- requeueCount: existing?.requeueCount ?? fresh.requeueCount,
109
- lastRequeueReason: existing?.lastRequeueReason
110
- };
111
- }
112
172
  async function enqueueTaskForLongPoll(context, options, task, queue, visibilityTimeout, resolvedPriority) {
113
- const attempt = task.attempt ?? 1, freshRecord = {
114
- operationId: task.operationId,
115
- activityName: task.activityName,
116
- input: task.input,
117
- queue,
118
- attempt,
119
- visibilityTimeout,
120
- retryPolicy: task.retryPolicy,
121
- queuedAt: Date.now(),
122
- workflowId: task.workflowId,
123
- workflowExecutionToken: task.workflowExecutionToken,
124
- retryCount: Math.max(0, attempt - 1),
125
- requeueCount: 0
126
- }, existingQueuedRecord = await readQueuedRecord(options.engine.storage, task.operationId), normalizedQueuedRecord = await markQueued(options.engine.storage, mergeQueuedRecordLifecycle(freshRecord, existingQueuedRecord)), enqueued = context.taskQueue.enqueue(queue, {
173
+ const storage = options.engine.storage, createInput = buildCreateQueuedInput(task, queue, visibilityTimeout), rawExisting = await storage.get(taskLedgerKey(task.operationId)), current = decodeRemoteTaskRecord(rawExisting);
174
+ let queuedRecord;
175
+ if (current === null) {
176
+ const result = await commitTaskLedgerTransition(storage, task.operationId, (freshCurrent, now) => createQueued(freshCurrent, createInput, now), 1);
177
+ if (!result.ok) {
178
+ const raced = decodeRemoteTaskRecord(await storage.get(taskLedgerKey(task.operationId)));
179
+ if (raced === null || raced.state !== "queued")
180
+ return !1;
181
+ queuedRecord = raced;
182
+ } else
183
+ queuedRecord = result.record;
184
+ } else if (current.state === "queued")
185
+ queuedRecord = current;
186
+ else
187
+ return !1;
188
+ const enqueued = context.taskQueue.enqueue(queue, {
127
189
  operationId: task.operationId,
128
190
  activityName: task.activityName,
129
191
  input: task.input,
130
- attempt,
192
+ attempt: queuedRecord.attempt,
131
193
  retryPolicy: task.retryPolicy,
132
194
  visibilityTimeout,
133
195
  workflowId: task.workflowId,
134
196
  workflowExecutionToken: task.workflowExecutionToken,
135
- firstQueuedAt: normalizedQueuedRecord.firstQueuedAt,
136
- lastQueuedAt: normalizedQueuedRecord.lastQueuedAt,
137
- lastDispatchedAt: normalizedQueuedRecord.lastDispatchedAt,
138
- startedAt: normalizedQueuedRecord.startedAt,
139
- retryCount: normalizedQueuedRecord.retryCount,
140
- requeueCount: normalizedQueuedRecord.requeueCount,
141
- lastRequeueReason: normalizedQueuedRecord.lastRequeueReason,
197
+ firstQueuedAt: queuedRecord.firstQueuedAt,
198
+ lastQueuedAt: queuedRecord.lastQueuedAt,
199
+ lastDispatchedAt: queuedRecord.lastDispatchedAt,
200
+ startedAt: queuedRecord.startedAt,
201
+ retryCount: queuedRecord.retryCount,
202
+ requeueCount: queuedRecord.requeueCount,
142
203
  ...task.headers ? { headers: task.headers } : {},
143
204
  ...resolvedPriority !== void 0 ? { priority: resolvedPriority } : {}
144
205
  });
@@ -146,6 +207,16 @@ async function enqueueTaskForLongPoll(context, options, task, queue, visibilityT
146
207
  return enqueued;
147
208
  }
148
209
  export async function dispatchTaskImpl(context, options, task) {
210
+ try {
211
+ await context.taskLedgerRecovery.ready;
212
+ } catch (error) {
213
+ throw Error(`Cannot dispatch task "${task.operationId}" \u2014 startup task-ledger recovery failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
214
+ }
215
+ if (!task.workflowType)
216
+ throw Error(`TaskDispatch for operation "${task.operationId}" is missing required field "workflowType".`);
217
+ const dotIndex = task.activityName.indexOf(".");
218
+ if (dotIndex !== -1 && task.activityName.slice(0, dotIndex) !== task.workflowType)
219
+ throw Error(`TaskDispatch for operation "${task.operationId}" has activityName "${task.activityName}" whose qualifier does not match workflowType "${task.workflowType}".`);
149
220
  const queue = task.queue ?? "default", visibilityTimeout = clampVisibilityTimeout(task.visibilityTimeout), resolvedPriority = resolveTaskPriority(context, options, task);
150
221
  if (!validateTaskEnvelope(context, task))
151
222
  return !1;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared two-step commit for applying a worker's task result to the durable
3
+ * remote task ledger (WFT-22) — used by both the WebSocket (`onTaskResultMessage`)
4
+ * and long-poll (`handleTaskResultRequest`) completion paths.
5
+ *
6
+ * `Leased --> Completing --> Terminal` is committed as two separate durable
7
+ * writes, not one, even though the caller already holds the full result: the
8
+ * project brief models `Completing` as an observable intermediate state
9
+ * specifically so a crash between the two writes leaves a durable, recoverable
10
+ * marker ("Completing prevents visibility scanning or disconnect handling from
11
+ * requeueing an attempt while its terminal result is being applied") that
12
+ * WFT-23's recovery is designed to resume. Collapsing the two transitions into
13
+ * one write would make that state unobservable and defeat the point of having
14
+ * it.
15
+ *
16
+ * A submitter can itself resume that crash window: if the record is already
17
+ * `completing` with the same `attemptToken` AND the same `pendingResultDigest`
18
+ * — the worker retrying the identical result after a server crash between the
19
+ * two writes, or a benign duplicate submission — this skips straight to
20
+ * `commitTerminalResult` instead of rejecting a legitimate resubmission with
21
+ * "expected task state leased". A `completing` record whose digest or token
22
+ * differs is a genuinely different result and is rejected normally.
23
+ *
24
+ * On exhausted retries — specifically when the *second* write
25
+ * (`commitTerminalFromCompleting`) exhausts its CAS retry budget after the
26
+ * record is already durably `completing` — this attempts one best-effort
27
+ * `Completing --> DeadLettered` write (WFT-24's `commitDeadLetter`,
28
+ * `../task-ledger-transitions.ts`) so a sustained, operation-specific
29
+ * storage write failure becomes an operator-visible dead letter instead of a
30
+ * silently stuck record. If the *first* write (`beginCompletion`) exhausts
31
+ * instead, the record never left `leased` and no dead letter is attempted —
32
+ * the visibility scanner's ordinary expiry path already covers that case.
33
+ * If the dead-letter write itself also fails, this falls back to the plain
34
+ * `ok: false` result exactly as before WFT-24: the record stays `completing`,
35
+ * a worker resubmitting the identical result can still resume through the
36
+ * `resuming` branch above, and no data is lost.
37
+ *
38
+ * @module server/runtime/task-ledger-completion
39
+ */
40
+ import type { Storage } from '../../storage/interface.ts';
41
+ import type { ServeOptions } from '../index.ts';
42
+ import { type RemoteTaskCompleting, type RemoteTaskDeadLettered, type RemoteTaskTerminal } from '../task-ledger.ts';
43
+ export type TaskLedgerCompletionInput = Readonly<{
44
+ operationId: string;
45
+ attemptToken: string;
46
+ status: 'completed' | 'failed';
47
+ value?: unknown;
48
+ error?: string;
49
+ }>;
50
+ export type TaskLedgerCompletionResult = Readonly<{
51
+ ok: true;
52
+ completing: RemoteTaskCompleting;
53
+ terminal: RemoteTaskTerminal;
54
+ }> | Readonly<{
55
+ ok: false;
56
+ reason: string;
57
+ deadLettered?: RemoteTaskDeadLettered;
58
+ }>;
59
+ /**
60
+ * Dispatch {@link TaskResultDeadLetteredEvent} for a record `commitTaskLedgerCompletion`
61
+ * dead-lettered. Shared by the WebSocket and long-poll completion paths so
62
+ * both build the event from the same fields — `workflowId`/`activityName`/`queue`
63
+ * come from the dead-lettered record itself (present on every `RemoteTaskBase`),
64
+ * not from the caller's original request, so the event is accurate even if the
65
+ * caller only had partial information.
66
+ */
67
+ export declare function dispatchTaskDeadLetteredEvent(options: ServeOptions, operationId: string, deadLettered: RemoteTaskDeadLettered, workerId: string | undefined): void;
68
+ export declare function commitTaskLedgerCompletion(storage: Storage, input: TaskLedgerCompletionInput): Promise<TaskLedgerCompletionResult>;
@@ -0,0 +1,72 @@
1
+ import { TaskResultDeadLetteredEvent } from "../../core/events.js";
2
+ import { isJSONValue } from "../../core/json.js";
3
+ import { sha256Hex } from "../../worker/manifest/content-digest.js";
4
+ import {
5
+ beginCompletion,
6
+ commitDeadLetter,
7
+ commitTerminalResult
8
+ } from "../task-ledger-transitions.js";
9
+ import {
10
+ decodeRemoteTaskRecord,
11
+ taskLedgerKey
12
+ } from "../task-ledger.js";
13
+ import { commitTaskLedgerTransition } from "./task-ledger-runtime.js";
14
+ const COMPLETION_MAX_ATTEMPTS = 3, DEAD_LETTER_MAX_ATTEMPTS = 1;
15
+ async function pendingResultDigest(input) {
16
+ return sha256Hex(JSON.stringify({
17
+ status: input.status,
18
+ value: input.value ?? null,
19
+ error: input.error ?? null
20
+ }));
21
+ }
22
+ function commitTerminalFromCompleting(storage, input, resultDigest) {
23
+ return commitTaskLedgerTransition(storage, input.operationId, (current, now) => commitTerminalResult(current, {
24
+ attemptToken: input.attemptToken,
25
+ resultDigest,
26
+ ...input.status === "failed" && input.error !== void 0 ? { error: input.error } : {}
27
+ }, now), COMPLETION_MAX_ATTEMPTS);
28
+ }
29
+ async function attemptDeadLetter(storage, input, resultDigest, persistenceFailureReason) {
30
+ const deadLettered = await commitTaskLedgerTransition(storage, input.operationId, (current, now) => commitDeadLetter(current, {
31
+ attemptToken: input.attemptToken,
32
+ resultDigest,
33
+ ...isJSONValue(input.value) ? { value: input.value } : {},
34
+ ...input.error !== void 0 ? { error: input.error } : {},
35
+ persistenceFailureReason
36
+ }, now), DEAD_LETTER_MAX_ATTEMPTS);
37
+ return deadLettered.ok ? deadLettered.record : void 0;
38
+ }
39
+ export function dispatchTaskDeadLetteredEvent(options, operationId, deadLettered, workerId) {
40
+ options.engine.dispatchEvent(new TaskResultDeadLetteredEvent({
41
+ operationId,
42
+ workflowId: deadLettered.workflowId,
43
+ activityName: deadLettered.activityName,
44
+ queue: deadLettered.queue,
45
+ workerId,
46
+ errorMessage: deadLettered.persistenceFailureReason
47
+ }));
48
+ }
49
+ export async function commitTaskLedgerCompletion(storage, input) {
50
+ const resultDigest = await pendingResultDigest(input), existing = decodeRemoteTaskRecord(await storage.get(taskLedgerKey(input.operationId)));
51
+ if (existing !== null && existing.state === "completing" && existing.attemptToken === input.attemptToken && existing.pendingResultDigest === resultDigest) {
52
+ const committed = await commitTerminalFromCompleting(storage, input, resultDigest);
53
+ if (!committed.ok) {
54
+ const deadLettered = await attemptDeadLetter(storage, input, resultDigest, committed.reason);
55
+ return { ...committed, ...deadLettered !== void 0 ? { deadLettered } : {} };
56
+ }
57
+ return { ok: !0, completing: existing, terminal: committed.record };
58
+ }
59
+ const begun = await commitTaskLedgerTransition(storage, input.operationId, (current) => beginCompletion(current, {
60
+ attemptToken: input.attemptToken,
61
+ pendingStatus: input.status,
62
+ pendingResultDigest: resultDigest
63
+ }), COMPLETION_MAX_ATTEMPTS);
64
+ if (!begun.ok)
65
+ return begun;
66
+ const committed = await commitTerminalFromCompleting(storage, input, resultDigest);
67
+ if (!committed.ok) {
68
+ const deadLettered = await attemptDeadLetter(storage, input, resultDigest, committed.reason);
69
+ return { ...committed, ...deadLettered !== void 0 ? { deadLettered } : {} };
70
+ }
71
+ return { ok: !0, completing: begun.record, terminal: committed.record };
72
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Startup task-ledger recovery (WFT-23).
3
+ *
4
+ * A one-shot full scan of the durable `task-ledger:` keyspace, run once per
5
+ * `serve()` call before the recovery gate on `ServerContext.taskLedgerRecovery`
6
+ * opens (see `buildServerContext` in `serve-internals.ts`). Reconstructs the
7
+ * in-memory registry, deadline tracker, and task-queue indexes that
8
+ * `restoreInflightTasks` used to rebuild from the retired `op:inflight:`
9
+ * keyspace — those indexes are process-local caches; the ledger is the
10
+ * durable authority, so nothing here writes state, only rehydrates memory to
11
+ * match what storage already says.
12
+ *
13
+ * One branch per non-terminal state:
14
+ * - `queued` — redispatch through the normal dispatch path so a lost
15
+ * `scheduleDelayedDispatch` timer (or a record this process
16
+ * never dispatched at all) still becomes runnable.
17
+ * - `leased`, deadline still in the future — rehydrate registry ownership
18
+ * and the deadline tracker so heartbeats and completions
19
+ * from the original worker are recognized after reconnect.
20
+ * - `leased`, deadline already passed — requeue or exhaust through the same
21
+ * `reassignOrExpireTask` CAS path `scanExpiredTasks` and
22
+ * `reconcileOrphanedRecords` use; deletion is never correct
23
+ * here (see `RequeueExpiredAttemptInput`'s contract).
24
+ * - `completing` — rehydrate registry ownership only. The pending result
25
+ * value isn't persisted (only its digest), so recovery
26
+ * cannot resolve this state itself; only the worker's
27
+ * redelivered `taskResult`, landing on
28
+ * `commitTaskLedgerCompletion`'s crash-resumption branch,
29
+ * can. Recovery's job is to make sure that redelivery still
30
+ * passes the WebSocket ownership guard after a restart.
31
+ * - `cancelling` — rehydrate registry ownership only, for the same reason:
32
+ * nothing in the current runtime calls `commitCancellation`
33
+ * yet (a future project's scope), so there is no action to
34
+ * resume, only ownership to preserve.
35
+ * - `terminal`, `deadLettered` — already resolved; skipped.
36
+ *
37
+ * A single corrupt or unrecognized record is logged and skipped — ordinary
38
+ * data hygiene, not a recovery failure. A failure in the scan itself (the
39
+ * storage iterator throwing) propagates and rejects the whole gate: per the
40
+ * project brief, "a scan failure leaves the remote worker plane unhealthy
41
+ * and prevents new claims rather than logging and continuing with partial
42
+ * indexes."
43
+ *
44
+ * @module server/runtime/task-ledger-recovery
45
+ */
46
+ import type { ServeOptions } from '../index.ts';
47
+ import type { ServerContext } from './context.ts';
48
+ /**
49
+ * Run startup task-ledger recovery to completion. Resolves once every
50
+ * non-terminal record has been reconstructed; rejects if the scan itself
51
+ * failed. Callers gate task-plane entry points on the resulting promise
52
+ * rather than awaiting this directly from within another recovery-time
53
+ * scan — see `ServerContext.taskLedgerRecovery`'s doc comment.
54
+ */
55
+ export declare function runTaskLedgerRecovery(context: ServerContext, options: ServeOptions): Promise<void>;
@@ -0,0 +1,73 @@
1
+ import {
2
+ decodeRemoteTaskRecord
3
+ } from "../task-ledger.js";
4
+ import { scheduleDelayedDispatch } from "./task-dispatch.js";
5
+ import { reassignOrExpireTask, taskDispatchFromLedgerRecord } from "./task-reconciliation.js";
6
+ function rebuildWorkflowIndex(context, operationId, workflowId) {
7
+ if (!workflowId)
8
+ return;
9
+ let operationIds = context.workflowOperations.get(workflowId);
10
+ if (!operationIds) {
11
+ operationIds = new Set;
12
+ context.workflowOperations.set(workflowId, operationIds);
13
+ }
14
+ operationIds.add(operationId);
15
+ context.operationToWorkflow.set(operationId, workflowId);
16
+ }
17
+ function rehydrateWorkerOwnership(context, record, now) {
18
+ const remaining = Math.max(0, record.leaseDeadline - now);
19
+ context.registry.assignTask(record.workerSessionId, record.operationId, remaining, record.fairShareKey, record.attemptToken);
20
+ const tracked = context.registry.getWorkerTasks(record.workerSessionId).find((task) => task.operationId === record.operationId);
21
+ if (tracked)
22
+ tracked.visibilityTimeout = record.visibilityTimeoutMilliseconds;
23
+ rebuildWorkflowIndex(context, record.operationId, record.workflowId);
24
+ }
25
+ async function recoverTaskLedgerRecord(context, options, decoded, now) {
26
+ switch (decoded.state) {
27
+ case "queued": {
28
+ const delay = Math.max(0, decoded.availableAt - now);
29
+ scheduleDelayedDispatch(context, options, taskDispatchFromLedgerRecord(decoded), delay);
30
+ return;
31
+ }
32
+ case "leased": {
33
+ if (decoded.leaseDeadline > now) {
34
+ rehydrateWorkerOwnership(context, decoded, now);
35
+ context.deadlineTracker.add({
36
+ operationId: decoded.operationId,
37
+ deadline: decoded.leaseDeadline
38
+ });
39
+ return;
40
+ }
41
+ await reassignOrExpireTask(context, options, decoded.operationId, decoded);
42
+ return;
43
+ }
44
+ case "completing":
45
+ case "cancelling": {
46
+ rehydrateWorkerOwnership(context, decoded, now);
47
+ return;
48
+ }
49
+ case "terminal":
50
+ case "deadLettered":
51
+ return;
52
+ default: {
53
+ const exhaustive = decoded;
54
+ }
55
+ }
56
+ }
57
+ export async function runTaskLedgerRecovery(context, options) {
58
+ const now = Date.now();
59
+ for await (const [key, value] of options.engine.storage.scan("task-ledger:")) {
60
+ if (context.stopping)
61
+ return;
62
+ try {
63
+ const decoded = decodeRemoteTaskRecord(value);
64
+ if (decoded === null) {
65
+ console.error(`[weft] Unrecognized task ledger record at "${key}" during startup recovery \u2014 skipping`);
66
+ continue;
67
+ }
68
+ await recoverTaskLedgerRecord(context, options, decoded, now);
69
+ } catch (error) {
70
+ console.error(`[weft] Failed to recover task ledger record at "${key}" \u2014 skipping:`, error);
71
+ }
72
+ }
73
+ }