@indigoai-us/hq-cloud 6.2.3 → 6.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +31 -71
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-journal-reconcile.test.js +56 -27
- package/dist/cli/rescue-journal-reconcile.test.js.map +1 -1
- package/dist/cli/share.d.ts +12 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +20 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +19 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +32 -0
- package/dist/cli/sync.test.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/public-surface.test.js +5 -0
- package/dist/public-surface.test.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/rescue-core.ts +35 -79
- package/src/cli/rescue-journal-reconcile.test.ts +62 -26
- package/src/cli/share.ts +21 -0
- package/src/cli/sync.test.ts +36 -0
- package/src/cli/sync.ts +20 -1
- package/src/index.ts +7 -0
- package/src/public-surface.test.ts +5 -0
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Regression for the rescue
|
|
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.
|
|
2
|
+
* Regression for the rescue <-> sync-journal interaction (6.2.4 semantics).
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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).
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
18
21
|
*/
|
|
19
22
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
20
23
|
import { execFileSync } from "child_process";
|
|
@@ -57,10 +60,12 @@ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
|
57
60
|
|
|
58
61
|
const SCAFFOLD = ["core/a.md", "core/docs/b.md", ".claude/c.sh", "core/core.yaml"];
|
|
59
62
|
|
|
60
|
-
describe.skipIf(!gitAvailable)("rescue
|
|
63
|
+
describe.skipIf(!gitAvailable)("rescue leaves the sync journal untouched + stamps core.yaml deterministically", () => {
|
|
61
64
|
let workDir: string, upstream: string, hqRoot: string, stateDir: string, floorSha: string;
|
|
62
65
|
let env: NodeJS.ProcessEnv;
|
|
63
66
|
let savedStateDir: string | undefined;
|
|
67
|
+
let headCommitterIso: string;
|
|
68
|
+
let seededHashes: Record<string, string>;
|
|
64
69
|
|
|
65
70
|
const git = (cwd: string, ...args: string[]) =>
|
|
66
71
|
execFileSync("git", args, {
|
|
@@ -92,6 +97,7 @@ describe.skipIf(!gitAvailable)("rescue reconciles sync-journal baseline (complet
|
|
|
92
97
|
w(upstream, "core/docs/b.md", "v2\n");
|
|
93
98
|
w(upstream, ".claude/c.sh", "v2\n");
|
|
94
99
|
git(upstream, "add", "-A"); git(upstream, "commit", "-m", "head");
|
|
100
|
+
headCommitterIso = git(upstream, "show", "-s", "--format=%cI", "HEAD");
|
|
95
101
|
|
|
96
102
|
// --- local HQ root: scaffold == floor (overlaid to v2); a pending personal/ edit ---
|
|
97
103
|
hqRoot = path.join(workDir, "hq");
|
|
@@ -102,7 +108,7 @@ describe.skipIf(!gitAvailable)("rescue reconciles sync-journal baseline (complet
|
|
|
102
108
|
w(hqRoot, "core/core.yaml", "version: 1\n");
|
|
103
109
|
w(hqRoot, "personal/edited.md", "USER_LOCAL\n"); // local pending edit
|
|
104
110
|
|
|
105
|
-
// --- state dir + seeded journal:
|
|
111
|
+
// --- state dir + seeded journal: pre-rescue baselines for scaffold + a divergent personal/ entry ---
|
|
106
112
|
stateDir = path.join(workDir, "state");
|
|
107
113
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
108
114
|
savedStateDir = process.env.HQ_STATE_DIR;
|
|
@@ -116,7 +122,12 @@ describe.skipIf(!gitAvailable)("rescue reconciles sync-journal baseline (complet
|
|
|
116
122
|
mtimeMs: fs.statSync(path.join(hqRoot, rel)).mtimeMs,
|
|
117
123
|
});
|
|
118
124
|
const files: Record<string, unknown> = {};
|
|
119
|
-
|
|
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
|
+
}
|
|
120
131
|
// personal/ pending edit: journal records a DIFFERENT (already-synced) hash than local.
|
|
121
132
|
files["personal/edited.md"] = { ...entry("personal/edited.md", "remote-side-hash-differs-from-local") };
|
|
122
133
|
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
|
|
@@ -147,7 +158,7 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
147
158
|
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
148
159
|
});
|
|
149
160
|
|
|
150
|
-
it("
|
|
161
|
+
it("leaves EVERY journal entry untouched so the push leg uploads rescue output (vault converges)", () => {
|
|
151
162
|
const r = runRescueCapture(
|
|
152
163
|
["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
|
|
153
164
|
env,
|
|
@@ -161,19 +172,44 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
161
172
|
|
|
162
173
|
const j = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files;
|
|
163
174
|
|
|
164
|
-
// INVARIANT:
|
|
165
|
-
//
|
|
166
|
-
//
|
|
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.)
|
|
167
180
|
for (const rel of SCAFFOLD) {
|
|
168
|
-
expect(j[rel].hash, `${rel}
|
|
169
|
-
expect(j[rel].remoteEtag, `${rel} remote side touched`).toBe("seed-etag");
|
|
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)));
|
|
170
185
|
}
|
|
171
186
|
|
|
172
|
-
// SAFETY: the personal/ pending edit is
|
|
173
|
-
// still uploads — its baseline stays the divergent seeded hash.
|
|
187
|
+
// SAFETY: the personal/ pending edit is untouched too — still uploads.
|
|
174
188
|
expect(j["personal/edited.md"].hash).toBe("remote-side-hash-differs-from-local");
|
|
175
189
|
expect(j["personal/edited.md"].hash).not.toBe(hashFile(path.join(hqRoot, "personal/edited.md")));
|
|
176
190
|
|
|
177
|
-
|
|
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);
|
|
178
214
|
});
|
|
179
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 {
|
|
@@ -216,6 +218,21 @@ export function isEphemeralPath(p: string): boolean {
|
|
|
216
218
|
return EPHEMERAL_PATH_PATTERN.test(p);
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
/**
|
|
222
|
+
* A vault key containing a backslash is never legitimate. HQ keys are POSIX
|
|
223
|
+
* (`toPosixKey` normalizes at every walker since 5.47.2 and `uploadFile`
|
|
224
|
+
* hard-normalizes at the S3 boundary), so a `\` in a remote key can only come
|
|
225
|
+
* from a pre-5.47.2 Windows client whose walker built keys with `path.sep` —
|
|
226
|
+
* verified live 2026-06-10: one such client duplicated 5,711 keys
|
|
227
|
+
* (`skills\demo-hq\SKILL.md`, …) into a company vault, and every up-to-date
|
|
228
|
+
* puller then materialized them as junk single-filename-with-backslash files
|
|
229
|
+
* that churned conflicts forever. The pull walker refuses these keys
|
|
230
|
+
* (skip-excluded-policy), symmetric with the ephemeral-mirror filter above.
|
|
231
|
+
*/
|
|
232
|
+
export function isMalformedVaultKey(key: string): boolean {
|
|
233
|
+
return key.includes("\\");
|
|
234
|
+
}
|
|
235
|
+
|
|
219
236
|
/**
|
|
220
237
|
* Test-only export. Kept under a `_testing` namespace so the module's public
|
|
221
238
|
* surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
|
|
@@ -755,6 +772,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
755
772
|
? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
|
|
756
773
|
: ignoreFilter;
|
|
757
774
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
775
|
+
// Seed the canonical personal-vault journal from the legacy `personal` file
|
|
776
|
+
// exactly once — engine-side so every consumer (sync-runner, hq-cli) gets
|
|
777
|
+
// it; see the matching guard in sync.ts.
|
|
778
|
+
if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
|
|
758
779
|
const journal = readJournal(journalSlug);
|
|
759
780
|
|
|
760
781
|
let filesUploaded = 0;
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -1100,6 +1100,42 @@ describe("sync", () => {
|
|
|
1100
1100
|
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
1101
1101
|
});
|
|
1102
1102
|
|
|
1103
|
+
it("skips remote keys containing backslashes (malformed Windows-client keys)", async () => {
|
|
1104
|
+
// A pre-5.47.2 Windows client built S3 keys with path.sep, duplicating a
|
|
1105
|
+
// company tree under keys like `skills\\demo-hq\\SKILL.md` (verified live
|
|
1106
|
+
// 2026-06-10: 5,711 such keys in one vault). On POSIX, downloading one
|
|
1107
|
+
// creates a junk FILE whose name contains backslashes, which then churns
|
|
1108
|
+
// conflict mirrors on every sync. The pull planner must refuse them at
|
|
1109
|
+
// planning time — same policy bucket as the ephemeral-mirror filter.
|
|
1110
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1111
|
+
// Malformed Windows key — must be filtered, never downloaded.
|
|
1112
|
+
{
|
|
1113
|
+
key: "skills\\demo\\SKILL.md",
|
|
1114
|
+
size: 44,
|
|
1115
|
+
lastModified: new Date(),
|
|
1116
|
+
etag: '"abc"',
|
|
1117
|
+
},
|
|
1118
|
+
// Its legitimate POSIX twin — must still download.
|
|
1119
|
+
{ key: "skills/demo/SKILL.md", size: 30, lastModified: new Date(), etag: '"def"' },
|
|
1120
|
+
]);
|
|
1121
|
+
|
|
1122
|
+
const result = await sync({
|
|
1123
|
+
company: "acme",
|
|
1124
|
+
vaultConfig: mockConfig,
|
|
1125
|
+
hqRoot: tmpDir,
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1129
|
+
// The malformed key MUST NOT be materialized — neither as a literal
|
|
1130
|
+
// backslash-named file nor as a nested path.
|
|
1131
|
+
expect(fs.existsSync(path.join(companyRoot, "skills\\demo\\SKILL.md"))).toBe(false);
|
|
1132
|
+
// The legitimate twin MUST download.
|
|
1133
|
+
expect(fs.existsSync(path.join(companyRoot, "skills", "demo", "SKILL.md"))).toBe(true);
|
|
1134
|
+
|
|
1135
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1136
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1103
1139
|
it("overwrites local on --on-conflict overwrite", async () => {
|
|
1104
1140
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
1105
1141
|
fs.mkdirSync(companyDocs, { recursive: true });
|
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,
|
|
@@ -40,7 +42,7 @@ import {
|
|
|
40
42
|
} from "../scope-shrink.js";
|
|
41
43
|
import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
|
|
42
44
|
import { createIgnoreFilter } from "../ignore.js";
|
|
43
|
-
import { isEphemeralPath } from "./share.js";
|
|
45
|
+
import { isEphemeralPath, isMalformedVaultKey } from "./share.js";
|
|
44
46
|
import { resolveConflict } from "./conflict.js";
|
|
45
47
|
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
46
48
|
import {
|
|
@@ -526,6 +528,12 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
526
528
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
527
529
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
528
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();
|
|
529
537
|
// Migrate v1 → v2 in place so the scope-shrink / pull-record machinery has
|
|
530
538
|
// its fields, and GC any tombstones past the 30-day retention window before
|
|
531
539
|
// we re-evaluate orphans (so a long-pruned path can re-download cleanly).
|
|
@@ -1469,6 +1477,17 @@ function computePullPlan(
|
|
|
1469
1477
|
continue;
|
|
1470
1478
|
}
|
|
1471
1479
|
|
|
1480
|
+
// Malformed-key filter — keys with backslash separators pushed by
|
|
1481
|
+
// pre-5.47.2 Windows clients. Downloading one materializes a junk local
|
|
1482
|
+
// file whose NAME contains backslashes (it is not a path on POSIX), which
|
|
1483
|
+
// then churns conflict mirrors forever. Refuse at planning time, same
|
|
1484
|
+
// policy bucket as the ephemeral filter above. The bogus keys themselves
|
|
1485
|
+
// are cleaned server-side; this keeps clean trees clean in the meantime.
|
|
1486
|
+
if (isMalformedVaultKey(remoteFile.key)) {
|
|
1487
|
+
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1472
1491
|
if (personalMode && remoteFile.key.startsWith("companies/")) {
|
|
1473
1492
|
// Default: drop every `companies/...` key — the legacy contract
|
|
1474
1493
|
// is that the personal bucket should never contain them.
|
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)
|
|
@@ -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)(
|