@indigoai-us/hq-cloud 5.25.0 → 5.26.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 +100 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +214 -16
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +372 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/sync/index.d.ts +11 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +9 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/push-event.d.ts +110 -0
- package/dist/sync/push-event.d.ts.map +1 -0
- package/dist/sync/push-event.js +153 -0
- package/dist/sync/push-event.js.map +1 -0
- package/dist/sync/push-event.test.d.ts +15 -0
- package/dist/sync/push-event.test.d.ts.map +1 -0
- package/dist/sync/push-event.test.js +188 -0
- package/dist/sync/push-event.test.js.map +1 -0
- package/dist/sync/push-transport.d.ts +67 -0
- package/dist/sync/push-transport.d.ts.map +1 -0
- package/dist/sync/push-transport.js +66 -0
- package/dist/sync/push-transport.js.map +1 -0
- package/dist/watcher.d.ts +160 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +298 -0
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.d.ts +2 -0
- package/dist/watcher.test.d.ts.map +1 -0
- package/dist/watcher.test.js +334 -0
- package/dist/watcher.test.js.map +1 -0
- package/package.json +3 -2
- package/src/bin/sync-runner.test.ts +487 -1
- package/src/bin/sync-runner.ts +305 -9
- package/src/index.ts +17 -0
- package/src/sync/index.ts +19 -0
- package/src/sync/push-event.test.ts +224 -0
- package/src/sync/push-event.ts +208 -0
- package/src/sync/push-transport.ts +84 -0
- package/src/watcher.test.ts +388 -0
- package/src/watcher.ts +386 -0
package/src/bin/sync-runner.ts
CHANGED
|
@@ -88,6 +88,12 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
|
88
88
|
import type { ConflictStrategy } from "../cli/conflict.js";
|
|
89
89
|
import type { UploadAuthor } from "../s3.js";
|
|
90
90
|
import { collectAndSendTelemetry } from "../telemetry.js";
|
|
91
|
+
import {
|
|
92
|
+
TreeWatcher,
|
|
93
|
+
WatchPushDriver,
|
|
94
|
+
systemClock,
|
|
95
|
+
type Clock,
|
|
96
|
+
} from "../watcher.js";
|
|
91
97
|
|
|
92
98
|
/**
|
|
93
99
|
* Sync direction for a run.
|
|
@@ -442,6 +448,15 @@ interface ParsedArgs {
|
|
|
442
448
|
watch: boolean;
|
|
443
449
|
/** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
|
|
444
450
|
pollRemoteMs?: number;
|
|
451
|
+
/**
|
|
452
|
+
* Event-driven push (Phase 1). When set (and `--watch` is on), the runner
|
|
453
|
+
* starts a {@link TreeWatcher} alongside the poll loop and pushes a targeted
|
|
454
|
+
* company/subtree within the debounce window of a local edit — instead of
|
|
455
|
+
* waiting up to a full `--poll-remote-ms` cycle. Gated OFF by default; the
|
|
456
|
+
* menubar passes it only for `@getindigo.ai` identities (see PRD decision).
|
|
457
|
+
* No-op without `--watch` (the one-shot path has nothing to keep alive).
|
|
458
|
+
*/
|
|
459
|
+
eventPush: boolean;
|
|
445
460
|
/**
|
|
446
461
|
* Drop the personal target from the fanout. Combined with the
|
|
447
462
|
* `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
|
|
@@ -460,6 +475,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
460
475
|
let watch = false;
|
|
461
476
|
let pollRemoteMs: number | undefined;
|
|
462
477
|
let skipPersonal = false;
|
|
478
|
+
let eventPush = false;
|
|
463
479
|
|
|
464
480
|
for (let i = 0; i < argv.length; i++) {
|
|
465
481
|
const arg = argv[i];
|
|
@@ -519,6 +535,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
519
535
|
// resolveSkipPersonal().
|
|
520
536
|
skipPersonal = true;
|
|
521
537
|
break;
|
|
538
|
+
case "--event-push":
|
|
539
|
+
// Phase 1 event-driven push enable flag. Requires --watch (validated
|
|
540
|
+
// below). Gated OFF by default; the menubar only passes it for
|
|
541
|
+
// @getindigo.ai identities for the first release.
|
|
542
|
+
eventPush = true;
|
|
543
|
+
break;
|
|
522
544
|
default:
|
|
523
545
|
return { error: `Unknown argument: ${arg}` };
|
|
524
546
|
}
|
|
@@ -533,8 +555,21 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
533
555
|
if (pollRemoteMs !== undefined && !watch) {
|
|
534
556
|
return { error: "--poll-remote-ms requires --watch" };
|
|
535
557
|
}
|
|
558
|
+
if (eventPush && !watch) {
|
|
559
|
+
return { error: "--event-push requires --watch" };
|
|
560
|
+
}
|
|
536
561
|
|
|
537
|
-
return {
|
|
562
|
+
return {
|
|
563
|
+
companies,
|
|
564
|
+
company,
|
|
565
|
+
onConflict,
|
|
566
|
+
hqRoot,
|
|
567
|
+
direction,
|
|
568
|
+
watch,
|
|
569
|
+
pollRemoteMs,
|
|
570
|
+
skipPersonal,
|
|
571
|
+
eventPush,
|
|
572
|
+
};
|
|
538
573
|
}
|
|
539
574
|
|
|
540
575
|
// ---------------------------------------------------------------------------
|
|
@@ -1169,29 +1204,290 @@ const isDirectInvocation = (() => {
|
|
|
1169
1204
|
* exit 0 today and so will retry — acceptable noise for the beta; deal with
|
|
1170
1205
|
* it via a richer return shape if it shows up in Sentry.
|
|
1171
1206
|
*/
|
|
1172
|
-
|
|
1207
|
+
/**
|
|
1208
|
+
* Test/event-driven seam (US-001).
|
|
1209
|
+
*
|
|
1210
|
+
* `runRunnerWithLoop` performs an unbounded poll loop in production. To make
|
|
1211
|
+
* the loop deterministically testable (US-003 wires the event-driven watcher
|
|
1212
|
+
* into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
|
|
1213
|
+
* The default uses the host timer and preserves exact production behavior.
|
|
1214
|
+
*
|
|
1215
|
+
* A test (or US-003's wiring) injects a fake sleep that resolves immediately
|
|
1216
|
+
* and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
|
|
1217
|
+
* so the loop can be exercised without a real 10-minute wait.
|
|
1218
|
+
*/
|
|
1219
|
+
export interface RunnerLoopDeps {
|
|
1220
|
+
/** Sleep `ms` between passes. Default: host setTimeout. */
|
|
1221
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1222
|
+
/**
|
|
1223
|
+
* Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
|
|
1224
|
+
* (and the event-push wiring) so the poll loop and the watcher-triggered
|
|
1225
|
+
* targeted push share one seam and one in-flight guard. The default ignores
|
|
1226
|
+
* `deps` and forwards just the argv to `runRunner`.
|
|
1227
|
+
*/
|
|
1228
|
+
runPass?: (passArgv: string[]) => Promise<number>;
|
|
1229
|
+
/**
|
|
1230
|
+
* Clock seam for the event-push watcher's debounce window. Defaults to
|
|
1231
|
+
* {@link systemClock}; tests inject a `FakeClock` to advance the window
|
|
1232
|
+
* deterministically. Only consulted when `--event-push` is on.
|
|
1233
|
+
*/
|
|
1234
|
+
clock?: Clock;
|
|
1235
|
+
/**
|
|
1236
|
+
* Factory for the file watcher used in event-push mode. Defaults to a real
|
|
1237
|
+
* {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
|
|
1238
|
+
* `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
|
|
1239
|
+
*/
|
|
1240
|
+
createWatcher?: (opts: {
|
|
1241
|
+
hqRoot: string;
|
|
1242
|
+
debounceMs: number;
|
|
1243
|
+
clock: Clock;
|
|
1244
|
+
}) => WatcherSurface;
|
|
1245
|
+
/**
|
|
1246
|
+
* Register a one-shot shutdown signal handler. Defaults to listening for
|
|
1247
|
+
* SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
|
|
1248
|
+
* clean teardown without sending real signals. The returned fn detaches the
|
|
1249
|
+
* handler (called during teardown so tests don't leak listeners).
|
|
1250
|
+
*/
|
|
1251
|
+
onShutdownSignal?: (handler: () => void) => () => void;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
|
|
1256
|
+
* it; tests inject a lighter stub. Kept narrow so the loop never reaches past
|
|
1257
|
+
* the lifecycle + change-subscription contract.
|
|
1258
|
+
*
|
|
1259
|
+
* `onChange`'s listener receives an OPTIONAL changed relative path. The real
|
|
1260
|
+
* {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
|
|
1261
|
+
* the loop routes the targeted push to the personal vault. A path-aware
|
|
1262
|
+
* watcher (or a test stub) can pass the changed `companies/<slug>/...`
|
|
1263
|
+
* relative path so the loop targets just that company.
|
|
1264
|
+
*/
|
|
1265
|
+
export interface WatcherSurface {
|
|
1266
|
+
onChange(listener: (changedRelPath?: string) => void): () => void;
|
|
1267
|
+
start(): void;
|
|
1268
|
+
stop(): void;
|
|
1269
|
+
dispose(): void;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Route a changed relative path to the push target that owns it.
|
|
1274
|
+
*
|
|
1275
|
+
* - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
|
|
1276
|
+
* pass per PRD decision — push only the changed company, not a full
|
|
1277
|
+
* `--companies` fanout).
|
|
1278
|
+
* - anything else under hqRoot → the personal target (a `--companies` push
|
|
1279
|
+
* restricted to personal via the personal-vault scope; modeled here as the
|
|
1280
|
+
* "personal" route the caller maps to the right argv).
|
|
1281
|
+
*
|
|
1282
|
+
* Returns `null` for paths the routing cannot attribute (defensive — the
|
|
1283
|
+
* watcher's filter already drops excluded top-levels, so this is belt-and-
|
|
1284
|
+
* suspenders for synthetic events).
|
|
1285
|
+
*/
|
|
1286
|
+
export function routeChangeToTarget(
|
|
1287
|
+
relPath: string,
|
|
1288
|
+
): { kind: "company"; slug: string } | { kind: "personal" } | null {
|
|
1289
|
+
const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
1290
|
+
if (norm === "" || norm.startsWith("..")) return null;
|
|
1291
|
+
const segments = norm.split("/").filter((s) => s.length > 0);
|
|
1292
|
+
if (segments.length === 0) return null;
|
|
1293
|
+
if (segments[0] === "companies") {
|
|
1294
|
+
// companies/<slug>/... — need at least the slug segment to target.
|
|
1295
|
+
if (segments.length < 2 || segments[1].length === 0) return null;
|
|
1296
|
+
return { kind: "company", slug: segments[1] };
|
|
1297
|
+
}
|
|
1298
|
+
return { kind: "personal" };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Build the argv for a targeted push pass from a routed change. The push runs
|
|
1303
|
+
* `--direction push` for just the routed target so a local edit propagates in
|
|
1304
|
+
* seconds without a full fanout. Company routes use `--company <slug>`;
|
|
1305
|
+
* personal routes use `--companies --direction push` (the personal-vault scope
|
|
1306
|
+
* is resolved inside runRunner's fanout; skipUnchanged no-ops the company
|
|
1307
|
+
* subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
|
|
1308
|
+
* the base argv. Pure helper, exported for unit testing the routing→argv map.
|
|
1309
|
+
*/
|
|
1310
|
+
export function buildTargetedPushArgv(
|
|
1311
|
+
route: { kind: "company"; slug: string } | { kind: "personal" },
|
|
1312
|
+
baseArgv: string[],
|
|
1313
|
+
): string[] {
|
|
1314
|
+
const carried = carriedFlags(baseArgv);
|
|
1315
|
+
if (route.kind === "company") {
|
|
1316
|
+
return ["--company", route.slug, "--direction", "push", ...carried];
|
|
1317
|
+
}
|
|
1318
|
+
return ["--companies", "--direction", "push", ...carried];
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
export async function runRunnerWithLoop(
|
|
1322
|
+
argv: string[],
|
|
1323
|
+
deps: RunnerLoopDeps = {},
|
|
1324
|
+
): Promise<number> {
|
|
1173
1325
|
if (!argv.includes("--watch")) {
|
|
1174
1326
|
return runRunner(argv);
|
|
1175
1327
|
}
|
|
1328
|
+
const sleep =
|
|
1329
|
+
deps.sleep ??
|
|
1330
|
+
((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
1331
|
+
const runPass = deps.runPass ?? ((passArgv: string[]) => runRunner(passArgv));
|
|
1176
1332
|
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
1177
1333
|
const pollMs =
|
|
1178
1334
|
pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
|
|
1335
|
+
const eventPush = argv.includes("--event-push");
|
|
1336
|
+
const hqIdx = argv.indexOf("--hq-root");
|
|
1337
|
+
const hqRoot =
|
|
1338
|
+
hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
|
|
1179
1339
|
|
|
1180
|
-
// Strip
|
|
1181
|
-
//
|
|
1182
|
-
// re-entering watch mode
|
|
1340
|
+
// Strip the loop-only flags before delegating: the parser inside runRunner
|
|
1341
|
+
// accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
|
|
1342
|
+
// iteration pass to think it's re-entering watch mode.
|
|
1183
1343
|
const passArgv = argv.filter((a, i) => {
|
|
1184
1344
|
if (a === "--watch") return false;
|
|
1185
1345
|
if (a === "--poll-remote-ms") return false;
|
|
1346
|
+
if (a === "--event-push") return false;
|
|
1186
1347
|
if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
|
|
1187
1348
|
return true;
|
|
1188
1349
|
});
|
|
1189
1350
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1351
|
+
// ---- shared in-flight guard ------------------------------------------
|
|
1352
|
+
// The poll loop AND watcher-triggered targeted pushes funnel through this
|
|
1353
|
+
// mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
|
|
1354
|
+
// trigger that arrives while a pass runs is collapsed by WatchPushDriver's
|
|
1355
|
+
// own pending-while-pushing logic, then re-armed after the pass settles.
|
|
1356
|
+
let inFlight = false;
|
|
1357
|
+
let stopped = false;
|
|
1358
|
+
const runGuarded = async (
|
|
1359
|
+
pass: () => Promise<number>,
|
|
1360
|
+
): Promise<number | "skipped"> => {
|
|
1361
|
+
if (inFlight) return "skipped";
|
|
1362
|
+
inFlight = true;
|
|
1363
|
+
try {
|
|
1364
|
+
return await pass();
|
|
1365
|
+
} finally {
|
|
1366
|
+
inFlight = false;
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
// ---- event-push wiring (Phase 1) -------------------------------------
|
|
1371
|
+
let watcher: WatcherSurface | null = null;
|
|
1372
|
+
let driver: WatchPushDriver | null = null;
|
|
1373
|
+
let detachSignal: (() => void) | null = null;
|
|
1374
|
+
let lastChangedRel: string | null = null;
|
|
1375
|
+
|
|
1376
|
+
if (eventPush) {
|
|
1377
|
+
const clock = deps.clock ?? systemClock;
|
|
1378
|
+
const debounceMs = 2000;
|
|
1379
|
+
const createWatcher =
|
|
1380
|
+
deps.createWatcher ??
|
|
1381
|
+
((opts) =>
|
|
1382
|
+
new TreeWatcher({
|
|
1383
|
+
hqRoot: opts.hqRoot,
|
|
1384
|
+
debounceMs: opts.debounceMs,
|
|
1385
|
+
clock: opts.clock,
|
|
1386
|
+
personalMode: true,
|
|
1387
|
+
}));
|
|
1388
|
+
watcher = createWatcher({ hqRoot, debounceMs, clock });
|
|
1389
|
+
|
|
1390
|
+
// The driver runs the targeted push when a debounced burst settles. Its
|
|
1391
|
+
// concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
|
|
1392
|
+
// through `runGuarded` so a poll pass in flight is never overlapped.
|
|
1393
|
+
driver = new WatchPushDriver({
|
|
1394
|
+
debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
|
|
1395
|
+
clock,
|
|
1396
|
+
push: async () => {
|
|
1397
|
+
if (stopped) return;
|
|
1398
|
+
const rel = lastChangedRel;
|
|
1399
|
+
const route = rel
|
|
1400
|
+
? routeChangeToTarget(rel)
|
|
1401
|
+
: { kind: "personal" as const };
|
|
1402
|
+
if (!route) return;
|
|
1403
|
+
const targetedArgv = buildTargetedPushArgv(route, passArgv);
|
|
1404
|
+
await runGuarded(() => runPass(targetedArgv));
|
|
1405
|
+
},
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
// A debounced TreeWatcher 'changed' signal feeds the driver, which fires
|
|
1409
|
+
// the targeted push after its own (zero) window — i.e. immediately, but
|
|
1410
|
+
// still serialized behind any in-flight pass. A path-aware watcher passes
|
|
1411
|
+
// the changed relative path so the push targets just its owning company;
|
|
1412
|
+
// the bare-signal TreeWatcher leaves it null → personal-vault route.
|
|
1413
|
+
watcher.onChange((changedRelPath) => {
|
|
1414
|
+
if (stopped) return;
|
|
1415
|
+
lastChangedRel = changedRelPath ?? null;
|
|
1416
|
+
driver?.notifyChange();
|
|
1417
|
+
});
|
|
1418
|
+
watcher.start();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ---- clean shutdown --------------------------------------------------
|
|
1422
|
+
// SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
|
|
1423
|
+
// the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
|
|
1424
|
+
// watcher + driver, and let the poll loop observe `stopped` on its next tick.
|
|
1425
|
+
let resolveStopped: (() => void) | null = null;
|
|
1426
|
+
const stoppedSignal = new Promise<void>((resolve) => {
|
|
1427
|
+
resolveStopped = resolve;
|
|
1428
|
+
});
|
|
1429
|
+
const shutdown = (): void => {
|
|
1430
|
+
if (stopped) return;
|
|
1431
|
+
stopped = true;
|
|
1432
|
+
try {
|
|
1433
|
+
driver?.dispose();
|
|
1434
|
+
} catch {
|
|
1435
|
+
/* ignore */
|
|
1436
|
+
}
|
|
1437
|
+
try {
|
|
1438
|
+
watcher?.dispose();
|
|
1439
|
+
} catch {
|
|
1440
|
+
/* ignore */
|
|
1441
|
+
}
|
|
1442
|
+
resolveStopped?.();
|
|
1443
|
+
};
|
|
1444
|
+
const onShutdownSignal =
|
|
1445
|
+
deps.onShutdownSignal ??
|
|
1446
|
+
((handler: () => void) => {
|
|
1447
|
+
const wrapped = () => handler();
|
|
1448
|
+
process.on("SIGTERM", wrapped);
|
|
1449
|
+
process.on("SIGINT", wrapped);
|
|
1450
|
+
return () => {
|
|
1451
|
+
process.off("SIGTERM", wrapped);
|
|
1452
|
+
process.off("SIGINT", wrapped);
|
|
1453
|
+
};
|
|
1454
|
+
});
|
|
1455
|
+
detachSignal = onShutdownSignal(shutdown);
|
|
1456
|
+
|
|
1457
|
+
try {
|
|
1458
|
+
while (!stopped) {
|
|
1459
|
+
const result = await runGuarded(() => runPass(passArgv));
|
|
1460
|
+
// A poll pass that was skipped because a watcher push held the guard is
|
|
1461
|
+
// benign — the next iteration retries after the poll interval.
|
|
1462
|
+
if (typeof result === "number" && result !== 0) {
|
|
1463
|
+
return result;
|
|
1464
|
+
}
|
|
1465
|
+
// Sleep the poll interval, but wake early on shutdown so SIGTERM stops
|
|
1466
|
+
// the loop promptly instead of waiting out a 10-minute cycle.
|
|
1467
|
+
await Promise.race([sleep(pollMs), stoppedSignal]);
|
|
1468
|
+
}
|
|
1469
|
+
return 0;
|
|
1470
|
+
} finally {
|
|
1471
|
+
shutdown();
|
|
1472
|
+
detachSignal?.();
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
|
|
1478
|
+
* re-targeted push pass inherits the same root and conflict policy. Pure
|
|
1479
|
+
* helper used by the event-push targeted-push composer.
|
|
1480
|
+
*/
|
|
1481
|
+
function carriedFlags(baseArgv: string[]): string[] {
|
|
1482
|
+
const carried: string[] = [];
|
|
1483
|
+
for (let i = 0; i < baseArgv.length; i++) {
|
|
1484
|
+
const a = baseArgv[i];
|
|
1485
|
+
if (a === "--hq-root" || a === "--on-conflict") {
|
|
1486
|
+
carried.push(a);
|
|
1487
|
+
if (baseArgv[i + 1] !== undefined) carried.push(baseArgv[++i]);
|
|
1488
|
+
}
|
|
1194
1489
|
}
|
|
1490
|
+
return carried;
|
|
1195
1491
|
}
|
|
1196
1492
|
|
|
1197
1493
|
if (isDirectInvocation) {
|
package/src/index.ts
CHANGED
|
@@ -281,3 +281,20 @@ export {
|
|
|
281
281
|
_setSignalsS3Factory,
|
|
282
282
|
_resetSignalsS3Factory,
|
|
283
283
|
} from "./signals/internals.js";
|
|
284
|
+
|
|
285
|
+
// Event-driven sync — PushEvent wire contract + PushTransport shipping seam
|
|
286
|
+
// (ported from hq-pro PR #112 per project event-driven-sync-menubar US-007).
|
|
287
|
+
export {
|
|
288
|
+
CONTENT_HASH_PATTERN,
|
|
289
|
+
ISO8601_DATETIME_PATTERN,
|
|
290
|
+
PushEventSchema,
|
|
291
|
+
PushEventDecodeError,
|
|
292
|
+
encodePushEvent,
|
|
293
|
+
decodePushEvent,
|
|
294
|
+
NoopPushTransport,
|
|
295
|
+
} from "./sync/index.js";
|
|
296
|
+
export type {
|
|
297
|
+
PushEvent,
|
|
298
|
+
PushEventDecodeIssue,
|
|
299
|
+
PushTransport,
|
|
300
|
+
} from "./sync/index.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @indigoai-us/hq-cloud — event-driven sync (`src/sync`) barrel.
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the PushEvent wire contract + the PushTransport shipping seam
|
|
5
|
+
* ported from hq-pro PR #112 (project event-driven-sync-menubar US-007).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
CONTENT_HASH_PATTERN,
|
|
10
|
+
ISO8601_DATETIME_PATTERN,
|
|
11
|
+
PushEventSchema,
|
|
12
|
+
PushEventDecodeError,
|
|
13
|
+
encodePushEvent,
|
|
14
|
+
decodePushEvent,
|
|
15
|
+
} from "./push-event.js";
|
|
16
|
+
export type { PushEvent, PushEventDecodeIssue } from "./push-event.js";
|
|
17
|
+
|
|
18
|
+
export { NoopPushTransport } from "./push-transport.js";
|
|
19
|
+
export type { PushTransport } from "./push-transport.js";
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `src/sync/push-event.ts` (US-007 port).
|
|
3
|
+
*
|
|
4
|
+
* Covers the three required acceptance assertions:
|
|
5
|
+
* 1. A known-good fixture round-trips through encode → decode unchanged.
|
|
6
|
+
* 2. Unknown extra fields on the input are dropped silently (no throw).
|
|
7
|
+
* 3. Missing required fields throw a typed `PushEventDecodeError` whose
|
|
8
|
+
* `.issues` exposes the underlying zod issues.
|
|
9
|
+
*
|
|
10
|
+
* Also covers the supporting invariants needed to keep the wire contract
|
|
11
|
+
* stable across the watcher → server → receiver hop: malformed JSON, bad
|
|
12
|
+
* hash/timestamp formats, and the integer/range bounds on `sequenceNumber`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
PushEventDecodeError,
|
|
19
|
+
decodePushEvent,
|
|
20
|
+
encodePushEvent,
|
|
21
|
+
type PushEvent,
|
|
22
|
+
} from "../../src/sync/index.js";
|
|
23
|
+
|
|
24
|
+
// A canonical, valid PushEvent. All other tests derive from this fixture so
|
|
25
|
+
// any single field mutation can't accidentally pass for the wrong reason.
|
|
26
|
+
const validFixture: PushEvent = {
|
|
27
|
+
relativePath: "docs/architecture/overview.md",
|
|
28
|
+
contentHash:
|
|
29
|
+
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
30
|
+
mtime: "2026-05-18T12:34:56.789Z",
|
|
31
|
+
originDeviceId: "device-laptop-a",
|
|
32
|
+
originTenantId: "tenant-indigo",
|
|
33
|
+
sequenceNumber: 42,
|
|
34
|
+
eventTimestamp: "2026-05-18T12:35:00.000Z",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("PushEvent encode/decode", () => {
|
|
38
|
+
// ── Acceptance #1: round-trip ──────────────────────────────────────────
|
|
39
|
+
it("round-trips a known-good fixture through encode → decode", () => {
|
|
40
|
+
const encoded = encodePushEvent(validFixture);
|
|
41
|
+
expect(typeof encoded).toBe("string");
|
|
42
|
+
|
|
43
|
+
const decoded = decodePushEvent(encoded);
|
|
44
|
+
expect(decoded).toEqual(validFixture);
|
|
45
|
+
|
|
46
|
+
// Re-encoding the decoded value must produce byte-identical output —
|
|
47
|
+
// catches accidental field re-ordering or coercion drift.
|
|
48
|
+
expect(encodePushEvent(decoded)).toBe(encoded);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("decodes from a pre-parsed object as well as from a JSON string", () => {
|
|
52
|
+
const fromObject = decodePushEvent({ ...validFixture });
|
|
53
|
+
const fromString = decodePushEvent(JSON.stringify(validFixture));
|
|
54
|
+
expect(fromObject).toEqual(validFixture);
|
|
55
|
+
expect(fromString).toEqual(validFixture);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Acceptance #2: unknown fields dropped ──────────────────────────────
|
|
59
|
+
it("drops unknown extra fields silently (does not throw)", () => {
|
|
60
|
+
const withExtras = {
|
|
61
|
+
...validFixture,
|
|
62
|
+
// Future producers may add fields like `originAppVersion`; today's
|
|
63
|
+
// consumers must not crash on them.
|
|
64
|
+
originAppVersion: "1.2.3",
|
|
65
|
+
experimentalFlag: true,
|
|
66
|
+
nested: { ignored: "yes" },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const decoded = decodePushEvent(withExtras);
|
|
70
|
+
expect(decoded).toEqual(validFixture);
|
|
71
|
+
expect(decoded).not.toHaveProperty("originAppVersion");
|
|
72
|
+
expect(decoded).not.toHaveProperty("experimentalFlag");
|
|
73
|
+
expect(decoded).not.toHaveProperty("nested");
|
|
74
|
+
|
|
75
|
+
// Round-tripping through JSON behaves the same way.
|
|
76
|
+
const decodedFromJson = decodePushEvent(JSON.stringify(withExtras));
|
|
77
|
+
expect(decodedFromJson).toEqual(validFixture);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── Acceptance #3: missing required fields throw typed error ───────────
|
|
81
|
+
it.each([
|
|
82
|
+
"relativePath",
|
|
83
|
+
"contentHash",
|
|
84
|
+
"mtime",
|
|
85
|
+
"originDeviceId",
|
|
86
|
+
"originTenantId",
|
|
87
|
+
"sequenceNumber",
|
|
88
|
+
"eventTimestamp",
|
|
89
|
+
] as const)("throws PushEventDecodeError when %s is missing", (field) => {
|
|
90
|
+
const partial: Record<string, unknown> = { ...validFixture };
|
|
91
|
+
delete partial[field];
|
|
92
|
+
|
|
93
|
+
let caught: unknown;
|
|
94
|
+
try {
|
|
95
|
+
decodePushEvent(partial);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
caught = err;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
101
|
+
const error = caught as PushEventDecodeError;
|
|
102
|
+
expect(error.stage).toBe("schema-validation");
|
|
103
|
+
expect(error.issues.length).toBeGreaterThan(0);
|
|
104
|
+
// The zod issue path must point at the missing field — that's what
|
|
105
|
+
// downstream callers rely on to render structured diagnostics.
|
|
106
|
+
expect(error.issues.some((issue) => issue.path.includes(field))).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("PushEventDecodeError carries the underlying zod issues array", () => {
|
|
110
|
+
// Multiple missing fields → multiple issues, all reachable via `.issues`.
|
|
111
|
+
const sparse = { relativePath: "x.md" } as unknown;
|
|
112
|
+
let caught: unknown;
|
|
113
|
+
try {
|
|
114
|
+
decodePushEvent(sparse);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
caught = err;
|
|
117
|
+
}
|
|
118
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
119
|
+
const error = caught as PushEventDecodeError;
|
|
120
|
+
expect(error.issues.length).toBeGreaterThanOrEqual(6);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Supporting wire-contract invariants ────────────────────────────────
|
|
124
|
+
it("throws PushEventDecodeError on malformed JSON input", () => {
|
|
125
|
+
let caught: unknown;
|
|
126
|
+
try {
|
|
127
|
+
decodePushEvent("{not valid json");
|
|
128
|
+
} catch (err) {
|
|
129
|
+
caught = err;
|
|
130
|
+
}
|
|
131
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
132
|
+
const error = caught as PushEventDecodeError;
|
|
133
|
+
expect(error.stage).toBe("json-parse");
|
|
134
|
+
expect(error.issues.length).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("surfaces a JSON-parseable non-object payload as schema-validation, not json-parse", () => {
|
|
138
|
+
// `'42'` is syntactically valid JSON, so the parse stage clears; the
|
|
139
|
+
// object-shape check is what fails. Pinning this here keeps the JSDoc
|
|
140
|
+
// contract (see decodePushEvent docs) honest.
|
|
141
|
+
let caught: unknown;
|
|
142
|
+
try {
|
|
143
|
+
decodePushEvent("42");
|
|
144
|
+
} catch (err) {
|
|
145
|
+
caught = err;
|
|
146
|
+
}
|
|
147
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
148
|
+
const error = caught as PushEventDecodeError;
|
|
149
|
+
expect(error.stage).toBe("schema-validation");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it.each([
|
|
153
|
+
["raw hex without `sha256:` prefix", "e".repeat(64)],
|
|
154
|
+
["wrong algorithm prefix", `md5:${"a".repeat(64)}`],
|
|
155
|
+
["uppercase hex", `sha256:${"A".repeat(64)}`],
|
|
156
|
+
["too few hex chars", `sha256:${"a".repeat(63)}`],
|
|
157
|
+
])("rejects contentHash: %s", (_label, badHash) => {
|
|
158
|
+
expect(() =>
|
|
159
|
+
decodePushEvent({ ...validFixture, contentHash: badHash }),
|
|
160
|
+
).toThrow(PushEventDecodeError);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it.each([
|
|
164
|
+
["missing timezone", "2026-05-18T12:34:56.789"],
|
|
165
|
+
["space separator", "2026-05-18 12:34:56Z"],
|
|
166
|
+
["date only", "2026-05-18"],
|
|
167
|
+
])("rejects ISO-8601 timestamps: %s", (_label, badTimestamp) => {
|
|
168
|
+
expect(() =>
|
|
169
|
+
decodePushEvent({ ...validFixture, mtime: badTimestamp }),
|
|
170
|
+
).toThrow(PushEventDecodeError);
|
|
171
|
+
expect(() =>
|
|
172
|
+
decodePushEvent({ ...validFixture, eventTimestamp: badTimestamp }),
|
|
173
|
+
).toThrow(PushEventDecodeError);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("accepts a sequenceNumber of 0 and rejects negative / fractional / oversized values", () => {
|
|
177
|
+
// 0 is allowed — sequence numbers are non-negative, not strictly positive.
|
|
178
|
+
expect(decodePushEvent({ ...validFixture, sequenceNumber: 0 })).toEqual({
|
|
179
|
+
...validFixture,
|
|
180
|
+
sequenceNumber: 0,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(() =>
|
|
184
|
+
decodePushEvent({ ...validFixture, sequenceNumber: -1 }),
|
|
185
|
+
).toThrow(PushEventDecodeError);
|
|
186
|
+
expect(() =>
|
|
187
|
+
decodePushEvent({ ...validFixture, sequenceNumber: 1.5 }),
|
|
188
|
+
).toThrow(PushEventDecodeError);
|
|
189
|
+
expect(() =>
|
|
190
|
+
decodePushEvent({
|
|
191
|
+
...validFixture,
|
|
192
|
+
sequenceNumber: Number.MAX_SAFE_INTEGER + 1,
|
|
193
|
+
}),
|
|
194
|
+
).toThrow(PushEventDecodeError);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("encodePushEvent validates and drops unknown fields from the output", () => {
|
|
198
|
+
// We cast through `unknown` because the public type forbids extra keys —
|
|
199
|
+
// this models a producer that hands us a wider object by mistake.
|
|
200
|
+
const wider = {
|
|
201
|
+
...validFixture,
|
|
202
|
+
stray: "field",
|
|
203
|
+
} as unknown as PushEvent;
|
|
204
|
+
const encoded = encodePushEvent(wider);
|
|
205
|
+
expect(encoded.includes("stray")).toBe(false);
|
|
206
|
+
expect(JSON.parse(encoded)).toEqual(validFixture);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("encodePushEvent throws PushEventDecodeError when input is invalid", () => {
|
|
210
|
+
const bad = { ...validFixture, contentHash: "not-a-hash" } as PushEvent;
|
|
211
|
+
let caught: unknown;
|
|
212
|
+
try {
|
|
213
|
+
encodePushEvent(bad);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
caught = err;
|
|
216
|
+
}
|
|
217
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
218
|
+
const error = caught as PushEventDecodeError;
|
|
219
|
+
expect(error.stage).toBe("schema-validation");
|
|
220
|
+
expect(
|
|
221
|
+
error.issues.some((issue) => issue.path.includes("contentHash")),
|
|
222
|
+
).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|