@indigoai-us/hq-cloud 6.11.12 → 6.11.13

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.
Files changed (107) hide show
  1. package/dist/bin/sync-runner-company.d.ts +35 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-company.js +290 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -0
  5. package/dist/bin/sync-runner-events.d.ts +12 -0
  6. package/dist/bin/sync-runner-events.d.ts.map +1 -0
  7. package/dist/bin/sync-runner-events.js +12 -0
  8. package/dist/bin/sync-runner-events.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +53 -0
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -0
  11. package/dist/bin/sync-runner-planning.js +59 -0
  12. package/dist/bin/sync-runner-planning.js.map +1 -0
  13. package/dist/bin/sync-runner-rollup.d.ts +24 -0
  14. package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-rollup.js +46 -0
  16. package/dist/bin/sync-runner-rollup.js.map +1 -0
  17. package/dist/bin/sync-runner-telemetry.d.ts +5 -0
  18. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
  19. package/dist/bin/sync-runner-telemetry.js +5 -0
  20. package/dist/bin/sync-runner-telemetry.js.map +1 -0
  21. package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
  23. package/dist/bin/sync-runner-watch-loop.js +372 -0
  24. package/dist/bin/sync-runner-watch-loop.js.map +1 -0
  25. package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
  26. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
  27. package/dist/bin/sync-runner-watch-routes.js +74 -0
  28. package/dist/bin/sync-runner-watch-routes.js.map +1 -0
  29. package/dist/bin/sync-runner.d.ts +3 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +73 -1154
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/cli/reindex.d.ts.map +1 -1
  34. package/dist/cli/reindex.js +34 -17
  35. package/dist/cli/reindex.js.map +1 -1
  36. package/dist/cli/reindex.test.js +39 -5
  37. package/dist/cli/reindex.test.js.map +1 -1
  38. package/dist/cli/rescue-classify-ordering.test.js +17 -0
  39. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  40. package/dist/cli/rescue-core.d.ts +45 -0
  41. package/dist/cli/rescue-core.d.ts.map +1 -1
  42. package/dist/cli/rescue-core.js +197 -170
  43. package/dist/cli/rescue-core.js.map +1 -1
  44. package/dist/cli/share.d.ts.map +1 -1
  45. package/dist/cli/share.js +224 -676
  46. package/dist/cli/share.js.map +1 -1
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +399 -726
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +20 -0
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/daemon-worker.d.ts +2 -2
  53. package/dist/daemon-worker.js +3 -3
  54. package/dist/daemon-worker.js.map +1 -1
  55. package/dist/object-io.js +1 -1
  56. package/dist/object-io.js.map +1 -1
  57. package/dist/remote-pull.d.ts +2 -2
  58. package/dist/remote-pull.d.ts.map +1 -1
  59. package/dist/remote-pull.js +23 -3
  60. package/dist/remote-pull.js.map +1 -1
  61. package/dist/remote-pull.test.js +24 -2
  62. package/dist/remote-pull.test.js.map +1 -1
  63. package/dist/sync/push-receiver.d.ts +6 -0
  64. package/dist/sync/push-receiver.d.ts.map +1 -1
  65. package/dist/sync/push-receiver.js +32 -2
  66. package/dist/sync/push-receiver.js.map +1 -1
  67. package/dist/sync/push-receiver.test.js +31 -0
  68. package/dist/sync/push-receiver.test.js.map +1 -1
  69. package/dist/sync-core.d.ts +27 -0
  70. package/dist/sync-core.d.ts.map +1 -0
  71. package/dist/sync-core.js +54 -0
  72. package/dist/sync-core.js.map +1 -0
  73. package/dist/vault-client.d.ts.map +1 -1
  74. package/dist/vault-client.js +284 -36
  75. package/dist/vault-client.js.map +1 -1
  76. package/dist/vault-client.test.js +59 -0
  77. package/dist/vault-client.test.js.map +1 -1
  78. package/dist/watcher.d.ts +2 -20
  79. package/dist/watcher.d.ts.map +1 -1
  80. package/dist/watcher.js +3 -113
  81. package/dist/watcher.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/bin/sync-runner-company.ts +350 -0
  84. package/src/bin/sync-runner-events.ts +25 -0
  85. package/src/bin/sync-runner-planning.ts +121 -0
  86. package/src/bin/sync-runner-rollup.ts +72 -0
  87. package/src/bin/sync-runner-telemetry.ts +8 -0
  88. package/src/bin/sync-runner-watch-loop.ts +443 -0
  89. package/src/bin/sync-runner-watch-routes.ts +86 -0
  90. package/src/bin/sync-runner.ts +96 -1253
  91. package/src/cli/reindex.test.ts +41 -3
  92. package/src/cli/reindex.ts +35 -19
  93. package/src/cli/rescue-classify-ordering.test.ts +20 -0
  94. package/src/cli/rescue-core.ts +252 -176
  95. package/src/cli/share.ts +363 -705
  96. package/src/cli/sync.test.ts +25 -0
  97. package/src/cli/sync.ts +612 -802
  98. package/src/daemon-worker.ts +3 -3
  99. package/src/object-io.ts +1 -1
  100. package/src/remote-pull.test.ts +30 -1
  101. package/src/remote-pull.ts +29 -4
  102. package/src/sync/push-receiver.test.ts +35 -0
  103. package/src/sync/push-receiver.ts +41 -2
  104. package/src/sync-core.ts +58 -0
  105. package/src/vault-client.test.ts +74 -0
  106. package/src/vault-client.ts +395 -43
  107. package/src/watcher.ts +6 -141
@@ -4,11 +4,11 @@
4
4
  *
5
5
  * Day 1: not invoked by CLI surface; retained for future automatic-sync milestone.
6
6
  * When re-enabled, this worker will need to resolve an EntityContext before
7
- * constructing the SyncWatcher. The process argv will need to include company
8
- * context (slug or UID) and vault-service config.
7
+ * constructing the active watcher/runner path. The process argv will need to
8
+ * include company context (slug or UID) and vault-service config.
9
9
  */
10
10
 
11
- // Day 1: SyncWatcher now requires an EntityContext.
11
+ // Day 1: this worker still requires EntityContext-aware startup wiring.
12
12
  // This file is retained for the automatic-sync milestone but is not functional
13
13
  // until the daemon startup path is updated to resolve entity context.
14
14
 
package/src/object-io.ts CHANGED
@@ -540,7 +540,7 @@ export class PresignObjectIO implements ObjectIO {
540
540
  ) {}
541
541
 
542
542
  private cacheKey(op: PresignOp, key: string): string {
543
- return `${op}${key}`;
543
+ return `${op}\u0000${key}`;
544
544
  }
545
545
 
546
546
  hasPrimedPut(key: string): boolean {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Failing-test seed for the Auto-sync (Beta) remote-pull loop.
3
3
  *
4
- * Background: SyncWatcher (watcher.ts) pushes local edits to S3 in seconds,
4
+ * Background: the watcher push path ships local edits to S3 in seconds,
5
5
  * but pulls happen only on a manual sync today. Auto-sync adds a periodic
6
6
  * (every 10 min) remote-pull pass per company. The decision of *which* keys
7
7
  * to download / delete locally / skip is pure given a remote listing, the
@@ -25,6 +25,7 @@ import {
25
25
  POST_FILTER_THRESHOLD,
26
26
  pullCompany,
27
27
  resolveCompanyScope,
28
+ VEND_FANOUT_CONCURRENCY,
28
29
  VEND_PATH_CAP,
29
30
  } from "./remote-pull.js";
30
31
  import type { RemoteFile } from "./s3.js";
@@ -579,6 +580,34 @@ describe("listRemoteForScope", () => {
579
580
  expect(calls.size).toBe(VEND_PATH_CAP + 3); // every prefix listed
580
581
  });
581
582
 
583
+ it("F28: bounds concurrent per-prefix listings across vend-fanout batches", async () => {
584
+ const prefixes = Array.from(
585
+ { length: POST_FILTER_THRESHOLD },
586
+ (_, i) => `companies/indigo/p${i}/`,
587
+ );
588
+ let active = 0;
589
+ let maxActive = 0;
590
+
591
+ await listRemoteForScope({
592
+ ctx: makeCtx(),
593
+ scope: {
594
+ companyUid: "cmp_indigo",
595
+ syncMode: "shared",
596
+ prefixSet: prefixes,
597
+ strategy: "vend-fanout",
598
+ },
599
+ listFn: async () => {
600
+ active += 1;
601
+ maxActive = Math.max(maxActive, active);
602
+ await new Promise((resolve) => setTimeout(resolve, 1));
603
+ active -= 1;
604
+ return [];
605
+ },
606
+ });
607
+
608
+ expect(maxActive).toBeLessThanOrEqual(VEND_FANOUT_CONCURRENCY);
609
+ });
610
+
582
611
  it("strategy=vend-fanout uses vendForBatchFn to narrow credentials per batch", async () => {
583
612
  const vendCalls: Array<{ paths: string[] }> = [];
584
613
  await listRemoteForScope({
@@ -8,7 +8,7 @@
8
8
  * sync-runner.ts trivial: list S3 → call `decideRemotePulls` → drive S3 +
9
9
  * filesystem from the result.
10
10
  *
11
- * Pairs with `SyncWatcher` (push-side) — together they implement the
11
+ * Pairs with the TreeWatcher push path — together they implement the
12
12
  * bidirectional auto-sync the Settings toggle exposes.
13
13
  */
14
14
  import type { RemoteFile } from "./s3.js";
@@ -160,7 +160,7 @@ export const VEND_PATH_CAP = 10;
160
160
  */
161
161
  export const POST_FILTER_THRESHOLD = 50;
162
162
 
163
- /** Bounded parallelism for vend fan-out (5 concurrent STS+ListObjectsV2 calls). */
163
+ /** Bounded parallelism for vend fan-out (5 concurrent vends/list paginators). */
164
164
  export const VEND_FANOUT_CONCURRENCY = 5;
165
165
 
166
166
  /**
@@ -291,6 +291,27 @@ async function mapWithConcurrency<T, R>(
291
291
  return results;
292
292
  }
293
293
 
294
+ function createConcurrencyLimiter(
295
+ concurrency: number,
296
+ ): <R>(fn: () => Promise<R>) => Promise<R> {
297
+ const limit = Math.max(1, concurrency);
298
+ let active = 0;
299
+ const waiters: Array<() => void> = [];
300
+
301
+ return async function runLimited<R>(fn: () => Promise<R>): Promise<R> {
302
+ if (active >= limit) {
303
+ await new Promise<void>((resolve) => waiters.push(resolve));
304
+ }
305
+ active += 1;
306
+ try {
307
+ return await fn();
308
+ } finally {
309
+ active -= 1;
310
+ waiters.shift()?.();
311
+ }
312
+ };
313
+ }
314
+
294
315
  export interface ListRemoteForScopeInput {
295
316
  ctx: EntityContext;
296
317
  scope: CompanyScope;
@@ -347,6 +368,7 @@ export async function listRemoteForScope(
347
368
  const scopeEntries = toScopePrefixEntries(scope.prefixSet);
348
369
  const listPrefixes = scopeEntries.map((entry) => entry.prefix);
349
370
  const batches = batchPrefixesForVend(listPrefixes);
371
+ const listWithLimit = createConcurrencyLimiter(VEND_FANOUT_CONCURRENCY);
350
372
  const perBatch = await mapWithConcurrency(
351
373
  batches,
352
374
  VEND_FANOUT_CONCURRENCY,
@@ -357,8 +379,11 @@ export async function listRemoteForScope(
357
379
  // For a coalesced batch we issue one ListObjectsV2 per prefix in the
358
380
  // batch. We can't issue one ListObjectsV2 across N prefixes (the API
359
381
  // takes a single Prefix); the per-batch grouping exists for the STS
360
- // session policy ceiling, not the list call itself.
361
- const lists = await Promise.all(paths.map((p) => list(batchCtx, p)));
382
+ // session policy ceiling, not the list call itself. The shared limiter
383
+ // keeps the total active prefix paginators bounded across all batches.
384
+ const lists = await Promise.all(
385
+ paths.map((p) => listWithLimit(() => list(batchCtx, p))),
386
+ );
362
387
  return lists.flat();
363
388
  },
364
389
  );
@@ -396,6 +396,41 @@ describe("US-009: SqsPushReceiver — reconnect / catch-up replay", () => {
396
396
  expect(sqs.deleted).not.toContain("rh-first-attempt");
397
397
  expect(sqs.deleted).toContain("rh-redelivery");
398
398
  });
399
+
400
+ it("F29: bounds dedupe state by evicting least-recently-used paths", async () => {
401
+ const sqs = new FakeSqs();
402
+ const processed: string[] = [];
403
+ const syncFn: SyncEngineFn = async (ctx) => {
404
+ processed.push(ctx.event.relativePath);
405
+ };
406
+
407
+ sqs.enqueue([msg(makeEvent({ relativePath: "companies/indigo/a.md", sequenceNumber: 1 }), "rh-a1")]);
408
+ sqs.enqueue([msg(makeEvent({ relativePath: "companies/indigo/b.md", sequenceNumber: 1 }), "rh-b1")]);
409
+ sqs.enqueue([msg(makeEvent({ relativePath: "companies/indigo/c.md", sequenceNumber: 1 }), "rh-c1")]);
410
+ sqs.enqueue([msg(makeEvent({ relativePath: "companies/indigo/a.md", sequenceNumber: 1 }), "rh-a-redelivered")]);
411
+
412
+ const receiver = new SqsPushReceiver({
413
+ tenantId: TENANT,
414
+ queueUrl: QUEUE_URL,
415
+ sqs,
416
+ syncFn,
417
+ enabled: true,
418
+ sleep: fastSleep,
419
+ dedupeMaxPaths: 2,
420
+ });
421
+ await receiver.start();
422
+ await waitFor(() => receiver.processedCount === 4);
423
+ await receiver.dispose();
424
+
425
+ expect(processed).toEqual([
426
+ "companies/indigo/a.md",
427
+ "companies/indigo/b.md",
428
+ "companies/indigo/c.md",
429
+ "companies/indigo/a.md",
430
+ ]);
431
+ expect(receiver.dedupedCount).toBe(0);
432
+ expect(sqs.deleted).toContain("rh-a-redelivered");
433
+ });
399
434
  });
400
435
 
401
436
  describe("US-009: SqsPushReceiver — flag gating", () => {
@@ -125,6 +125,39 @@ export const DEFAULT_MAX_MESSAGES = 10;
125
125
  export const DEFAULT_RECONNECT_INITIAL_MS = 250;
126
126
  export const DEFAULT_RECONNECT_MAX_MS = 30_000;
127
127
 
128
+ /** Maximum distinct paths retained in receiver dedupe state. */
129
+ export const DEFAULT_RECEIVER_DEDUPE_MAX_PATHS = 50_000;
130
+
131
+ class BoundedSequenceDedupe {
132
+ private readonly maxPaths: number;
133
+ private readonly seen = new Map<string, number>();
134
+
135
+ constructor(maxPaths: number | undefined) {
136
+ this.maxPaths = Math.max(
137
+ 1,
138
+ Math.floor(maxPaths ?? DEFAULT_RECEIVER_DEDUPE_MAX_PATHS),
139
+ );
140
+ }
141
+
142
+ get(relativePath: string): number | undefined {
143
+ const sequenceNumber = this.seen.get(relativePath);
144
+ if (sequenceNumber === undefined) return undefined;
145
+ this.seen.delete(relativePath);
146
+ this.seen.set(relativePath, sequenceNumber);
147
+ return sequenceNumber;
148
+ }
149
+
150
+ set(relativePath: string, sequenceNumber: number): void {
151
+ if (this.seen.has(relativePath)) this.seen.delete(relativePath);
152
+ this.seen.set(relativePath, sequenceNumber);
153
+ while (this.seen.size > this.maxPaths) {
154
+ const oldest = this.seen.keys().next().value;
155
+ if (oldest === undefined) return;
156
+ this.seen.delete(oldest);
157
+ }
158
+ }
159
+ }
160
+
128
161
  // ─── Narrow SQS surface (the injectable transport seam) ──────────────────────
129
162
 
130
163
  /**
@@ -321,6 +354,8 @@ export interface SqsPushReceiverOptions {
321
354
  maxMessages?: number;
322
355
  /** Max time `dispose()` waits for an in-flight syncFn after abort. */
323
356
  disposeDrainMs?: number;
357
+ /** Maximum distinct paths retained in dedupe state. */
358
+ dedupeMaxPaths?: number;
324
359
  /** Reconnect backoff config. */
325
360
  reconnect?: {
326
361
  initialMs?: number;
@@ -384,7 +419,7 @@ export class SqsPushReceiver implements PushReceiver {
384
419
  private inFlightSync: Promise<void> | null = null;
385
420
 
386
421
  /** Per-path highest sequence number already PROCESSED by syncFn. */
387
- private readonly seenSequencePerPath = new Map<string, number>();
422
+ private readonly seenSequencePerPath: BoundedSequenceDedupe;
388
423
 
389
424
  private _processedCount = 0;
390
425
  private _dedupedCount = 0;
@@ -413,6 +448,7 @@ export class SqsPushReceiver implements PushReceiver {
413
448
  this.maxMessages = opts.maxMessages ?? DEFAULT_MAX_MESSAGES;
414
449
  this.disposeDrainMs =
415
450
  opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
451
+ this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
416
452
  this.reconnectInitialMs =
417
453
  opts.reconnect?.initialMs ?? DEFAULT_RECONNECT_INITIAL_MS;
418
454
  this.reconnectMaxMs = opts.reconnect?.maxMs ?? DEFAULT_RECONNECT_MAX_MS;
@@ -821,6 +857,8 @@ export interface InMemoryPushReceiverOptions {
821
857
  flagProvider?: EventDrivenPushFlagProvider;
822
858
  env?: Record<string, string | undefined>;
823
859
  disposeDrainMs?: number;
860
+ /** Maximum distinct paths retained in dedupe state. */
861
+ dedupeMaxPaths?: number;
824
862
  }
825
863
 
826
864
  /**
@@ -846,7 +884,7 @@ export class InMemoryPushReceiver implements PushReceiver {
846
884
 
847
885
  private disconnectedFlag = false;
848
886
  private readonly pendingDuringDisconnect: PushEvent[] = [];
849
- private readonly seenSequencePerPath = new Map<string, number>();
887
+ private readonly seenSequencePerPath: BoundedSequenceDedupe;
850
888
 
851
889
  private inFlightAbort: AbortController | null = null;
852
890
  private inFlightSync: Promise<void> | null = null;
@@ -862,6 +900,7 @@ export class InMemoryPushReceiver implements PushReceiver {
862
900
  this.logger = opts.logger ?? NOOP_LOGGER;
863
901
  this.disposeDrainMs =
864
902
  opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
903
+ this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
865
904
  this.enabled = resolveEnabled({
866
905
  explicit: opts.enabled,
867
906
  flagProvider: opts.flagProvider,
@@ -0,0 +1,58 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { normalizeEtag } from "./journal.js";
4
+
5
+ /**
6
+ * Is this error the S3/STS "access denied" class? Expected when a scoped
7
+ * member/guest credential touches a key outside its granted ACL prefixes
8
+ * (the server's `SCOPE_EXCEEDS_PARENT` surfaces as a 403 AccessDenied /
9
+ * Forbidden).
10
+ */
11
+ export function isAccessDenied(err: unknown): boolean {
12
+ if (err && typeof err === "object" && "name" in err) {
13
+ const name = (err as { name?: unknown }).name;
14
+ return name === "AccessDenied" || name === "Forbidden";
15
+ }
16
+ return false;
17
+ }
18
+
19
+ export function resolveTransferConcurrency(): number {
20
+ const raw = process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
21
+ if (raw === undefined || raw === "") return 16;
22
+ const parsed = Number.parseInt(raw, 10);
23
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 16;
24
+ }
25
+
26
+ /**
27
+ * Resolve active company from .hq/config.json.
28
+ */
29
+ export function resolveActiveCompany(hqRoot: string): string | undefined {
30
+ const configPath = path.join(hqRoot, ".hq", "config.json");
31
+ if (fs.existsSync(configPath)) {
32
+ try {
33
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
34
+ return config.activeCompany ?? config.companySlug;
35
+ } catch {
36
+ // Ignore parse errors
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * Returns true when the remote object appears to have moved since the
44
+ * journal entry's last-recorded sync. Prefers ETag equality; falls back to
45
+ * `lastModified > syncedAt` for legacy entries written before remoteEtag
46
+ * was tracked. Conservative on tie (`<=` skews "remote unchanged") so an
47
+ * S3-side mtime that exactly equals our syncedAt is not treated as drift.
48
+ */
49
+ export function hasRemoteChanged(
50
+ remote: { lastModified: Date; etag: string },
51
+ entry: { syncedAt: string; remoteEtag?: string },
52
+ ): boolean {
53
+ if (entry.remoteEtag) {
54
+ return normalizeEtag(remote.etag) !== entry.remoteEtag;
55
+ }
56
+ const syncedAt = new Date(entry.syncedAt).getTime();
57
+ return remote.lastModified.getTime() > syncedAt;
58
+ }
@@ -440,6 +440,80 @@ describe("API surface", () => {
440
440
  });
441
441
  });
442
442
 
443
+ describe("response validation (F26)", () => {
444
+ it("F26: validates a valid files/list response and returns typed data", async () => {
445
+ fetchSpy.mockResolvedValueOnce(
446
+ jsonResponse(200, {
447
+ objects: [
448
+ {
449
+ key: "shared/docs/a.txt",
450
+ size: 123,
451
+ lastModified: "2026-05-20T12:00:00.000Z",
452
+ etag: "abc123",
453
+ permission: "read",
454
+ },
455
+ ],
456
+ cursor: null,
457
+ truncated: false,
458
+ }),
459
+ );
460
+
461
+ const page = await client.listFiles("cmp_abc", "shared/docs/");
462
+
463
+ expect(page.objects[0]).toEqual({
464
+ key: "shared/docs/a.txt",
465
+ size: 123,
466
+ lastModified: "2026-05-20T12:00:00.000Z",
467
+ etag: "abc123",
468
+ permission: "read",
469
+ });
470
+ expect(page.cursor).toBeNull();
471
+ expect(page.truncated).toBe(false);
472
+ });
473
+
474
+ it("F26: defaults omitted files/list paging fields", async () => {
475
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
476
+
477
+ const page = await client.listFiles("cmp_abc", "shared/docs/");
478
+
479
+ expect(page).toEqual({
480
+ objects: [],
481
+ cursor: null,
482
+ truncated: false,
483
+ });
484
+ });
485
+
486
+ it("F26: defaults omitted telemetry opt-in fields to disabled", async () => {
487
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
488
+
489
+ const optIn = await client.getTelemetryOptIn();
490
+
491
+ expect(optIn).toEqual({ enabled: false, updatedAt: null });
492
+ });
493
+
494
+ it("F26: maps a malformed files/list response to VaultClientError", async () => {
495
+ fetchSpy.mockResolvedValueOnce(
496
+ jsonResponse(200, {
497
+ objects: [
498
+ {
499
+ key: "shared/docs/a.txt",
500
+ size: 123,
501
+ lastModified: "2026-05-20T12:00:00.000Z",
502
+ permission: "read",
503
+ },
504
+ ],
505
+ cursor: null,
506
+ truncated: false,
507
+ }),
508
+ );
509
+
510
+ await expect(client.listFiles("cmp_abc")).rejects.toMatchObject({
511
+ name: "VaultClientError",
512
+ statusCode: 502,
513
+ });
514
+ });
515
+ });
516
+
443
517
  describe("VaultClient identity bootstrap", () => {
444
518
  let client: VaultClient;
445
519
  let fetchSpy: MockInstance<typeof fetch>;