@indigoai-us/hq-cloud 6.11.11 → 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.
- package/dist/bin/sync-runner-company.d.ts +35 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.js +290 -0
- package/dist/bin/sync-runner-company.js.map +1 -0
- package/dist/bin/sync-runner-events.d.ts +12 -0
- package/dist/bin/sync-runner-events.d.ts.map +1 -0
- package/dist/bin/sync-runner-events.js +12 -0
- package/dist/bin/sync-runner-events.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +53 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.js +59 -0
- package/dist/bin/sync-runner-planning.js.map +1 -0
- package/dist/bin/sync-runner-rollup.d.ts +24 -0
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
- package/dist/bin/sync-runner-rollup.js +46 -0
- package/dist/bin/sync-runner-rollup.js.map +1 -0
- package/dist/bin/sync-runner-telemetry.d.ts +5 -0
- package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
- package/dist/bin/sync-runner-telemetry.js +5 -0
- package/dist/bin/sync-runner-telemetry.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-loop.js +372 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-routes.js +74 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +5 -54
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +76 -978
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +265 -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 +34 -17
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -5
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +75 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +45 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +320 -170
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +276 -660
- 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 +541 -748
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +382 -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/daemon-worker.d.ts +2 -2
- package/dist/daemon-worker.js +3 -3
- package/dist/daemon-worker.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 +149 -30
- 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.map +1 -1
- package/dist/personal-vault.js +8 -2
- 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 +8 -3
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +85 -16
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +213 -2
- 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 +12 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +45 -17
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +67 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync-core.d.ts +27 -0
- package/dist/sync-core.d.ts.map +1 -0
- package/dist/sync-core.js +54 -0
- package/dist/sync-core.js.map +1 -0
- 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/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +284 -36
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +59 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +38 -20
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +155 -143
- 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-company.ts +350 -0
- package/src/bin/sync-runner-events.ts +25 -0
- package/src/bin/sync-runner-planning.ts +121 -0
- package/src/bin/sync-runner-rollup.ts +72 -0
- package/src/bin/sync-runner-telemetry.ts +8 -0
- package/src/bin/sync-runner-watch-loop.ts +443 -0
- package/src/bin/sync-runner-watch-routes.ts +86 -0
- package/src/bin/sync-runner.test.ts +298 -11
- package/src/bin/sync-runner.ts +99 -1054
- package/src/cli/reindex.test.ts +41 -3
- package/src/cli/reindex.ts +35 -19
- package/src/cli/rescue-classify-ordering.test.ts +81 -0
- package/src/cli/rescue-core.ts +400 -165
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +420 -693
- package/src/cli/sync.test.ts +460 -1
- package/src/cli/sync.ts +788 -825
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/daemon-worker.ts +3 -3
- 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 +183 -31
- 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 +8 -2
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +235 -1
- package/src/remote-pull.ts +106 -18
- 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 +73 -1
- package/src/sync/push-receiver.ts +56 -20
- package/src/sync-core.ts +58 -0
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/vault-client.test.ts +74 -0
- package/src/vault-client.ts +395 -43
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +215 -174
|
@@ -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
|
/**
|
|
@@ -191,8 +224,8 @@ export interface PushReceiverContext {
|
|
|
191
224
|
* company/path; in tests it's a fake recording invocations.
|
|
192
225
|
*
|
|
193
226
|
* Errors from `syncFn` are CAUGHT by the receiver — they log and the loop
|
|
194
|
-
* continues. A
|
|
195
|
-
*
|
|
227
|
+
* continues. A failed sync is left unacknowledged in SQS so the queue can
|
|
228
|
+
* redeliver it after the visibility timeout.
|
|
196
229
|
*/
|
|
197
230
|
export type SyncEngineFn = (ctx: PushReceiverContext) => Promise<void>;
|
|
198
231
|
|
|
@@ -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
|
|
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;
|
|
@@ -613,18 +649,18 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
613
649
|
|
|
614
650
|
const handled = await this.dispatch(validated);
|
|
615
651
|
if (handled) {
|
|
616
|
-
// Delete only after
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
// dedupe; the poll/cadence safety net is the recovery path for the throw.
|
|
652
|
+
// Delete only after a successful handoff or dedupe-skip. A syncFn throw
|
|
653
|
+
// leaves the message undeleted so SQS redelivery can retry the targeted
|
|
654
|
+
// pull.
|
|
620
655
|
await this.safeDelete(msg);
|
|
621
656
|
}
|
|
622
657
|
}
|
|
623
658
|
|
|
624
659
|
/**
|
|
625
|
-
* Dedupe + invoke `syncFn`. Returns true once the event has been
|
|
626
|
-
* for (deduped, or syncFn
|
|
627
|
-
* Stores the in-flight promise so `dispose()` can drain
|
|
660
|
+
* Dedupe + invoke `syncFn`. Returns true only once the event has been
|
|
661
|
+
* accounted for successfully (deduped, or syncFn completed) so the caller can
|
|
662
|
+
* delete the message. Stores the in-flight promise so `dispose()` can drain
|
|
663
|
+
* it.
|
|
628
664
|
*/
|
|
629
665
|
private async dispatch(event: PushEvent): Promise<boolean> {
|
|
630
666
|
if (this.disposing || this.disposed) return false;
|
|
@@ -645,18 +681,13 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
645
681
|
return true;
|
|
646
682
|
}
|
|
647
683
|
|
|
648
|
-
// Record BEFORE invoking syncFn — a back-to-back event for the same path
|
|
649
|
-
// must see this sequence in its dedupe check. The trade-off: a syncFn that
|
|
650
|
-
// throws still advances the counter ("latest we KNOW about"); the safety
|
|
651
|
-
// net poll is the recovery path for the throw.
|
|
652
|
-
this.seenSequencePerPath.set(event.relativePath, event.sequenceNumber);
|
|
653
|
-
|
|
654
684
|
const controller = new AbortController();
|
|
655
685
|
this.inFlightAbort = controller;
|
|
656
686
|
const ctx: PushReceiverContext = { event, signal: controller.signal };
|
|
657
687
|
|
|
658
688
|
const holder: { p: Promise<void> | null } = { p: null };
|
|
659
689
|
const startMs = this.now();
|
|
690
|
+
let handled = false;
|
|
660
691
|
const p: Promise<void> = (async () => {
|
|
661
692
|
try {
|
|
662
693
|
this.logger.debug(
|
|
@@ -669,7 +700,9 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
669
700
|
"push receiver invoking sync engine",
|
|
670
701
|
);
|
|
671
702
|
await this.syncFn(ctx);
|
|
703
|
+
this.seenSequencePerPath.set(event.relativePath, event.sequenceNumber);
|
|
672
704
|
this._processedCount += 1;
|
|
705
|
+
handled = true;
|
|
673
706
|
this.logger.debug(
|
|
674
707
|
{
|
|
675
708
|
event: "receiver.sync.completed",
|
|
@@ -715,8 +748,8 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
715
748
|
});
|
|
716
749
|
} catch (err) {
|
|
717
750
|
// Critical: catch, log, return. A misbehaving sync engine must never
|
|
718
|
-
// crash the receiver loop.
|
|
719
|
-
//
|
|
751
|
+
// crash the receiver loop. Leaving the SQS message undeleted lets
|
|
752
|
+
// redelivery retry the failed targeted pull.
|
|
720
753
|
const e = err as NodeJS.ErrnoException;
|
|
721
754
|
this.logger.error(
|
|
722
755
|
{
|
|
@@ -736,7 +769,7 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
736
769
|
holder.p = p;
|
|
737
770
|
this.inFlightSync = p;
|
|
738
771
|
await p;
|
|
739
|
-
return
|
|
772
|
+
return handled;
|
|
740
773
|
}
|
|
741
774
|
|
|
742
775
|
/** Delete a message, swallowing transport errors (redelivery is harmless). */
|
|
@@ -824,6 +857,8 @@ export interface InMemoryPushReceiverOptions {
|
|
|
824
857
|
flagProvider?: EventDrivenPushFlagProvider;
|
|
825
858
|
env?: Record<string, string | undefined>;
|
|
826
859
|
disposeDrainMs?: number;
|
|
860
|
+
/** Maximum distinct paths retained in dedupe state. */
|
|
861
|
+
dedupeMaxPaths?: number;
|
|
827
862
|
}
|
|
828
863
|
|
|
829
864
|
/**
|
|
@@ -849,7 +884,7 @@ export class InMemoryPushReceiver implements PushReceiver {
|
|
|
849
884
|
|
|
850
885
|
private disconnectedFlag = false;
|
|
851
886
|
private readonly pendingDuringDisconnect: PushEvent[] = [];
|
|
852
|
-
private readonly seenSequencePerPath
|
|
887
|
+
private readonly seenSequencePerPath: BoundedSequenceDedupe;
|
|
853
888
|
|
|
854
889
|
private inFlightAbort: AbortController | null = null;
|
|
855
890
|
private inFlightSync: Promise<void> | null = null;
|
|
@@ -865,6 +900,7 @@ export class InMemoryPushReceiver implements PushReceiver {
|
|
|
865
900
|
this.logger = opts.logger ?? NOOP_LOGGER;
|
|
866
901
|
this.disposeDrainMs =
|
|
867
902
|
opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
|
|
903
|
+
this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
|
|
868
904
|
this.enabled = resolveEnabled({
|
|
869
905
|
explicit: opts.enabled,
|
|
870
906
|
flagProvider: opts.flagProvider,
|
package/src/sync-core.ts
ADDED
|
@@ -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
|
+
}
|
package/src/telemetry.test.ts
CHANGED
|
@@ -292,6 +292,91 @@ describe("collectAndSendTelemetry", () => {
|
|
|
292
292
|
expect(lastWire).toBeLessThan(1_000_000);
|
|
293
293
|
});
|
|
294
294
|
|
|
295
|
+
it("F19: caps usage telemetry batches at 100 events", async () => {
|
|
296
|
+
const client = makeClient();
|
|
297
|
+
const lines = Array.from({ length: 101 }, (_, i) => JSON.stringify({
|
|
298
|
+
type: "user",
|
|
299
|
+
timestamp: `2026-04-25T10:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}Z`,
|
|
300
|
+
sessionId: "s-f19-row-cap",
|
|
301
|
+
uuid: `u-f19-row-${i}`,
|
|
302
|
+
cwd: "/Users/x",
|
|
303
|
+
gitBranch: "main",
|
|
304
|
+
userType: "human",
|
|
305
|
+
message: { role: "user", content: [{ type: "text", text: "hi" }], id: `m${i}` },
|
|
306
|
+
}));
|
|
307
|
+
writeJsonl(env, "proj", "f19-row-cap.jsonl", lines);
|
|
308
|
+
|
|
309
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
310
|
+
|
|
311
|
+
expect(result.eventsSent).toBe(101);
|
|
312
|
+
expect(result.batchesSent).toBe(2);
|
|
313
|
+
expect(client.posts.map((post) => post.events.length)).toEqual([100, 1]);
|
|
314
|
+
for (const post of client.posts) {
|
|
315
|
+
expect(post.events.length).toBeLessThanOrEqual(100);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("F19: caps usage telemetry POST bodies at 240 KiB", async () => {
|
|
320
|
+
const client = makeClient();
|
|
321
|
+
const branch = "x".repeat(9_000);
|
|
322
|
+
const lines = Array.from({ length: 30 }, (_, i) => JSON.stringify({
|
|
323
|
+
type: "user",
|
|
324
|
+
timestamp: `2026-04-25T11:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}Z`,
|
|
325
|
+
sessionId: "s-f19-byte-cap",
|
|
326
|
+
uuid: `u-f19-byte-${i}`,
|
|
327
|
+
cwd: "/Users/x",
|
|
328
|
+
gitBranch: branch,
|
|
329
|
+
userType: "human",
|
|
330
|
+
message: { role: "user", content: [{ type: "text", text: "hi" }], id: `m${i}` },
|
|
331
|
+
}));
|
|
332
|
+
writeJsonl(env, "proj", "f19-byte-cap.jsonl", lines);
|
|
333
|
+
|
|
334
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
335
|
+
const wireSizes = client.posts.map((post) => Buffer.byteLength(JSON.stringify(post), "utf-8"));
|
|
336
|
+
|
|
337
|
+
expect(result.eventsSent).toBe(30);
|
|
338
|
+
expect(result.batchesSent).toBeGreaterThan(1);
|
|
339
|
+
for (const wireSize of wireSizes) {
|
|
340
|
+
expect(wireSize).toBeLessThanOrEqual(240 * 1024);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("R-F19: bounds a single oversized sanitized usage row before POST", async () => {
|
|
345
|
+
const client = makeClient();
|
|
346
|
+
const logs: string[] = [];
|
|
347
|
+
const hugeBranch = "x".repeat(400_000);
|
|
348
|
+
writeJsonl(env, "proj", "r-f19-singleton.jsonl", [
|
|
349
|
+
JSON.stringify({
|
|
350
|
+
type: "user",
|
|
351
|
+
timestamp: "2026-06-19T10:00:00.000Z",
|
|
352
|
+
sessionId: "s-r-f19-singleton",
|
|
353
|
+
uuid: "u-r-f19-singleton",
|
|
354
|
+
cwd: "/Users/x",
|
|
355
|
+
gitBranch: hugeBranch,
|
|
356
|
+
userType: "human",
|
|
357
|
+
message: {
|
|
358
|
+
role: "user",
|
|
359
|
+
content: [{ type: "text", text: "hi" }],
|
|
360
|
+
id: "m-r-f19-singleton",
|
|
361
|
+
},
|
|
362
|
+
}),
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
const result = await collectAndSendTelemetry({
|
|
366
|
+
...makeOpts(env, client),
|
|
367
|
+
log: (msg) => logs.push(msg),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(result.eventsSent).toBe(1);
|
|
371
|
+
expect(client.posts).toHaveLength(1);
|
|
372
|
+
const wireSize = Buffer.byteLength(JSON.stringify(client.posts[0]), "utf-8");
|
|
373
|
+
expect(wireSize).toBeLessThanOrEqual(240 * 1024);
|
|
374
|
+
expect(client.posts[0].events[0].gitBranch).not.toBe(hugeBranch);
|
|
375
|
+
expect(logs.some((line) => line.includes("oversized row truncated"))).toBe(
|
|
376
|
+
true,
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
295
380
|
it("(e) POST 500 → cursor NOT advanced", async () => {
|
|
296
381
|
const client = makeClient({ postResponse: new Error("server 500") });
|
|
297
382
|
writeJsonl(env, "proj", "s.jsonl", [USER_ROW, ASST_ROW, USER_ROW]);
|
package/src/telemetry.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* file against a persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`,
|
|
12
12
|
* sanitizes new rows through a tight allowlist that matches the server's
|
|
13
13
|
* KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
|
|
14
|
-
* batches into
|
|
14
|
+
* batches into server-sized POST bodies, and ships them to `/v1/usage`.
|
|
15
15
|
*
|
|
16
16
|
* Trust model: the caller's `personUid` is resolved on the server from the
|
|
17
17
|
* Cognito JWT — never from the body. `sanitizeRow` strips prompt bodies,
|
|
@@ -245,7 +245,9 @@ async function listJsonlFiles(root: string): Promise<string[]> {
|
|
|
245
245
|
|
|
246
246
|
// ── Batching primitives ───────────────────────────────────────────────────────
|
|
247
247
|
|
|
248
|
-
const
|
|
248
|
+
const MAX_BATCH_EVENTS = 100;
|
|
249
|
+
const MAX_BATCH_BYTES = 240 * 1024;
|
|
250
|
+
const ROW_TRUNCATION_SUFFIX = "...[truncated]";
|
|
249
251
|
|
|
250
252
|
interface RowSource {
|
|
251
253
|
filePath: string;
|
|
@@ -273,6 +275,49 @@ function envelopeBytes(machineId: string, installerVersion: string): number {
|
|
|
273
275
|
);
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
function jsonBytes(value: unknown): number {
|
|
279
|
+
return Buffer.byteLength(JSON.stringify(value), "utf-8");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function truncateLongestStringField(row: Record<string, unknown>): boolean {
|
|
283
|
+
let longestKey: string | undefined;
|
|
284
|
+
let longestBytes = 0;
|
|
285
|
+
for (const [key, value] of Object.entries(row)) {
|
|
286
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
287
|
+
const bytes = Buffer.byteLength(value, "utf-8");
|
|
288
|
+
if (bytes > longestBytes) {
|
|
289
|
+
longestBytes = bytes;
|
|
290
|
+
longestKey = key;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (longestKey === undefined) return false;
|
|
294
|
+
|
|
295
|
+
const value = row[longestKey] as string;
|
|
296
|
+
const keepChars =
|
|
297
|
+
value.length > ROW_TRUNCATION_SUFFIX.length
|
|
298
|
+
? Math.floor((value.length - ROW_TRUNCATION_SUFFIX.length) / 2)
|
|
299
|
+
: 0;
|
|
300
|
+
const next =
|
|
301
|
+
keepChars > 0
|
|
302
|
+
? `${value.slice(0, keepChars)}${ROW_TRUNCATION_SUFFIX}`
|
|
303
|
+
: "";
|
|
304
|
+
if (next === value) return false;
|
|
305
|
+
row[longestKey] = next;
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function boundRowForPost(
|
|
310
|
+
row: Record<string, unknown>,
|
|
311
|
+
maxRowBytes: number,
|
|
312
|
+
): Record<string, unknown> | null {
|
|
313
|
+
if (maxRowBytes < 0) return null;
|
|
314
|
+
const bounded = { ...row };
|
|
315
|
+
while (jsonBytes(bounded) > maxRowBytes) {
|
|
316
|
+
if (!truncateLongestStringField(bounded)) return null;
|
|
317
|
+
}
|
|
318
|
+
return bounded;
|
|
319
|
+
}
|
|
320
|
+
|
|
276
321
|
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
277
322
|
|
|
278
323
|
/**
|
|
@@ -324,7 +369,7 @@ export async function collectAndSendTelemetry(
|
|
|
324
369
|
|
|
325
370
|
const files = await listJsonlFiles(claudeProjectsRoot);
|
|
326
371
|
|
|
327
|
-
// 3. Walk each file, sanitize new rows, batch, flush at
|
|
372
|
+
// 3. Walk each file, sanitize new rows, batch, flush at the server contract.
|
|
328
373
|
//
|
|
329
374
|
// Byte accounting is incremental: we track `batchBytes` as the projected
|
|
330
375
|
// serialized size of the current batch (envelope + per-row JSON + commas).
|
|
@@ -440,15 +485,33 @@ export async function collectAndSendTelemetry(
|
|
|
440
485
|
);
|
|
441
486
|
const sanitized = sanitizeRow(parsed, companyUid);
|
|
442
487
|
if (!sanitized) continue;
|
|
488
|
+
const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
|
|
489
|
+
const wasOversized = jsonBytes(sanitized) > maxRowBytes;
|
|
490
|
+
const bounded = boundRowForPost(sanitized, maxRowBytes);
|
|
491
|
+
if (!bounded) {
|
|
492
|
+
log(
|
|
493
|
+
`[telemetry] oversized row dropped before send (${filePath}:${i + 1})`,
|
|
494
|
+
);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (wasOversized) {
|
|
498
|
+
log(
|
|
499
|
+
`[telemetry] oversized row truncated before send (${filePath}:${i + 1})`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
443
502
|
|
|
444
503
|
// Cost of appending this row to the current batch: the row's JSON
|
|
445
504
|
// length plus 1 byte for the leading comma when there's already at
|
|
446
505
|
// least one row. (No comma when the batch is empty — the row sits
|
|
447
506
|
// alone inside the events array.)
|
|
448
|
-
const rowJsonBytes = Buffer.byteLength(JSON.stringify(
|
|
507
|
+
const rowJsonBytes = Buffer.byteLength(JSON.stringify(bounded), "utf-8");
|
|
449
508
|
const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
|
|
450
509
|
|
|
451
|
-
if (
|
|
510
|
+
if (
|
|
511
|
+
batchEvents.length > 0 &&
|
|
512
|
+
(batchEvents.length >= MAX_BATCH_EVENTS ||
|
|
513
|
+
batchBytes + addCost > MAX_BATCH_BYTES)
|
|
514
|
+
) {
|
|
452
515
|
await flush();
|
|
453
516
|
// After flush, batchEvents is empty → no comma needed for the first row.
|
|
454
517
|
batchBytes = ENVELOPE_BYTES + rowJsonBytes;
|
|
@@ -456,7 +519,7 @@ export async function collectAndSendTelemetry(
|
|
|
456
519
|
batchBytes += addCost;
|
|
457
520
|
}
|
|
458
521
|
|
|
459
|
-
batchEvents.push(
|
|
522
|
+
batchEvents.push(bounded);
|
|
460
523
|
batchSources.push({
|
|
461
524
|
filePath,
|
|
462
525
|
endOffset: lineEndOffsets[i],
|
package/src/types.ts
CHANGED
|
@@ -72,6 +72,14 @@ export interface JournalEntry {
|
|
|
72
72
|
*/
|
|
73
73
|
removedAt?: string;
|
|
74
74
|
removedReason?: "scope_shrink" | "narrow_apply" | "manual";
|
|
75
|
+
/**
|
|
76
|
+
* Durable automatic-pull retention marker. Set when a scope shrink keeps an
|
|
77
|
+
* out-of-scope caller-authored or unknown-author entry on disk instead of
|
|
78
|
+
* pruning it. Subsequent pulls under the already-narrowed scope use this to
|
|
79
|
+
* exclude the survivor from remote-missing local deletes; it is cleared if
|
|
80
|
+
* the entry becomes in-scope again.
|
|
81
|
+
*/
|
|
82
|
+
outOfScopeProtected?: boolean;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
/**
|
package/src/vault-client.test.ts
CHANGED
|
@@ -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>;
|