@gajae-code/coding-agent 0.12.10 → 0.12.11

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.
@@ -7,10 +7,10 @@ import {
7
7
  applyOwnerOnlyPathSecurity,
8
8
  exactRemoveDirectoryTree,
9
9
  exactReplacePath,
10
- exactRestore,
11
10
  exactUnlink,
12
11
  linkNoReplacePath,
13
12
  type NativeDirectoryTreeSnapshot,
13
+ type NativeExactUnlinkResult,
14
14
  type NativeOwnerOnlySecurityResult,
15
15
  openRecoveryFsRoot,
16
16
  type RecoveryFsRoot,
@@ -55,6 +55,28 @@ export class ManagedPublishError extends Error {
55
55
  this.diagnostic = formatNativePublishDiagnostic(outcome);
56
56
  }
57
57
  }
58
+ export class ManagedReplaceError extends Error {
59
+ readonly code: string;
60
+ readonly cleanupReceiptPath: string;
61
+ readonly detachedPath: string | undefined;
62
+ readonly retainedSuccessorPath: string | undefined;
63
+ readonly retainedPlaceholderPath: string | undefined;
64
+ readonly retainedUnknownPath: string | undefined;
65
+ readonly replacementCause: unknown;
66
+
67
+ constructor(result: NativeExactUnlinkResult, cleanupReceiptPath: string, replacementCause?: unknown) {
68
+ const code = result.code ?? "unknown";
69
+ super(`managed_replace_failed:${code}`, replacementCause === undefined ? undefined : { cause: replacementCause });
70
+ this.name = "ManagedReplaceError";
71
+ this.code = code;
72
+ this.cleanupReceiptPath = cleanupReceiptPath;
73
+ this.detachedPath = result.detachedPath;
74
+ this.retainedSuccessorPath = result.retainedSuccessorPath;
75
+ this.retainedPlaceholderPath = result.retainedPlaceholderPath;
76
+ this.retainedUnknownPath = result.retainedUnknownPath;
77
+ this.replacementCause = replacementCause;
78
+ }
79
+ }
58
80
 
59
81
  function publishFailure(outcome: NativePublishOutcome): ManagedPublishError {
60
82
  const classification =
@@ -74,6 +96,17 @@ function publishFailure(outcome: NativePublishOutcome): ManagedPublishError {
74
96
  return new ManagedPublishError(classification, outcome);
75
97
  }
76
98
 
99
+ function exactUnlinkCompleted(result: NativeExactUnlinkResult): boolean {
100
+ return (
101
+ result.ok ||
102
+ (result.code === "cleanup_pending" &&
103
+ result.payloadDurable === true &&
104
+ result.detachedPath !== undefined &&
105
+ result.retainedSuccessorPath === undefined &&
106
+ result.retainedUnknownPath === undefined)
107
+ );
108
+ }
109
+
77
110
  /** A same-filesystem rename updates the moved root's ctime but no other tree identity. */
78
111
  function sameDirectoryTreeSnapshotAfterRename(
79
112
  left: NativeDirectoryTreeSnapshot,
@@ -166,27 +199,69 @@ export interface ManagedFileSnapshot {
166
199
  };
167
200
  }
168
201
 
202
+ type SerializedReplacementIdentity = {
203
+ dev: string;
204
+ ino: string;
205
+ nlink: string;
206
+ size: string;
207
+ mtimeNs: string;
208
+ ctimeNs: string;
209
+ sha256: string;
210
+ };
211
+
169
212
  type ReplacementCleanupReceipt = {
170
- version: 1;
171
- predecessor: string;
172
- successor: string;
173
- identity: {
174
- dev: string;
175
- ino: string;
176
- nlink: string;
177
- size: string;
178
- mtimeNs: string;
179
- ctimeNs: string;
180
- sha256: string;
181
- };
213
+ version: 3;
214
+ staging: string;
215
+ destination: string;
216
+ successor: SerializedReplacementIdentity;
217
+ predecessor: SerializedReplacementIdentity;
182
218
  };
183
219
 
184
220
  const U64_MAX = 18_446_744_073_709_551_615n;
185
221
 
186
- function parseCanonicalU64(value: unknown): bigint | undefined {
187
- if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return undefined;
188
- const parsed = BigInt(value);
189
- return parsed <= U64_MAX ? parsed : undefined;
222
+ function replacementCleanupReceiptBinding(
223
+ name: string,
224
+ ): { predecessor: { dev: bigint; ino: bigint }; receipt: { dev: bigint; ino: bigint } } | undefined {
225
+ const match =
226
+ /^\.gjc-replace-cleanup-(0|[1-9a-f][0-9a-f]*)-(0|[1-9a-f][0-9a-f]*)-receipt-(0|[1-9a-f][0-9a-f]*)-(0|[1-9a-f][0-9a-f]*)\.json$/.exec(
227
+ name,
228
+ );
229
+ if (!match?.[1] || !match[2] || !match[3] || !match[4]) return undefined;
230
+ const predecessor = { dev: BigInt(`0x${match[1]}`), ino: BigInt(`0x${match[2]}`) };
231
+ const receipt = { dev: BigInt(`0x${match[3]}`), ino: BigInt(`0x${match[4]}`) };
232
+ return predecessor.dev <= U64_MAX && predecessor.ino <= U64_MAX && receipt.dev <= U64_MAX && receipt.ino <= U64_MAX
233
+ ? { predecessor, receipt }
234
+ : undefined;
235
+ }
236
+
237
+ function serializeReplacementIdentity(identity: ManagedFileSnapshot["identity"]): SerializedReplacementIdentity {
238
+ return {
239
+ dev: identity.dev.toString(),
240
+ ino: identity.ino.toString(),
241
+ nlink: identity.nlink.toString(),
242
+ size: String(identity.size),
243
+ mtimeNs: identity.mtimeNs.toString(),
244
+ ctimeNs: identity.ctimeNs.toString(),
245
+ sha256: identity.sha256,
246
+ };
247
+ }
248
+
249
+ function replacementReceiptPath(
250
+ baseDir: string,
251
+ predecessor: ManagedFileSnapshot["identity"],
252
+ receipt: ManagedFileSnapshot["identity"],
253
+ ): string {
254
+ return path.join(
255
+ baseDir,
256
+ `.gjc-replace-cleanup-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}-receipt-${receipt.dev.toString(16)}-${receipt.ino.toString(16)}.json`,
257
+ );
258
+ }
259
+
260
+ function replacementReceiptRetirementName(
261
+ receipt: ManagedFileSnapshot["identity"],
262
+ predecessor: ManagedFileSnapshot["identity"],
263
+ ): string {
264
+ return `.gjc-receipt-remove-${receipt.dev.toString(16)}-${receipt.ino.toString(16)}-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}`;
190
265
  }
191
266
 
192
267
  const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
@@ -710,72 +785,44 @@ export class ManagedSessionDescendantStore {
710
785
  }
711
786
  }
712
787
 
788
+ #detachReplacementCleanupReceipt(receiptPath: string): void {
789
+ const binding = replacementCleanupReceiptBinding(path.basename(receiptPath));
790
+ if (!binding) throw new Error("managed_replace_cleanup_receipt_invalid");
791
+ const receipt = captureManagedFileNoFollow(receiptPath);
792
+ if (receipt.identity.dev !== binding.receipt.dev || receipt.identity.ino !== binding.receipt.ino)
793
+ throw new Error("managed_replace_cleanup_receipt_invalid");
794
+ const predecessor = binding.predecessor;
795
+ const quarantineName = `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}`;
796
+ const detached = exactUnlink(receiptPath, {
797
+ dev: receipt.identity.dev,
798
+ ino: receipt.identity.ino,
799
+ nlink: receipt.identity.nlink,
800
+ parentDev: this.#subtreeRoot.dev,
801
+ parentIno: this.#subtreeRoot.ino,
802
+ size: BigInt(receipt.identity.size),
803
+ mtimeNs: receipt.identity.mtimeNs,
804
+ sha256: receipt.identity.sha256,
805
+ detachOnly: true,
806
+ quarantineName,
807
+ });
808
+ if (
809
+ (!detached.ok && detached.code !== "cleanup_pending") ||
810
+ detached.detachedPath !== path.join(this.#baseDir, quarantineName) ||
811
+ detached.retainedSuccessorPath ||
812
+ detached.retainedUnknownPath
813
+ )
814
+ throw new Error(`managed_replace_receipt_cleanup_pending:${detached.code ?? "unknown"}`);
815
+ fsyncDirectory(this.#baseDir);
816
+ }
817
+
713
818
  #reconcileReplacementCleanupReceipts(): void {
714
819
  if (this.#reconcilingReplacementCleanup) return;
715
820
  this.#reconcilingReplacementCleanup = true;
716
821
  try {
717
822
  for (const name of fs.readdirSync(this.#baseDir)) {
718
- const match = /^\.gjc-replace-cleanup-([0-9a-f]+)-([0-9a-f]+)\.json$/.exec(name);
719
- if (!match) continue;
720
- const receiptPath = path.join(this.#baseDir, name);
721
- const parsed = JSON.parse(
722
- captureManagedFileNoFollow(receiptPath).bytes.toString("utf8"),
723
- ) as Partial<ReplacementCleanupReceipt>;
724
- const identity = parsed.identity;
725
- const dev = parseCanonicalU64(identity?.dev);
726
- const ino = parseCanonicalU64(identity?.ino);
727
- const nlink = parseCanonicalU64(identity?.nlink);
728
- const size = parseCanonicalU64(identity?.size);
729
- const mtimeNs = parseCanonicalU64(identity?.mtimeNs);
730
- const ctimeNs = parseCanonicalU64(identity?.ctimeNs);
731
- const expectedPredecessor =
732
- dev !== undefined && ino !== undefined
733
- ? path.join(this.#baseDir, `.gjc-exact-replace-destination-${dev.toString(16)}-${ino.toString(16)}`)
734
- : undefined;
735
- if (
736
- parsed.version !== 1 ||
737
- dev === undefined ||
738
- ino === undefined ||
739
- nlink === undefined ||
740
- size === undefined ||
741
- mtimeNs === undefined ||
742
- ctimeNs === undefined ||
743
- typeof identity?.sha256 !== "string" ||
744
- !match[1] ||
745
- !match[2] ||
746
- BigInt(`0x${match[1]}`) !== dev ||
747
- BigInt(`0x${match[2]}`) !== ino ||
748
- typeof parsed.predecessor !== "string" ||
749
- path.resolve(parsed.predecessor) !== expectedPredecessor ||
750
- typeof parsed.successor !== "string" ||
751
- path.dirname(path.resolve(parsed.successor)) !== this.#baseDir
752
- )
753
- throw new Error("managed_replace_cleanup_receipt_invalid");
754
- const retired = exactUnlink(expectedPredecessor, {
755
- dev,
756
- ino,
757
- nlink,
758
- parentDev: this.#subtreeRoot.dev,
759
- parentIno: this.#subtreeRoot.ino,
760
- size,
761
- mtimeNs,
762
- sha256: identity.sha256,
763
- quarantineName: `.gjc-replace-retry-${dev.toString(16)}-${ino.toString(16)}`,
764
- });
765
- if (!retired.ok) throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`);
766
- const receipt = captureManagedFileNoFollow(receiptPath);
767
- const removed = exactUnlink(receiptPath, {
768
- dev: receipt.identity.dev,
769
- ino: receipt.identity.ino,
770
- nlink: receipt.identity.nlink,
771
- parentDev: this.#subtreeRoot.dev,
772
- parentIno: this.#subtreeRoot.ino,
773
- size: BigInt(receipt.identity.size),
774
- mtimeNs: receipt.identity.mtimeNs,
775
- sha256: receipt.identity.sha256,
776
- quarantineName: `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}`,
777
- });
778
- if (!removed.ok) throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`);
823
+ if (!name.startsWith(".gjc-replace-cleanup-")) continue;
824
+ if (!replacementCleanupReceiptBinding(name)) throw new Error("managed_replace_cleanup_receipt_invalid");
825
+ this.#detachReplacementCleanupReceipt(path.join(this.#baseDir, name));
779
826
  }
780
827
  } finally {
781
828
  this.#reconcilingReplacementCleanup = false;
@@ -861,21 +908,7 @@ export class ManagedSessionDescendantStore {
861
908
  const current = captureManagedFileNoFollow(resolved);
862
909
  if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
863
910
  throw new Error("managed_replace_identity_mismatch");
864
- replaceManagedFileSync(
865
- resolved,
866
- bytes,
867
- this.#subtreeRoot,
868
- this.#policy,
869
- () => {
870
- const currentAtCommit = captureManagedFileNoFollow(resolved);
871
- if (
872
- !sameIdentity(currentAtCommit.identity, expected.identity) ||
873
- currentAtCommit.identity.sha256 !== expected.identity.sha256
874
- )
875
- throw new Error("managed_replace_identity_mismatch");
876
- },
877
- expected.identity,
878
- );
911
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
879
912
  return;
880
913
  }
881
914
  const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
@@ -914,7 +947,7 @@ export class ManagedSessionDescendantStore {
914
947
  this.#beforeMutation();
915
948
  this.#assertBound();
916
949
  const resolved = this.#resolve(relativePath);
917
- let existing = this.readExpected(relativePath);
950
+ const existing = this.readExpected(relativePath);
918
951
  if (!existing) throw new Error("managed_append_missing");
919
952
  if (this.#authority) {
920
953
  const appended = this.#authority.appendManaged(
@@ -931,52 +964,18 @@ export class ManagedSessionDescendantStore {
931
964
  this.#assertBound();
932
965
  return;
933
966
  }
934
- // On Darwin, the first write-append open can change only ctime (e.g. com.apple.provenance)
935
- // while leaving dev/ino/size/mtime/content intact. Allow one bounded refresh+retry for that
936
- // transition only; any other identity change or a second ctime transition stays fail-closed.
937
- // See https://github.com/Yeachan-Heo/gajae-code/issues/2944
938
- let fd: number | undefined;
939
- let darwinCtimeRefreshConsumed = false;
940
- try {
941
- for (;;) {
942
- fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
943
- const before = identity(fs.fstatSync(fd, { bigint: true }));
944
- if (!sameIdentity(before, existing.identity)) {
945
- const allowDarwinCtimeRefresh =
946
- process.platform === "darwin" &&
947
- !darwinCtimeRefreshConsumed &&
948
- isCtimeOnlyIdentityMismatch(before, existing.identity);
949
- if (!allowDarwinCtimeRefresh) throw new Error("identity_mismatch");
950
- fs.closeSync(fd);
951
- fd = undefined;
952
- darwinCtimeRefreshConsumed = true;
953
- const original = existing;
954
- const refreshed = this.readExpected(relativePath);
955
- if (
956
- !refreshed ||
957
- !sameStableIdentityIgnoringCtime(refreshed.identity, original.identity) ||
958
- refreshed.identity.sha256 !== original.identity.sha256 ||
959
- !refreshed.bytes.equals(original.bytes)
960
- ) {
961
- throw new Error("identity_mismatch");
962
- }
963
- existing = refreshed;
964
- continue;
965
- }
966
- secureFileDescriptor(resolved, fd, "verify");
967
- let offset = 0;
968
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
969
- fs.fsyncSync(fd);
970
- secureFileDescriptor(resolved, fd, "verify");
971
- const after = identity(fs.fstatSync(fd, { bigint: true }));
972
- const named = identity(fs.lstatSync(resolved, { bigint: true }));
973
- if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
974
- throw new Error("identity_mismatch");
975
- break;
976
- }
977
- } finally {
978
- if (fd !== undefined) fs.closeSync(fd);
979
- }
967
+ // Darwin has no retained native root authority. Do not append in place:
968
+ // a short write can leave a malformed JSONL tail with no safe recovery
969
+ // boundary. Replace the exact captured file instead so the old transcript
970
+ // or the complete successor remains recoverable after every reported error.
971
+ replaceManagedFileSync(
972
+ resolved,
973
+ Buffer.concat([existing.bytes, Buffer.from(bytes)]),
974
+ this.#subtreeRoot,
975
+ this.#policy,
976
+ undefined,
977
+ existing.identity,
978
+ );
980
979
  this.#assertBound();
981
980
  }
982
981
 
@@ -1425,25 +1424,19 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
1425
1424
  left.ctimeNs === right.ctimeNs
1426
1425
  );
1427
1426
  }
1428
-
1429
- /** Content/path identity that remains meaningful across a Darwin write-open ctime-only transition. */
1430
- function sameStableIdentityIgnoringCtime(
1427
+ function sameReplacementIdentity(
1431
1428
  left: ManagedFileSnapshot["identity"],
1432
1429
  right: ManagedFileSnapshot["identity"],
1433
1430
  ): boolean {
1434
1431
  return (
1435
- left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs
1432
+ left.dev === right.dev &&
1433
+ left.ino === right.ino &&
1434
+ left.nlink === right.nlink &&
1435
+ left.size === right.size &&
1436
+ left.mtimeNs === right.mtimeNs
1436
1437
  );
1437
1438
  }
1438
1439
 
1439
- /** True when observed identity matches expected on every field except ctimeNs. */
1440
- function isCtimeOnlyIdentityMismatch(
1441
- observed: ManagedFileSnapshot["identity"],
1442
- expected: ManagedFileSnapshot["identity"],
1443
- ): boolean {
1444
- return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
1445
- }
1446
-
1447
1440
  function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
1448
1441
  try {
1449
1442
  const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
@@ -1668,12 +1661,13 @@ export function publishManagedFileNoReplaceSync(
1668
1661
  bytes: Uint8Array,
1669
1662
  root?: ManagedDirectoryRoot,
1670
1663
  policy: ManagedSessionSecurityPolicy = "default",
1671
- ): void {
1664
+ ): ManagedFileSnapshot["identity"] {
1672
1665
  const parent = path.dirname(destination);
1673
1666
  ensureManagedDirectory(parent, root, policy);
1674
1667
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
1675
1668
  let fd: number | undefined;
1676
1669
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1670
+ let publishedIdentity: ManagedFileSnapshot["identity"] | undefined;
1677
1671
  let failure: unknown;
1678
1672
  let outcome: NativePublishOutcome | undefined;
1679
1673
  let linkPublished = false;
@@ -1691,6 +1685,7 @@ export function publishManagedFileNoReplaceSync(
1691
1685
  secureFileDescriptor(staging, fd, "verify");
1692
1686
  const staged = fs.fstatSync(fd, { bigint: true });
1693
1687
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1688
+ publishedIdentity = identity(staged, createHash("sha256").update(bytes).digest("hex"));
1694
1689
 
1695
1690
  outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1696
1691
  if (renameFlagsUnsupported(outcome)) {
@@ -1726,6 +1721,8 @@ export function publishManagedFileNoReplaceSync(
1726
1721
  }
1727
1722
  }
1728
1723
  if (failure !== undefined) throw failure;
1724
+ if (!publishedIdentity) throw new Error("managed_publish_identity_unavailable");
1725
+ return publishedIdentity;
1729
1726
  }
1730
1727
 
1731
1728
  /** Replace one managed regular file while retaining the secured staging fd through publication. */
@@ -1743,6 +1740,16 @@ export function replaceManagedFileSync(
1743
1740
  let fd: number | undefined;
1744
1741
  let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
1745
1742
  let preserveStaging = false;
1743
+ let receiptCleanup:
1744
+ | {
1745
+ path: string;
1746
+ parentDev: bigint;
1747
+ parentIno: bigint;
1748
+ identity: ManagedFileSnapshot["identity"];
1749
+ predecessor: ManagedFileSnapshot["identity"];
1750
+ }
1751
+ | undefined;
1752
+ let failure: unknown;
1746
1753
  try {
1747
1754
  fd = fs.openSync(
1748
1755
  staging,
@@ -1762,23 +1769,54 @@ export function replaceManagedFileSync(
1762
1769
  }
1763
1770
  assertManagedDirectoryRoot(root);
1764
1771
  assertFence?.();
1765
- if (process.platform !== "win32" && expectedDestination) {
1766
- throw new Error("managed_replace_exact_unavailable");
1767
- }
1768
- if (process.platform === "win32" && expectedDestination) {
1772
+ if (expectedDestination) {
1769
1773
  const parentIdentity = fs.lstatSync(parent, { bigint: true });
1774
+ const successor = identity(staged, createHash("sha256").update(bytes).digest("hex"));
1775
+ const receiptStagingPath = path.join(parent, `.gjc-replace-receipt-pending-${randomUUID()}.json`);
1776
+ preserveStaging = true;
1777
+ const publishedReceiptIdentity = publishManagedFileNoReplaceSync(
1778
+ receiptStagingPath,
1779
+ Buffer.from(
1780
+ JSON.stringify({
1781
+ version: 3,
1782
+ staging,
1783
+ destination,
1784
+ successor: serializeReplacementIdentity(successor),
1785
+ predecessor: serializeReplacementIdentity(expectedDestination),
1786
+ } satisfies ReplacementCleanupReceipt),
1787
+ ),
1788
+ root,
1789
+ policy,
1790
+ );
1791
+ const receiptPath = replacementReceiptPath(parent, expectedDestination, publishedReceiptIdentity);
1792
+ const receiptPublish = classifyNativePublishOutcome(renameNoReplacePath(receiptStagingPath, receiptPath));
1793
+ if (!receiptPublish.ok) throw publishFailure(receiptPublish);
1794
+ const namedReceipt = captureManagedFileNoFollow(receiptPath);
1795
+ if (
1796
+ namedReceipt.identity.dev !== publishedReceiptIdentity.dev ||
1797
+ namedReceipt.identity.ino !== publishedReceiptIdentity.ino
1798
+ )
1799
+ throw new Error("managed_replace_cleanup_receipt_identity_changed");
1800
+ fsyncDirectory(parent);
1801
+ receiptCleanup = {
1802
+ path: receiptPath,
1803
+ parentDev: parentIdentity.dev,
1804
+ parentIno: parentIdentity.ino,
1805
+ identity: publishedReceiptIdentity,
1806
+ predecessor: expectedDestination,
1807
+ };
1770
1808
  const replaced = exactReplacePath(
1771
1809
  staging,
1772
1810
  destination,
1773
1811
  {
1774
- dev: staged.dev,
1775
- ino: staged.ino,
1776
- nlink: staged.nlink,
1812
+ dev: successor.dev,
1813
+ ino: successor.ino,
1814
+ nlink: successor.nlink,
1777
1815
  parentDev: parentIdentity.dev,
1778
1816
  parentIno: parentIdentity.ino,
1779
- size: staged.size,
1780
- mtimeNs: staged.mtimeNs,
1781
- sha256: createHash("sha256").update(bytes).digest("hex"),
1817
+ size: BigInt(successor.size),
1818
+ mtimeNs: successor.mtimeNs,
1819
+ sha256: successor.sha256,
1782
1820
  },
1783
1821
  {
1784
1822
  dev: expectedDestination.dev,
@@ -1791,88 +1829,12 @@ export function replaceManagedFileSync(
1791
1829
  sha256: expectedDestination.sha256,
1792
1830
  },
1793
1831
  );
1794
- if (!replaced.ok) {
1795
- const committedWithPredecessor =
1796
- replaced.detachedPath === replaced.retainedPlaceholderPath &&
1797
- replaced.retainedSuccessorPath === destination;
1798
- if (committedWithPredecessor) {
1799
- const retired = exactUnlink(replaced.retainedPlaceholderPath!, {
1800
- dev: expectedDestination.dev,
1801
- ino: expectedDestination.ino,
1802
- nlink: expectedDestination.nlink,
1803
- parentDev: parentIdentity.dev,
1804
- parentIno: parentIdentity.ino,
1805
- size: BigInt(expectedDestination.size),
1806
- mtimeNs: expectedDestination.mtimeNs,
1807
- sha256: expectedDestination.sha256,
1808
- quarantineName: `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}`,
1809
- });
1810
- if (!retired.ok) {
1811
- const predecessor =
1812
- retired.detachedPath ?? retired.retainedPlaceholderPath ?? replaced.retainedPlaceholderPath;
1813
- const receiptPath = path.join(
1814
- parent,
1815
- `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}.json`,
1816
- );
1817
- publishManagedFileNoReplaceSync(
1818
- receiptPath,
1819
- Buffer.from(
1820
- JSON.stringify({
1821
- version: 1,
1822
- predecessor,
1823
- successor: destination,
1824
- identity: {
1825
- dev: expectedDestination.dev.toString(),
1826
- ino: expectedDestination.ino.toString(),
1827
- nlink: expectedDestination.nlink.toString(),
1828
- size: String(expectedDestination.size),
1829
- mtimeNs: expectedDestination.mtimeNs.toString(),
1830
- ctimeNs: expectedDestination.ctimeNs.toString(),
1831
- sha256: expectedDestination.sha256,
1832
- },
1833
- }),
1834
- ),
1835
- root,
1836
- policy,
1837
- );
1838
- throw new Error(
1839
- `managed_replace_cleanup_pending:receipt=${receiptPath}:predecessor=${predecessor}:successor=${destination}:code=${retired.code ?? "unknown"}`,
1840
- );
1841
- }
1842
- }
1843
- if (!committedWithPredecessor) {
1844
- if (replaced.detachedPath) {
1845
- const restored = exactRestore(replaced.detachedPath, staging, {
1846
- dev: staged.dev,
1847
- ino: staged.ino,
1848
- nlink: staged.nlink,
1849
- parentDev: parentIdentity.dev,
1850
- parentIno: parentIdentity.ino,
1851
- size: staged.size,
1852
- mtimeNs: staged.mtimeNs,
1853
- sha256: createHash("sha256").update(bytes).digest("hex"),
1854
- });
1855
- if (restored.ok) preserveStaging = true;
1856
- if (!restored.ok)
1857
- throw new Error(
1858
- `managed_replace_failed:${replaced.code ?? "unknown"}:retained=${replaced.detachedPath}:restore=${restored.code ?? "unknown"}`,
1859
- );
1860
- }
1861
- const retained = [
1862
- replaced.detachedPath,
1863
- replaced.retainedSuccessorPath,
1864
- replaced.retainedPlaceholderPath,
1865
- replaced.retainedUnknownPath,
1866
- ]
1867
- .filter((pathname): pathname is string => typeof pathname === "string")
1868
- .join(",");
1869
- throw new Error(
1870
- `managed_replace_failed:${replaced.code ?? "unknown"}${retained ? `:retained=${retained}` : ""}`,
1871
- );
1872
- }
1873
- }
1832
+ if (!replaced.ok) throw new ManagedReplaceError(replaced, receiptCleanup.path);
1833
+ const named = captureManagedFileNoFollow(destination);
1834
+ if (!sameReplacementIdentity(named.identity, successor) || named.identity.sha256 !== successor.sha256)
1835
+ throw new Error("destination_identity_changed");
1836
+ fsyncDirectory(parent);
1874
1837
  } else fs.renameSync(staging, destination);
1875
- assertFence?.();
1876
1838
  assertManagedDirectoryRoot(root);
1877
1839
  const named = fs.lstatSync(destination, { bigint: true });
1878
1840
  if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
@@ -1884,17 +1846,53 @@ export function replaceManagedFileSync(
1884
1846
  fd = undefined;
1885
1847
  }
1886
1848
  fsyncDirectory(parent);
1849
+ if (receiptCleanup) {
1850
+ try {
1851
+ const removed = exactUnlink(receiptCleanup.path, {
1852
+ dev: receiptCleanup.identity.dev,
1853
+ ino: receiptCleanup.identity.ino,
1854
+ nlink: receiptCleanup.identity.nlink,
1855
+ parentDev: receiptCleanup.parentDev,
1856
+ parentIno: receiptCleanup.parentIno,
1857
+ size: BigInt(receiptCleanup.identity.size),
1858
+ mtimeNs: receiptCleanup.identity.mtimeNs,
1859
+ sha256: receiptCleanup.identity.sha256,
1860
+ quarantineName: replacementReceiptRetirementName(receiptCleanup.identity, receiptCleanup.predecessor),
1861
+ });
1862
+ if (!exactUnlinkCompleted(removed) && removed.code !== "not_found")
1863
+ throw new ManagedReplaceError(removed, receiptCleanup.path);
1864
+ try {
1865
+ fsyncDirectory(parent);
1866
+ } catch (error) {
1867
+ throw new ManagedReplaceError(
1868
+ { ...removed, ok: false, code: "durability_failed" },
1869
+ receiptCleanup.path,
1870
+ error,
1871
+ );
1872
+ }
1873
+ } catch (error) {
1874
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1875
+ }
1876
+ }
1877
+ } catch (error) {
1878
+ failure = error;
1887
1879
  } finally {
1888
1880
  if (fd !== undefined) fs.closeSync(fd);
1889
1881
  if (stagedIdentity && !preserveStaging) {
1890
1882
  try {
1891
1883
  const named = fs.lstatSync(staging, { bigint: true });
1892
1884
  if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
1893
- } catch {
1894
- // Never delete an object whose staging identity is no longer proven.
1885
+ } catch (error) {
1886
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1887
+ failure =
1888
+ failure === undefined
1889
+ ? error
1890
+ : new AggregateError([failure, error], "Managed replacement and staging cleanup both failed.");
1891
+ }
1895
1892
  }
1896
1893
  }
1897
1894
  }
1895
+ if (failure !== undefined) throw failure;
1898
1896
  }
1899
1897
 
1900
1898
  export async function replaceManagedFile(