@nodetool-ai/execution 0.8.1

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 (231) hide show
  1. package/README.md +129 -0
  2. package/dist/.nodetool-build-stamp +1 -0
  3. package/dist/app-debug/app-spec.d.ts +94 -0
  4. package/dist/app-debug/app-spec.d.ts.map +1 -0
  5. package/dist/app-debug/app-spec.js +710 -0
  6. package/dist/app-debug/app-spec.js.map +1 -0
  7. package/dist/app-debug/application-target.d.ts +53 -0
  8. package/dist/app-debug/application-target.d.ts.map +1 -0
  9. package/dist/app-debug/application-target.js +126 -0
  10. package/dist/app-debug/application-target.js.map +1 -0
  11. package/dist/app-debug/bundle-target.d.ts +21 -0
  12. package/dist/app-debug/bundle-target.d.ts.map +1 -0
  13. package/dist/app-debug/bundle-target.js +51 -0
  14. package/dist/app-debug/bundle-target.js.map +1 -0
  15. package/dist/app-debug/graph-shape.d.ts +14 -0
  16. package/dist/app-debug/graph-shape.d.ts.map +1 -0
  17. package/dist/app-debug/graph-shape.js +23 -0
  18. package/dist/app-debug/graph-shape.js.map +1 -0
  19. package/dist/app-debug/index.d.ts +26 -0
  20. package/dist/app-debug/index.d.ts.map +1 -0
  21. package/dist/app-debug/index.js +19 -0
  22. package/dist/app-debug/index.js.map +1 -0
  23. package/dist/app-debug/markdown.d.ts +6 -0
  24. package/dist/app-debug/markdown.d.ts.map +1 -0
  25. package/dist/app-debug/markdown.js +133 -0
  26. package/dist/app-debug/markdown.js.map +1 -0
  27. package/dist/app-debug/runtime.d.ts +267 -0
  28. package/dist/app-debug/runtime.d.ts.map +1 -0
  29. package/dist/app-debug/runtime.js +633 -0
  30. package/dist/app-debug/runtime.js.map +1 -0
  31. package/dist/app-debug/script-operation.d.ts +58 -0
  32. package/dist/app-debug/script-operation.d.ts.map +1 -0
  33. package/dist/app-debug/script-operation.js +44 -0
  34. package/dist/app-debug/script-operation.js.map +1 -0
  35. package/dist/app-debug/simulate.d.ts +85 -0
  36. package/dist/app-debug/simulate.d.ts.map +1 -0
  37. package/dist/app-debug/simulate.js +991 -0
  38. package/dist/app-debug/simulate.js.map +1 -0
  39. package/dist/app-debug/summarize.d.ts +64 -0
  40. package/dist/app-debug/summarize.d.ts.map +1 -0
  41. package/dist/app-debug/summarize.js +65 -0
  42. package/dist/app-debug/summarize.js.map +1 -0
  43. package/dist/app-debug/types.d.ts +343 -0
  44. package/dist/app-debug/types.d.ts.map +1 -0
  45. package/dist/app-debug/types.js +2 -0
  46. package/dist/app-debug/types.js.map +1 -0
  47. package/dist/cost-ledger.d.ts +147 -0
  48. package/dist/cost-ledger.d.ts.map +1 -0
  49. package/dist/cost-ledger.js +288 -0
  50. package/dist/cost-ledger.js.map +1 -0
  51. package/dist/debug/collector.d.ts +28 -0
  52. package/dist/debug/collector.d.ts.map +1 -0
  53. package/dist/debug/collector.js +284 -0
  54. package/dist/debug/collector.js.map +1 -0
  55. package/dist/debug/index.d.ts +15 -0
  56. package/dist/debug/index.d.ts.map +1 -0
  57. package/dist/debug/index.js +16 -0
  58. package/dist/debug/index.js.map +1 -0
  59. package/dist/debug/types.d.ts +179 -0
  60. package/dist/debug/types.d.ts.map +1 -0
  61. package/dist/debug/types.js +11 -0
  62. package/dist/debug/types.js.map +1 -0
  63. package/dist/debug/verdict.d.ts +27 -0
  64. package/dist/debug/verdict.d.ts.map +1 -0
  65. package/dist/debug/verdict.js +51 -0
  66. package/dist/debug/verdict.js.map +1 -0
  67. package/dist/executor-resolver.d.ts +13 -0
  68. package/dist/executor-resolver.d.ts.map +1 -0
  69. package/dist/executor-resolver.js +13 -0
  70. package/dist/executor-resolver.js.map +1 -0
  71. package/dist/fal-output-decoder.d.ts +36 -0
  72. package/dist/fal-output-decoder.d.ts.map +1 -0
  73. package/dist/fal-output-decoder.js +129 -0
  74. package/dist/fal-output-decoder.js.map +1 -0
  75. package/dist/generation-lifecycle.d.ts +54 -0
  76. package/dist/generation-lifecycle.d.ts.map +1 -0
  77. package/dist/generation-lifecycle.js +431 -0
  78. package/dist/generation-lifecycle.js.map +1 -0
  79. package/dist/generation-recovery-worker.d.ts +100 -0
  80. package/dist/generation-recovery-worker.d.ts.map +1 -0
  81. package/dist/generation-recovery-worker.js +995 -0
  82. package/dist/generation-recovery-worker.js.map +1 -0
  83. package/dist/generation-tracker.d.ts +83 -0
  84. package/dist/generation-tracker.d.ts.map +1 -0
  85. package/dist/generation-tracker.js +436 -0
  86. package/dist/generation-tracker.js.map +1 -0
  87. package/dist/index.d.ts +28 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +21 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/js-script-debug/index.d.ts +13 -0
  92. package/dist/js-script-debug/index.d.ts.map +1 -0
  93. package/dist/js-script-debug/index.js +12 -0
  94. package/dist/js-script-debug/index.js.map +1 -0
  95. package/dist/js-script-debug/markdown.d.ts +6 -0
  96. package/dist/js-script-debug/markdown.d.ts.map +1 -0
  97. package/dist/js-script-debug/markdown.js +62 -0
  98. package/dist/js-script-debug/markdown.js.map +1 -0
  99. package/dist/js-script-debug/outputs.d.ts +20 -0
  100. package/dist/js-script-debug/outputs.d.ts.map +1 -0
  101. package/dist/js-script-debug/outputs.js +30 -0
  102. package/dist/js-script-debug/outputs.js.map +1 -0
  103. package/dist/js-script-debug/report.d.ts +15 -0
  104. package/dist/js-script-debug/report.d.ts.map +1 -0
  105. package/dist/js-script-debug/report.js +90 -0
  106. package/dist/js-script-debug/report.js.map +1 -0
  107. package/dist/js-script-debug/types.d.ts +63 -0
  108. package/dist/js-script-debug/types.d.ts.map +1 -0
  109. package/dist/js-script-debug/types.js +10 -0
  110. package/dist/js-script-debug/types.js.map +1 -0
  111. package/dist/js-script-debug/validate.d.ts +38 -0
  112. package/dist/js-script-debug/validate.d.ts.map +1 -0
  113. package/dist/js-script-debug/validate.js +137 -0
  114. package/dist/js-script-debug/validate.js.map +1 -0
  115. package/dist/message-stream.d.ts +33 -0
  116. package/dist/message-stream.d.ts.map +1 -0
  117. package/dist/message-stream.js +58 -0
  118. package/dist/message-stream.js.map +1 -0
  119. package/dist/normalize-graph.d.ts +13 -0
  120. package/dist/normalize-graph.d.ts.map +1 -0
  121. package/dist/normalize-graph.js +63 -0
  122. package/dist/normalize-graph.js.map +1 -0
  123. package/dist/output-names.d.ts +13 -0
  124. package/dist/output-names.d.ts.map +1 -0
  125. package/dist/output-names.js +13 -0
  126. package/dist/output-names.js.map +1 -0
  127. package/dist/predicates.d.ts +6 -0
  128. package/dist/predicates.d.ts.map +1 -0
  129. package/dist/predicates.js +6 -0
  130. package/dist/predicates.js.map +1 -0
  131. package/dist/preflight.d.ts +87 -0
  132. package/dist/preflight.d.ts.map +1 -0
  133. package/dist/preflight.js +139 -0
  134. package/dist/preflight.js.map +1 -0
  135. package/dist/provider-secret-key.d.ts +3 -0
  136. package/dist/provider-secret-key.d.ts.map +1 -0
  137. package/dist/provider-secret-key.js +7 -0
  138. package/dist/provider-secret-key.js.map +1 -0
  139. package/dist/service/app-debug-service.d.ts +78 -0
  140. package/dist/service/app-debug-service.d.ts.map +1 -0
  141. package/dist/service/app-debug-service.js +201 -0
  142. package/dist/service/app-debug-service.js.map +1 -0
  143. package/dist/service/app-run-server.d.ts +28 -0
  144. package/dist/service/app-run-server.d.ts.map +1 -0
  145. package/dist/service/app-run-server.js +104 -0
  146. package/dist/service/app-run-server.js.map +1 -0
  147. package/dist/service/debug-sessions.d.ts +158 -0
  148. package/dist/service/debug-sessions.d.ts.map +1 -0
  149. package/dist/service/debug-sessions.js +358 -0
  150. package/dist/service/debug-sessions.js.map +1 -0
  151. package/dist/service/index.d.ts +22 -0
  152. package/dist/service/index.d.ts.map +1 -0
  153. package/dist/service/index.js +17 -0
  154. package/dist/service/index.js.map +1 -0
  155. package/dist/service/job-progress.d.ts +40 -0
  156. package/dist/service/job-progress.d.ts.map +1 -0
  157. package/dist/service/job-progress.js +53 -0
  158. package/dist/service/job-progress.js.map +1 -0
  159. package/dist/service/workflow-run.d.ts +279 -0
  160. package/dist/service/workflow-run.d.ts.map +1 -0
  161. package/dist/service/workflow-run.js +598 -0
  162. package/dist/service/workflow-run.js.map +1 -0
  163. package/dist/service/workflow-workspace.d.ts +89 -0
  164. package/dist/service/workflow-workspace.d.ts.map +1 -0
  165. package/dist/service/workflow-workspace.js +160 -0
  166. package/dist/service/workflow-workspace.js.map +1 -0
  167. package/dist/session.d.ts +62 -0
  168. package/dist/session.d.ts.map +1 -0
  169. package/dist/session.js +353 -0
  170. package/dist/session.js.map +1 -0
  171. package/dist/sketch-debug/index.d.ts +11 -0
  172. package/dist/sketch-debug/index.d.ts.map +1 -0
  173. package/dist/sketch-debug/index.js +10 -0
  174. package/dist/sketch-debug/index.js.map +1 -0
  175. package/dist/sketch-debug/markdown.d.ts +6 -0
  176. package/dist/sketch-debug/markdown.d.ts.map +1 -0
  177. package/dist/sketch-debug/markdown.js +93 -0
  178. package/dist/sketch-debug/markdown.js.map +1 -0
  179. package/dist/sketch-debug/report.d.ts +15 -0
  180. package/dist/sketch-debug/report.d.ts.map +1 -0
  181. package/dist/sketch-debug/report.js +98 -0
  182. package/dist/sketch-debug/report.js.map +1 -0
  183. package/dist/sketch-debug/types.d.ts +62 -0
  184. package/dist/sketch-debug/types.d.ts.map +1 -0
  185. package/dist/sketch-debug/types.js +10 -0
  186. package/dist/sketch-debug/types.js.map +1 -0
  187. package/dist/sketch-debug/validate.d.ts +14 -0
  188. package/dist/sketch-debug/validate.d.ts.map +1 -0
  189. package/dist/sketch-debug/validate.js +478 -0
  190. package/dist/sketch-debug/validate.js.map +1 -0
  191. package/dist/supervisor.d.ts +45 -0
  192. package/dist/supervisor.d.ts.map +1 -0
  193. package/dist/supervisor.js +97 -0
  194. package/dist/supervisor.js.map +1 -0
  195. package/dist/timeline-debug/index.d.ts +11 -0
  196. package/dist/timeline-debug/index.d.ts.map +1 -0
  197. package/dist/timeline-debug/index.js +10 -0
  198. package/dist/timeline-debug/index.js.map +1 -0
  199. package/dist/timeline-debug/legibility.d.ts +23 -0
  200. package/dist/timeline-debug/legibility.d.ts.map +1 -0
  201. package/dist/timeline-debug/legibility.js +240 -0
  202. package/dist/timeline-debug/legibility.js.map +1 -0
  203. package/dist/timeline-debug/markdown.d.ts +6 -0
  204. package/dist/timeline-debug/markdown.d.ts.map +1 -0
  205. package/dist/timeline-debug/markdown.js +103 -0
  206. package/dist/timeline-debug/markdown.js.map +1 -0
  207. package/dist/timeline-debug/motion.d.ts +20 -0
  208. package/dist/timeline-debug/motion.d.ts.map +1 -0
  209. package/dist/timeline-debug/motion.js +362 -0
  210. package/dist/timeline-debug/motion.js.map +1 -0
  211. package/dist/timeline-debug/report.d.ts +16 -0
  212. package/dist/timeline-debug/report.d.ts.map +1 -0
  213. package/dist/timeline-debug/report.js +100 -0
  214. package/dist/timeline-debug/report.js.map +1 -0
  215. package/dist/timeline-debug/showcase.d.ts +7 -0
  216. package/dist/timeline-debug/showcase.d.ts.map +1 -0
  217. package/dist/timeline-debug/showcase.js +347 -0
  218. package/dist/timeline-debug/showcase.js.map +1 -0
  219. package/dist/timeline-debug/types.d.ts +64 -0
  220. package/dist/timeline-debug/types.d.ts.map +1 -0
  221. package/dist/timeline-debug/types.js +10 -0
  222. package/dist/timeline-debug/types.js.map +1 -0
  223. package/dist/timeline-debug/validate.d.ts +24 -0
  224. package/dist/timeline-debug/validate.d.ts.map +1 -0
  225. package/dist/timeline-debug/validate.js +1432 -0
  226. package/dist/timeline-debug/validate.js.map +1 -0
  227. package/dist/types.d.ts +211 -0
  228. package/dist/types.d.ts.map +1 -0
  229. package/dist/types.js +2 -0
  230. package/dist/types.js.map +1 -0
  231. package/package.json +103 -0
@@ -0,0 +1,995 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { GenerationAttempt, GenerationAttachment, GenerationOutput, GenerationWebhookDelivery, Prediction } from "@nodetool-ai/models";
3
+ import { createLogger } from "@nodetool-ai/config";
4
+ import { decodeFalOutputs, deterministicFalStorageKey } from "./fal-output-decoder.js";
5
+ const log = createLogger("nodetool.execution.generation-recovery");
6
+ function record(value) {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value)
8
+ ? value
9
+ : null;
10
+ }
11
+ function parsedRecord(value) {
12
+ if (!value)
13
+ return null;
14
+ try {
15
+ return record(JSON.parse(value));
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function asString(value) {
22
+ return typeof value === "string" && value.length > 0 ? value : null;
23
+ }
24
+ function statusError(value) {
25
+ const error = value.error ?? value.error_type ?? value.payload_error;
26
+ if (error == null || error === "")
27
+ return null;
28
+ return typeof error === "string" ? error : JSON.stringify(error);
29
+ }
30
+ const TERMINAL_ATTACHMENT_STATUSES = new Set([
31
+ "attached",
32
+ "superseded",
33
+ "target_deleted"
34
+ ]);
35
+ function attachmentIntents(generation) {
36
+ return Array.isArray(generation.metadata?.attachments)
37
+ ? generation.metadata.attachments
38
+ .map(record)
39
+ .filter((value) => value !== null)
40
+ : [];
41
+ }
42
+ function isTerminalProjection(generation) {
43
+ return ((generation.status === "completed" &&
44
+ generation.provider_status === "succeeded" &&
45
+ generation.output_status === "ready") ||
46
+ generation.status === "failed" ||
47
+ generation.status === "cancelled");
48
+ }
49
+ /**
50
+ * Bounded, lease-fenced finalization. Webhook rows are consumed before queue
51
+ * polling, so a callback that arrived during a process restart wins without
52
+ * spending another provider read. Every write is idempotent and guarded by
53
+ * the generation and attempt lease versions.
54
+ */
55
+ export class DurableGenerationRecoveryWorker {
56
+ provider;
57
+ workerId;
58
+ batchSize;
59
+ leaseMs;
60
+ now;
61
+ finalizeOutput;
62
+ submitAccepted;
63
+ attachOutput;
64
+ pass = null;
65
+ constructor(options) {
66
+ this.provider = options.provider;
67
+ this.workerId = options.workerId ?? `generation-recovery:${randomUUID()}`;
68
+ this.batchSize = Math.max(1, Math.min(options.batchSize ?? 50, 500));
69
+ this.leaseMs = Math.max(5_000, Math.min(options.leaseMs ?? 60_000, 15 * 60_000));
70
+ this.now = options.now ?? (() => new Date());
71
+ this.finalizeOutput = options.finalizeOutput;
72
+ this.submitAccepted = options.submitAccepted;
73
+ this.attachOutput = options.attachOutput;
74
+ }
75
+ /**
76
+ * One pass at a time per instance. A pass polls the provider item by item,
77
+ * so it can outlast the interval the host runs it on; a second overlapping
78
+ * pass would reclaim leases the first still holds and redo its work.
79
+ */
80
+ async runOnce() {
81
+ if (this.pass)
82
+ return this.pass;
83
+ this.pass = this.runPass().finally(() => {
84
+ this.pass = null;
85
+ });
86
+ return this.pass;
87
+ }
88
+ /** A lease window measured at the moment of the claim it fences. A batch
89
+ * can take longer than one lease, so a window minted for the whole batch
90
+ * hands its last items an expiry that has already passed. */
91
+ leaseWindow() {
92
+ const now = this.now();
93
+ return {
94
+ nowIso: now.toISOString(),
95
+ expiresAt: new Date(now.getTime() + this.leaseMs).toISOString()
96
+ };
97
+ }
98
+ async runPass() {
99
+ const result = {
100
+ deliveries: 0,
101
+ polledAttempts: 0,
102
+ completed: 0,
103
+ failed: 0,
104
+ skipped: 0
105
+ };
106
+ const deliveries = await GenerationWebhookDelivery.pending(this.now().toISOString(), this.batchSize);
107
+ for (const delivery of deliveries) {
108
+ const { nowIso, expiresAt } = this.leaseWindow();
109
+ const claimed = await GenerationWebhookDelivery.claim(delivery.id, this.workerId, nowIso, expiresAt);
110
+ if (!claimed) {
111
+ result.skipped++;
112
+ continue;
113
+ }
114
+ result.deliveries++;
115
+ let outcome;
116
+ try {
117
+ outcome = await this.finalizeDelivery(claimed, nowIso, expiresAt);
118
+ }
119
+ catch (error) {
120
+ // Storage/finalizer failures are retryable infrastructure failures,
121
+ // not provider failures. Leave the leased delivery for its expiry so
122
+ // another bounded pass can retry it.
123
+ log.warn("Could not finalize FAL webhook delivery", {
124
+ deliveryId: claimed.id,
125
+ error: error instanceof Error ? error.message : String(error)
126
+ });
127
+ outcome = "skipped";
128
+ }
129
+ if (outcome === "completed")
130
+ result.completed++;
131
+ else if (outcome === "failed")
132
+ result.failed++;
133
+ else
134
+ result.skipped++;
135
+ }
136
+ const remaining = Math.max(0, this.batchSize - result.deliveries);
137
+ if (remaining > 0) {
138
+ const attempts = await GenerationAttempt.recoverable(this.now().toISOString(), remaining);
139
+ for (const attempt of attempts) {
140
+ const { nowIso, expiresAt } = this.leaseWindow();
141
+ let outcome;
142
+ try {
143
+ outcome = await this.pollAttempt(attempt, nowIso, expiresAt);
144
+ }
145
+ catch (error) {
146
+ log.warn("Could not finalize FAL recovery attempt", {
147
+ attemptId: attempt.id,
148
+ error: error instanceof Error ? error.message : String(error)
149
+ });
150
+ outcome = "skipped";
151
+ }
152
+ result.polledAttempts++;
153
+ if (outcome === "completed")
154
+ result.completed++;
155
+ else if (outcome === "failed")
156
+ result.failed++;
157
+ else
158
+ result.skipped++;
159
+ }
160
+ }
161
+ return result;
162
+ }
163
+ async finalizeDelivery(delivery, nowIso, expiresAt) {
164
+ let attempt = delivery.attempt_id
165
+ ? await GenerationAttempt.get(delivery.attempt_id)
166
+ : await GenerationAttempt.findByProviderRequest(delivery.provider, delivery.provider_account_ref, delivery.provider_request_id);
167
+ if (!attempt) {
168
+ await GenerationWebhookDelivery.markProcessed(delivery.id, this.workerId, delivery.lease_version, "generation attempt no longer exists");
169
+ return "skipped";
170
+ }
171
+ const existingGeneration = await Prediction.find(attempt.generation_id);
172
+ const deliveryStatus = asString(record(delivery.observation)?.status);
173
+ const contradictoryTerminal = existingGeneration
174
+ ? (existingGeneration.status === "completed" &&
175
+ deliveryStatus === "ERROR") ||
176
+ ((existingGeneration.status === "failed" ||
177
+ existingGeneration.status === "cancelled") &&
178
+ deliveryStatus === "OK")
179
+ : false;
180
+ if (contradictoryTerminal) {
181
+ if (!attempt.provider_request_id)
182
+ return "skipped";
183
+ let recheck;
184
+ try {
185
+ const queue = await this.provider.queueFor(attempt);
186
+ const binding = await queue.bind({
187
+ provider: "fal_ai",
188
+ endpoint: attempt.endpoint ?? "",
189
+ providerRequestId: attempt.provider_request_id,
190
+ requestId: attempt.provider_request_id
191
+ });
192
+ const observation = await queue.wait(binding, {
193
+ signal: AbortSignal.timeout(5_000),
194
+ pollIntervalMs: 500
195
+ });
196
+ recheck = `provider recheck reported ${observation.state}`;
197
+ }
198
+ catch (error) {
199
+ const state = error && typeof error === "object" && "state" in error
200
+ ? asString(error.state)
201
+ : null;
202
+ if (!state)
203
+ return "skipped";
204
+ recheck = `provider recheck reported ${state}`;
205
+ }
206
+ await GenerationWebhookDelivery.markConflictProcessed(delivery.id, this.workerId, delivery.lease_version, `callback status ${deliveryStatus} contradicts terminal generation ${existingGeneration?.status}; ${recheck}`);
207
+ return "skipped";
208
+ }
209
+ if (existingGeneration && isTerminalProjection(existingGeneration)) {
210
+ if (attempt.provider_request_id &&
211
+ attempt.provider_request_id !== delivery.provider_request_id) {
212
+ await GenerationWebhookDelivery.markConflictProcessed(delivery.id, this.workerId, delivery.lease_version, "callback request ID conflicts with the bound attempt");
213
+ return "skipped";
214
+ }
215
+ const claimed = await GenerationAttempt.claimLease(attempt.id, this.workerId, nowIso, expiresAt);
216
+ if (!claimed)
217
+ return "skipped";
218
+ const outputs = typeof GenerationOutput.forAttempt === "function"
219
+ ? await GenerationOutput.forAttempt(claimed.id)
220
+ : [];
221
+ const attachments = await this.retryPendingAttachments(claimed, existingGeneration, outputs);
222
+ if (attachments === "skipped")
223
+ return "skipped";
224
+ await this.settleAttachmentProjection(existingGeneration, attachments);
225
+ const nextCheckAt = attachments === "pending"
226
+ ? new Date(this.now().getTime() + this.leaseMs).toISOString()
227
+ : null;
228
+ if (!(await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
229
+ submission_status: "submitted",
230
+ provider_status: existingGeneration.status === "completed"
231
+ ? "succeeded"
232
+ : existingGeneration.status === "cancelled"
233
+ ? "cancelled"
234
+ : "failed",
235
+ last_error: existingGeneration.error ?? null,
236
+ next_check_at: nextCheckAt
237
+ })))
238
+ return "skipped";
239
+ await GenerationWebhookDelivery.markProcessed(delivery.id, this.workerId, delivery.lease_version, null);
240
+ return "skipped";
241
+ }
242
+ const claimed = await GenerationAttempt.claimLease(attempt.id, this.workerId, nowIso, expiresAt);
243
+ if (!claimed)
244
+ return "skipped";
245
+ attempt = claimed;
246
+ let observation = delivery.observation;
247
+ if (!attempt.provider_request_id) {
248
+ try {
249
+ const generation = await Prediction.find(attempt.generation_id);
250
+ if (!generation)
251
+ return "skipped";
252
+ const queue = await this.provider.queueFor(attempt);
253
+ const binding = await queue.bind({
254
+ provider: "fal_ai",
255
+ endpoint: attempt.endpoint ?? "",
256
+ providerRequestId: delivery.provider_request_id,
257
+ requestId: delivery.provider_request_id
258
+ });
259
+ const verified = await queue.wait(binding, {
260
+ signal: AbortSignal.timeout(5_000),
261
+ pollIntervalMs: 500
262
+ });
263
+ const adopted = await GenerationAttempt.bindProviderRequest(attempt.id, this.workerId, attempt.lease_version, delivery.provider_request_id);
264
+ if (!adopted) {
265
+ const current = await GenerationAttempt.get(attempt.id);
266
+ if (current?.provider_request_id !== delivery.provider_request_id) {
267
+ await GenerationWebhookDelivery.markConflictProcessed(delivery.id, this.workerId, delivery.lease_version, "callback request ID could not be authenticated to the attempt");
268
+ return "skipped";
269
+ }
270
+ }
271
+ observation = verified;
272
+ attempt =
273
+ (await GenerationAttempt.get(attempt.id)) ??
274
+ attempt;
275
+ }
276
+ catch {
277
+ return "skipped";
278
+ }
279
+ }
280
+ else if (attempt.provider_request_id !== delivery.provider_request_id) {
281
+ await GenerationWebhookDelivery.markConflictProcessed(delivery.id, this.workerId, delivery.lease_version, "callback request ID conflicts with the bound attempt");
282
+ return "skipped";
283
+ }
284
+ const callback = record(observation);
285
+ const callbackStatus = asString(callback?.status);
286
+ // fal OK callbacks can omit the result or report payload_error. They are
287
+ // success evidence, not provider failures. Re-read the authoritative
288
+ // result through the attempt's authenticated queue adapter.
289
+ if (callbackStatus === "OK" &&
290
+ (callback?.payload_error != null ||
291
+ (!record(callback?.payload) &&
292
+ !record(callback?.result) &&
293
+ !record(callback?.data)))) {
294
+ try {
295
+ const providerRequestId = attempt.provider_request_id;
296
+ if (!providerRequestId)
297
+ return "skipped";
298
+ const queue = await this.provider.queueFor(attempt);
299
+ const binding = await queue.bind({
300
+ provider: "fal_ai",
301
+ endpoint: attempt.endpoint ?? "",
302
+ providerRequestId,
303
+ requestId: providerRequestId
304
+ });
305
+ observation = await queue.wait(binding, {
306
+ signal: AbortSignal.timeout(5_000),
307
+ pollIntervalMs: 500
308
+ });
309
+ }
310
+ catch {
311
+ return "skipped";
312
+ }
313
+ }
314
+ const outcome = await this.finalizeAttempt(attempt, observation, nowIso, expiresAt, true);
315
+ if (outcome === "skipped")
316
+ return "skipped";
317
+ await GenerationWebhookDelivery.markProcessed(delivery.id, this.workerId, delivery.lease_version, outcome === "failed" ? "provider returned an error" : null);
318
+ return outcome;
319
+ }
320
+ async pollAttempt(attempt, nowIso, expiresAt) {
321
+ const claimed = await GenerationAttempt.claimLease(attempt.id, this.workerId, nowIso, expiresAt);
322
+ if (!claimed)
323
+ return "skipped";
324
+ const generation = await Prediction.find(claimed.generation_id);
325
+ if (!generation)
326
+ return "skipped";
327
+ if (isTerminalProjection(generation)) {
328
+ const storedOutputs = typeof GenerationOutput.forAttempt === "function"
329
+ ? await GenerationOutput.forAttempt(claimed.id)
330
+ : [];
331
+ const attachments = await this.retryPendingAttachments(claimed, generation, storedOutputs);
332
+ if (attachments === "skipped")
333
+ return "skipped";
334
+ await this.settleAttachmentProjection(generation, attachments);
335
+ await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
336
+ submission_status: "submitted",
337
+ provider_status: generation.status === "completed"
338
+ ? "succeeded"
339
+ : generation.status === "cancelled"
340
+ ? "cancelled"
341
+ : "failed",
342
+ last_error: generation.error ?? null,
343
+ next_check_at: attachments === "pending"
344
+ ? new Date(this.now().getTime() + this.leaseMs).toISOString()
345
+ : null
346
+ });
347
+ return "skipped";
348
+ }
349
+ const storedRetry = await this.retryStoredOutputs(claimed, generation, nowIso, expiresAt);
350
+ if (storedRetry !== null)
351
+ return storedRetry;
352
+ if (!claimed.provider_request_id) {
353
+ if (claimed.submission_status === "accepted" && this.submitAccepted) {
354
+ const submitting = await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, { submission_status: "submitting", next_check_at: null });
355
+ if (!submitting)
356
+ return "skipped";
357
+ try {
358
+ const submission = await this.submitAccepted(generation, claimed);
359
+ if (!submission.providerRequestId)
360
+ throw new Error("submission did not return a request ID");
361
+ const bound = await GenerationAttempt.bindProviderRequest(claimed.id, this.workerId, claimed.lease_version, submission.providerRequestId);
362
+ if (!bound)
363
+ return "skipped";
364
+ if (!(await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
365
+ provider_request_id: submission.providerRequestId,
366
+ gateway_request_id: submission.gatewayRequestId ?? null,
367
+ submission_status: "submitted",
368
+ provider_status: "queued",
369
+ next_check_at: null
370
+ })))
371
+ return "skipped";
372
+ return "skipped";
373
+ }
374
+ catch (error) {
375
+ const message = error instanceof Error ? error.message : String(error);
376
+ await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
377
+ submission_status: "submission_unknown",
378
+ provider_status: "unknown",
379
+ last_error: message,
380
+ next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
381
+ });
382
+ return "skipped";
383
+ }
384
+ }
385
+ await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
386
+ submission_status: "submission_unknown",
387
+ provider_status: "unknown",
388
+ next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
389
+ });
390
+ return "skipped";
391
+ }
392
+ if (generation.cancel_requested_at || claimed.cancel_requested_at) {
393
+ try {
394
+ const queue = await this.provider.queueFor(claimed);
395
+ if (claimed.endpoint) {
396
+ const binding = await queue.bind({
397
+ provider: "fal_ai",
398
+ endpoint: claimed.endpoint,
399
+ providerRequestId: claimed.provider_request_id,
400
+ requestId: claimed.provider_request_id
401
+ });
402
+ await queue.cancel(binding);
403
+ }
404
+ }
405
+ catch (error) {
406
+ log.warn("Could not cancel FAL request during recovery", {
407
+ generationId: claimed.generation_id,
408
+ error: error instanceof Error ? error.message : String(error)
409
+ });
410
+ }
411
+ }
412
+ try {
413
+ const queue = await this.provider.queueFor(claimed);
414
+ const binding = await queue.bind({
415
+ provider: "fal_ai",
416
+ endpoint: claimed.endpoint ?? "",
417
+ providerRequestId: claimed.provider_request_id,
418
+ requestId: claimed.provider_request_id
419
+ });
420
+ const observation = await queue.wait(binding, {
421
+ signal: AbortSignal.timeout(5_000),
422
+ pollIntervalMs: 500
423
+ });
424
+ return this.finalizeAttempt(claimed, observation, nowIso, expiresAt, true);
425
+ }
426
+ catch (error) {
427
+ const message = error instanceof Error ? error.message : String(error);
428
+ await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
429
+ // A bound request remains submitted when a status/result read times
430
+ // out. Only an ambiguous submit is submission_unknown.
431
+ submission_status: "submitted",
432
+ provider_status: "unknown",
433
+ last_error: message,
434
+ next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
435
+ });
436
+ return "skipped";
437
+ }
438
+ }
439
+ async finalizeAttempt(attempt, rawObservation, nowIso, expiresAt, attemptAlreadyClaimed = false) {
440
+ const generation = await Prediction.find(attempt.generation_id);
441
+ if (!generation)
442
+ return "skipped";
443
+ const observation = record(rawObservation) ?? {};
444
+ const status = asString(observation.status);
445
+ const error = status === "ERROR" ? statusError(observation) : statusError(observation);
446
+ const payload = observation.payload ?? observation.result ?? observation.data;
447
+ const attemptLease = attemptAlreadyClaimed
448
+ ? attempt
449
+ : await GenerationAttempt.claimLease(attempt.id, this.workerId, nowIso, expiresAt);
450
+ if (!attemptLease)
451
+ return "skipped";
452
+ const generationLease = await Prediction.claimGenerationLease(generation.id, this.workerId, nowIso, expiresAt);
453
+ if (!generationLease)
454
+ return "skipped";
455
+ const heartbeat = this.startLeaseRenewal(attemptLease, generationLease);
456
+ try {
457
+ return await this.finalizeAttemptWithLeases(attempt, generation, observation, status, error, payload, nowIso, attemptLease, generationLease, heartbeat);
458
+ }
459
+ finally {
460
+ heartbeat.stop();
461
+ }
462
+ }
463
+ /** Retry durable output saves from the committed raw result before asking
464
+ * fal for a queue result. This is important after result retention expires
465
+ * or after a crash between object upload and the output-row CAS. */
466
+ async retryStoredOutputs(attempt, generation, nowIso, expiresAt) {
467
+ // Older hosts may not expose the optional output query yet. In that case
468
+ // fall through to the authenticated queue lookup.
469
+ if (typeof GenerationOutput.forAttempt !== "function")
470
+ return null;
471
+ const outputs = await GenerationOutput.forAttempt(attempt.id);
472
+ if (outputs.length === 0)
473
+ return null;
474
+ const retryable = outputs.filter((output) => output.status !== "ready" &&
475
+ output.raw_result !== null &&
476
+ this.finalizeOutput !== undefined);
477
+ if (retryable.length === 0) {
478
+ const attachments = await this.retryPendingAttachments(attempt, generation, outputs);
479
+ if (attachments === "skipped")
480
+ return "skipped";
481
+ if (attachments === "pending") {
482
+ await GenerationAttempt.transition(attempt.id, this.workerId, attempt.lease_version, {
483
+ next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
484
+ });
485
+ return "skipped";
486
+ }
487
+ return this.repairFromStoredOutputs(attempt, generation, outputs, attachments, nowIso, expiresAt);
488
+ }
489
+ const generationLease = await Prediction.claimGenerationLease(generation.id, this.workerId, nowIso, expiresAt);
490
+ if (!generationLease)
491
+ return "skipped";
492
+ const heartbeat = this.startLeaseRenewal(attempt, generationLease);
493
+ try {
494
+ const states = new Map();
495
+ for (const output of outputs) {
496
+ if (output.status === "ready") {
497
+ states.set(output.id, {
498
+ status: "ready",
499
+ storage_key: output.storage_key,
500
+ asset_id: output.asset_id,
501
+ provider_ref: output.provider_ref,
502
+ raw_result: output.raw_result
503
+ });
504
+ continue;
505
+ }
506
+ if (!output.raw_result || !this.finalizeOutput) {
507
+ states.set(output.id, { status: output.status });
508
+ continue;
509
+ }
510
+ if (heartbeat.lost())
511
+ return "skipped";
512
+ const saving = await GenerationOutput.transition(output.id, {
513
+ status: "saving",
514
+ provider_ref: output.provider_ref,
515
+ raw_result: output.raw_result
516
+ }, {
517
+ attemptId: attempt.id,
518
+ workerId: this.workerId,
519
+ leaseVersion: attempt.lease_version
520
+ });
521
+ if (!saving)
522
+ return "skipped";
523
+ let savedState;
524
+ try {
525
+ savedState = await this.finalizeOutput({
526
+ generation,
527
+ attempt,
528
+ payload: output.raw_result,
529
+ descriptor: {
530
+ outputKey: output.output_key,
531
+ outputIndex: output.output_index,
532
+ outputType: output.output_type === "structured" ? "structured" : "media",
533
+ providerRef: output.provider_ref,
534
+ rawResult: output.raw_result,
535
+ storageKey: deterministicFalStorageKey(generation.id, attempt.id, output.output_key, output.output_index),
536
+ existingStorageKey: output.storage_key,
537
+ existingAssetId: output.asset_id
538
+ }
539
+ });
540
+ }
541
+ catch (error) {
542
+ const message = error instanceof Error ? error.message : String(error);
543
+ const retrying = await GenerationOutput.transition(output.id, {
544
+ status: "retrying",
545
+ error: message,
546
+ provider_ref: output.provider_ref,
547
+ raw_result: output.raw_result
548
+ }, {
549
+ attemptId: attempt.id,
550
+ workerId: this.workerId,
551
+ leaseVersion: attempt.lease_version
552
+ });
553
+ if (!retrying)
554
+ return "skipped";
555
+ states.set(output.id, { status: "retrying", error: message });
556
+ continue;
557
+ }
558
+ const saved = await GenerationOutput.transition(output.id, savedState, {
559
+ attemptId: attempt.id,
560
+ workerId: this.workerId,
561
+ leaseVersion: attempt.lease_version
562
+ });
563
+ if (!saved || heartbeat.lost())
564
+ return "skipped";
565
+ states.set(output.id, savedState);
566
+ }
567
+ const allReady = outputs.length > 0 &&
568
+ outputs.every((output) => states.get(output.id)?.status === "ready");
569
+ const hasRetrying = [...states.values()].some((state) => state.status === "retrying");
570
+ const refreshedOutputs = await GenerationOutput.forAttempt(attempt.id);
571
+ const attachments = await this.retryPendingAttachments(attempt, generation, refreshedOutputs);
572
+ if (attachments === "skipped")
573
+ return "skipped";
574
+ const nextCheckAt = hasRetrying || attachments === "pending"
575
+ ? new Date(this.now().getTime() + this.leaseMs).toISOString()
576
+ : null;
577
+ const stagedNextCheckAt = nextCheckAt ??
578
+ new Date(this.now().getTime() + this.leaseMs).toISOString();
579
+ if (heartbeat.lost())
580
+ return "skipped";
581
+ if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attempt.lease_version, {
582
+ provider_status: "succeeded",
583
+ submission_status: "submitted",
584
+ next_check_at: stagedNextCheckAt
585
+ })))
586
+ return "skipped";
587
+ if (heartbeat.lost())
588
+ return "skipped";
589
+ if (!(await Prediction.transitionDurable(generation.id, this.workerId, generationLease.lease_version, {
590
+ status: allReady ? "completed" : "recovering",
591
+ provider_status: "succeeded",
592
+ output_status: allReady ? "ready" : "retrying",
593
+ attachment_status: attachments === "complete" ? "attached" : "pending",
594
+ asset_ids: [...states.values()]
595
+ .map((state) => state.asset_id)
596
+ .filter((id) => typeof id === "string"),
597
+ next_check_at: nextCheckAt,
598
+ completed_at: allReady ? this.now().toISOString() : null
599
+ })))
600
+ return "skipped";
601
+ if (!nextCheckAt) {
602
+ if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attempt.lease_version, { next_check_at: null }))) {
603
+ return "skipped";
604
+ }
605
+ }
606
+ return allReady && attachments !== "pending" ? "completed" : "skipped";
607
+ }
608
+ finally {
609
+ heartbeat.stop();
610
+ }
611
+ }
612
+ /**
613
+ * Commit the terminal generation from output rows that are already saved.
614
+ * The attempt's recorded provider result is the manifest of a complete
615
+ * result: every output identity it names must have a ready row before the
616
+ * generation is called completed, so a crash halfway through recording a
617
+ * multi-output result still falls back to the provider. Returning null means
618
+ * the state could not be repaired locally.
619
+ */
620
+ async repairFromStoredOutputs(attempt, generation, outputs, attachments, nowIso, expiresAt) {
621
+ const manifest = parsedRecord(attempt.raw_result_ref);
622
+ if (!manifest)
623
+ return null;
624
+ const ready = new Map(outputs
625
+ .filter((output) => output.status === "ready")
626
+ .map((output) => [
627
+ `${output.output_key}:${output.output_index}`,
628
+ output
629
+ ]));
630
+ const expected = decodeFalOutputs(manifest);
631
+ if (expected.length === 0 ||
632
+ expected.some((descriptor) => !ready.has(`${descriptor.outputKey}:${descriptor.outputIndex}`))) {
633
+ return null;
634
+ }
635
+ const generationLease = await Prediction.claimGenerationLease(generation.id, this.workerId, nowIso, expiresAt);
636
+ if (!generationLease)
637
+ return "skipped";
638
+ if (!(await Prediction.transitionDurable(generation.id, this.workerId, generationLease.lease_version, {
639
+ status: "completed",
640
+ submission_status: "submitted",
641
+ provider_status: "succeeded",
642
+ provider_request_id: attempt.provider_request_id,
643
+ output_status: "ready",
644
+ attachment_status: attachments === "complete" ? "attached" : "pending",
645
+ error: null,
646
+ asset_ids: [...ready.values()]
647
+ .map((output) => output.asset_id)
648
+ .filter((id) => typeof id === "string"),
649
+ next_check_at: null,
650
+ completed_at: this.now().toISOString()
651
+ })))
652
+ return "skipped";
653
+ if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attempt.lease_version, {
654
+ submission_status: "submitted",
655
+ provider_status: "succeeded",
656
+ last_error: null,
657
+ next_check_at: null
658
+ })))
659
+ return "skipped";
660
+ return "completed";
661
+ }
662
+ startLeaseRenewal(attempt, generation) {
663
+ let leaseLost = false;
664
+ let stopped = false;
665
+ let renewing = false;
666
+ const renew = async () => {
667
+ if (stopped || renewing || leaseLost)
668
+ return;
669
+ renewing = true;
670
+ try {
671
+ const expiresAt = new Date(this.now().getTime() + this.leaseMs).toISOString();
672
+ const [attemptRenewed, generationRenewed] = await Promise.all([
673
+ typeof GenerationAttempt.renewLease === "function"
674
+ ? GenerationAttempt.renewLease(attempt.id, this.workerId, attempt.lease_version, expiresAt)
675
+ : true,
676
+ typeof Prediction.renewGenerationLease === "function"
677
+ ? Prediction.renewGenerationLease(generation.id, this.workerId, generation.lease_version, expiresAt)
678
+ : true
679
+ ]);
680
+ if (!attemptRenewed || !generationRenewed)
681
+ leaseLost = true;
682
+ }
683
+ catch {
684
+ leaseLost = true;
685
+ }
686
+ finally {
687
+ renewing = false;
688
+ }
689
+ };
690
+ const timer = setInterval(() => void renew(), Math.max(1_000, Math.floor(this.leaseMs / 3)));
691
+ return {
692
+ lost: () => leaseLost,
693
+ stop: () => {
694
+ stopped = true;
695
+ clearInterval(timer);
696
+ }
697
+ };
698
+ }
699
+ async finalizeAttemptWithLeases(attempt, generation, observation, status, error, payload, nowIso, attemptLease, generationLease, heartbeat) {
700
+ if (heartbeat.lost())
701
+ return "skipped";
702
+ const queueState = asString(observation.state);
703
+ const terminalFailure = status === "ERROR" ||
704
+ queueState === "failed" ||
705
+ queueState === "cancelled";
706
+ const terminalError = error ??
707
+ (queueState === "failed"
708
+ ? "provider queue reported failure"
709
+ : queueState === "cancelled"
710
+ ? "provider request was cancelled"
711
+ : status === "ERROR"
712
+ ? "provider returned an error"
713
+ : null);
714
+ if (terminalFailure && terminalError) {
715
+ if (!(await Prediction.transitionDurable(generation.id, this.workerId, generationLease.lease_version, {
716
+ status: queueState === "cancelled" ? "cancelled" : "failed",
717
+ provider_status: queueState === "cancelled" ? "cancelled" : "failed",
718
+ output_status: "unavailable",
719
+ error: terminalError,
720
+ completed_at: nowIso
721
+ })))
722
+ return "skipped";
723
+ if (heartbeat.lost())
724
+ return "skipped";
725
+ if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, {
726
+ submission_status: "submitted",
727
+ provider_status: queueState === "cancelled" ? "cancelled" : "failed",
728
+ last_error: terminalError,
729
+ next_check_at: null
730
+ })))
731
+ return "skipped";
732
+ return queueState === "cancelled" ? "skipped" : "failed";
733
+ }
734
+ if (queueState === "queued" || queueState === "running") {
735
+ return (await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, {
736
+ submission_status: "submitted",
737
+ provider_status: queueState,
738
+ next_check_at: new Date(this.now().getTime() + Math.min(this.leaseMs, 60_000)).toISOString()
739
+ }))
740
+ ? "skipped"
741
+ : "skipped";
742
+ }
743
+ if (error)
744
+ return "skipped";
745
+ const output = record(payload);
746
+ if (!output) {
747
+ log.warn("FAL generation completed without an object payload", {
748
+ generationId: generation.id
749
+ });
750
+ if (!(await GenerationAttempt.transition(attemptLease.id, this.workerId, attemptLease.lease_version, {
751
+ provider_status: "succeeded",
752
+ next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
753
+ })))
754
+ return "skipped";
755
+ return "skipped";
756
+ }
757
+ const descriptors = decodeFalOutputs(output);
758
+ // Record the complete provider result before the first output row is
759
+ // written. A later pass reads it back as the manifest of what this result
760
+ // contains, so a crash before the generation projection can be repaired
761
+ // from the saved rows instead of another paid provider read.
762
+ if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, { raw_result_ref: JSON.stringify(output) })))
763
+ return "skipped";
764
+ const outputStates = [];
765
+ const persistedOutputs = [];
766
+ for (const descriptor of descriptors) {
767
+ const persisted = await GenerationOutput.upsertOutput({
768
+ generation_id: generation.id,
769
+ attempt_id: attempt.id,
770
+ output_key: descriptor.outputKey,
771
+ output_index: descriptor.outputIndex,
772
+ output_type: descriptor.outputType,
773
+ provider_ref: descriptor.providerRef,
774
+ raw_result: descriptor.rawResult
775
+ });
776
+ // A restart after the storage commit must reuse the existing output and
777
+ // asset instead of downloading or creating another asset.
778
+ if (persisted.status === "ready") {
779
+ persistedOutputs.push(persisted);
780
+ outputStates.push({
781
+ status: "ready",
782
+ provider_ref: persisted.provider_ref,
783
+ raw_result: persisted.raw_result,
784
+ storage_key: persisted.storage_key,
785
+ asset_id: persisted.asset_id
786
+ });
787
+ continue;
788
+ }
789
+ const saving = await GenerationOutput.transition(persisted.id, {
790
+ status: "saving",
791
+ provider_ref: descriptor.providerRef,
792
+ raw_result: descriptor.rawResult
793
+ }, {
794
+ attemptId: attemptLease.id,
795
+ workerId: this.workerId,
796
+ leaseVersion: attemptLease.lease_version
797
+ });
798
+ if (!saving || heartbeat.lost())
799
+ return "skipped";
800
+ let outputState;
801
+ try {
802
+ outputState = this.finalizeOutput
803
+ ? await this.finalizeOutput({
804
+ generation,
805
+ attempt,
806
+ payload: output,
807
+ descriptor: {
808
+ ...descriptor,
809
+ storageKey: deterministicFalStorageKey(generation.id, attempt.id, descriptor.outputKey, descriptor.outputIndex),
810
+ // A finalizer can use these to make a storage retry reuse a
811
+ // deterministic object rather than uploading a second copy.
812
+ existingStorageKey: persisted.storage_key,
813
+ existingAssetId: persisted.asset_id
814
+ }
815
+ })
816
+ : { status: "saving", raw_result: descriptor.rawResult };
817
+ }
818
+ catch (error) {
819
+ const message = error instanceof Error ? error.message : String(error);
820
+ const retrying = await GenerationOutput.transition(persisted.id, {
821
+ status: "retrying",
822
+ error: message,
823
+ provider_ref: descriptor.providerRef,
824
+ raw_result: descriptor.rawResult
825
+ }, {
826
+ attemptId: attemptLease.id,
827
+ workerId: this.workerId,
828
+ leaseVersion: attemptLease.lease_version
829
+ });
830
+ if (!retrying)
831
+ return "skipped";
832
+ outputStates.push({ status: "retrying", error: message });
833
+ continue;
834
+ }
835
+ if (this.finalizeOutput || outputState.status !== "saving") {
836
+ const saved = await GenerationOutput.transition(persisted.id, outputState, {
837
+ attemptId: attemptLease.id,
838
+ workerId: this.workerId,
839
+ leaseVersion: attemptLease.lease_version
840
+ });
841
+ if (!saved || heartbeat.lost())
842
+ return "skipped";
843
+ if (saved.status === "ready") {
844
+ persistedOutputs.push(saved);
845
+ }
846
+ }
847
+ outputStates.push(outputState);
848
+ }
849
+ const attachments = await this.retryPendingAttachments(attemptLease, generation, persistedOutputs);
850
+ if (attachments === "skipped")
851
+ return "skipped";
852
+ const hasRetryingOutput = outputStates.some((state) => state.status === "retrying");
853
+ const hasSavingOutput = outputStates.some((state) => state.status === "saving");
854
+ const hasUnavailableOutput = outputStates.some((state) => state.status === "unavailable");
855
+ const allReady = outputStates.every((state) => state.status === "ready");
856
+ const nextCheckAt = hasRetryingOutput || attachments === "pending"
857
+ ? new Date(this.now().getTime() + this.leaseMs).toISOString()
858
+ : null;
859
+ // Keep the attempt scheduled until the generation projection commits. If
860
+ // a crash or lease loss happens between these writes, the next pass can
861
+ // repair the terminal generation instead of losing the attempt forever.
862
+ const stagedNextCheckAt = nextCheckAt ??
863
+ new Date(this.now().getTime() + this.leaseMs).toISOString();
864
+ if (heartbeat.lost())
865
+ return "skipped";
866
+ if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, {
867
+ submission_status: "submitted",
868
+ provider_status: "succeeded",
869
+ last_error: null,
870
+ next_check_at: stagedNextCheckAt
871
+ })))
872
+ return "skipped";
873
+ if (heartbeat.lost())
874
+ return "skipped";
875
+ const projected = await Prediction.transitionDurable(generation.id, this.workerId, generationLease.lease_version, {
876
+ status: allReady ? "completed" : "recovering",
877
+ provider_status: "succeeded",
878
+ provider_request_id: attempt.provider_request_id,
879
+ output_status: allReady
880
+ ? "ready"
881
+ : hasUnavailableOutput
882
+ ? "unavailable"
883
+ : hasRetryingOutput
884
+ ? "retrying"
885
+ : hasSavingOutput
886
+ ? "saving"
887
+ : "saving",
888
+ // Intent creation is not a destination mutation. Keep it pending
889
+ // until a destination-specific adapter commits the attachment.
890
+ attachment_status: "pending",
891
+ error: null,
892
+ asset_ids: outputStates
893
+ .map((state) => state.asset_id)
894
+ .filter((id) => typeof id === "string"),
895
+ next_check_at: nextCheckAt,
896
+ completed_at: allReady ? nowIso : null
897
+ });
898
+ if (!projected) {
899
+ const current = await Prediction.find(generation.id);
900
+ const alreadyProjected = current
901
+ ? hasRetryingOutput
902
+ ? current.provider_status === "succeeded" &&
903
+ current.output_status === "retrying"
904
+ : current.provider_status === "succeeded" &&
905
+ current.output_status === "ready"
906
+ : false;
907
+ if (!alreadyProjected)
908
+ return "skipped";
909
+ }
910
+ if (!nextCheckAt) {
911
+ if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, { next_check_at: null })))
912
+ return "skipped";
913
+ }
914
+ return allReady ? "completed" : hasUnavailableOutput ? "failed" : "skipped";
915
+ }
916
+ /** Record on the generation that its destination work is done. A generation
917
+ * whose media is saved and ready is terminal and holds no lease, so the
918
+ * projection cannot ride on a fenced transition. */
919
+ async settleAttachmentProjection(generation, attachments) {
920
+ if (attachments !== "complete")
921
+ return;
922
+ if (generation.attachment_status === "attached")
923
+ return;
924
+ await Prediction.settleAttachments(generation.id, "attached");
925
+ }
926
+ /** Apply each destination intent independently. A ready output can be
927
+ * retried after its generation is terminal, so attachment work must not be
928
+ * coupled to provider polling or storage retries. */
929
+ async retryPendingAttachments(attempt, generation, outputs) {
930
+ const intents = attachmentIntents(generation);
931
+ if (intents.length === 0 || !this.attachOutput)
932
+ return null;
933
+ let pendingCount = 0;
934
+ for (const intent of intents) {
935
+ const targetType = asString(intent.target_type);
936
+ const targetId = asString(intent.target_id);
937
+ if (!targetType || !targetId)
938
+ continue;
939
+ const outputKey = asString(intent.output_key);
940
+ const outputIndex = typeof intent.output_index === "number" ? intent.output_index : 0;
941
+ // An intent without an output key means "the media this generation
942
+ // produced". A structured row carries no asset, so attaching it could
943
+ // never succeed and would reschedule the attempt forever.
944
+ const output = outputKey
945
+ ? outputs.find((candidate) => candidate.output_key === outputKey &&
946
+ candidate.output_index === outputIndex)
947
+ : (outputs.find((candidate) => candidate.output_type === "media" && Boolean(candidate.asset_id)) ??
948
+ outputs.find((candidate) => candidate.output_type === "media") ??
949
+ outputs[0]);
950
+ if (!output || output.status !== "ready") {
951
+ pendingCount++;
952
+ continue;
953
+ }
954
+ const attachment = await GenerationAttachment.upsertAttachment({
955
+ generation_id: generation.id,
956
+ output_id: output.id,
957
+ target_type: targetType,
958
+ target_id: targetId,
959
+ selected: intent.selected === true
960
+ });
961
+ if (TERMINAL_ATTACHMENT_STATUSES.has(attachment.status))
962
+ continue;
963
+ pendingCount++;
964
+ let change;
965
+ try {
966
+ change = await this.attachOutput({
967
+ generation,
968
+ attempt,
969
+ output,
970
+ attachment
971
+ });
972
+ }
973
+ catch (error) {
974
+ change = {
975
+ status: "retrying",
976
+ error: error instanceof Error ? error.message : String(error)
977
+ };
978
+ }
979
+ if (!change || change.status === "pending")
980
+ continue;
981
+ const transitioned = await GenerationAttachment.transition(attachment.id, change, {
982
+ attemptId: attempt.id,
983
+ workerId: this.workerId,
984
+ leaseVersion: attempt.lease_version
985
+ });
986
+ if (!transitioned)
987
+ return "skipped";
988
+ if (TERMINAL_ATTACHMENT_STATUSES.has(transitioned.status)) {
989
+ pendingCount--;
990
+ }
991
+ }
992
+ return pendingCount > 0 ? "pending" : "complete";
993
+ }
994
+ }
995
+ //# sourceMappingURL=generation-recovery-worker.js.map