@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.
- package/dist/backup-prune.d.ts +68 -0
- package/dist/backup-prune.d.ts.map +1 -0
- package/dist/backup-prune.js +196 -0
- package/dist/backup-prune.js.map +1 -0
- package/dist/backup-prune.test.d.ts +2 -0
- package/dist/backup-prune.test.d.ts.map +1 -0
- package/dist/backup-prune.test.js +89 -0
- package/dist/backup-prune.test.js.map +1 -0
- package/dist/bin/backup-prune-runner.d.ts +3 -0
- package/dist/bin/backup-prune-runner.d.ts.map +1 -0
- package/dist/bin/backup-prune-runner.js +50 -0
- package/dist/bin/backup-prune-runner.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +28 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +5 -3
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +105 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +7 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +509 -282
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-snapshot.d.ts +14 -0
- package/dist/cli/rescue-snapshot.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.js +39 -0
- package/dist/cli/rescue-snapshot.js.map +1 -0
- package/dist/cli/rescue-snapshot.test.d.ts +2 -0
- package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.test.js +46 -0
- package/dist/cli/rescue-snapshot.test.js.map +1 -0
- package/dist/cli/rescue.test.js +24 -0
- package/dist/cli/rescue.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +12 -11
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +41 -6
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +51 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/local-path-codec.d.ts +19 -0
- package/dist/local-path-codec.d.ts.map +1 -0
- package/dist/local-path-codec.js +69 -0
- package/dist/local-path-codec.js.map +1 -0
- package/dist/object-io.d.ts +19 -2
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +168 -32
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +157 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/s3.d.ts +8 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +52 -22
- package/dist/s3.js.map +1 -1
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +4 -3
- package/dist/scope-shrink.js.map +1 -1
- package/dist/signals/get.test.js +21 -1
- package/dist/signals/get.test.js.map +1 -1
- package/dist/signals/list.test.js +21 -1
- package/dist/signals/list.test.js.map +1 -1
- package/dist/sources/get.test.js +21 -1
- package/dist/sources/get.test.js.map +1 -1
- package/dist/sources/list.test.js +21 -1
- package/dist/sources/list.test.js.map +1 -1
- package/package.json +3 -2
- package/scripts/presign-transport-e2e.mjs +57 -10
- package/scripts/vault-rebaseline.sh +49 -1
- package/scripts/vault-rescue.sh +42 -1
- package/src/backup-prune.test.ts +98 -0
- package/src/backup-prune.ts +182 -0
- package/src/bin/backup-prune-runner.ts +33 -0
- package/src/bin/sync-runner-watch-loop.ts +18 -0
- package/src/bin/sync-runner.ts +2 -0
- package/src/cli/reindex.ts +5 -3
- package/src/cli/rescue-classify-ordering.test.ts +121 -0
- package/src/cli/rescue-core.ts +311 -89
- package/src/cli/rescue-snapshot.test.ts +57 -0
- package/src/cli/rescue-snapshot.ts +51 -0
- package/src/cli/rescue.test.ts +34 -0
- package/src/cli/share.ts +12 -11
- package/src/cli/sync.test.ts +62 -0
- package/src/cli/sync.ts +51 -7
- package/src/local-path-codec.ts +95 -0
- package/src/object-io.test.ts +175 -0
- package/src/object-io.ts +213 -32
- package/src/s3.ts +61 -12
- package/src/scope-shrink.ts +4 -3
- package/src/signals/get.test.ts +26 -2
- package/src/signals/list.test.ts +26 -2
- package/src/sources/get.test.ts +26 -2
- 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
|
+
}
|
package/src/cli/rescue.test.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
2006
|
+
const rel = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
2006
2007
|
if (rel === "" || rel === ".") {
|
|
2007
2008
|
return [""];
|
|
2008
2009
|
}
|
|
2009
|
-
prefixes.add(rel
|
|
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 =
|
|
2248
|
+
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
2248
2249
|
let presentLocally = true;
|
|
2249
2250
|
try {
|
|
2250
2251
|
fs.lstatSync(localPath);
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
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 =
|
|
1240
|
-
const conflictKey =
|
|
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 =
|
|
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
|
+
}
|