@indigoai-us/hq-cloud 6.2.1 → 6.2.3
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/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +8 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +70 -54
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-journal-reconcile.test.js +70 -48
- package/dist/cli/rescue-journal-reconcile.test.js.map +1 -1
- 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-scope.test.js +33 -1
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +9 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/journal.d.ts +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +7 -1
- package/dist/journal.js.map +1 -1
- package/dist/remote-pull.d.ts +7 -0
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +5 -0
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +110 -0
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +20 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +11 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +122 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.ts +8 -0
- package/src/cli/rescue-core.ts +72 -63
- package/src/cli/rescue-journal-reconcile.test.ts +76 -53
- package/src/cli/share.test.ts +105 -1
- package/src/cli/share.ts +102 -8
- package/src/cli/sync-scope.test.ts +35 -1
- package/src/cli/sync.ts +25 -1
- package/src/journal.ts +7 -0
- package/src/remote-pull.test.ts +118 -0
- package/src/remote-pull.ts +12 -0
- package/src/scope-shrink.test.ts +128 -0
- package/src/scope-shrink.ts +29 -0
- package/src/types.ts +12 -0
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Regression for the rescue
|
|
2
|
+
* Regression for the rescue -> sync-journal stale-baseline bug (and its
|
|
3
|
+
* follow-ups). The rescue overlay + the post-overlay core.yaml stamp rewrite
|
|
4
|
+
* scaffold files from upstream, but their personal-vault journal entries keep
|
|
5
|
+
* the PRE-rescue hash. The next sync then reads localHash != journal.hash
|
|
6
|
+
* ("local changed") and mints a false `.conflict-*` mirror.
|
|
3
7
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* overlay actually re-laid (scoped to rsync `-i` itemize output, so a user's
|
|
10
|
-
* pending edit is never touched).
|
|
8
|
+
* runRescue now reconciles the baseline by DIFFING the journal against current
|
|
9
|
+
* local hashes (robust — the earlier rsync-itemize regex undercounted), running
|
|
10
|
+
* AFTER every in-doRescue mutation (so it also catches the core.yaml stamp), and
|
|
11
|
+
* scoped to NON-preserved roots so a user's pending edit in personal/ is never
|
|
12
|
+
* marked synced.
|
|
11
13
|
*
|
|
12
|
-
* This test seeds
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* This test seeds stale baselines for several overlaid scaffold files + core.yaml
|
|
15
|
+
* and a pending personal/ edit, runs a real rescue, and asserts: every changed
|
|
16
|
+
* scaffold entry is re-stamped to current local (no false conflict), while the
|
|
17
|
+
* personal/ pending edit is left untouched (still uploads).
|
|
15
18
|
*/
|
|
16
19
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
17
20
|
import { execFileSync } from "child_process";
|
|
@@ -52,7 +55,9 @@ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
|
52
55
|
return { status, stdout };
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
const SCAFFOLD = ["core/a.md", "core/docs/b.md", ".claude/c.sh", "core/core.yaml"];
|
|
59
|
+
|
|
60
|
+
describe.skipIf(!gitAvailable)("rescue reconciles sync-journal baseline (complete + robust)", () => {
|
|
56
61
|
let workDir: string, upstream: string, hqRoot: string, stateDir: string, floorSha: string;
|
|
57
62
|
let env: NodeJS.ProcessEnv;
|
|
58
63
|
let savedStateDir: string | undefined;
|
|
@@ -64,52 +69,61 @@ describe.skipIf(!gitAvailable)("rescue re-stamps sync-journal baseline for re-la
|
|
|
64
69
|
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
|
|
65
70
|
}).toString().trim();
|
|
66
71
|
|
|
72
|
+
const w = (root: string, rel: string, body: string) => {
|
|
73
|
+
const p = path.join(root, rel);
|
|
74
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
75
|
+
fs.writeFileSync(p, body);
|
|
76
|
+
};
|
|
77
|
+
|
|
67
78
|
beforeAll(() => {
|
|
68
79
|
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-journal-"));
|
|
69
80
|
|
|
70
|
-
// upstream: floor (
|
|
81
|
+
// --- upstream: floor (all scaffold = v1), HEAD advances the 3 docs to v2 ---
|
|
71
82
|
upstream = path.join(workDir, "upstream");
|
|
72
|
-
fs.mkdirSync(
|
|
83
|
+
fs.mkdirSync(upstream, { recursive: true });
|
|
73
84
|
git(workDir, "init", "-b", "main", "upstream");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
w(upstream, "core/a.md", "v1\n");
|
|
86
|
+
w(upstream, "core/docs/b.md", "v1\n");
|
|
87
|
+
w(upstream, ".claude/c.sh", "v1\n");
|
|
88
|
+
w(upstream, "core/core.yaml", "version: 1\n");
|
|
89
|
+
git(upstream, "add", "-A"); git(upstream, "commit", "-m", "floor");
|
|
77
90
|
floorSha = git(upstream, "rev-parse", "HEAD");
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
w(upstream, "core/a.md", "v2\n");
|
|
92
|
+
w(upstream, "core/docs/b.md", "v2\n");
|
|
93
|
+
w(upstream, ".claude/c.sh", "v2\n");
|
|
94
|
+
git(upstream, "add", "-A"); git(upstream, "commit", "-m", "head");
|
|
81
95
|
|
|
82
|
-
// local HQ root:
|
|
96
|
+
// --- local HQ root: scaffold == floor (overlaid to v2); a pending personal/ edit ---
|
|
83
97
|
hqRoot = path.join(workDir, "hq");
|
|
84
|
-
fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
|
|
85
|
-
fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
|
|
86
98
|
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
w(hqRoot, "core/a.md", "v1\n");
|
|
100
|
+
w(hqRoot, "core/docs/b.md", "v1\n");
|
|
101
|
+
w(hqRoot, ".claude/c.sh", "v1\n");
|
|
102
|
+
w(hqRoot, "core/core.yaml", "version: 1\n");
|
|
103
|
+
w(hqRoot, "personal/edited.md", "USER_LOCAL\n"); // local pending edit
|
|
89
104
|
|
|
90
|
-
// state dir + seeded
|
|
105
|
+
// --- state dir + seeded journal: stale baselines for scaffold + a divergent personal/ entry ---
|
|
91
106
|
stateDir = path.join(workDir, "state");
|
|
92
107
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
93
108
|
savedStateDir = process.env.HQ_STATE_DIR;
|
|
94
|
-
process.env.HQ_STATE_DIR = stateDir;
|
|
95
|
-
const
|
|
109
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
110
|
+
const entry = (rel: string, hash: string) => ({
|
|
111
|
+
hash,
|
|
112
|
+
size: fs.statSync(path.join(hqRoot, rel)).size,
|
|
113
|
+
syncedAt: new Date(0).toISOString(),
|
|
114
|
+
direction: "down" as const,
|
|
115
|
+
remoteEtag: "seed-etag",
|
|
116
|
+
mtimeMs: fs.statSync(path.join(hqRoot, rel)).mtimeMs,
|
|
117
|
+
});
|
|
118
|
+
const files: Record<string, unknown> = {};
|
|
119
|
+
for (const rel of SCAFFOLD) files[rel] = entry(rel, hashFile(path.join(hqRoot, rel))); // hash of v1/version:1
|
|
120
|
+
// personal/ pending edit: journal records a DIFFERENT (already-synced) hash than local.
|
|
121
|
+
files["personal/edited.md"] = { ...entry("personal/edited.md", "remote-side-hash-differs-from-local") };
|
|
96
122
|
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: [],
|
|
123
|
+
version: "2", lastSync: new Date(0).toISOString(), files, pulls: [],
|
|
110
124
|
} as never);
|
|
111
125
|
|
|
112
|
-
// git shim: redirect
|
|
126
|
+
// --- git shim: redirect clone to the local fixture ---
|
|
113
127
|
const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
114
128
|
const shimDir = path.join(workDir, "shim");
|
|
115
129
|
fs.mkdirSync(shimDir, { recursive: true });
|
|
@@ -133,24 +147,33 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
133
147
|
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
134
148
|
});
|
|
135
149
|
|
|
136
|
-
it("re-stamps
|
|
137
|
-
const docAbs = path.join(hqRoot, "core/doc.md");
|
|
138
|
-
const staleHash = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"].hash;
|
|
139
|
-
|
|
150
|
+
it("re-stamps EVERY changed scaffold file (incl. core.yaml), and never touches the personal/ pending edit", () => {
|
|
140
151
|
const r = runRescueCapture(
|
|
141
152
|
["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
|
|
142
153
|
env,
|
|
143
154
|
);
|
|
144
155
|
expect(r.status, r.stdout).toBe(0);
|
|
145
156
|
|
|
146
|
-
//
|
|
147
|
-
|
|
157
|
+
// the 3 docs were overlaid v1 -> v2
|
|
158
|
+
for (const rel of ["core/a.md", "core/docs/b.md", ".claude/c.sh"]) {
|
|
159
|
+
expect(fs.readFileSync(path.join(hqRoot, rel), "utf-8")).toBe("v2\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const j = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files;
|
|
163
|
+
|
|
164
|
+
// INVARIANT: after the reconcile, every scaffold entry's baseline == current
|
|
165
|
+
// local hash -> localChanged is false -> no false conflict on the next sync.
|
|
166
|
+
// core.yaml is included BECAUSE the reconcile runs AFTER its yq/py stamp.
|
|
167
|
+
for (const rel of SCAFFOLD) {
|
|
168
|
+
expect(j[rel].hash, `${rel} not reconciled`).toBe(hashFile(path.join(hqRoot, rel)));
|
|
169
|
+
expect(j[rel].remoteEtag, `${rel} remote side touched`).toBe("seed-etag"); // clean push/converge, not conflict
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// SAFETY: the personal/ pending edit is preserved (NOT marked synced) so it
|
|
173
|
+
// still uploads — its baseline stays the divergent seeded hash.
|
|
174
|
+
expect(j["personal/edited.md"].hash).toBe("remote-side-hash-differs-from-local");
|
|
175
|
+
expect(j["personal/edited.md"].hash).not.toBe(hashFile(path.join(hqRoot, "personal/edited.md")));
|
|
148
176
|
|
|
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
177
|
expect(r.stdout).toContain("Reconciled sync-journal baseline");
|
|
155
178
|
});
|
|
156
179
|
});
|
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
|
|
|
@@ -48,7 +48,12 @@ vi.mock("../s3.js", async () => {
|
|
|
48
48
|
if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
|
|
49
49
|
// Deterministic per-key body so re-downloads produce a stable hash.
|
|
50
50
|
innerFs.writeFileSync(localPath, `mock:${key}`);
|
|
51
|
-
|
|
51
|
+
// Real GETs carry author metadata; surface a fixed uploader sub so
|
|
52
|
+
// journal entries are stamped with a KNOWN author. With no `callerSub`
|
|
53
|
+
// passed (the default for these tests), that author is "foreign", so
|
|
54
|
+
// the scope-shrink prune/block paths behave exactly as pre-guard —
|
|
55
|
+
// only the new own-author retention test passes a matching callerSub.
|
|
56
|
+
return { metadata: { "created-by-sub": "uploader-sub" } };
|
|
52
57
|
}),
|
|
53
58
|
listRemoteFiles: innerVi.fn().mockImplementation(async () => REMOTE.current),
|
|
54
59
|
deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
|
|
@@ -230,6 +235,35 @@ describe("sync — scope-aware download (US-005)", () => {
|
|
|
230
235
|
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
231
236
|
});
|
|
232
237
|
|
|
238
|
+
it("scope shrink (all → shared) RETAINS the owner's own out-of-scope file", async () => {
|
|
239
|
+
// Corey's exact scenario: the caller authored everything (the mock stamps
|
|
240
|
+
// `created-by-sub: "uploader-sub"`), so passing the matching `callerSub`
|
|
241
|
+
// means narrowing to `shared` must NOT prune their own work — mode only
|
|
242
|
+
// governs mirroring OTHER people's files.
|
|
243
|
+
const all = await sync({
|
|
244
|
+
company: "acme",
|
|
245
|
+
vaultConfig: mockConfig,
|
|
246
|
+
hqRoot: tmpDir,
|
|
247
|
+
syncMode: "all",
|
|
248
|
+
callerSub: "uploader-sub",
|
|
249
|
+
});
|
|
250
|
+
expect(all.filesDownloaded).toBe(2);
|
|
251
|
+
|
|
252
|
+
const shared = await sync({
|
|
253
|
+
company: "acme",
|
|
254
|
+
vaultConfig: mockConfig,
|
|
255
|
+
hqRoot: tmpDir,
|
|
256
|
+
syncMode: "shared",
|
|
257
|
+
prefixSet: ["knowledge/"],
|
|
258
|
+
callerSub: "uploader-sub",
|
|
259
|
+
});
|
|
260
|
+
// Out of scope, but authored by the caller → retained, not pruned.
|
|
261
|
+
expect(shared.scopeOrphansRemoved).toBe(0);
|
|
262
|
+
expect(shared.scopeOrphansBlocked).toBe(0);
|
|
263
|
+
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
264
|
+
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
233
267
|
it("refuses a bulk auto-prune over the safety cap, then proceeds when forced", async () => {
|
|
234
268
|
// Pull both files under all-mode, then narrow to a scope covering neither
|
|
235
269
|
// → 2 clean orphans. With the cap set to 1, the auto-prune is refused.
|
package/src/cli/sync.ts
CHANGED
|
@@ -149,7 +149,7 @@ export type SyncProgressEvent =
|
|
|
149
149
|
*/
|
|
150
150
|
type: "reconciled";
|
|
151
151
|
path: string;
|
|
152
|
-
direction: "pull";
|
|
152
|
+
direction: "pull" | "push";
|
|
153
153
|
}
|
|
154
154
|
| {
|
|
155
155
|
type: "new-files";
|
|
@@ -330,6 +330,14 @@ export interface SyncOptions {
|
|
|
330
330
|
* tombstoned. Mirrors `hq sync narrow --force`.
|
|
331
331
|
*/
|
|
332
332
|
forceScopeShrink?: boolean;
|
|
333
|
+
/**
|
|
334
|
+
* The caller's own Cognito `sub`, used by the scope-shrink authorship guard
|
|
335
|
+
* so a scope shrink never prunes content the caller authored. Injected by the
|
|
336
|
+
* entry point — the runner sources it from its decoded idToken claims (the
|
|
337
|
+
* same sub stamped onto uploads as `created-by-sub`). The engine never reads
|
|
338
|
+
* it from disk, so it stays pure/hermetic; undefined degrades safely.
|
|
339
|
+
*/
|
|
340
|
+
callerSub?: string;
|
|
333
341
|
/**
|
|
334
342
|
* Skip the post-sync `reindex()` refresh (skill wrappers + personal overlay
|
|
335
343
|
* mirrors + workers registry). By default, when a sync changes on-disk
|
|
@@ -536,6 +544,13 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
536
544
|
const syncMode: SyncMode = options.syncMode ?? "all";
|
|
537
545
|
const currentPrefixSet =
|
|
538
546
|
syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
|
|
547
|
+
// Authorship guard input (scope-shrink): the caller's own Cognito sub,
|
|
548
|
+
// injected by the entry point (the runner sources it from its decoded
|
|
549
|
+
// idToken claims — the same sub stamped onto uploads as `created-by-sub`).
|
|
550
|
+
// Undefined degrades safely: own-author files lose their special shield, but
|
|
551
|
+
// the `protectUnknownAuthors` conservative path below still prevents a
|
|
552
|
+
// routine sync from deleting anything it can't prove is foreign.
|
|
553
|
+
const callerSub = options.callerSub;
|
|
539
554
|
|
|
540
555
|
let filesDownloaded = 0;
|
|
541
556
|
let bytesDownloaded = 0;
|
|
@@ -603,6 +618,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
603
618
|
hqRoot: companyRoot,
|
|
604
619
|
lastPrefixSet,
|
|
605
620
|
currentPrefixSet,
|
|
621
|
+
callerSub,
|
|
622
|
+
// Automatic pull: never auto-prune content the caller authored, and never
|
|
623
|
+
// make a destructive guess about unknown-author (legacy) orphans. The
|
|
624
|
+
// explicit `hq sync narrow` ritual opts out of the unknown-author shield.
|
|
625
|
+
protectUnknownAuthors: true,
|
|
606
626
|
});
|
|
607
627
|
if (shrinkPlan.dirty.length > 0 && options.forceScopeShrink !== true) {
|
|
608
628
|
throw new ScopeShrinkBlockedError(
|
|
@@ -988,6 +1008,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
988
1008
|
try {
|
|
989
1009
|
const { metadata } = await downloadFile(ctx, remoteFile.key, localPath);
|
|
990
1010
|
const author = metadata?.["created-by"] ?? null;
|
|
1011
|
+
// Author sub for the scope-shrink authorship guard — same field the
|
|
1012
|
+
// upload side stamps, read straight off the GET response metadata.
|
|
1013
|
+
const createdBySub = metadata?.["created-by-sub"];
|
|
991
1014
|
|
|
992
1015
|
// Symlink records materialize as real symlinks on disk. lstat
|
|
993
1016
|
// (does not follow) lets us detect that case so the journal stamp
|
|
@@ -1026,6 +1049,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
1026
1049
|
"down",
|
|
1027
1050
|
remoteFile.etag,
|
|
1028
1051
|
localLstat.mtimeMs,
|
|
1052
|
+
createdBySub,
|
|
1029
1053
|
);
|
|
1030
1054
|
|
|
1031
1055
|
// Attach message from the prior journal entry if present (set by a
|
package/src/journal.ts
CHANGED
|
@@ -211,6 +211,7 @@ export function updateEntry(
|
|
|
211
211
|
direction: "up" | "down",
|
|
212
212
|
remoteEtag?: string,
|
|
213
213
|
mtimeMs?: number,
|
|
214
|
+
createdBySub?: string,
|
|
214
215
|
): void {
|
|
215
216
|
const entry: JournalEntry = {
|
|
216
217
|
hash,
|
|
@@ -224,6 +225,12 @@ export function updateEntry(
|
|
|
224
225
|
if (mtimeMs !== undefined) {
|
|
225
226
|
entry.mtimeMs = mtimeMs;
|
|
226
227
|
}
|
|
228
|
+
// Authorship (scope-shrink guard input). Stamped from the object's
|
|
229
|
+
// `created-by-sub` S3 metadata on download. Only persisted when present so
|
|
230
|
+
// legacy journals and author-less uploads stay byte-identical.
|
|
231
|
+
if (createdBySub !== undefined && createdBySub !== "") {
|
|
232
|
+
entry.createdBySub = createdBySub;
|
|
233
|
+
}
|
|
227
234
|
journal.files[relativePath] = entry;
|
|
228
235
|
journal.lastSync = new Date().toISOString();
|
|
229
236
|
}
|