@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.
- package/dist/bin/sync-runner.d.ts +16 -8
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +28 -40
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +24 -24
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +31 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +47 -0
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +102 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +3 -2
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +3 -2
- package/dist/sync/event-sync.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +24 -28
- package/src/bin/sync-runner.ts +28 -39
- package/src/index.ts +7 -0
- package/src/journal.test.ts +120 -0
- package/src/journal.ts +66 -0
- package/src/sync/event-sync.ts +3 -2
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
// the
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
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,
|
package/src/journal.test.ts
CHANGED
|
@@ -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.
|
package/src/sync/event-sync.ts
CHANGED
|
@@ -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
|
|
61
|
-
*
|
|
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",
|