@indigoai-us/hq-cloud 6.11.10 → 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.
- package/dist/bin/sync-runner.d.ts +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +231 -52
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +330 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +16 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -1
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +58 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.js +229 -15
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +30 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +188 -59
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +487 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +55 -10
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +61 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +93 -6
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +59 -0
- package/dist/journal.test.js.map +1 -1
- package/dist/machine-auth.test.js +60 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +37 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +148 -29
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +121 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +8 -8
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +99 -32
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +51 -4
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts +8 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +17 -3
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +34 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +20 -9
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +124 -28
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +57 -2
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/remote-pull.d.ts +6 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +62 -13
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +189 -0
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +197 -116
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +109 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +3 -2
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +1 -1
- package/dist/scope-shrink.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +69 -9
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +86 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +34 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +73 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/metrics.d.ts +17 -1
- package/dist/sync/metrics.d.ts.map +1 -1
- package/dist/sync/metrics.js +32 -1
- package/dist/sync/metrics.js.map +1 -1
- package/dist/sync/metrics.test.js +74 -1
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +15 -7
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +6 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +13 -15
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +36 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +59 -6
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +74 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +36 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +152 -30
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +103 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +396 -11
- package/src/bin/sync-runner.ts +254 -52
- package/src/cli/reindex.test.ts +47 -1
- package/src/cli/reindex.ts +17 -1
- package/src/cli/rescue-classify-ordering.test.ts +61 -0
- package/src/cli/rescue-core.ts +261 -15
- package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +103 -34
- package/src/cli/sync.test.ts +594 -1
- package/src/cli/sync.ts +229 -65
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/index.ts +8 -0
- package/src/journal.test.ts +72 -0
- package/src/journal.ts +95 -8
- package/src/machine-auth.test.ts +64 -2
- package/src/object-io.test.ts +142 -0
- package/src/object-io.ts +182 -30
- package/src/operation-lock.test.ts +63 -4
- package/src/operation-lock.ts +99 -31
- package/src/personal-vault.test.ts +42 -0
- package/src/personal-vault.ts +18 -3
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +205 -0
- package/src/remote-pull.ts +77 -14
- package/src/s3.test.ts +126 -0
- package/src/s3.ts +237 -122
- package/src/scope-shrink.ts +6 -3
- package/src/skill-telemetry.test.ts +109 -0
- package/src/skill-telemetry.ts +82 -14
- package/src/sync/event-sync.test.ts +75 -0
- package/src/sync/event-sync.ts +54 -1
- package/src/sync/metrics.test.ts +81 -0
- package/src/sync/metrics.ts +59 -4
- package/src/sync/pull-scope.ts +23 -7
- package/src/sync/push-receiver.test.ts +38 -1
- package/src/sync/push-receiver.ts +15 -18
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +209 -33
package/src/skill-telemetry.ts
CHANGED
|
@@ -617,7 +617,52 @@ async function readCodexSessionContext(
|
|
|
617
617
|
}
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
-
const
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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<
|
|
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 (
|
|
865
|
-
|
|
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({
|
package/src/sync/event-sync.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/sync/metrics.test.ts
CHANGED
|
@@ -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-
|
package/src/sync/metrics.ts
CHANGED
|
@@ -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:
|
|
92
|
+
let _cwClient: CloudWatchClientLike | undefined;
|
|
81
93
|
|
|
82
|
-
function getCloudWatchClient():
|
|
94
|
+
function getCloudWatchClient(): CloudWatchClientLike {
|
|
83
95
|
if (!_cwClient) {
|
|
84
|
-
|
|
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?:
|
|
156
|
+
client?: CloudWatchClientLike;
|
|
102
157
|
/** Optional pino logger for emission failures. */
|
|
103
158
|
logger?: Logger;
|
|
104
159
|
}
|
package/src/sync/pull-scope.ts
CHANGED
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import * as fs from "fs";
|
|
23
23
|
import * as path from "path";
|
|
24
|
-
import {
|
|
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
|
-
|
|
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 === ""))
|
|
145
|
+
if (customPrefixes.some((p) => p.prefix === "")) {
|
|
146
|
+
return { syncMode: "all" };
|
|
147
|
+
}
|
|
142
148
|
return {
|
|
143
149
|
syncMode: "custom",
|
|
144
|
-
prefixSet: coalescePrefixes([
|
|
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) =>
|
|
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 === ""))
|
|
176
|
+
if (sharedPrefixes.some((p) => p.prefix === "")) {
|
|
177
|
+
return { syncMode: "all" };
|
|
178
|
+
}
|
|
166
179
|
return {
|
|
167
180
|
syncMode: "shared",
|
|
168
|
-
prefixSet: coalescePrefixes([
|
|
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
|
-
|
|
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
|
|