@indigoai-us/hq-cloud 6.2.2 → 6.2.4
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 +32 -56
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-journal-reconcile.test.js +100 -49
- 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 +6 -1
- package/dist/cli/share.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 +24 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.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/public-surface.test.js +5 -0
- package/dist/public-surface.test.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 +2 -2
- package/src/bin/sync-runner.ts +8 -0
- package/src/cli/rescue-core.ts +36 -71
- package/src/cli/rescue-journal-reconcile.test.ts +113 -54
- package/src/cli/share.ts +6 -0
- package/src/cli/sync-scope.test.ts +35 -1
- package/src/cli/sync.ts +32 -0
- package/src/index.ts +7 -0
- package/src/journal.ts +7 -0
- package/src/public-surface.test.ts +5 -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
package/src/cli/rescue-core.ts
CHANGED
|
@@ -28,13 +28,6 @@ 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";
|
|
38
31
|
|
|
39
32
|
export interface RunRescueResult {
|
|
40
33
|
status: number;
|
|
@@ -568,6 +561,16 @@ function doRescue(
|
|
|
568
561
|
}
|
|
569
562
|
|
|
570
563
|
const srcSha = run("git", ["rev-parse", "HEAD"], { cwd: srcDir, env }).stdout.trim();
|
|
564
|
+
// Committer timestamp of the source SHA — the DETERMINISTIC stamp value for
|
|
565
|
+
// core/core.yaml's `last_sync_at`. Stamping wall-clock "now" made every
|
|
566
|
+
// rescue run on every machine produce a byte-different core.yaml for the
|
|
567
|
+
// SAME release, which is exactly the genuine local≠remote divergence that
|
|
568
|
+
// minted `core.yaml.conflict-*` mirrors across machines. Same SHA in →
|
|
569
|
+
// identical bytes out, on any machine, any day.
|
|
570
|
+
const srcCommitIsoRaw = run("git", ["show", "-s", "--format=%cI", "HEAD"], {
|
|
571
|
+
cwd: srcDir,
|
|
572
|
+
env,
|
|
573
|
+
}).stdout.trim();
|
|
571
574
|
out(`==> Source SHA: ${srcSha}\n`);
|
|
572
575
|
|
|
573
576
|
// --- Restore file mtimes from git history ---
|
|
@@ -799,7 +802,7 @@ function doRescue(
|
|
|
799
802
|
}
|
|
800
803
|
const overlay = run(
|
|
801
804
|
"rsync",
|
|
802
|
-
["-
|
|
805
|
+
["-a", ...overlayProtect, ...rsyncExcludes, srcDir + "/", hqRoot + "/"],
|
|
803
806
|
{ env },
|
|
804
807
|
);
|
|
805
808
|
if (overlay.status !== 0) {
|
|
@@ -825,71 +828,24 @@ function doRescue(
|
|
|
825
828
|
}
|
|
826
829
|
}
|
|
827
830
|
|
|
828
|
-
// ---
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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`,
|
|
831
|
+
// --- Stamp sync-point provenance into core/core.yaml ---
|
|
832
|
+
// `last_sync_at` = the source commit's committer time, NOT wall-clock now:
|
|
833
|
+
// the stamp must be a pure function of srcSha so every machine rescuing the
|
|
834
|
+
// same release writes byte-identical core.yaml (see srcCommitIsoRaw). An
|
|
835
|
+
// unparseable commit timestamp skips the stamp entirely — a nondeterministic
|
|
836
|
+
// stamp is worse than no stamp (it re-creates cross-machine conflict mirrors).
|
|
837
|
+
const srcCommitDate = new Date(srcCommitIsoRaw);
|
|
838
|
+
if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml) && Number.isNaN(srcCommitDate.getTime())) {
|
|
839
|
+
err(
|
|
840
|
+
` WARN: could not parse source commit timestamp (${JSON.stringify(srcCommitIsoRaw)}); skipping core/core.yaml stamp to keep it deterministic\n`,
|
|
887
841
|
);
|
|
888
842
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
843
|
+
if (
|
|
844
|
+
cfg.narrowPaths.length === 0 &&
|
|
845
|
+
isFileFollow(coreYaml) &&
|
|
846
|
+
!Number.isNaN(srcCommitDate.getTime())
|
|
847
|
+
) {
|
|
848
|
+
const nowUtc = utcStamp(srcCommitDate, "colon");
|
|
893
849
|
if (yqAvailable) {
|
|
894
850
|
const stampEnv: NodeJS.ProcessEnv = {
|
|
895
851
|
...env,
|
|
@@ -955,6 +911,15 @@ function doRescue(
|
|
|
955
911
|
}
|
|
956
912
|
}
|
|
957
913
|
|
|
914
|
+
// NOTE (6.2.4): the post-rescue "sync-journal baseline reconcile" that lived
|
|
915
|
+
// here (6.2.1 itemize-parse, 6.2.3 hash-diff) is intentionally GONE. Masking
|
|
916
|
+
// rescue-changed scaffold as already-synced starved the push leg — the vault
|
|
917
|
+
// silently kept stale scaffold bytes while the journal claimed convergence.
|
|
918
|
+
// Correct semantics: leave rescue-changed files visible as "local changed";
|
|
919
|
+
// the next bidirectional sync pushes them up (vault converges to the rescue
|
|
920
|
+
// output), and the pull-side byte-identical convergence probe (6.2.0)
|
|
921
|
+
// absorbs cross-machine races without minting `.conflict-*` mirrors.
|
|
922
|
+
|
|
958
923
|
// --- File count summary ---
|
|
959
924
|
out("\n");
|
|
960
925
|
out("==> File count summary:\n");
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Regression for the rescue
|
|
2
|
+
* Regression for the rescue <-> sync-journal interaction (6.2.4 semantics).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* pending edit is never touched).
|
|
4
|
+
* History: 6.2.1/6.2.3 "reconciled" the journal after a rescue by re-stamping
|
|
5
|
+
* changed scaffold entries to the current local hash. That MASKED the rescue's
|
|
6
|
+
* changes from the sync engine: the push leg saw localChanged=false and never
|
|
7
|
+
* uploaded the regenerated scaffold, so the vault silently kept stale bytes
|
|
8
|
+
* while the journal claimed convergence (verified live 2026-06-10: 4 scaffold
|
|
9
|
+
* files byte-diverged from the vault under a 0-conflict sync).
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Correct semantics (this test): the rescue must NOT touch the personal-vault
|
|
12
|
+
* sync journal at all. Rescue-changed scaffold stays visible as "local
|
|
13
|
+
* changed", the next bidirectional sync pushes it up, and the pull-side
|
|
14
|
+
* byte-identical convergence probe (6.2.0) absorbs cross-machine races.
|
|
15
|
+
*
|
|
16
|
+
* Also covered: the core/core.yaml provenance stamp must be DETERMINISTIC —
|
|
17
|
+
* a pure function of the source SHA (committer time, not wall-clock now) — so
|
|
18
|
+
* every machine rescuing the same release writes byte-identical core.yaml.
|
|
19
|
+
* The wall-clock stamp was the one genuine cross-machine divergence engine
|
|
20
|
+
* behind `core.yaml.conflict-*` mirrors.
|
|
15
21
|
*/
|
|
16
22
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
17
23
|
import { execFileSync } from "child_process";
|
|
@@ -52,10 +58,14 @@ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
|
52
58
|
return { status, stdout };
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
|
|
61
|
+
const SCAFFOLD = ["core/a.md", "core/docs/b.md", ".claude/c.sh", "core/core.yaml"];
|
|
62
|
+
|
|
63
|
+
describe.skipIf(!gitAvailable)("rescue leaves the sync journal untouched + stamps core.yaml deterministically", () => {
|
|
56
64
|
let workDir: string, upstream: string, hqRoot: string, stateDir: string, floorSha: string;
|
|
57
65
|
let env: NodeJS.ProcessEnv;
|
|
58
66
|
let savedStateDir: string | undefined;
|
|
67
|
+
let headCommitterIso: string;
|
|
68
|
+
let seededHashes: Record<string, string>;
|
|
59
69
|
|
|
60
70
|
const git = (cwd: string, ...args: string[]) =>
|
|
61
71
|
execFileSync("git", args, {
|
|
@@ -64,52 +74,67 @@ describe.skipIf(!gitAvailable)("rescue re-stamps sync-journal baseline for re-la
|
|
|
64
74
|
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
|
|
65
75
|
}).toString().trim();
|
|
66
76
|
|
|
77
|
+
const w = (root: string, rel: string, body: string) => {
|
|
78
|
+
const p = path.join(root, rel);
|
|
79
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
80
|
+
fs.writeFileSync(p, body);
|
|
81
|
+
};
|
|
82
|
+
|
|
67
83
|
beforeAll(() => {
|
|
68
84
|
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-journal-"));
|
|
69
85
|
|
|
70
|
-
// upstream: floor (
|
|
86
|
+
// --- upstream: floor (all scaffold = v1), HEAD advances the 3 docs to v2 ---
|
|
71
87
|
upstream = path.join(workDir, "upstream");
|
|
72
|
-
fs.mkdirSync(
|
|
88
|
+
fs.mkdirSync(upstream, { recursive: true });
|
|
73
89
|
git(workDir, "init", "-b", "main", "upstream");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
w(upstream, "core/a.md", "v1\n");
|
|
91
|
+
w(upstream, "core/docs/b.md", "v1\n");
|
|
92
|
+
w(upstream, ".claude/c.sh", "v1\n");
|
|
93
|
+
w(upstream, "core/core.yaml", "version: 1\n");
|
|
94
|
+
git(upstream, "add", "-A"); git(upstream, "commit", "-m", "floor");
|
|
77
95
|
floorSha = git(upstream, "rev-parse", "HEAD");
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
w(upstream, "core/a.md", "v2\n");
|
|
97
|
+
w(upstream, "core/docs/b.md", "v2\n");
|
|
98
|
+
w(upstream, ".claude/c.sh", "v2\n");
|
|
99
|
+
git(upstream, "add", "-A"); git(upstream, "commit", "-m", "head");
|
|
100
|
+
headCommitterIso = git(upstream, "show", "-s", "--format=%cI", "HEAD");
|
|
81
101
|
|
|
82
|
-
// local HQ root:
|
|
102
|
+
// --- local HQ root: scaffold == floor (overlaid to v2); a pending personal/ edit ---
|
|
83
103
|
hqRoot = path.join(workDir, "hq");
|
|
84
|
-
fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
|
|
85
|
-
fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
|
|
86
104
|
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
w(hqRoot, "core/a.md", "v1\n");
|
|
106
|
+
w(hqRoot, "core/docs/b.md", "v1\n");
|
|
107
|
+
w(hqRoot, ".claude/c.sh", "v1\n");
|
|
108
|
+
w(hqRoot, "core/core.yaml", "version: 1\n");
|
|
109
|
+
w(hqRoot, "personal/edited.md", "USER_LOCAL\n"); // local pending edit
|
|
89
110
|
|
|
90
|
-
// state dir + seeded
|
|
111
|
+
// --- state dir + seeded journal: pre-rescue baselines for scaffold + a divergent personal/ entry ---
|
|
91
112
|
stateDir = path.join(workDir, "state");
|
|
92
113
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
93
114
|
savedStateDir = process.env.HQ_STATE_DIR;
|
|
94
|
-
process.env.HQ_STATE_DIR = stateDir;
|
|
95
|
-
const
|
|
115
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
116
|
+
const entry = (rel: string, hash: string) => ({
|
|
117
|
+
hash,
|
|
118
|
+
size: fs.statSync(path.join(hqRoot, rel)).size,
|
|
119
|
+
syncedAt: new Date(0).toISOString(),
|
|
120
|
+
direction: "down" as const,
|
|
121
|
+
remoteEtag: "seed-etag",
|
|
122
|
+
mtimeMs: fs.statSync(path.join(hqRoot, rel)).mtimeMs,
|
|
123
|
+
});
|
|
124
|
+
const files: Record<string, unknown> = {};
|
|
125
|
+
seededHashes = {};
|
|
126
|
+
for (const rel of SCAFFOLD) {
|
|
127
|
+
const h = hashFile(path.join(hqRoot, rel)); // hash of v1/version:1
|
|
128
|
+
seededHashes[rel] = h;
|
|
129
|
+
files[rel] = entry(rel, h);
|
|
130
|
+
}
|
|
131
|
+
// personal/ pending edit: journal records a DIFFERENT (already-synced) hash than local.
|
|
132
|
+
files["personal/edited.md"] = { ...entry("personal/edited.md", "remote-side-hash-differs-from-local") };
|
|
96
133
|
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: [],
|
|
134
|
+
version: "2", lastSync: new Date(0).toISOString(), files, pulls: [],
|
|
110
135
|
} as never);
|
|
111
136
|
|
|
112
|
-
// git shim: redirect
|
|
137
|
+
// --- git shim: redirect clone to the local fixture ---
|
|
113
138
|
const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
114
139
|
const shimDir = path.join(workDir, "shim");
|
|
115
140
|
fs.mkdirSync(shimDir, { recursive: true });
|
|
@@ -133,24 +158,58 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
133
158
|
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
134
159
|
});
|
|
135
160
|
|
|
136
|
-
it("
|
|
137
|
-
const docAbs = path.join(hqRoot, "core/doc.md");
|
|
138
|
-
const staleHash = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"].hash;
|
|
139
|
-
|
|
161
|
+
it("leaves EVERY journal entry untouched so the push leg uploads rescue output (vault converges)", () => {
|
|
140
162
|
const r = runRescueCapture(
|
|
141
163
|
["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
|
|
142
164
|
env,
|
|
143
165
|
);
|
|
144
166
|
expect(r.status, r.stdout).toBe(0);
|
|
145
167
|
|
|
146
|
-
//
|
|
147
|
-
|
|
168
|
+
// the 3 docs were overlaid v1 -> v2
|
|
169
|
+
for (const rel of ["core/a.md", "core/docs/b.md", ".claude/c.sh"]) {
|
|
170
|
+
expect(fs.readFileSync(path.join(hqRoot, rel), "utf-8")).toBe("v2\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const j = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files;
|
|
174
|
+
|
|
175
|
+
// INVARIANT: the rescue never rewrites journal baselines. Every scaffold
|
|
176
|
+
// entry still carries its PRE-rescue hash -> localChanged=true on the next
|
|
177
|
+
// sync -> the push leg uploads the regenerated scaffold and the VAULT
|
|
178
|
+
// converges to the rescue output. (Masking these — 6.2.1/6.2.3 — left the
|
|
179
|
+
// vault silently stale.)
|
|
180
|
+
for (const rel of SCAFFOLD) {
|
|
181
|
+
expect(j[rel].hash, `${rel} baseline was rewritten`).toBe(seededHashes[rel]);
|
|
182
|
+
expect(j[rel].remoteEtag, `${rel} remote side touched`).toBe("seed-etag");
|
|
183
|
+
// and the local file genuinely changed, so the entry is push-visible
|
|
184
|
+
expect(j[rel].hash).not.toBe(hashFile(path.join(hqRoot, rel)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// SAFETY: the personal/ pending edit is untouched too — still uploads.
|
|
188
|
+
expect(j["personal/edited.md"].hash).toBe("remote-side-hash-differs-from-local");
|
|
189
|
+
expect(j["personal/edited.md"].hash).not.toBe(hashFile(path.join(hqRoot, "personal/edited.md")));
|
|
148
190
|
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
191
|
+
// the masking reconcile is gone
|
|
192
|
+
expect(r.stdout).not.toContain("Reconciled sync-journal baseline");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("stamps core.yaml deterministically: byte-identical across runs, last_sync_at = source committer time", () => {
|
|
196
|
+
const coreYaml = path.join(hqRoot, "core/core.yaml");
|
|
197
|
+
const firstRun = fs.readFileSync(coreYaml, "utf-8");
|
|
198
|
+
|
|
199
|
+
// last_sync_at must be derived from the HEAD commit, not wall-clock now.
|
|
200
|
+
// utcStamp(_, "colon") renders UTC as YYYY-MM-DDTHH:MM:SSZ — compare on
|
|
201
|
+
// the epoch second, not the string, to stay offset-agnostic.
|
|
202
|
+
const stamped = /last_sync_at:\s*["']?([0-9TZ:.-]+)["']?/.exec(firstRun);
|
|
203
|
+
expect(stamped, `no last_sync_at in:\n${firstRun}`).not.toBeNull();
|
|
204
|
+
expect(new Date(stamped![1]).getTime()).toBe(new Date(headCommitterIso).getTime());
|
|
205
|
+
|
|
206
|
+
// a second rescue of the SAME SHA must write byte-identical core.yaml —
|
|
207
|
+
// this is the cross-machine `core.yaml.conflict-*` mirror regression.
|
|
208
|
+
const r2 = runRescueCapture(
|
|
209
|
+
["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
|
|
210
|
+
env,
|
|
211
|
+
);
|
|
212
|
+
expect(r2.status, r2.stdout).toBe(0);
|
|
213
|
+
expect(fs.readFileSync(coreYaml, "utf-8")).toBe(firstRun);
|
|
155
214
|
});
|
|
156
215
|
});
|
package/src/cli/share.ts
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
updateEntry,
|
|
31
31
|
removeEntry,
|
|
32
32
|
normalizeEtag,
|
|
33
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
34
|
+
migratePersonalVaultJournal,
|
|
33
35
|
} from "../journal.js";
|
|
34
36
|
import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
|
|
35
37
|
import {
|
|
@@ -755,6 +757,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
755
757
|
? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
|
|
756
758
|
: ignoreFilter;
|
|
757
759
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
760
|
+
// Seed the canonical personal-vault journal from the legacy `personal` file
|
|
761
|
+
// exactly once — engine-side so every consumer (sync-runner, hq-cli) gets
|
|
762
|
+
// it; see the matching guard in sync.ts.
|
|
763
|
+
if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
|
|
758
764
|
const journal = readJournal(journalSlug);
|
|
759
765
|
|
|
760
766
|
let filesUploaded = 0;
|
|
@@ -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
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
lastPullRecord,
|
|
32
32
|
appendPullRecord,
|
|
33
33
|
generatePullId,
|
|
34
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
35
|
+
migratePersonalVaultJournal,
|
|
34
36
|
} from "../journal.js";
|
|
35
37
|
import {
|
|
36
38
|
buildScopeShrinkPlan,
|
|
@@ -330,6 +332,14 @@ export interface SyncOptions {
|
|
|
330
332
|
* tombstoned. Mirrors `hq sync narrow --force`.
|
|
331
333
|
*/
|
|
332
334
|
forceScopeShrink?: boolean;
|
|
335
|
+
/**
|
|
336
|
+
* The caller's own Cognito `sub`, used by the scope-shrink authorship guard
|
|
337
|
+
* so a scope shrink never prunes content the caller authored. Injected by the
|
|
338
|
+
* entry point — the runner sources it from its decoded idToken claims (the
|
|
339
|
+
* same sub stamped onto uploads as `created-by-sub`). The engine never reads
|
|
340
|
+
* it from disk, so it stays pure/hermetic; undefined degrades safely.
|
|
341
|
+
*/
|
|
342
|
+
callerSub?: string;
|
|
333
343
|
/**
|
|
334
344
|
* Skip the post-sync `reindex()` refresh (skill wrappers + personal overlay
|
|
335
345
|
* mirrors + workers registry). By default, when a sync changes on-disk
|
|
@@ -518,6 +528,12 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
518
528
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
519
529
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
520
530
|
const startedAt = new Date().toISOString();
|
|
531
|
+
// Personal-vault callers must never start from an empty journal when only
|
|
532
|
+
// the legacy `personal` file exists (mass re-download/etag churn). Seeding
|
|
533
|
+
// here — inside the engine — covers every consumer (sync-runner already
|
|
534
|
+
// seeds; hq-cli historically didn't, which split the vault's bookkeeping
|
|
535
|
+
// across two journal files and re-flagged synced files as conflicts).
|
|
536
|
+
if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
|
|
521
537
|
// Migrate v1 → v2 in place so the scope-shrink / pull-record machinery has
|
|
522
538
|
// its fields, and GC any tombstones past the 30-day retention window before
|
|
523
539
|
// we re-evaluate orphans (so a long-pruned path can re-download cleanly).
|
|
@@ -536,6 +552,13 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
536
552
|
const syncMode: SyncMode = options.syncMode ?? "all";
|
|
537
553
|
const currentPrefixSet =
|
|
538
554
|
syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
|
|
555
|
+
// Authorship guard input (scope-shrink): the caller's own Cognito sub,
|
|
556
|
+
// injected by the entry point (the runner sources it from its decoded
|
|
557
|
+
// idToken claims — the same sub stamped onto uploads as `created-by-sub`).
|
|
558
|
+
// Undefined degrades safely: own-author files lose their special shield, but
|
|
559
|
+
// the `protectUnknownAuthors` conservative path below still prevents a
|
|
560
|
+
// routine sync from deleting anything it can't prove is foreign.
|
|
561
|
+
const callerSub = options.callerSub;
|
|
539
562
|
|
|
540
563
|
let filesDownloaded = 0;
|
|
541
564
|
let bytesDownloaded = 0;
|
|
@@ -603,6 +626,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
603
626
|
hqRoot: companyRoot,
|
|
604
627
|
lastPrefixSet,
|
|
605
628
|
currentPrefixSet,
|
|
629
|
+
callerSub,
|
|
630
|
+
// Automatic pull: never auto-prune content the caller authored, and never
|
|
631
|
+
// make a destructive guess about unknown-author (legacy) orphans. The
|
|
632
|
+
// explicit `hq sync narrow` ritual opts out of the unknown-author shield.
|
|
633
|
+
protectUnknownAuthors: true,
|
|
606
634
|
});
|
|
607
635
|
if (shrinkPlan.dirty.length > 0 && options.forceScopeShrink !== true) {
|
|
608
636
|
throw new ScopeShrinkBlockedError(
|
|
@@ -988,6 +1016,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
988
1016
|
try {
|
|
989
1017
|
const { metadata } = await downloadFile(ctx, remoteFile.key, localPath);
|
|
990
1018
|
const author = metadata?.["created-by"] ?? null;
|
|
1019
|
+
// Author sub for the scope-shrink authorship guard — same field the
|
|
1020
|
+
// upload side stamps, read straight off the GET response metadata.
|
|
1021
|
+
const createdBySub = metadata?.["created-by-sub"];
|
|
991
1022
|
|
|
992
1023
|
// Symlink records materialize as real symlinks on disk. lstat
|
|
993
1024
|
// (does not follow) lets us detect that case so the journal stamp
|
|
@@ -1026,6 +1057,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
1026
1057
|
"down",
|
|
1027
1058
|
remoteFile.etag,
|
|
1028
1059
|
localLstat.mtimeMs,
|
|
1060
|
+
createdBySub,
|
|
1029
1061
|
);
|
|
1030
1062
|
|
|
1031
1063
|
// Attach message from the prior journal entry if present (set by a
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,13 @@ export {
|
|
|
41
41
|
gcTombstones,
|
|
42
42
|
TOMBSTONE_TTL_MS,
|
|
43
43
|
JOURNAL_VERSION_CURRENT,
|
|
44
|
+
// Canonical personal-vault journal slug + its one-time legacy seed. Exported
|
|
45
|
+
// so downstream CLIs (hq-cli `sync --personal`) journal the personal vault
|
|
46
|
+
// under the SAME slug as hq-sync-runner. hq-cli hardcoding the legacy
|
|
47
|
+
// `"personal"` slug split the vault's bookkeeping across two journal files —
|
|
48
|
+
// each surface re-flagged the other's already-synced files as conflicts.
|
|
49
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
50
|
+
migratePersonalVaultJournal,
|
|
44
51
|
} from "./journal.js";
|
|
45
52
|
|
|
46
53
|
// Prefix coalescing helper (US-005)
|
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
|
}
|
|
@@ -33,6 +33,11 @@ describe("public package surface contract (@indigoai-us/hq-cloud)", () => {
|
|
|
33
33
|
"VendInput",
|
|
34
34
|
"VendResult",
|
|
35
35
|
"VendCredentials",
|
|
36
|
+
// Canonical personal-vault journal slug (+ legacy seed) — consumed by
|
|
37
|
+
// hq-cli `sync --personal` so the CLI and hq-sync-runner journal the
|
|
38
|
+
// vault under ONE slug (split-brain regression, 2026-06-10).
|
|
39
|
+
"PERSONAL_VAULT_JOURNAL_SLUG",
|
|
40
|
+
"migratePersonalVaultJournal",
|
|
36
41
|
] as const;
|
|
37
42
|
|
|
38
43
|
it.each(SYNC_BROWSE_NAMES)(
|