@vercel/factory 0.0.15 → 0.0.16

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 (214) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/README.md +49 -261
  3. package/dist/agent-routes.d.mts +47 -3
  4. package/dist/agent-routes.mjs +28 -1
  5. package/dist/agent-routes.mjs.map +1 -1
  6. package/dist/api-contracts.d.mts +20 -1
  7. package/dist/api-contracts.mjs +2 -1
  8. package/dist/api-contracts.mjs.map +1 -1
  9. package/dist/api.d.mts +45 -2
  10. package/dist/api.mjs +199 -10
  11. package/dist/api.mjs.map +1 -1
  12. package/dist/approval-contracts.d.mts +6 -0
  13. package/dist/blob/index.d.mts +51 -14
  14. package/dist/blob/index.mjs +26 -10
  15. package/dist/blob/index.mjs.map +1 -1
  16. package/dist/budget.d.mts +7 -0
  17. package/dist/budget.mjs +6 -0
  18. package/dist/budget.mjs.map +1 -1
  19. package/dist/build-factory.d.mts +1 -0
  20. package/dist/change-verification/dispatch.d.mts +16 -3
  21. package/dist/change-verification/dispatch.mjs +45 -7
  22. package/dist/change-verification/dispatch.mjs.map +1 -1
  23. package/dist/change-verification/eve-tool.d.mts +2 -1
  24. package/dist/change-verification/eve-tool.mjs +35 -47
  25. package/dist/change-verification/eve-tool.mjs.map +1 -1
  26. package/dist/change-verification/result.mjs +130 -0
  27. package/dist/change-verification/result.mjs.map +1 -0
  28. package/dist/changes/eve-record-change.d.mts +2 -2
  29. package/dist/changes/eve-record-change.mjs +43 -9
  30. package/dist/changes/eve-record-change.mjs.map +1 -1
  31. package/dist/changes.d.mts +4 -3
  32. package/dist/changes.mjs +2 -2
  33. package/dist/changes.mjs.map +1 -1
  34. package/dist/client-events.d.mts +10 -3
  35. package/dist/client-events.mjs +6 -2
  36. package/dist/client-events.mjs.map +1 -1
  37. package/dist/client-stream.mjs +8 -2
  38. package/dist/client-stream.mjs.map +1 -1
  39. package/dist/client-transcript.mjs +5 -1
  40. package/dist/client-transcript.mjs.map +1 -1
  41. package/dist/client.d.mts +121 -12
  42. package/dist/client.mjs +117 -9
  43. package/dist/client.mjs.map +1 -1
  44. package/dist/code-review/contracts.d.mts +1 -0
  45. package/dist/code-review/eve-post-review.d.mts +4 -4
  46. package/dist/code-review/eve-post-review.mjs +29 -17
  47. package/dist/code-review/eve-post-review.mjs.map +1 -1
  48. package/dist/code-review/eve-review-comments.d.mts +13 -2
  49. package/dist/code-review/eve-review-comments.mjs +45 -11
  50. package/dist/code-review/eve-review-comments.mjs.map +1 -1
  51. package/dist/code-review/github-reporter.d.mts +2 -0
  52. package/dist/code-review/github-reporter.mjs +7 -5
  53. package/dist/code-review/github-reporter.mjs.map +1 -1
  54. package/dist/code-review.d.mts +3 -2
  55. package/dist/deepsec/eve-tool.mjs +3 -1
  56. package/dist/deepsec/eve-tool.mjs.map +1 -1
  57. package/dist/dispatch.d.mts +79 -8
  58. package/dist/dispatch.mjs +68 -9
  59. package/dist/dispatch.mjs.map +1 -1
  60. package/dist/eve/index.d.mts +70 -10
  61. package/dist/eve/index.mjs +93 -22
  62. package/dist/eve/index.mjs.map +1 -1
  63. package/dist/eve/invoke.mjs +24 -11
  64. package/dist/eve/invoke.mjs.map +1 -1
  65. package/dist/eve/session-client.d.mts +122 -4
  66. package/dist/eve/session-client.mjs +127 -13
  67. package/dist/eve/session-client.mjs.map +1 -1
  68. package/dist/eve/task-execution.d.mts +380 -0
  69. package/dist/eve/task-execution.mjs +57 -2
  70. package/dist/eve/task-execution.mjs.map +1 -1
  71. package/dist/eve/task-session.d.mts +44 -2
  72. package/dist/eve/task-session.mjs +44 -2
  73. package/dist/eve/task-session.mjs.map +1 -1
  74. package/dist/eve/transcript.mjs +5 -1
  75. package/dist/eve/transcript.mjs.map +1 -1
  76. package/dist/execution.d.mts +117 -9
  77. package/dist/execution.mjs +76 -6
  78. package/dist/execution.mjs.map +1 -1
  79. package/dist/finding-remediation/admission.d.mts +2 -0
  80. package/dist/finding-remediation/admission.mjs +4 -1
  81. package/dist/finding-remediation/admission.mjs.map +1 -1
  82. package/dist/findings.d.mts +1 -0
  83. package/dist/github-publication.d.mts +1 -0
  84. package/dist/github-publication.mjs +97 -84
  85. package/dist/github-publication.mjs.map +1 -1
  86. package/dist/github-transfer.d.mts +15 -6
  87. package/dist/github-transfer.mjs +214 -66
  88. package/dist/github-transfer.mjs.map +1 -1
  89. package/dist/github.d.mts +51 -11
  90. package/dist/github.mjs +126 -24
  91. package/dist/github.mjs.map +1 -1
  92. package/dist/inbox-activity.d.mts +53 -0
  93. package/dist/inbox-activity.mjs +41 -0
  94. package/dist/inbox-activity.mjs.map +1 -0
  95. package/dist/index.d.mts +3 -1
  96. package/dist/index.mjs +3 -2
  97. package/dist/intake-contracts.d.mts +0 -1
  98. package/dist/integrations/deepsec.d.mts +1 -0
  99. package/dist/integrations/github.d.mts +2 -2
  100. package/dist/integrations/github.mjs +2 -2
  101. package/dist/integrations/slack.d.mts +3 -1
  102. package/dist/integrations/slack.mjs +3 -1
  103. package/dist/integrations/vercel.d.mts +4 -2
  104. package/dist/integrations/vercel.mjs +3 -2
  105. package/dist/merge-resolution/eve-tools.d.mts +1 -0
  106. package/dist/merge-resolution/eve-tools.mjs +7 -2
  107. package/dist/merge-resolution/eve-tools.mjs.map +1 -1
  108. package/dist/model-settings.d.mts +41 -0
  109. package/dist/model-settings.mjs +35 -0
  110. package/dist/model-settings.mjs.map +1 -0
  111. package/dist/planning/reconcile.mjs +6 -0
  112. package/dist/planning/reconcile.mjs.map +1 -1
  113. package/dist/postgres/index.d.mts +43 -2
  114. package/dist/postgres/index.mjs +40 -2
  115. package/dist/postgres/index.mjs.map +1 -1
  116. package/dist/presets/software-development/dispatch.d.mts +4 -1
  117. package/dist/presets/software-development/dispatch.mjs +2 -1
  118. package/dist/presets/software-development/dispatch.mjs.map +1 -1
  119. package/dist/presets/software-development/task-communication.d.mts +1 -0
  120. package/dist/presets/software-development/task-communication.mjs +48 -11
  121. package/dist/presets/software-development/task-communication.mjs.map +1 -1
  122. package/dist/presets/software-development.d.mts +1 -0
  123. package/dist/pull-requests/github-publisher.d.mts +15 -1
  124. package/dist/pull-requests/github-publisher.mjs +61 -1
  125. package/dist/pull-requests/github-publisher.mjs.map +1 -1
  126. package/dist/pull-requests.d.mts +1 -0
  127. package/dist/sandbox/index.d.mts +1 -0
  128. package/dist/schema/agent-route.d.mts +19 -1
  129. package/dist/schema/agent-route.mjs +19 -1
  130. package/dist/schema/agent-route.mjs.map +1 -1
  131. package/dist/schema/factory-config.d.mts +27 -0
  132. package/dist/schema/factory-config.mjs +33 -3
  133. package/dist/schema/factory-config.mjs.map +1 -1
  134. package/dist/schema/repository.d.mts +4 -0
  135. package/dist/schema/repository.mjs +5 -1
  136. package/dist/schema/repository.mjs.map +1 -1
  137. package/dist/schema/session.d.mts +1 -0
  138. package/dist/schema/session.mjs +1 -0
  139. package/dist/schema/session.mjs.map +1 -1
  140. package/dist/schema/slack-pr-notifications.d.mts +12 -0
  141. package/dist/schema/slack-pr-notifications.mjs +11 -0
  142. package/dist/schema/slack-pr-notifications.mjs.map +1 -0
  143. package/dist/schema/task-graph.d.mts +39 -0
  144. package/dist/schema/task.d.mts +1 -0
  145. package/dist/schema/task.mjs +2 -1
  146. package/dist/schema/task.mjs.map +1 -1
  147. package/dist/schema/transcript.d.mts +6 -0
  148. package/dist/schema/transcript.mjs +2 -1
  149. package/dist/schema/transcript.mjs.map +1 -1
  150. package/dist/schema/work.d.mts +52 -3
  151. package/dist/schema/work.mjs.map +1 -1
  152. package/dist/session-previews.d.mts +76 -0
  153. package/dist/session-previews.mjs +55 -0
  154. package/dist/session-previews.mjs.map +1 -0
  155. package/dist/session-review.d.mts +120 -0
  156. package/dist/session-review.mjs +79 -0
  157. package/dist/session-review.mjs.map +1 -0
  158. package/dist/signal-triage.mjs +1 -1
  159. package/dist/signals.d.mts +1 -0
  160. package/dist/stall.d.mts +4 -1
  161. package/dist/stall.mjs +6 -2
  162. package/dist/stall.mjs.map +1 -1
  163. package/dist/store/driver.d.mts +1 -1
  164. package/dist/store/driver.mjs.map +1 -1
  165. package/dist/store/engine.d.mts +206 -8
  166. package/dist/store/engine.mjs +147 -13
  167. package/dist/store/engine.mjs.map +1 -1
  168. package/dist/store/memory.d.mts +18 -1
  169. package/dist/store/memory.mjs +18 -1
  170. package/dist/store/memory.mjs.map +1 -1
  171. package/dist/store/slack-pr-notifications.d.mts +44 -0
  172. package/dist/store/slack-pr-notifications.mjs +121 -0
  173. package/dist/store/slack-pr-notifications.mjs.map +1 -0
  174. package/dist/store/task-work.d.mts +121 -6
  175. package/dist/store/task-work.mjs +7 -4
  176. package/dist/store/task-work.mjs.map +1 -1
  177. package/dist/sweep.d.mts +28 -6
  178. package/dist/sweep.mjs +34 -6
  179. package/dist/sweep.mjs.map +1 -1
  180. package/dist/task-graph-view.d.mts +3 -0
  181. package/dist/tasks.d.mts +3 -3
  182. package/dist/tasks.mjs +3 -3
  183. package/dist/vercel-git.d.mts +35 -3
  184. package/dist/vercel-git.mjs +265 -33
  185. package/dist/vercel-git.mjs.map +1 -1
  186. package/dist/vercel-github-api.d.mts +103 -0
  187. package/dist/vercel-github-api.mjs +363 -0
  188. package/dist/vercel-github-api.mjs.map +1 -0
  189. package/dist/vercel.d.mts +3 -2
  190. package/dist/vercel.mjs +3 -2
  191. package/dist/vercel.mjs.map +1 -1
  192. package/dist/work-triage.d.mts +1 -0
  193. package/dist/workflows.d.mts +102 -4
  194. package/dist/workflows.mjs +55 -2
  195. package/dist/workflows.mjs.map +1 -1
  196. package/dist/workspace-files-git.d.mts +15 -0
  197. package/dist/workspace-files-git.mjs +61 -0
  198. package/dist/workspace-files-git.mjs.map +1 -0
  199. package/dist/workspace-files.d.mts +107 -0
  200. package/dist/workspace-files.mjs +74 -0
  201. package/dist/workspace-files.mjs.map +1 -0
  202. package/docs/getting-started.md +104 -0
  203. package/docs/index.md +100 -0
  204. package/docs/recipes/cancellation.md +215 -0
  205. package/docs/recipes/custom-workflow.md +153 -0
  206. package/docs/recipes/dependent-tasks.md +207 -0
  207. package/docs/recipes/eve-agent.md +277 -0
  208. package/docs/recipes/human-input.md +204 -0
  209. package/docs/recipes/persistence-recovery.md +268 -0
  210. package/docs/recipes/retry-recovery.md +241 -0
  211. package/docs/recipes/task-messaging.md +215 -0
  212. package/docs/recipes/typed-eve-result.md +161 -0
  213. package/docs/runtime-integration.md +137 -0
  214. package/package.json +17 -6
@@ -22,6 +22,7 @@ declare const approvalViewSchema: z.ZodObject<{
22
22
  id: z.ZodType<`task_${string}`, string, z.core.$ZodTypeInternals<`task_${string}`, string>>;
23
23
  repositoryIds: z.ZodReadonly<z.ZodTuple<[z.ZodType<`repo_${string}`, string, z.core.$ZodTypeInternals<`repo_${string}`, string>>], z.ZodType<`repo_${string}`, string, z.core.$ZodTypeInternals<`repo_${string}`, string>>>>;
24
24
  kind: z.ZodType<TaskKind, unknown, z.core.$ZodTypeInternals<TaskKind, unknown>>;
25
+ model: z.ZodOptional<z.ZodString>;
25
26
  area: z.ZodOptional<z.ZodString>;
26
27
  state: z.ZodEnum<{
27
28
  queued: "queued";
@@ -138,6 +139,7 @@ declare const approvalViewSchema: z.ZodObject<{
138
139
  attempt: number;
139
140
  createdAt: string;
140
141
  updatedAt: string;
142
+ model?: string | undefined;
141
143
  area?: string | undefined;
142
144
  approval?: "required" | "granted" | "denied" | undefined;
143
145
  workResult?: {
@@ -215,6 +217,7 @@ declare const approvalViewSchema: z.ZodObject<{
215
217
  attempt: number;
216
218
  createdAt: string;
217
219
  updatedAt: string;
220
+ model?: string | undefined;
218
221
  area?: string | undefined;
219
222
  approval?: "required" | "granted" | "denied" | undefined;
220
223
  workResult?: {
@@ -351,6 +354,7 @@ declare const approvalPageSchema: z.ZodObject<{
351
354
  id: z.ZodType<`task_${string}`, string, z.core.$ZodTypeInternals<`task_${string}`, string>>;
352
355
  repositoryIds: z.ZodReadonly<z.ZodTuple<[z.ZodType<`repo_${string}`, string, z.core.$ZodTypeInternals<`repo_${string}`, string>>], z.ZodType<`repo_${string}`, string, z.core.$ZodTypeInternals<`repo_${string}`, string>>>>;
353
356
  kind: z.ZodType<TaskKind, unknown, z.core.$ZodTypeInternals<TaskKind, unknown>>;
357
+ model: z.ZodOptional<z.ZodString>;
354
358
  area: z.ZodOptional<z.ZodString>;
355
359
  state: z.ZodEnum<{
356
360
  queued: "queued";
@@ -467,6 +471,7 @@ declare const approvalPageSchema: z.ZodObject<{
467
471
  attempt: number;
468
472
  createdAt: string;
469
473
  updatedAt: string;
474
+ model?: string | undefined;
470
475
  area?: string | undefined;
471
476
  approval?: "required" | "granted" | "denied" | undefined;
472
477
  workResult?: {
@@ -544,6 +549,7 @@ declare const approvalPageSchema: z.ZodObject<{
544
549
  attempt: number;
545
550
  createdAt: string;
546
551
  updatedAt: string;
552
+ model?: string | undefined;
547
553
  area?: string | undefined;
548
554
  approval?: "required" | "granted" | "denied" | undefined;
549
555
  workResult?: {
@@ -4,44 +4,71 @@ import { ArtifactInput, ArtifactKind, ArtifactRef, ArtifactStore, ReadArtifactIn
4
4
  //#region src/blob/index.d.ts
5
5
  /** Blob content paired with the ETag required for compare-and-swap persistence. */
6
6
  interface BlobObject {
7
+ /** Complete UTF-8 body returned by an uncached read. */
7
8
  body: string;
9
+ /** Current object ETag used to fence a later conditional write. */
8
10
  etag: string;
9
11
  }
10
12
  /** Exact blob pathname requested from storage. */
11
13
  interface ReadBlobInput {
14
+ /** Exact namespaced object path; the client must not apply a second namespace. */
12
15
  pathname: string;
13
16
  }
14
17
  /** Blob body and conditional-write policy used for one storage write. */
15
18
  interface WriteBlobInput extends ReadBlobInput {
19
+ /** Complete replacement body to persist. */
16
20
  body: string;
21
+ /** Whether an existing path may be replaced; `false` means create-if-absent. */
17
22
  overwrite: boolean;
23
+ /** Optional ETag that must still match atomically when overwriting. */
18
24
  ifMatch?: string;
19
25
  }
20
26
  /** Path prefix used to list matching blobs. */
21
27
  interface ListBlobPathsInput {
28
+ /** Path prefix whose complete matching object names are requested. */
22
29
  prefix: string;
23
30
  }
24
31
  /** The narrow blob operations the driver needs; injectable for tests. */
25
32
  interface BlobClient {
26
- /** Must return the latest content, never a cached copy. */
33
+ /**
34
+ * Returns the latest content rather than a cached copy, or `null` when absent.
35
+ *
36
+ * @param input - Exact object path to read.
37
+ * @returns Current body and ETag, or `null` when the path does not exist.
38
+ */
27
39
  read(input: ReadBlobInput): Promise<BlobObject | null>;
28
- /** Must reject if overwrite is false and the path exists, or if ifMatch is given and the ETag differs. */
40
+ /**
41
+ * Persists one complete object and enforces its create or ETag precondition atomically.
42
+ *
43
+ * @param input - Exact path, body, overwrite policy, and optional ETag fence.
44
+ * @returns After the object write is durable at the provider boundary.
45
+ */
29
46
  write(input: WriteBlobInput): Promise<void>;
47
+ /**
48
+ * Lists every object path matching the prefix, following all provider pages.
49
+ *
50
+ * @param input - Namespace prefix to enumerate.
51
+ * @returns Matching exact paths; callers do not depend on their order.
52
+ */
30
53
  listPaths(input: ListBlobPathsInput): Promise<readonly string[]>;
31
54
  }
32
- /** Default private Vercel Blob client used by the durable Factory store driver. */
55
+ /**
56
+ * Default private Vercel Blob client used by the durable Factory store driver.
57
+ * Reads explicitly bypass the CDN cache, and writes resolve only after the SDK accepts them.
58
+ */
33
59
  declare const vercelBlobClient: BlobClient;
34
60
  /** Bounds retries and exponential backoff for transient Blob operations. */
35
61
  interface BlobRetryOptions {
36
- /** Maximum attempts of a single read or write, including the first. Default 3. */
62
+ /** Positive integer attempts for one read or write, including the first. Default 3. */
37
63
  attempts?: number;
38
- /** Backoff base in milliseconds; doubles each retry. Default 250. */
64
+ /** Finite non-negative backoff base in milliseconds; doubles each retry. Default 250. */
39
65
  baseDelayMs?: number;
40
- /** Ceiling on total elapsed retry time so a real outage still surfaces as a failure. Default 5000. */
66
+ /** Finite non-negative elapsed-time ceiling in milliseconds. Default 5000. */
41
67
  maxTotalMs?: number;
42
68
  }
43
69
  /** Configures the Blob client, namespace, read concurrency, and transient retries. */
44
70
  interface BlobDriverOptions {
71
+ /** Blob transport used for reads, conditional writes, and listings; defaults to Vercel Blob. */
45
72
  client?: BlobClient;
46
73
  /** Path prefix inside the blob store; defaults to "factory". */
47
74
  prefix?: string;
@@ -53,14 +80,24 @@ interface BlobDriverOptions {
53
80
  now?: MillisecondClock;
54
81
  }
55
82
  /**
56
- * Store driver on Vercel Blob: durable and dependency-free (one Blob store on
57
- * the project, no database). Inserts are create-if-absent. Updates read the latest content,
58
- * bypassing Blob's CDN cache, and write with an ETag precondition, so a
59
- * writer that lost a race gets a VersionConflictError rather than
60
- * overwriting the winner. Reads and writes retry with backoff on transient
61
- * (5xx/429/network) failures, bounded by attempts and total elapsed time;
62
- * once exhausted, the failure surfaces as TransientStoreError so a real
63
- * outage does not hang a caller retrying forever.
83
+ * Creates a durable Vercel Blob driver with atomic compare-and-swap updates.
84
+ *
85
+ * @remarks
86
+ * Inserts are create-if-absent. Updates bypass Blob's CDN cache and use the current ETag, so a
87
+ * writer that loses a race receives `VersionConflictError` instead of overwriting the winner.
88
+ * Reads and writes retry only 5xx, 429, and network failures within the configured attempt and
89
+ * elapsed-time bounds; exhaustion surfaces as `TransientStoreError`. If a transient write may
90
+ * have landed before its response was lost, the retry reads the record back and treats only the
91
+ * exact intended value and version as success. A different value remains a real conflict.
92
+ *
93
+ * Collection listing downloads records under the shared `readConcurrency` bound, coalesces
94
+ * concurrent lists of the same collection, and rejects malformed persisted values. Listing the
95
+ * provider's paths itself is not retried by this driver. The caller owns credentials and any
96
+ * injected client's lifecycle.
97
+ *
98
+ * @param options - Optional Blob transport, namespace, read limit, retry policy, and retry clock.
99
+ * @returns A `blob` driver suitable for `createStores`.
100
+ * @throws When concurrency or retry bounds are invalid.
64
101
  */
65
102
  declare function createBlobDriver(options?: BlobDriverOptions): StoreDriver;
66
103
  //#endregion
@@ -9,7 +9,7 @@ function isAlreadyExistsError(error) {
9
9
  return error instanceof Error && /already exists|not allowed to overwrite|overwrite/iu.test(error.message);
10
10
  }
11
11
  function isPreconditionError(error) {
12
- return error instanceof Error && (error.name === "BlobPreconditionFailedError" || /precondition|etag|412/iu.test(error.message));
12
+ return error instanceof Error && (error.name === "BlobPreconditionFailedError" || /precondition|etag|412/iu.test(error.message) || error.message === "Vercel Blob: The conditional request cannot succeed due to a conflicting operation against this resource.");
13
13
  }
14
14
  const TRANSIENT_NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
15
15
  "ECONNRESET",
@@ -46,7 +46,10 @@ function jsonValuesEqual(a, b) {
46
46
  if (aKeys.length !== bKeys.length) return false;
47
47
  return aKeys.every((key) => Object.hasOwn(b, key) && jsonValuesEqual(a[key], b[key]));
48
48
  }
49
- /** Default private Vercel Blob client used by the durable Factory store driver. */
49
+ /**
50
+ * Default private Vercel Blob client used by the durable Factory store driver.
51
+ * Reads explicitly bypass the CDN cache, and writes resolve only after the SDK accepts them.
52
+ */
50
53
  const vercelBlobClient = {
51
54
  async read({ pathname }) {
52
55
  let result;
@@ -89,14 +92,24 @@ const vercelBlobClient = {
89
92
  }
90
93
  };
91
94
  /**
92
- * Store driver on Vercel Blob: durable and dependency-free (one Blob store on
93
- * the project, no database). Inserts are create-if-absent. Updates read the latest content,
94
- * bypassing Blob's CDN cache, and write with an ETag precondition, so a
95
- * writer that lost a race gets a VersionConflictError rather than
96
- * overwriting the winner. Reads and writes retry with backoff on transient
97
- * (5xx/429/network) failures, bounded by attempts and total elapsed time;
98
- * once exhausted, the failure surfaces as TransientStoreError so a real
99
- * outage does not hang a caller retrying forever.
95
+ * Creates a durable Vercel Blob driver with atomic compare-and-swap updates.
96
+ *
97
+ * @remarks
98
+ * Inserts are create-if-absent. Updates bypass Blob's CDN cache and use the current ETag, so a
99
+ * writer that loses a race receives `VersionConflictError` instead of overwriting the winner.
100
+ * Reads and writes retry only 5xx, 429, and network failures within the configured attempt and
101
+ * elapsed-time bounds; exhaustion surfaces as `TransientStoreError`. If a transient write may
102
+ * have landed before its response was lost, the retry reads the record back and treats only the
103
+ * exact intended value and version as success. A different value remains a real conflict.
104
+ *
105
+ * Collection listing downloads records under the shared `readConcurrency` bound, coalesces
106
+ * concurrent lists of the same collection, and rejects malformed persisted values. Listing the
107
+ * provider's paths itself is not retried by this driver. The caller owns credentials and any
108
+ * injected client's lifecycle.
109
+ *
110
+ * @param options - Optional Blob transport, namespace, read limit, retry policy, and retry clock.
111
+ * @returns A `blob` driver suitable for `createStores`.
112
+ * @throws When concurrency or retry bounds are invalid.
100
113
  */
101
114
  function createBlobDriver(options = {}) {
102
115
  const client = options.client ?? vercelBlobClient;
@@ -109,6 +122,9 @@ function createBlobDriver(options = {}) {
109
122
  baseDelayMs: options.retry?.baseDelayMs ?? 250,
110
123
  maxTotalMs: options.retry?.maxTotalMs ?? 5e3
111
124
  };
125
+ if (!Number.isInteger(retry.attempts) || retry.attempts < 1) throw new Error("Blob retry attempts must be a positive integer");
126
+ if (!Number.isFinite(retry.baseDelayMs) || retry.baseDelayMs < 0) throw new Error("Blob retry base delay must be a finite non-negative number");
127
+ if (!Number.isFinite(retry.maxTotalMs) || retry.maxTotalMs < 0) throw new Error("Blob retry elapsed-time limit must be a finite non-negative number");
112
128
  let activeReads = 0;
113
129
  const readWaiters = [];
114
130
  const activeLists = /* @__PURE__ */ new Map();
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/blob/index.ts"],"sourcesContent":["import { get, list, put } from \"@vercel/blob\";\nexport {\n artifactReference,\n createArtifactStore,\n parseArtifactRef,\n type ArtifactRef,\n type ArtifactInput,\n type ArtifactKind,\n type ReadArtifactInput,\n type ArtifactStore,\n} from \"./artifacts\";\nimport {\n RecordExistsError,\n RecordNotFoundError,\n TransientStoreError,\n VersionConflictError,\n type CollectionName,\n type StoredEntry,\n type StoreDriver,\n} from \"../store/driver\";\nimport type { MillisecondClock } from \"../schema/common\";\n\n/** Blob content paired with the ETag required for compare-and-swap persistence. */\nexport interface BlobObject {\n body: string;\n etag: string;\n}\n\n/** Exact blob pathname requested from storage. */\nexport interface ReadBlobInput {\n pathname: string;\n}\n\n/** Blob body and conditional-write policy used for one storage write. */\nexport interface WriteBlobInput extends ReadBlobInput {\n body: string;\n overwrite: boolean;\n ifMatch?: string;\n}\n\n/** Path prefix used to list matching blobs. */\nexport interface ListBlobPathsInput {\n prefix: string;\n}\n\n/** The narrow blob operations the driver needs; injectable for tests. */\nexport interface BlobClient {\n /** Must return the latest content, never a cached copy. */\n read(input: ReadBlobInput): Promise<BlobObject | null>;\n /** Must reject if overwrite is false and the path exists, or if ifMatch is given and the ETag differs. */\n write(input: WriteBlobInput): Promise<void>;\n listPaths(input: ListBlobPathsInput): Promise<readonly string[]>;\n}\n\n// @vercel/blob throws BlobNotFoundError with the message\n// \"Vercel Blob: The requested blob does not exist\".\nfunction isNotFoundError(error: unknown): boolean {\n return (\n error instanceof Error &&\n (error.name === \"BlobNotFoundError\" || /does not exist|not.?found|404/iu.test(error.message))\n );\n}\n\nfunction isAlreadyExistsError(error: unknown): boolean {\n return (\n error instanceof Error &&\n /already exists|not allowed to overwrite|overwrite/iu.test(error.message)\n );\n}\n\n// @vercel/blob throws BlobPreconditionFailedError when ifMatch does not hold.\nfunction isPreconditionError(error: unknown): boolean {\n return (\n error instanceof Error &&\n (error.name === \"BlobPreconditionFailedError\" || /precondition|etag|412/iu.test(error.message))\n );\n}\n\nconst TRANSIENT_NETWORK_ERROR_CODES = new Set([\n \"ECONNRESET\",\n \"ECONNREFUSED\",\n \"ETIMEDOUT\",\n \"ENOTFOUND\",\n \"EAI_AGAIN\",\n \"EPIPE\",\n \"EHOSTUNREACH\",\n \"ENETUNREACH\",\n \"UND_ERR_CONNECT_TIMEOUT\",\n \"UND_ERR_SOCKET\",\n \"UND_ERR_HEADERS_TIMEOUT\",\n]);\n\n// Blob 5xx/429s and plain network failures are transient: the same read or\n// write is worth retrying with backoff. Direct downloads bypass the SDK's\n// own request-retry policy entirely, and outages can also surface as a\n// generic HTTP error on the write path, so this checks the error's code and\n// message rather than one fixed message shape.\nfunction isTransientBlobError(error: unknown): boolean {\n if (!(error instanceof Error)) return false;\n if (isAlreadyExistsError(error) || isPreconditionError(error) || isNotFoundError(error))\n return false;\n const code = (error as NodeJS.ErrnoException).code;\n if (code !== undefined && TRANSIENT_NETWORK_ERROR_CODES.has(code)) return true;\n if (error.name === \"AbortError\") return true;\n if (\n /fetch failed|network|socket hang up|timed? ?out|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN/iu.test(\n error.message,\n )\n ) {\n return true;\n }\n // Matches an HTTP 429 or 5xx status appearing anywhere in the message,\n // whether from the direct-download fetch or a generic API error.\n return /(?:^|[^0-9])(?:429|5\\d{2})(?:[^0-9]|$)/u.test(error.message);\n}\n\n// Structural equality over JSON-serializable values: good enough to tell\n// whether a record we just read back is the exact one we tried to write,\n// without depending on key order surviving a JSON round-trip.\nfunction jsonValuesEqual(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n if (typeof a !== typeof b) return false;\n if (a === null || b === null || typeof a !== \"object\" || typeof b !== \"object\") return false;\n if (Array.isArray(a) || Array.isArray(b)) {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;\n return a.every((item, index) => jsonValuesEqual(item, b[index]));\n }\n const aKeys = Object.keys(a as Record<string, unknown>);\n const bKeys = Object.keys(b as Record<string, unknown>);\n if (aKeys.length !== bKeys.length) return false;\n return aKeys.every(\n (key) =>\n Object.hasOwn(b as Record<string, unknown>, key) &&\n jsonValuesEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),\n );\n}\n\n/** Default private Vercel Blob client used by the durable Factory store driver. */\nexport const vercelBlobClient: BlobClient = {\n async read({ pathname }) {\n let result;\n try {\n // Uncached: the ledger is read-modify-write and must see the latest version.\n result = await get(pathname, { access: \"private\", useCache: false });\n } catch (error) {\n if (isNotFoundError(error)) {\n return null;\n }\n throw error;\n }\n if (result === null || result.stream === null) {\n return null;\n }\n return { body: await new Response(result.stream).text(), etag: result.blob.etag };\n },\n\n async write({ pathname, body, overwrite, ifMatch }) {\n // The SDK treats an empty string as a missing body.\n await put(pathname, body === \"\" ? Buffer.alloc(0) : body, {\n access: \"private\",\n allowOverwrite: overwrite,\n contentType: \"application/json\",\n ...(ifMatch === undefined ? {} : { ifMatch: ifMatch.replace(/^W\\//u, \"\") }),\n });\n },\n\n async listPaths({ prefix }) {\n const paths: string[] = [];\n let cursor: string | undefined;\n do {\n const page = await list({ prefix, cursor, limit: 1000 });\n paths.push(...page.blobs.map((blob) => blob.pathname));\n cursor = page.hasMore ? page.cursor : undefined;\n } while (cursor !== undefined);\n return paths;\n },\n};\n\n/** Bounds retries and exponential backoff for transient Blob operations. */\nexport interface BlobRetryOptions {\n /** Maximum attempts of a single read or write, including the first. Default 3. */\n attempts?: number;\n /** Backoff base in milliseconds; doubles each retry. Default 250. */\n baseDelayMs?: number;\n /** Ceiling on total elapsed retry time so a real outage still surfaces as a failure. Default 5000. */\n maxTotalMs?: number;\n}\n\n/** Configures the Blob client, namespace, read concurrency, and transient retries. */\nexport interface BlobDriverOptions {\n client?: BlobClient;\n /** Path prefix inside the blob store; defaults to \"factory\". */\n prefix?: string;\n /** Maximum Blob downloads in flight while listing a collection. Default 32. */\n readConcurrency?: number;\n /** Bounds retry of transient (5xx/429/network) Blob failures on reads and writes. */\n retry?: BlobRetryOptions;\n /** Millisecond clock used to bound total retry time. */\n now?: MillisecondClock;\n}\n\n/**\n * Store driver on Vercel Blob: durable and dependency-free (one Blob store on\n * the project, no database). Inserts are create-if-absent. Updates read the latest content,\n * bypassing Blob's CDN cache, and write with an ETag precondition, so a\n * writer that lost a race gets a VersionConflictError rather than\n * overwriting the winner. Reads and writes retry with backoff on transient\n * (5xx/429/network) failures, bounded by attempts and total elapsed time;\n * once exhausted, the failure surfaces as TransientStoreError so a real\n * outage does not hang a caller retrying forever.\n */\nexport function createBlobDriver(options: BlobDriverOptions = {}): StoreDriver {\n const client = options.client ?? vercelBlobClient;\n const now = options.now ?? Date.now;\n const prefix = options.prefix ?? \"factory\";\n const readConcurrency = options.readConcurrency ?? 32;\n if (!Number.isInteger(readConcurrency) || readConcurrency < 1) {\n throw new Error(\"Blob read concurrency must be a positive integer\");\n }\n const retry = {\n attempts: options.retry?.attempts ?? 3,\n baseDelayMs: options.retry?.baseDelayMs ?? 250,\n maxTotalMs: options.retry?.maxTotalMs ?? 5000,\n };\n let activeReads = 0;\n const readWaiters: (() => void)[] = [];\n const activeLists = new Map<CollectionName, Promise<StoredEntry[]>>();\n\n // Retries only transient failures, with exponential backoff bounded by\n // both attempt count and total elapsed time. Non-transient errors (not\n // found, already exists, precondition/version conflicts, malformed data)\n // are rethrown immediately and untouched by this loop.\n async function withTransientRetry<T>(\n collection: CollectionName,\n id: string,\n operation: () => Promise<T>,\n ): Promise<T> {\n const start = now();\n for (let attempt = 0; ; attempt += 1) {\n try {\n return await operation();\n } catch (error) {\n if (!isTransientBlobError(error)) throw error;\n const nextDelayMs = retry.baseDelayMs * 2 ** attempt;\n if (attempt >= retry.attempts - 1 || now() - start + nextDelayMs > retry.maxTotalMs) {\n throw new TransientStoreError(collection, id, error);\n }\n await new Promise((resolve) => setTimeout(resolve, nextDelayMs));\n }\n }\n }\n\n async function limitedRead(pathname: string): Promise<BlobObject | null> {\n if (activeReads < readConcurrency) activeReads += 1;\n else await new Promise<void>((resolve) => readWaiters.push(resolve));\n try {\n return await client.read({ pathname });\n } finally {\n const waiter = readWaiters.shift();\n if (waiter === undefined) activeReads -= 1;\n else waiter();\n }\n }\n\n function pathFor(collection: CollectionName, id: string): string {\n return `${prefix}/${collection}/${id}.json`;\n }\n\n async function readRecord(\n collection: CollectionName,\n id: string,\n ): Promise<{ value: unknown; version: number; etag: string } | null> {\n let object: BlobObject | null;\n try {\n object = await withTransientRetry(collection, id, () => limitedRead(pathFor(collection, id)));\n } catch (error) {\n if (isNotFoundError(error)) {\n return null;\n }\n throw error;\n }\n if (object === null) {\n return null;\n }\n const parsed = JSON.parse(object.body) as { value: unknown; version: number };\n if (typeof parsed.version !== \"number\") {\n throw new Error(`malformed stored record at ${pathFor(collection, id)}`);\n }\n return { value: parsed.value, version: parsed.version, etag: object.etag };\n }\n\n // A write that fails with a transient error (network drop, 5xx) may still\n // have landed on the server before the response reached us: the retry\n // withTransientRetry just took then legitimately collides with our own\n // prior success. These two helpers disambiguate that case from a real\n // conflict with another writer by reading the record back and comparing\n // it to what this call tried to write; only called after this call itself\n // retried (a fresh, non-retried collision is always a real conflict).\n async function wroteThisInsert(\n collection: CollectionName,\n id: string,\n value: unknown,\n ): Promise<boolean> {\n const after = await readRecord(collection, id);\n return after !== null && after.version === 1 && jsonValuesEqual(after.value, value);\n }\n\n async function wroteThisUpdate(\n collection: CollectionName,\n id: string,\n value: unknown,\n expectedNewVersion: number,\n ): Promise<boolean> {\n const after = await readRecord(collection, id);\n return (\n after !== null && after.version === expectedNewVersion && jsonValuesEqual(after.value, value)\n );\n }\n\n return {\n name: \"blob\",\n\n async get(collection, id) {\n const record = await readRecord(collection, id);\n return record === null ? null : { value: record.value, version: record.version };\n },\n\n async insert({ collection, id, value }) {\n // Tracks whether this call itself retried: a write that fails with a\n // transient error can still have landed on the server before the\n // response was lost, so a *retried* write's own \"already exists\"\n // collision is ambiguous rather than automatically a real conflict.\n let attempts = 0;\n try {\n await withTransientRetry(collection, id, () => {\n attempts += 1;\n return client.write({\n pathname: pathFor(collection, id),\n body: JSON.stringify({ value, version: 1 }),\n overwrite: false,\n });\n });\n } catch (error) {\n if (isAlreadyExistsError(error)) {\n if (attempts > 1 && (await wroteThisInsert(collection, id, value))) {\n return;\n }\n throw new RecordExistsError(collection, id);\n }\n throw error;\n }\n },\n\n async update({ collection, id, value, expectedVersion }) {\n const existing = await readRecord(collection, id);\n if (existing === null) {\n throw new RecordNotFoundError(collection, id);\n }\n if (existing.version !== expectedVersion) {\n throw new VersionConflictError(collection, id, expectedVersion);\n }\n // Tracks whether this call itself retried: see the matching comment on\n // insert() above. A retried update's own precondition failure is\n // ambiguous in the same way.\n let attempts = 0;\n try {\n await withTransientRetry(collection, id, () => {\n attempts += 1;\n return client.write({\n pathname: pathFor(collection, id),\n body: JSON.stringify({ value, version: expectedVersion + 1 }),\n overwrite: true,\n // The write API's ifMatch rejects the weak form (W/\"…\") that reads return.\n ifMatch: existing.etag.replace(/^W\\//u, \"\"),\n });\n });\n } catch (error) {\n if (isPreconditionError(error)) {\n if (attempts > 1 && (await wroteThisUpdate(collection, id, value, expectedVersion + 1))) {\n return;\n }\n throw new VersionConflictError(collection, id, expectedVersion);\n }\n throw error;\n }\n },\n\n async list(collection) {\n const active = activeLists.get(collection);\n if (active !== undefined) return active;\n const listing = (async () => {\n const dir = `${prefix}/${collection}/`;\n const paths = await client.listPaths({ prefix: dir });\n const entries: (StoredEntry | null)[] = new Array(paths.length);\n let next = 0;\n let failure: unknown;\n const workers = Array.from(\n { length: Math.min(readConcurrency, paths.length) },\n async () => {\n while (next < paths.length && failure === undefined) {\n const index = next++;\n const pathname = paths[index]!;\n const id = pathname.slice(dir.length).replace(/\\.json$/u, \"\");\n try {\n const record = await readRecord(collection, id);\n entries[index] =\n record === null\n ? null\n : { id, record: { value: record.value, version: record.version } };\n } catch (error) {\n failure ??= error;\n }\n }\n },\n );\n await Promise.all(workers);\n if (failure !== undefined) throw failure;\n return entries.filter((entry): entry is StoredEntry => entry !== null);\n })();\n activeLists.set(collection, listing);\n try {\n return await listing;\n } finally {\n if (activeLists.get(collection) === listing) activeLists.delete(collection);\n }\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/blob/index.ts"],"sourcesContent":["import { get, list, put } from \"@vercel/blob\";\nexport {\n artifactReference,\n createArtifactStore,\n parseArtifactRef,\n type ArtifactRef,\n type ArtifactInput,\n type ArtifactKind,\n type ReadArtifactInput,\n type ArtifactStore,\n} from \"./artifacts\";\nimport {\n RecordExistsError,\n RecordNotFoundError,\n TransientStoreError,\n VersionConflictError,\n type CollectionName,\n type StoredEntry,\n type StoreDriver,\n} from \"../store/driver\";\nimport type { MillisecondClock } from \"../schema/common\";\n\n/** Blob content paired with the ETag required for compare-and-swap persistence. */\nexport interface BlobObject {\n /** Complete UTF-8 body returned by an uncached read. */\n body: string;\n /** Current object ETag used to fence a later conditional write. */\n etag: string;\n}\n\n/** Exact blob pathname requested from storage. */\nexport interface ReadBlobInput {\n /** Exact namespaced object path; the client must not apply a second namespace. */\n pathname: string;\n}\n\n/** Blob body and conditional-write policy used for one storage write. */\nexport interface WriteBlobInput extends ReadBlobInput {\n /** Complete replacement body to persist. */\n body: string;\n /** Whether an existing path may be replaced; `false` means create-if-absent. */\n overwrite: boolean;\n /** Optional ETag that must still match atomically when overwriting. */\n ifMatch?: string;\n}\n\n/** Path prefix used to list matching blobs. */\nexport interface ListBlobPathsInput {\n /** Path prefix whose complete matching object names are requested. */\n prefix: string;\n}\n\n/** The narrow blob operations the driver needs; injectable for tests. */\nexport interface BlobClient {\n /**\n * Returns the latest content rather than a cached copy, or `null` when absent.\n *\n * @param input - Exact object path to read.\n * @returns Current body and ETag, or `null` when the path does not exist.\n */\n read(input: ReadBlobInput): Promise<BlobObject | null>;\n /**\n * Persists one complete object and enforces its create or ETag precondition atomically.\n *\n * @param input - Exact path, body, overwrite policy, and optional ETag fence.\n * @returns After the object write is durable at the provider boundary.\n */\n write(input: WriteBlobInput): Promise<void>;\n /**\n * Lists every object path matching the prefix, following all provider pages.\n *\n * @param input - Namespace prefix to enumerate.\n * @returns Matching exact paths; callers do not depend on their order.\n */\n listPaths(input: ListBlobPathsInput): Promise<readonly string[]>;\n}\n\n// @vercel/blob throws BlobNotFoundError with the message\n// \"Vercel Blob: The requested blob does not exist\".\nfunction isNotFoundError(error: unknown): boolean {\n return (\n error instanceof Error &&\n (error.name === \"BlobNotFoundError\" || /does not exist|not.?found|404/iu.test(error.message))\n );\n}\n\nfunction isAlreadyExistsError(error: unknown): boolean {\n return (\n error instanceof Error &&\n /already exists|not allowed to overwrite|overwrite/iu.test(error.message)\n );\n}\n\n// The SDK also forwards provider conditional-operation conflicts as generic BlobErrors.\nfunction isPreconditionError(error: unknown): boolean {\n return (\n error instanceof Error &&\n (error.name === \"BlobPreconditionFailedError\" ||\n /precondition|etag|412/iu.test(error.message) ||\n error.message ===\n \"Vercel Blob: The conditional request cannot succeed due to a conflicting operation against this resource.\")\n );\n}\n\nconst TRANSIENT_NETWORK_ERROR_CODES = new Set([\n \"ECONNRESET\",\n \"ECONNREFUSED\",\n \"ETIMEDOUT\",\n \"ENOTFOUND\",\n \"EAI_AGAIN\",\n \"EPIPE\",\n \"EHOSTUNREACH\",\n \"ENETUNREACH\",\n \"UND_ERR_CONNECT_TIMEOUT\",\n \"UND_ERR_SOCKET\",\n \"UND_ERR_HEADERS_TIMEOUT\",\n]);\n\n// Blob 5xx/429s and plain network failures are transient: the same read or\n// write is worth retrying with backoff. Direct downloads bypass the SDK's\n// own request-retry policy entirely, and outages can also surface as a\n// generic HTTP error on the write path, so this checks the error's code and\n// message rather than one fixed message shape.\nfunction isTransientBlobError(error: unknown): boolean {\n if (!(error instanceof Error)) return false;\n if (isAlreadyExistsError(error) || isPreconditionError(error) || isNotFoundError(error))\n return false;\n const code = (error as NodeJS.ErrnoException).code;\n if (code !== undefined && TRANSIENT_NETWORK_ERROR_CODES.has(code)) return true;\n if (error.name === \"AbortError\") return true;\n if (\n /fetch failed|network|socket hang up|timed? ?out|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN/iu.test(\n error.message,\n )\n ) {\n return true;\n }\n // Matches an HTTP 429 or 5xx status appearing anywhere in the message,\n // whether from the direct-download fetch or a generic API error.\n return /(?:^|[^0-9])(?:429|5\\d{2})(?:[^0-9]|$)/u.test(error.message);\n}\n\n// Structural equality over JSON-serializable values: good enough to tell\n// whether a record we just read back is the exact one we tried to write,\n// without depending on key order surviving a JSON round-trip.\nfunction jsonValuesEqual(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n if (typeof a !== typeof b) return false;\n if (a === null || b === null || typeof a !== \"object\" || typeof b !== \"object\") return false;\n if (Array.isArray(a) || Array.isArray(b)) {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;\n return a.every((item, index) => jsonValuesEqual(item, b[index]));\n }\n const aKeys = Object.keys(a as Record<string, unknown>);\n const bKeys = Object.keys(b as Record<string, unknown>);\n if (aKeys.length !== bKeys.length) return false;\n return aKeys.every(\n (key) =>\n Object.hasOwn(b as Record<string, unknown>, key) &&\n jsonValuesEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),\n );\n}\n\n/**\n * Default private Vercel Blob client used by the durable Factory store driver.\n * Reads explicitly bypass the CDN cache, and writes resolve only after the SDK accepts them.\n */\nexport const vercelBlobClient: BlobClient = {\n async read({ pathname }) {\n let result;\n try {\n // Uncached: the ledger is read-modify-write and must see the latest version.\n result = await get(pathname, { access: \"private\", useCache: false });\n } catch (error) {\n if (isNotFoundError(error)) {\n return null;\n }\n throw error;\n }\n if (result === null || result.stream === null) {\n return null;\n }\n return { body: await new Response(result.stream).text(), etag: result.blob.etag };\n },\n\n async write({ pathname, body, overwrite, ifMatch }) {\n // The SDK treats an empty string as a missing body.\n await put(pathname, body === \"\" ? Buffer.alloc(0) : body, {\n access: \"private\",\n allowOverwrite: overwrite,\n contentType: \"application/json\",\n ...(ifMatch === undefined ? {} : { ifMatch: ifMatch.replace(/^W\\//u, \"\") }),\n });\n },\n\n async listPaths({ prefix }) {\n const paths: string[] = [];\n let cursor: string | undefined;\n do {\n const page = await list({ prefix, cursor, limit: 1000 });\n paths.push(...page.blobs.map((blob) => blob.pathname));\n cursor = page.hasMore ? page.cursor : undefined;\n } while (cursor !== undefined);\n return paths;\n },\n};\n\n/** Bounds retries and exponential backoff for transient Blob operations. */\nexport interface BlobRetryOptions {\n /** Positive integer attempts for one read or write, including the first. Default 3. */\n attempts?: number;\n /** Finite non-negative backoff base in milliseconds; doubles each retry. Default 250. */\n baseDelayMs?: number;\n /** Finite non-negative elapsed-time ceiling in milliseconds. Default 5000. */\n maxTotalMs?: number;\n}\n\n/** Configures the Blob client, namespace, read concurrency, and transient retries. */\nexport interface BlobDriverOptions {\n /** Blob transport used for reads, conditional writes, and listings; defaults to Vercel Blob. */\n client?: BlobClient;\n /** Path prefix inside the blob store; defaults to \"factory\". */\n prefix?: string;\n /** Maximum Blob downloads in flight while listing a collection. Default 32. */\n readConcurrency?: number;\n /** Bounds retry of transient (5xx/429/network) Blob failures on reads and writes. */\n retry?: BlobRetryOptions;\n /** Millisecond clock used to bound total retry time. */\n now?: MillisecondClock;\n}\n\n/**\n * Creates a durable Vercel Blob driver with atomic compare-and-swap updates.\n *\n * @remarks\n * Inserts are create-if-absent. Updates bypass Blob's CDN cache and use the current ETag, so a\n * writer that loses a race receives `VersionConflictError` instead of overwriting the winner.\n * Reads and writes retry only 5xx, 429, and network failures within the configured attempt and\n * elapsed-time bounds; exhaustion surfaces as `TransientStoreError`. If a transient write may\n * have landed before its response was lost, the retry reads the record back and treats only the\n * exact intended value and version as success. A different value remains a real conflict.\n *\n * Collection listing downloads records under the shared `readConcurrency` bound, coalesces\n * concurrent lists of the same collection, and rejects malformed persisted values. Listing the\n * provider's paths itself is not retried by this driver. The caller owns credentials and any\n * injected client's lifecycle.\n *\n * @param options - Optional Blob transport, namespace, read limit, retry policy, and retry clock.\n * @returns A `blob` driver suitable for `createStores`.\n * @throws When concurrency or retry bounds are invalid.\n */\nexport function createBlobDriver(options: BlobDriverOptions = {}): StoreDriver {\n const client = options.client ?? vercelBlobClient;\n const now = options.now ?? Date.now;\n const prefix = options.prefix ?? \"factory\";\n const readConcurrency = options.readConcurrency ?? 32;\n if (!Number.isInteger(readConcurrency) || readConcurrency < 1) {\n throw new Error(\"Blob read concurrency must be a positive integer\");\n }\n const retry = {\n attempts: options.retry?.attempts ?? 3,\n baseDelayMs: options.retry?.baseDelayMs ?? 250,\n maxTotalMs: options.retry?.maxTotalMs ?? 5000,\n };\n if (!Number.isInteger(retry.attempts) || retry.attempts < 1) {\n throw new Error(\"Blob retry attempts must be a positive integer\");\n }\n if (!Number.isFinite(retry.baseDelayMs) || retry.baseDelayMs < 0) {\n throw new Error(\"Blob retry base delay must be a finite non-negative number\");\n }\n if (!Number.isFinite(retry.maxTotalMs) || retry.maxTotalMs < 0) {\n throw new Error(\"Blob retry elapsed-time limit must be a finite non-negative number\");\n }\n let activeReads = 0;\n const readWaiters: (() => void)[] = [];\n const activeLists = new Map<CollectionName, Promise<StoredEntry[]>>();\n\n // Retries only transient failures, with exponential backoff bounded by\n // both attempt count and total elapsed time. Non-transient errors (not\n // found, already exists, precondition/version conflicts, malformed data)\n // are rethrown immediately and untouched by this loop.\n async function withTransientRetry<T>(\n collection: CollectionName,\n id: string,\n operation: () => Promise<T>,\n ): Promise<T> {\n const start = now();\n for (let attempt = 0; ; attempt += 1) {\n try {\n return await operation();\n } catch (error) {\n if (!isTransientBlobError(error)) throw error;\n const nextDelayMs = retry.baseDelayMs * 2 ** attempt;\n if (attempt >= retry.attempts - 1 || now() - start + nextDelayMs > retry.maxTotalMs) {\n throw new TransientStoreError(collection, id, error);\n }\n await new Promise((resolve) => setTimeout(resolve, nextDelayMs));\n }\n }\n }\n\n async function limitedRead(pathname: string): Promise<BlobObject | null> {\n if (activeReads < readConcurrency) activeReads += 1;\n else await new Promise<void>((resolve) => readWaiters.push(resolve));\n try {\n return await client.read({ pathname });\n } finally {\n const waiter = readWaiters.shift();\n if (waiter === undefined) activeReads -= 1;\n else waiter();\n }\n }\n\n function pathFor(collection: CollectionName, id: string): string {\n return `${prefix}/${collection}/${id}.json`;\n }\n\n async function readRecord(\n collection: CollectionName,\n id: string,\n ): Promise<{ value: unknown; version: number; etag: string } | null> {\n let object: BlobObject | null;\n try {\n object = await withTransientRetry(collection, id, () => limitedRead(pathFor(collection, id)));\n } catch (error) {\n if (isNotFoundError(error)) {\n return null;\n }\n throw error;\n }\n if (object === null) {\n return null;\n }\n const parsed = JSON.parse(object.body) as { value: unknown; version: number };\n if (typeof parsed.version !== \"number\") {\n throw new Error(`malformed stored record at ${pathFor(collection, id)}`);\n }\n return { value: parsed.value, version: parsed.version, etag: object.etag };\n }\n\n // A write that fails with a transient error (network drop, 5xx) may still\n // have landed on the server before the response reached us: the retry\n // withTransientRetry just took then legitimately collides with our own\n // prior success. These two helpers disambiguate that case from a real\n // conflict with another writer by reading the record back and comparing\n // it to what this call tried to write; only called after this call itself\n // retried (a fresh, non-retried collision is always a real conflict).\n async function wroteThisInsert(\n collection: CollectionName,\n id: string,\n value: unknown,\n ): Promise<boolean> {\n const after = await readRecord(collection, id);\n return after !== null && after.version === 1 && jsonValuesEqual(after.value, value);\n }\n\n async function wroteThisUpdate(\n collection: CollectionName,\n id: string,\n value: unknown,\n expectedNewVersion: number,\n ): Promise<boolean> {\n const after = await readRecord(collection, id);\n return (\n after !== null && after.version === expectedNewVersion && jsonValuesEqual(after.value, value)\n );\n }\n\n return {\n name: \"blob\",\n\n async get(collection, id) {\n const record = await readRecord(collection, id);\n return record === null ? null : { value: record.value, version: record.version };\n },\n\n async insert({ collection, id, value }) {\n // Tracks whether this call itself retried: a write that fails with a\n // transient error can still have landed on the server before the\n // response was lost, so a *retried* write's own \"already exists\"\n // collision is ambiguous rather than automatically a real conflict.\n let attempts = 0;\n try {\n await withTransientRetry(collection, id, () => {\n attempts += 1;\n return client.write({\n pathname: pathFor(collection, id),\n body: JSON.stringify({ value, version: 1 }),\n overwrite: false,\n });\n });\n } catch (error) {\n if (isAlreadyExistsError(error)) {\n if (attempts > 1 && (await wroteThisInsert(collection, id, value))) {\n return;\n }\n throw new RecordExistsError(collection, id);\n }\n throw error;\n }\n },\n\n async update({ collection, id, value, expectedVersion }) {\n const existing = await readRecord(collection, id);\n if (existing === null) {\n throw new RecordNotFoundError(collection, id);\n }\n if (existing.version !== expectedVersion) {\n throw new VersionConflictError(collection, id, expectedVersion);\n }\n // Tracks whether this call itself retried: see the matching comment on\n // insert() above. A retried update's own precondition failure is\n // ambiguous in the same way.\n let attempts = 0;\n try {\n await withTransientRetry(collection, id, () => {\n attempts += 1;\n return client.write({\n pathname: pathFor(collection, id),\n body: JSON.stringify({ value, version: expectedVersion + 1 }),\n overwrite: true,\n // The write API's ifMatch rejects the weak form (W/\"…\") that reads return.\n ifMatch: existing.etag.replace(/^W\\//u, \"\"),\n });\n });\n } catch (error) {\n if (isPreconditionError(error)) {\n if (attempts > 1 && (await wroteThisUpdate(collection, id, value, expectedVersion + 1))) {\n return;\n }\n throw new VersionConflictError(collection, id, expectedVersion);\n }\n throw error;\n }\n },\n\n async list(collection) {\n const active = activeLists.get(collection);\n if (active !== undefined) return active;\n const listing = (async () => {\n const dir = `${prefix}/${collection}/`;\n const paths = await client.listPaths({ prefix: dir });\n const entries: (StoredEntry | null)[] = new Array(paths.length);\n let next = 0;\n let failure: unknown;\n const workers = Array.from(\n { length: Math.min(readConcurrency, paths.length) },\n async () => {\n while (next < paths.length && failure === undefined) {\n const index = next++;\n const pathname = paths[index]!;\n const id = pathname.slice(dir.length).replace(/\\.json$/u, \"\");\n try {\n const record = await readRecord(collection, id);\n entries[index] =\n record === null\n ? null\n : { id, record: { value: record.value, version: record.version } };\n } catch (error) {\n failure ??= error;\n }\n }\n },\n );\n await Promise.all(workers);\n if (failure !== undefined) throw failure;\n return entries.filter((entry): entry is StoredEntry => entry !== null);\n })();\n activeLists.set(collection, listing);\n try {\n return await listing;\n } finally {\n if (activeLists.get(collection) === listing) activeLists.delete(collection);\n }\n },\n };\n}\n"],"mappings":";;;;AA+EA,SAAS,gBAAgB,OAAyB;CAChD,OACE,iBAAiB,UAChB,MAAM,SAAS,uBAAuB,kCAAkC,KAAK,MAAM,OAAO;AAE/F;AAEA,SAAS,qBAAqB,OAAyB;CACrD,OACE,iBAAiB,SACjB,sDAAsD,KAAK,MAAM,OAAO;AAE5E;AAGA,SAAS,oBAAoB,OAAyB;CACpD,OACE,iBAAiB,UAChB,MAAM,SAAS,iCACd,0BAA0B,KAAK,MAAM,OAAO,KAC5C,MAAM,YACJ;AAER;AAEA,MAAM,gDAAgC,IAAI,IAAI;CAC5C;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;AAOD,SAAS,qBAAqB,OAAyB;CACrD,IAAI,EAAE,iBAAiB,QAAQ,OAAO;CACtC,IAAI,qBAAqB,KAAK,KAAK,oBAAoB,KAAK,KAAK,gBAAgB,KAAK,GACpF,OAAO;CACT,MAAM,OAAQ,MAAgC;CAC9C,IAAI,SAAS,KAAA,KAAa,8BAA8B,IAAI,IAAI,GAAG,OAAO;CAC1E,IAAI,MAAM,SAAS,cAAc,OAAO;CACxC,IACE,0GAA0G,KACxG,MAAM,OACR,GAEA,OAAO;CAIT,OAAO,0CAA0C,KAAK,MAAM,OAAO;AACrE;AAKA,SAAS,gBAAgB,GAAY,GAAqB;CACxD,IAAI,MAAM,GAAG,OAAO;CACpB,IAAI,OAAO,MAAM,OAAO,GAAG,OAAO;CAClC,IAAI,MAAM,QAAQ,MAAM,QAAQ,OAAO,MAAM,YAAY,OAAO,MAAM,UAAU,OAAO;CACvF,IAAI,MAAM,QAAQ,CAAC,KAAK,MAAM,QAAQ,CAAC,GAAG;EACxC,IAAI,CAAC,MAAM,QAAQ,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,QAAQ,OAAO;EAC5E,OAAO,EAAE,OAAO,MAAM,UAAU,gBAAgB,MAAM,EAAE,MAAM,CAAC;CACjE;CACA,MAAM,QAAQ,OAAO,KAAK,CAA4B;CACtD,MAAM,QAAQ,OAAO,KAAK,CAA4B;CACtD,IAAI,MAAM,WAAW,MAAM,QAAQ,OAAO;CAC1C,OAAO,MAAM,OACV,QACC,OAAO,OAAO,GAA8B,GAAG,KAC/C,gBAAiB,EAA8B,MAAO,EAA8B,IAAI,CAC5F;AACF;;;;;AAMA,MAAa,mBAA+B;CAC1C,MAAM,KAAK,EAAE,YAAY;EACvB,IAAI;EACJ,IAAI;GAEF,SAAS,MAAM,IAAI,UAAU;IAAE,QAAQ;IAAW,UAAU;GAAM,CAAC;EACrE,SAAS,OAAO;GACd,IAAI,gBAAgB,KAAK,GACvB,OAAO;GAET,MAAM;EACR;EACA,IAAI,WAAW,QAAQ,OAAO,WAAW,MACvC,OAAO;EAET,OAAO;GAAE,MAAM,MAAM,IAAI,SAAS,OAAO,MAAM,CAAC,CAAC,KAAK;GAAG,MAAM,OAAO,KAAK;EAAK;CAClF;CAEA,MAAM,MAAM,EAAE,UAAU,MAAM,WAAW,WAAW;EAElD,MAAM,IAAI,UAAU,SAAS,KAAK,OAAO,MAAM,CAAC,IAAI,MAAM;GACxD,QAAQ;GACR,gBAAgB;GAChB,aAAa;GACb,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,QAAQ,QAAQ,SAAS,EAAE,EAAE;EAC3E,CAAC;CACH;CAEA,MAAM,UAAU,EAAE,UAAU;EAC1B,MAAM,QAAkB,CAAC;EACzB,IAAI;EACJ,GAAG;GACD,MAAM,OAAO,MAAM,KAAK;IAAE;IAAQ;IAAQ,OAAO;GAAK,CAAC;GACvD,MAAM,KAAK,GAAG,KAAK,MAAM,KAAK,SAAS,KAAK,QAAQ,CAAC;GACrD,SAAS,KAAK,UAAU,KAAK,SAAS,KAAA;EACxC,SAAS,WAAW,KAAA;EACpB,OAAO;CACT;AACF;;;;;;;;;;;;;;;;;;;;;AA8CA,SAAgB,iBAAiB,UAA6B,CAAC,GAAgB;CAC7E,MAAM,SAAS,QAAQ,UAAU;CACjC,MAAM,MAAM,QAAQ,OAAO,KAAK;CAChC,MAAM,SAAS,QAAQ,UAAU;CACjC,MAAM,kBAAkB,QAAQ,mBAAmB;CACnD,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,kBAAkB,GAC1D,MAAM,IAAI,MAAM,kDAAkD;CAEpE,MAAM,QAAQ;EACZ,UAAU,QAAQ,OAAO,YAAY;EACrC,aAAa,QAAQ,OAAO,eAAe;EAC3C,YAAY,QAAQ,OAAO,cAAc;CAC3C;CACA,IAAI,CAAC,OAAO,UAAU,MAAM,QAAQ,KAAK,MAAM,WAAW,GACxD,MAAM,IAAI,MAAM,gDAAgD;CAElE,IAAI,CAAC,OAAO,SAAS,MAAM,WAAW,KAAK,MAAM,cAAc,GAC7D,MAAM,IAAI,MAAM,4DAA4D;CAE9E,IAAI,CAAC,OAAO,SAAS,MAAM,UAAU,KAAK,MAAM,aAAa,GAC3D,MAAM,IAAI,MAAM,oEAAoE;CAEtF,IAAI,cAAc;CAClB,MAAM,cAA8B,CAAC;CACrC,MAAM,8BAAc,IAAI,IAA4C;CAMpE,eAAe,mBACb,YACA,IACA,WACY;EACZ,MAAM,QAAQ,IAAI;EAClB,KAAK,IAAI,UAAU,IAAK,WAAW,GACjC,IAAI;GACF,OAAO,MAAM,UAAU;EACzB,SAAS,OAAO;GACd,IAAI,CAAC,qBAAqB,KAAK,GAAG,MAAM;GACxC,MAAM,cAAc,MAAM,cAAc,KAAK;GAC7C,IAAI,WAAW,MAAM,WAAW,KAAK,IAAI,IAAI,QAAQ,cAAc,MAAM,YACvE,MAAM,IAAI,oBAAoB,YAAY,IAAI,KAAK;GAErD,MAAM,IAAI,SAAS,YAAY,WAAW,SAAS,WAAW,CAAC;EACjE;CAEJ;CAEA,eAAe,YAAY,UAA8C;EACvE,IAAI,cAAc,iBAAiB,eAAe;OAC7C,MAAM,IAAI,SAAe,YAAY,YAAY,KAAK,OAAO,CAAC;EACnE,IAAI;GACF,OAAO,MAAM,OAAO,KAAK,EAAE,SAAS,CAAC;EACvC,UAAU;GACR,MAAM,SAAS,YAAY,MAAM;GACjC,IAAI,WAAW,KAAA,GAAW,eAAe;QACpC,OAAO;EACd;CACF;CAEA,SAAS,QAAQ,YAA4B,IAAoB;EAC/D,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,GAAG;CACvC;CAEA,eAAe,WACb,YACA,IACmE;EACnE,IAAI;EACJ,IAAI;GACF,SAAS,MAAM,mBAAmB,YAAY,UAAU,YAAY,QAAQ,YAAY,EAAE,CAAC,CAAC;EAC9F,SAAS,OAAO;GACd,IAAI,gBAAgB,KAAK,GACvB,OAAO;GAET,MAAM;EACR;EACA,IAAI,WAAW,MACb,OAAO;EAET,MAAM,SAAS,KAAK,MAAM,OAAO,IAAI;EACrC,IAAI,OAAO,OAAO,YAAY,UAC5B,MAAM,IAAI,MAAM,8BAA8B,QAAQ,YAAY,EAAE,GAAG;EAEzE,OAAO;GAAE,OAAO,OAAO;GAAO,SAAS,OAAO;GAAS,MAAM,OAAO;EAAK;CAC3E;CASA,eAAe,gBACb,YACA,IACA,OACkB;EAClB,MAAM,QAAQ,MAAM,WAAW,YAAY,EAAE;EAC7C,OAAO,UAAU,QAAQ,MAAM,YAAY,KAAK,gBAAgB,MAAM,OAAO,KAAK;CACpF;CAEA,eAAe,gBACb,YACA,IACA,OACA,oBACkB;EAClB,MAAM,QAAQ,MAAM,WAAW,YAAY,EAAE;EAC7C,OACE,UAAU,QAAQ,MAAM,YAAY,sBAAsB,gBAAgB,MAAM,OAAO,KAAK;CAEhG;CAEA,OAAO;EACL,MAAM;EAEN,MAAM,IAAI,YAAY,IAAI;GACxB,MAAM,SAAS,MAAM,WAAW,YAAY,EAAE;GAC9C,OAAO,WAAW,OAAO,OAAO;IAAE,OAAO,OAAO;IAAO,SAAS,OAAO;GAAQ;EACjF;EAEA,MAAM,OAAO,EAAE,YAAY,IAAI,SAAS;GAKtC,IAAI,WAAW;GACf,IAAI;IACF,MAAM,mBAAmB,YAAY,UAAU;KAC7C,YAAY;KACZ,OAAO,OAAO,MAAM;MAClB,UAAU,QAAQ,YAAY,EAAE;MAChC,MAAM,KAAK,UAAU;OAAE;OAAO,SAAS;MAAE,CAAC;MAC1C,WAAW;KACb,CAAC;IACH,CAAC;GACH,SAAS,OAAO;IACd,IAAI,qBAAqB,KAAK,GAAG;KAC/B,IAAI,WAAW,KAAM,MAAM,gBAAgB,YAAY,IAAI,KAAK,GAC9D;KAEF,MAAM,IAAI,kBAAkB,YAAY,EAAE;IAC5C;IACA,MAAM;GACR;EACF;EAEA,MAAM,OAAO,EAAE,YAAY,IAAI,OAAO,mBAAmB;GACvD,MAAM,WAAW,MAAM,WAAW,YAAY,EAAE;GAChD,IAAI,aAAa,MACf,MAAM,IAAI,oBAAoB,YAAY,EAAE;GAE9C,IAAI,SAAS,YAAY,iBACvB,MAAM,IAAI,qBAAqB,YAAY,IAAI,eAAe;GAKhE,IAAI,WAAW;GACf,IAAI;IACF,MAAM,mBAAmB,YAAY,UAAU;KAC7C,YAAY;KACZ,OAAO,OAAO,MAAM;MAClB,UAAU,QAAQ,YAAY,EAAE;MAChC,MAAM,KAAK,UAAU;OAAE;OAAO,SAAS,kBAAkB;MAAE,CAAC;MAC5D,WAAW;MAEX,SAAS,SAAS,KAAK,QAAQ,SAAS,EAAE;KAC5C,CAAC;IACH,CAAC;GACH,SAAS,OAAO;IACd,IAAI,oBAAoB,KAAK,GAAG;KAC9B,IAAI,WAAW,KAAM,MAAM,gBAAgB,YAAY,IAAI,OAAO,kBAAkB,CAAC,GACnF;KAEF,MAAM,IAAI,qBAAqB,YAAY,IAAI,eAAe;IAChE;IACA,MAAM;GACR;EACF;EAEA,MAAM,KAAK,YAAY;GACrB,MAAM,SAAS,YAAY,IAAI,UAAU;GACzC,IAAI,WAAW,KAAA,GAAW,OAAO;GACjC,MAAM,WAAW,YAAY;IAC3B,MAAM,MAAM,GAAG,OAAO,GAAG,WAAW;IACpC,MAAM,QAAQ,MAAM,OAAO,UAAU,EAAE,QAAQ,IAAI,CAAC;IACpD,MAAM,UAAkC,IAAI,MAAM,MAAM,MAAM;IAC9D,IAAI,OAAO;IACX,IAAI;IACJ,MAAM,UAAU,MAAM,KACpB,EAAE,QAAQ,KAAK,IAAI,iBAAiB,MAAM,MAAM,EAAE,GAClD,YAAY;KACV,OAAO,OAAO,MAAM,UAAU,YAAY,KAAA,GAAW;MACnD,MAAM,QAAQ;MAEd,MAAM,KADW,MAAM,MACJ,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC,QAAQ,YAAY,EAAE;MAC5D,IAAI;OACF,MAAM,SAAS,MAAM,WAAW,YAAY,EAAE;OAC9C,QAAQ,SACN,WAAW,OACP,OACA;QAAE;QAAI,QAAQ;SAAE,OAAO,OAAO;SAAO,SAAS,OAAO;QAAQ;OAAE;MACvE,SAAS,OAAO;OACd,YAAY;MACd;KACF;IACF,CACF;IACA,MAAM,QAAQ,IAAI,OAAO;IACzB,IAAI,YAAY,KAAA,GAAW,MAAM;IACjC,OAAO,QAAQ,QAAQ,UAAgC,UAAU,IAAI;GACvE,EAAA,CAAG;GACH,YAAY,IAAI,YAAY,OAAO;GACnC,IAAI;IACF,OAAO,MAAM;GACf,UAAU;IACR,IAAI,YAAY,IAAI,UAAU,MAAM,SAAS,YAAY,OAAO,UAAU;GAC5E;EACF;CACF;AACF"}
package/dist/budget.d.mts CHANGED
@@ -65,6 +65,8 @@ interface BudgetNotice {
65
65
  interface BudgetSweepOptions {
66
66
  factoryId: FactoryId;
67
67
  limits: BudgetLimits;
68
+ /** Defaults to true. False reports spending without threshold checks or budget notices. */
69
+ enforce?: boolean;
68
70
  /** Defaults to the current UTC month. */
69
71
  month?: BudgetMonth;
70
72
  now?: DateClock;
@@ -79,6 +81,11 @@ interface BudgetSweepOptions {
79
81
  * new stamp was needed, and `delivery_failed` remains eligible for retry.
80
82
  */
81
83
  type BudgetSweepResult = {
84
+ readonly month: BudgetMonth;
85
+ readonly spendUsd: number;
86
+ readonly status: "disabled";
87
+ readonly noticeState: "not_applicable";
88
+ } | {
82
89
  readonly month: BudgetMonth;
83
90
  readonly spendUsd: number;
84
91
  readonly status: "ok";
package/dist/budget.mjs CHANGED
@@ -96,6 +96,12 @@ async function sweepBudget(stores, options) {
96
96
  limits: options.limits
97
97
  });
98
98
  const spendUsd = computeMonthSpendUsd(options.tasks ?? await stores.tasks.list(), month);
99
+ if (options.enforce === false) return {
100
+ month,
101
+ spendUsd,
102
+ status: "disabled",
103
+ noticeState: "not_applicable"
104
+ };
99
105
  const status = budgetStatus(spendUsd, options.limits);
100
106
  async function crossThreshold(notice, outcome) {
101
107
  const field = notice === "warning" ? "warningAt" : "exhaustedAt";
@@ -1 +1 @@
1
- {"version":3,"file":"budget.mjs","names":[],"sources":["../src/budget.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { DateClock } from \"./schema/common\";\nimport { factoryIdSchema, type FactoryId, type TaskId } from \"./schema/id\";\nimport { budgetMonthSchema, type BudgetLimits, type BudgetMonth } from \"./schema/budget\";\nimport type { Task } from \"./schema/task\";\nimport type { FactoryStores } from \"./store/engine\";\n\n/**\n * Stable factory identity for budget rows. Nothing mints factory ids yet, so\n * the app derives one from a deployment-stable seed such as the Vercel\n * project id.\n */\nexport function deriveFactoryId(seed: string): FactoryId {\n return factoryIdSchema.parse(\n `fac_${createHash(\"sha256\").update(seed).digest(\"hex\").slice(0, 32)}`,\n );\n}\n\n/** The budget month (\"YYYY-MM\", UTC) a timestamp falls into. */\nexport function budgetMonthOf(isoDateTime: string): BudgetMonth {\n return budgetMonthSchema.parse(isoDateTime.slice(0, 7));\n}\n\n// Currency math runs in integer micro-dollars so decimal limits compare\n// exactly; 0.1 + 0.2 must never refuse an exact-fit reservation.\nfunction microsOf(usd: number): number {\n return Math.round(usd * 1_000_000);\n}\n\n/**\n * Monthly spend is derived from task rows, never stored: each task counts its\n * settled cost once that exists, otherwise its held reservation. A task\n * settles once, so a settled row never also counts its reservation.\n * Attribution uses the reservation time so tasks that wait across a month\n * boundary charge the month they actually ran in.\n */\nexport function computeMonthSpendUsd(tasks: readonly Task[], month: BudgetMonth): number {\n let spendMicros = 0;\n for (const task of tasks) {\n if (budgetMonthOf(task.reservedAt ?? task.createdAt) !== month) {\n continue;\n }\n spendMicros += microsOf(task.settledUsd ?? task.reservedUsd ?? 0);\n }\n return spendMicros / 1_000_000;\n}\n\n/** Derived monthly capacity state before another per-Task reservation. */\nexport type BudgetStatus = \"ok\" | \"warning\" | \"exhausted\";\n\n/**\n * True when one more per-task slice fits under the monthly total. Dispatch\n * refuses on this exact predicate and budgetStatus reports \"exhausted\" on its\n * negation, so refusing and reporting can never disagree.\n */\nexport function canReserve(\n spendUsd: number,\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\">,\n): boolean {\n return microsOf(spendUsd) + microsOf(limits.perTaskReservationUsd) <= microsOf(limits.totalUsd);\n}\n\n/** Classify monthly spend using the same exact-fit predicate enforced by dispatch. */\nexport function budgetStatus(\n spendUsd: number,\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\" | \"warningPercent\">,\n): BudgetStatus {\n if (!canReserve(spendUsd, limits)) {\n return \"exhausted\";\n }\n // Integer comparison: spend/total * 100 >= warningPercent without float\n // drift on decimal limits.\n if (microsOf(spendUsd) * 100 >= microsOf(limits.totalUsd) * limits.warningPercent) {\n return \"warning\";\n }\n return \"ok\";\n}\n\nconst TERMINAL_TASK_STATES = new Set<Task[\"state\"]>([\"succeeded\", \"failed\", \"cancelled\"]);\n\n/** Grace period, clock, and optional snapshot for terminal usage settlement. */\nexport interface SweepSettlementsOptions {\n /** How long a terminal task waits for late usage before the sweep settles it. */\n graceMinutes?: number;\n now?: DateClock;\n /** A dispatcher-wide snapshot avoids another complete task read. */\n tasks?: readonly Task[];\n}\n\n/** Task-local charges newly settled by one reconciliation pass. */\nexport interface SweepSettlementsResult {\n readonly settled: readonly { readonly taskId: TaskId; readonly totalUsd: number }[];\n}\n\n/** Task capability required to settle terminal reservations. */\nexport type SettlementSweepStores = Pick<FactoryStores, \"tasks\">;\n\n/**\n * Settle reserved terminal tasks the session hook did not: a task that ended\n * after its session (a change verified later) or whose session died. Settles\n * at the recorded usage, or at zero with a warning reason when none was\n * recorded within the grace window.\n */\nexport async function sweepSettlements(\n stores: SettlementSweepStores,\n options: SweepSettlementsOptions = {},\n): Promise<SweepSettlementsResult> {\n const graceMs = (options.graceMinutes ?? 10) * 60_000;\n const nowMs = (options.now ?? (() => new Date()))().getTime();\n const settled: { taskId: TaskId; totalUsd: number }[] = [];\n for (const task of options.tasks ?? (await stores.tasks.list())) {\n if (task.reservedUsd === undefined || task.settledUsd !== undefined) {\n continue;\n }\n if (!TERMINAL_TASK_STATES.has(task.state)) {\n continue;\n }\n if (nowMs - Date.parse(task.updatedAt) < graceMs) {\n continue;\n }\n // A concurrent writer (operator retry, another sweep) may move the task\n // between the list and the settle; skip it rather than aborting the pass.\n const totalUsd = task.usage?.costUsd ?? 0;\n try {\n const result = await stores.tasks.settle(task.id, {\n cost: { totalUsd },\n ...{\n reason:\n task.usage === undefined\n ? \"usage missing after ten minutes; settled at zero\"\n : \"measured usage settled by sweep\",\n },\n });\n // Report this settlement's amount, not the task's accumulated total.\n settled.push({ taskId: result.id, totalUsd });\n } catch {\n continue;\n }\n }\n return { settled };\n}\n\n/** Owner-only notification emitted when monthly spend crosses a budget threshold. */\nexport interface BudgetNotice {\n outcome: \"budget_warning\" | \"budget_exhausted\";\n month: BudgetMonth;\n spendUsd: number;\n limits: BudgetLimits;\n}\n\n/** Monthly limits, delivery hook, and optional snapshot used for budget reconciliation. */\nexport interface BudgetSweepOptions {\n factoryId: FactoryId;\n limits: BudgetLimits;\n /** Defaults to the current UTC month. */\n month?: BudgetMonth;\n now?: DateClock;\n /** Owner-only delivery; invoked at most once per month per outcome. */\n notify?: (notice: BudgetNotice) => Promise<void>;\n /** A dispatcher-wide snapshot avoids another complete task read. */\n tasks?: readonly Task[];\n}\n\n/**\n * Derived monthly spend and exact notice outcome produced by budget reconciliation.\n * `stamped` means this sweep recorded the threshold, `already_stamped` means no\n * new stamp was needed, and `delivery_failed` remains eligible for retry.\n */\nexport type BudgetSweepResult =\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"ok\";\n readonly noticeState: \"not_applicable\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"warning\";\n readonly noticeState: \"stamped\" | \"already_stamped\" | \"delivery_failed\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"exhausted\";\n readonly noticeState: \"stamped\" | \"already_stamped\" | \"delivery_failed\";\n };\n\n/** Budget and Task capabilities required to reconcile monthly budget thresholds. */\nexport type BudgetSweepStores = Pick<FactoryStores, \"budgets\" | \"tasks\">;\n\n/**\n * Ensure the month's budget row, derive spend, and record threshold crossings\n * write-once. Delivery comes first and the stamp only lands after it, so a\n * failed notification retries on the next sweep instead of being lost; the\n * write-once stamp still keeps each threshold to one message per month.\n */\nexport async function sweepBudget(\n stores: BudgetSweepStores,\n options: BudgetSweepOptions,\n): Promise<BudgetSweepResult> {\n const now = options.now ?? (() => new Date());\n const month = options.month ?? budgetMonthOf(now().toISOString());\n const budget = await stores.budgets.ensure({\n factoryId: options.factoryId,\n month,\n limits: options.limits,\n });\n const spendUsd = computeMonthSpendUsd(options.tasks ?? (await stores.tasks.list()), month);\n const status = budgetStatus(spendUsd, options.limits);\n\n async function crossThreshold(\n notice: \"warning\" | \"exhausted\",\n outcome: BudgetNotice[\"outcome\"],\n ): Promise<\"stamped\" | \"already_stamped\" | \"delivery_failed\"> {\n const field = notice === \"warning\" ? \"warningAt\" : \"exhaustedAt\";\n if (budget.notices[field] !== undefined) {\n return \"already_stamped\";\n }\n if (options.notify !== undefined) {\n try {\n await options.notify({ outcome, month, spendUsd, limits: options.limits });\n } catch {\n // Leave the notice unstamped so the next sweep retries delivery.\n return \"delivery_failed\";\n }\n }\n return (await stores.budgets.stampNotice(month, notice)).stamped\n ? \"stamped\"\n : \"already_stamped\";\n }\n\n if (status === \"exhausted\") {\n const noticeState = await crossThreshold(\"exhausted\", \"budget_exhausted\");\n // The warning threshold was necessarily crossed too; stamp it silently so\n // a later settle-down cannot deliver \"warning\" after \"exhausted\".\n if (budget.notices.warningAt === undefined) {\n await stores.budgets.stampNotice(month, \"warning\");\n }\n return { month, spendUsd, status, noticeState };\n }\n if (status === \"warning\") {\n const noticeState = await crossThreshold(\"warning\", \"budget_warning\");\n return { month, spendUsd, status, noticeState };\n }\n return { month, spendUsd, status, noticeState: \"not_applicable\" };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"budget.mjs","names":[],"sources":["../src/budget.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { DateClock } from \"./schema/common\";\nimport { factoryIdSchema, type FactoryId, type TaskId } from \"./schema/id\";\nimport { budgetMonthSchema, type BudgetLimits, type BudgetMonth } from \"./schema/budget\";\nimport type { Task } from \"./schema/task\";\nimport type { FactoryStores } from \"./store/engine\";\n\n/**\n * Stable factory identity for budget rows. Nothing mints factory ids yet, so\n * the app derives one from a deployment-stable seed such as the Vercel\n * project id.\n */\nexport function deriveFactoryId(seed: string): FactoryId {\n return factoryIdSchema.parse(\n `fac_${createHash(\"sha256\").update(seed).digest(\"hex\").slice(0, 32)}`,\n );\n}\n\n/** The budget month (\"YYYY-MM\", UTC) a timestamp falls into. */\nexport function budgetMonthOf(isoDateTime: string): BudgetMonth {\n return budgetMonthSchema.parse(isoDateTime.slice(0, 7));\n}\n\n// Currency math runs in integer micro-dollars so decimal limits compare\n// exactly; 0.1 + 0.2 must never refuse an exact-fit reservation.\nfunction microsOf(usd: number): number {\n return Math.round(usd * 1_000_000);\n}\n\n/**\n * Monthly spend is derived from task rows, never stored: each task counts its\n * settled cost once that exists, otherwise its held reservation. A task\n * settles once, so a settled row never also counts its reservation.\n * Attribution uses the reservation time so tasks that wait across a month\n * boundary charge the month they actually ran in.\n */\nexport function computeMonthSpendUsd(tasks: readonly Task[], month: BudgetMonth): number {\n let spendMicros = 0;\n for (const task of tasks) {\n if (budgetMonthOf(task.reservedAt ?? task.createdAt) !== month) {\n continue;\n }\n spendMicros += microsOf(task.settledUsd ?? task.reservedUsd ?? 0);\n }\n return spendMicros / 1_000_000;\n}\n\n/** Derived monthly capacity state before another per-Task reservation. */\nexport type BudgetStatus = \"ok\" | \"warning\" | \"exhausted\";\n\n/**\n * True when one more per-task slice fits under the monthly total. Dispatch\n * refuses on this exact predicate and budgetStatus reports \"exhausted\" on its\n * negation, so refusing and reporting can never disagree.\n */\nexport function canReserve(\n spendUsd: number,\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\">,\n): boolean {\n return microsOf(spendUsd) + microsOf(limits.perTaskReservationUsd) <= microsOf(limits.totalUsd);\n}\n\n/** Classify monthly spend using the same exact-fit predicate enforced by dispatch. */\nexport function budgetStatus(\n spendUsd: number,\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\" | \"warningPercent\">,\n): BudgetStatus {\n if (!canReserve(spendUsd, limits)) {\n return \"exhausted\";\n }\n // Integer comparison: spend/total * 100 >= warningPercent without float\n // drift on decimal limits.\n if (microsOf(spendUsd) * 100 >= microsOf(limits.totalUsd) * limits.warningPercent) {\n return \"warning\";\n }\n return \"ok\";\n}\n\nconst TERMINAL_TASK_STATES = new Set<Task[\"state\"]>([\"succeeded\", \"failed\", \"cancelled\"]);\n\n/** Grace period, clock, and optional snapshot for terminal usage settlement. */\nexport interface SweepSettlementsOptions {\n /** How long a terminal task waits for late usage before the sweep settles it. */\n graceMinutes?: number;\n now?: DateClock;\n /** A dispatcher-wide snapshot avoids another complete task read. */\n tasks?: readonly Task[];\n}\n\n/** Task-local charges newly settled by one reconciliation pass. */\nexport interface SweepSettlementsResult {\n readonly settled: readonly { readonly taskId: TaskId; readonly totalUsd: number }[];\n}\n\n/** Task capability required to settle terminal reservations. */\nexport type SettlementSweepStores = Pick<FactoryStores, \"tasks\">;\n\n/**\n * Settle reserved terminal tasks the session hook did not: a task that ended\n * after its session (a change verified later) or whose session died. Settles\n * at the recorded usage, or at zero with a warning reason when none was\n * recorded within the grace window.\n */\nexport async function sweepSettlements(\n stores: SettlementSweepStores,\n options: SweepSettlementsOptions = {},\n): Promise<SweepSettlementsResult> {\n const graceMs = (options.graceMinutes ?? 10) * 60_000;\n const nowMs = (options.now ?? (() => new Date()))().getTime();\n const settled: { taskId: TaskId; totalUsd: number }[] = [];\n for (const task of options.tasks ?? (await stores.tasks.list())) {\n if (task.reservedUsd === undefined || task.settledUsd !== undefined) {\n continue;\n }\n if (!TERMINAL_TASK_STATES.has(task.state)) {\n continue;\n }\n if (nowMs - Date.parse(task.updatedAt) < graceMs) {\n continue;\n }\n // A concurrent writer (operator retry, another sweep) may move the task\n // between the list and the settle; skip it rather than aborting the pass.\n const totalUsd = task.usage?.costUsd ?? 0;\n try {\n const result = await stores.tasks.settle(task.id, {\n cost: { totalUsd },\n ...{\n reason:\n task.usage === undefined\n ? \"usage missing after ten minutes; settled at zero\"\n : \"measured usage settled by sweep\",\n },\n });\n // Report this settlement's amount, not the task's accumulated total.\n settled.push({ taskId: result.id, totalUsd });\n } catch {\n continue;\n }\n }\n return { settled };\n}\n\n/** Owner-only notification emitted when monthly spend crosses a budget threshold. */\nexport interface BudgetNotice {\n outcome: \"budget_warning\" | \"budget_exhausted\";\n month: BudgetMonth;\n spendUsd: number;\n limits: BudgetLimits;\n}\n\n/** Monthly limits, delivery hook, and optional snapshot used for budget reconciliation. */\nexport interface BudgetSweepOptions {\n factoryId: FactoryId;\n limits: BudgetLimits;\n /** Defaults to true. False reports spending without threshold checks or budget notices. */\n enforce?: boolean;\n /** Defaults to the current UTC month. */\n month?: BudgetMonth;\n now?: DateClock;\n /** Owner-only delivery; invoked at most once per month per outcome. */\n notify?: (notice: BudgetNotice) => Promise<void>;\n /** A dispatcher-wide snapshot avoids another complete task read. */\n tasks?: readonly Task[];\n}\n\n/**\n * Derived monthly spend and exact notice outcome produced by budget reconciliation.\n * `stamped` means this sweep recorded the threshold, `already_stamped` means no\n * new stamp was needed, and `delivery_failed` remains eligible for retry.\n */\nexport type BudgetSweepResult =\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"disabled\";\n readonly noticeState: \"not_applicable\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"ok\";\n readonly noticeState: \"not_applicable\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"warning\";\n readonly noticeState: \"stamped\" | \"already_stamped\" | \"delivery_failed\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"exhausted\";\n readonly noticeState: \"stamped\" | \"already_stamped\" | \"delivery_failed\";\n };\n\n/** Budget and Task capabilities required to reconcile monthly budget thresholds. */\nexport type BudgetSweepStores = Pick<FactoryStores, \"budgets\" | \"tasks\">;\n\n/**\n * Ensure the month's budget row, derive spend, and record threshold crossings\n * write-once. Delivery comes first and the stamp only lands after it, so a\n * failed notification retries on the next sweep instead of being lost; the\n * write-once stamp still keeps each threshold to one message per month.\n */\nexport async function sweepBudget(\n stores: BudgetSweepStores,\n options: BudgetSweepOptions,\n): Promise<BudgetSweepResult> {\n const now = options.now ?? (() => new Date());\n const month = options.month ?? budgetMonthOf(now().toISOString());\n const budget = await stores.budgets.ensure({\n factoryId: options.factoryId,\n month,\n limits: options.limits,\n });\n const spendUsd = computeMonthSpendUsd(options.tasks ?? (await stores.tasks.list()), month);\n if (options.enforce === false)\n return { month, spendUsd, status: \"disabled\", noticeState: \"not_applicable\" };\n const status = budgetStatus(spendUsd, options.limits);\n\n async function crossThreshold(\n notice: \"warning\" | \"exhausted\",\n outcome: BudgetNotice[\"outcome\"],\n ): Promise<\"stamped\" | \"already_stamped\" | \"delivery_failed\"> {\n const field = notice === \"warning\" ? \"warningAt\" : \"exhaustedAt\";\n if (budget.notices[field] !== undefined) {\n return \"already_stamped\";\n }\n if (options.notify !== undefined) {\n try {\n await options.notify({ outcome, month, spendUsd, limits: options.limits });\n } catch {\n // Leave the notice unstamped so the next sweep retries delivery.\n return \"delivery_failed\";\n }\n }\n return (await stores.budgets.stampNotice(month, notice)).stamped\n ? \"stamped\"\n : \"already_stamped\";\n }\n\n if (status === \"exhausted\") {\n const noticeState = await crossThreshold(\"exhausted\", \"budget_exhausted\");\n // The warning threshold was necessarily crossed too; stamp it silently so\n // a later settle-down cannot deliver \"warning\" after \"exhausted\".\n if (budget.notices.warningAt === undefined) {\n await stores.budgets.stampNotice(month, \"warning\");\n }\n return { month, spendUsd, status, noticeState };\n }\n if (status === \"warning\") {\n const noticeState = await crossThreshold(\"warning\", \"budget_warning\");\n return { month, spendUsd, status, noticeState };\n }\n return { month, spendUsd, status, noticeState: \"not_applicable\" };\n}\n"],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ /// <reference types="node" />
1
2
  import { FactoryConfigInput } from "./schema/factory-config.mjs";
2
3
  import { EveNextConfig, EveNextConfigFunction, EveNextConfigInput } from "eve/next";
3
4
  //#region src/build-factory.d.ts
@@ -3,9 +3,10 @@ import { AgentRouteBinding } from "../schema/agent-route.mjs";
3
3
  import { Task } from "../schema/task.mjs";
4
4
  import "../agent-routes.mjs";
5
5
  import { FactoryStores } from "../store/engine.mjs";
6
+ import { PresentVerificationResultInput } from "./eve-tool.mjs";
6
7
  //#region src/change-verification/dispatch.d.ts
7
8
  /** Store capabilities required to inspect and create verification Tasks. */
8
- type ChangeVerificationStores = Pick<FactoryStores, "tasks">;
9
+ type ChangeVerificationStores = Pick<FactoryStores, "tasks" | "changes" | "signals">;
9
10
  /** Verification Tasks admitted or found idempotently by one reconciliation pass. */
10
11
  interface EnsureChangeVerificationTasksResult {
11
12
  readonly created: readonly TaskId[];
@@ -16,9 +17,21 @@ interface EnsureChangeVerificationTasksOptions {
16
17
  readonly stores: ChangeVerificationStores;
17
18
  readonly tasks?: readonly Task[];
18
19
  readonly route?: AgentRouteBinding;
20
+ /** Idempotent presentation callback, replayed from the saved outcome after interruption. */
21
+ readonly presentResult?: (input: PresentVerificationResultInput) => Promise<void>;
19
22
  }
20
- /** Supplied code-change policy: admit one verification child per Change and exact verifier binding. */
23
+ /** One verifier snapshot and the workflow adapters needed to recover its saved result. */
24
+ interface RecoverChangeVerificationResultOptions extends Omit<EnsureChangeVerificationTasksOptions, "tasks"> {
25
+ readonly task: Task;
26
+ }
27
+ /**
28
+ * Recover an exact verifier attempt before generic lifecycle policy replaces or escalates it.
29
+ * Returns false when no eligible report exists; storage or presentation errors propagate so callers
30
+ * can defer lifecycle changes and retry the same attempt.
31
+ */
32
+ declare function recoverChangeVerificationResult(options: RecoverChangeVerificationResultOptions): Promise<boolean>;
33
+ /** Admit one verification child per producer attempt and recover its recorded outcome. */
21
34
  declare function ensureChangeVerificationTasks(options: EnsureChangeVerificationTasksOptions): Promise<EnsureChangeVerificationTasksResult>;
22
35
  //#endregion
23
- export { ChangeVerificationStores, EnsureChangeVerificationTasksOptions, EnsureChangeVerificationTasksResult, ensureChangeVerificationTasks };
36
+ export { ChangeVerificationStores, EnsureChangeVerificationTasksOptions, EnsureChangeVerificationTasksResult, RecoverChangeVerificationResultOptions, ensureChangeVerificationTasks, recoverChangeVerificationResult };
24
37
  //# sourceMappingURL=dispatch.d.mts.map
@@ -1,7 +1,36 @@
1
1
  import { parseAgentRouteBinding } from "../schema/agent-route.mjs";
2
2
  import { routeKey, routeTaskWork } from "../agent-routes.mjs";
3
+ import { applyVerificationReport, verificationDelivery, verificationParentAttempt, verificationReportSchema } from "./result.mjs";
3
4
  //#region src/change-verification/dispatch.ts
4
- /** Supplied code-change policy: admit one verification child per Change and exact verifier binding. */
5
+ /**
6
+ * Recover an exact verifier attempt before generic lifecycle policy replaces or escalates it.
7
+ * Returns false when no eligible report exists; storage or presentation errors propagate so callers
8
+ * can defer lifecycle changes and retry the same attempt.
9
+ */
10
+ async function recoverChangeVerificationResult(options) {
11
+ const { stores, task } = options;
12
+ const route = options.route ?? parseAgentRouteBinding({
13
+ id: "verification",
14
+ version: 1
15
+ });
16
+ if (!task.work.route || routeKey(task.work.route) !== routeKey(route) || ![
17
+ "running",
18
+ "succeeded",
19
+ "failed"
20
+ ].includes(task.state)) return false;
21
+ const report = await stores.signals.getByDelivery(verificationDelivery(task));
22
+ if (!report) return false;
23
+ if (!(await applyVerificationReport(stores, report)).recorded) return false;
24
+ const { output } = verificationReportSchema.parse(report.payload);
25
+ await options.presentResult?.({
26
+ taskId: task.id,
27
+ changeId: output.changeId,
28
+ verdict: output.verdict,
29
+ summary: output.summary
30
+ });
31
+ return true;
32
+ }
33
+ /** Admit one verification child per producer attempt and recover its recorded outcome. */
5
34
  async function ensureChangeVerificationTasks(options) {
6
35
  const { stores } = options;
7
36
  const route = options.route ?? parseAgentRouteBinding({
@@ -14,11 +43,17 @@ async function ensureChangeVerificationTasks(options) {
14
43
  created: [],
15
44
  existing: []
16
45
  };
46
+ for (const task of tasks) if (await recoverChangeVerificationResult({
47
+ ...options,
48
+ task
49
+ })) result.existing.push(task.id);
17
50
  const sources = tasks.filter((task) => task.work.route?.id === "code-change" && task.state === "verifying" && task.changeId !== void 0);
18
- for (const source of sources) {
19
- const existing = tasks.find((task) => task.work.route !== void 0 && routeKey(task.work.route) === routeKey(route) && task.parentTaskId === source.id && task.changeId === source.changeId);
51
+ for (const snapshot of sources) {
52
+ const source = await stores.tasks.get(snapshot.id);
53
+ if (!source || source.state !== "verifying" || source.attempt !== snapshot.attempt || source.changeId !== snapshot.changeId) continue;
54
+ const existing = tasks.find((task) => task.work.route !== void 0 && routeKey(task.work.route) === routeKey(route) && task.parentTaskId === source.id && task.changeId === source.changeId && verificationParentAttempt(task) === source.attempt);
20
55
  if (existing !== void 0) {
21
- result.existing.push(existing.id);
56
+ if (!result.existing.includes(existing.id)) result.existing.push(existing.id);
22
57
  continue;
23
58
  }
24
59
  const verification = await stores.tasks.create({
@@ -27,19 +62,22 @@ async function ensureChangeVerificationTasks(options) {
27
62
  work: routeTaskWork({
28
63
  route,
29
64
  title: `Verify change ${source.changeId}`,
30
- input: { changeId: source.changeId }
65
+ input: {
66
+ changeId: source.changeId,
67
+ parentAttempt: source.attempt
68
+ }
31
69
  }),
32
70
  origin: {},
33
71
  parentTaskId: source.id,
34
72
  replyTo: source.replyTo,
35
73
  changeId: source.changeId,
36
- dedupeKey: `verification:${source.id}:${source.changeId}:${routeKey(route)}`
74
+ dedupeKey: `verification:${source.id}:${source.attempt}:${source.changeId}:${routeKey(route)}`
37
75
  });
38
76
  (existingIds.has(verification.id) ? result.existing : result.created).push(verification.id);
39
77
  }
40
78
  return result;
41
79
  }
42
80
  //#endregion
43
- export { ensureChangeVerificationTasks };
81
+ export { ensureChangeVerificationTasks, recoverChangeVerificationResult };
44
82
 
45
83
  //# sourceMappingURL=dispatch.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.mjs","names":[],"sources":["../../src/change-verification/dispatch.ts"],"sourcesContent":["import {\n parseAgentRouteBinding,\n routeKey,\n routeTaskWork,\n type AgentRouteBinding,\n} from \"../agent-routes\";\nimport type { TaskId } from \"../schema/id\";\nimport type { Task } from \"../schema/task\";\nimport type { FactoryStores } from \"../store/engine\";\n\n/** Store capabilities required to inspect and create verification Tasks. */\nexport type ChangeVerificationStores = Pick<FactoryStores, \"tasks\">;\n\n/** Verification Tasks admitted or found idempotently by one reconciliation pass. */\nexport interface EnsureChangeVerificationTasksResult {\n readonly created: readonly TaskId[];\n readonly existing: readonly TaskId[];\n}\n\n/** Stores, optional snapshot, and exact verifier route used by one admission pass. */\nexport interface EnsureChangeVerificationTasksOptions {\n readonly stores: ChangeVerificationStores;\n readonly tasks?: readonly Task[];\n readonly route?: AgentRouteBinding;\n}\n\n/** Supplied code-change policy: admit one verification child per Change and exact verifier binding. */\nexport async function ensureChangeVerificationTasks(\n options: EnsureChangeVerificationTasksOptions,\n): Promise<EnsureChangeVerificationTasksResult> {\n const { stores } = options;\n const route = options.route ?? parseAgentRouteBinding({ id: \"verification\", version: 1 });\n const tasks = options.tasks ?? (await stores.tasks.list());\n const existingIds = new Set(tasks.map((task) => task.id));\n const result: { created: TaskId[]; existing: TaskId[] } = { created: [], existing: [] };\n const sources = tasks.filter(\n (task) =>\n task.work.route?.id === \"code-change\" &&\n task.state === \"verifying\" &&\n task.changeId !== undefined,\n );\n\n for (const source of sources) {\n const existing = tasks.find(\n (task) =>\n task.work.route !== undefined &&\n routeKey(task.work.route) === routeKey(route) &&\n task.parentTaskId === source.id &&\n task.changeId === source.changeId,\n );\n if (existing !== undefined) {\n result.existing.push(existing.id);\n continue;\n }\n const verification = await stores.tasks.create({\n repositoryIds: source.repositoryIds,\n kind: \"verification\",\n work: routeTaskWork({\n route: route,\n title: `Verify change ${source.changeId}`,\n input: {\n changeId: source.changeId!,\n },\n }),\n origin: {},\n parentTaskId: source.id,\n replyTo: source.replyTo,\n changeId: source.changeId,\n dedupeKey: `verification:${source.id}:${source.changeId}:${routeKey(route)}`,\n });\n (existingIds.has(verification.id) ? result.existing : result.created).push(verification.id);\n }\n\n return result;\n}\n"],"mappings":";;;;AA2BA,eAAsB,8BACpB,SAC8C;CAC9C,MAAM,EAAE,WAAW;CACnB,MAAM,QAAQ,QAAQ,SAAS,uBAAuB;EAAE,IAAI;EAAgB,SAAS;CAAE,CAAC;CACxF,MAAM,QAAQ,QAAQ,SAAU,MAAM,OAAO,MAAM,KAAK;CACxD,MAAM,cAAc,IAAI,IAAI,MAAM,KAAK,SAAS,KAAK,EAAE,CAAC;CACxD,MAAM,SAAoD;EAAE,SAAS,CAAC;EAAG,UAAU,CAAC;CAAE;CACtF,MAAM,UAAU,MAAM,QACnB,SACC,KAAK,KAAK,OAAO,OAAO,iBACxB,KAAK,UAAU,eACf,KAAK,aAAa,KAAA,CACtB;CAEA,KAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,WAAW,MAAM,MACpB,SACC,KAAK,KAAK,UAAU,KAAA,KACpB,SAAS,KAAK,KAAK,KAAK,MAAM,SAAS,KAAK,KAC5C,KAAK,iBAAiB,OAAO,MAC7B,KAAK,aAAa,OAAO,QAC7B;EACA,IAAI,aAAa,KAAA,GAAW;GAC1B,OAAO,SAAS,KAAK,SAAS,EAAE;GAChC;EACF;EACA,MAAM,eAAe,MAAM,OAAO,MAAM,OAAO;GAC7C,eAAe,OAAO;GACtB,MAAM;GACN,MAAM,cAAc;IACX;IACP,OAAO,iBAAiB,OAAO;IAC/B,OAAO,EACL,UAAU,OAAO,SACnB;GACF,CAAC;GACD,QAAQ,CAAC;GACT,cAAc,OAAO;GACrB,SAAS,OAAO;GAChB,UAAU,OAAO;GACjB,WAAW,gBAAgB,OAAO,GAAG,GAAG,OAAO,SAAS,GAAG,SAAS,KAAK;EAC3E,CAAC;EACD,CAAC,YAAY,IAAI,aAAa,EAAE,IAAI,OAAO,WAAW,OAAO,QAAA,CAAS,KAAK,aAAa,EAAE;CAC5F;CAEA,OAAO;AACT"}
1
+ {"version":3,"file":"dispatch.mjs","names":[],"sources":["../../src/change-verification/dispatch.ts"],"sourcesContent":["import {\n parseAgentRouteBinding,\n routeKey,\n routeTaskWork,\n type AgentRouteBinding,\n} from \"../agent-routes\";\nimport type { TaskId } from \"../schema/id\";\nimport type { Task } from \"../schema/task\";\nimport type { FactoryStores } from \"../store/engine\";\nimport {\n applyVerificationReport,\n verificationDelivery,\n verificationParentAttempt,\n verificationReportSchema,\n} from \"./result\";\nimport type { PresentVerificationResultInput } from \"./eve-tool\";\n\n/** Store capabilities required to inspect and create verification Tasks. */\nexport type ChangeVerificationStores = Pick<FactoryStores, \"tasks\" | \"changes\" | \"signals\">;\n\n/** Verification Tasks admitted or found idempotently by one reconciliation pass. */\nexport interface EnsureChangeVerificationTasksResult {\n readonly created: readonly TaskId[];\n readonly existing: readonly TaskId[];\n}\n\n/** Stores, optional snapshot, and exact verifier route used by one admission pass. */\nexport interface EnsureChangeVerificationTasksOptions {\n readonly stores: ChangeVerificationStores;\n readonly tasks?: readonly Task[];\n readonly route?: AgentRouteBinding;\n /** Idempotent presentation callback, replayed from the saved outcome after interruption. */\n readonly presentResult?: (input: PresentVerificationResultInput) => Promise<void>;\n}\n\n/** One verifier snapshot and the workflow adapters needed to recover its saved result. */\nexport interface RecoverChangeVerificationResultOptions extends Omit<\n EnsureChangeVerificationTasksOptions,\n \"tasks\"\n> {\n readonly task: Task;\n}\n\n/**\n * Recover an exact verifier attempt before generic lifecycle policy replaces or escalates it.\n * Returns false when no eligible report exists; storage or presentation errors propagate so callers\n * can defer lifecycle changes and retry the same attempt.\n */\nexport async function recoverChangeVerificationResult(\n options: RecoverChangeVerificationResultOptions,\n): Promise<boolean> {\n const { stores, task } = options;\n const route = options.route ?? parseAgentRouteBinding({ id: \"verification\", version: 1 });\n if (\n !task.work.route ||\n routeKey(task.work.route) !== routeKey(route) ||\n ![\"running\", \"succeeded\", \"failed\"].includes(task.state)\n )\n return false;\n const report = await stores.signals.getByDelivery(verificationDelivery(task));\n if (!report) return false;\n const outcome = await applyVerificationReport(stores, report);\n if (!outcome.recorded) return false;\n const { output } = verificationReportSchema.parse(report.payload);\n await options.presentResult?.({\n taskId: task.id,\n changeId: output.changeId,\n verdict: output.verdict,\n summary: output.summary,\n });\n return true;\n}\n\n/** Admit one verification child per producer attempt and recover its recorded outcome. */\nexport async function ensureChangeVerificationTasks(\n options: EnsureChangeVerificationTasksOptions,\n): Promise<EnsureChangeVerificationTasksResult> {\n const { stores } = options;\n const route = options.route ?? parseAgentRouteBinding({ id: \"verification\", version: 1 });\n const tasks = options.tasks ?? (await stores.tasks.list());\n const existingIds = new Set(tasks.map((task) => task.id));\n const result: { created: TaskId[]; existing: TaskId[] } = { created: [], existing: [] };\n for (const task of tasks) {\n if (await recoverChangeVerificationResult({ ...options, task })) result.existing.push(task.id);\n }\n const sources = tasks.filter(\n (task) =>\n task.work.route?.id === \"code-change\" &&\n task.state === \"verifying\" &&\n task.changeId !== undefined,\n );\n\n for (const snapshot of sources) {\n const source = await stores.tasks.get(snapshot.id);\n if (\n !source ||\n source.state !== \"verifying\" ||\n source.attempt !== snapshot.attempt ||\n source.changeId !== snapshot.changeId\n )\n continue;\n const existing = tasks.find(\n (task) =>\n task.work.route !== undefined &&\n routeKey(task.work.route) === routeKey(route) &&\n task.parentTaskId === source.id &&\n task.changeId === source.changeId &&\n verificationParentAttempt(task) === source.attempt,\n );\n if (existing !== undefined) {\n if (!result.existing.includes(existing.id)) result.existing.push(existing.id);\n continue;\n }\n const verification = await stores.tasks.create({\n repositoryIds: source.repositoryIds,\n kind: \"verification\",\n work: routeTaskWork({\n route: route,\n title: `Verify change ${source.changeId}`,\n input: {\n changeId: source.changeId!,\n parentAttempt: source.attempt,\n },\n }),\n origin: {},\n parentTaskId: source.id,\n replyTo: source.replyTo,\n changeId: source.changeId,\n dedupeKey: `verification:${source.id}:${source.attempt}:${source.changeId}:${routeKey(route)}`,\n });\n (existingIds.has(verification.id) ? result.existing : result.created).push(verification.id);\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;AAgDA,eAAsB,gCACpB,SACkB;CAClB,MAAM,EAAE,QAAQ,SAAS;CACzB,MAAM,QAAQ,QAAQ,SAAS,uBAAuB;EAAE,IAAI;EAAgB,SAAS;CAAE,CAAC;CACxF,IACE,CAAC,KAAK,KAAK,SACX,SAAS,KAAK,KAAK,KAAK,MAAM,SAAS,KAAK,KAC5C,CAAC;EAAC;EAAW;EAAa;CAAQ,CAAC,CAAC,SAAS,KAAK,KAAK,GAEvD,OAAO;CACT,MAAM,SAAS,MAAM,OAAO,QAAQ,cAAc,qBAAqB,IAAI,CAAC;CAC5E,IAAI,CAAC,QAAQ,OAAO;CAEpB,IAAI,EAAC,MADiB,wBAAwB,QAAQ,MAAM,EAAA,CAC/C,UAAU,OAAO;CAC9B,MAAM,EAAE,WAAW,yBAAyB,MAAM,OAAO,OAAO;CAChE,MAAM,QAAQ,gBAAgB;EAC5B,QAAQ,KAAK;EACb,UAAU,OAAO;EACjB,SAAS,OAAO;EAChB,SAAS,OAAO;CAClB,CAAC;CACD,OAAO;AACT;;AAGA,eAAsB,8BACpB,SAC8C;CAC9C,MAAM,EAAE,WAAW;CACnB,MAAM,QAAQ,QAAQ,SAAS,uBAAuB;EAAE,IAAI;EAAgB,SAAS;CAAE,CAAC;CACxF,MAAM,QAAQ,QAAQ,SAAU,MAAM,OAAO,MAAM,KAAK;CACxD,MAAM,cAAc,IAAI,IAAI,MAAM,KAAK,SAAS,KAAK,EAAE,CAAC;CACxD,MAAM,SAAoD;EAAE,SAAS,CAAC;EAAG,UAAU,CAAC;CAAE;CACtF,KAAK,MAAM,QAAQ,OACjB,IAAI,MAAM,gCAAgC;EAAE,GAAG;EAAS;CAAK,CAAC,GAAG,OAAO,SAAS,KAAK,KAAK,EAAE;CAE/F,MAAM,UAAU,MAAM,QACnB,SACC,KAAK,KAAK,OAAO,OAAO,iBACxB,KAAK,UAAU,eACf,KAAK,aAAa,KAAA,CACtB;CAEA,KAAK,MAAM,YAAY,SAAS;EAC9B,MAAM,SAAS,MAAM,OAAO,MAAM,IAAI,SAAS,EAAE;EACjD,IACE,CAAC,UACD,OAAO,UAAU,eACjB,OAAO,YAAY,SAAS,WAC5B,OAAO,aAAa,SAAS,UAE7B;EACF,MAAM,WAAW,MAAM,MACpB,SACC,KAAK,KAAK,UAAU,KAAA,KACpB,SAAS,KAAK,KAAK,KAAK,MAAM,SAAS,KAAK,KAC5C,KAAK,iBAAiB,OAAO,MAC7B,KAAK,aAAa,OAAO,YACzB,0BAA0B,IAAI,MAAM,OAAO,OAC/C;EACA,IAAI,aAAa,KAAA,GAAW;GAC1B,IAAI,CAAC,OAAO,SAAS,SAAS,SAAS,EAAE,GAAG,OAAO,SAAS,KAAK,SAAS,EAAE;GAC5E;EACF;EACA,MAAM,eAAe,MAAM,OAAO,MAAM,OAAO;GAC7C,eAAe,OAAO;GACtB,MAAM;GACN,MAAM,cAAc;IACX;IACP,OAAO,iBAAiB,OAAO;IAC/B,OAAO;KACL,UAAU,OAAO;KACjB,eAAe,OAAO;IACxB;GACF,CAAC;GACD,QAAQ,CAAC;GACT,cAAc,OAAO;GACrB,SAAS,OAAO;GAChB,UAAU,OAAO;GACjB,WAAW,gBAAgB,OAAO,GAAG,GAAG,OAAO,QAAQ,GAAG,OAAO,SAAS,GAAG,SAAS,KAAK;EAC7F,CAAC;EACD,CAAC,YAAY,IAAI,aAAa,EAAE,IAAI,OAAO,WAAW,OAAO,QAAA,CAAS,KAAK,aAAa,EAAE;CAC5F;CAEA,OAAO;AACT"}
@@ -4,7 +4,7 @@ import { FactoryStores } from "../store/engine.mjs";
4
4
  import { ToolContext, ToolDefinition } from "eve/tools";
5
5
  //#region src/change-verification/eve-tool.d.ts
6
6
  /** Change and Task capabilities required by the verification-result tool. */
7
- type RecordVerificationToolStores = Pick<FactoryStores, "changes" | "tasks">;
7
+ type RecordVerificationToolStores = Pick<FactoryStores, "changes" | "tasks" | "signals">;
8
8
  /** Verified outcome presented after its Task and Change updates commit. */
9
9
  interface PresentVerificationResultInput {
10
10
  taskId: TaskId;
@@ -14,6 +14,7 @@ interface PresentVerificationResultInput {
14
14
  }
15
15
  /** Configures presentation after a verification verdict has been durably recorded. */
16
16
  interface RecordVerificationToolOptions {
17
+ /** Idempotent delivery, retried with the saved verdict after interrupted completion. */
17
18
  presentResult?: (input: PresentVerificationResultInput) => Promise<void>;
18
19
  }
19
20
  /** Evidence and verdict supplied by the verifier for one Change. */