@indigoai-us/hq-cloud 6.3.5 → 6.4.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.
@@ -222,47 +222,35 @@ export function resolveSkipPersonal(flag: boolean): boolean {
222
222
  return env === "1" || env === "true" || env === "yes";
223
223
  }
224
224
 
225
- /**
226
- * Email domains enrolled in the presigned-URL transport rollout.
227
- *
228
- * Staged by the operator (2026-06-10) in descending user count: getindigo.ai
229
- * was the pilot; gmail.com / vyg.ai / amass.com are batch 2. The presign
230
- * transport also routes every upload through the vault API's
231
- * `validateObjectKey` (INVALID_KEY_BACKSLASH et al.), so enrolling a domain
232
- * upgrades its server-side input validation versus the STS-direct path.
233
- * Matching is on the EXACT domain (the part after the last `@`), not a
234
- * suffix — `evil-gmail.com` and `gmail.com.evil.com` never match.
235
- */
236
- const PRESIGN_ROLLOUT_DOMAINS: ReadonlySet<string> = new Set([
237
- "getindigo.ai",
238
- "gmail.com",
239
- "vyg.ai",
240
- "amass.com",
241
- ]);
242
-
243
225
  /**
244
226
  * Decide whether this session uses the presigned-URL transport.
245
227
  *
246
- * Rollout gate: ON for accounts whose verified email domain is enrolled in
247
- * `PRESIGN_ROLLOUT_DOMAINS`. `HQ_SYNC_PRESIGN_TRANSPORT` overrides the email
248
- * check in both directions (`1`/`true`/`yes`/`on` force on,
249
- * `0`/`false`/`no`/`off` force off) so the transport can be exercised by
250
- * unenrolled testers or rolled back for enrolled accounts without a redeploy.
251
- * An unset/blank override falls through to the email check; an unrecognized
252
- * override value is ignored (email check wins) rather than silently forcing
253
- * a state.
228
+ * GA (2026-06-11): the presigned-URL transport is now the DEFAULT for every
229
+ * account the staged per-domain rollout (getindigo.ai pilot → gmail.com /
230
+ * vyg.ai / amass.com batch 2) is complete. The transport only NARROWS client
231
+ * privilege: the client holds no raw AWS credentials (it just fetches signed
232
+ * URLs) and every upload is validated server-side via the vault API, so it is
233
+ * safe as the universal default.
234
+ *
235
+ * `HQ_SYNC_PRESIGN_TRANSPORT` still overrides in both directions
236
+ * (`1`/`true`/`yes`/`on` → force on, `0`/`false`/`no`/`off` → force off), so
237
+ * an individual account can be rolled back to the STS-direct path WITHOUT a
238
+ * redeploy if a regression surfaces. An unset/blank or unrecognized override
239
+ * falls through to the GA default (on).
240
+ *
241
+ * `email` is retained in the signature for the override contract and so the
242
+ * gate can be re-narrowed to a domain set in future without touching the call
243
+ * site; it is intentionally unused while the transport is GA.
254
244
  */
255
245
  export function resolvePresignTransport(
256
246
  email: string | undefined,
257
247
  override: string | undefined,
258
248
  ): boolean {
249
+ void email;
259
250
  const o = (override ?? "").trim().toLowerCase();
260
251
  if (o === "1" || o === "true" || o === "yes" || o === "on") return true;
261
252
  if (o === "0" || o === "false" || o === "no" || o === "off") return false;
262
- if (typeof email !== "string") return false;
263
- const at = email.lastIndexOf("@");
264
- if (at < 0) return false;
265
- return PRESIGN_ROLLOUT_DOMAINS.has(email.slice(at + 1).toLowerCase());
253
+ return true;
266
254
  }
267
255
 
268
256
  // Personal-vault scope (exclusion list + path computer) lives in
@@ -1016,15 +1004,16 @@ export async function runRunner(
1016
1004
  : undefined;
1017
1005
 
1018
1006
  // ---- transport selection (presigned-URL vs STS-direct-S3) -------------
1019
- // Default transport is unchanged: STS-vended credentials + direct S3 SDK.
1020
- // The presigned-URL transport (vault `list` + `presign` endpoints) is
1021
- // feature-flagged to @getindigo.ai accounts during rollout. The gate runs
1022
- // ONCE here every s3.ts call in this session's fanout resolves through
1023
- // the installed factory. `HQ_SYNC_PRESIGN_TRANSPORT` is an explicit
1024
- // override (1/true force on, 0/false → force off) that wins over the
1025
- // email check, so the flag can be flipped for testing or rollback without
1026
- // a redeploy. Setting the factory unconditionally (even to the default)
1027
- // keeps the choice deterministic if a prior run mutated module state.
1007
+ // The presigned-URL transport (vault `list` + `presign` endpoints) is now
1008
+ // the GA default for every account. The STS-direct-S3 path (STS-vended
1009
+ // credentials + direct S3 SDK) remains the fallback only when an account is
1010
+ // force-disabled via HQ_SYNC_PRESIGN_TRANSPORT or the client build predates
1011
+ // the presign methods. The gate runs ONCE here — every s3.ts call in this
1012
+ // session's fanout resolves through the installed factory.
1013
+ // HQ_SYNC_PRESIGN_TRANSPORT is an explicit override (1/true force on,
1014
+ // 0/false force off) for testing or emergency rollback without a redeploy.
1015
+ // Setting the factory unconditionally (even to the default) keeps the choice
1016
+ // deterministic if a prior run mutated module state.
1028
1017
  const presignCapable = client as Partial<PresignTransportClient>;
1029
1018
  if (
1030
1019
  resolvePresignTransport(
package/src/index.ts CHANGED
@@ -31,6 +31,11 @@ export {
31
31
  getEntry,
32
32
  removeEntry,
33
33
  getJournalPath,
34
+ // State-dir + all-shard enumeration. `listJournals` is the single source of
35
+ // truth for "every journal the engine can write" — surfaces like
36
+ // `hq sync status` must read all shards through it, never a single path.
37
+ getStateDir,
38
+ listJournals,
34
39
  // Journal v2 (US-005)
35
40
  migrateToV2,
36
41
  generatePullId,
@@ -50,6 +55,8 @@ export {
50
55
  migratePersonalVaultJournal,
51
56
  } from "./journal.js";
52
57
 
58
+ export type { JournalSummary } from "./journal.js";
59
+
53
60
  // Prefix coalescing helper (US-005)
54
61
  export {
55
62
  coalescePrefixes,
@@ -27,6 +27,7 @@ import {
27
27
  TOMBSTONE_TTL_MS,
28
28
  PERSONAL_VAULT_JOURNAL_SLUG,
29
29
  migratePersonalVaultJournal,
30
+ listJournals,
30
31
  } from "./journal.js";
31
32
  import type { SyncJournal, PullRecord } from "./types.js";
32
33
 
@@ -643,4 +644,123 @@ describe("journal", () => {
643
644
  expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(false);
644
645
  });
645
646
  });
647
+
648
+ // Regression: `hq sync status` reported "No sync journal yet" right after a
649
+ // successful sync because it read one path keyed off the HQ-ROOT, while the
650
+ // engine writes slug-sharded journals (personal vault + per-company).
651
+ // `listJournals` is the all-shard enumeration the status surface now uses.
652
+ describe("listJournals", () => {
653
+ function seed(slug: string, files: SyncJournal["files"]): void {
654
+ writeJournal(slug, {
655
+ version: "2",
656
+ lastSync: "",
657
+ files,
658
+ pulls: [],
659
+ });
660
+ }
661
+
662
+ it("returns [] when the state dir has no journals (truly-empty case)", () => {
663
+ expect(listJournals()).toEqual([]);
664
+ });
665
+
666
+ it("returns [] when the state dir does not exist at all", () => {
667
+ process.env.HQ_STATE_DIR = path.join(stateDir, "does-not-exist");
668
+ expect(listJournals()).toEqual([]);
669
+ });
670
+
671
+ it("enumerates the personal-vault shard written by a `--personal` sync", () => {
672
+ seed(PERSONAL_VAULT_JOURNAL_SLUG, {
673
+ ".claude/skills/x/SKILL.md": {
674
+ hash: "a",
675
+ size: 10,
676
+ syncedAt: "2026-06-08T10:00:00.000Z",
677
+ direction: "up",
678
+ },
679
+ });
680
+
681
+ const all = listJournals();
682
+ expect(all).toHaveLength(1);
683
+ expect(all[0]!.slug).toBe(PERSONAL_VAULT_JOURNAL_SLUG);
684
+ expect(all[0]!.path).toBe(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG));
685
+ expect(Object.keys(all[0]!.journal.files)).toContain(
686
+ ".claude/skills/x/SKILL.md",
687
+ );
688
+ });
689
+
690
+ it("enumerates a per-company shard written by a company sync", () => {
691
+ seed("marshalops", {
692
+ "knowledge/a.md": {
693
+ hash: "b",
694
+ size: 20,
695
+ syncedAt: "2026-06-08T11:00:00.000Z",
696
+ direction: "down",
697
+ },
698
+ });
699
+
700
+ const all = listJournals();
701
+ expect(all.map((j) => j.slug)).toEqual(["marshalops"]);
702
+ });
703
+
704
+ it("enumerates ALL shards together, sorted by slug", () => {
705
+ seed(PERSONAL_VAULT_JOURNAL_SLUG, {
706
+ ".claude/x": {
707
+ hash: "a",
708
+ size: 1,
709
+ syncedAt: "2026-06-08T10:00:00.000Z",
710
+ direction: "up",
711
+ },
712
+ });
713
+ seed("marshalops", {
714
+ "k/a": {
715
+ hash: "b",
716
+ size: 2,
717
+ syncedAt: "2026-06-08T11:00:00.000Z",
718
+ direction: "down",
719
+ },
720
+ });
721
+ seed("glazeymedia", {
722
+ "k/b": {
723
+ hash: "c",
724
+ size: 3,
725
+ syncedAt: "2026-06-08T12:00:00.000Z",
726
+ direction: "down",
727
+ },
728
+ });
729
+
730
+ const slugs = listJournals().map((j) => j.slug);
731
+ // Sorted: "__hq_personal_vault__" < "glazeymedia" < "marshalops".
732
+ expect(slugs).toEqual([
733
+ PERSONAL_VAULT_JOURNAL_SLUG,
734
+ "glazeymedia",
735
+ "marshalops",
736
+ ]);
737
+ });
738
+
739
+ it("skips a corrupt shard but still returns the healthy ones", () => {
740
+ seed("marshalops", {
741
+ "k/a": {
742
+ hash: "b",
743
+ size: 2,
744
+ syncedAt: "2026-06-08T11:00:00.000Z",
745
+ direction: "down",
746
+ },
747
+ });
748
+ fs.writeFileSync(
749
+ path.join(stateDir, "sync-journal.broken.json"),
750
+ "{ not valid json",
751
+ );
752
+
753
+ const all = listJournals();
754
+ expect(all.map((j) => j.slug)).toEqual(["marshalops"]);
755
+ });
756
+
757
+ it("ignores unrelated files in the state dir", () => {
758
+ seed("marshalops", {});
759
+ fs.writeFileSync(path.join(stateDir, "config.json"), "{}");
760
+ fs.writeFileSync(path.join(stateDir, "sync-journal.json"), "{}"); // no slug
761
+ fs.writeFileSync(path.join(stateDir, "notes.txt"), "hi");
762
+
763
+ expect(listJournals().map((j) => j.slug)).toEqual(["marshalops"]);
764
+ });
765
+ });
646
766
  });
package/src/journal.ts CHANGED
@@ -135,6 +135,72 @@ export function readJournal(slug: string): SyncJournal {
135
135
  return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
136
136
  }
137
137
 
138
+ /** One enumerated journal shard: its recovered slug, on-disk path, contents. */
139
+ export interface JournalSummary {
140
+ /**
141
+ * Slug recovered from the `sync-journal.<slug>.json` filename — the
142
+ * sanitized form the engine wrote (e.g. a company slug,
143
+ * `PERSONAL_VAULT_JOURNAL_SLUG`, or the legacy `"personal"`).
144
+ */
145
+ slug: string;
146
+ /** Absolute path to the journal file. */
147
+ path: string;
148
+ /** Parsed journal contents. */
149
+ journal: SyncJournal;
150
+ }
151
+
152
+ /**
153
+ * Enumerate every sync journal present in the state dir.
154
+ *
155
+ * The engine SHARDS journals by slug (ADR-0001 Phase 5): the personal-vault
156
+ * fanout slot under `PERSONAL_VAULT_JOURNAL_SLUG`, one shard per cloud company,
157
+ * and the legacy `"personal"` shard. A caller that reads a single fixed path
158
+ * therefore only ever sees one scope — and a caller that mistakes a non-slug
159
+ * value for a slug (e.g. `hq sync status` passing the HQ-root PATH, which
160
+ * `sanitizeSlug` mangles into `_Users_<user>_hq`) sees a slug the engine never
161
+ * writes, and reports "no journal" right after a successful sync. Any surface
162
+ * that wants the COMPLETE local sync picture must read ALL shards via this
163
+ * helper rather than reconstructing a path.
164
+ *
165
+ * Slugs are recovered from each filename. A shard that fails to read or parse
166
+ * is skipped rather than thrown — one corrupt shard must not blind the caller
167
+ * to the healthy ones. Results are sorted by slug for deterministic output.
168
+ */
169
+ export function listJournals(): JournalSummary[] {
170
+ const dir = getStateDir();
171
+ let names: string[];
172
+ try {
173
+ names = fs.readdirSync(dir);
174
+ } catch {
175
+ return []; // state dir absent → no journals yet
176
+ }
177
+ const out: JournalSummary[] = [];
178
+ for (const name of names) {
179
+ if (
180
+ !name.startsWith(JOURNAL_FILE_PREFIX) ||
181
+ !name.endsWith(JOURNAL_FILE_SUFFIX)
182
+ ) {
183
+ continue;
184
+ }
185
+ const slug = name.slice(
186
+ JOURNAL_FILE_PREFIX.length,
187
+ name.length - JOURNAL_FILE_SUFFIX.length,
188
+ );
189
+ if (!slug) continue; // guard against a stray "sync-journal..json"
190
+ const filePath = path.join(dir, name);
191
+ try {
192
+ const journal = JSON.parse(
193
+ fs.readFileSync(filePath, "utf-8"),
194
+ ) as SyncJournal;
195
+ out.push({ slug, path: filePath, journal });
196
+ } catch {
197
+ // Corrupt/unreadable shard — skip; don't blind the caller to the rest.
198
+ }
199
+ }
200
+ out.sort((a, b) => a.slug.localeCompare(b.slug));
201
+ return out;
202
+ }
203
+
138
204
  /**
139
205
  * Defuse the pre-5.47.2 Windows backslash-key landmine in a journal's `files`
140
206
  * map. Such clients stamped keys with the OS path separator ("\\"), e.g.
@@ -57,8 +57,9 @@ import type { TreeChangeBatch } from "../watcher.js";
57
57
  * EXACT full-address matching, case-insensitive — NOT a domain suffix. The
58
58
  * single-account Phase 3 rollout (2026-06-10) targets the operator's own
59
59
  * devices; `xhassaan@getindigo.ai` and `hassaan@getindigo.ai.evil.com` must
60
- * never match. Broadening later is an entry here (or a domain-set like
61
- * `PRESIGN_ROLLOUT_DOMAINS` once GA'd).
60
+ * never match. Broadening later is an entry here, then a domain set, then a
61
+ * GA default-on switch (the path the presigned-URL transport already took in
62
+ * `resolvePresignTransport`).
62
63
  */
63
64
  export const EVENT_SYNC_ROLLOUT_EMAILS: ReadonlySet<string> = new Set([
64
65
  "hassaan@getindigo.ai",