@indigoai-us/hq-cloud 6.14.49 → 6.15.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/.github/workflows/unreleased-commits-nag.yml +256 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +153 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +11 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
- package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-routes.test.js +51 -0
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +11 -12
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +173 -52
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex-knowledge.d.ts +7 -0
- package/dist/cli/reindex-knowledge.d.ts.map +1 -0
- package/dist/cli/reindex-knowledge.js +389 -0
- package/dist/cli/reindex-knowledge.js.map +1 -0
- package/dist/cli/reindex-knowledge.test.d.ts +12 -0
- package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
- package/dist/cli/reindex-knowledge.test.js +260 -0
- package/dist/cli/reindex-knowledge.test.js.map +1 -0
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +8 -0
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +8 -2
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +85 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +390 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
- package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
- package/dist/cli/rescue-settings-reconcile.test.js +523 -0
- package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +81 -51
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +11 -19
- package/dist/journal.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +8 -25
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -48
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +6 -32
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/feature-flags.d.ts +20 -117
- package/dist/sync/feature-flags.d.ts.map +1 -1
- package/dist/sync/feature-flags.js +24 -147
- package/dist/sync/feature-flags.js.map +1 -1
- package/dist/sync/feature-flags.test.js +15 -61
- package/dist/sync/feature-flags.test.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/lease-client.d.ts +67 -0
- package/dist/sync/lease-client.d.ts.map +1 -0
- package/dist/sync/lease-client.js +158 -0
- package/dist/sync/lease-client.js.map +1 -0
- package/dist/sync/lease-client.test.d.ts +2 -0
- package/dist/sync/lease-client.test.d.ts.map +1 -0
- package/dist/sync/lease-client.test.js +119 -0
- package/dist/sync/lease-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +8 -19
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +6 -50
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +6 -57
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/realtime-rollout.d.ts +85 -0
- package/dist/sync/realtime-rollout.d.ts.map +1 -0
- package/dist/sync/realtime-rollout.js +206 -0
- package/dist/sync/realtime-rollout.js.map +1 -0
- package/dist/sync/realtime-rollout.test.d.ts +2 -0
- package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
- package/dist/sync/realtime-rollout.test.js +71 -0
- package/dist/sync/realtime-rollout.test.js.map +1 -0
- package/dist/sync/state-store.d.ts +40 -0
- package/dist/sync/state-store.d.ts.map +1 -0
- package/dist/sync/state-store.js +680 -0
- package/dist/sync/state-store.js.map +1 -0
- package/dist/sync/state-store.test.d.ts +2 -0
- package/dist/sync/state-store.test.d.ts.map +1 -0
- package/dist/sync/state-store.test.js +159 -0
- package/dist/sync/state-store.test.js.map +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-watch-loop.ts +176 -32
- package/src/bin/sync-runner-watch-routes.test.ts +71 -0
- package/src/bin/sync-runner-watch-routes.ts +27 -0
- package/src/bin/sync-runner.test.ts +188 -53
- package/src/bin/sync-runner.ts +11 -12
- package/src/cli/reindex-knowledge.test.ts +307 -0
- package/src/cli/reindex-knowledge.ts +450 -0
- package/src/cli/reindex.ts +9 -0
- package/src/cli/rescue-classify-ordering.test.ts +9 -3
- package/src/cli/rescue-core.ts +459 -1
- package/src/cli/rescue-settings-reconcile.test.ts +637 -0
- package/src/index.ts +2 -4
- package/src/journal.test.ts +19 -21
- package/src/journal.ts +87 -54
- package/src/sync/event-sync.test.ts +9 -44
- package/src/sync/event-sync.ts +26 -52
- package/src/sync/feature-flags.test.ts +22 -72
- package/src/sync/feature-flags.ts +32 -199
- package/src/sync/index.ts +2 -4
- package/src/sync/lease-client.test.ts +128 -0
- package/src/sync/lease-client.ts +207 -0
- package/src/sync/push-receiver.test.ts +6 -58
- package/src/sync/push-receiver.ts +9 -74
- package/src/sync/realtime-rollout.test.ts +86 -0
- package/src/sync/realtime-rollout.ts +262 -0
- package/src/sync/state-store.test.ts +194 -0
- package/src/sync/state-store.ts +727 -0
- package/src/types.ts +14 -0
- package/test/e2e/sync/transient-company-leg.test.ts +8 -4
package/src/journal.test.ts
CHANGED
|
@@ -119,7 +119,7 @@ describe("journal", () => {
|
|
|
119
119
|
expect(roundTripped.pulls).toEqual([]);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
it("
|
|
122
|
+
it("migrates a torn v2 primary from its last-good copy into the v3 state store", () => {
|
|
123
123
|
const lastGood: SyncJournal = {
|
|
124
124
|
version: "2",
|
|
125
125
|
lastSync: "2026-06-18T00:00:00.000Z",
|
|
@@ -133,11 +133,11 @@ describe("journal", () => {
|
|
|
133
133
|
},
|
|
134
134
|
pulls: [],
|
|
135
135
|
};
|
|
136
|
-
writeJournal("indigo", lastGood);
|
|
137
|
-
|
|
138
|
-
// Simulate a torn direct write of the primary journal file. The next
|
|
139
|
-
// read must use the durable last-good copy rather than aborting sync.
|
|
140
136
|
fs.writeFileSync(getJournalPath("indigo"), '{"version":"2","files":');
|
|
137
|
+
fs.writeFileSync(
|
|
138
|
+
`${getJournalPath("indigo")}.last-good`,
|
|
139
|
+
JSON.stringify(lastGood),
|
|
140
|
+
);
|
|
141
141
|
|
|
142
142
|
const recovered = readJournal("indigo");
|
|
143
143
|
expect(recovered.version).toBe("2");
|
|
@@ -149,23 +149,7 @@ describe("journal", () => {
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
it("R-F08: surfaces primary IO errors instead of masking them with last-good", () => {
|
|
152
|
-
const lastGood: SyncJournal = {
|
|
153
|
-
version: "2",
|
|
154
|
-
lastSync: "2026-06-18T00:00:00.000Z",
|
|
155
|
-
files: {
|
|
156
|
-
"docs/stale.md": {
|
|
157
|
-
hash: "stale-hash",
|
|
158
|
-
size: 128,
|
|
159
|
-
syncedAt: "2026-06-18T00:00:00.000Z",
|
|
160
|
-
direction: "down",
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
|
-
pulls: [],
|
|
164
|
-
};
|
|
165
|
-
writeJournal("indigo", lastGood);
|
|
166
|
-
|
|
167
152
|
const journalPath = getJournalPath("indigo");
|
|
168
|
-
fs.unlinkSync(journalPath);
|
|
169
153
|
fs.mkdirSync(journalPath);
|
|
170
154
|
|
|
171
155
|
expect(() => readJournal("indigo")).toThrow(/EISDIR|directory/i);
|
|
@@ -204,6 +188,20 @@ describe("journal", () => {
|
|
|
204
188
|
expect(brandstage.files).toHaveProperty("b.md");
|
|
205
189
|
expect(brandstage.files).not.toHaveProperty("a.md");
|
|
206
190
|
});
|
|
191
|
+
|
|
192
|
+
it("migrates once, appends authoritative updates, and refreshes the compatibility mirror", () => {
|
|
193
|
+
const initial: SyncJournal = { version: "2", lastSync: "one", files: {}, pulls: [] };
|
|
194
|
+
writeJournal("indigo", initial);
|
|
195
|
+
const locator = fs.readFileSync(getJournalPath("indigo"));
|
|
196
|
+
|
|
197
|
+
writeJournal("indigo", { version: "2", lastSync: "two", files: {}, pulls: [] });
|
|
198
|
+
|
|
199
|
+
expect(fs.readFileSync(getJournalPath("indigo"))).not.toEqual(locator);
|
|
200
|
+
expect(
|
|
201
|
+
JSON.parse(fs.readFileSync(getJournalPath("indigo"), "utf8")) as SyncJournal,
|
|
202
|
+
).toMatchObject({ version: "2", lastSync: "two" });
|
|
203
|
+
expect(readJournal("indigo").lastSync).toBe("two");
|
|
204
|
+
});
|
|
207
205
|
});
|
|
208
206
|
|
|
209
207
|
describe("updateEntry", () => {
|
package/src/journal.ts
CHANGED
|
@@ -17,8 +17,9 @@ import * as fs from "fs";
|
|
|
17
17
|
import * as os from "os";
|
|
18
18
|
import * as path from "path";
|
|
19
19
|
import * as crypto from "crypto";
|
|
20
|
-
import type { SyncJournal, JournalEntry, PullRecord } from "./types.js";
|
|
20
|
+
import type { SyncJournal, JournalEntry, PullRecord, V3JournalState, V3JournalWalPayload } from "./types.js";
|
|
21
21
|
import { toPosixKey } from "./s3.js";
|
|
22
|
+
import { StateStore } from "./sync/state-store.js";
|
|
22
23
|
|
|
23
24
|
/** Tombstone retention. 30 days in milliseconds — roughly two release cycles. */
|
|
24
25
|
export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -36,6 +37,8 @@ export const MAX_PULLS_PER_COMPANY = 50;
|
|
|
36
37
|
const JOURNAL_FILE_PREFIX = "sync-journal.";
|
|
37
38
|
const JOURNAL_FILE_SUFFIX = ".json";
|
|
38
39
|
const JOURNAL_LAST_GOOD_SUFFIX = ".last-good";
|
|
40
|
+
const JOURNAL_WAL_RECORD_TYPE = 1;
|
|
41
|
+
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
44
|
* Where per-company journals are stored. Honors `HQ_STATE_DIR` for tests and
|
|
@@ -135,13 +138,54 @@ export function migratePersonalVaultJournal(): void {
|
|
|
135
138
|
* installs never pass through v1 on disk.
|
|
136
139
|
*/
|
|
137
140
|
export function readJournal(slug: string): SyncJournal {
|
|
141
|
+
if (StateStore.exists(getStateDir(), journalScopeId(slug))) {
|
|
142
|
+
return openJournalStore(slug, emptyJournal()).getState().journal;
|
|
143
|
+
}
|
|
138
144
|
const journalPath = getJournalPath(slug);
|
|
145
|
+
let legacy: SyncJournal;
|
|
139
146
|
if (fs.existsSync(journalPath)) {
|
|
140
|
-
|
|
147
|
+
legacy = readJournalFileWithLastGood(journalPath);
|
|
148
|
+
} else {
|
|
149
|
+
legacy = emptyJournal();
|
|
141
150
|
}
|
|
151
|
+
migrateToV2(legacy);
|
|
152
|
+
return openJournalStore(slug, legacy).getState().journal;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function emptyJournal(): SyncJournal {
|
|
142
156
|
return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
|
|
143
157
|
}
|
|
144
158
|
|
|
159
|
+
function journalScopeId(slug: string): string {
|
|
160
|
+
return `journal:${sanitizeSlug(slug)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function openJournalStore(slug: string, initialJournal: SyncJournal): StateStore<V3JournalState> {
|
|
164
|
+
return StateStore.open({
|
|
165
|
+
rootDir: getStateDir(),
|
|
166
|
+
scopeId: journalScopeId(slug),
|
|
167
|
+
initialState: { journal: initialJournal },
|
|
168
|
+
reduce: (state, record) => {
|
|
169
|
+
if (record.type !== JOURNAL_WAL_RECORD_TYPE) {
|
|
170
|
+
throw new Error(`journal: unexpected v3 WAL record type ${record.type}`);
|
|
171
|
+
}
|
|
172
|
+
const payload = record.payload as Partial<V3JournalWalPayload>;
|
|
173
|
+
if (!payload || typeof payload !== "object" || !isJournal(payload.journal)) {
|
|
174
|
+
throw new Error("journal: v3 WAL record has an invalid journal payload");
|
|
175
|
+
}
|
|
176
|
+
return { ...state, journal: payload.journal };
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isJournal(value: unknown): value is SyncJournal {
|
|
182
|
+
return typeof value === "object" && value !== null &&
|
|
183
|
+
((value as SyncJournal).version === "1" || (value as SyncJournal).version === "2") &&
|
|
184
|
+
typeof (value as SyncJournal).lastSync === "string" &&
|
|
185
|
+
typeof (value as SyncJournal).files === "object" &&
|
|
186
|
+
(value as SyncJournal).files !== null;
|
|
187
|
+
}
|
|
188
|
+
|
|
145
189
|
function parseJournalContent(content: string): SyncJournal {
|
|
146
190
|
if (content.length === 0) {
|
|
147
191
|
throw new SyntaxError("journal JSON is empty");
|
|
@@ -225,7 +269,9 @@ export function listJournals(): JournalSummary[] {
|
|
|
225
269
|
if (!slug) continue; // guard against a stray "sync-journal..json"
|
|
226
270
|
const filePath = path.join(dir, name);
|
|
227
271
|
try {
|
|
228
|
-
|
|
272
|
+
// Prefer the v3 store when this legacy filename is only its immutable
|
|
273
|
+
// compatibility locator; otherwise this performs the v2 last-good read.
|
|
274
|
+
const journal = readJournal(slug);
|
|
229
275
|
out.push({ slug, path: filePath, journal });
|
|
230
276
|
} catch (err) {
|
|
231
277
|
if (!isCorruptJournalJson(err)) throw err;
|
|
@@ -303,69 +349,56 @@ export function migrateToV2(journal: SyncJournal): SyncJournal {
|
|
|
303
349
|
*/
|
|
304
350
|
export function writeJournal(slug: string, journal: SyncJournal): void {
|
|
305
351
|
migrateToV2(journal);
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
352
|
+
const store = openJournalStore(slug, journal);
|
|
353
|
+
// The initial v2 → v3 migration is a durable snapshot. Every normal write
|
|
354
|
+
// thereafter is one framed append + fdatasync, never a whole-journal rewrite.
|
|
355
|
+
if (store.wasCreated) {
|
|
356
|
+
writeLegacyJournalMirror(slug, journal);
|
|
357
|
+
} else {
|
|
358
|
+
store.append(JOURNAL_WAL_RECORD_TYPE, { journal });
|
|
359
|
+
// The JSON file remains a compatibility mirror for shipped CLI/status
|
|
360
|
+
// consumers that have not moved to StateStore yet. It is never used for
|
|
361
|
+
// recovery once a v3 generation exists; the framed WAL above is the sole
|
|
362
|
+
// authority and hot path. Keep the mirror current so those consumers do
|
|
363
|
+
// not observe stale sync state during the phased migration.
|
|
364
|
+
writeLegacyJournalMirror(slug, journal);
|
|
365
|
+
}
|
|
315
366
|
}
|
|
316
367
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Keep the v2-shaped filename as a compatibility mirror for older CLI/status
|
|
370
|
+
* surfaces. The v3 state store is authoritative after migration; this mirror
|
|
371
|
+
* is atomically replaced only to preserve existing readers until their owner
|
|
372
|
+
* migrates them. WAL recovery never consults it once a v3 generation exists.
|
|
373
|
+
*/
|
|
374
|
+
function writeLegacyJournalMirror(slug: string, journal: SyncJournal): void {
|
|
375
|
+
const journalPath = getJournalPath(slug);
|
|
376
|
+
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
|
377
|
+
const temporary = path.join(
|
|
378
|
+
path.dirname(journalPath),
|
|
379
|
+
`.${path.basename(journalPath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`,
|
|
324
380
|
);
|
|
325
381
|
let fd: number | undefined;
|
|
326
382
|
try {
|
|
327
|
-
fd = fs.openSync(
|
|
328
|
-
fs.writeFileSync(fd,
|
|
329
|
-
fs.
|
|
383
|
+
fd = fs.openSync(temporary, "wx");
|
|
384
|
+
fs.writeFileSync(fd, JSON.stringify(journal));
|
|
385
|
+
fs.fdatasyncSync(fd);
|
|
330
386
|
fs.closeSync(fd);
|
|
331
387
|
fd = undefined;
|
|
332
|
-
fs.renameSync(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
} catch {
|
|
339
|
-
/* ignore close failure while surfacing the original write error */
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
try {
|
|
343
|
-
fs.rmSync(tmpPath, { force: true });
|
|
344
|
-
} catch {
|
|
345
|
-
/* best-effort cleanup */
|
|
346
|
-
}
|
|
347
|
-
throw err;
|
|
388
|
+
fs.renameSync(temporary, journalPath);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* preserve mirror write failure */ }
|
|
391
|
+
throw new Error(`journal: failed to update v3 compatibility mirror ${journalPath}`, { cause: error });
|
|
392
|
+
} finally {
|
|
393
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
348
394
|
}
|
|
349
395
|
}
|
|
350
396
|
|
|
351
|
-
function
|
|
352
|
-
|
|
353
|
-
try {
|
|
354
|
-
fd = fs.openSync(dir, "r");
|
|
355
|
-
fs.fsyncSync(fd);
|
|
356
|
-
} catch {
|
|
357
|
-
// Directory fsync is unsupported on some platforms/filesystems.
|
|
358
|
-
} finally {
|
|
359
|
-
if (fd !== undefined) {
|
|
360
|
-
try {
|
|
361
|
-
fs.closeSync(fd);
|
|
362
|
-
} catch {
|
|
363
|
-
/* ignore */
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
397
|
+
function lastGoodJournalPath(journalPath: string): string {
|
|
398
|
+
return `${journalPath}${JOURNAL_LAST_GOOD_SUFFIX}`;
|
|
367
399
|
}
|
|
368
400
|
|
|
401
|
+
|
|
369
402
|
export function hashFile(filePath: string): string {
|
|
370
403
|
const content = fs.readFileSync(filePath);
|
|
371
404
|
return crypto.createHash("sha256").update(content).digest("hex");
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
import { describe, expect, it, vi } from "vitest";
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
-
EVENT_SYNC_ROLLOUT_EMAILS,
|
|
18
17
|
resolveEventSync,
|
|
19
18
|
subscribeSyncReceive,
|
|
20
19
|
sqsClientFromAwsSdk,
|
|
@@ -25,8 +24,6 @@ import {
|
|
|
25
24
|
import type { SqsClientLike, PushReceiverContext } from "./push-receiver.js";
|
|
26
25
|
import type { PushEvent } from "./push-event.js";
|
|
27
26
|
|
|
28
|
-
const ENROLLED = "hassaan@getindigo.ai";
|
|
29
|
-
|
|
30
27
|
function subscribeResponse(
|
|
31
28
|
overrides: Partial<SubscribeSyncResponse["credentials"]> = {},
|
|
32
29
|
queueUrl = "https://sqs.us-east-1.amazonaws.com/000000000000/sync-push-prs_t-dev1",
|
|
@@ -46,48 +43,16 @@ function subscribeResponse(
|
|
|
46
43
|
|
|
47
44
|
// ─── resolveEventSync (US-019) ──────────────────────────────────────────────
|
|
48
45
|
|
|
49
|
-
describe("resolveEventSync —
|
|
50
|
-
it("enrolls exactly the allowlisted address (case-insensitive, trimmed)", () => {
|
|
51
|
-
expect(EVENT_SYNC_ROLLOUT_EMAILS.has(ENROLLED)).toBe(true);
|
|
52
|
-
expect(resolveEventSync(ENROLLED, undefined)).toBe(true);
|
|
53
|
-
expect(resolveEventSync("HASSAAN@GetIndigo.AI", undefined)).toBe(true);
|
|
54
|
-
expect(resolveEventSync(` ${ENROLLED} `, undefined)).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("never matches look-alikes — exact full address, not suffix/superset", () => {
|
|
58
|
-
expect(resolveEventSync("xhassaan@getindigo.ai", undefined)).toBe(false);
|
|
59
|
-
expect(resolveEventSync("hassaan@getindigo.ai.evil.com", undefined)).toBe(
|
|
60
|
-
false,
|
|
61
|
-
);
|
|
62
|
-
expect(resolveEventSync("hassaan@evil-getindigo.ai", undefined)).toBe(
|
|
63
|
-
false,
|
|
64
|
-
);
|
|
65
|
-
expect(resolveEventSync("corey@getindigo.ai", undefined)).toBe(false);
|
|
66
|
-
expect(resolveEventSync(undefined, undefined)).toBe(false);
|
|
67
|
-
expect(resolveEventSync("", undefined)).toBe(false);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it.each(["1", "true", "yes", "on", " ON "])(
|
|
71
|
-
"override %j forces ON for unenrolled accounts",
|
|
72
|
-
(o) => {
|
|
73
|
-
expect(resolveEventSync("nobody@example.com", o)).toBe(true);
|
|
74
|
-
expect(resolveEventSync(undefined, o)).toBe(true);
|
|
75
|
-
},
|
|
76
|
-
);
|
|
77
|
-
|
|
46
|
+
describe("resolveEventSync — local disable only", () => {
|
|
78
47
|
it.each(["0", "false", "no", "off"])(
|
|
79
|
-
"override %j
|
|
80
|
-
(
|
|
81
|
-
expect(resolveEventSync(ENROLLED, o)).toBe(false);
|
|
82
|
-
},
|
|
48
|
+
"override %j keeps the event path dormant",
|
|
49
|
+
(override) => expect(resolveEventSync(override)).toBe(false),
|
|
83
50
|
);
|
|
84
51
|
|
|
85
|
-
it("
|
|
86
|
-
|
|
87
|
-
expect(resolveEventSync(
|
|
88
|
-
|
|
89
|
-
expect(resolveEventSync("nobody@example.com", "maybe")).toBe(false);
|
|
90
|
-
});
|
|
52
|
+
it.each([undefined, "", "1", "true", "yes", "on", "indigo"])(
|
|
53
|
+
"override %j cannot grant authority",
|
|
54
|
+
(override) => expect(resolveEventSync(override)).toBe(true),
|
|
55
|
+
);
|
|
91
56
|
});
|
|
92
57
|
|
|
93
58
|
// ─── subscribeSyncReceive ───────────────────────────────────────────────────
|
|
@@ -567,7 +532,7 @@ describe("startEventSync", () => {
|
|
|
567
532
|
}
|
|
568
533
|
});
|
|
569
534
|
|
|
570
|
-
it("
|
|
535
|
+
it("does not create live handles when authenticated legacy inventory fails", async () => {
|
|
571
536
|
const logged: string[] = [];
|
|
572
537
|
const handles = await startEventSync({
|
|
573
538
|
...happyOpts([]),
|
|
@@ -581,7 +546,7 @@ describe("startEventSync", () => {
|
|
|
581
546
|
expect(logged.join("\n")).toContain("subscribe 503");
|
|
582
547
|
});
|
|
583
548
|
|
|
584
|
-
it("
|
|
549
|
+
it("does not create live handles when identity resolution fails", async () => {
|
|
585
550
|
const logged: string[] = [];
|
|
586
551
|
const handles = await startEventSync({
|
|
587
552
|
...happyOpts([]),
|
package/src/sync/event-sync.ts
CHANGED
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
* Phases 1–2 built every piece but left them unconnected: the watcher runs
|
|
6
6
|
* targeted *push passes* (S3 upload) but never publishes a PushEvent, and the
|
|
7
7
|
* runner's receiver seam defaults to {@link NoopPushReceiver}. This module is
|
|
8
|
-
* the connective tissue that turns both on
|
|
8
|
+
* the connective tissue that turns both on after a server grant:
|
|
9
9
|
*
|
|
10
|
-
* - {@link resolveEventSync} —
|
|
11
|
-
*
|
|
12
|
-
* directions. ONE gate governs publish AND receive so a device is never
|
|
13
|
-
* publish-only or receive-only in a half-rolled state.
|
|
10
|
+
* - {@link resolveEventSync} — a negative-only local kill switch. A server
|
|
11
|
+
* grant remains the only positive authority for publish and receive.
|
|
14
12
|
* - {@link subscribeSyncReceive} — `POST /v1/sync/subscribe` (US-015/US-016):
|
|
15
13
|
* mints the per-device queue and returns `{queueUrl, region, credentials}`,
|
|
16
14
|
* where `credentials` are short-lived STS creds scoped to receive/delete on
|
|
@@ -22,7 +20,7 @@
|
|
|
22
20
|
* retry on an expiry-class error. The queue URL is stable across re-vends
|
|
23
21
|
* (same device → same queue, idempotent endpoint); only creds rotate.
|
|
24
22
|
* - {@link startEventSync} — the wiring entry the runner calls from the
|
|
25
|
-
* `--event-push` watch block
|
|
23
|
+
* `--event-push` watch block unless locally disabled. Resolves tenant + device
|
|
26
24
|
* identity, builds the {@link HttpPushTransport} + {@link PushEventEmitter}
|
|
27
25
|
* (publish leg) and the {@link SqsPushReceiver} (receive leg, self-echo
|
|
28
26
|
* filtered), and returns handles. Any startup failure degrades to
|
|
@@ -57,42 +55,16 @@ import {
|
|
|
57
55
|
createSyncLatencyMetricPublisher,
|
|
58
56
|
type SyncMetricCredentials,
|
|
59
57
|
} from "./metrics.js";
|
|
60
|
-
|
|
61
|
-
// ─── US-019: rollout gate ───────────────────────────────────────────────────
|
|
58
|
+
import { isEventSyncLocallyDisabled } from "./feature-flags.js";
|
|
62
59
|
|
|
63
60
|
/**
|
|
64
|
-
*
|
|
61
|
+
* Decide whether the client may request event-sync authority from the server.
|
|
65
62
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* devices; `xhassaan@getindigo.ai` and `hassaan@getindigo.ai.evil.com` must
|
|
69
|
-
* never match. Broadening later is an entry here, then a domain set, then a
|
|
70
|
-
* GA default-on switch (the same rollout path the presigned-URL sync transport
|
|
71
|
-
* already took to GA).
|
|
63
|
+
* This is negative-only. An unset value or a former force-on value merely
|
|
64
|
+
* permits the authenticated server request; it cannot authorize event sync.
|
|
72
65
|
*/
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
]);
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Decide whether this session runs event-driven sync (publish + receive).
|
|
79
|
-
*
|
|
80
|
-
* Env-override-in-both-directions precedence: `HQ_SYNC_EVENT_SYNC`
|
|
81
|
-
* overrides (`1`/`true`/`yes`/`on` → force on,
|
|
82
|
-
* `0`/`false`/`no`/`off` → force off) so unenrolled testers can exercise it
|
|
83
|
-
* and enrolled accounts can be rolled back without a release. An unset/blank
|
|
84
|
-
* override falls through to the exact-email check; an unrecognized override
|
|
85
|
-
* value is ignored (email check wins).
|
|
86
|
-
*/
|
|
87
|
-
export function resolveEventSync(
|
|
88
|
-
email: string | undefined,
|
|
89
|
-
override: string | undefined,
|
|
90
|
-
): boolean {
|
|
91
|
-
const o = (override ?? "").trim().toLowerCase();
|
|
92
|
-
if (o === "1" || o === "true" || o === "yes" || o === "on") return true;
|
|
93
|
-
if (o === "0" || o === "false" || o === "no" || o === "off") return false;
|
|
94
|
-
if (typeof email !== "string") return false;
|
|
95
|
-
return EVENT_SYNC_ROLLOUT_EMAILS.has(email.trim().toLowerCase());
|
|
66
|
+
export function resolveEventSync(override: string | undefined): boolean {
|
|
67
|
+
return !isEventSyncLocallyDisabled(override);
|
|
96
68
|
}
|
|
97
69
|
|
|
98
70
|
// ─── US-018: subscribe + credentials ────────────────────────────────────────
|
|
@@ -454,6 +426,19 @@ export async function startEventSync(
|
|
|
454
426
|
const tenantId = await opts.resolveTenantId();
|
|
455
427
|
const deviceId = opts.deviceId;
|
|
456
428
|
|
|
429
|
+
// The subscription response is the authenticated legacy inventory/grant.
|
|
430
|
+
// No email, environment, tenant, or queued hint can reach the live path
|
|
431
|
+
// without this server-issued queue and credential material.
|
|
432
|
+
const subscribe =
|
|
433
|
+
opts.subscribe ??
|
|
434
|
+
((dev: string) =>
|
|
435
|
+
subscribeSyncReceive({
|
|
436
|
+
apiUrl: opts.apiUrl,
|
|
437
|
+
authToken: opts.authToken,
|
|
438
|
+
deviceId: dev,
|
|
439
|
+
}));
|
|
440
|
+
const initial = await subscribe(deviceId);
|
|
441
|
+
|
|
457
442
|
// ── publish leg (US-017) ──
|
|
458
443
|
const transport =
|
|
459
444
|
opts.transport ??
|
|
@@ -467,8 +452,9 @@ export async function startEventSync(
|
|
|
467
452
|
originTenantId: tenantId,
|
|
468
453
|
originDeviceId: deviceId,
|
|
469
454
|
transport,
|
|
470
|
-
//
|
|
471
|
-
//
|
|
455
|
+
// This adapter represents the authenticated server grant above. It is
|
|
456
|
+
// created only after the grant succeeds and ignores the tenant argument;
|
|
457
|
+
// it is not a tenant-derived client selector.
|
|
472
458
|
flagProvider: { isEnabled: () => true },
|
|
473
459
|
// Wall-clock sequence numbers: monotonic per device ACROSS daemon
|
|
474
460
|
// restarts (the emitter's default in-process counter restarts at 0,
|
|
@@ -485,15 +471,6 @@ export async function startEventSync(
|
|
|
485
471
|
});
|
|
486
472
|
|
|
487
473
|
// ── receive leg (US-018) ──
|
|
488
|
-
const subscribe =
|
|
489
|
-
opts.subscribe ??
|
|
490
|
-
((dev: string) =>
|
|
491
|
-
subscribeSyncReceive({
|
|
492
|
-
apiUrl: opts.apiUrl,
|
|
493
|
-
authToken: opts.authToken,
|
|
494
|
-
deviceId: dev,
|
|
495
|
-
}));
|
|
496
|
-
const initial = await subscribe(deviceId);
|
|
497
474
|
const publishMetric: PublishMetricFn = initial.cloudWatch
|
|
498
475
|
? createSyncLatencyMetricPublisher(initial.cloudWatch)
|
|
499
476
|
: async () => undefined;
|
|
@@ -515,9 +492,6 @@ export async function startEventSync(
|
|
|
515
492
|
await opts.syncFn(ctx);
|
|
516
493
|
},
|
|
517
494
|
publishMetric,
|
|
518
|
-
// The client rollout gate is the authority; the env-driven per-tenant
|
|
519
|
-
// flag provider is a server-side convention not set on user machines.
|
|
520
|
-
enabled: true,
|
|
521
495
|
});
|
|
522
496
|
await receiver.start();
|
|
523
497
|
|
|
@@ -28,11 +28,8 @@ import path from "node:path";
|
|
|
28
28
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
29
29
|
|
|
30
30
|
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
FEATURE_FLAG_TENANTS_ENV_VAR,
|
|
34
|
-
StaticFlagProvider,
|
|
35
|
-
defaultFlagProvider,
|
|
31
|
+
EVENT_SYNC_LOCAL_DISABLE_ENV_VAR,
|
|
32
|
+
isEventSyncLocallyDisabled,
|
|
36
33
|
} from "./feature-flags.js";
|
|
37
34
|
import {
|
|
38
35
|
HttpPushTransport,
|
|
@@ -79,72 +76,25 @@ function makeFetch(
|
|
|
79
76
|
return { fetch, calls };
|
|
80
77
|
}
|
|
81
78
|
|
|
82
|
-
// ─── 1.
|
|
79
|
+
// ─── 1. Local disable (feature-flags.ts) ─────────────────────────────────────
|
|
83
80
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const p = new StaticFlagProvider(["indigo"]);
|
|
87
|
-
expect(p.isEnabled("indigo")).toBe(true);
|
|
88
|
-
expect(p.isEnabled("acme")).toBe(false);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("env tenant list: empty/unset → all tenants OFF (default)", () => {
|
|
92
|
-
expect(new EnvTenantListFlagProvider({ env: {} }).isEnabled("indigo")).toBe(
|
|
93
|
-
false,
|
|
94
|
-
);
|
|
95
|
-
expect(
|
|
96
|
-
new EnvTenantListFlagProvider({
|
|
97
|
-
env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: "" },
|
|
98
|
-
}).isEnabled("indigo"),
|
|
99
|
-
).toBe(false);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("env tenant list enables exact, case-sensitive matches only", () => {
|
|
103
|
-
const p = new EnvTenantListFlagProvider({
|
|
104
|
-
env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: " indigo , acme " },
|
|
105
|
-
});
|
|
106
|
-
expect(p.isEnabled("indigo")).toBe(true);
|
|
107
|
-
expect(p.isEnabled("acme")).toBe(true);
|
|
108
|
-
expect(p.isEnabled("Indigo")).toBe(false);
|
|
109
|
-
expect(p.isEnabled("other")).toBe(false);
|
|
110
|
-
expect(p.enabledTenants).toEqual(new Set(["indigo", "acme"]));
|
|
111
|
-
});
|
|
81
|
+
const serverGranted = { isEnabled: () => true };
|
|
82
|
+
const serverDenied = { isEnabled: () => false };
|
|
112
83
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
});
|
|
117
|
-
expect(p.isEnabled("anything")).toBe(true);
|
|
118
|
-
expect(p.globalOverride).toBe(true);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("trailing/empty commas dropped silently; internal-whitespace warn-and-skipped (no throw)", () => {
|
|
122
|
-
const warn = vi.fn();
|
|
123
|
-
const p = new EnvTenantListFlagProvider({
|
|
124
|
-
env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: ",,indigo,,bad tenant," },
|
|
125
|
-
warn,
|
|
126
|
-
});
|
|
127
|
-
expect(p.isEnabled("indigo")).toBe(true);
|
|
128
|
-
expect(p.isEnabled("bad tenant")).toBe(false);
|
|
129
|
-
expect(warn).toHaveBeenCalledTimes(1);
|
|
130
|
-
expect(warn.mock.calls[0][1]).toMatchObject({ rawEntry: "bad tenant" });
|
|
84
|
+
describe("Event sync local control", () => {
|
|
85
|
+
it("exports only the local disable control", () => {
|
|
86
|
+
expect(EVENT_SYNC_LOCAL_DISABLE_ENV_VAR).toBe("HQ_SYNC_EVENT_SYNC");
|
|
131
87
|
});
|
|
132
88
|
|
|
133
|
-
it("
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
expect(p.isEnabled("indigo")).toBe(true);
|
|
138
|
-
expect(p.isEnabled("acme")).toBe(false);
|
|
139
|
-
});
|
|
89
|
+
it.each(["0", "false", "no", "off", " OFF "])(
|
|
90
|
+
"recognizes %j as a local disable",
|
|
91
|
+
(value) => expect(isEventSyncLocallyDisabled(value)).toBe(true),
|
|
92
|
+
);
|
|
140
93
|
|
|
141
|
-
it("
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
(p.enabledTenants as Set<string>).add("acme");
|
|
146
|
-
expect(p.isEnabled("acme")).toBe(false);
|
|
147
|
-
});
|
|
94
|
+
it.each([undefined, "", "1", "true", "yes", "on", "indigo,acme"])(
|
|
95
|
+
"%j does not grant authority",
|
|
96
|
+
(value) => expect(isEventSyncLocallyDisabled(value)).toBe(false),
|
|
97
|
+
);
|
|
148
98
|
});
|
|
149
99
|
|
|
150
100
|
// ─── 2. POST (HttpPushTransport) ──────────────────────────────────────────────
|
|
@@ -307,7 +257,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
307
257
|
originTenantId: "indigo",
|
|
308
258
|
originDeviceId: "dev-1",
|
|
309
259
|
transport: spyTransport,
|
|
310
|
-
flagProvider:
|
|
260
|
+
flagProvider: serverGranted,
|
|
311
261
|
now: () => new Date("2026-05-21T00:00:00.000Z"),
|
|
312
262
|
});
|
|
313
263
|
|
|
@@ -331,7 +281,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
331
281
|
originTenantId: "indigo",
|
|
332
282
|
originDeviceId: "dev-1",
|
|
333
283
|
transport: { start: async () => {}, dispose: async () => {}, connected: true, publish: async (e) => { published.push(e); } },
|
|
334
|
-
flagProvider:
|
|
284
|
+
flagProvider: serverGranted,
|
|
335
285
|
});
|
|
336
286
|
writeFileSync(path.join(dir, "b.md"), "second");
|
|
337
287
|
await emitter.emitForBatch(batchFor(REL));
|
|
@@ -345,7 +295,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
345
295
|
originTenantId: "indigo",
|
|
346
296
|
originDeviceId: "dev-1",
|
|
347
297
|
transport: { start: async () => {}, dispose: async () => {}, connected: false, publish },
|
|
348
|
-
flagProvider:
|
|
298
|
+
flagProvider: serverDenied, // indigo NOT enabled
|
|
349
299
|
});
|
|
350
300
|
expect(emitter.enabled).toBe(false);
|
|
351
301
|
await emitter.emitForBatch(batchFor(REL));
|
|
@@ -370,7 +320,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
370
320
|
throw new Error("network down");
|
|
371
321
|
},
|
|
372
322
|
},
|
|
373
|
-
flagProvider:
|
|
323
|
+
flagProvider: serverGranted,
|
|
374
324
|
onError,
|
|
375
325
|
});
|
|
376
326
|
// Must not reject — the daemon stays alive.
|
|
@@ -387,7 +337,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
387
337
|
originTenantId: "indigo",
|
|
388
338
|
originDeviceId: "dev-1",
|
|
389
339
|
transport: { start: async () => {}, dispose: async () => {}, connected: true, publish: async (e) => { published.push(e); } },
|
|
390
|
-
flagProvider:
|
|
340
|
+
flagProvider: serverGranted,
|
|
391
341
|
onError,
|
|
392
342
|
});
|
|
393
343
|
// REL exists; "ghost.md" does not.
|
|
@@ -414,7 +364,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
414
364
|
originTenantId: "indigo",
|
|
415
365
|
originDeviceId: "dev-1",
|
|
416
366
|
transport,
|
|
417
|
-
flagProvider:
|
|
367
|
+
flagProvider: serverGranted,
|
|
418
368
|
});
|
|
419
369
|
await transport.start();
|
|
420
370
|
await emitter.emitForBatch(batchFor(REL));
|