@indigoai-us/hq-cloud 6.2.2 → 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/sync-scope.test.js +33 -1
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +8 -0
- 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/sync-scope.test.ts +35 -1
- package/src/cli/sync.ts +24 -0
- 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
|
});
|
|
@@ -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
|
@@ -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
|
}
|
package/src/remote-pull.test.ts
CHANGED
|
@@ -594,6 +594,9 @@ describe("pullCompany (engine orchestrator)", () => {
|
|
|
594
594
|
size: 8,
|
|
595
595
|
syncedAt: new Date().toISOString(),
|
|
596
596
|
direction: "down",
|
|
597
|
+
// Authored by someone else (no matching callerSub passed below), so
|
|
598
|
+
// the authorship guard correctly leaves it eligible for shrink.
|
|
599
|
+
createdBySub: "uploader-sub",
|
|
597
600
|
},
|
|
598
601
|
},
|
|
599
602
|
pulls: [
|
|
@@ -651,12 +654,14 @@ describe("pullCompany (engine orchestrator)", () => {
|
|
|
651
654
|
size: 8,
|
|
652
655
|
syncedAt: new Date().toISOString(),
|
|
653
656
|
direction: "down",
|
|
657
|
+
createdBySub: "uploader-sub", // foreign author — eligible for shrink
|
|
654
658
|
},
|
|
655
659
|
"companies/indigo/scratch/clean.md": {
|
|
656
660
|
hash: sha256("clean"),
|
|
657
661
|
size: 5,
|
|
658
662
|
syncedAt: new Date().toISOString(),
|
|
659
663
|
direction: "down",
|
|
664
|
+
createdBySub: "uploader-sub", // foreign author — eligible for shrink
|
|
660
665
|
},
|
|
661
666
|
},
|
|
662
667
|
pulls: [
|
|
@@ -711,6 +716,7 @@ describe("pullCompany (engine orchestrator)", () => {
|
|
|
711
716
|
size: 5,
|
|
712
717
|
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
713
718
|
direction: "down",
|
|
719
|
+
createdBySub: "uploader-sub", // foreign author — eligible for shrink
|
|
714
720
|
},
|
|
715
721
|
},
|
|
716
722
|
};
|
|
@@ -741,6 +747,118 @@ describe("pullCompany (engine orchestrator)", () => {
|
|
|
741
747
|
expect(journal.version).toBe("2"); // migrated by appendPullRecord
|
|
742
748
|
});
|
|
743
749
|
|
|
750
|
+
it("retains a caller-authored orphan across a scope shrink (own work is never pruned)", async () => {
|
|
751
|
+
// The owner narrowed to `shared`, but `projects/mine.md` is their own work
|
|
752
|
+
// (createdBySub === callerSub). A scope shrink must NOT prune it — mode
|
|
753
|
+
// only governs mirroring OTHER people's files.
|
|
754
|
+
const mineAbs = path.join(hqRoot, "companies/indigo/projects/mine.md");
|
|
755
|
+
fs.mkdirSync(path.dirname(mineAbs), { recursive: true });
|
|
756
|
+
fs.writeFileSync(mineAbs, "mine");
|
|
757
|
+
const past = Date.now() - 60_000;
|
|
758
|
+
fs.utimesSync(mineAbs, past / 1000, past / 1000);
|
|
759
|
+
|
|
760
|
+
const journal: SyncJournal = {
|
|
761
|
+
version: "2",
|
|
762
|
+
lastSync: "",
|
|
763
|
+
files: {
|
|
764
|
+
"companies/indigo/projects/mine.md": {
|
|
765
|
+
hash: sha256("mine"),
|
|
766
|
+
size: 4,
|
|
767
|
+
syncedAt: new Date().toISOString(),
|
|
768
|
+
direction: "down",
|
|
769
|
+
createdBySub: "owner-sub",
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
pulls: [
|
|
773
|
+
{
|
|
774
|
+
pullId: "01PREV",
|
|
775
|
+
companyUid: "cmp_indigo",
|
|
776
|
+
startedAt: "2026-05-19T00:00:00.000Z",
|
|
777
|
+
completedAt: "2026-05-19T00:00:05.000Z",
|
|
778
|
+
syncMode: "all",
|
|
779
|
+
prefixSet: ["companies/indigo/"],
|
|
780
|
+
scopeChangeDetected: false,
|
|
781
|
+
orphansRemoved: 0,
|
|
782
|
+
orphansBlocked: 0,
|
|
783
|
+
},
|
|
784
|
+
],
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const result = await pullCompany({
|
|
788
|
+
ctx: makeCtx(),
|
|
789
|
+
journal,
|
|
790
|
+
hqRoot,
|
|
791
|
+
callerSub: "owner-sub",
|
|
792
|
+
scope: {
|
|
793
|
+
companyUid: "cmp_indigo",
|
|
794
|
+
syncMode: "shared",
|
|
795
|
+
prefixSet: ["companies/indigo/meetings/"],
|
|
796
|
+
strategy: "vend-fanout",
|
|
797
|
+
},
|
|
798
|
+
listFn: async () => [],
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
expect(result.pullRecord.scopeChangeDetected).toBe(false);
|
|
802
|
+
expect(result.pullRecord.orphansRemoved).toBe(0);
|
|
803
|
+
expect(fs.existsSync(mineAbs)).toBe(true); // own work preserved
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it("retains an unknown-author orphan on the automatic path (conservative, never auto-deletes)", async () => {
|
|
807
|
+
// A legacy entry with no recorded author. The background pull must not make
|
|
808
|
+
// a destructive guess — retain it (the explicit narrow ritual is the
|
|
809
|
+
// confirmed path that can reclaim it).
|
|
810
|
+
const legacyAbs = path.join(hqRoot, "companies/indigo/projects/legacy.md");
|
|
811
|
+
fs.mkdirSync(path.dirname(legacyAbs), { recursive: true });
|
|
812
|
+
fs.writeFileSync(legacyAbs, "legacy");
|
|
813
|
+
const past = Date.now() - 60_000;
|
|
814
|
+
fs.utimesSync(legacyAbs, past / 1000, past / 1000);
|
|
815
|
+
|
|
816
|
+
const journal: SyncJournal = {
|
|
817
|
+
version: "2",
|
|
818
|
+
lastSync: "",
|
|
819
|
+
files: {
|
|
820
|
+
"companies/indigo/projects/legacy.md": {
|
|
821
|
+
hash: sha256("legacy"),
|
|
822
|
+
size: 6,
|
|
823
|
+
syncedAt: new Date().toISOString(),
|
|
824
|
+
direction: "down",
|
|
825
|
+
// no createdBySub — predates author stamping
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
pulls: [
|
|
829
|
+
{
|
|
830
|
+
pullId: "01PREV",
|
|
831
|
+
companyUid: "cmp_indigo",
|
|
832
|
+
startedAt: "2026-05-19T00:00:00.000Z",
|
|
833
|
+
completedAt: "2026-05-19T00:00:05.000Z",
|
|
834
|
+
syncMode: "all",
|
|
835
|
+
prefixSet: ["companies/indigo/"],
|
|
836
|
+
scopeChangeDetected: false,
|
|
837
|
+
orphansRemoved: 0,
|
|
838
|
+
orphansBlocked: 0,
|
|
839
|
+
},
|
|
840
|
+
],
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const result = await pullCompany({
|
|
844
|
+
ctx: makeCtx(),
|
|
845
|
+
journal,
|
|
846
|
+
hqRoot,
|
|
847
|
+
callerSub: "owner-sub",
|
|
848
|
+
scope: {
|
|
849
|
+
companyUid: "cmp_indigo",
|
|
850
|
+
syncMode: "shared",
|
|
851
|
+
prefixSet: ["companies/indigo/meetings/"],
|
|
852
|
+
strategy: "vend-fanout",
|
|
853
|
+
},
|
|
854
|
+
listFn: async () => [],
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
expect(result.pullRecord.scopeChangeDetected).toBe(false);
|
|
858
|
+
expect(result.pullRecord.orphansRemoved).toBe(0);
|
|
859
|
+
expect(fs.existsSync(legacyAbs)).toBe(true); // legacy file retained
|
|
860
|
+
});
|
|
861
|
+
|
|
744
862
|
it("GC's expired tombstones at the start of every leg", async () => {
|
|
745
863
|
const old = new Date(
|
|
746
864
|
Date.now() - 31 * 24 * 60 * 60 * 1000,
|
package/src/remote-pull.ts
CHANGED
|
@@ -368,6 +368,13 @@ export interface PullCompanyInput {
|
|
|
368
368
|
conflictKeys?: Set<string>;
|
|
369
369
|
/** Honor the operator override on dirty orphans (US-005 contract). */
|
|
370
370
|
forceScopeShrink?: boolean;
|
|
371
|
+
/**
|
|
372
|
+
* The caller's own Cognito `sub` for the scope-shrink authorship guard — a
|
|
373
|
+
* scope shrink never prunes content the caller authored. Defaults to the
|
|
374
|
+
* cached session sub (`resolveCallerSubFromCache()`); pass explicitly when
|
|
375
|
+
* the runner already decoded its idToken claims.
|
|
376
|
+
*/
|
|
377
|
+
callerSub?: string;
|
|
371
378
|
/** Listing override hook — see `ListRemoteForScopeInput.listFn`. */
|
|
372
379
|
listFn?: ListRemoteForScopeInput["listFn"];
|
|
373
380
|
vendForBatchFn?: ListRemoteForScopeInput["vendForBatchFn"];
|
|
@@ -432,6 +439,11 @@ export async function pullCompany(
|
|
|
432
439
|
hqRoot: input.hqRoot,
|
|
433
440
|
lastPrefixSet,
|
|
434
441
|
currentPrefixSet: input.scope.prefixSet,
|
|
442
|
+
callerSub: input.callerSub,
|
|
443
|
+
// Background runner pull: protect the caller's own work and don't make a
|
|
444
|
+
// destructive guess about unknown-author (legacy) orphans. The explicit
|
|
445
|
+
// `hq sync narrow` ritual is the confirmed path that opts out of this.
|
|
446
|
+
protectUnknownAuthors: true,
|
|
435
447
|
});
|
|
436
448
|
|
|
437
449
|
let scopeShrinkApplied: ApplyScopeShrinkResult | null = null;
|
package/src/scope-shrink.test.ts
CHANGED
|
@@ -239,6 +239,134 @@ describe("buildScopeShrinkPlan", () => {
|
|
|
239
239
|
});
|
|
240
240
|
expect(plan.orphans).toEqual([]);
|
|
241
241
|
});
|
|
242
|
+
|
|
243
|
+
// ── Authorship guard ──────────────────────────────────────────────────────
|
|
244
|
+
// Sync mode decides whether you mirror OTHER people's files; it must never
|
|
245
|
+
// orphan content the caller authored. Owners hold their whole vault by
|
|
246
|
+
// role-bypass, so without this guard a `shared`/`custom` scope would treat
|
|
247
|
+
// their own un-granted work as "someone else's file" and prune it.
|
|
248
|
+
|
|
249
|
+
it("never orphans a file the caller authored, even out of scope", () => {
|
|
250
|
+
const journal: SyncJournal = {
|
|
251
|
+
...emptyJournal(),
|
|
252
|
+
files: {
|
|
253
|
+
"companies/indigo/projects/mine.md": {
|
|
254
|
+
hash: "h",
|
|
255
|
+
size: 1,
|
|
256
|
+
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
257
|
+
direction: "down",
|
|
258
|
+
createdBySub: "sub-corey",
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
const plan = buildScopeShrinkPlan({
|
|
263
|
+
journal,
|
|
264
|
+
hqRoot,
|
|
265
|
+
lastPrefixSet: ["companies/indigo/"],
|
|
266
|
+
currentPrefixSet: ["companies/indigo/meetings/"],
|
|
267
|
+
callerSub: "sub-corey",
|
|
268
|
+
});
|
|
269
|
+
expect(plan.orphans).toEqual([]);
|
|
270
|
+
expect(plan.scopeChangeDetected).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("orphans a file authored by someone else (mode stops mirroring others' files)", () => {
|
|
274
|
+
const journal: SyncJournal = {
|
|
275
|
+
...emptyJournal(),
|
|
276
|
+
files: {
|
|
277
|
+
"companies/indigo/projects/theirs.md": {
|
|
278
|
+
hash: "h",
|
|
279
|
+
size: 1,
|
|
280
|
+
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
281
|
+
direction: "down",
|
|
282
|
+
createdBySub: "sub-jacob",
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
const plan = buildScopeShrinkPlan({
|
|
287
|
+
journal,
|
|
288
|
+
hqRoot,
|
|
289
|
+
lastPrefixSet: ["companies/indigo/"],
|
|
290
|
+
currentPrefixSet: ["companies/indigo/meetings/"],
|
|
291
|
+
callerSub: "sub-corey",
|
|
292
|
+
protectUnknownAuthors: true,
|
|
293
|
+
});
|
|
294
|
+
expect(plan.orphans.map((o) => o.path)).toEqual([
|
|
295
|
+
"companies/indigo/projects/theirs.md",
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("retains an unknown-author orphan when protectUnknownAuthors is set (conservative auto path)", () => {
|
|
300
|
+
const journal: SyncJournal = {
|
|
301
|
+
...emptyJournal(),
|
|
302
|
+
files: {
|
|
303
|
+
"companies/indigo/projects/legacy.md": {
|
|
304
|
+
hash: "h",
|
|
305
|
+
size: 1,
|
|
306
|
+
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
307
|
+
direction: "down",
|
|
308
|
+
// no createdBySub — a legacy entry predating author stamping
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
const plan = buildScopeShrinkPlan({
|
|
313
|
+
journal,
|
|
314
|
+
hqRoot,
|
|
315
|
+
lastPrefixSet: ["companies/indigo/"],
|
|
316
|
+
currentPrefixSet: ["companies/indigo/meetings/"],
|
|
317
|
+
callerSub: "sub-corey",
|
|
318
|
+
protectUnknownAuthors: true,
|
|
319
|
+
});
|
|
320
|
+
expect(plan.orphans).toEqual([]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("prunes an unknown-author orphan when protectUnknownAuthors is off (explicit narrow path)", () => {
|
|
324
|
+
const journal: SyncJournal = {
|
|
325
|
+
...emptyJournal(),
|
|
326
|
+
files: {
|
|
327
|
+
"companies/indigo/projects/legacy.md": {
|
|
328
|
+
hash: "h",
|
|
329
|
+
size: 1,
|
|
330
|
+
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
331
|
+
direction: "down",
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
const plan = buildScopeShrinkPlan({
|
|
336
|
+
journal,
|
|
337
|
+
hqRoot,
|
|
338
|
+
lastPrefixSet: ["companies/indigo/"],
|
|
339
|
+
currentPrefixSet: ["companies/indigo/meetings/"],
|
|
340
|
+
callerSub: "sub-corey",
|
|
341
|
+
});
|
|
342
|
+
expect(plan.orphans.map((o) => o.path)).toEqual([
|
|
343
|
+
"companies/indigo/projects/legacy.md",
|
|
344
|
+
]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("ignores authorship when no callerSub is supplied (back-compat)", () => {
|
|
348
|
+
const journal: SyncJournal = {
|
|
349
|
+
...emptyJournal(),
|
|
350
|
+
files: {
|
|
351
|
+
"companies/indigo/projects/mine.md": {
|
|
352
|
+
hash: "h",
|
|
353
|
+
size: 1,
|
|
354
|
+
syncedAt: "2026-05-01T00:00:00.000Z",
|
|
355
|
+
direction: "down",
|
|
356
|
+
createdBySub: "sub-corey",
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
const plan = buildScopeShrinkPlan({
|
|
361
|
+
journal,
|
|
362
|
+
hqRoot,
|
|
363
|
+
lastPrefixSet: ["companies/indigo/"],
|
|
364
|
+
currentPrefixSet: ["companies/indigo/meetings/"],
|
|
365
|
+
});
|
|
366
|
+
expect(plan.orphans.map((o) => o.path)).toEqual([
|
|
367
|
+
"companies/indigo/projects/mine.md",
|
|
368
|
+
]);
|
|
369
|
+
});
|
|
242
370
|
});
|
|
243
371
|
|
|
244
372
|
describe("applyScopeShrink", () => {
|