@indigoai-us/hq-cloud 6.3.3 → 6.3.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/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +26 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +66 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +22 -6
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +21 -8
- package/dist/ignore.test.js.map +1 -1
- package/dist/journal.d.ts +19 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +42 -0
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +111 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/personal-vault.d.ts +6 -4
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +6 -4
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +16 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/sync.test.ts +85 -0
- package/src/cli/sync.ts +25 -1
- package/src/ignore.test.ts +22 -8
- package/src/ignore.ts +23 -6
- package/src/journal.test.ts +120 -0
- package/src/journal.ts +41 -0
- package/src/personal-vault.test.ts +27 -0
- package/src/personal-vault.ts +6 -4
package/src/journal.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
writeJournal,
|
|
18
18
|
updateEntry,
|
|
19
19
|
migrateToV2,
|
|
20
|
+
normalizeJournalKeys,
|
|
20
21
|
appendPullRecord,
|
|
21
22
|
lastPullRecord,
|
|
22
23
|
tombstoneEntry,
|
|
@@ -208,6 +209,125 @@ describe("journal", () => {
|
|
|
208
209
|
expect(raw.version).toBe("2");
|
|
209
210
|
expect(raw.pulls).toEqual([]);
|
|
210
211
|
});
|
|
212
|
+
|
|
213
|
+
it("normalizes backslash keys even on an already-v2 journal", () => {
|
|
214
|
+
// The landmine predates the v2 bump, so a poisoned journal can be v2.
|
|
215
|
+
// migrateToV2's early-return must not skip normalization.
|
|
216
|
+
const j: SyncJournal = {
|
|
217
|
+
version: "2",
|
|
218
|
+
lastSync: "",
|
|
219
|
+
files: {
|
|
220
|
+
"projects\\forecast\\x.csv": {
|
|
221
|
+
hash: "h",
|
|
222
|
+
size: 1,
|
|
223
|
+
syncedAt: "2026-06-10",
|
|
224
|
+
direction: "down",
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
pulls: [],
|
|
228
|
+
};
|
|
229
|
+
migrateToV2(j);
|
|
230
|
+
expect(j.files["projects\\forecast\\x.csv"]).toBeUndefined();
|
|
231
|
+
expect(j.files["projects/forecast/x.csv"]).toBeDefined();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("normalizeJournalKeys (Windows backslash-key landmine — ridge data loss)", () => {
|
|
236
|
+
// Regression for feedback_b8d09d0f: pre-5.47.2 Windows clients stamped
|
|
237
|
+
// journal keys with path.sep ("\\"). Those keys never match the
|
|
238
|
+
// forward-slash remote LIST, so the cross-machine delete planner classed
|
|
239
|
+
// live local files as remote-deleted and path.join collapsed the key back
|
|
240
|
+
// onto the real file — deleting ~36 live files in a single pull. The
|
|
241
|
+
// canonical defense is rewriting every key to POSIX form on load.
|
|
242
|
+
it("rewrites backslash keys to POSIX form and reports the count", () => {
|
|
243
|
+
const j: SyncJournal = {
|
|
244
|
+
version: "2",
|
|
245
|
+
lastSync: "",
|
|
246
|
+
files: {
|
|
247
|
+
"projects\\forecast-development\\forecasts\\sean-frank.csv": {
|
|
248
|
+
hash: "h1",
|
|
249
|
+
size: 10,
|
|
250
|
+
syncedAt: "2026-06-10",
|
|
251
|
+
direction: "down",
|
|
252
|
+
},
|
|
253
|
+
"docs\\notes.md": {
|
|
254
|
+
hash: "h2",
|
|
255
|
+
size: 5,
|
|
256
|
+
syncedAt: "2026-06-10",
|
|
257
|
+
direction: "down",
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
pulls: [],
|
|
261
|
+
};
|
|
262
|
+
const rewritten = normalizeJournalKeys(j);
|
|
263
|
+
expect(rewritten).toBe(2);
|
|
264
|
+
expect(
|
|
265
|
+
j.files["projects/forecast-development/forecasts/sean-frank.csv"],
|
|
266
|
+
).toBeDefined();
|
|
267
|
+
expect(j.files["docs/notes.md"]).toBeDefined();
|
|
268
|
+
// No malformed keys survive.
|
|
269
|
+
expect(
|
|
270
|
+
Object.keys(j.files).some((k) => k.includes("\\")),
|
|
271
|
+
).toBe(false);
|
|
272
|
+
// Entry payload is preserved verbatim under the new key.
|
|
273
|
+
expect(j.files["docs/notes.md"]?.hash).toBe("h2");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("leaves a clean (all-POSIX) journal untouched and returns 0", () => {
|
|
277
|
+
const j: SyncJournal = {
|
|
278
|
+
version: "2",
|
|
279
|
+
lastSync: "",
|
|
280
|
+
files: {
|
|
281
|
+
"docs/a.md": { hash: "h", size: 1, syncedAt: "x", direction: "down" },
|
|
282
|
+
},
|
|
283
|
+
pulls: [],
|
|
284
|
+
};
|
|
285
|
+
const before = JSON.stringify(j);
|
|
286
|
+
const rewritten = normalizeJournalKeys(j);
|
|
287
|
+
expect(rewritten).toBe(0);
|
|
288
|
+
expect(JSON.stringify(j)).toBe(before);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("prefers an existing POSIX twin and drops the malformed duplicate", () => {
|
|
292
|
+
// When both forms exist, the POSIX entry is authoritative (it round-
|
|
293
|
+
// tripped through an up-to-date client). The malformed dup is discarded.
|
|
294
|
+
const j: SyncJournal = {
|
|
295
|
+
version: "2",
|
|
296
|
+
lastSync: "",
|
|
297
|
+
files: {
|
|
298
|
+
"docs/a.md": {
|
|
299
|
+
hash: "posix-wins",
|
|
300
|
+
size: 1,
|
|
301
|
+
syncedAt: "x",
|
|
302
|
+
direction: "down",
|
|
303
|
+
},
|
|
304
|
+
"docs\\a.md": {
|
|
305
|
+
hash: "backslash-loses",
|
|
306
|
+
size: 2,
|
|
307
|
+
syncedAt: "x",
|
|
308
|
+
direction: "down",
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
pulls: [],
|
|
312
|
+
};
|
|
313
|
+
normalizeJournalKeys(j);
|
|
314
|
+
expect(j.files["docs\\a.md"]).toBeUndefined();
|
|
315
|
+
expect(j.files["docs/a.md"]?.hash).toBe("posix-wins");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("is idempotent — a second pass rewrites nothing", () => {
|
|
319
|
+
const j: SyncJournal = {
|
|
320
|
+
version: "2",
|
|
321
|
+
lastSync: "",
|
|
322
|
+
files: {
|
|
323
|
+
"a\\b\\c.txt": { hash: "h", size: 1, syncedAt: "x", direction: "down" },
|
|
324
|
+
},
|
|
325
|
+
pulls: [],
|
|
326
|
+
};
|
|
327
|
+
expect(normalizeJournalKeys(j)).toBe(1);
|
|
328
|
+
expect(normalizeJournalKeys(j)).toBe(0);
|
|
329
|
+
expect(j.files["a/b/c.txt"]).toBeDefined();
|
|
330
|
+
});
|
|
211
331
|
});
|
|
212
332
|
|
|
213
333
|
describe("generatePullId", () => {
|
package/src/journal.ts
CHANGED
|
@@ -18,6 +18,7 @@ import * as os from "os";
|
|
|
18
18
|
import * as path from "path";
|
|
19
19
|
import * as crypto from "crypto";
|
|
20
20
|
import type { SyncJournal, JournalEntry, PullRecord } from "./types.js";
|
|
21
|
+
import { toPosixKey } from "./s3.js";
|
|
21
22
|
|
|
22
23
|
/** Tombstone retention. 30 days in milliseconds — roughly two release cycles. */
|
|
23
24
|
export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -134,6 +135,42 @@ export function readJournal(slug: string): SyncJournal {
|
|
|
134
135
|
return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Defuse the pre-5.47.2 Windows backslash-key landmine in a journal's `files`
|
|
140
|
+
* map. Such clients stamped keys with the OS path separator ("\\"), e.g.
|
|
141
|
+
* `projects\\forecast-development\\x.csv`. A backslash key is a live data-loss
|
|
142
|
+
* hazard for the cross-machine delete planner (Bug #9): it never matches the
|
|
143
|
+
* forward-slash remote LIST, so the planner classifies the still-present local
|
|
144
|
+
* file as remote-deleted, and `path.join(companyRoot, key)` collapses the
|
|
145
|
+
* backslashes back onto the REAL POSIX file — which the executor then unlinks
|
|
146
|
+
* (ridge incident, feedback_b8d09d0f: a single pull deleted ~36 live files,
|
|
147
|
+
* with 587 backslash keys left in the journal as a recurring landmine).
|
|
148
|
+
*
|
|
149
|
+
* Rewriting every key to its canonical POSIX form on load removes the hazard
|
|
150
|
+
* idempotently — a clean (all-POSIX) journal is returned untouched. Merge rule:
|
|
151
|
+
* if a key's POSIX twin already exists, the POSIX entry is authoritative (it
|
|
152
|
+
* round-tripped through an up-to-date client) and the malformed duplicate is
|
|
153
|
+
* dropped; otherwise the entry is moved to its POSIX key. Returns the number of
|
|
154
|
+
* keys rewritten (0 for a clean journal) for telemetry and test assertions.
|
|
155
|
+
*/
|
|
156
|
+
export function normalizeJournalKeys(journal: SyncJournal): number {
|
|
157
|
+
if (!journal.files) return 0;
|
|
158
|
+
let rewritten = 0;
|
|
159
|
+
// Snapshot keys up front — we mutate journal.files during the walk.
|
|
160
|
+
for (const key of Object.keys(journal.files)) {
|
|
161
|
+
const posix = toPosixKey(key);
|
|
162
|
+
if (posix === key) continue; // already canonical (no backslash)
|
|
163
|
+
const entry = journal.files[key];
|
|
164
|
+
delete journal.files[key];
|
|
165
|
+
rewritten++;
|
|
166
|
+
// POSIX twin already present → it wins; drop the malformed duplicate.
|
|
167
|
+
if (!(posix in journal.files)) {
|
|
168
|
+
journal.files[posix] = entry;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return rewritten;
|
|
172
|
+
}
|
|
173
|
+
|
|
137
174
|
/**
|
|
138
175
|
* Coerce any-version journal into a v2 shape. Idempotent for v2 inputs.
|
|
139
176
|
* Mutates the input and returns it for chainable use. Call this immediately
|
|
@@ -145,6 +182,10 @@ export function readJournal(slug: string): SyncJournal {
|
|
|
145
182
|
* "all" in the scope-shrink algorithm).
|
|
146
183
|
*/
|
|
147
184
|
export function migrateToV2(journal: SyncJournal): SyncJournal {
|
|
185
|
+
// Backslash-key normalization runs UNCONDITIONALLY — a poisoned journal can
|
|
186
|
+
// already carry version "2" (the landmine predates the schema bump), so this
|
|
187
|
+
// must precede the v2 early-return or already-v2 journals stay poisoned.
|
|
188
|
+
normalizeJournalKeys(journal);
|
|
148
189
|
if (journal.version === "2" && Array.isArray(journal.pulls)) {
|
|
149
190
|
return journal;
|
|
150
191
|
}
|
|
@@ -319,4 +319,31 @@ describe("personal-vault helpers", () => {
|
|
|
319
319
|
expect(out).toContain(path.join("companies", "foo"));
|
|
320
320
|
expect(out).not.toContain(path.join("companies", "team-acme"));
|
|
321
321
|
});
|
|
322
|
+
|
|
323
|
+
it("manifest: stray `manifest.yaml.conflict-*` litter is never enumerated as a company", () => {
|
|
324
|
+
// Regression guard for DEV-1719's secondary symptom: when the personal
|
|
325
|
+
// push accumulated `companies/manifest.yaml.conflict-<ts>.yaml` mirror
|
|
326
|
+
// files, those FILES must never be mis-counted as company subdirs (they
|
|
327
|
+
// are not directories) nor leak into the push set. The real manifest.yaml
|
|
328
|
+
// is still included; the conflict litter is not.
|
|
329
|
+
fs.mkdirSync(path.join(hqRoot, "companies"));
|
|
330
|
+
fs.writeFileSync(
|
|
331
|
+
path.join(hqRoot, "companies", "manifest.yaml"),
|
|
332
|
+
"companies: {}\n",
|
|
333
|
+
);
|
|
334
|
+
fs.writeFileSync(
|
|
335
|
+
path.join(hqRoot, "companies", "manifest.yaml.conflict-2026-06-08T10-00-00Z-ab12cd.yaml"),
|
|
336
|
+
"companies: {}\n",
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// Not treated as a company subdir (only directories are eligible).
|
|
340
|
+
expect(computePersonalCompanySubdirs(hqRoot)).toEqual([]);
|
|
341
|
+
|
|
342
|
+
// Push set carries the real manifest, never the conflict litter.
|
|
343
|
+
const out = rel(computePersonalVaultPaths(hqRoot, { includeLocalCompanies: true }));
|
|
344
|
+
expect(out).toContain(path.join("companies", "manifest.yaml"));
|
|
345
|
+
expect(
|
|
346
|
+
out.some((p) => p.includes("manifest.yaml.conflict-")),
|
|
347
|
+
).toBe(false);
|
|
348
|
+
});
|
|
322
349
|
});
|
package/src/personal-vault.ts
CHANGED
|
@@ -30,10 +30,12 @@
|
|
|
30
30
|
* INCLUDED as of user directive 2026-05-13. `core/` ships the hq-core
|
|
31
31
|
* scaffold — policies/, settings/, skills/, workers/, plus the rules
|
|
32
32
|
* manifest at core/core.yaml. `data/` and `personal/` carry per-user data,
|
|
33
|
-
* policies, hooks, and skills that follow the user across machines.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* policies, hooks, and skills that follow the user across machines. A
|
|
34
|
+
* root-level `core.yaml` (at hq_root, distinct from `core/core.yaml`) is
|
|
35
|
+
* filtered layout-aware in `createIgnoreFilter`: excluded only when
|
|
36
|
+
* `core/core.yaml` exists (v15+, where root core.yaml is a stale duplicate);
|
|
37
|
+
* on v12–v14 roots with no `core/core.yaml`, the root `core.yaml` IS the
|
|
38
|
+
* scaffold/version manifest and round-trips like any other top-level file.
|
|
37
39
|
*/
|
|
38
40
|
|
|
39
41
|
import * as fs from "fs";
|