@indigoai-us/hq-cloud 5.29.0 → 5.31.0

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.
@@ -24,6 +24,8 @@ import {
24
24
  gcTombstones,
25
25
  generatePullId,
26
26
  TOMBSTONE_TTL_MS,
27
+ PERSONAL_VAULT_JOURNAL_SLUG,
28
+ migratePersonalVaultJournal,
27
29
  } from "./journal.js";
28
30
  import type { SyncJournal, PullRecord } from "./types.js";
29
31
 
@@ -422,4 +424,103 @@ describe("journal", () => {
422
424
  expect(j.files["bogus.md"]).toBeDefined();
423
425
  });
424
426
  });
427
+
428
+ // ── Personal-vault journal-slug collision fix ──────────────────────────────
429
+ //
430
+ // Root cause: the --companies fanout's personal-vault slot journaled under
431
+ // the literal slug "personal", colliding with the companies/personal
432
+ // company (entity slug "personal"). The two targets have different sync
433
+ // roots, so the company's whole-tree delete-plan walked the shared
434
+ // sync-journal.personal.json, resolved the vault's hq-root keys against
435
+ // hqRoot/companies/personal (where they don't exist), tombstoned them as
436
+ // "remote already 404", and dropped them — only for the vault slot to
437
+ // re-upload them next cycle (~190 .claude/skills/* files of churn/sync).
438
+ describe("PERSONAL_VAULT_JOURNAL_SLUG", () => {
439
+ it("is decoupled from the 'personal' company slug", () => {
440
+ expect(PERSONAL_VAULT_JOURNAL_SLUG).not.toBe("personal");
441
+ });
442
+
443
+ it("survives sanitizeSlug unchanged (no path/dot/empty mangling)", () => {
444
+ // getJournalPath runs the slug through sanitizeSlug; the sentinel must
445
+ // pass through verbatim (it is all [a-zA-Z0-9_-] and not all [_-]).
446
+ expect(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG)).toBe(
447
+ path.join(
448
+ stateDir,
449
+ `sync-journal.${PERSONAL_VAULT_JOURNAL_SLUG}.json`,
450
+ ),
451
+ );
452
+ });
453
+
454
+ it("maps to a different journal file than 'personal'", () => {
455
+ expect(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG)).not.toBe(
456
+ getJournalPath("personal"),
457
+ );
458
+ });
459
+ });
460
+
461
+ describe("migratePersonalVaultJournal", () => {
462
+ const legacyJournal = (): SyncJournal => ({
463
+ version: "2",
464
+ lastSync: "2026-05-22T00:00:00.000Z",
465
+ files: {
466
+ ".claude/skills/deep-plan/SKILL.md": {
467
+ hash: "abc123",
468
+ size: 42,
469
+ syncedAt: "2026-05-22T00:00:00.000Z",
470
+ direction: "up",
471
+ },
472
+ },
473
+ pulls: [],
474
+ });
475
+
476
+ it("seeds the reserved slug from the legacy 'personal' journal when absent", () => {
477
+ writeJournal("personal", legacyJournal());
478
+ expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(false);
479
+
480
+ migratePersonalVaultJournal();
481
+
482
+ expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(true);
483
+ const seeded = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
484
+ // The hq-root-relative vault entry is preserved so it is NOT re-uploaded.
485
+ expect(seeded.files[".claude/skills/deep-plan/SKILL.md"]).toEqual(
486
+ legacyJournal().files[".claude/skills/deep-plan/SKILL.md"],
487
+ );
488
+ // Legacy journal is left intact (still used by the companies/personal company).
489
+ expect(fs.existsSync(getJournalPath("personal"))).toBe(true);
490
+ });
491
+
492
+ it("is idempotent — does not clobber an existing reserved-slug journal", () => {
493
+ // Pre-existing reserved-slug journal with distinct content.
494
+ const existing: SyncJournal = {
495
+ version: "2",
496
+ lastSync: "2026-05-22T12:00:00.000Z",
497
+ files: {
498
+ "core/keep-me.md": {
499
+ hash: "keep",
500
+ size: 7,
501
+ syncedAt: "2026-05-22T12:00:00.000Z",
502
+ direction: "up",
503
+ },
504
+ },
505
+ pulls: [],
506
+ };
507
+ writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, existing);
508
+ writeJournal("personal", legacyJournal());
509
+
510
+ migratePersonalVaultJournal();
511
+
512
+ const after = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
513
+ expect(after.files["core/keep-me.md"]).toBeDefined();
514
+ // The legacy entry was NOT copied over the existing reserved journal.
515
+ expect(after.files[".claude/skills/deep-plan/SKILL.md"]).toBeUndefined();
516
+ });
517
+
518
+ it("no-ops when the legacy 'personal' journal is absent", () => {
519
+ expect(fs.existsSync(getJournalPath("personal"))).toBe(false);
520
+
521
+ migratePersonalVaultJournal();
522
+
523
+ expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(false);
524
+ });
525
+ });
425
526
  });
package/src/journal.ts CHANGED
@@ -60,6 +60,58 @@ export function getJournalPath(slug: string): string {
60
60
  );
61
61
  }
62
62
 
63
+ /**
64
+ * Reserved journal slug for the personal-vault fanout slot in the `--companies`
65
+ * runner. The vault slot uploads the whole HQ overlay (`.claude/`, `core/`,
66
+ * `personal/`, …) and journals hq-root-relative keys; its `syncRoot` is the HQ
67
+ * root itself.
68
+ *
69
+ * It MUST NOT share a journal with any real cloud company. Previously the slot
70
+ * used the literal slug `"personal"`, which collided with the
71
+ * `companies/personal` company (whose entity slug is also `"personal"`). The
72
+ * two targets have different sync roots, so the company's whole-tree
73
+ * `computeDeletePlan` walked the shared `sync-journal.personal.json`, resolved
74
+ * the vault's hq-root keys against `hqRoot/companies/personal` (where they
75
+ * don't exist), tombstoned them as "remote already 404", and dropped them from
76
+ * the journal — only for the vault slot to re-upload them next cycle. ~190
77
+ * `.claude/skills/*` files churned every sync.
78
+ *
79
+ * This sentinel value can never be produced by a real company slug from the
80
+ * entity service (which yields URL-safe lowercase slugs without leading
81
+ * underscores), and it survives `sanitizeSlug` unchanged (only `[a-zA-Z0-9_-]`
82
+ * chars; the embedded letters keep it off the all-`[_-]` reject path).
83
+ */
84
+ export const PERSONAL_VAULT_JOURNAL_SLUG = "__hq_personal_vault__";
85
+
86
+ /**
87
+ * One-time seed migration for the personal-vault journal slug.
88
+ *
89
+ * Before this fix the personal-vault slot journaled under the slug
90
+ * `"personal"`. After the fix it journals under
91
+ * `PERSONAL_VAULT_JOURNAL_SLUG`. Without a seed, the first run under the new
92
+ * slug would start from an empty journal and re-upload the entire HQ overlay.
93
+ *
94
+ * To avoid that mass re-upload, this copies the legacy `sync-journal.personal.json`
95
+ * to `sync-journal.__hq_personal_vault__.json` exactly once: only when the new
96
+ * file does NOT exist and the legacy file DOES. Idempotent — a no-op when the
97
+ * new file already exists or the legacy file is absent.
98
+ *
99
+ * The legacy `personal` journal is left untouched (it is still the journal for
100
+ * the real `companies/personal` company). After the seed, both journals
101
+ * converge after one cleanup cycle: the legacy `personal` journal tombstones
102
+ * the now-foreign hq-root keys once; the new vault journal tombstones any
103
+ * companies/personal-relative keys once. That single convergence pass is
104
+ * expected and harmless.
105
+ */
106
+ export function migratePersonalVaultJournal(): void {
107
+ const newPath = getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG);
108
+ if (fs.existsSync(newPath)) return;
109
+ const legacyPath = getJournalPath("personal");
110
+ if (!fs.existsSync(legacyPath)) return;
111
+ const legacy = readJournal("personal");
112
+ writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, legacy);
113
+ }
114
+
63
115
  /**
64
116
  * Read a per-company journal from disk.
65
117
  *
package/src/s3.test.ts CHANGED
@@ -629,4 +629,34 @@ describe("downloadFile", () => {
629
629
  expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
630
630
  expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
631
631
  });
632
+
633
+ it("returns the object's user-metadata (including created-by) for a regular file", async () => {
634
+ nextGetObjectResponse = {
635
+ Body: (async function* () {
636
+ yield new Uint8Array([104, 105]); // "hi"
637
+ })(),
638
+ Metadata: { "created-by": "alice@example.com", "created-by-sub": "sub-123" },
639
+ };
640
+
641
+ const localPath = path.join(tmpRoot, "authored.md");
642
+ const result = await downloadFile(makeCtx(), "authored.md", localPath);
643
+
644
+ expect(result.metadata?.["created-by"]).toBe("alice@example.com");
645
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("hi");
646
+ });
647
+
648
+ it("returns the object's user-metadata for a symlink record", async () => {
649
+ const localPath = path.join(tmpRoot, "authored-link.md");
650
+ nextGetObjectResponse = {
651
+ Body: (async function* () {
652
+ yield new Uint8Array();
653
+ })(),
654
+ Metadata: { "hq-symlink-target": "x", "created-by": "bob@example.com" },
655
+ };
656
+
657
+ const result = await downloadFile(makeCtx(), "authored-link.md", localPath);
658
+
659
+ expect(result.metadata?.["created-by"]).toBe("bob@example.com");
660
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
661
+ });
632
662
  });
package/src/s3.ts CHANGED
@@ -279,11 +279,20 @@ export async function uploadSymlink(
279
279
  return { etag: response.ETag || "" };
280
280
  }
281
281
 
282
+ /**
283
+ * Download an object to localPath and return its S3 user-metadata.
284
+ *
285
+ * Materializes regular files and symlink records (the symlink branch
286
+ * reconstructs the link from the body/marker). The GetObject response
287
+ * already carries `response.Metadata` (S3 lowercases keys), so we
288
+ * return it to callers — e.g. the pull loop reads `created-by` to
289
+ * attribute downloaded files to their author with zero extra network.
290
+ */
282
291
  export async function downloadFile(
283
292
  ctx: EntityContext,
284
293
  key: string,
285
294
  localPath: string,
286
- ): Promise<void> {
295
+ ): Promise<{ metadata?: Record<string, string> }> {
287
296
  const client = buildClient(ctx);
288
297
 
289
298
  const response = await client.send(
@@ -362,7 +371,7 @@ export async function downloadFile(
362
371
  }
363
372
  }
364
373
  fs.symlinkSync(symlinkTarget, localPath);
365
- return;
374
+ return { metadata: response.Metadata };
366
375
  }
367
376
 
368
377
  // Symmetric to the symlink branch above: when a key was previously a
@@ -395,6 +404,7 @@ export async function downloadFile(
395
404
  chunks.push(Buffer.from(chunk));
396
405
  }
397
406
  fs.writeFileSync(localPath, Buffer.concat(chunks));
407
+ return { metadata: response.Metadata };
398
408
  }
399
409
 
400
410
  export interface RemoteFile {