@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.
- package/CHANGELOG.md +356 -0
- package/README.md +49 -261
- package/dist/agent-routes.d.mts +47 -3
- package/dist/agent-routes.mjs +28 -1
- package/dist/agent-routes.mjs.map +1 -1
- package/dist/api-contracts.d.mts +20 -1
- package/dist/api-contracts.mjs +2 -1
- package/dist/api-contracts.mjs.map +1 -1
- package/dist/api.d.mts +45 -2
- package/dist/api.mjs +199 -10
- package/dist/api.mjs.map +1 -1
- package/dist/approval-contracts.d.mts +6 -0
- package/dist/blob/index.d.mts +51 -14
- package/dist/blob/index.mjs +26 -10
- package/dist/blob/index.mjs.map +1 -1
- package/dist/budget.d.mts +7 -0
- package/dist/budget.mjs +6 -0
- package/dist/budget.mjs.map +1 -1
- package/dist/build-factory.d.mts +1 -0
- package/dist/change-verification/dispatch.d.mts +16 -3
- package/dist/change-verification/dispatch.mjs +45 -7
- package/dist/change-verification/dispatch.mjs.map +1 -1
- package/dist/change-verification/eve-tool.d.mts +2 -1
- package/dist/change-verification/eve-tool.mjs +35 -47
- package/dist/change-verification/eve-tool.mjs.map +1 -1
- package/dist/change-verification/result.mjs +130 -0
- package/dist/change-verification/result.mjs.map +1 -0
- package/dist/changes/eve-record-change.d.mts +2 -2
- package/dist/changes/eve-record-change.mjs +43 -9
- package/dist/changes/eve-record-change.mjs.map +1 -1
- package/dist/changes.d.mts +4 -3
- package/dist/changes.mjs +2 -2
- package/dist/changes.mjs.map +1 -1
- package/dist/client-events.d.mts +10 -3
- package/dist/client-events.mjs +6 -2
- package/dist/client-events.mjs.map +1 -1
- package/dist/client-stream.mjs +8 -2
- package/dist/client-stream.mjs.map +1 -1
- package/dist/client-transcript.mjs +5 -1
- package/dist/client-transcript.mjs.map +1 -1
- package/dist/client.d.mts +121 -12
- package/dist/client.mjs +117 -9
- package/dist/client.mjs.map +1 -1
- package/dist/code-review/contracts.d.mts +1 -0
- package/dist/code-review/eve-post-review.d.mts +4 -4
- package/dist/code-review/eve-post-review.mjs +29 -17
- package/dist/code-review/eve-post-review.mjs.map +1 -1
- package/dist/code-review/eve-review-comments.d.mts +13 -2
- package/dist/code-review/eve-review-comments.mjs +45 -11
- package/dist/code-review/eve-review-comments.mjs.map +1 -1
- package/dist/code-review/github-reporter.d.mts +2 -0
- package/dist/code-review/github-reporter.mjs +7 -5
- package/dist/code-review/github-reporter.mjs.map +1 -1
- package/dist/code-review.d.mts +3 -2
- package/dist/deepsec/eve-tool.mjs +3 -1
- package/dist/deepsec/eve-tool.mjs.map +1 -1
- package/dist/dispatch.d.mts +79 -8
- package/dist/dispatch.mjs +68 -9
- package/dist/dispatch.mjs.map +1 -1
- package/dist/eve/index.d.mts +70 -10
- package/dist/eve/index.mjs +93 -22
- package/dist/eve/index.mjs.map +1 -1
- package/dist/eve/invoke.mjs +24 -11
- package/dist/eve/invoke.mjs.map +1 -1
- package/dist/eve/session-client.d.mts +122 -4
- package/dist/eve/session-client.mjs +127 -13
- package/dist/eve/session-client.mjs.map +1 -1
- package/dist/eve/task-execution.d.mts +380 -0
- package/dist/eve/task-execution.mjs +57 -2
- package/dist/eve/task-execution.mjs.map +1 -1
- package/dist/eve/task-session.d.mts +44 -2
- package/dist/eve/task-session.mjs +44 -2
- package/dist/eve/task-session.mjs.map +1 -1
- package/dist/eve/transcript.mjs +5 -1
- package/dist/eve/transcript.mjs.map +1 -1
- package/dist/execution.d.mts +117 -9
- package/dist/execution.mjs +76 -6
- package/dist/execution.mjs.map +1 -1
- package/dist/finding-remediation/admission.d.mts +2 -0
- package/dist/finding-remediation/admission.mjs +4 -1
- package/dist/finding-remediation/admission.mjs.map +1 -1
- package/dist/findings.d.mts +1 -0
- package/dist/github-publication.d.mts +1 -0
- package/dist/github-publication.mjs +97 -84
- package/dist/github-publication.mjs.map +1 -1
- package/dist/github-transfer.d.mts +15 -6
- package/dist/github-transfer.mjs +214 -66
- package/dist/github-transfer.mjs.map +1 -1
- package/dist/github.d.mts +51 -11
- package/dist/github.mjs +126 -24
- package/dist/github.mjs.map +1 -1
- package/dist/inbox-activity.d.mts +53 -0
- package/dist/inbox-activity.mjs +41 -0
- package/dist/inbox-activity.mjs.map +1 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +3 -2
- package/dist/intake-contracts.d.mts +0 -1
- package/dist/integrations/deepsec.d.mts +1 -0
- package/dist/integrations/github.d.mts +2 -2
- package/dist/integrations/github.mjs +2 -2
- package/dist/integrations/slack.d.mts +3 -1
- package/dist/integrations/slack.mjs +3 -1
- package/dist/integrations/vercel.d.mts +4 -2
- package/dist/integrations/vercel.mjs +3 -2
- package/dist/merge-resolution/eve-tools.d.mts +1 -0
- package/dist/merge-resolution/eve-tools.mjs +7 -2
- package/dist/merge-resolution/eve-tools.mjs.map +1 -1
- package/dist/model-settings.d.mts +41 -0
- package/dist/model-settings.mjs +35 -0
- package/dist/model-settings.mjs.map +1 -0
- package/dist/planning/reconcile.mjs +6 -0
- package/dist/planning/reconcile.mjs.map +1 -1
- package/dist/postgres/index.d.mts +43 -2
- package/dist/postgres/index.mjs +40 -2
- package/dist/postgres/index.mjs.map +1 -1
- package/dist/presets/software-development/dispatch.d.mts +4 -1
- package/dist/presets/software-development/dispatch.mjs +2 -1
- package/dist/presets/software-development/dispatch.mjs.map +1 -1
- package/dist/presets/software-development/task-communication.d.mts +1 -0
- package/dist/presets/software-development/task-communication.mjs +48 -11
- package/dist/presets/software-development/task-communication.mjs.map +1 -1
- package/dist/presets/software-development.d.mts +1 -0
- package/dist/pull-requests/github-publisher.d.mts +15 -1
- package/dist/pull-requests/github-publisher.mjs +61 -1
- package/dist/pull-requests/github-publisher.mjs.map +1 -1
- package/dist/pull-requests.d.mts +1 -0
- package/dist/sandbox/index.d.mts +1 -0
- package/dist/schema/agent-route.d.mts +19 -1
- package/dist/schema/agent-route.mjs +19 -1
- package/dist/schema/agent-route.mjs.map +1 -1
- package/dist/schema/factory-config.d.mts +27 -0
- package/dist/schema/factory-config.mjs +33 -3
- package/dist/schema/factory-config.mjs.map +1 -1
- package/dist/schema/repository.d.mts +4 -0
- package/dist/schema/repository.mjs +5 -1
- package/dist/schema/repository.mjs.map +1 -1
- package/dist/schema/session.d.mts +1 -0
- package/dist/schema/session.mjs +1 -0
- package/dist/schema/session.mjs.map +1 -1
- package/dist/schema/slack-pr-notifications.d.mts +12 -0
- package/dist/schema/slack-pr-notifications.mjs +11 -0
- package/dist/schema/slack-pr-notifications.mjs.map +1 -0
- package/dist/schema/task-graph.d.mts +39 -0
- package/dist/schema/task.d.mts +1 -0
- package/dist/schema/task.mjs +2 -1
- package/dist/schema/task.mjs.map +1 -1
- package/dist/schema/transcript.d.mts +6 -0
- package/dist/schema/transcript.mjs +2 -1
- package/dist/schema/transcript.mjs.map +1 -1
- package/dist/schema/work.d.mts +52 -3
- package/dist/schema/work.mjs.map +1 -1
- package/dist/session-previews.d.mts +76 -0
- package/dist/session-previews.mjs +55 -0
- package/dist/session-previews.mjs.map +1 -0
- package/dist/session-review.d.mts +120 -0
- package/dist/session-review.mjs +79 -0
- package/dist/session-review.mjs.map +1 -0
- package/dist/signal-triage.mjs +1 -1
- package/dist/signals.d.mts +1 -0
- package/dist/stall.d.mts +4 -1
- package/dist/stall.mjs +6 -2
- package/dist/stall.mjs.map +1 -1
- package/dist/store/driver.d.mts +1 -1
- package/dist/store/driver.mjs.map +1 -1
- package/dist/store/engine.d.mts +206 -8
- package/dist/store/engine.mjs +147 -13
- package/dist/store/engine.mjs.map +1 -1
- package/dist/store/memory.d.mts +18 -1
- package/dist/store/memory.mjs +18 -1
- package/dist/store/memory.mjs.map +1 -1
- package/dist/store/slack-pr-notifications.d.mts +44 -0
- package/dist/store/slack-pr-notifications.mjs +121 -0
- package/dist/store/slack-pr-notifications.mjs.map +1 -0
- package/dist/store/task-work.d.mts +121 -6
- package/dist/store/task-work.mjs +7 -4
- package/dist/store/task-work.mjs.map +1 -1
- package/dist/sweep.d.mts +28 -6
- package/dist/sweep.mjs +34 -6
- package/dist/sweep.mjs.map +1 -1
- package/dist/task-graph-view.d.mts +3 -0
- package/dist/tasks.d.mts +3 -3
- package/dist/tasks.mjs +3 -3
- package/dist/vercel-git.d.mts +35 -3
- package/dist/vercel-git.mjs +265 -33
- package/dist/vercel-git.mjs.map +1 -1
- package/dist/vercel-github-api.d.mts +103 -0
- package/dist/vercel-github-api.mjs +363 -0
- package/dist/vercel-github-api.mjs.map +1 -0
- package/dist/vercel.d.mts +3 -2
- package/dist/vercel.mjs +3 -2
- package/dist/vercel.mjs.map +1 -1
- package/dist/work-triage.d.mts +1 -0
- package/dist/workflows.d.mts +102 -4
- package/dist/workflows.mjs +55 -2
- package/dist/workflows.mjs.map +1 -1
- package/dist/workspace-files-git.d.mts +15 -0
- package/dist/workspace-files-git.mjs +61 -0
- package/dist/workspace-files-git.mjs.map +1 -0
- package/dist/workspace-files.d.mts +107 -0
- package/dist/workspace-files.mjs +74 -0
- package/dist/workspace-files.mjs.map +1 -0
- package/docs/getting-started.md +104 -0
- package/docs/index.md +100 -0
- package/docs/recipes/cancellation.md +215 -0
- package/docs/recipes/custom-workflow.md +153 -0
- package/docs/recipes/dependent-tasks.md +207 -0
- package/docs/recipes/eve-agent.md +277 -0
- package/docs/recipes/human-input.md +204 -0
- package/docs/recipes/persistence-recovery.md +268 -0
- package/docs/recipes/retry-recovery.md +241 -0
- package/docs/recipes/task-messaging.md +215 -0
- package/docs/recipes/typed-eve-result.md +161 -0
- package/docs/runtime-integration.md +137 -0
- 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?: {
|
package/dist/blob/index.d.mts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
62
|
+
/** Positive integer attempts for one read or write, including the first. Default 3. */
|
|
37
63
|
attempts?: number;
|
|
38
|
-
/**
|
|
64
|
+
/** Finite non-negative backoff base in milliseconds; doubles each retry. Default 250. */
|
|
39
65
|
baseDelayMs?: number;
|
|
40
|
-
/**
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
package/dist/blob/index.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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();
|
package/dist/blob/index.mjs.map
CHANGED
|
@@ -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":"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"}
|
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";
|
package/dist/budget.mjs.map
CHANGED
|
@@ -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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"}
|
package/dist/build-factory.d.mts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
19
|
-
const
|
|
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: {
|
|
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/**
|
|
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. */
|