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
@@ -1,4 +1,4 @@
1
- import { randomBytes, randomUUID } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import {
3
3
  appendFileSync,
4
4
  chmodSync,
@@ -15,8 +15,10 @@ import {
15
15
  import { hostname } from "node:os";
16
16
  import { dirname, isAbsolute, join, resolve } from "node:path";
17
17
  import { readWorkflowApproval } from "../workflow/approvals.ts";
18
+ import { stableDigest } from "../workflow/durable-record.ts";
18
19
  import { readWorkflowProof } from "../workflow/proof.ts";
19
20
  import { readWorkflowRunManifest, workflowScriptDigest } from "../workflow/run-state.ts";
21
+ import { isWorkspaceBoundExecutionEvidence } from "../workflow/workspaces/validate.ts";
20
22
 
21
23
  export const WORK_INTENT_SCHEMA_VERSION = 1 as const;
22
24
  export const WORK_EVENT_SCHEMA_VERSION = 1 as const;
@@ -92,6 +94,10 @@ export interface WorkEvent {
92
94
  run_id?: string;
93
95
  attempt?: number;
94
96
  trigger?: WorkAttemptTrigger;
97
+ workspace_binding_id?: string;
98
+ proof_sha256?: string;
99
+ terminal_attestation_sha256?: string;
100
+ accepted_unknowns?: string[];
95
101
  state?: WorkState;
96
102
  next_action?: WorkNextAction;
97
103
  }
@@ -264,7 +270,7 @@ export function reconcileAllWorkItems(coordRoot: string, actor?: string): WorkRe
264
270
  export function acceptWorkItem(
265
271
  coordRoot: string,
266
272
  workId: string,
267
- input: { actor?: string; reason?: string } = {},
273
+ input: { actor?: string; reason?: string; acceptedUnknowns?: readonly string[] } = {},
268
274
  ): WorkRecord {
269
275
  return appendGovernanceEvent(coordRoot, workId, "work.accepted", ["in_review"], input);
270
276
  }
@@ -278,7 +284,7 @@ export function cancelWorkItem(
278
284
  coordRoot,
279
285
  workId,
280
286
  "work.cancelled",
281
- ["waiting", "ready", "blocked", "in_review"],
287
+ ["waiting", "ready", "running", "awaiting_approval", "blocked", "in_review"],
282
288
  input,
283
289
  );
284
290
  }
@@ -531,48 +537,109 @@ function appendGovernanceEvent(
531
537
  workId: string,
532
538
  event: "work.accepted" | "work.cancelled" | "work.reopened",
533
539
  allowed: WorkState[],
534
- input: { actor?: string; reason?: string },
540
+ input: { actor?: string; reason?: string; acceptedUnknowns?: readonly string[] },
535
541
  ): WorkRecord {
536
- const release = acquireWorkLease(coordRoot, workId);
542
+ const releaseWork = event === "work.cancelled" ? undefined : acquireWorkLease(coordRoot, workId);
543
+ const releaseEvent = acquireWorkEventLease(coordRoot, workId);
537
544
  try {
538
- const current = readWorkItemIgnoringLease(coordRoot, workId);
545
+ const current = releaseWork
546
+ ? readWorkItemIgnoringLease(coordRoot, workId)
547
+ : readWorkItem(coordRoot, workId);
539
548
  if (!allowed.includes(current.projection.state)) {
540
549
  throw new Error(
541
550
  `work item ${workId} cannot ${event.replace("work.", "")} from state ${current.projection.state}`,
542
551
  );
543
552
  }
544
- appendWorkEvent(coordRoot, workId, {
553
+ const acceptance =
554
+ event === "work.accepted"
555
+ ? acceptanceAuthority(coordRoot, current, input.acceptedUnknowns ?? [])
556
+ : undefined;
557
+ appendWorkEventUnderLease(coordRoot, workId, {
545
558
  event,
546
559
  actor: boundedActor(input.actor),
547
560
  reason: boundedOptional(input.reason, "work reason", MAX_REASON),
561
+ ...acceptance,
548
562
  });
549
563
  } finally {
550
- release();
564
+ releaseEvent();
565
+ releaseWork?.();
551
566
  }
552
567
  return reconcileWorkItem(coordRoot, workId, input.actor);
553
568
  }
554
569
 
570
+ function acceptanceAuthority(
571
+ coordRoot: string,
572
+ current: WorkRecord,
573
+ acceptedUnknowns: readonly string[],
574
+ ): Pick<
575
+ WorkEvent,
576
+ | "run_id"
577
+ | "attempt"
578
+ | "proof_sha256"
579
+ | "workspace_binding_id"
580
+ | "terminal_attestation_sha256"
581
+ | "accepted_unknowns"
582
+ > {
583
+ const latest = current.projection.attempts.at(-1);
584
+ if (!latest?.proof_path) throw new Error("work acceptance requires terminal workflow proof");
585
+ const manifest = readWorkflowRunManifest(coordRoot, latest.run_id);
586
+ const proof = readWorkflowProof(coordRoot, latest.run_id);
587
+ if (
588
+ proof.run.work_item_id !== current.intent.id ||
589
+ proof.run.attempt_context?.number !== latest.number
590
+ ) {
591
+ throw new Error("work acceptance proof does not match the durable attempt");
592
+ }
593
+ const accepted = [...new Set(acceptedUnknowns)].sort();
594
+ for (const code of accepted) {
595
+ boundedString(code, "accepted unknown code", 200);
596
+ }
597
+ const execution = proof.execution;
598
+ return {
599
+ run_id: latest.run_id,
600
+ attempt: latest.number,
601
+ proof_sha256: createHash("sha256").update(readFileSync(latest.proof_path)).digest("hex"),
602
+ workspace_binding_id: manifest.execution.workspace_binding?.binding_id,
603
+ terminal_attestation_sha256: isWorkspaceBoundExecutionEvidence(execution)
604
+ ? stableDigest(execution.terminal_attestation)
605
+ : undefined,
606
+ accepted_unknowns: execution ? accepted : undefined,
607
+ };
608
+ }
609
+
555
610
  /** @internal Runner seam; not a separate persistence contract. */
556
611
  export function appendReconcileIfChanged(
557
612
  coordRoot: string,
558
613
  record: WorkRecord,
559
614
  actor: string,
560
615
  ): void {
561
- const last = [...record.events].reverse().find((event) => event.event === "work.reconciled");
562
- if (
563
- last?.state === record.projection.state &&
564
- last.reason === record.projection.reason &&
565
- last.next_action === record.projection.next_action
566
- ) {
567
- return;
568
- }
569
- appendWorkEvent(coordRoot, record.intent.id, {
570
- event: "work.reconciled",
571
- actor,
572
- reason: record.projection.reason,
573
- state: record.projection.state,
574
- next_action: record.projection.next_action,
575
- });
616
+ let release: (() => void) | undefined;
617
+ try {
618
+ release = acquireWorkEventLease(coordRoot, record.intent.id);
619
+ } catch (error) {
620
+ if (workEventLeaseIsLive(coordRoot, record.intent.id)) return;
621
+ throw error;
622
+ }
623
+ try {
624
+ const current = readWorkItemIgnoringLease(coordRoot, record.intent.id);
625
+ const last = [...current.events].reverse().find((event) => event.event === "work.reconciled");
626
+ if (
627
+ last?.state === current.projection.state &&
628
+ last.reason === current.projection.reason &&
629
+ last.next_action === current.projection.next_action
630
+ ) {
631
+ return;
632
+ }
633
+ appendWorkEventUnderLease(coordRoot, current.intent.id, {
634
+ event: "work.reconciled",
635
+ actor,
636
+ reason: current.projection.reason,
637
+ state: current.projection.state,
638
+ next_action: current.projection.next_action,
639
+ });
640
+ } finally {
641
+ release();
642
+ }
576
643
  }
577
644
 
578
645
  /** @internal Runner seam; not a separate persistence contract. */
@@ -580,15 +647,28 @@ export function appendWorkEvent(
580
647
  coordRoot: string,
581
648
  workId: string,
582
649
  input: Omit<WorkEvent, "schema_version" | "work_id" | "seq" | "ts">,
650
+ ): WorkEvent {
651
+ const release = acquireWorkEventLease(coordRoot, workId);
652
+ try {
653
+ return appendWorkEventUnderLease(coordRoot, workId, input);
654
+ } finally {
655
+ release();
656
+ }
657
+ }
658
+
659
+ function appendWorkEventUnderLease(
660
+ coordRoot: string,
661
+ workId: string,
662
+ input: Omit<WorkEvent, "schema_version" | "work_id" | "seq" | "ts">,
583
663
  ): WorkEvent {
584
664
  const events = readWorkEvents(coordRoot, workId);
585
665
  if (events.length === 0 && input.event !== "work.created") {
586
- appendWorkEvent(coordRoot, workId, {
666
+ appendWorkEventUnderLease(coordRoot, workId, {
587
667
  event: "work.created",
588
668
  actor: "recovery",
589
669
  reason: "recovered creation receipt from immutable intent",
590
670
  });
591
- return appendWorkEvent(coordRoot, workId, input);
671
+ return appendWorkEventUnderLease(coordRoot, workId, input);
592
672
  }
593
673
  if (events.length >= MAX_EVENTS)
594
674
  throw new Error(`work item ${workId} exceeds ${MAX_EVENTS} events`);
@@ -699,7 +779,11 @@ function validateWorkEvent(event: WorkEvent, workId: string, seq: number): void
699
779
  }
700
780
  boundedActor(event.actor);
701
781
  boundedOptional(event.reason, "work reason", MAX_REASON);
702
- if (event.event === "attempt.started" || event.event === "attempt.resumed") {
782
+ if (
783
+ event.event === "attempt.started" ||
784
+ event.event === "attempt.resumed" ||
785
+ (event.event === "work.accepted" && event.run_id !== undefined)
786
+ ) {
703
787
  if (
704
788
  !event.run_id ||
705
789
  !RUN_ID.test(event.run_id) ||
@@ -709,6 +793,26 @@ function validateWorkEvent(event: WorkEvent, workId: string, seq: number): void
709
793
  throw new Error(`work item ${workId} event ${seq} has invalid attempt data`);
710
794
  }
711
795
  }
796
+ if (event.event === "work.accepted" && event.run_id !== undefined) {
797
+ if (event.workspace_binding_id !== undefined) {
798
+ boundedString(event.workspace_binding_id, "workspace binding id", 200);
799
+ }
800
+ if (!/^[a-f0-9]{64}$/.test(event.proof_sha256 ?? "")) {
801
+ throw new Error(`work item ${workId} event ${seq} has invalid proof digest`);
802
+ }
803
+ if (event.workspace_binding_id !== undefined) {
804
+ if (
805
+ !/^[a-f0-9]{64}$/.test(event.terminal_attestation_sha256 ?? "") ||
806
+ !Array.isArray(event.accepted_unknowns) ||
807
+ event.accepted_unknowns.some(
808
+ (code) => typeof code !== "string" || code.length === 0 || code.length > 200,
809
+ ) ||
810
+ new Set(event.accepted_unknowns).size !== event.accepted_unknowns.length
811
+ ) {
812
+ throw new Error(`work item ${workId} event ${seq} has invalid execution review evidence`);
813
+ }
814
+ }
815
+ }
712
816
  if (
713
817
  event.trigger !== undefined &&
714
818
  (event.event !== "attempt.started" ||
@@ -802,8 +906,27 @@ function workDir(coordRoot: string, workId: string): string {
802
906
 
803
907
  /** @internal Runner seam; not a separate persistence contract. */
804
908
  export function acquireWorkLease(coordRoot: string, workId: string): () => void {
909
+ return acquireWorkFileLease(coordRoot, workId, "lease.json", "is already active");
910
+ }
911
+
912
+ /** @internal Work journal mutation seam. */
913
+ export function acquireWorkEventLease(coordRoot: string, workId: string): () => void {
914
+ return acquireWorkFileLease(
915
+ coordRoot,
916
+ workId,
917
+ "events.lease.json",
918
+ "event journal is already active",
919
+ );
920
+ }
921
+
922
+ function acquireWorkFileLease(
923
+ coordRoot: string,
924
+ workId: string,
925
+ filename: string,
926
+ activeMessage: string,
927
+ ): () => void {
805
928
  readWorkIntent(coordRoot, workId);
806
- const path = join(workDir(coordRoot, workId), "lease.json");
929
+ const path = join(workDir(coordRoot, workId), filename);
807
930
  const owner = {
808
931
  pid: process.pid,
809
932
  host: hostname(),
@@ -829,7 +952,7 @@ export function acquireWorkLease(coordRoot: string, workId: string): () => void
829
952
  const existing = readLease(path);
830
953
  if (existing && leaseIsLive(existing)) {
831
954
  throw new Error(
832
- `work item ${workId} is already active under pid ${existing.pid} on ${existing.host}`,
955
+ `work item ${workId} ${activeMessage} under pid ${existing.pid} on ${existing.host}`,
833
956
  );
834
957
  }
835
958
  unlinkSync(path);
@@ -850,6 +973,11 @@ function workLeaseIsLive(coordRoot: string, workId: string): boolean {
850
973
  return lease ? leaseIsLive(lease) : false;
851
974
  }
852
975
 
976
+ function workEventLeaseIsLive(coordRoot: string, workId: string): boolean {
977
+ const lease = readLease(join(workDir(coordRoot, workId), "events.lease.json"));
978
+ return lease ? leaseIsLive(lease) : false;
979
+ }
980
+
853
981
  function workflowResumeLeaseIsLive(coordRoot: string, runId: string): boolean {
854
982
  const lease = readLease(join(coordRoot, ".harnery", "workflows", runId, "resume.lock"));
855
983
  return lease ? leaseIsLive(lease) : false;
@@ -6,13 +6,17 @@
6
6
  * harness CLI under a live session inherits vars that make it exit 1 with
7
7
  * empty output; an empty-string var still reads as set. Scrub all three
8
8
  * families (CLAUDE*, CODEX*, CURSOR*) regardless of which adapter spawns —
9
- * a codex child launched from inside a Claude Code session must not
10
- * inherit CLAUDE* either.
11
- * 2. **The scrub targets SESSION vars, not credentials.** CURSOR_API_KEY
9
+ * a codex child launched from inside a Claude Code session must not inherit
10
+ * CLAUDE* either. Preserve explicit auth/config locators such as CODEX_HOME:
11
+ * changing the home silently changes which subscription account the child
12
+ * uses.
13
+ * 2. **The scrub targets SESSION vars, not credentials or auth locators.** CURSOR_API_KEY
12
14
  * matches the CURSOR* prefix but is an auth credential, so it is carved
13
- * out and re-added — a key-only cursor host must keep working. (The other
14
- * two key vars, ANTHROPIC_API_KEY and OPENAI_API_KEY, don't collide with
15
- * the scrub prefixes.)
15
+ * out and re-added — a key-only cursor host must keep working. CODEX_HOME
16
+ * is likewise preserved because codex reads its ChatGPT subscription login
17
+ * from `$CODEX_HOME/auth.json`. (The other two key vars,
18
+ * ANTHROPIC_API_KEY and OPENAI_API_KEY, don't collide with the scrub
19
+ * prefixes.)
16
20
  * 3. **subscriptionOnly deletes every API-key var** (see billing.ts) so the
17
21
  * child can only authenticate via its stored subscription login — the
18
22
  * guarantee behind `workflow run --subscription-only`.
@@ -26,6 +30,7 @@
26
30
  import { API_KEY_VARS } from "./billing.ts";
27
31
 
28
32
  const SCRUB_PREFIXES = ["CLAUDE", "CODEX", "CURSOR"];
33
+ const PRESERVED_HARNESS_VARS = new Set(["CODEX_HOME"]);
29
34
 
30
35
  export interface ChildEnvOpts {
31
36
  /** Delete all API-key vars so children can only use stored logins. */
@@ -38,7 +43,13 @@ export function buildChildEnv(runId?: string, opts: ChildEnvOpts = {}): Record<s
38
43
  for (const [k, v] of Object.entries(process.env)) {
39
44
  if (v === undefined) continue;
40
45
  if (opts.subscriptionOnly && keyVars.has(k)) continue;
41
- if (SCRUB_PREFIXES.some((p) => k.startsWith(p)) && !keyVars.has(k)) continue;
46
+ if (
47
+ SCRUB_PREFIXES.some((p) => k.startsWith(p)) &&
48
+ !keyVars.has(k) &&
49
+ !PRESERVED_HARNESS_VARS.has(k)
50
+ ) {
51
+ continue;
52
+ }
42
53
  env[k] = v;
43
54
  }
44
55
  env.HARNERY_WORKFLOW_CHILD = "1";
@@ -0,0 +1,94 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ linkSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ statSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { dirname } from "node:path";
16
+
17
+ const JSON_LIMIT = 512 * 1024;
18
+
19
+ export function stableDigest(value: unknown): string {
20
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
21
+ }
22
+
23
+ export function canonicalJson(value: unknown): string {
24
+ return JSON.stringify(canonicalize(value));
25
+ }
26
+
27
+ export function fileSha256(path: string): string {
28
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
29
+ }
30
+
31
+ export function writeImmutableJson(path: string, value: unknown): boolean {
32
+ const body = `${JSON.stringify(value, null, 2)}\n`;
33
+ if (Buffer.byteLength(body) > JSON_LIMIT) {
34
+ throw new Error(`immutable record exceeds ${JSON_LIMIT} bytes`);
35
+ }
36
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
37
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
38
+ let fd: number | undefined;
39
+ try {
40
+ fd = openSync(temporary, "wx", 0o600);
41
+ writeFileSync(fd, body, "utf8");
42
+ fsyncSync(fd);
43
+ closeSync(fd);
44
+ fd = undefined;
45
+ try {
46
+ linkSync(temporary, path);
47
+ chmodSync(path, 0o600);
48
+ fsyncParentDirectory(path);
49
+ return true;
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
52
+ const existing = readFileSync(path, "utf8");
53
+ if (existing !== body) throw new Error(`immutable record already exists at ${path}`);
54
+ return false;
55
+ }
56
+ } finally {
57
+ if (fd !== undefined) closeSync(fd);
58
+ if (existsSync(temporary)) unlinkSync(temporary);
59
+ }
60
+ }
61
+
62
+ export function fsyncParentDirectory(path: string): void {
63
+ const directory = dirname(path);
64
+ const fd = openSync(directory, "r");
65
+ try {
66
+ fsyncSync(fd);
67
+ } finally {
68
+ closeSync(fd);
69
+ }
70
+ }
71
+
72
+ export function readJsonRecord<T>(path: string, label: string): T {
73
+ if (!existsSync(path)) throw new Error(`${label} does not exist at ${path}`);
74
+ const size = statSync(path).size;
75
+ if (size <= 0 || size > JSON_LIMIT) throw new Error(`${label} has invalid size ${size}`);
76
+ try {
77
+ return JSON.parse(readFileSync(path, "utf8")) as T;
78
+ } catch (error) {
79
+ throw new Error(`cannot parse ${label} at ${path}: ${(error as Error).message}`);
80
+ }
81
+ }
82
+
83
+ function canonicalize(value: unknown): unknown {
84
+ if (Array.isArray(value)) return value.map(canonicalize);
85
+ if (value && typeof value === "object") {
86
+ return Object.fromEntries(
87
+ Object.entries(value as Record<string, unknown>)
88
+ .filter(([, item]) => item !== undefined)
89
+ .sort(([a], [b]) => a.localeCompare(b))
90
+ .map(([key, item]) => [key, canonicalize(item)]),
91
+ );
92
+ }
93
+ return value;
94
+ }