@indigoai-us/hq-cloud 6.14.6 → 6.14.8

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 (97) hide show
  1. package/dist/backup-prune.d.ts +68 -0
  2. package/dist/backup-prune.d.ts.map +1 -0
  3. package/dist/backup-prune.js +196 -0
  4. package/dist/backup-prune.js.map +1 -0
  5. package/dist/backup-prune.test.d.ts +2 -0
  6. package/dist/backup-prune.test.d.ts.map +1 -0
  7. package/dist/backup-prune.test.js +89 -0
  8. package/dist/backup-prune.test.js.map +1 -0
  9. package/dist/bin/backup-prune-runner.d.ts +3 -0
  10. package/dist/bin/backup-prune-runner.d.ts.map +1 -0
  11. package/dist/bin/backup-prune-runner.js +50 -0
  12. package/dist/bin/backup-prune-runner.js.map +1 -0
  13. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-watch-loop.js +28 -0
  15. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  16. package/dist/bin/sync-runner.d.ts +2 -0
  17. package/dist/bin/sync-runner.d.ts.map +1 -1
  18. package/dist/bin/sync-runner.js.map +1 -1
  19. package/dist/cli/reindex.d.ts.map +1 -1
  20. package/dist/cli/reindex.js +5 -3
  21. package/dist/cli/reindex.js.map +1 -1
  22. package/dist/cli/rescue-classify-ordering.test.js +105 -0
  23. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  24. package/dist/cli/rescue-core.d.ts +7 -0
  25. package/dist/cli/rescue-core.d.ts.map +1 -1
  26. package/dist/cli/rescue-core.js +509 -282
  27. package/dist/cli/rescue-core.js.map +1 -1
  28. package/dist/cli/rescue-snapshot.d.ts +14 -0
  29. package/dist/cli/rescue-snapshot.d.ts.map +1 -0
  30. package/dist/cli/rescue-snapshot.js +39 -0
  31. package/dist/cli/rescue-snapshot.js.map +1 -0
  32. package/dist/cli/rescue-snapshot.test.d.ts +2 -0
  33. package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
  34. package/dist/cli/rescue-snapshot.test.js +46 -0
  35. package/dist/cli/rescue-snapshot.test.js.map +1 -0
  36. package/dist/cli/rescue.test.js +24 -0
  37. package/dist/cli/rescue.test.js.map +1 -1
  38. package/dist/cli/share.d.ts.map +1 -1
  39. package/dist/cli/share.js +12 -11
  40. package/dist/cli/share.js.map +1 -1
  41. package/dist/cli/sync.d.ts.map +1 -1
  42. package/dist/cli/sync.js +41 -6
  43. package/dist/cli/sync.js.map +1 -1
  44. package/dist/cli/sync.test.js +51 -0
  45. package/dist/cli/sync.test.js.map +1 -1
  46. package/dist/local-path-codec.d.ts +19 -0
  47. package/dist/local-path-codec.d.ts.map +1 -0
  48. package/dist/local-path-codec.js +69 -0
  49. package/dist/local-path-codec.js.map +1 -0
  50. package/dist/object-io.d.ts +19 -2
  51. package/dist/object-io.d.ts.map +1 -1
  52. package/dist/object-io.js +168 -32
  53. package/dist/object-io.js.map +1 -1
  54. package/dist/object-io.test.js +157 -1
  55. package/dist/object-io.test.js.map +1 -1
  56. package/dist/s3.d.ts +8 -0
  57. package/dist/s3.d.ts.map +1 -1
  58. package/dist/s3.js +52 -22
  59. package/dist/s3.js.map +1 -1
  60. package/dist/scope-shrink.d.ts.map +1 -1
  61. package/dist/scope-shrink.js +4 -3
  62. package/dist/scope-shrink.js.map +1 -1
  63. package/dist/signals/get.test.js +21 -1
  64. package/dist/signals/get.test.js.map +1 -1
  65. package/dist/signals/list.test.js +21 -1
  66. package/dist/signals/list.test.js.map +1 -1
  67. package/dist/sources/get.test.js +21 -1
  68. package/dist/sources/get.test.js.map +1 -1
  69. package/dist/sources/list.test.js +21 -1
  70. package/dist/sources/list.test.js.map +1 -1
  71. package/package.json +3 -2
  72. package/scripts/presign-transport-e2e.mjs +57 -10
  73. package/scripts/vault-rebaseline.sh +49 -1
  74. package/scripts/vault-rescue.sh +42 -1
  75. package/src/backup-prune.test.ts +98 -0
  76. package/src/backup-prune.ts +182 -0
  77. package/src/bin/backup-prune-runner.ts +33 -0
  78. package/src/bin/sync-runner-watch-loop.ts +18 -0
  79. package/src/bin/sync-runner.ts +2 -0
  80. package/src/cli/reindex.ts +5 -3
  81. package/src/cli/rescue-classify-ordering.test.ts +121 -0
  82. package/src/cli/rescue-core.ts +311 -89
  83. package/src/cli/rescue-snapshot.test.ts +57 -0
  84. package/src/cli/rescue-snapshot.ts +51 -0
  85. package/src/cli/rescue.test.ts +34 -0
  86. package/src/cli/share.ts +12 -11
  87. package/src/cli/sync.test.ts +62 -0
  88. package/src/cli/sync.ts +51 -7
  89. package/src/local-path-codec.ts +95 -0
  90. package/src/object-io.test.ts +175 -0
  91. package/src/object-io.ts +213 -32
  92. package/src/s3.ts +61 -12
  93. package/src/scope-shrink.ts +4 -3
  94. package/src/signals/get.test.ts +26 -2
  95. package/src/signals/list.test.ts +26 -2
  96. package/src/sources/get.test.ts +26 -2
  97. package/src/sources/list.test.ts +26 -2
@@ -0,0 +1,51 @@
1
+ /** Filesystem shape captured during rescue classification. */
2
+ export type RescuePathKind = "missing" | "file" | "directory" | "symlink" | "other";
3
+
4
+
5
+ /** Raised when a path changes shape between read-only classification and apply. */
6
+ export class RescuePathChangedError extends Error {
7
+ constructor(
8
+ public readonly rel: string,
9
+ public readonly expected: RescuePathKind,
10
+ public readonly actual: RescuePathKind,
11
+ ) {
12
+ super(
13
+ `rescue path changed after classification: ${rel} (expected ${expected}, found ${actual}); ` +
14
+ "another process may be rebuilding generated HQ paths, so rescue stopped before mutation",
15
+ );
16
+ this.name = "RescuePathChangedError";
17
+ }
18
+ }
19
+
20
+ /** Return whether a directory is reproducible state that rescue must not snapshot or classify. */
21
+ export function isRescueTransientDirectory(rel: string): boolean {
22
+ const normalized = rel.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
23
+ if (
24
+ normalized === "worktrees" ||
25
+ normalized.startsWith("worktrees/") ||
26
+ normalized === ".worktrees" ||
27
+ normalized.startsWith(".worktrees/") ||
28
+ normalized === ".sst-sandbox-home" ||
29
+ normalized.startsWith(".sst-sandbox-home/") ||
30
+ normalized === ".tmp-codex-asar" ||
31
+ normalized.startsWith(".tmp-codex-asar/") ||
32
+ normalized === ".claude/worktrees" ||
33
+ normalized.startsWith(".claude/worktrees/")
34
+ ) {
35
+ return true;
36
+ }
37
+ return normalized
38
+ .split("/")
39
+ .some((part) =>
40
+ ["node_modules", ".git", ".pnpm-store", ".sst", ".next", "target"].includes(part),
41
+ );
42
+ }
43
+
44
+ /** Fail safely when apply observes a different path shape than classification did. */
45
+ export function assertRescuePathTypeUnchanged(
46
+ rel: string,
47
+ expected: RescuePathKind,
48
+ actual: RescuePathKind,
49
+ ): void {
50
+ if (expected !== actual) throw new RescuePathChangedError(rel, expected, actual);
51
+ }
@@ -1,5 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { buildRescueArgs, extractLockTimeout } from "./rescue.js";
3
+ import { toCygwinRsyncPath } from "./rescue-core.js";
4
+ import {
5
+ decodeLocalVaultSegment,
6
+ encodeLocalVaultSegment,
7
+ localPathForVaultKey,
8
+ vaultKeyForLocalPath,
9
+ } from "../local-path-codec.js";
3
10
 
4
11
  describe("extractLockTimeout", () => {
5
12
  it("returns the explicit option untouched when extraArgs is empty/absent", () => {
@@ -95,3 +102,30 @@ describe("buildRescueArgs", () => {
95
102
  expect(buildRescueArgs({ paths: [] })).toEqual([]);
96
103
  });
97
104
  });
105
+
106
+ describe("Windows sync path adapters", () => {
107
+ it("converts drive-letter and UNC rsync arguments to Cygwin paths", () => {
108
+ expect(toCygwinRsyncPath("C:\\HQ\\core\\")).toBe("/cygdrive/c/HQ/core/");
109
+ expect(toCygwinRsyncPath("D:/HQ/workspace")).toBe("/cygdrive/d/HQ/workspace");
110
+ expect(toCygwinRsyncPath("\\\\fileserver\\hq-share\\HQ\\")).toBe(
111
+ "//fileserver/hq-share/HQ/",
112
+ );
113
+ });
114
+
115
+ it("round-trips Windows-safe local names without changing canonical vault keys", () => {
116
+ const win32 = true;
117
+ expect(encodeLocalVaultSegment("core:skill", win32)).toBe("core%3Askill");
118
+ expect(encodeLocalVaultSegment("core%3Askill", win32)).toBe("core%253Askill");
119
+ expect(encodeLocalVaultSegment("core\u0000skill", win32)).toBe("core%00skill");
120
+ expect(decodeLocalVaultSegment("core%3Askill", win32)).toBe("core:skill");
121
+
122
+ // Pull materializes the key under a safe filename, while the journal and
123
+ // the following push retain the original canonical S3 key.
124
+ const canonicalKey = "factory:slack/apps/web/oauth-token.json";
125
+ const pulledLocalPath = localPathForVaultKey("C:\\HQ\\companies\\acme", canonicalKey, win32);
126
+ expect(pulledLocalPath).toContain("factory%3Aslack");
127
+ const journalKey = canonicalKey;
128
+ const pushedKey = vaultKeyForLocalPath("C:\\HQ\\companies\\acme", pulledLocalPath, win32);
129
+ expect(pushedKey).toBe(journalKey);
130
+ });
131
+ });
package/src/cli/share.ts CHANGED
@@ -11,6 +11,7 @@ import * as path from "path";
11
11
  import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
12
12
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
13
13
  import { createSyncProgressRecorder } from "../sync-progress.js";
14
+ import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
14
15
  import {
15
16
  uploadFile,
16
17
  uploadSymlink,
@@ -788,7 +789,7 @@ function wrapFilterWithScope(
788
789
  ): (absPath: string, isDir?: boolean) => boolean {
789
790
  return (absPath: string, isDir?: boolean) => {
790
791
  if (!underlying(absPath, isDir)) return false;
791
- const rel = path.relative(syncRoot, absPath).split(path.sep).join("/");
792
+ const rel = vaultKeyForLocalPath(syncRoot, absPath);
792
793
  if (rel === "" || rel.startsWith("..")) return true; // root / outside — defer
793
794
  if (isDir) {
794
795
  if (isDirInScope(rel, prefixSet)) return true;
@@ -819,7 +820,7 @@ export function wrapFilterWithIgnoreVisibility(
819
820
  if (allowed) return true;
820
821
 
821
822
  onAnyExcluded?.();
822
- const rel = path.relative(hqRoot, absPath).split(path.sep).join("/");
823
+ const rel = vaultKeyForLocalPath(hqRoot, absPath);
823
824
  if (rel === "" || rel.startsWith("..")) return false;
824
825
  if (!isExpectedIgnore(rel)) onIgnoreExcluded(rel);
825
826
  return false;
@@ -1456,13 +1457,13 @@ async function writePushConflictMirror(
1456
1457
  try {
1457
1458
  const detectedAt = new Date().toISOString();
1458
1459
  const machineId = readShortMachineId(run.hqRoot);
1459
- const originalRelative = path.relative(run.hqRoot, item.absolutePath);
1460
+ const originalRelative = vaultKeyForLocalPath(run.hqRoot, item.absolutePath);
1460
1461
  const conflictRelative = buildConflictPath(
1461
1462
  originalRelative,
1462
1463
  detectedAt,
1463
1464
  machineId,
1464
1465
  );
1465
- const conflictAbs = path.join(run.hqRoot, conflictRelative);
1466
+ const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
1466
1467
  if (!isMaterializationPathStillContained(run.syncRoot, conflictAbs)) {
1467
1468
  run.emit({
1468
1469
  type: "error",
@@ -1787,7 +1788,7 @@ function collectFiles(
1787
1788
  console.error(` Warning: ${p} is outside company folder, skipping.`);
1788
1789
  continue;
1789
1790
  }
1790
- const relativePath = toPosixKey(path.relative(syncRoot, absolutePath));
1791
+ const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
1791
1792
  // Probe the filter with both isDir hints — we don't know whether
1792
1793
  // the link's target is a file or a directory without
1793
1794
  // stat-following the link, which we explicitly avoid (it would
@@ -1817,7 +1818,7 @@ function collectFiles(
1817
1818
  if (!filter(absolutePath, true)) continue;
1818
1819
  results.push(...walkDir(absolutePath, syncRoot, filter));
1819
1820
  } else if (lstat.isFile()) {
1820
- const relativePath = toPosixKey(path.relative(syncRoot, absolutePath));
1821
+ const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
1821
1822
  if (filter(absolutePath)) {
1822
1823
  results.push({ kind: "file", absolutePath, relativePath });
1823
1824
  }
@@ -1867,7 +1868,7 @@ function walkDir(
1867
1868
  results.push({
1868
1869
  kind: "symlink",
1869
1870
  absolutePath,
1870
- relativePath: toPosixKey(path.relative(syncRoot, absolutePath)),
1871
+ relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
1871
1872
  target: fs.readlinkSync(absolutePath),
1872
1873
  });
1873
1874
  continue;
@@ -1883,7 +1884,7 @@ function walkDir(
1883
1884
  results.push({
1884
1885
  kind: "file",
1885
1886
  absolutePath,
1886
- relativePath: toPosixKey(path.relative(syncRoot, absolutePath)),
1887
+ relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
1887
1888
  });
1888
1889
  }
1889
1890
  }
@@ -2002,11 +2003,11 @@ function resolveDeleteScopeRoots(
2002
2003
  if (!isWithin(syncRoot, absolutePath)) continue;
2003
2004
  const stat = fs.statSync(absolutePath);
2004
2005
  if (!stat.isDirectory()) continue;
2005
- const rel = path.relative(syncRoot, absolutePath);
2006
+ const rel = vaultKeyForLocalPath(syncRoot, absolutePath);
2006
2007
  if (rel === "" || rel === ".") {
2007
2008
  return [""];
2008
2009
  }
2009
- prefixes.add(rel.split(path.sep).join("/"));
2010
+ prefixes.add(rel);
2010
2011
  }
2011
2012
  return Array.from(prefixes);
2012
2013
  }
@@ -2244,7 +2245,7 @@ async function computeDeletePlan(
2244
2245
  );
2245
2246
  if (!inScope) continue;
2246
2247
  inScopeJournalEntries++;
2247
- const localPath = path.join(syncRoot, relativeKey);
2248
+ const localPath = localPathForVaultKey(syncRoot, relativeKey);
2248
2249
  let presentLocally = true;
2249
2250
  try {
2250
2251
  fs.lstatSync(localPath);
@@ -2566,6 +2566,68 @@ describe("sync", () => {
2566
2566
  expect(journal.files["knowledge/readme.md"].remoteEtag).toBe("def456");
2567
2567
  });
2568
2568
 
2569
+ it("checkpoints completed downloads so a failed bulk pass resumes only its tail", async () => {
2570
+ process.env.HQ_SYNC_TRANSFER_CONCURRENCY = "1";
2571
+ const remoteFiles = Array.from({ length: 9 }, (_, index) => ({
2572
+ key: `bulk/file-${index}.md`,
2573
+ size: 10,
2574
+ lastModified: new Date(),
2575
+ etag: `"bulk-${index}"`,
2576
+ }));
2577
+ const defaultDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
2578
+ if (!defaultDownload) throw new Error("missing default download mock");
2579
+
2580
+ try {
2581
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
2582
+ vi.mocked(s3Module.downloadFile).mockImplementation(
2583
+ async (ctx, key, localPath) => {
2584
+ if (key === "bulk/file-8.md") {
2585
+ // This models the next transfer crashing the process. The first
2586
+ // eight entries must already be durable before the final writer.
2587
+ const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2588
+ expect(Object.keys(checkpoint.files)).toHaveLength(8);
2589
+ throw new Error("simulated crash after checkpoint");
2590
+ }
2591
+ return defaultDownload(ctx, key, localPath);
2592
+ },
2593
+ );
2594
+
2595
+ const first = await sync({
2596
+ company: "acme",
2597
+ vaultConfig: mockConfig,
2598
+ hqRoot: tmpDir,
2599
+ skipReindex: true,
2600
+ onEvent: () => undefined,
2601
+ });
2602
+ expect(first.filesDownloaded).toBe(8);
2603
+
2604
+ const checkpoint = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2605
+ expect(Object.keys(checkpoint.files)).toHaveLength(8);
2606
+
2607
+ vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
2608
+ vi.mocked(s3Module.downloadFile).mockClear();
2609
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
2610
+
2611
+ await sync({
2612
+ company: "acme",
2613
+ vaultConfig: mockConfig,
2614
+ hqRoot: tmpDir,
2615
+ skipReindex: true,
2616
+ onEvent: () => undefined,
2617
+ });
2618
+
2619
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
2620
+ expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(
2621
+ expect.anything(),
2622
+ "bulk/file-8.md",
2623
+ expect.any(String),
2624
+ );
2625
+ } finally {
2626
+ vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
2627
+ delete process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
2628
+ }
2629
+ });
2630
+
2569
2631
  // ── Stage-1 plan event ─────────────────────────────────────────────────
2570
2632
 
2571
2633
  it("emits a plan event before any progress events", async () => {
package/src/cli/sync.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "../telemetry-events.js";
20
20
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
21
21
  import { createSyncProgressRecorder } from "../sync-progress.js";
22
+ import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
22
23
  import {
23
24
  downloadFile,
24
25
  listRemoteFiles,
@@ -26,7 +27,7 @@ import {
26
27
  primeObjectTransport,
27
28
  toPosixKey,
28
29
  } from "../s3.js";
29
- import type { RemoteFile } from "../s3.js";
30
+ import type { DownloadModeWarning, RemoteFile } from "../s3.js";
30
31
  import {
31
32
  readJournal,
32
33
  writeJournal,
@@ -601,6 +602,8 @@ interface PullRunContext {
601
602
  */
602
603
  currentExcludeSet: string[];
603
604
  fileTombstones: ReadonlyMap<string, CompanyTombstone>;
605
+ /** Successful downloads since the last durable journal checkpoint. */
606
+ downloadsSinceJournalCheckpoint: number;
604
607
  }
605
608
 
606
609
  interface PullCounters {
@@ -646,6 +649,8 @@ export function resolveAutoPruneCap(): number {
646
649
 
647
650
  /** Max time to wait on the best-effort new-files notification POST. */
648
651
  const NOTIFY_FILE_ADDED_TIMEOUT_MS = 5000;
652
+ /** Bound crash recovery to at most this many completed pull downloads. */
653
+ const DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE = 8;
649
654
 
650
655
  /**
651
656
  * Server cap on files per `/v1/notify/file-added` report. The endpoint rejects
@@ -914,6 +919,7 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
914
919
  currentPrefixSet,
915
920
  currentExcludeSet,
916
921
  fileTombstones,
922
+ downloadsSinceJournalCheckpoint: 0,
917
923
  };
918
924
  }
919
925
 
@@ -971,6 +977,30 @@ function reportInvalidScopedKeys(run: PullRunContext, plan: PullPlan): void {
971
977
  }, { claims: run.options.telemetryClaims });
972
978
  }
973
979
 
980
+ /**
981
+ * A legacy object without hq-mode can otherwise downgrade a hook to the
982
+ * receiver umask with no durable signal. downloadFile already prints a local
983
+ * warning for every caller; the sync path also records a path-free telemetry
984
+ * event so operators can find vaults that still need the one-time backfill.
985
+ */
986
+ function reportModeWarnings(
987
+ run: PullRunContext,
988
+ warnings: DownloadModeWarning[] | undefined,
989
+ ): void {
990
+ for (const warning of warnings ?? []) {
991
+ void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
992
+ eventName: "sync_mode_guardrail_warning",
993
+ source: "hq-sync",
994
+ companyUid: run.ctx.uid,
995
+ properties: {
996
+ leg: "pull",
997
+ reason: warning.reason,
998
+ fallback: warning.fallback ?? null,
999
+ },
1000
+ }, { claims: run.options.telemetryClaims });
1001
+ }
1002
+ }
1003
+
974
1004
  function createPullCounters(): PullCounters {
975
1005
  return {
976
1006
  filesDownloaded: 0,
@@ -1220,7 +1250,7 @@ async function executeConflictItem(
1220
1250
  // the REMOTE hash/etag) and skip the conflict path entirely. This removes both
1221
1251
  // the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
1222
1252
  // divergent local content) that the keep path produced for these files.
1223
- if (isCloudAuthoritative(path.relative(run.hqRoot, localPath))) {
1253
+ if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
1224
1254
  downloadItems.push({ action: "download", remoteFile, localPath, isNew: false });
1225
1255
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
1226
1256
  return null;
@@ -1230,14 +1260,14 @@ async function executeConflictItem(
1230
1260
 
1231
1261
  const detectedAt = new Date().toISOString();
1232
1262
  const machineId = readShortMachineId(run.hqRoot);
1233
- const originalRelative = path.relative(run.hqRoot, localPath);
1263
+ const originalRelative = vaultKeyForLocalPath(run.hqRoot, localPath);
1234
1264
  const conflictRelative = buildConflictPath(
1235
1265
  originalRelative,
1236
1266
  detectedAt,
1237
1267
  machineId,
1238
1268
  );
1239
- const conflictAbs = path.join(run.hqRoot, conflictRelative);
1240
- const conflictKey = toPosixKey(path.relative(run.companyRoot, conflictAbs));
1269
+ const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
1270
+ const conflictKey = vaultKeyForLocalPath(run.companyRoot, conflictAbs);
1241
1271
 
1242
1272
  if (!isDownloadWritePathStillContained(run.companyRoot, conflictKey, conflictAbs)) {
1243
1273
  counters.filesSkipped++;
@@ -1459,11 +1489,12 @@ async function downloadOne(
1459
1489
  }
1460
1490
 
1461
1491
  try {
1462
- const { metadata, contentHash, contentSize } = await downloadFile(
1492
+ const { metadata, contentHash, contentSize, modeWarnings } = await downloadFile(
1463
1493
  run.ctx,
1464
1494
  remoteFile.key,
1465
1495
  localPath,
1466
1496
  );
1497
+ reportModeWarnings(run, modeWarnings);
1467
1498
  const author = metadata?.["created-by"] ?? null;
1468
1499
  const createdBySub = metadata?.["created-by-sub"];
1469
1500
 
@@ -1508,6 +1539,19 @@ async function downloadOne(
1508
1539
  message: err instanceof Error ? err.message : String(err),
1509
1540
  });
1510
1541
  }
1542
+ return;
1543
+ }
1544
+
1545
+ run.downloadsSinceJournalCheckpoint++;
1546
+ if (
1547
+ run.downloadsSinceJournalCheckpoint >=
1548
+ DOWNLOAD_JOURNAL_CHECKPOINT_BATCH_SIZE
1549
+ ) {
1550
+ // writeJournal is already fsync + rename atomic. Persist completed work
1551
+ // before scheduling more objects so an abrupt process exit only replays a
1552
+ // small tail, not an entire bulk download.
1553
+ writeJournal(run.journalSlug, run.journal);
1554
+ run.downloadsSinceJournalCheckpoint = 0;
1511
1555
  }
1512
1556
  }
1513
1557
 
@@ -1803,7 +1847,7 @@ function resolveContainedVaultPath(root: string, key: string): string | null {
1803
1847
  if (isMalformedVaultKey(key) || hasTraversalSegment(key)) return null;
1804
1848
 
1805
1849
  const resolvedRoot = path.resolve(root);
1806
- const resolvedLocal = path.resolve(resolvedRoot, key);
1850
+ const resolvedLocal = localPathForVaultKey(resolvedRoot, key);
1807
1851
  if (!isPathWithin(resolvedRoot, resolvedLocal)) return null;
1808
1852
 
1809
1853
  let realRoot: string;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Reversible local filename adapter for canonical vault keys on Win32.
3
+ *
4
+ * Vault keys are POSIX identifiers and may contain ':' (for example
5
+ * `factory:slack`). Win32 cannot materialize those strings as filename
6
+ * segments, so keep the key canonical in S3 and journals while encoding only
7
+ * the local segment that reaches the filesystem.
8
+ */
9
+ import * as path from "path";
10
+
11
+ const WIN32_RESERVED_CHARS = /[<>:"/\\|?*]/;
12
+ const WIN32_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
13
+
14
+ function isWin32ReservedCharacter(char: string): boolean {
15
+ return char.charCodeAt(0) <= 0x1f || WIN32_RESERVED_CHARS.test(char);
16
+ }
17
+
18
+ function percentEscape(char: string): string {
19
+ return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`;
20
+ }
21
+
22
+ /**
23
+ * Encode one canonical vault-key segment for a Win32 filename. Percent is
24
+ * escaped before every other character so decoding cannot collide with a
25
+ * literal `%3A` name. The optional flag makes platform-specific behavior
26
+ * directly testable without mutating `process.platform`.
27
+ */
28
+ export function encodeLocalVaultSegment(
29
+ segment: string,
30
+ win32: boolean = process.platform === "win32",
31
+ ): string {
32
+ if (!win32) return segment;
33
+
34
+ let encoded = "";
35
+ for (let index = 0; index < segment.length; index++) {
36
+ const char = segment[index]!;
37
+ const isTrailingDotOrSpace =
38
+ index === segment.length - 1 && (char === "." || char === " ");
39
+ const isDotSegment = (segment === "." || segment === "..") && index === 0;
40
+ const isReservedDeviceFirstChar = WIN32_DEVICE_NAME.test(segment) && index === 0;
41
+ if (
42
+ char === "%" ||
43
+ isWin32ReservedCharacter(char) ||
44
+ isTrailingDotOrSpace ||
45
+ isDotSegment ||
46
+ isReservedDeviceFirstChar
47
+ ) {
48
+ encoded += percentEscape(char);
49
+ } else {
50
+ encoded += char;
51
+ }
52
+ }
53
+ return encoded;
54
+ }
55
+
56
+ /** Decode one local filename segment back to its canonical vault-key form. */
57
+ export function decodeLocalVaultSegment(
58
+ segment: string,
59
+ win32: boolean = process.platform === "win32",
60
+ ): string {
61
+ if (!win32) return segment;
62
+ return segment.replace(/%([0-9a-f]{2})/gi, (_whole, hex: string) =>
63
+ String.fromCharCode(Number.parseInt(hex, 16)),
64
+ );
65
+ }
66
+
67
+ /** Materialize a canonical slash-delimited vault key beneath a local root. */
68
+ export function localPathForVaultKey(
69
+ root: string,
70
+ key: string,
71
+ win32: boolean = process.platform === "win32",
72
+ ): string {
73
+ return path.join(
74
+ root,
75
+ ...key.split("/").map((segment) => encodeLocalVaultSegment(segment, win32)),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Turn a local, root-relative path back into its canonical vault key. This
81
+ * recognizes legacy percent-encoded Windows materializations as well as the
82
+ * current codec, so they converge on one remote/journal key instead of
83
+ * creating duplicates.
84
+ */
85
+ export function vaultKeyForLocalPath(
86
+ root: string,
87
+ localPath: string,
88
+ win32: boolean = process.platform === "win32",
89
+ ): string {
90
+ return path
91
+ .relative(root, localPath)
92
+ .split(path.sep)
93
+ .map((segment) => decodeLocalVaultSegment(segment, win32))
94
+ .join("/");
95
+ }