@indigoai-us/hq-cloud 5.43.0 → 5.45.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.
@@ -19,6 +19,7 @@ import {
19
19
  routeChangeToTarget,
20
20
  buildTargetedPushArgv,
21
21
  resolvePullScope,
22
+ readPinnedPrefixes,
22
23
  } from "./sync-runner.js";
23
24
  import type {
24
25
  RunnerEvent,
@@ -3338,4 +3339,138 @@ describe("resolvePullScope", () => {
3338
3339
  );
3339
3340
  expect(scope).toEqual({ syncMode: "all" });
3340
3341
  });
3342
+
3343
+ // ── pin union (Phase C: hq files get) ──────────────────────────────────────
3344
+
3345
+ it("unions pinned prefixes into a shared-mode scope", async () => {
3346
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-pins-shared-"));
3347
+ try {
3348
+ fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
3349
+ fs.writeFileSync(
3350
+ path.join(root, ".hq", "pins.json"),
3351
+ JSON.stringify({ version: 1, pins: { acme: ["pinned/dir/"] } }),
3352
+ );
3353
+ const scope = await resolvePullScope(
3354
+ stubClient({
3355
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3356
+ getMembershipSyncConfig: async () => ({
3357
+ membershipId: "mk_a",
3358
+ syncMode: "shared",
3359
+ isDefault: false,
3360
+ }),
3361
+ listMyExplicitGrants: async () =>
3362
+ [{ companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" }] as never,
3363
+ }),
3364
+ "cmp_a",
3365
+ "acme",
3366
+ root,
3367
+ );
3368
+ expect(scope.syncMode).toBe("shared");
3369
+ // Grant prefix + pinned prefix, coalesced + sorted.
3370
+ expect(scope.prefixSet).toEqual(["knowledge/", "pinned/dir/"]);
3371
+ } finally {
3372
+ fs.rmSync(root, { recursive: true, force: true });
3373
+ }
3374
+ });
3375
+
3376
+ it("unions pinned prefixes into a custom-mode scope", async () => {
3377
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-pins-custom-"));
3378
+ try {
3379
+ fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
3380
+ fs.writeFileSync(
3381
+ path.join(root, ".hq", "pins.json"),
3382
+ JSON.stringify({ version: 1, pins: { acme: ["extra/"] } }),
3383
+ );
3384
+ const scope = await resolvePullScope(
3385
+ stubClient({
3386
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3387
+ getMembershipSyncConfig: async () => ({
3388
+ membershipId: "mk_a",
3389
+ syncMode: "custom",
3390
+ customPaths: ["projects/x/"],
3391
+ isDefault: false,
3392
+ }),
3393
+ }),
3394
+ "cmp_a",
3395
+ "acme",
3396
+ root,
3397
+ );
3398
+ expect(scope.syncMode).toBe("custom");
3399
+ expect(scope.prefixSet).toEqual(["extra/", "projects/x/"]);
3400
+ } finally {
3401
+ fs.rmSync(root, { recursive: true, force: true });
3402
+ }
3403
+ });
3404
+
3405
+ it("ignores pins for other companies (per-slug isolation)", async () => {
3406
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-pins-iso-"));
3407
+ try {
3408
+ fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
3409
+ fs.writeFileSync(
3410
+ path.join(root, ".hq", "pins.json"),
3411
+ JSON.stringify({ version: 1, pins: { beta: ["beta-only/"] } }),
3412
+ );
3413
+ const scope = await resolvePullScope(
3414
+ stubClient({
3415
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3416
+ getMembershipSyncConfig: async () => ({
3417
+ membershipId: "mk_a",
3418
+ syncMode: "shared",
3419
+ isDefault: false,
3420
+ }),
3421
+ listMyExplicitGrants: async () =>
3422
+ [{ companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" }] as never,
3423
+ }),
3424
+ "cmp_a",
3425
+ "acme",
3426
+ root,
3427
+ );
3428
+ // beta's pin must not leak into acme's scope.
3429
+ expect(scope.prefixSet).toEqual(["knowledge/"]);
3430
+ } finally {
3431
+ fs.rmSync(root, { recursive: true, force: true });
3432
+ }
3433
+ });
3434
+ });
3435
+
3436
+ // ---------------------------------------------------------------------------
3437
+ // readPinnedPrefixes — per-machine pin set reader
3438
+ // ---------------------------------------------------------------------------
3439
+
3440
+ describe("readPinnedPrefixes", () => {
3441
+ let root: string;
3442
+ beforeEach(() => {
3443
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-readpins-"));
3444
+ });
3445
+ afterEach(() => {
3446
+ fs.rmSync(root, { recursive: true, force: true });
3447
+ });
3448
+
3449
+ it("returns [] when the pins file is missing", () => {
3450
+ expect(readPinnedPrefixes(root, "acme")).toEqual([]);
3451
+ });
3452
+
3453
+ it("returns the company's pinned prefixes", () => {
3454
+ fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
3455
+ fs.writeFileSync(
3456
+ path.join(root, ".hq", "pins.json"),
3457
+ JSON.stringify({ version: 1, pins: { acme: ["a/", "b/"], beta: ["c/"] } }),
3458
+ );
3459
+ expect(readPinnedPrefixes(root, "acme")).toEqual(["a/", "b/"]);
3460
+ });
3461
+
3462
+ it("drops empty-string entries (an everything-pin is meaningless here)", () => {
3463
+ fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
3464
+ fs.writeFileSync(
3465
+ path.join(root, ".hq", "pins.json"),
3466
+ JSON.stringify({ version: 1, pins: { acme: ["", "real/"] } }),
3467
+ );
3468
+ expect(readPinnedPrefixes(root, "acme")).toEqual(["real/"]);
3469
+ });
3470
+
3471
+ it("returns [] on a corrupt pins file", () => {
3472
+ fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
3473
+ fs.writeFileSync(path.join(root, ".hq", "pins.json"), "{ not json");
3474
+ expect(readPinnedPrefixes(root, "acme")).toEqual([]);
3475
+ });
3341
3476
  });
@@ -379,6 +379,9 @@ export async function resolvePullScope(
379
379
  // Company slug — required to normalize grant paths (which may be anchored
380
380
  // at `companies/<slug>/` or `<slug>/`) into the company-relative namespace.
381
381
  slug: string,
382
+ // Local HQ root — used to read the per-machine pin set (`.hq/pins.json`).
383
+ // When omitted, pins are simply not unioned (no behavior change).
384
+ hqRoot?: string,
382
385
  ): Promise<PullScope> {
383
386
  if (!client.getMembershipSyncConfig) return { syncMode: "all" };
384
387
  try {
@@ -387,6 +390,14 @@ export async function resolvePullScope(
387
390
  if (!m) return { syncMode: "all" };
388
391
  const cfg = await client.getMembershipSyncConfig(m.membershipKey);
389
392
  if (cfg.syncMode === "all") return { syncMode: "all" };
393
+
394
+ // Pins are company-relative prefixes a user explicitly materialized via
395
+ // `hq files get`. They're unioned into the scope so a scoped pull keeps
396
+ // them instead of pruning them as out-of-scope orphans. Pins only WIDEN
397
+ // scope, never narrow — and `all` mode (handled above) ignores them since
398
+ // it pulls everything anyway.
399
+ const pinPrefixes = hqRoot ? readPinnedPrefixes(hqRoot, slug) : [];
400
+
390
401
  if (cfg.syncMode === "custom") {
391
402
  const customPrefixes = (cfg.customPaths ?? []).map((p) =>
392
403
  grantPathToPrefix(p, slug),
@@ -395,7 +406,10 @@ export async function resolvePullScope(
395
406
  // `coalescePrefixes` (which drops empties) to "nothing", which would
396
407
  // prune the whole tree. An everything-scope is semantically `all`.
397
408
  if (customPrefixes.some((p) => p === "")) return { syncMode: "all" };
398
- return { syncMode: "custom", prefixSet: coalescePrefixes(customPrefixes) };
409
+ return {
410
+ syncMode: "custom",
411
+ prefixSet: coalescePrefixes([...customPrefixes, ...pinPrefixes]),
412
+ };
399
413
  }
400
414
  // shared: scope to the caller's explicit grants. Real grant paths are
401
415
  // inconsistent — full (`companies/<slug>/x/*`), slug-anchored
@@ -416,13 +430,42 @@ export async function resolvePullScope(
416
430
  // `coalescePrefixes` drops empties (collapsing "everything" to "nothing"),
417
431
  // treat any such grant as full-access `all` rather than risk pruning.
418
432
  if (sharedPrefixes.some((p) => p === "")) return { syncMode: "all" };
419
- return { syncMode: "shared", prefixSet: coalescePrefixes(sharedPrefixes) };
433
+ return {
434
+ syncMode: "shared",
435
+ prefixSet: coalescePrefixes([...sharedPrefixes, ...pinPrefixes]),
436
+ };
420
437
  } catch {
421
438
  // Degrade to `all` — never prune on a resolution failure.
422
439
  return { syncMode: "all" };
423
440
  }
424
441
  }
425
442
 
443
+ /**
444
+ * Read the per-machine pin set (`<hqRoot>/.hq/pins.json`) and return the
445
+ * company-relative pinned prefixes for `slug`. These are prefixes the user
446
+ * materialized on demand via `hq files get` that must survive a scoped pull.
447
+ *
448
+ * Tolerant by construction: a missing, unreadable, or malformed file yields
449
+ * `[]` (no pins) — pins only ever widen scope, so "no pins" is the safe
450
+ * default. Empty-string entries are dropped (an everything-pin is meaningless
451
+ * here; `all` mode already covers that case).
452
+ */
453
+ export function readPinnedPrefixes(hqRoot: string, slug: string): string[] {
454
+ try {
455
+ const raw = fs.readFileSync(path.join(hqRoot, ".hq", "pins.json"), "utf-8");
456
+ const parsed = JSON.parse(raw) as { pins?: Record<string, unknown> };
457
+ const list = parsed?.pins?.[slug];
458
+ if (Array.isArray(list)) {
459
+ return list.filter(
460
+ (p): p is string => typeof p === "string" && p.length > 0,
461
+ );
462
+ }
463
+ } catch {
464
+ /* missing / unreadable / malformed → no pins */
465
+ }
466
+ return [];
467
+ }
468
+
426
469
  /**
427
470
  * Backoff schedule (in ms) between attempts 2 and 3 of
428
471
  * `listMembershipsWithRetry`. Short on purpose — memberships is a single
@@ -1259,7 +1302,12 @@ export async function runRunner(
1259
1302
  const pullScope: PullScope =
1260
1303
  target.personalMode === true
1261
1304
  ? { syncMode: "all" }
1262
- : await resolvePullScope(client, target.uid, target.slug);
1305
+ : await resolvePullScope(
1306
+ client,
1307
+ target.uid,
1308
+ target.slug,
1309
+ parsed.hqRoot,
1310
+ );
1263
1311
  pullResult = await syncFn({
1264
1312
  company: target.uid,
1265
1313
  vaultConfig,
@@ -1233,6 +1233,87 @@ describe("sync", () => {
1233
1233
  );
1234
1234
  });
1235
1235
 
1236
+ it("reports new files to POST /v1/notify/file-added with company + file metadata", async () => {
1237
+ vi.mocked(s3Module.headRemoteFile).mockImplementation(async (_ctx, key) => {
1238
+ if (key === "docs/handoff.md") {
1239
+ return {
1240
+ lastModified: new Date(),
1241
+ etag: '"abc123"',
1242
+ size: 42,
1243
+ metadata: { "created-by": "alice@example.com" } as Record<string, string>,
1244
+ };
1245
+ }
1246
+ if (key === "knowledge/readme.md") {
1247
+ return {
1248
+ lastModified: new Date(),
1249
+ etag: '"def456"',
1250
+ size: 100,
1251
+ metadata: {} as Record<string, string>,
1252
+ };
1253
+ }
1254
+ return null;
1255
+ });
1256
+
1257
+ await sync({
1258
+ company: "acme",
1259
+ vaultConfig: mockConfig,
1260
+ hqRoot: tmpDir,
1261
+ onEvent: () => {},
1262
+ });
1263
+
1264
+ const calls = vi.mocked(globalThis.fetch).mock.calls as Array<
1265
+ [string, RequestInit?]
1266
+ >;
1267
+ const post = calls.find(([u]) =>
1268
+ String(u).includes("/v1/notify/file-added"),
1269
+ );
1270
+ expect(post).toBeDefined();
1271
+ expect(String(post![0])).toBe("https://vault-api.test/v1/notify/file-added");
1272
+ const init = post![1]!;
1273
+ expect(init.method).toBe("POST");
1274
+ expect((init.headers as Record<string, string>).Authorization).toMatch(
1275
+ /^Bearer /,
1276
+ );
1277
+ const body = JSON.parse(String(init.body));
1278
+ expect(body.companySlug).toBe("acme");
1279
+ expect(body.companyUid).toBe("cmp_01ABCDEF");
1280
+ expect(body.files).toEqual(
1281
+ expect.arrayContaining([
1282
+ { path: "docs/handoff.md", bytes: 42, addedBy: "alice@example.com" },
1283
+ { path: "knowledge/readme.md", bytes: 100 }, // null addedBy omitted
1284
+ ]),
1285
+ );
1286
+ });
1287
+
1288
+ it("never lets a file-added report failure break the sync (best-effort)", async () => {
1289
+ vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
1290
+ // Make ONLY the notify report throw; delegate everything else to the
1291
+ // default mock so the sync itself still runs.
1292
+ const base = vi.mocked(globalThis.fetch).getMockImplementation()!;
1293
+ vi.mocked(globalThis.fetch).mockImplementation(
1294
+ async (url: unknown, init?: unknown) => {
1295
+ if (String(url).includes("/v1/notify/file-added")) {
1296
+ throw new Error("notify endpoint down");
1297
+ }
1298
+ return base(url as string, init as RequestInit);
1299
+ },
1300
+ );
1301
+
1302
+ const newFilesEvents: Array<{ type: string }> = [];
1303
+ await expect(
1304
+ sync({
1305
+ company: "acme",
1306
+ vaultConfig: mockConfig,
1307
+ hqRoot: tmpDir,
1308
+ onEvent: (e) => {
1309
+ if (e.type === "new-files") newFilesEvents.push(e);
1310
+ },
1311
+ }),
1312
+ ).resolves.toBeDefined();
1313
+ // Sync still completed and emitted its new-files event.
1314
+ expect(newFilesEvents).toHaveLength(1);
1315
+ });
1316
+
1236
1317
  it("sets addedBy to null when HeadObject fails (best-effort)", async () => {
1237
1318
  vi.mocked(s3Module.headRemoteFile).mockRejectedValue(new Error("S3 transient error"));
1238
1319
 
package/src/cli/sync.ts CHANGED
@@ -382,6 +382,73 @@ export function resolveAutoPruneCap(): number {
382
382
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
383
383
  }
384
384
 
385
+ /** Max time to wait on the best-effort new-files notification POST. */
386
+ const NOTIFY_FILE_ADDED_TIMEOUT_MS = 5000;
387
+
388
+ /**
389
+ * Best-effort report of the files that were new to this drive during the sync,
390
+ * so the HQ Sync app can show a persistent cross-session "new files" history.
391
+ *
392
+ * POSTs to `${apiUrl}/v1/notify/file-added`, which writes per-recipient
393
+ * FILE_EVENT rows for the calling user (the one the files are new for). Fully
394
+ * non-fatal: any error, non-2xx, or timeout is swallowed — the durable signal
395
+ * is the synced file itself; this is only a notification mirror. Bounded by a
396
+ * 5s timeout so a hung endpoint can't stall sync completion. No-op when there
397
+ * are no new files.
398
+ */
399
+ async function reportNewFilesToNotify(
400
+ vaultConfig: VaultServiceConfig,
401
+ companyUid: string,
402
+ companySlug: string,
403
+ files: Array<{ path: string; bytes: number; addedBy: string | null }>,
404
+ ): Promise<void> {
405
+ if (files.length === 0) return;
406
+ try {
407
+ const token =
408
+ typeof vaultConfig.authToken === "function"
409
+ ? await vaultConfig.authToken()
410
+ : vaultConfig.authToken;
411
+ const base = vaultConfig.apiUrl.replace(/\/+$/, "");
412
+ const controller = new AbortController();
413
+ const timer = setTimeout(
414
+ () => controller.abort(),
415
+ NOTIFY_FILE_ADDED_TIMEOUT_MS,
416
+ );
417
+ try {
418
+ await fetch(`${base}/v1/notify/file-added`, {
419
+ method: "POST",
420
+ headers: {
421
+ Authorization: `Bearer ${token}`,
422
+ "Content-Type": "application/json",
423
+ },
424
+ body: JSON.stringify({
425
+ companyUid,
426
+ companySlug,
427
+ files: files.map((f) => ({
428
+ path: f.path,
429
+ bytes: f.bytes,
430
+ ...(f.addedBy ? { addedBy: f.addedBy } : {}),
431
+ })),
432
+ }),
433
+ signal: controller.signal,
434
+ });
435
+ } finally {
436
+ clearTimeout(timer);
437
+ }
438
+ } catch (err) {
439
+ // Best-effort: never let notification reporting affect the sync result.
440
+ try {
441
+ console.error(
442
+ `[hq-sync] new-files notify report failed (non-fatal): ${
443
+ err instanceof Error ? err.message : String(err)
444
+ }`,
445
+ );
446
+ } catch {
447
+ // swallow — logging must never break sync
448
+ }
449
+ }
450
+ }
451
+
385
452
  /**
386
453
  * Sync (pull) all allowed files from the entity vault.
387
454
  */
@@ -932,6 +999,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
932
999
  }
933
1000
  emit({ type: "new-files", files: enrichedNewFiles });
934
1001
 
1002
+ // Report new files to the notification service so they persist as a
1003
+ // cross-session "new files" history in the HQ Sync app (POST
1004
+ // /v1/notify/file-added → per-recipient FILE_EVENT rows for THIS user, who is
1005
+ // the one the files are new for). Best-effort and bounded: a failure or a
1006
+ // hung request must never delay or break the sync — the durable signal is the
1007
+ // synced file itself, this is only a notification mirror.
1008
+ await reportNewFilesToNotify(vaultConfig, ctx.uid, ctx.slug, enrichedNewFiles);
1009
+
935
1010
  // Codex P1 (PR #24 follow-up): scope-gate tombstone candidates with
936
1011
  // a per-object HEAD before unlinking. `listRemoteFiles` is STS-scoped
937
1012
  // (guest sessions with `allowedPrefixes`, role downgrade, custom