@indigoai-us/hq-cloud 6.11.11 → 6.11.12

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 (160) hide show
  1. package/dist/bin/sync-runner.d.ts +2 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +231 -52
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +265 -11
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/rescue-classify-ordering.test.js +58 -0
  8. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  9. package/dist/cli/rescue-core.js +138 -15
  10. package/dist/cli/rescue-core.js.map +1 -1
  11. package/dist/cli/share.d.ts +2 -1
  12. package/dist/cli/share.d.ts.map +1 -1
  13. package/dist/cli/share.js +100 -32
  14. package/dist/cli/share.js.map +1 -1
  15. package/dist/cli/share.test.js +30 -0
  16. package/dist/cli/share.test.js.map +1 -1
  17. package/dist/cli/sync.d.ts +28 -1
  18. package/dist/cli/sync.d.ts.map +1 -1
  19. package/dist/cli/sync.js +178 -58
  20. package/dist/cli/sync.js.map +1 -1
  21. package/dist/cli/sync.test.js +362 -1
  22. package/dist/cli/sync.test.js.map +1 -1
  23. package/dist/cognito-auth.d.ts.map +1 -1
  24. package/dist/cognito-auth.js +55 -10
  25. package/dist/cognito-auth.js.map +1 -1
  26. package/dist/cognito-auth.test.js +61 -0
  27. package/dist/cognito-auth.test.js.map +1 -1
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/journal.d.ts.map +1 -1
  33. package/dist/journal.js +93 -6
  34. package/dist/journal.js.map +1 -1
  35. package/dist/journal.test.js +59 -0
  36. package/dist/journal.test.js.map +1 -1
  37. package/dist/machine-auth.test.js +60 -2
  38. package/dist/machine-auth.test.js.map +1 -1
  39. package/dist/object-io.d.ts +37 -1
  40. package/dist/object-io.d.ts.map +1 -1
  41. package/dist/object-io.js +148 -29
  42. package/dist/object-io.js.map +1 -1
  43. package/dist/object-io.test.js +121 -0
  44. package/dist/object-io.test.js.map +1 -1
  45. package/dist/operation-lock.d.ts +8 -8
  46. package/dist/operation-lock.d.ts.map +1 -1
  47. package/dist/operation-lock.js +99 -32
  48. package/dist/operation-lock.js.map +1 -1
  49. package/dist/operation-lock.test.js +51 -4
  50. package/dist/operation-lock.test.js.map +1 -1
  51. package/dist/personal-vault.d.ts.map +1 -1
  52. package/dist/personal-vault.js +8 -2
  53. package/dist/personal-vault.js.map +1 -1
  54. package/dist/personal-vault.test.js +34 -0
  55. package/dist/personal-vault.test.js.map +1 -1
  56. package/dist/prefix-coalesce.d.ts +20 -9
  57. package/dist/prefix-coalesce.d.ts.map +1 -1
  58. package/dist/prefix-coalesce.js +124 -28
  59. package/dist/prefix-coalesce.js.map +1 -1
  60. package/dist/prefix-coalesce.test.js +57 -2
  61. package/dist/prefix-coalesce.test.js.map +1 -1
  62. package/dist/remote-pull.d.ts +6 -1
  63. package/dist/remote-pull.d.ts.map +1 -1
  64. package/dist/remote-pull.js +62 -13
  65. package/dist/remote-pull.js.map +1 -1
  66. package/dist/remote-pull.test.js +189 -0
  67. package/dist/remote-pull.test.js.map +1 -1
  68. package/dist/s3.d.ts +2 -0
  69. package/dist/s3.d.ts.map +1 -1
  70. package/dist/s3.js +197 -116
  71. package/dist/s3.js.map +1 -1
  72. package/dist/s3.test.js +109 -0
  73. package/dist/s3.test.js.map +1 -1
  74. package/dist/scope-shrink.d.ts +3 -2
  75. package/dist/scope-shrink.d.ts.map +1 -1
  76. package/dist/scope-shrink.js +1 -1
  77. package/dist/scope-shrink.js.map +1 -1
  78. package/dist/skill-telemetry.d.ts +1 -1
  79. package/dist/skill-telemetry.d.ts.map +1 -1
  80. package/dist/skill-telemetry.js +69 -9
  81. package/dist/skill-telemetry.js.map +1 -1
  82. package/dist/skill-telemetry.test.js +86 -0
  83. package/dist/skill-telemetry.test.js.map +1 -1
  84. package/dist/sync/event-sync.d.ts +6 -0
  85. package/dist/sync/event-sync.d.ts.map +1 -1
  86. package/dist/sync/event-sync.js +34 -1
  87. package/dist/sync/event-sync.js.map +1 -1
  88. package/dist/sync/event-sync.test.js +73 -0
  89. package/dist/sync/event-sync.test.js.map +1 -1
  90. package/dist/sync/metrics.d.ts +17 -1
  91. package/dist/sync/metrics.d.ts.map +1 -1
  92. package/dist/sync/metrics.js +32 -1
  93. package/dist/sync/metrics.js.map +1 -1
  94. package/dist/sync/metrics.test.js +74 -1
  95. package/dist/sync/metrics.test.js.map +1 -1
  96. package/dist/sync/pull-scope.d.ts.map +1 -1
  97. package/dist/sync/pull-scope.js +15 -7
  98. package/dist/sync/pull-scope.js.map +1 -1
  99. package/dist/sync/push-receiver.d.ts +6 -5
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +13 -15
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +36 -1
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/telemetry.d.ts +1 -1
  106. package/dist/telemetry.d.ts.map +1 -1
  107. package/dist/telemetry.js +59 -6
  108. package/dist/telemetry.js.map +1 -1
  109. package/dist/telemetry.test.js +74 -0
  110. package/dist/telemetry.test.js.map +1 -1
  111. package/dist/types.d.ts +8 -0
  112. package/dist/types.d.ts.map +1 -1
  113. package/dist/watcher.d.ts +36 -0
  114. package/dist/watcher.d.ts.map +1 -1
  115. package/dist/watcher.js +152 -30
  116. package/dist/watcher.js.map +1 -1
  117. package/dist/watcher.test.js +103 -0
  118. package/dist/watcher.test.js.map +1 -1
  119. package/package.json +1 -1
  120. package/src/bin/sync-runner.test.ts +298 -11
  121. package/src/bin/sync-runner.ts +254 -52
  122. package/src/cli/rescue-classify-ordering.test.ts +61 -0
  123. package/src/cli/rescue-core.ts +174 -15
  124. package/src/cli/share.test.ts +38 -0
  125. package/src/cli/share.ts +103 -34
  126. package/src/cli/sync.test.ts +435 -1
  127. package/src/cli/sync.ts +217 -64
  128. package/src/cognito-auth.test.ts +77 -0
  129. package/src/cognito-auth.ts +73 -11
  130. package/src/index.ts +8 -0
  131. package/src/journal.test.ts +72 -0
  132. package/src/journal.ts +95 -8
  133. package/src/machine-auth.test.ts +64 -2
  134. package/src/object-io.test.ts +142 -0
  135. package/src/object-io.ts +182 -30
  136. package/src/operation-lock.test.ts +63 -4
  137. package/src/operation-lock.ts +99 -31
  138. package/src/personal-vault.test.ts +42 -0
  139. package/src/personal-vault.ts +8 -2
  140. package/src/prefix-coalesce.test.ts +71 -1
  141. package/src/prefix-coalesce.ts +155 -30
  142. package/src/remote-pull.test.ts +205 -0
  143. package/src/remote-pull.ts +77 -14
  144. package/src/s3.test.ts +126 -0
  145. package/src/s3.ts +237 -122
  146. package/src/scope-shrink.ts +6 -3
  147. package/src/skill-telemetry.test.ts +109 -0
  148. package/src/skill-telemetry.ts +82 -14
  149. package/src/sync/event-sync.test.ts +75 -0
  150. package/src/sync/event-sync.ts +54 -1
  151. package/src/sync/metrics.test.ts +81 -0
  152. package/src/sync/metrics.ts +59 -4
  153. package/src/sync/pull-scope.ts +23 -7
  154. package/src/sync/push-receiver.test.ts +38 -1
  155. package/src/sync/push-receiver.ts +15 -18
  156. package/src/telemetry.test.ts +85 -0
  157. package/src/telemetry.ts +69 -6
  158. package/src/types.ts +8 -0
  159. package/src/watcher.test.ts +117 -0
  160. package/src/watcher.ts +209 -33
@@ -617,7 +617,52 @@ async function readCodexSessionContext(
617
617
  }
618
618
  }
619
619
 
620
- const MAX_BATCH_BYTES = 1_000_000;
620
+ const MAX_BATCH_EVENTS = 100;
621
+ const MAX_BATCH_BYTES = 240 * 1024;
622
+ const ROW_TRUNCATION_SUFFIX = "...[truncated]";
623
+
624
+ function jsonBytes(value: unknown): number {
625
+ return Buffer.byteLength(JSON.stringify(value), "utf-8");
626
+ }
627
+
628
+ function truncateLongestStringField(row: Record<string, unknown>): boolean {
629
+ let longestKey: string | undefined;
630
+ let longestBytes = 0;
631
+ for (const [key, value] of Object.entries(row)) {
632
+ if (typeof value !== "string" || value.length === 0) continue;
633
+ const bytes = Buffer.byteLength(value, "utf-8");
634
+ if (bytes > longestBytes) {
635
+ longestBytes = bytes;
636
+ longestKey = key;
637
+ }
638
+ }
639
+ if (longestKey === undefined) return false;
640
+
641
+ const value = row[longestKey] as string;
642
+ const keepChars =
643
+ value.length > ROW_TRUNCATION_SUFFIX.length
644
+ ? Math.floor((value.length - ROW_TRUNCATION_SUFFIX.length) / 2)
645
+ : 0;
646
+ const next =
647
+ keepChars > 0
648
+ ? `${value.slice(0, keepChars)}${ROW_TRUNCATION_SUFFIX}`
649
+ : "";
650
+ if (next === value) return false;
651
+ row[longestKey] = next;
652
+ return true;
653
+ }
654
+
655
+ function boundRowForPost(
656
+ row: Record<string, unknown>,
657
+ maxRowBytes: number,
658
+ ): Record<string, unknown> | null {
659
+ if (maxRowBytes < 0) return null;
660
+ const bounded = { ...row };
661
+ while (jsonBytes(bounded) > maxRowBytes) {
662
+ if (!truncateLongestStringField(bounded)) return null;
663
+ }
664
+ return bounded;
665
+ }
621
666
 
622
667
  // ── Main entry point ──────────────────────────────────────────────────────────
623
668
 
@@ -627,7 +672,7 @@ const MAX_BATCH_BYTES = 1_000_000;
627
672
  * Cursor model (per-batch commit, matching the token collector for robustness):
628
673
  * each file is scanned from its stored byte offset to EOF; extracted events
629
674
  * carry the byte offset of the line they came from. Events are flushed in
630
- * ≤1 MiB batches, and the cursor advances **per successful batch** — so if one
675
+ * server-sized batches, and the cursor advances **per successful batch** — so if one
631
676
  * batch in a large (e.g. first-run backfill) fails, the batches that already
632
677
  * succeeded stay committed and only the rest re-send next sync.
633
678
  *
@@ -707,6 +752,11 @@ export async function collectAndSendSkillTelemetry(
707
752
  const fileScans: Record<string, FileScan> = {};
708
753
  const rotationResets: Record<string, CursorEntry> = {};
709
754
  const sourced: Sourced[] = [];
755
+ const envelopeBytes = Buffer.byteLength(
756
+ JSON.stringify({ machineId: opts.machineId, installerVersion: opts.installerVersion, events: [] }),
757
+ "utf-8",
758
+ );
759
+ const maxRowBytes = MAX_BATCH_BYTES - envelopeBytes;
710
760
 
711
761
  for (const { filePath, kind } of files) {
712
762
  let stat;
@@ -811,13 +861,27 @@ export async function collectAndSendSkillTelemetry(
811
861
  // Resolve cwd → owning company (cmp_* uid) from the per-run map.
812
862
  // Unresolved → undefined → companyUid omitted (unattributed/personal).
813
863
  const companyUid = resolveCompanyForCwd(ev.cwd, repoCompanyMap);
814
- sourced.push({ row: toWireRow(ev, companyUid), filePath, endOffset });
864
+ const wireRow = toWireRow(ev, companyUid);
865
+ const wasOversized = jsonBytes(wireRow) > maxRowBytes;
866
+ const bounded = boundRowForPost(wireRow, maxRowBytes);
867
+ if (!bounded) {
868
+ log(
869
+ `[skill-telemetry] oversized row dropped before send (${filePath}:${i + 1})`,
870
+ );
871
+ continue;
872
+ }
873
+ if (wasOversized) {
874
+ log(
875
+ `[skill-telemetry] oversized row truncated before send (${filePath}:${i + 1})`,
876
+ );
877
+ }
878
+ sourced.push({ row: bounded, filePath, endOffset });
815
879
  fileScans[filePath].eventCount++;
816
880
  }
817
881
  }
818
882
  }
819
883
 
820
- // 4. Flush in ≤1 MiB batches, advancing per-file progress on each 2xx.
884
+ // 4. Flush in server-sized batches, advancing per-file progress on each 2xx.
821
885
  let eventsSent = 0;
822
886
  let batchesSent = 0;
823
887
 
@@ -825,16 +889,11 @@ export async function collectAndSendSkillTelemetry(
825
889
  const sentCount: Record<string, number> = {};
826
890
  const committedOffset: Record<string, number> = {};
827
891
 
828
- const envelopeBytes = Buffer.byteLength(
829
- JSON.stringify({ machineId: opts.machineId, installerVersion: opts.installerVersion, events: [] }),
830
- "utf-8",
831
- );
832
-
833
892
  let batch: Sourced[] = [];
834
893
  let batchBytes = envelopeBytes;
835
894
 
836
- const flush = async (): Promise<void> => {
837
- if (batch.length === 0) return;
895
+ const flush = async (): Promise<boolean> => {
896
+ if (batch.length === 0) return true;
838
897
  const toSend = batch;
839
898
  batch = [];
840
899
  batchBytes = envelopeBytes;
@@ -852,24 +911,33 @@ export async function collectAndSendSkillTelemetry(
852
911
  const prev = committedOffset[s.filePath] ?? 0;
853
912
  if (s.endOffset > prev) committedOffset[s.filePath] = s.endOffset;
854
913
  }
914
+ return true;
855
915
  } catch (err) {
856
916
  log(`[skill-telemetry] postSkillInvocations failed (${(err as Error).message ?? err}) — these rows re-send next sync`);
857
917
  // Cursor not advanced for this batch; eventKey dedups the eventual re-send.
918
+ return false;
858
919
  }
859
920
  };
860
921
 
922
+ let stoppedAfterFailure = false;
861
923
  for (const s of sourced) {
862
924
  const rowBytes = Buffer.byteLength(JSON.stringify(s.row), "utf-8");
863
925
  const addCost = rowBytes + (batch.length > 0 ? 1 : 0);
864
- if (batch.length > 0 && batchBytes + addCost > MAX_BATCH_BYTES) {
865
- await flush();
926
+ if (
927
+ batch.length > 0 &&
928
+ (batch.length >= MAX_BATCH_EVENTS || batchBytes + addCost > MAX_BATCH_BYTES)
929
+ ) {
930
+ if (!(await flush())) {
931
+ stoppedAfterFailure = true;
932
+ break;
933
+ }
866
934
  batchBytes = envelopeBytes + rowBytes;
867
935
  } else {
868
936
  batchBytes += addCost;
869
937
  }
870
938
  batch.push(s);
871
939
  }
872
- await flush();
940
+ if (!stoppedAfterFailure) await flush();
873
941
 
874
942
  // 5. Build the new cursor: loaded < rotationResets < per-file commit.
875
943
  // A file settles at EOF only when every event extracted from it this run
@@ -492,6 +492,81 @@ describe("startEventSync", () => {
492
492
  await handles!.dispose();
493
493
  });
494
494
 
495
+ it("R-F18: disables receive metrics when subscribe omits CloudWatch credentials", async () => {
496
+ const oldMetricEnv = {
497
+ region: process.env.HQ_SYNC_CLOUDWATCH_REGION,
498
+ accessKeyId: process.env.HQ_SYNC_CLOUDWATCH_ACCESS_KEY_ID,
499
+ secretAccessKey: process.env.HQ_SYNC_CLOUDWATCH_SECRET_ACCESS_KEY,
500
+ sessionToken: process.env.HQ_SYNC_CLOUDWATCH_SESSION_TOKEN,
501
+ };
502
+ delete process.env.HQ_SYNC_CLOUDWATCH_REGION;
503
+ delete process.env.HQ_SYNC_CLOUDWATCH_ACCESS_KEY_ID;
504
+ delete process.env.HQ_SYNC_CLOUDWATCH_SECRET_ACCESS_KEY;
505
+ delete process.env.HQ_SYNC_CLOUDWATCH_SESSION_TOKEN;
506
+
507
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
508
+ const syncCalls: PushEvent[] = [];
509
+ let deliverNext: ((body: string) => void) | null = null;
510
+ const queueSqs: SqsClientLike = {
511
+ receiveMessage: ({ signal }) =>
512
+ new Promise((resolve) => {
513
+ signal.addEventListener("abort", () => resolve({ messages: [] }), {
514
+ once: true,
515
+ });
516
+ deliverNext = (body: string) => {
517
+ deliverNext = null;
518
+ resolve({ messages: [{ Body: body, ReceiptHandle: "rh" }] });
519
+ };
520
+ }),
521
+ deleteMessage: async () => {},
522
+ };
523
+
524
+ let handles: Awaited<ReturnType<typeof startEventSync>> = null;
525
+ try {
526
+ handles = await startEventSync({
527
+ ...happyOpts(syncCalls),
528
+ buildSqs: () => queueSqs,
529
+ });
530
+ expect(handles).not.toBeNull();
531
+
532
+ await vi.waitFor(() => {
533
+ if (!deliverNext) throw new Error("receiver not polling yet");
534
+ });
535
+ deliverNext!(
536
+ JSON.stringify(pushEvent({ originDeviceId: "peer-device" })),
537
+ );
538
+ await vi.waitFor(() => expect(syncCalls).toHaveLength(1));
539
+ await new Promise((resolve) => setTimeout(resolve, 0));
540
+
541
+ expect(consoleSpy).not.toHaveBeenCalled();
542
+ } finally {
543
+ await handles?.dispose();
544
+ consoleSpy.mockRestore();
545
+ if (oldMetricEnv.region === undefined) {
546
+ delete process.env.HQ_SYNC_CLOUDWATCH_REGION;
547
+ } else {
548
+ process.env.HQ_SYNC_CLOUDWATCH_REGION = oldMetricEnv.region;
549
+ }
550
+ if (oldMetricEnv.accessKeyId === undefined) {
551
+ delete process.env.HQ_SYNC_CLOUDWATCH_ACCESS_KEY_ID;
552
+ } else {
553
+ process.env.HQ_SYNC_CLOUDWATCH_ACCESS_KEY_ID = oldMetricEnv.accessKeyId;
554
+ }
555
+ if (oldMetricEnv.secretAccessKey === undefined) {
556
+ delete process.env.HQ_SYNC_CLOUDWATCH_SECRET_ACCESS_KEY;
557
+ } else {
558
+ process.env.HQ_SYNC_CLOUDWATCH_SECRET_ACCESS_KEY =
559
+ oldMetricEnv.secretAccessKey;
560
+ }
561
+ if (oldMetricEnv.sessionToken === undefined) {
562
+ delete process.env.HQ_SYNC_CLOUDWATCH_SESSION_TOKEN;
563
+ } else {
564
+ process.env.HQ_SYNC_CLOUDWATCH_SESSION_TOKEN =
565
+ oldMetricEnv.sessionToken;
566
+ }
567
+ }
568
+ });
569
+
495
570
  it("returns null (poll-only) when subscribe fails — never throws", async () => {
496
571
  const logged: string[] = [];
497
572
  const handles = await startEventSync({
@@ -42,11 +42,16 @@ import { HttpPushTransport, type AuthTokenSource } from "./push-transport.js";
42
42
  import {
43
43
  SqsPushReceiver,
44
44
  type PushReceiver,
45
+ type PublishMetricFn,
45
46
  type SqsClientLike,
46
47
  type SyncEngineFn,
47
48
  } from "./push-receiver.js";
48
49
  import { PushEventEmitter } from "../watcher.js";
49
50
  import type { TreeChangeBatch } from "../watcher.js";
51
+ import {
52
+ createSyncLatencyMetricPublisher,
53
+ type SyncMetricCredentials,
54
+ } from "./metrics.js";
50
55
 
51
56
  // ─── US-019: rollout gate ───────────────────────────────────────────────────
52
57
 
@@ -102,6 +107,11 @@ export interface SubscribeSyncResponse {
102
107
  region: string;
103
108
  /** Short-lived creds scoped to receive/delete on exactly this queue. */
104
109
  credentials: SubscribeSyncCredentials;
110
+ /** Optional short-lived creds scoped to publish sync latency CloudWatch metrics. */
111
+ cloudWatch?: {
112
+ region: string;
113
+ credentials: SyncMetricCredentials;
114
+ };
105
115
  }
106
116
 
107
117
  /** Minimal fetch seam (matches push-transport.ts's FetchLike posture). */
@@ -122,6 +132,42 @@ function asNonEmptyString(v: unknown, field: string): string {
122
132
  return v;
123
133
  }
124
134
 
135
+ function parseOptionalCloudWatch(
136
+ obj: Record<string, unknown>,
137
+ ): SubscribeSyncResponse["cloudWatch"] {
138
+ if (obj.cloudWatch === undefined) return undefined;
139
+ if (
140
+ !obj.cloudWatch ||
141
+ typeof obj.cloudWatch !== "object" ||
142
+ Array.isArray(obj.cloudWatch)
143
+ ) {
144
+ throw new Error("subscribeSyncReceive: response missing cloudWatch.region");
145
+ }
146
+ const cloudWatch = obj.cloudWatch as Record<string, unknown>;
147
+ const creds = (cloudWatch.credentials ?? {}) as Record<string, unknown>;
148
+ return {
149
+ region: asNonEmptyString(cloudWatch.region, "cloudWatch.region"),
150
+ credentials: {
151
+ accessKeyId: asNonEmptyString(
152
+ creds.accessKeyId,
153
+ "cloudWatch.credentials.accessKeyId",
154
+ ),
155
+ secretAccessKey: asNonEmptyString(
156
+ creds.secretAccessKey,
157
+ "cloudWatch.credentials.secretAccessKey",
158
+ ),
159
+ sessionToken:
160
+ typeof creds.sessionToken === "string" && creds.sessionToken.length > 0
161
+ ? creds.sessionToken
162
+ : undefined,
163
+ expiration:
164
+ typeof creds.expiration === "string" && creds.expiration.length > 0
165
+ ? creds.expiration
166
+ : undefined,
167
+ },
168
+ };
169
+ }
170
+
125
171
  /**
126
172
  * `POST /v1/sync/subscribe` — provision (idempotently) this device's queue
127
173
  * and vend fresh receive credentials. Auth mirrors HttpPushTransport: Bearer
@@ -170,7 +216,8 @@ export async function subscribeSyncReceive(opts: {
170
216
  const parsed: unknown = JSON.parse(await res.text());
171
217
  const obj = parsed as Record<string, unknown>;
172
218
  const creds = (obj.credentials ?? {}) as Record<string, unknown>;
173
- return {
219
+ const cloudWatch = parseOptionalCloudWatch(obj);
220
+ const response: SubscribeSyncResponse = {
174
221
  queueUrl: asNonEmptyString(obj.queueUrl, "queueUrl"),
175
222
  region: asNonEmptyString(obj.region, "region"),
176
223
  credentials: {
@@ -186,6 +233,8 @@ export async function subscribeSyncReceive(opts: {
186
233
  expiration: asNonEmptyString(creds.expiration, "credentials.expiration"),
187
234
  },
188
235
  };
236
+ if (cloudWatch !== undefined) response.cloudWatch = cloudWatch;
237
+ return response;
189
238
  }
190
239
 
191
240
  // ─── SQS SDK adapter ────────────────────────────────────────────────────────
@@ -436,6 +485,9 @@ export async function startEventSync(
436
485
  deviceId: dev,
437
486
  }));
438
487
  const initial = await subscribe(deviceId);
488
+ const publishMetric: PublishMetricFn = initial.cloudWatch
489
+ ? createSyncLatencyMetricPublisher(initial.cloudWatch)
490
+ : async () => undefined;
439
491
  const sqs = createRefreshingSqsClient({
440
492
  initial,
441
493
  subscribe: () => subscribe(deviceId),
@@ -453,6 +505,7 @@ export async function startEventSync(
453
505
  if (ctx.event.originDeviceId === deviceId) return;
454
506
  await opts.syncFn(ctx);
455
507
  },
508
+ publishMetric,
456
509
  // The client rollout gate is the authority; the env-driven per-tenant
457
510
  // flag provider is a server-side convention not set on user machines.
458
511
  enabled: true,
@@ -29,6 +29,7 @@ import {
29
29
  SYNC_LATENCY_METRIC_NAME,
30
30
  SYNC_METRIC_NAMESPACE,
31
31
  _setSyncCloudWatchClient,
32
+ createSyncLatencyMetricPublisher,
32
33
  publishSyncLatencyMetric,
33
34
  type SyncLatencyMetric,
34
35
  } from "./metrics.js";
@@ -274,6 +275,86 @@ describe("publishSyncLatencyMetric", () => {
274
275
 
275
276
  overrideMock.restore();
276
277
  });
278
+
279
+ it("F18: rejects ambient CloudWatch clients without scoped credentials and region", async () => {
280
+ const constructedWith: unknown[] = [];
281
+ const send = vi.fn(async () => ({}));
282
+
283
+ class CapturingCloudWatchClient {
284
+ readonly send = send;
285
+
286
+ constructor(config: unknown) {
287
+ constructedWith.push(config);
288
+ }
289
+ }
290
+
291
+ class CapturingPutMetricDataCommand {
292
+ constructor(readonly input: unknown) {}
293
+ }
294
+
295
+ vi.resetModules();
296
+ vi.doMock("@aws-sdk/client-cloudwatch", () => ({
297
+ CloudWatchClient: CapturingCloudWatchClient,
298
+ PutMetricDataCommand: CapturingPutMetricDataCommand,
299
+ }));
300
+
301
+ try {
302
+ const metrics = await import("./metrics.js");
303
+
304
+ await metrics.publishSyncLatencyMetric(makeMetric());
305
+
306
+ expect(constructedWith).not.toContainEqual({});
307
+ } finally {
308
+ vi.doUnmock("@aws-sdk/client-cloudwatch");
309
+ vi.resetModules();
310
+ }
311
+ });
312
+
313
+ it("R-F18: creates a metric publisher from explicit vended CloudWatch credentials", async () => {
314
+ const constructedWith: unknown[] = [];
315
+ const sent: unknown[] = [];
316
+ const publisher = createSyncLatencyMetricPublisher({
317
+ region: "us-west-2",
318
+ credentials: {
319
+ accessKeyId: "AKIA-VENDED",
320
+ secretAccessKey: "secret-vended",
321
+ sessionToken: "token-vended",
322
+ expiration: "2026-06-20T12:00:00.000Z",
323
+ },
324
+ buildClient: (config) => {
325
+ constructedWith.push(config);
326
+ return {
327
+ send: async (command: { input: unknown }) => {
328
+ sent.push(command.input);
329
+ return {};
330
+ },
331
+ };
332
+ },
333
+ });
334
+
335
+ await publisher(makeMetric({ tenantId: "prs_vended" }));
336
+
337
+ expect(constructedWith).toHaveLength(1);
338
+ expect(constructedWith[0]).toMatchObject({
339
+ region: "us-west-2",
340
+ credentials: {
341
+ accessKeyId: "AKIA-VENDED",
342
+ secretAccessKey: "secret-vended",
343
+ sessionToken: "token-vended",
344
+ expiration: new Date("2026-06-20T12:00:00.000Z"),
345
+ },
346
+ });
347
+ expect(sent).toHaveLength(1);
348
+ expect(sent[0]).toMatchObject({
349
+ Namespace: SYNC_METRIC_NAMESPACE,
350
+ MetricData: [
351
+ {
352
+ MetricName: SYNC_LATENCY_METRIC_NAME,
353
+ Dimensions: [{ Name: "TenantId", Value: "prs_vended" }],
354
+ },
355
+ ],
356
+ });
357
+ });
277
358
  });
278
359
 
279
360
  // ── Receive-success-path metric emission (US-011 AC: "metric emission unit-
@@ -75,13 +75,41 @@ export interface SyncLatencyMetric {
75
75
  timestamp: Date;
76
76
  }
77
77
 
78
+ export interface SyncMetricCredentials {
79
+ accessKeyId: string;
80
+ secretAccessKey: string;
81
+ sessionToken?: string;
82
+ expiration?: string;
83
+ }
84
+
85
+ type CloudWatchClientConfig = NonNullable<
86
+ ConstructorParameters<typeof CloudWatchClient>[0]
87
+ >;
88
+ type CloudWatchClientLike = Pick<CloudWatchClient, "send">;
89
+
78
90
  // ── Client ─────────────────────────────────────────────────────────────────
79
91
 
80
- let _cwClient: CloudWatchClient | undefined;
92
+ let _cwClient: CloudWatchClientLike | undefined;
81
93
 
82
- function getCloudWatchClient(): CloudWatchClient {
94
+ function getCloudWatchClient(): CloudWatchClientLike {
83
95
  if (!_cwClient) {
84
- _cwClient = new CloudWatchClient({});
96
+ const region = process.env.HQ_SYNC_CLOUDWATCH_REGION;
97
+ const accessKeyId = process.env.HQ_SYNC_CLOUDWATCH_ACCESS_KEY_ID;
98
+ const secretAccessKey = process.env.HQ_SYNC_CLOUDWATCH_SECRET_ACCESS_KEY;
99
+ const sessionToken = process.env.HQ_SYNC_CLOUDWATCH_SESSION_TOKEN;
100
+ if (!region || !accessKeyId || !secretAccessKey) {
101
+ throw new Error(
102
+ "CloudWatch metric publisher requires scoped HQ_SYNC_CLOUDWATCH_REGION and credentials",
103
+ );
104
+ }
105
+ _cwClient = new CloudWatchClient({
106
+ region,
107
+ credentials: {
108
+ accessKeyId,
109
+ secretAccessKey,
110
+ ...(sessionToken ? { sessionToken } : {}),
111
+ },
112
+ });
85
113
  }
86
114
  return _cwClient;
87
115
  }
@@ -94,11 +122,38 @@ export function _setSyncCloudWatchClient(client: CloudWatchClient): void {
94
122
  _cwClient = client;
95
123
  }
96
124
 
125
+ export interface CreateSyncLatencyMetricPublisherOptions {
126
+ region: string;
127
+ credentials: SyncMetricCredentials;
128
+ /** Override CloudWatch construction (tests). */
129
+ buildClient?: (config: CloudWatchClientConfig) => CloudWatchClientLike;
130
+ }
131
+
132
+ export function createSyncLatencyMetricPublisher(
133
+ opts: CreateSyncLatencyMetricPublisherOptions,
134
+ ): (metric: SyncLatencyMetric) => Promise<void> {
135
+ const credentials = {
136
+ accessKeyId: opts.credentials.accessKeyId,
137
+ secretAccessKey: opts.credentials.secretAccessKey,
138
+ ...(opts.credentials.sessionToken
139
+ ? { sessionToken: opts.credentials.sessionToken }
140
+ : {}),
141
+ ...(opts.credentials.expiration
142
+ ? { expiration: new Date(opts.credentials.expiration) }
143
+ : {}),
144
+ };
145
+ const client = (opts.buildClient ?? ((config) => new CloudWatchClient(config)))({
146
+ region: opts.region,
147
+ credentials,
148
+ });
149
+ return (metric) => publishSyncLatencyMetric(metric, { client });
150
+ }
151
+
97
152
  // ── Publish ────────────────────────────────────────────────────────────────
98
153
 
99
154
  export interface PublishSyncLatencyMetricOptions {
100
155
  /** Override the CloudWatch client (tests). Defaults to the module singleton. */
101
- client?: CloudWatchClient;
156
+ client?: CloudWatchClientLike;
102
157
  /** Optional pino logger for emission failures. */
103
158
  logger?: Logger;
104
159
  }
@@ -21,7 +21,11 @@
21
21
  */
22
22
  import * as fs from "fs";
23
23
  import * as path from "path";
24
- import { coalescePrefixes, grantPathToPrefix } from "../prefix-coalesce.js";
24
+ import {
25
+ coalescePrefixes,
26
+ grantPathToScopePrefix,
27
+ pathToScopePrefix,
28
+ } from "../prefix-coalesce.js";
25
29
  import type {
26
30
  ExplicitGrant,
27
31
  MembershipSyncConfig,
@@ -133,15 +137,20 @@ export async function resolvePullScope(
133
137
 
134
138
  if (cfg.syncMode === "custom") {
135
139
  const customPrefixes = (cfg.customPaths ?? []).map((p) =>
136
- grantPathToPrefix(p, slug),
140
+ grantPathToScopePrefix(p, slug),
137
141
  );
138
142
  // A bare-everything entry ("" — e.g. a `*` path) collapses under
139
143
  // `coalescePrefixes` (which drops empties) to "nothing", which would
140
144
  // prune the whole tree. An everything-scope is semantically `all`.
141
- if (customPrefixes.some((p) => p === "")) return { syncMode: "all" };
145
+ if (customPrefixes.some((p) => p.prefix === "")) {
146
+ return { syncMode: "all" };
147
+ }
142
148
  return {
143
149
  syncMode: "custom",
144
- prefixSet: coalescePrefixes([...customPrefixes, ...pinPrefixes]),
150
+ prefixSet: coalescePrefixes([
151
+ ...customPrefixes,
152
+ ...pinPrefixes.map(pathToScopePrefix),
153
+ ]),
145
154
  };
146
155
  }
147
156
  // shared: scope to the caller's explicit grants. Real grant paths are
@@ -158,14 +167,21 @@ export async function resolvePullScope(
158
167
  // []) is a real "nothing shared with me" and is allowed to narrow.
159
168
  if (!client.listMyExplicitGrants) return { syncMode: "all" };
160
169
  const grants = await client.listMyExplicitGrants(companyUid);
161
- const sharedPrefixes = grants.map((g) => grantPathToPrefix(g.path, slug));
170
+ const sharedPrefixes = grants.map((g) =>
171
+ grantPathToScopePrefix(g.path, slug),
172
+ );
162
173
  // A wildcard grant (`*`) normalizes to "" = everything. Since
163
174
  // `coalescePrefixes` drops empties (collapsing "everything" to "nothing"),
164
175
  // treat any such grant as full-access `all` rather than risk pruning.
165
- if (sharedPrefixes.some((p) => p === "")) return { syncMode: "all" };
176
+ if (sharedPrefixes.some((p) => p.prefix === "")) {
177
+ return { syncMode: "all" };
178
+ }
166
179
  return {
167
180
  syncMode: "shared",
168
- prefixSet: coalescePrefixes([...sharedPrefixes, ...pinPrefixes]),
181
+ prefixSet: coalescePrefixes([
182
+ ...sharedPrefixes,
183
+ ...pinPrefixes.map(pathToScopePrefix),
184
+ ]),
169
185
  };
170
186
  } catch {
171
187
  // Degrade to `all` — never prune on a resolution failure.
@@ -357,7 +357,44 @@ describe("US-009: SqsPushReceiver — reconnect / catch-up replay", () => {
357
357
  await receiver.dispose();
358
358
  // First threw (processedCount not incremented), second succeeded.
359
359
  expect(receiver.processedCount).toBe(1);
360
- expect(sqs.deleted).toEqual(expect.arrayContaining(["rh-1", "rh-2"]));
360
+ // F10: a failed targeted pull is left undeleted for SQS redelivery; only the
361
+ // successful message (rh-2) is acknowledged. The thrown one (rh-1) stays on
362
+ // the queue so the visibility-timeout redelivery can retry it.
363
+ expect(sqs.deleted).toContain("rh-2");
364
+ expect(sqs.deleted).not.toContain("rh-1");
365
+ });
366
+
367
+ it("F10: leaves failed targeted pull messages undeleted so redelivery retries", async () => {
368
+ const sqs = new FakeSqs();
369
+ let calls = 0;
370
+ const syncFn: SyncEngineFn = async () => {
371
+ calls += 1;
372
+ if (calls === 1) throw new Error("targeted pull failed");
373
+ };
374
+ const event = makeEvent({
375
+ relativePath: "companies/indigo/retry-after-failure.md",
376
+ sequenceNumber: 7,
377
+ });
378
+ sqs.enqueue([msg(event, "rh-first-attempt")]);
379
+ sqs.enqueue([msg(event, "rh-redelivery")]);
380
+
381
+ const receiver = new SqsPushReceiver({
382
+ tenantId: TENANT,
383
+ queueUrl: QUEUE_URL,
384
+ sqs,
385
+ syncFn,
386
+ enabled: true,
387
+ sleep: fastSleep,
388
+ });
389
+ await receiver.start();
390
+ await waitFor(() => sqs.deleted.includes("rh-redelivery"));
391
+ await receiver.dispose();
392
+
393
+ expect(calls).toBe(2);
394
+ expect(receiver.processedCount).toBe(1);
395
+ expect(receiver.dedupedCount).toBe(0);
396
+ expect(sqs.deleted).not.toContain("rh-first-attempt");
397
+ expect(sqs.deleted).toContain("rh-redelivery");
361
398
  });
362
399
  });
363
400