harnery 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +1 -0
  2. package/dist/commands/workflow.d.ts.map +1 -1
  3. package/dist/commands/workflow.js +302 -5
  4. package/dist/core/work/state.d.ts +7 -0
  5. package/dist/core/work/state.d.ts.map +1 -1
  6. package/dist/core/work/state.js +112 -23
  7. package/dist/core/workflow/child-env.d.ts +10 -6
  8. package/dist/core/workflow/child-env.d.ts.map +1 -1
  9. package/dist/core/workflow/child-env.js +15 -7
  10. package/dist/core/workflow/durable-record.d.ts +7 -0
  11. package/dist/core/workflow/durable-record.d.ts.map +1 -0
  12. package/dist/core/workflow/durable-record.js +83 -0
  13. package/dist/core/workflow/engine.d.ts.map +1 -1
  14. package/dist/core/workflow/engine.js +208 -29
  15. package/dist/core/workflow/index.d.ts +2 -0
  16. package/dist/core/workflow/index.d.ts.map +1 -1
  17. package/dist/core/workflow/index.js +1 -0
  18. package/dist/core/workflow/proof.d.ts +4 -0
  19. package/dist/core/workflow/proof.d.ts.map +1 -1
  20. package/dist/core/workflow/proof.js +50 -15
  21. package/dist/core/workflow/run-state.d.ts +3 -0
  22. package/dist/core/workflow/run-state.d.ts.map +1 -1
  23. package/dist/core/workflow/run-state.js +25 -13
  24. package/dist/core/workflow/spawn-cursor.d.ts +3 -0
  25. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  26. package/dist/core/workflow/spawn-cursor.js +8 -4
  27. package/dist/core/workflow/types.d.ts +10 -0
  28. package/dist/core/workflow/types.d.ts.map +1 -1
  29. package/dist/core/workflow/workspaces/attestation-error.d.ts +7 -0
  30. package/dist/core/workflow/workspaces/attestation-error.d.ts.map +1 -0
  31. package/dist/core/workflow/workspaces/attestation-error.js +25 -0
  32. package/dist/core/workflow/workspaces/cancellation.d.ts +8 -0
  33. package/dist/core/workflow/workspaces/cancellation.d.ts.map +1 -0
  34. package/dist/core/workflow/workspaces/cancellation.js +95 -0
  35. package/dist/core/workflow/workspaces/cleanup.d.ts +9 -0
  36. package/dist/core/workflow/workspaces/cleanup.d.ts.map +1 -0
  37. package/dist/core/workflow/workspaces/cleanup.js +223 -0
  38. package/dist/core/workflow/workspaces/execution.d.ts +25 -0
  39. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -0
  40. package/dist/core/workflow/workspaces/execution.js +316 -0
  41. package/dist/core/workflow/workspaces/git.d.ts +25 -0
  42. package/dist/core/workflow/workspaces/git.d.ts.map +1 -0
  43. package/dist/core/workflow/workspaces/git.js +102 -0
  44. package/dist/core/workflow/workspaces/index.d.ts +15 -0
  45. package/dist/core/workflow/workspaces/index.d.ts.map +1 -0
  46. package/dist/core/workflow/workspaces/index.js +7 -0
  47. package/dist/core/workflow/workspaces/inspect.d.ts +85 -0
  48. package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -0
  49. package/dist/core/workflow/workspaces/inspect.js +255 -0
  50. package/dist/core/workflow/workspaces/integration.d.ts +32 -0
  51. package/dist/core/workflow/workspaces/integration.d.ts.map +1 -0
  52. package/dist/core/workflow/workspaces/integration.js +631 -0
  53. package/dist/core/workflow/workspaces/leases.d.ts +37 -0
  54. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -0
  55. package/dist/core/workflow/workspaces/leases.js +397 -0
  56. package/dist/core/workflow/workspaces/lifecycle.d.ts +43 -0
  57. package/dist/core/workflow/workspaces/lifecycle.d.ts.map +1 -0
  58. package/dist/core/workflow/workspaces/lifecycle.js +622 -0
  59. package/dist/core/workflow/workspaces/local-git.d.ts +8 -0
  60. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -0
  61. package/dist/core/workflow/workspaces/local-git.js +1474 -0
  62. package/dist/core/workflow/workspaces/paths.d.ts +23 -0
  63. package/dist/core/workflow/workspaces/paths.d.ts.map +1 -0
  64. package/dist/core/workflow/workspaces/paths.js +191 -0
  65. package/dist/core/workflow/workspaces/recovery.d.ts +3 -0
  66. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -0
  67. package/dist/core/workflow/workspaces/recovery.js +50 -0
  68. package/dist/core/workflow/workspaces/state.d.ts +60 -0
  69. package/dist/core/workflow/workspaces/state.d.ts.map +1 -0
  70. package/dist/core/workflow/workspaces/state.js +296 -0
  71. package/dist/core/workflow/workspaces/types.d.ts +445 -0
  72. package/dist/core/workflow/workspaces/types.d.ts.map +1 -0
  73. package/dist/core/workflow/workspaces/types.js +2 -0
  74. package/dist/core/workflow/workspaces/validate.d.ts +14 -0
  75. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -0
  76. package/dist/core/workflow/workspaces/validate.js +476 -0
  77. package/package.json +5 -3
  78. package/src/commands/workflow.ts +366 -8
  79. package/src/core/work/state.ts +156 -28
  80. package/src/core/workflow/child-env.ts +18 -7
  81. package/src/core/workflow/durable-record.ts +94 -0
  82. package/src/core/workflow/engine.ts +230 -30
  83. package/src/core/workflow/index.ts +74 -0
  84. package/src/core/workflow/proof.ts +70 -22
  85. package/src/core/workflow/run-state.ts +46 -11
  86. package/src/core/workflow/spawn-cursor.ts +8 -4
  87. package/src/core/workflow/types.ts +14 -0
  88. package/src/core/workflow/workspaces/attestation-error.ts +30 -0
  89. package/src/core/workflow/workspaces/cancellation.ts +137 -0
  90. package/src/core/workflow/workspaces/cleanup.ts +308 -0
  91. package/src/core/workflow/workspaces/execution.ts +412 -0
  92. package/src/core/workflow/workspaces/git.ts +125 -0
  93. package/src/core/workflow/workspaces/index.ts +89 -0
  94. package/src/core/workflow/workspaces/inspect.ts +421 -0
  95. package/src/core/workflow/workspaces/integration.ts +845 -0
  96. package/src/core/workflow/workspaces/leases.ts +517 -0
  97. package/src/core/workflow/workspaces/lifecycle.ts +792 -0
  98. package/src/core/workflow/workspaces/local-git.ts +1892 -0
  99. package/src/core/workflow/workspaces/paths.ts +247 -0
  100. package/src/core/workflow/workspaces/recovery.ts +56 -0
  101. package/src/core/workflow/workspaces/state.ts +481 -0
  102. package/src/core/workflow/workspaces/types.ts +517 -0
  103. package/src/core/workflow/workspaces/validate.ts +595 -0
@@ -0,0 +1,481 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import {
14
+ canonicalJson,
15
+ fsyncParentDirectory,
16
+ readJsonRecord,
17
+ stableDigest,
18
+ writeImmutableJson,
19
+ } from "../durable-record.ts";
20
+ import type {
21
+ GitRepositoryBinding,
22
+ IntegrationApplyAttempt,
23
+ IntegrationApplyReceipt,
24
+ IntegrationAuthorization,
25
+ IntegrationPlan,
26
+ IntegrationReviewRecord,
27
+ WorkspaceAllocationRequest,
28
+ WorkspaceBinding,
29
+ WorkspaceCancellationReceipt,
30
+ WorkspaceCleanupAttempt,
31
+ WorkspaceCleanupIntent,
32
+ WorkspaceCleanupReceipt,
33
+ WorkspaceCleanupResult,
34
+ } from "./types.ts";
35
+
36
+ const JSON_LIMIT = 512 * 1024;
37
+ const EVENT_LIMIT = 32 * 1024;
38
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
39
+
40
+ export interface WorkspaceClaim {
41
+ schema_version: 1;
42
+ provider_id: string;
43
+ provider_version: string;
44
+ binding_id: string;
45
+ workspace_id: string;
46
+ request: WorkspaceAllocationRequest;
47
+ request_sha256: string;
48
+ recovery_token: string;
49
+ created_at: string;
50
+ workspace_root: string;
51
+ active_root: string;
52
+ writable_root: WorkspaceBinding["writable_root"];
53
+ repository: {
54
+ source_root: GitRepositoryBinding["source_root"];
55
+ common_dir: GitRepositoryBinding["common_dir"];
56
+ base_commit: string;
57
+ target_commit: string;
58
+ target_ref: string;
59
+ workspace_ref: string;
60
+ workspace_branch: string;
61
+ };
62
+ }
63
+
64
+ export interface WorkspaceProviderEvent {
65
+ schema_version: 1;
66
+ seq: number;
67
+ previous_sha256: string | null;
68
+ record_sha256: string;
69
+ event: string;
70
+ recorded_at: string;
71
+ request_sha256: string;
72
+ binding_id: string;
73
+ workspace_id: string;
74
+ data: Record<string, unknown>;
75
+ }
76
+
77
+ export function workflowRunDir(coordRoot: string, runId: string): string {
78
+ assertId(runId, "run id");
79
+ return join(resolve(coordRoot), ".harnery", "workflows", runId);
80
+ }
81
+
82
+ export function workspaceRequestPath(coordRoot: string, runId: string): string {
83
+ return join(workflowRunDir(coordRoot, runId), "workspace-request.json");
84
+ }
85
+
86
+ export function workspaceClaimPath(
87
+ coordRoot: string,
88
+ providerId: string,
89
+ bindingId: string,
90
+ ): string {
91
+ assertId(providerId, "provider id");
92
+ assertId(bindingId, "binding id");
93
+ return join(resolve(coordRoot), ".harnery", "workspaces", providerId, bindingId, "claim.json");
94
+ }
95
+
96
+ export function workspaceEventsPath(
97
+ coordRoot: string,
98
+ providerId: string,
99
+ bindingId: string,
100
+ ): string {
101
+ return join(dirname(workspaceClaimPath(coordRoot, providerId, bindingId)), "events.jsonl");
102
+ }
103
+
104
+ export function workspaceBindingPath(
105
+ coordRoot: string,
106
+ providerId: string,
107
+ bindingId: string,
108
+ ): string {
109
+ return join(dirname(workspaceClaimPath(coordRoot, providerId, bindingId)), "binding.json");
110
+ }
111
+
112
+ export function workspaceLockPath(
113
+ coordRoot: string,
114
+ providerId: string,
115
+ bindingId: string,
116
+ ): string {
117
+ return join(dirname(workspaceClaimPath(coordRoot, providerId, bindingId)), "operation.lease");
118
+ }
119
+
120
+ export function writeWorkspaceRequest(
121
+ coordRoot: string,
122
+ request: WorkspaceAllocationRequest,
123
+ ): string {
124
+ const path = workspaceRequestPath(coordRoot, request.run_id);
125
+ writeImmutableJson(path, request);
126
+ return path;
127
+ }
128
+
129
+ export function readWorkspaceRequest(
130
+ coordRoot: string,
131
+ runId: string,
132
+ ): WorkspaceAllocationRequest | undefined {
133
+ const path = workspaceRequestPath(coordRoot, runId);
134
+ return existsSync(path)
135
+ ? readJsonRecord<WorkspaceAllocationRequest>(path, "workspace request")
136
+ : undefined;
137
+ }
138
+
139
+ export function writeWorkspaceClaim(coordRoot: string, claim: WorkspaceClaim): boolean {
140
+ const path = workspaceClaimPath(coordRoot, claim.provider_id, claim.binding_id);
141
+ try {
142
+ return writeImmutableJson(path, claim);
143
+ } catch (error) {
144
+ // A competing allocation may have won the exclusive link with its one
145
+ // random recovery token. The caller must read and validate that claim.
146
+ if (existsSync(path) && (error as Error).message.includes("immutable record already exists")) {
147
+ return false;
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ export function readWorkspaceClaim(
154
+ coordRoot: string,
155
+ providerId: string,
156
+ bindingId: string,
157
+ ): WorkspaceClaim | undefined {
158
+ const path = workspaceClaimPath(coordRoot, providerId, bindingId);
159
+ return existsSync(path) ? readJsonRecord<WorkspaceClaim>(path, "workspace claim") : undefined;
160
+ }
161
+
162
+ export function writeWorkspaceBinding(coordRoot: string, binding: WorkspaceBinding): boolean {
163
+ return writeImmutableJson(
164
+ workspaceBindingPath(coordRoot, binding.provider.id, binding.binding_id),
165
+ binding,
166
+ );
167
+ }
168
+
169
+ export function readWorkspaceBinding(
170
+ coordRoot: string,
171
+ providerId: string,
172
+ bindingId: string,
173
+ ): WorkspaceBinding | undefined {
174
+ const path = workspaceBindingPath(coordRoot, providerId, bindingId);
175
+ return existsSync(path) ? readJsonRecord<WorkspaceBinding>(path, "workspace binding") : undefined;
176
+ }
177
+
178
+ export function appendWorkspaceEvent(
179
+ coordRoot: string,
180
+ claim: WorkspaceClaim,
181
+ event: string,
182
+ data: Record<string, unknown> = {},
183
+ ): WorkspaceProviderEvent {
184
+ if (!event || event.length > 100 || Buffer.byteLength(JSON.stringify(data)) > EVENT_LIMIT / 2) {
185
+ throw new Error("workspace provider event is invalid or too large");
186
+ }
187
+ const path = workspaceEventsPath(coordRoot, claim.provider_id, claim.binding_id);
188
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
189
+ const existed = existsSync(path);
190
+ const prior = readWorkspaceEvents(coordRoot, claim.provider_id, claim.binding_id);
191
+ const basis = {
192
+ schema_version: 1 as const,
193
+ seq: prior.length + 1,
194
+ previous_sha256: prior.at(-1)?.record_sha256 ?? null,
195
+ event,
196
+ recorded_at: new Date().toISOString(),
197
+ request_sha256: claim.request_sha256,
198
+ binding_id: claim.binding_id,
199
+ workspace_id: claim.workspace_id,
200
+ data,
201
+ };
202
+ const record: WorkspaceProviderEvent = {
203
+ ...basis,
204
+ record_sha256: stableDigest(basis),
205
+ };
206
+ const line = `${canonicalJson(record)}\n`;
207
+ if (Buffer.byteLength(line) > EVENT_LIMIT)
208
+ throw new Error("workspace provider event is too large");
209
+ const fd = openSync(path, "a", 0o600);
210
+ try {
211
+ writeFileSync(fd, line, "utf8");
212
+ fsyncSync(fd);
213
+ } finally {
214
+ closeSync(fd);
215
+ }
216
+ chmodSync(path, 0o600);
217
+ if (!existed) fsyncParentDirectory(path);
218
+ return record;
219
+ }
220
+
221
+ export function readWorkspaceEvents(
222
+ coordRoot: string,
223
+ providerId: string,
224
+ bindingId: string,
225
+ ): WorkspaceProviderEvent[] {
226
+ const path = workspaceEventsPath(coordRoot, providerId, bindingId);
227
+ if (!existsSync(path)) return [];
228
+ if (statSync(path).size > JSON_LIMIT * 4) throw new Error("workspace event journal is too large");
229
+ const claim = readWorkspaceClaim(coordRoot, providerId, bindingId);
230
+ if (!claim) throw new Error("workspace event authority claim is missing");
231
+ const records = readFileSync(path, "utf8")
232
+ .split("\n")
233
+ .filter(Boolean)
234
+ .map((line, index) => {
235
+ if (Buffer.byteLength(line) > EVENT_LIMIT) {
236
+ throw new Error(`workspace event ${index + 1} is too large`);
237
+ }
238
+ let record: WorkspaceProviderEvent;
239
+ try {
240
+ record = JSON.parse(line) as WorkspaceProviderEvent;
241
+ } catch (error) {
242
+ throw new Error(`cannot parse workspace event ${index + 1}: ${(error as Error).message}`);
243
+ }
244
+ const { record_sha256, ...basis } = record;
245
+ const previous = index === 0 ? null : recordsDigestPlaceholder();
246
+ if (
247
+ record.schema_version !== 1 ||
248
+ record.seq !== index + 1 ||
249
+ record.binding_id !== bindingId ||
250
+ record.workspace_id !== claim.workspace_id ||
251
+ record.request_sha256 !== claim.request_sha256 ||
252
+ record_sha256 !== stableDigest(basis) ||
253
+ (index === 0 && record.previous_sha256 !== null)
254
+ ) {
255
+ throw new Error(`workspace event ${index + 1} is corrupt`);
256
+ }
257
+ void previous;
258
+ return record;
259
+ });
260
+ for (let index = 1; index < records.length; index++) {
261
+ if (records[index]!.previous_sha256 !== records[index - 1]!.record_sha256) {
262
+ throw new Error(`workspace event ${index + 1} breaks the digest chain`);
263
+ }
264
+ }
265
+ return records;
266
+ }
267
+
268
+ // Keeps the JSON parser above free of a closure over a partially initialized array.
269
+ function recordsDigestPlaceholder(): null {
270
+ return null;
271
+ }
272
+
273
+ export function appendWorkflowJournalEvent(
274
+ coordRoot: string,
275
+ runId: string,
276
+ event: string,
277
+ data: Record<string, unknown>,
278
+ ): void {
279
+ const path = join(workflowRunDir(coordRoot, runId), "journal.jsonl");
280
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
281
+ const existed = existsSync(path);
282
+ const line = `${JSON.stringify({
283
+ schema_version: 1,
284
+ run_id: runId,
285
+ ts: new Date().toISOString(),
286
+ event,
287
+ ...data,
288
+ })}\n`;
289
+ if (Buffer.byteLength(line) > EVENT_LIMIT) throw new Error("workflow journal event is too large");
290
+ const fd = openSync(path, "a", 0o600);
291
+ try {
292
+ writeFileSync(fd, line, "utf8");
293
+ fsyncSync(fd);
294
+ } finally {
295
+ closeSync(fd);
296
+ }
297
+ if (!existed) fsyncParentDirectory(path);
298
+ }
299
+
300
+ export type WorkflowSupplement =
301
+ | WorkspaceBinding
302
+ | IntegrationReviewRecord
303
+ | IntegrationPlan
304
+ | IntegrationAuthorization
305
+ | IntegrationApplyReceipt
306
+ | WorkspaceCleanupResult
307
+ | WorkspaceCancellationReceipt
308
+ | WorkspaceCleanupIntent
309
+ | WorkspaceCleanupReceipt;
310
+
311
+ export function appendCleanupAttempt(
312
+ coordRoot: string,
313
+ runId: string,
314
+ input: Omit<WorkspaceCleanupAttempt, "seq" | "previous_sha256" | "record_sha256">,
315
+ ): WorkspaceCleanupAttempt {
316
+ const path = join(workflowRunDir(coordRoot, runId), "cleanup", "attempts.jsonl");
317
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
318
+ const existed = existsSync(path);
319
+ const prior = readCleanupAttempts(coordRoot, runId);
320
+ const basis = {
321
+ ...input,
322
+ seq: prior.length + 1,
323
+ previous_sha256: prior.at(-1)?.record_sha256 ?? null,
324
+ };
325
+ const attempt: WorkspaceCleanupAttempt = {
326
+ ...basis,
327
+ record_sha256: stableDigest(basis),
328
+ };
329
+ const line = `${canonicalJson(attempt)}\n`;
330
+ if (Buffer.byteLength(line) > EVENT_LIMIT) {
331
+ throw new Error("workspace cleanup attempt is too large");
332
+ }
333
+ const fd = openSync(path, "a", 0o600);
334
+ try {
335
+ writeFileSync(fd, line, "utf8");
336
+ fsyncSync(fd);
337
+ } finally {
338
+ closeSync(fd);
339
+ }
340
+ chmodSync(path, 0o600);
341
+ if (!existed) fsyncParentDirectory(path);
342
+ return attempt;
343
+ }
344
+
345
+ export function appendIntegrationAttempt(
346
+ coordRoot: string,
347
+ runId: string,
348
+ input: Omit<IntegrationApplyAttempt, "seq" | "previous_sha256" | "record_sha256">,
349
+ ): IntegrationApplyAttempt {
350
+ const path = join(workflowRunDir(coordRoot, runId), "integration", "attempts.jsonl");
351
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
352
+ const existed = existsSync(path);
353
+ const prior = readIntegrationAttempts(coordRoot, runId);
354
+ const basis = {
355
+ ...input,
356
+ seq: prior.length + 1,
357
+ previous_sha256: prior.at(-1)?.record_sha256 ?? null,
358
+ };
359
+ const attempt: IntegrationApplyAttempt = {
360
+ ...basis,
361
+ record_sha256: stableDigest(basis),
362
+ };
363
+ appendChainedRecord(path, attempt, "integration attempt");
364
+ if (!existed) fsyncParentDirectory(path);
365
+ return attempt;
366
+ }
367
+
368
+ export function readIntegrationAttempts(
369
+ coordRoot: string,
370
+ runId: string,
371
+ ): IntegrationApplyAttempt[] {
372
+ return readChainedRecords<IntegrationApplyAttempt>(
373
+ join(workflowRunDir(coordRoot, runId), "integration", "attempts.jsonl"),
374
+ "integration attempt",
375
+ );
376
+ }
377
+
378
+ export function readCleanupAttempts(coordRoot: string, runId: string): WorkspaceCleanupAttempt[] {
379
+ return readChainedRecords<WorkspaceCleanupAttempt>(
380
+ join(workflowRunDir(coordRoot, runId), "cleanup", "attempts.jsonl"),
381
+ "workspace cleanup attempt",
382
+ );
383
+ }
384
+
385
+ function appendChainedRecord(
386
+ path: string,
387
+ record: WorkspaceCleanupAttempt | IntegrationApplyAttempt,
388
+ label: string,
389
+ ): void {
390
+ const line = `${canonicalJson(record)}\n`;
391
+ if (Buffer.byteLength(line) > EVENT_LIMIT) {
392
+ throw new Error(`${label} is too large`);
393
+ }
394
+ const fd = openSync(path, "a", 0o600);
395
+ try {
396
+ writeFileSync(fd, line, "utf8");
397
+ fsyncSync(fd);
398
+ } finally {
399
+ closeSync(fd);
400
+ }
401
+ chmodSync(path, 0o600);
402
+ }
403
+
404
+ function readChainedRecords<
405
+ T extends {
406
+ schema_version: 1;
407
+ seq: number;
408
+ previous_sha256: string | null;
409
+ record_sha256: string;
410
+ },
411
+ >(path: string, label: string): T[] {
412
+ if (!existsSync(path)) return [];
413
+ if (statSync(path).size > JSON_LIMIT * 4) {
414
+ throw new Error(`${label} journal is too large`);
415
+ }
416
+ const records = readFileSync(path, "utf8")
417
+ .split("\n")
418
+ .filter(Boolean)
419
+ .map((line, index) => {
420
+ let record: T;
421
+ try {
422
+ record = JSON.parse(line) as T;
423
+ } catch (error) {
424
+ throw new Error(`cannot parse ${label} ${index + 1}: ${(error as Error).message}`);
425
+ }
426
+ const { record_sha256, ...basis } = record;
427
+ if (
428
+ record.schema_version !== 1 ||
429
+ record.seq !== index + 1 ||
430
+ record_sha256 !== stableDigest(basis) ||
431
+ (index === 0 && record.previous_sha256 !== null)
432
+ ) {
433
+ throw new Error(`${label} ${index + 1} is corrupt`);
434
+ }
435
+ return record;
436
+ });
437
+ for (let index = 1; index < records.length; index++) {
438
+ if (records[index]!.previous_sha256 !== records[index - 1]!.record_sha256) {
439
+ throw new Error(`${label} ${index + 1} breaks the digest chain`);
440
+ }
441
+ }
442
+ return records;
443
+ }
444
+
445
+ export function writeWorkflowSupplement(
446
+ coordRoot: string,
447
+ runId: string,
448
+ relativePath: string,
449
+ value: WorkflowSupplement,
450
+ ): string {
451
+ if (
452
+ relativePath.startsWith("/") ||
453
+ relativePath.split(/[\\/]/).some((part) => !part || part === "." || part === "..")
454
+ ) {
455
+ throw new Error("workflow supplement path is unsafe");
456
+ }
457
+ const path = join(workflowRunDir(coordRoot, runId), relativePath);
458
+ writeImmutableJson(path, value);
459
+ return path;
460
+ }
461
+
462
+ export function readWorkflowSupplement<T>(
463
+ coordRoot: string,
464
+ runId: string,
465
+ relativePath: string,
466
+ ): T | undefined {
467
+ const path = join(workflowRunDir(coordRoot, runId), relativePath);
468
+ return existsSync(path) ? readJsonRecord<T>(path, relativePath) : undefined;
469
+ }
470
+
471
+ function assertId(value: string, field: string): void {
472
+ if (!ID.test(value)) throw new Error(`${field} is invalid`);
473
+ }
474
+
475
+ export {
476
+ canonicalJson,
477
+ fileSha256,
478
+ readJsonRecord,
479
+ stableDigest,
480
+ writeImmutableJson,
481
+ } from "../durable-record.ts";