@indigoai-us/hq-cloud 6.2.0 → 6.2.2
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/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +54 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-journal-reconcile.test.d.ts +2 -0
- package/dist/cli/rescue-journal-reconcile.test.d.ts.map +1 -0
- package/dist/cli/rescue-journal-reconcile.test.js +135 -0
- package/dist/cli/rescue-journal-reconcile.test.js.map +1 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +83 -8
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +91 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/rescue-core.ts +70 -1
- package/src/cli/rescue-journal-reconcile.test.ts +156 -0
- package/src/cli/share.test.ts +105 -1
- package/src/cli/share.ts +102 -8
- package/src/cli/sync.ts +1 -1
package/src/cli/rescue-core.ts
CHANGED
|
@@ -28,6 +28,13 @@ import { spawnSync } from "child_process";
|
|
|
28
28
|
import * as fs from "fs";
|
|
29
29
|
import * as os from "os";
|
|
30
30
|
import * as path from "path";
|
|
31
|
+
import {
|
|
32
|
+
readJournal,
|
|
33
|
+
writeJournal,
|
|
34
|
+
hashFile,
|
|
35
|
+
updateEntry,
|
|
36
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
37
|
+
} from "../journal.js";
|
|
31
38
|
|
|
32
39
|
export interface RunRescueResult {
|
|
33
40
|
status: number;
|
|
@@ -792,7 +799,7 @@ function doRescue(
|
|
|
792
799
|
}
|
|
793
800
|
const overlay = run(
|
|
794
801
|
"rsync",
|
|
795
|
-
["-
|
|
802
|
+
["-ai", ...overlayProtect, ...rsyncExcludes, srcDir + "/", hqRoot + "/"],
|
|
796
803
|
{ env },
|
|
797
804
|
);
|
|
798
805
|
if (overlay.status !== 0) {
|
|
@@ -818,6 +825,68 @@ function doRescue(
|
|
|
818
825
|
}
|
|
819
826
|
}
|
|
820
827
|
|
|
828
|
+
// --- Reconcile the sync-journal baseline for re-laid files ---
|
|
829
|
+
// The overlay rewrote scaffold files from upstream, but their personal-vault
|
|
830
|
+
// sync-journal entries still carry the PRE-rescue hash. The next sync would
|
|
831
|
+
// then read localHash != journal.hash ("local changed") and — when the vault
|
|
832
|
+
// also moved — mint a false `.conflict-*` mirror (the root cause of the
|
|
833
|
+
// conflict-file litter). Re-stamp the baseline to the freshly laid-down
|
|
834
|
+
// content so `localChanged` is false: the common single-machine case
|
|
835
|
+
// converges silently, and a genuinely-divergent vault degrades to a clean
|
|
836
|
+
// pull instead of a conflict mirror.
|
|
837
|
+
//
|
|
838
|
+
// Scoped STRICTLY to the paths rsync reported transferring (`-i` itemize) —
|
|
839
|
+
// never a blanket journal rewrite — so a user's unsynced pending edit (which
|
|
840
|
+
// the walk already rescued to personal/ rather than overlaying) is never
|
|
841
|
+
// silently marked as synced. Best-effort: a reconcile failure must never
|
|
842
|
+
// fail the rescue.
|
|
843
|
+
try {
|
|
844
|
+
const relaid: string[] = [];
|
|
845
|
+
for (const line of overlay.stdout.split("\n")) {
|
|
846
|
+
// itemize line: 11-char change code, space, path. 2nd col `f` = file.
|
|
847
|
+
const m = /^[<>ch.]f\S{9} (.+)$/.exec(line);
|
|
848
|
+
if (m) relaid.push(m[1]);
|
|
849
|
+
}
|
|
850
|
+
if (relaid.length > 0) {
|
|
851
|
+
const journal = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
852
|
+
let restamped = 0;
|
|
853
|
+
for (const rel of relaid) {
|
|
854
|
+
const prev = journal.files[rel];
|
|
855
|
+
if (!prev) continue; // only re-stamp paths already in the synced baseline
|
|
856
|
+
const abs = path.join(hqRoot, rel);
|
|
857
|
+
let st: fs.Stats;
|
|
858
|
+
try {
|
|
859
|
+
st = fs.lstatSync(abs);
|
|
860
|
+
} catch {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (st.isSymbolicLink() || !st.isFile()) continue;
|
|
864
|
+
updateEntry(
|
|
865
|
+
journal,
|
|
866
|
+
rel,
|
|
867
|
+
hashFile(abs),
|
|
868
|
+
st.size,
|
|
869
|
+
prev.direction,
|
|
870
|
+
prev.remoteEtag,
|
|
871
|
+
st.mtimeMs,
|
|
872
|
+
);
|
|
873
|
+
restamped++;
|
|
874
|
+
}
|
|
875
|
+
if (restamped > 0) {
|
|
876
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, journal);
|
|
877
|
+
out(
|
|
878
|
+
`==> Reconciled sync-journal baseline for ${restamped} re-laid file(s)\n`,
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
} catch (e) {
|
|
883
|
+
out(
|
|
884
|
+
` (sync-journal reconcile skipped: ${
|
|
885
|
+
e instanceof Error ? e.message : String(e)
|
|
886
|
+
})\n`,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
821
890
|
// --- Stamp sync-point provenance into core/core.yaml ---
|
|
822
891
|
if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml)) {
|
|
823
892
|
const nowUtc = utcStamp(new Date(), "colon");
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression for the rescue → sync-journal stale-baseline bug.
|
|
3
|
+
*
|
|
4
|
+
* Root cause: the rescue overlay rewrites scaffold files from upstream but
|
|
5
|
+
* never re-stamps the personal-vault sync journal. The journal keeps the
|
|
6
|
+
* PRE-rescue hash, so the next sync reads localHash != journal.hash ("local
|
|
7
|
+
* changed") and — when the vault also moved — mints a false `.conflict-*`
|
|
8
|
+
* mirror. `runRescue` now re-stamps the journal baseline for the files the
|
|
9
|
+
* overlay actually re-laid (scoped to rsync `-i` itemize output, so a user's
|
|
10
|
+
* pending edit is never touched).
|
|
11
|
+
*
|
|
12
|
+
* This test seeds a journal whose entry for an overlaid file carries the OLD
|
|
13
|
+
* hash, runs a real (non-dry-run) rescue, and asserts the entry was re-stamped
|
|
14
|
+
* to the freshly laid-down content — i.e. `localChanged` would now be false.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
17
|
+
import { execFileSync } from "child_process";
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as os from "os";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { runRescue } from "./rescue-core.js";
|
|
22
|
+
import {
|
|
23
|
+
hashFile,
|
|
24
|
+
readJournal,
|
|
25
|
+
writeJournal,
|
|
26
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
27
|
+
} from "../journal.js";
|
|
28
|
+
|
|
29
|
+
function hasGit(): boolean {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync("git", ["--version"], { stdio: "ignore" });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const gitAvailable = hasGit();
|
|
38
|
+
|
|
39
|
+
function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
40
|
+
let stdout = "";
|
|
41
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
42
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
43
|
+
process.stdout.write = ((c: unknown) => ((stdout += String(c)), true)) as typeof process.stdout.write;
|
|
44
|
+
process.stderr.write = (() => true) as typeof process.stderr.write;
|
|
45
|
+
let status: number;
|
|
46
|
+
try {
|
|
47
|
+
status = runRescue(argv, { env }).status;
|
|
48
|
+
} finally {
|
|
49
|
+
process.stdout.write = origOut;
|
|
50
|
+
process.stderr.write = origErr;
|
|
51
|
+
}
|
|
52
|
+
return { status, stdout };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe.skipIf(!gitAvailable)("rescue re-stamps sync-journal baseline for re-laid files", () => {
|
|
56
|
+
let workDir: string, upstream: string, hqRoot: string, stateDir: string, floorSha: string;
|
|
57
|
+
let env: NodeJS.ProcessEnv;
|
|
58
|
+
let savedStateDir: string | undefined;
|
|
59
|
+
|
|
60
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
61
|
+
execFileSync("git", args, {
|
|
62
|
+
cwd,
|
|
63
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
64
|
+
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
|
|
65
|
+
}).toString().trim();
|
|
66
|
+
|
|
67
|
+
beforeAll(() => {
|
|
68
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-journal-"));
|
|
69
|
+
|
|
70
|
+
// upstream: floor (doc.md=v1), then HEAD advances doc.md -> v2.
|
|
71
|
+
upstream = path.join(workDir, "upstream");
|
|
72
|
+
fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
|
|
73
|
+
git(workDir, "init", "-b", "main", "upstream");
|
|
74
|
+
fs.writeFileSync(path.join(upstream, "core/doc.md"), "v1\n");
|
|
75
|
+
git(upstream, "add", "-A");
|
|
76
|
+
git(upstream, "commit", "-m", "floor");
|
|
77
|
+
floorSha = git(upstream, "rev-parse", "HEAD");
|
|
78
|
+
fs.writeFileSync(path.join(upstream, "core/doc.md"), "v2\n");
|
|
79
|
+
git(upstream, "add", "-A");
|
|
80
|
+
git(upstream, "commit", "-m", "head");
|
|
81
|
+
|
|
82
|
+
// local HQ root: doc.md == floor (v1) -> rescue overlays it to HEAD (v2).
|
|
83
|
+
hqRoot = path.join(workDir, "hq");
|
|
84
|
+
fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
|
|
85
|
+
fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
|
|
86
|
+
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
87
|
+
const docAbs = path.join(hqRoot, "core/doc.md");
|
|
88
|
+
fs.writeFileSync(docAbs, "v1\n");
|
|
89
|
+
|
|
90
|
+
// state dir + seeded personal-vault journal carrying the STALE (v1) hash.
|
|
91
|
+
stateDir = path.join(workDir, "state");
|
|
92
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
93
|
+
savedStateDir = process.env.HQ_STATE_DIR;
|
|
94
|
+
process.env.HQ_STATE_DIR = stateDir; // getStateDir() reads process.env
|
|
95
|
+
const staleHash = hashFile(docAbs); // hash of v1
|
|
96
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
|
|
97
|
+
version: "2",
|
|
98
|
+
lastSync: new Date(0).toISOString(),
|
|
99
|
+
files: {
|
|
100
|
+
"core/doc.md": {
|
|
101
|
+
hash: staleHash,
|
|
102
|
+
size: fs.statSync(docAbs).size,
|
|
103
|
+
syncedAt: new Date(0).toISOString(),
|
|
104
|
+
direction: "down",
|
|
105
|
+
remoteEtag: "seed-etag",
|
|
106
|
+
mtimeMs: fs.statSync(docAbs).mtimeMs,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
pulls: [],
|
|
110
|
+
} as never);
|
|
111
|
+
|
|
112
|
+
// git shim: redirect `git clone <github-url>` to the local fixture.
|
|
113
|
+
const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
114
|
+
const shimDir = path.join(workDir, "shim");
|
|
115
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
116
|
+
fs.writeFileSync(
|
|
117
|
+
path.join(shimDir, "git"),
|
|
118
|
+
`#!/usr/bin/env bash
|
|
119
|
+
if [ "$1" = "clone" ]; then
|
|
120
|
+
args=(); for a in "$@"; do case "$a" in https://github.com/*) a=${JSON.stringify(upstream)} ;; esac; args+=("$a"); done
|
|
121
|
+
exec ${JSON.stringify(realGit)} "\${args[@]}"
|
|
122
|
+
fi
|
|
123
|
+
exec ${JSON.stringify(realGit)} "$@"
|
|
124
|
+
`,
|
|
125
|
+
{ mode: 0o755 },
|
|
126
|
+
);
|
|
127
|
+
env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}`, HQ_STATE_DIR: stateDir };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterAll(() => {
|
|
131
|
+
if (savedStateDir === undefined) delete process.env.HQ_STATE_DIR;
|
|
132
|
+
else process.env.HQ_STATE_DIR = savedStateDir;
|
|
133
|
+
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("re-stamps the journal so the overlaid file is no longer seen as locally changed", () => {
|
|
137
|
+
const docAbs = path.join(hqRoot, "core/doc.md");
|
|
138
|
+
const staleHash = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"].hash;
|
|
139
|
+
|
|
140
|
+
const r = runRescueCapture(
|
|
141
|
+
["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
|
|
142
|
+
env,
|
|
143
|
+
);
|
|
144
|
+
expect(r.status, r.stdout).toBe(0);
|
|
145
|
+
|
|
146
|
+
// overlay actually re-laid the file (v1 -> v2)
|
|
147
|
+
expect(fs.readFileSync(docAbs, "utf-8")).toBe("v2\n");
|
|
148
|
+
|
|
149
|
+
// journal entry was re-stamped to the NEW content's hash
|
|
150
|
+
const entry = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"];
|
|
151
|
+
expect(entry.hash).toBe(hashFile(docAbs)); // == hash(v2): localChanged would be FALSE
|
|
152
|
+
expect(entry.hash).not.toBe(staleHash); // proves it changed from the stale v1 baseline
|
|
153
|
+
expect(entry.remoteEtag).toBe("seed-etag"); // remote side untouched -> clean push/converge, not conflict
|
|
154
|
+
expect(r.stdout).toContain("Reconciled sync-journal baseline");
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/cli/share.test.ts
CHANGED
|
@@ -39,7 +39,7 @@ vi.mock("readline", () => ({
|
|
|
39
39
|
|
|
40
40
|
import * as readline from "readline";
|
|
41
41
|
import { share, _testing as shareTesting } from "./share.js";
|
|
42
|
-
import { deleteRemoteFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
|
|
42
|
+
import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
|
|
43
43
|
import type { EntityContext } from "../types.js";
|
|
44
44
|
|
|
45
45
|
const mockConfig: VaultServiceConfig = {
|
|
@@ -482,6 +482,110 @@ describe("share", () => {
|
|
|
482
482
|
expect(result.filesUploaded).toBe(0);
|
|
483
483
|
});
|
|
484
484
|
|
|
485
|
+
it("fresh-install multipart collision: byte-identical remote is NOT a conflict (reconciled, no mirror)", async () => {
|
|
486
|
+
// Root cause of "HQ installs with conflicts": on a first push the
|
|
487
|
+
// journal is empty, so the fresh-collision branch decides conflict-vs-
|
|
488
|
+
// identical from the remote etag alone. A multipart remote etag
|
|
489
|
+
// (\`<md5>-<partCount>\`) is NOT a content hash, so the OLD code assumed
|
|
490
|
+
// a collision for ANY multipart object — minting a false conflict for
|
|
491
|
+
// the most common fresh-install case: re-pushing a byte-identical
|
|
492
|
+
// core/ scaffold file whose remote copy happened to be multipart-
|
|
493
|
+
// uploaded. The fix fetches the remote bytes once and compares content
|
|
494
|
+
// hashes; identical content reconciles (journal re-stamped, PUT
|
|
495
|
+
// skipped) instead of producing a conflict + \`.conflict-*\` mirror.
|
|
496
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
497
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
498
|
+
const testFile = path.join(companyRoot, "core-scaffold.md");
|
|
499
|
+
const scaffoldBytes = "identical-scaffold-content-shipped-to-every-user";
|
|
500
|
+
fs.writeFileSync(testFile, scaffoldBytes);
|
|
501
|
+
|
|
502
|
+
// Remote exists with a MULTIPART etag and NO journal entry (first push).
|
|
503
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
504
|
+
lastModified: new Date(),
|
|
505
|
+
etag: '"d41d8cd98f00b204e9800998ecf8427e-2"',
|
|
506
|
+
size: scaffoldBytes.length,
|
|
507
|
+
});
|
|
508
|
+
// The convergence probe downloads the remote bytes — which are
|
|
509
|
+
// byte-identical to local — so the content hashes match.
|
|
510
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
511
|
+
fs.writeFileSync(dest as string, scaffoldBytes);
|
|
512
|
+
return undefined as never;
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const events: unknown[] = [];
|
|
516
|
+
const result = await share({
|
|
517
|
+
paths: [testFile],
|
|
518
|
+
company: "acme",
|
|
519
|
+
vaultConfig: mockConfig,
|
|
520
|
+
hqRoot: tmpDir,
|
|
521
|
+
onConflict: "keep",
|
|
522
|
+
onEvent: (e) => events.push(e),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// No conflict, no upload (bytes already there), counted as a skip.
|
|
526
|
+
expect(result.conflictPaths).toEqual([]);
|
|
527
|
+
expect(result.filesUploaded).toBe(0);
|
|
528
|
+
expect(result.filesSkipped).toBe(1);
|
|
529
|
+
const conflicts = events.filter(
|
|
530
|
+
(e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
|
|
531
|
+
);
|
|
532
|
+
expect(conflicts).toHaveLength(0);
|
|
533
|
+
const reconciled = events.filter(
|
|
534
|
+
(e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "reconciled",
|
|
535
|
+
);
|
|
536
|
+
expect(reconciled).toHaveLength(1);
|
|
537
|
+
expect(reconciled[0]).toMatchObject({ path: "core-scaffold.md", direction: "push" });
|
|
538
|
+
// No conflict-mirror litter on disk.
|
|
539
|
+
const litter = fs
|
|
540
|
+
.readdirSync(companyRoot)
|
|
541
|
+
.filter((n) => n.includes(".conflict-"));
|
|
542
|
+
expect(litter).toEqual([]);
|
|
543
|
+
// Local file untouched.
|
|
544
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(scaffoldBytes);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("fresh-install multipart collision: genuinely different remote IS a conflict (Bug #7 preserved)", async () => {
|
|
548
|
+
// The convergence probe must not weaken Bug #7 protection: a multipart
|
|
549
|
+
// remote whose CONTENT actually differs from local is still a real
|
|
550
|
+
// fresh collision and must surface a conflict (so a peer's content
|
|
551
|
+
// isn't silently clobbered).
|
|
552
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
553
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
554
|
+
const testFile = path.join(companyRoot, "diverged-multipart.md");
|
|
555
|
+
fs.writeFileSync(testFile, "my-local-version");
|
|
556
|
+
|
|
557
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
558
|
+
lastModified: new Date(),
|
|
559
|
+
etag: '"0123456789abcdef0123456789abcdef-3"',
|
|
560
|
+
size: 32,
|
|
561
|
+
});
|
|
562
|
+
// The probe (and the subsequent keep-mirror download) return DIFFERENT
|
|
563
|
+
// remote bytes.
|
|
564
|
+
vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
|
|
565
|
+
fs.writeFileSync(dest as string, "a-peers-different-version");
|
|
566
|
+
return undefined as never;
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const events: unknown[] = [];
|
|
570
|
+
const result = await share({
|
|
571
|
+
paths: [testFile],
|
|
572
|
+
company: "acme",
|
|
573
|
+
vaultConfig: mockConfig,
|
|
574
|
+
hqRoot: tmpDir,
|
|
575
|
+
onConflict: "keep",
|
|
576
|
+
onEvent: (e) => events.push(e),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(result.conflictPaths).toEqual(["diverged-multipart.md"]);
|
|
580
|
+
expect(result.filesUploaded).toBe(0);
|
|
581
|
+
const conflicts = events.filter(
|
|
582
|
+
(e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
|
|
583
|
+
);
|
|
584
|
+
expect(conflicts).toHaveLength(1);
|
|
585
|
+
// Local file untouched under keep.
|
|
586
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe("my-local-version");
|
|
587
|
+
});
|
|
588
|
+
|
|
485
589
|
it("uploads (no conflict) when only the local side changed since last sync", async () => {
|
|
486
590
|
// Regression for hq-cloud#<conflict-detection>: a local edit to a file
|
|
487
591
|
// that exists on S3 used to trigger a push conflict because the
|
package/src/cli/share.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as fs from "fs";
|
|
9
|
+
import * as os from "os";
|
|
9
10
|
import * as path from "path";
|
|
10
11
|
import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
12
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
@@ -45,6 +46,59 @@ import {
|
|
|
45
46
|
} from "../lib/conflict-file.js";
|
|
46
47
|
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Push-side fresh-collision convergence probe.
|
|
51
|
+
*
|
|
52
|
+
* For a first push (no journal entry) where the remote object already
|
|
53
|
+
* exists AND was multipart-uploaded, the remote etag (`<md5>-<partCount>`)
|
|
54
|
+
* is not a content hash, so we cannot tell "byte-identical" from "genuine
|
|
55
|
+
* divergence" without looking at the bytes. Fetch the remote object once to
|
|
56
|
+
* a throwaway temp file, hash it the same symlink-aware way the planner
|
|
57
|
+
* hashed local, and report whether the two contents DIFFER.
|
|
58
|
+
*
|
|
59
|
+
* Returns `true` on a genuine difference (a real fresh collision) and
|
|
60
|
+
* `false` when the bytes are identical. Fails safe to `true` on any
|
|
61
|
+
* fetch/hash error — a false positive merely prompts the operator, while a
|
|
62
|
+
* false negative could silently clobber a peer's content. Mirrors the
|
|
63
|
+
* pull-side convergence guard in `sync.ts`.
|
|
64
|
+
*
|
|
65
|
+
* The temp file lives in the OS temp dir (never under hqRoot) so it can
|
|
66
|
+
* never round-trip to S3, and is removed in a `finally` regardless of
|
|
67
|
+
* outcome. The name is derived from the relative path so concurrent probes
|
|
68
|
+
* for distinct files never collide on disk.
|
|
69
|
+
*/
|
|
70
|
+
async function remoteContentDiffers(
|
|
71
|
+
ctx: EntityContext,
|
|
72
|
+
relativePath: string,
|
|
73
|
+
localHash: string,
|
|
74
|
+
hqRoot: string,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
const probeKey = crypto
|
|
77
|
+
.createHash("sha256")
|
|
78
|
+
.update(relativePath)
|
|
79
|
+
.digest("hex")
|
|
80
|
+
.slice(0, 16);
|
|
81
|
+
const probePath = path.join(
|
|
82
|
+
os.tmpdir(),
|
|
83
|
+
`hq-conflict-probe-${readShortMachineId(hqRoot)}-${probeKey}.tmp`,
|
|
84
|
+
);
|
|
85
|
+
try {
|
|
86
|
+
await downloadFile(ctx, relativePath, probePath);
|
|
87
|
+
const remoteHash = fs.lstatSync(probePath).isSymbolicLink()
|
|
88
|
+
? hashSymlinkTarget(fs.readlinkSync(probePath))
|
|
89
|
+
: hashFile(probePath);
|
|
90
|
+
return remoteHash !== localHash;
|
|
91
|
+
} catch {
|
|
92
|
+
return true;
|
|
93
|
+
} finally {
|
|
94
|
+
try {
|
|
95
|
+
fs.rmSync(probePath, { force: true });
|
|
96
|
+
} catch {
|
|
97
|
+
/* best-effort cleanup; a stray temp probe is harmless */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
48
102
|
/**
|
|
49
103
|
* Local-only ephemeral artifacts: conflict-mirror files written by the pull
|
|
50
104
|
* leg whenever a 3-way merge keeps local AND wants to preserve the remote
|
|
@@ -940,12 +994,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
940
994
|
const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
|
|
941
995
|
|
|
942
996
|
let isFreshCollision = false;
|
|
997
|
+
let multipartConverged = false;
|
|
943
998
|
if (!journalEntry && item.kind === "file") {
|
|
944
999
|
// Single-part S3 PUT etag is MD5 of the body. Multipart uploads
|
|
945
|
-
// produce \`<md5>-<partCount
|
|
946
|
-
// as ambiguous and DO classify as a conflict (safer for the
|
|
947
|
-
// first-time path — false positives prompt the operator, false
|
|
948
|
-
// negatives lose data). Symlink records (\`kind: "symlink"\`)
|
|
1000
|
+
// produce \`<md5>-<partCount>\`. Symlink records (\`kind: "symlink"\`)
|
|
949
1001
|
// skip the check entirely — the wire body shape (\`hq-symlink:\`
|
|
950
1002
|
// prefix + target) isn't a pure byte mirror and would mis-
|
|
951
1003
|
// classify; symlink overwrites are rare and an audit pass after
|
|
@@ -962,13 +1014,55 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
962
1014
|
// path which is idempotent (S3 will overwrite with identical
|
|
963
1015
|
// content + carry our metadata). Cheap, no behavior change.
|
|
964
1016
|
} else {
|
|
965
|
-
// Multipart
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
|
|
1017
|
+
// Multipart remote etag is \`<md5>-<partCount>\`, NOT a usable
|
|
1018
|
+
// content hash, so — unlike the single-part branch — we cannot
|
|
1019
|
+
// decide collision-vs-identical from the etag alone. The old
|
|
1020
|
+
// behavior assumed a collision here, which minted a FALSE
|
|
1021
|
+
// conflict for the most common fresh-install case: re-pushing a
|
|
1022
|
+
// byte-identical \`core/\` scaffold file whose remote copy happened
|
|
1023
|
+
// to be multipart-uploaded. Every fresh install that hit an
|
|
1024
|
+
// already-populated bucket therefore came up "with conflicts".
|
|
1025
|
+
//
|
|
1026
|
+
// Instead, fetch the remote bytes once and compare content
|
|
1027
|
+
// hashes directly — the same convergence guard the pull side
|
|
1028
|
+
// uses (sync.ts). Identical content is NOT a conflict. On any
|
|
1029
|
+
// fetch/hash failure we fail safe to "conflict" (false positives
|
|
1030
|
+
// prompt the operator; false negatives risk clobbering a peer).
|
|
1031
|
+
const remoteDiffers = await remoteContentDiffers(
|
|
1032
|
+
ctx,
|
|
1033
|
+
relativePath,
|
|
1034
|
+
localHash,
|
|
1035
|
+
hqRoot,
|
|
1036
|
+
);
|
|
1037
|
+
if (remoteDiffers) {
|
|
1038
|
+
isFreshCollision = true;
|
|
1039
|
+
} else {
|
|
1040
|
+
// Byte-identical multipart object already present. Seed the
|
|
1041
|
+
// journal baseline from the remote so the next sync sees no
|
|
1042
|
+
// change on either side, and skip the redundant PUT —
|
|
1043
|
+
// re-uploading would needlessly rewrite remote and churn its
|
|
1044
|
+
// etag from multipart to single-part.
|
|
1045
|
+
multipartConverged = true;
|
|
1046
|
+
}
|
|
969
1047
|
}
|
|
970
1048
|
}
|
|
971
1049
|
|
|
1050
|
+
if (multipartConverged) {
|
|
1051
|
+
const lstat = fs.lstatSync(absolutePath);
|
|
1052
|
+
updateEntry(
|
|
1053
|
+
journal,
|
|
1054
|
+
relativePath,
|
|
1055
|
+
localHash,
|
|
1056
|
+
lstat.size,
|
|
1057
|
+
"up",
|
|
1058
|
+
remoteMeta.etag,
|
|
1059
|
+
lstat.mtimeMs,
|
|
1060
|
+
);
|
|
1061
|
+
emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1062
|
+
filesSkipped++;
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
972
1066
|
if ((localChanged && remoteChanged) || isFreshCollision) {
|
|
973
1067
|
conflictPaths.push(relativePath);
|
|
974
1068
|
|