@hraness/kb 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,959 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import {
5
+ link,
6
+ lstat,
7
+ open,
8
+ opendir,
9
+ realpath,
10
+ rename,
11
+ unlink,
12
+ } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join, relative, resolve, sep } from "node:path";
15
+
16
+ import { acquireFileLease, type NoteLock } from "../note-lock.js";
17
+ import { redactSensitiveText } from "./persist.js";
18
+ import { sanitizeTerminalLine, sanitizeTerminalText } from "./terminal.js";
19
+
20
+ export const CAPTURE_JOB_SCHEMA_VERSION = 1 as const;
21
+ export const MAX_CAPTURE_JOB_BYTES = 512 * 1024;
22
+ export const MAX_CAPTURE_JOB_ATTEMPTS = 128;
23
+ export const MAX_CAPTURE_JOB_WARNINGS = 128;
24
+
25
+ const MAX_CAPTURE_JOB_RECORDS = 10_000;
26
+ const MAX_CAPTURE_JOB_LIST_LIMIT = 1_000;
27
+ const MAX_REVISION = Number.MAX_SAFE_INTEGER;
28
+ const MAX_TARGET_BYTES = 16 * 1024;
29
+ const MAX_METHOD_BYTES = 256;
30
+ const MAX_MESSAGE_BYTES = 8 * 1024;
31
+ const MAX_WARNING_BYTES = 8 * 1024;
32
+ const MAX_ERROR_BYTES = 16 * 1024;
33
+ const MAX_BUNDLE_PATH_BYTES = 16 * 1024;
34
+ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
35
+ const SHA256 = /^[0-9a-f]{64}$/;
36
+
37
+ export const captureJobPhases = [
38
+ "queued",
39
+ "acquiring",
40
+ "extracting",
41
+ "persisting",
42
+ "finalizing",
43
+ "finished",
44
+ ] as const;
45
+
46
+ export type CaptureJobPhase = (typeof captureJobPhases)[number];
47
+ export type RunningCaptureJobPhase = Exclude<CaptureJobPhase, "finished">;
48
+ export type CaptureJobLifecycle = "running" | "completed" | "failed";
49
+ export type CaptureJobStatus =
50
+ | "complete"
51
+ | "partial"
52
+ | "auth-required"
53
+ | "blocked"
54
+ | "unsupported";
55
+ export type CaptureJobAttemptOutcome = "succeeded" | "failed" | "skipped";
56
+
57
+ export type CaptureJobAttempt = {
58
+ readonly method: string;
59
+ readonly outcome: CaptureJobAttemptOutcome;
60
+ readonly message: string;
61
+ };
62
+
63
+ export type CaptureJobBundle = {
64
+ /** Caller-selected retained bundle path; this module never reads or removes it. */
65
+ readonly path: string;
66
+ /** SHA-256 of the bundle's authoritative Markdown document. */
67
+ readonly sha256: string;
68
+ };
69
+
70
+ export type CaptureJobRecord = {
71
+ readonly schemaVersion: typeof CAPTURE_JOB_SCHEMA_VERSION;
72
+ readonly id: string;
73
+ readonly revision: number;
74
+ readonly lifecycle: CaptureJobLifecycle;
75
+ readonly captureStatus: CaptureJobStatus | null;
76
+ readonly startedAt: string;
77
+ readonly updatedAt: string;
78
+ readonly finishedAt: string | null;
79
+ readonly phase: CaptureJobPhase;
80
+ /** Sanitized target with credential-bearing URL components redacted. */
81
+ readonly target: string;
82
+ readonly attempts: readonly CaptureJobAttempt[];
83
+ readonly warnings: readonly string[];
84
+ readonly bundle: CaptureJobBundle | null;
85
+ readonly error: string | null;
86
+ };
87
+
88
+ const captureJobStoreBrand: unique symbol = Symbol("captureJobStore");
89
+
90
+ export type CaptureJobStore = {
91
+ readonly root: string;
92
+ readonly [captureJobStoreBrand]: {
93
+ readonly device: bigint;
94
+ readonly inode: bigint;
95
+ };
96
+ };
97
+
98
+ export type CreateCaptureJobInput = {
99
+ readonly target: string;
100
+ readonly id?: string;
101
+ readonly at?: Date;
102
+ };
103
+
104
+ export type UpdateCaptureJobInput = {
105
+ readonly expectedRevision: number;
106
+ readonly phase: RunningCaptureJobPhase;
107
+ readonly at?: Date;
108
+ readonly appendAttempts?: readonly CaptureJobAttempt[];
109
+ readonly appendWarnings?: readonly string[];
110
+ };
111
+
112
+ export type CompleteCaptureJobInput = {
113
+ readonly expectedRevision: number;
114
+ readonly status: CaptureJobStatus;
115
+ readonly at?: Date;
116
+ readonly bundle?: CaptureJobBundle | null;
117
+ readonly appendAttempts?: readonly CaptureJobAttempt[];
118
+ readonly appendWarnings?: readonly string[];
119
+ };
120
+
121
+ export type FailCaptureJobInput = {
122
+ readonly expectedRevision: number;
123
+ readonly error: string;
124
+ readonly at?: Date;
125
+ readonly appendAttempts?: readonly CaptureJobAttempt[];
126
+ readonly appendWarnings?: readonly string[];
127
+ };
128
+
129
+ export class CaptureJobSafetyError extends Error {
130
+ constructor(message: string) {
131
+ super(message);
132
+ this.name = "CaptureJobSafetyError";
133
+ }
134
+ }
135
+
136
+ export class CaptureJobNotFoundError extends Error {
137
+ readonly id: string;
138
+
139
+ constructor(id: string) {
140
+ super(`Capture job ${id} does not exist.`);
141
+ this.name = "CaptureJobNotFoundError";
142
+ this.id = id;
143
+ }
144
+ }
145
+
146
+ export class CaptureJobConflictError extends Error {
147
+ readonly id: string;
148
+ readonly expectedRevision: number;
149
+ readonly actualRevision: number;
150
+
151
+ constructor(id: string, expectedRevision: number, actualRevision: number) {
152
+ super(`Capture job ${id} revision changed from ${expectedRevision} to ${actualRevision}.`);
153
+ this.name = "CaptureJobConflictError";
154
+ this.id = id;
155
+ this.expectedRevision = expectedRevision;
156
+ this.actualRevision = actualRevision;
157
+ }
158
+ }
159
+
160
+ type FileIdentity = {
161
+ readonly device: bigint;
162
+ readonly inode: bigint;
163
+ };
164
+
165
+ type CaptureJobSnapshot = {
166
+ readonly record: CaptureJobRecord;
167
+ readonly identity: FileIdentity;
168
+ };
169
+
170
+ class CaptureJobChangedDuringReadError extends Error {}
171
+
172
+ function isErrno(error: unknown, code: string): boolean {
173
+ return typeof error === "object"
174
+ && error !== null
175
+ && "code" in error
176
+ && error.code === code;
177
+ }
178
+
179
+ function isRecord(value: unknown): value is Record<string, unknown> {
180
+ return typeof value === "object" && value !== null && !Array.isArray(value);
181
+ }
182
+
183
+ function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
184
+ const actual = Object.keys(value).sort();
185
+ const sortedExpected = [...expected].sort();
186
+ return actual.length === sortedExpected.length
187
+ && actual.every((key, index) => key === sortedExpected[index]);
188
+ }
189
+
190
+ function utf8Bytes(value: string): number {
191
+ return Buffer.byteLength(value, "utf8");
192
+ }
193
+
194
+ function normalizedText(
195
+ value: unknown,
196
+ label: string,
197
+ maxBytes: number,
198
+ line: boolean,
199
+ ): string {
200
+ if (typeof value !== "string") throw new Error(`${label} must be a string.`);
201
+ if (utf8Bytes(value) > maxBytes) {
202
+ throw new Error(`${label} exceeds the ${maxBytes}-byte input limit.`);
203
+ }
204
+ const sanitized = (line ? sanitizeTerminalLine : sanitizeTerminalText)(redactSensitiveText(value)).trim();
205
+ if (sanitized === "") throw new Error(`${label} must not be empty.`);
206
+ if (utf8Bytes(sanitized) > maxBytes) {
207
+ throw new Error(`${label} exceeds the ${maxBytes}-byte limit.`);
208
+ }
209
+ return sanitized;
210
+ }
211
+
212
+ function storedText(
213
+ value: unknown,
214
+ label: string,
215
+ maxBytes: number,
216
+ line: boolean,
217
+ ): string {
218
+ const normalized = normalizedText(value, label, maxBytes, line);
219
+ if (normalized !== value) {
220
+ throw new CaptureJobSafetyError(`${label} is not in sanitized canonical form.`);
221
+ }
222
+ return normalized;
223
+ }
224
+
225
+ function captureJobId(value: unknown): string {
226
+ if (typeof value !== "string" || !UUID_V4.test(value)) {
227
+ throw new Error("Capture job id must be a canonical lowercase UUID v4.");
228
+ }
229
+ return value;
230
+ }
231
+
232
+ function revision(value: unknown, label = "revision"): number {
233
+ if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > MAX_REVISION) {
234
+ throw new Error(`${label} must be a positive safe integer.`);
235
+ }
236
+ return value as number;
237
+ }
238
+
239
+ function timestamp(value: unknown, label: string): string {
240
+ if (typeof value !== "string" || value.length !== 24) {
241
+ throw new Error(`${label} must be a canonical ISO timestamp.`);
242
+ }
243
+ const parsed = new Date(value);
244
+ if (!Number.isFinite(parsed.valueOf()) || parsed.toISOString() !== value) {
245
+ throw new Error(`${label} must be a canonical ISO timestamp.`);
246
+ }
247
+ return value;
248
+ }
249
+
250
+ function inputTimestamp(value: Date | undefined): string {
251
+ const selected = value ?? new Date();
252
+ if (!(selected instanceof Date) || !Number.isFinite(selected.valueOf())) {
253
+ throw new Error("Capture job timestamp must be a valid Date.");
254
+ }
255
+ return selected.toISOString();
256
+ }
257
+
258
+ function phase(value: unknown): CaptureJobPhase {
259
+ if (typeof value !== "string" || !(captureJobPhases as readonly string[]).includes(value)) {
260
+ throw new Error("Capture job phase is invalid.");
261
+ }
262
+ return value as CaptureJobPhase;
263
+ }
264
+
265
+ function lifecycle(value: unknown): CaptureJobLifecycle {
266
+ if (value !== "running" && value !== "completed" && value !== "failed") {
267
+ throw new Error("Capture job lifecycle is invalid.");
268
+ }
269
+ return value;
270
+ }
271
+
272
+ function captureStatus(value: unknown): CaptureJobStatus {
273
+ if (
274
+ value !== "complete"
275
+ && value !== "partial"
276
+ && value !== "auth-required"
277
+ && value !== "blocked"
278
+ && value !== "unsupported"
279
+ ) {
280
+ throw new Error("Capture job status is invalid.");
281
+ }
282
+ return value;
283
+ }
284
+
285
+ function attemptOutcome(value: unknown): CaptureJobAttemptOutcome {
286
+ if (value !== "succeeded" && value !== "failed" && value !== "skipped") {
287
+ throw new Error("Capture job attempt outcome is invalid.");
288
+ }
289
+ return value;
290
+ }
291
+
292
+ function normalizeAttempt(value: unknown, label: string, stored: boolean): CaptureJobAttempt {
293
+ if (!isRecord(value) || !hasExactKeys(value, ["method", "outcome", "message"])) {
294
+ throw new Error(`${label} must contain only method, outcome, and message.`);
295
+ }
296
+ const text = stored ? storedText : normalizedText;
297
+ return {
298
+ method: text(value.method, `${label}.method`, MAX_METHOD_BYTES, true),
299
+ outcome: attemptOutcome(value.outcome),
300
+ message: text(value.message, `${label}.message`, MAX_MESSAGE_BYTES, false),
301
+ };
302
+ }
303
+
304
+ function normalizeAttempts(value: unknown, stored: boolean): readonly CaptureJobAttempt[] {
305
+ if (!Array.isArray(value)) throw new Error("Capture job attempts must be an array.");
306
+ if (value.length > MAX_CAPTURE_JOB_ATTEMPTS) {
307
+ throw new Error(`Capture job attempts exceed the ${MAX_CAPTURE_JOB_ATTEMPTS}-item limit.`);
308
+ }
309
+ return value.map((item, index) => normalizeAttempt(item, `attempts[${index}]`, stored));
310
+ }
311
+
312
+ function normalizeWarnings(value: unknown, stored: boolean): readonly string[] {
313
+ if (!Array.isArray(value)) throw new Error("Capture job warnings must be an array.");
314
+ if (value.length > MAX_CAPTURE_JOB_WARNINGS) {
315
+ throw new Error(`Capture job warnings exceed the ${MAX_CAPTURE_JOB_WARNINGS}-item limit.`);
316
+ }
317
+ const text = stored ? storedText : normalizedText;
318
+ return value.map((item, index) => text(item, `warnings[${index}]`, MAX_WARNING_BYTES, false));
319
+ }
320
+
321
+ function normalizeBundle(value: unknown, stored: boolean): CaptureJobBundle {
322
+ if (!isRecord(value) || !hasExactKeys(value, ["path", "sha256"])) {
323
+ throw new Error("Capture job bundle must contain only path and sha256.");
324
+ }
325
+ const text = stored ? storedText : normalizedText;
326
+ const digest = typeof value.sha256 === "string" ? value.sha256 : "";
327
+ if (!SHA256.test(digest)) throw new Error("Capture job bundle sha256 must be a lowercase SHA-256 digest.");
328
+ return {
329
+ path: text(value.path, "bundle.path", MAX_BUNDLE_PATH_BYTES, true),
330
+ sha256: digest,
331
+ };
332
+ }
333
+
334
+ function parseCaptureJob(value: unknown): CaptureJobRecord {
335
+ const keys = [
336
+ "schemaVersion",
337
+ "id",
338
+ "revision",
339
+ "lifecycle",
340
+ "captureStatus",
341
+ "startedAt",
342
+ "updatedAt",
343
+ "finishedAt",
344
+ "phase",
345
+ "target",
346
+ "attempts",
347
+ "warnings",
348
+ "bundle",
349
+ "error",
350
+ ] as const;
351
+ if (!isRecord(value) || !hasExactKeys(value, keys)) {
352
+ throw new Error("Capture job record has an invalid schema.");
353
+ }
354
+ if (value.schemaVersion !== CAPTURE_JOB_SCHEMA_VERSION) {
355
+ throw new Error(`Unsupported capture job schema version ${String(value.schemaVersion)}.`);
356
+ }
357
+
358
+ const parsedLifecycle = lifecycle(value.lifecycle);
359
+ const parsedPhase = phase(value.phase);
360
+ const startedAt = timestamp(value.startedAt, "startedAt");
361
+ const updatedAt = timestamp(value.updatedAt, "updatedAt");
362
+ const finishedAt = value.finishedAt === null ? null : timestamp(value.finishedAt, "finishedAt");
363
+ const parsedStatus = value.captureStatus === null ? null : captureStatus(value.captureStatus);
364
+ const bundle = value.bundle === null ? null : normalizeBundle(value.bundle, true);
365
+ const error = value.error === null
366
+ ? null
367
+ : storedText(value.error, "error", MAX_ERROR_BYTES, false);
368
+
369
+ if (updatedAt < startedAt) throw new Error("updatedAt must not precede startedAt.");
370
+ if (finishedAt !== null && (finishedAt !== updatedAt || finishedAt < startedAt)) {
371
+ throw new Error("finishedAt must equal the terminal updatedAt timestamp.");
372
+ }
373
+ if (parsedLifecycle === "running") {
374
+ if (parsedPhase === "finished" || parsedStatus !== null || finishedAt !== null || bundle !== null || error !== null) {
375
+ throw new Error("Running capture job state is inconsistent.");
376
+ }
377
+ } else if (parsedLifecycle === "completed") {
378
+ if (parsedPhase !== "finished" || parsedStatus === null || finishedAt === null || error !== null) {
379
+ throw new Error("Completed capture job state is inconsistent.");
380
+ }
381
+ } else if (parsedPhase !== "finished" || parsedStatus !== null || finishedAt === null || bundle !== null || error === null) {
382
+ throw new Error("Failed capture job state is inconsistent.");
383
+ }
384
+
385
+ return {
386
+ schemaVersion: CAPTURE_JOB_SCHEMA_VERSION,
387
+ id: captureJobId(value.id),
388
+ revision: revision(value.revision),
389
+ lifecycle: parsedLifecycle,
390
+ captureStatus: parsedStatus,
391
+ startedAt,
392
+ updatedAt,
393
+ finishedAt,
394
+ phase: parsedPhase,
395
+ target: storedText(value.target, "target", MAX_TARGET_BYTES, true),
396
+ attempts: normalizeAttempts(value.attempts, true),
397
+ warnings: normalizeWarnings(value.warnings, true),
398
+ bundle,
399
+ error,
400
+ };
401
+ }
402
+
403
+ function renderCaptureJob(record: CaptureJobRecord): string {
404
+ return `${JSON.stringify(record, null, 2)}\n`;
405
+ }
406
+
407
+ function sameIdentity(
408
+ left: { readonly dev: bigint; readonly ino: bigint },
409
+ right: FileIdentity,
410
+ ): boolean {
411
+ return left.dev === right.device && left.ino === right.inode;
412
+ }
413
+
414
+ function within(root: string, candidate: string): boolean {
415
+ const fromRoot = relative(root, candidate);
416
+ return fromRoot !== ""
417
+ && fromRoot !== ".."
418
+ && !fromRoot.startsWith(`..${sep}`)
419
+ && !fromRoot.includes(sep);
420
+ }
421
+
422
+ function pathFor(store: CaptureJobStore, id: string): string {
423
+ const safeId = captureJobId(id);
424
+ const candidate = join(store.root, `${safeId}.json`);
425
+ if (!within(store.root, candidate)) throw new CaptureJobSafetyError("Capture job path escapes its store.");
426
+ return candidate;
427
+ }
428
+
429
+ async function directoryIdentity(path: string): Promise<FileIdentity> {
430
+ const before = await lstat(path, { bigint: true });
431
+ if (!before.isDirectory() || before.isSymbolicLink()) {
432
+ throw new CaptureJobSafetyError("Capture job store must be a real directory, not a link.");
433
+ }
434
+ if ((Number(before.mode) & 0o077) !== 0) {
435
+ throw new CaptureJobSafetyError("Capture job store must not grant group or world access.");
436
+ }
437
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
438
+ try {
439
+ const opened = await handle.stat({ bigint: true });
440
+ if (
441
+ !opened.isDirectory()
442
+ || (Number(opened.mode) & 0o077) !== 0
443
+ || !sameIdentity(opened, { device: before.dev, inode: before.ino })
444
+ ) {
445
+ throw new CaptureJobSafetyError("Capture job store changed while it was opened.");
446
+ }
447
+ return { device: opened.dev, inode: opened.ino };
448
+ } finally {
449
+ await handle.close();
450
+ }
451
+ }
452
+
453
+ /** Open an existing, explicitly selected private directory as a capture-job store. */
454
+ export async function openCaptureJobStore(root: string): Promise<CaptureJobStore> {
455
+ if (typeof root !== "string" || root.trim() === "") {
456
+ throw new Error("Capture job store path must not be empty.");
457
+ }
458
+ const selected = resolve(root);
459
+ if (selected === dirname(selected) || selected === resolve(homedir())) {
460
+ throw new CaptureJobSafetyError("Capture job store must be a dedicated subdirectory.");
461
+ }
462
+ let canonical: string;
463
+ try {
464
+ canonical = await realpath(selected);
465
+ } catch (error) {
466
+ if (isErrno(error, "ENOENT")) {
467
+ throw new CaptureJobSafetyError("Capture job store must already exist.");
468
+ }
469
+ throw error;
470
+ }
471
+ if (canonical !== selected) {
472
+ throw new CaptureJobSafetyError("Capture job store path must not contain filesystem aliases or links.");
473
+ }
474
+ const identity = await directoryIdentity(canonical);
475
+ return {
476
+ root: canonical,
477
+ [captureJobStoreBrand]: identity,
478
+ };
479
+ }
480
+
481
+ async function assertStore(store: CaptureJobStore): Promise<void> {
482
+ if (!isRecord(store) || typeof store.root !== "string" || !(captureJobStoreBrand in store)) {
483
+ throw new CaptureJobSafetyError("Capture job store was not opened by openCaptureJobStore().");
484
+ }
485
+ const expected = store[captureJobStoreBrand];
486
+ const canonical = await realpath(store.root).catch(() => "");
487
+ if (canonical !== store.root) throw new CaptureJobSafetyError("Capture job store path changed or became aliased.");
488
+ const actual = await directoryIdentity(store.root);
489
+ if (actual.device !== expected.device || actual.inode !== expected.inode) {
490
+ throw new CaptureJobSafetyError("Capture job store was replaced after it was opened.");
491
+ }
492
+ }
493
+
494
+ async function fsyncStore(store: CaptureJobStore): Promise<void> {
495
+ await assertStore(store);
496
+ const handle = await open(store.root, constants.O_RDONLY | constants.O_NOFOLLOW);
497
+ try {
498
+ const stat = await handle.stat({ bigint: true });
499
+ const expected = store[captureJobStoreBrand];
500
+ if (!stat.isDirectory() || !sameIdentity(stat, expected)) {
501
+ throw new CaptureJobSafetyError("Capture job store changed before synchronization.");
502
+ }
503
+ await handle.sync();
504
+ } finally {
505
+ await handle.close();
506
+ }
507
+ }
508
+
509
+ function assertPrivateRegularFile(
510
+ stat: Awaited<ReturnType<typeof lstat>>,
511
+ label: string,
512
+ ): void {
513
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) {
514
+ throw new CaptureJobSafetyError(`${label} must be a single-link regular file.`);
515
+ }
516
+ if ((Number(stat.mode) & 0o077) !== 0) {
517
+ throw new CaptureJobSafetyError(`${label} must not grant group or world access.`);
518
+ }
519
+ if (stat.size < 1n || stat.size > BigInt(MAX_CAPTURE_JOB_BYTES)) {
520
+ throw new CaptureJobSafetyError(`${label} exceeds the bounded record size.`);
521
+ }
522
+ }
523
+
524
+ async function readSnapshotOnce(store: CaptureJobStore, id: string): Promise<CaptureJobSnapshot> {
525
+ await assertStore(store);
526
+ const path = pathFor(store, id);
527
+ let before: Awaited<ReturnType<typeof lstat>>;
528
+ try {
529
+ before = await lstat(path, { bigint: true });
530
+ } catch (error) {
531
+ if (isErrno(error, "ENOENT")) throw new CaptureJobNotFoundError(id);
532
+ throw error;
533
+ }
534
+ assertPrivateRegularFile(before, "Capture job record");
535
+ const identity = { device: before.dev, inode: before.ino };
536
+ let handle;
537
+ try {
538
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
539
+ } catch (error) {
540
+ if (isErrno(error, "ENOENT")) throw new CaptureJobChangedDuringReadError();
541
+ throw new CaptureJobSafetyError(`Capture job record could not be opened safely: ${String(error)}`);
542
+ }
543
+ let bytes: Uint8Array;
544
+ try {
545
+ const opened = await handle.stat({ bigint: true });
546
+ assertPrivateRegularFile(opened, "Opened capture job record");
547
+ if (!sameIdentity(opened, identity)) throw new CaptureJobChangedDuringReadError();
548
+ bytes = await handle.readFile();
549
+ const complete = await handle.stat({ bigint: true });
550
+ if (!sameIdentity(complete, identity) || complete.size !== BigInt(bytes.byteLength)) {
551
+ throw new CaptureJobChangedDuringReadError();
552
+ }
553
+ } finally {
554
+ await handle.close();
555
+ }
556
+ if (bytes.byteLength < 1 || bytes.byteLength > MAX_CAPTURE_JOB_BYTES) {
557
+ throw new CaptureJobSafetyError("Capture job record exceeds the bounded record size.");
558
+ }
559
+ const after = await lstat(path, { bigint: true }).catch((error: unknown) => {
560
+ if (isErrno(error, "ENOENT")) return null;
561
+ throw error;
562
+ });
563
+ if (after === null || !sameIdentity(after, identity)) throw new CaptureJobChangedDuringReadError();
564
+ assertPrivateRegularFile(after, "Capture job record");
565
+ await assertStore(store);
566
+
567
+ let text: string;
568
+ try {
569
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
570
+ } catch {
571
+ throw new CaptureJobSafetyError("Capture job record is not valid UTF-8.");
572
+ }
573
+ let raw: unknown;
574
+ try {
575
+ raw = JSON.parse(text) as unknown;
576
+ } catch {
577
+ throw new CaptureJobSafetyError("Capture job record is not valid JSON.");
578
+ }
579
+ const record = parseCaptureJob(raw);
580
+ if (record.id !== id) throw new CaptureJobSafetyError("Capture job id does not match its filename.");
581
+ if (renderCaptureJob(record) !== text) {
582
+ throw new CaptureJobSafetyError("Capture job record is not canonical JSON.");
583
+ }
584
+ return { record, identity };
585
+ }
586
+
587
+ async function readSnapshot(store: CaptureJobStore, id: string): Promise<CaptureJobSnapshot> {
588
+ captureJobId(id);
589
+ for (let attempt = 0; attempt < 3; attempt += 1) {
590
+ try {
591
+ return await readSnapshotOnce(store, id);
592
+ } catch (error) {
593
+ if (!(error instanceof CaptureJobChangedDuringReadError) || attempt === 2) throw error;
594
+ }
595
+ }
596
+ throw new CaptureJobChangedDuringReadError();
597
+ }
598
+
599
+ async function writeTemporary(
600
+ store: CaptureJobStore,
601
+ record: CaptureJobRecord,
602
+ ): Promise<{ readonly path: string; readonly identity: FileIdentity }> {
603
+ const text = renderCaptureJob(record);
604
+ if (utf8Bytes(text) > MAX_CAPTURE_JOB_BYTES) {
605
+ throw new Error(`Capture job record exceeds the ${MAX_CAPTURE_JOB_BYTES}-byte limit.`);
606
+ }
607
+ await assertStore(store);
608
+ const temporaryPath = join(store.root, `.${record.id}.${randomUUID()}.tmp`);
609
+ if (!within(store.root, temporaryPath)) throw new CaptureJobSafetyError("Temporary capture job path escapes its store.");
610
+ const handle = await open(
611
+ temporaryPath,
612
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
613
+ 0o600,
614
+ );
615
+ let closed = false;
616
+ let identity: FileIdentity | null = null;
617
+ try {
618
+ const created = await handle.stat({ bigint: true });
619
+ if (!created.isFile() || created.nlink !== 1n || (Number(created.mode) & 0o077) !== 0) {
620
+ throw new CaptureJobSafetyError("Temporary capture job record is not a private regular file.");
621
+ }
622
+ identity = { device: created.dev, inode: created.ino };
623
+ await handle.writeFile(text, "utf8");
624
+ await handle.sync();
625
+ const complete = await handle.stat({ bigint: true });
626
+ if (!complete.isFile() || complete.nlink !== 1n || !sameIdentity(complete, identity)) {
627
+ throw new CaptureJobSafetyError("Temporary capture job record changed before installation.");
628
+ }
629
+ await handle.close();
630
+ closed = true;
631
+ await assertStore(store);
632
+ const named = await lstat(temporaryPath, { bigint: true });
633
+ if (!sameIdentity(named, identity) || named.nlink !== 1n || !named.isFile()) {
634
+ throw new CaptureJobSafetyError("Temporary capture job name changed before installation.");
635
+ }
636
+ return { path: temporaryPath, identity };
637
+ } catch (error) {
638
+ if (!closed) await handle.close().catch(() => undefined);
639
+ if (identity !== null) {
640
+ await unlinkOwnedTemporary(store, temporaryPath, identity).catch(() => undefined);
641
+ }
642
+ throw error;
643
+ }
644
+ }
645
+
646
+ async function unlinkOwnedTemporary(
647
+ store: CaptureJobStore,
648
+ path: string,
649
+ identity: FileIdentity,
650
+ ): Promise<void> {
651
+ await assertStore(store);
652
+ const current = await lstat(path, { bigint: true }).catch((error: unknown) => {
653
+ if (isErrno(error, "ENOENT")) return null;
654
+ throw error;
655
+ });
656
+ if (current !== null && current.isFile() && current.nlink === 1n && sameIdentity(current, identity)) {
657
+ await unlink(path);
658
+ }
659
+ }
660
+
661
+ async function assertCurrentSnapshot(
662
+ store: CaptureJobStore,
663
+ id: string,
664
+ expected: CaptureJobSnapshot,
665
+ ): Promise<void> {
666
+ const actual = await readSnapshot(store, id);
667
+ if (actual.record.revision !== expected.record.revision || actual.identity.device !== expected.identity.device || actual.identity.inode !== expected.identity.inode) {
668
+ throw new CaptureJobConflictError(id, expected.record.revision, actual.record.revision);
669
+ }
670
+ }
671
+
672
+ async function installCreatedRecord(
673
+ store: CaptureJobStore,
674
+ record: CaptureJobRecord,
675
+ lease: NoteLock,
676
+ ): Promise<void> {
677
+ const temporary = await writeTemporary(store, record);
678
+ const path = pathFor(store, record.id);
679
+ let temporaryExists = true;
680
+ try {
681
+ await assertStore(store);
682
+ await lease.assertOwned();
683
+ try {
684
+ await link(temporary.path, path);
685
+ } catch (error) {
686
+ if (isErrno(error, "EEXIST")) throw new CaptureJobConflictError(record.id, 0, (await readSnapshot(store, record.id)).record.revision);
687
+ throw error;
688
+ }
689
+ await unlink(temporary.path);
690
+ temporaryExists = false;
691
+ const installed = await readSnapshot(store, record.id);
692
+ if (installed.record.revision !== record.revision || installed.record.id !== record.id) {
693
+ throw new CaptureJobSafetyError("Installed capture job record does not match the requested record.");
694
+ }
695
+ if (renderCaptureJob(installed.record) !== renderCaptureJob(record)) {
696
+ throw new CaptureJobSafetyError("Installed capture job record differs from the requested record.");
697
+ }
698
+ await fsyncStore(store);
699
+ } finally {
700
+ if (temporaryExists) await unlinkOwnedTemporary(store, temporary.path, temporary.identity).catch(() => undefined);
701
+ }
702
+ }
703
+
704
+ async function installUpdatedRecord(
705
+ store: CaptureJobStore,
706
+ previous: CaptureJobSnapshot,
707
+ record: CaptureJobRecord,
708
+ lease: NoteLock,
709
+ ): Promise<void> {
710
+ const temporary = await writeTemporary(store, record);
711
+ const path = pathFor(store, record.id);
712
+ let temporaryExists = true;
713
+ try {
714
+ await assertCurrentSnapshot(store, record.id, previous);
715
+ await assertStore(store);
716
+ await lease.assertOwned();
717
+ await rename(temporary.path, path);
718
+ temporaryExists = false;
719
+ const installed = await readSnapshot(store, record.id);
720
+ if (
721
+ installed.record.revision !== record.revision
722
+ || renderCaptureJob(installed.record) !== renderCaptureJob(record)
723
+ ) {
724
+ throw new CaptureJobSafetyError("Capture job update was not installed atomically.");
725
+ }
726
+ await fsyncStore(store);
727
+ } finally {
728
+ if (temporaryExists) await unlinkOwnedTemporary(store, temporary.path, temporary.identity).catch(() => undefined);
729
+ }
730
+ }
731
+
732
+ const mutationTails = new Map<string, Promise<void>>();
733
+
734
+ async function withMutation<T>(
735
+ store: CaptureJobStore,
736
+ id: string,
737
+ action: (lease: NoteLock) => Promise<T>,
738
+ ): Promise<T> {
739
+ const key = `${store.root}\u0000${id}`;
740
+ const predecessor = mutationTails.get(key) ?? Promise.resolve();
741
+ let release = (): void => undefined;
742
+ const gate = new Promise<void>((resolveGate) => {
743
+ release = resolveGate;
744
+ });
745
+ const tail = predecessor.catch(() => undefined).then(() => gate);
746
+ mutationTails.set(key, tail);
747
+ await predecessor.catch(() => undefined);
748
+ let lease: NoteLock | undefined;
749
+ try {
750
+ await assertStore(store);
751
+ lease = await acquireFileLease(join(store.root, `.${captureJobId(id)}.lock`));
752
+ await assertStore(store);
753
+ await lease.assertOwned();
754
+ const result = await action(lease);
755
+ // The installer verifies and fsyncs its committed record. A lease probe
756
+ // after that point could only turn a successful revision into an ambiguous
757
+ // failure acknowledgement; ownership is checked immediately before install.
758
+ return result;
759
+ } finally {
760
+ await lease?.release().catch(() => undefined);
761
+ release();
762
+ if (mutationTails.get(key) === tail) mutationTails.delete(key);
763
+ }
764
+ }
765
+
766
+ function ensureRunning(record: CaptureJobRecord): void {
767
+ if (record.lifecycle !== "running") {
768
+ throw new Error(`Capture job ${record.id} is terminal and cannot transition again.`);
769
+ }
770
+ if (record.revision >= MAX_REVISION) throw new Error("Capture job revision limit reached.");
771
+ }
772
+
773
+ function ensureExpectedRevision(record: CaptureJobRecord, expected: number): void {
774
+ const normalized = revision(expected, "expectedRevision");
775
+ if (record.revision !== normalized) {
776
+ throw new CaptureJobConflictError(record.id, normalized, record.revision);
777
+ }
778
+ }
779
+
780
+ function nextTimestamp(record: CaptureJobRecord, selected: Date | undefined): string {
781
+ const next = inputTimestamp(selected);
782
+ if (next < record.updatedAt) throw new Error("Capture job timestamp must not move backwards.");
783
+ return next;
784
+ }
785
+
786
+ function appendAttempts(
787
+ current: readonly CaptureJobAttempt[],
788
+ appended: readonly CaptureJobAttempt[] | undefined,
789
+ ): readonly CaptureJobAttempt[] {
790
+ const next = [...current, ...normalizeAttempts(appended ?? [], false)];
791
+ if (next.length > MAX_CAPTURE_JOB_ATTEMPTS) {
792
+ throw new Error(`Capture job attempts exceed the ${MAX_CAPTURE_JOB_ATTEMPTS}-item limit.`);
793
+ }
794
+ return next;
795
+ }
796
+
797
+ function appendWarnings(current: readonly string[], appended: readonly string[] | undefined): readonly string[] {
798
+ const next = [...current, ...normalizeWarnings(appended ?? [], false)];
799
+ if (next.length > MAX_CAPTURE_JOB_WARNINGS) {
800
+ throw new Error(`Capture job warnings exceed the ${MAX_CAPTURE_JOB_WARNINGS}-item limit.`);
801
+ }
802
+ return next;
803
+ }
804
+
805
+ /** Create and durably install a running capture job. No default store is consulted. */
806
+ export async function createCaptureJob(
807
+ store: CaptureJobStore,
808
+ input: CreateCaptureJobInput,
809
+ ): Promise<CaptureJobRecord> {
810
+ await assertStore(store);
811
+ const id = captureJobId(input.id ?? randomUUID());
812
+ return withMutation(store, id, async (lease) => {
813
+ const at = inputTimestamp(input.at);
814
+ const record: CaptureJobRecord = {
815
+ schemaVersion: CAPTURE_JOB_SCHEMA_VERSION,
816
+ id,
817
+ revision: 1,
818
+ lifecycle: "running",
819
+ captureStatus: null,
820
+ startedAt: at,
821
+ updatedAt: at,
822
+ finishedAt: null,
823
+ phase: "queued",
824
+ target: normalizedText(input.target, "target", MAX_TARGET_BYTES, true),
825
+ attempts: [],
826
+ warnings: [],
827
+ bundle: null,
828
+ error: null,
829
+ };
830
+ await installCreatedRecord(store, record, lease);
831
+ return record;
832
+ });
833
+ }
834
+
835
+ /** Read one bounded record without following a link or accepting a hard link. */
836
+ export async function readCaptureJob(store: CaptureJobStore, id: string): Promise<CaptureJobRecord> {
837
+ return (await readSnapshot(store, id)).record;
838
+ }
839
+
840
+ /** List retained jobs without deleting or pruning any record. */
841
+ export async function listCaptureJobs(
842
+ store: CaptureJobStore,
843
+ options: { readonly limit?: number } = {},
844
+ ): Promise<readonly CaptureJobRecord[]> {
845
+ await assertStore(store);
846
+ const limit = options.limit ?? 100;
847
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_CAPTURE_JOB_LIST_LIMIT) {
848
+ throw new Error(`Capture job list limit must be between 1 and ${MAX_CAPTURE_JOB_LIST_LIMIT}.`);
849
+ }
850
+ const ids: string[] = [];
851
+ let entries = 0;
852
+ const directory = await opendir(store.root);
853
+ // The async iterator closes the directory both on exhaustion and on early exit.
854
+ for await (const entry of directory) {
855
+ entries += 1;
856
+ if (entries > MAX_CAPTURE_JOB_RECORDS) {
857
+ throw new Error(`Capture job store exceeds the ${MAX_CAPTURE_JOB_RECORDS}-entry scan limit.`);
858
+ }
859
+ if (!entry.name.endsWith(".json")) continue;
860
+ const id = entry.name.slice(0, -5);
861
+ if (UUID_V4.test(id)) ids.push(id);
862
+ }
863
+ await assertStore(store);
864
+ const records: CaptureJobRecord[] = [];
865
+ for (const id of ids) {
866
+ records.push(await readCaptureJob(store, id));
867
+ records.sort((left, right) => right.startedAt.localeCompare(left.startedAt) || left.id.localeCompare(right.id));
868
+ if (records.length > limit) records.pop();
869
+ }
870
+ records.sort((left, right) => right.startedAt.localeCompare(left.startedAt) || left.id.localeCompare(right.id));
871
+ return records;
872
+ }
873
+
874
+ /** Advance a running job and append bounded attempts/warnings. Phases cannot regress. */
875
+ export async function updateCaptureJob(
876
+ store: CaptureJobStore,
877
+ id: string,
878
+ input: UpdateCaptureJobInput,
879
+ ): Promise<CaptureJobRecord> {
880
+ captureJobId(id);
881
+ return withMutation(store, id, async (lease) => {
882
+ const previous = await readSnapshot(store, id);
883
+ ensureRunning(previous.record);
884
+ ensureExpectedRevision(previous.record, input.expectedRevision);
885
+ const nextPhase = phase(input.phase);
886
+ if (nextPhase === "finished") throw new Error("Running capture job phase cannot be finished.");
887
+ if (captureJobPhases.indexOf(nextPhase) < captureJobPhases.indexOf(previous.record.phase)) {
888
+ throw new Error("Capture job phase cannot move backwards.");
889
+ }
890
+ const at = nextTimestamp(previous.record, input.at);
891
+ const record: CaptureJobRecord = {
892
+ ...previous.record,
893
+ revision: previous.record.revision + 1,
894
+ updatedAt: at,
895
+ phase: nextPhase,
896
+ attempts: appendAttempts(previous.record.attempts, input.appendAttempts),
897
+ warnings: appendWarnings(previous.record.warnings, input.appendWarnings),
898
+ };
899
+ await installUpdatedRecord(store, previous, record, lease);
900
+ return record;
901
+ });
902
+ }
903
+
904
+ /** Finish a capture that returned a bounded capture status. */
905
+ export async function completeCaptureJob(
906
+ store: CaptureJobStore,
907
+ id: string,
908
+ input: CompleteCaptureJobInput,
909
+ ): Promise<CaptureJobRecord> {
910
+ captureJobId(id);
911
+ return withMutation(store, id, async (lease) => {
912
+ const previous = await readSnapshot(store, id);
913
+ ensureRunning(previous.record);
914
+ ensureExpectedRevision(previous.record, input.expectedRevision);
915
+ const at = nextTimestamp(previous.record, input.at);
916
+ const record: CaptureJobRecord = {
917
+ ...previous.record,
918
+ revision: previous.record.revision + 1,
919
+ lifecycle: "completed",
920
+ captureStatus: captureStatus(input.status),
921
+ updatedAt: at,
922
+ finishedAt: at,
923
+ phase: "finished",
924
+ attempts: appendAttempts(previous.record.attempts, input.appendAttempts),
925
+ warnings: appendWarnings(previous.record.warnings, input.appendWarnings),
926
+ bundle: input.bundle === undefined || input.bundle === null ? null : normalizeBundle(input.bundle, false),
927
+ };
928
+ await installUpdatedRecord(store, previous, record, lease);
929
+ return record;
930
+ });
931
+ }
932
+
933
+ /** Finish a job after an operational failure; capture status remains epistemically unknown. */
934
+ export async function failCaptureJob(
935
+ store: CaptureJobStore,
936
+ id: string,
937
+ input: FailCaptureJobInput,
938
+ ): Promise<CaptureJobRecord> {
939
+ captureJobId(id);
940
+ return withMutation(store, id, async (lease) => {
941
+ const previous = await readSnapshot(store, id);
942
+ ensureRunning(previous.record);
943
+ ensureExpectedRevision(previous.record, input.expectedRevision);
944
+ const at = nextTimestamp(previous.record, input.at);
945
+ const record: CaptureJobRecord = {
946
+ ...previous.record,
947
+ revision: previous.record.revision + 1,
948
+ lifecycle: "failed",
949
+ updatedAt: at,
950
+ finishedAt: at,
951
+ phase: "finished",
952
+ attempts: appendAttempts(previous.record.attempts, input.appendAttempts),
953
+ warnings: appendWarnings(previous.record.warnings, input.appendWarnings),
954
+ error: normalizedText(input.error, "error", MAX_ERROR_BYTES, false),
955
+ };
956
+ await installUpdatedRecord(store, previous, record, lease);
957
+ return record;
958
+ });
959
+ }