@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/telemetry.ts
DELETED
|
@@ -1,1109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Usage telemetry collector — TypeScript port of the Tauri Rust collector that
|
|
3
|
-
* used to live at `hq-workspace/apps/hq-sync/src-tauri/src/commands/telemetry.rs`.
|
|
4
|
-
*
|
|
5
|
-
* Why it moved: the Rust copy only ran inside the macOS menubar app. By moving
|
|
6
|
-
* the logic into `@indigoai-us/hq-cloud`, every consumer of the package
|
|
7
|
-
* (`hq-sync-runner`, `hq-cli`, mobile wrappers) emits telemetry uniformly.
|
|
8
|
-
*
|
|
9
|
-
* What it does: after each successful sync (`all-complete` arm of
|
|
10
|
-
* `bin/sync-runner.ts`), walks Claude session logs plus live and archived Codex
|
|
11
|
-
* rollouts, then diffs each file against the persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`, sanitizes new rows through a tight allowlist that matches the server's
|
|
12
|
-
* KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
|
|
13
|
-
* batches into server-sized POST bodies, and ships them to `/v1/usage`.
|
|
14
|
-
*
|
|
15
|
-
* Trust model: the caller's `personUid` is resolved on the server from the
|
|
16
|
-
* Cognito JWT — never from the body. `sanitizeRow` strips prompt bodies,
|
|
17
|
-
* thinking content, tool inputs/outputs, and any nested `message` object so
|
|
18
|
-
* the wire payload contains only token-accounting fields.
|
|
19
|
-
*
|
|
20
|
-
* Errors are swallowed by design — telemetry must never abort or delay a
|
|
21
|
-
* sync. The cursor is only advanced for batches the server 2xx'd, so a
|
|
22
|
-
* transient outage retries automatically on the next sync.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { createHash } from "node:crypto";
|
|
26
|
-
import { promises as fs } from "node:fs";
|
|
27
|
-
import * as os from "node:os";
|
|
28
|
-
import * as path from "node:path";
|
|
29
|
-
|
|
30
|
-
import {
|
|
31
|
-
buildRepoCompanyMap,
|
|
32
|
-
resolveCompanyForCwd,
|
|
33
|
-
type RepoCompanyMap,
|
|
34
|
-
} from "./company-resolver.js";
|
|
35
|
-
import type {
|
|
36
|
-
TelemetryOptInResponse,
|
|
37
|
-
UsageBatch,
|
|
38
|
-
UsageIngestResult,
|
|
39
|
-
} from "./vault-client.js";
|
|
40
|
-
|
|
41
|
-
// ── Public surface ────────────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Minimal subset of `VaultClient` the collector needs. Declared as an
|
|
45
|
-
* interface so tests can inject a stub without spinning up a fetch mock.
|
|
46
|
-
* The real `VaultClient` from `./vault-client.js` satisfies this structurally.
|
|
47
|
-
*/
|
|
48
|
-
export interface TelemetryClientSurface {
|
|
49
|
-
getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
|
|
50
|
-
postUsage(batch: UsageBatch): Promise<UsageIngestResult>;
|
|
51
|
-
/**
|
|
52
|
-
* Optional so an older client (or a narrow test stub) still satisfies the
|
|
53
|
-
* surface — when it is absent the consent self-heal is simply skipped.
|
|
54
|
-
*/
|
|
55
|
-
setTelemetryOptIn?(
|
|
56
|
-
enabled: boolean,
|
|
57
|
-
opts?: { onlyIfUnset?: boolean },
|
|
58
|
-
): Promise<{ applied: boolean } | void>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface CollectTelemetryOptions {
|
|
62
|
-
client: TelemetryClientSurface;
|
|
63
|
-
/** Stable per-machine id. The Tauri menubar reads this from `~/.hq/menubar.json`; the runner can pass it through or generate one once and cache. */
|
|
64
|
-
machineId: string;
|
|
65
|
-
/** Version of the wrapping caller (menubar app, CLI, etc.). Reaches CloudWatch metrics as the `installerVersion` dimension. */
|
|
66
|
-
installerVersion: string;
|
|
67
|
-
/**
|
|
68
|
-
* HQ root, used to resolve each event's `cwd` → owning repo → owning company
|
|
69
|
-
* via `<hqRoot>/companies/manifest.yaml` and stamp `companyUid` on the event
|
|
70
|
-
* (surface-hq-console-telemetry US-002). The manifest is parsed ONCE per run
|
|
71
|
-
* (see `buildRepoCompanyMap`); the resulting map is reused for every event.
|
|
72
|
-
* When omitted (or when no repo matches), `companyUid` is left UNSET and the
|
|
73
|
-
* server treats the event as unattributed/personal.
|
|
74
|
-
*/
|
|
75
|
-
hqRoot?: string;
|
|
76
|
-
/**
|
|
77
|
-
* Explicit single-company sync scope (`--company <slug-or-uid>`). Used only
|
|
78
|
-
* when an event cwd does not resolve through the manifest. This is intentionally
|
|
79
|
-
* absent for multi-company and personal runs to prevent cross-tenant attribution.
|
|
80
|
-
*/
|
|
81
|
-
fallbackCompany?: string;
|
|
82
|
-
/** Override `~/.claude/projects` for tests. */
|
|
83
|
-
claudeProjectsRoot?: string;
|
|
84
|
-
/** Override `~/.codex` for tests. Both live and archived rollouts are scanned. */
|
|
85
|
-
codexRoot?: string;
|
|
86
|
-
/** Override the per-runtime scan budget for deterministic tests. */
|
|
87
|
-
maxScanBytesPerSource?: number;
|
|
88
|
-
/** Override the upload batch cap for deterministic tests. */
|
|
89
|
-
maxBatchesPerRun?: number;
|
|
90
|
-
/** Override `~/.hq/telemetry-cursor.json` for tests. */
|
|
91
|
-
cursorPath?: string;
|
|
92
|
-
/** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
|
|
93
|
-
menubarPath?: string;
|
|
94
|
-
/** Diagnostic sink. No-op by default. */
|
|
95
|
-
log?: (msg: string) => void;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface CollectTelemetryResult {
|
|
99
|
-
/** Whether the opt-in check resolved to true (either server-side or via the menubar fallback). When false, nothing else ran. */
|
|
100
|
-
enabled: boolean;
|
|
101
|
-
/** Source for the `enabled` decision — useful for diagnosing missing-events reports. */
|
|
102
|
-
optInSource: "server" | "menubar-fallback" | "menubar-reasserted" | "skipped";
|
|
103
|
-
/** How many `.jsonl` files we considered (before the cursor diff). */
|
|
104
|
-
filesScanned: number;
|
|
105
|
-
/** Total events successfully POSTed across all batches. */
|
|
106
|
-
eventsSent: number;
|
|
107
|
-
/** Number of `POST /v1/usage` requests made. */
|
|
108
|
-
batchesSent: number;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ── Cursor schema ─────────────────────────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
interface CodexUsageContext {
|
|
114
|
-
session_id?: string;
|
|
115
|
-
cwd?: string;
|
|
116
|
-
git_branch?: string;
|
|
117
|
-
session_model?: string;
|
|
118
|
-
collaboration_model?: string;
|
|
119
|
-
turn_model?: string;
|
|
120
|
-
discarding_partial_line?: boolean;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
interface CursorEntry {
|
|
124
|
-
offset: number;
|
|
125
|
-
mtime: number;
|
|
126
|
-
/** Runtime context needed to resume a Codex rollout from a byte offset. */
|
|
127
|
-
context?: CodexUsageContext;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
interface TelemetryCursor {
|
|
131
|
-
version: string;
|
|
132
|
-
files: Record<string, CursorEntry>;
|
|
133
|
-
/** Preserved for cursor compatibility with the desktop collector. */
|
|
134
|
-
codex_next_rollout?: string;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function emptyCursor(): TelemetryCursor {
|
|
138
|
-
return { version: "1", files: {} };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async function loadCursor(cursorPath: string): Promise<TelemetryCursor> {
|
|
142
|
-
try {
|
|
143
|
-
const raw = await fs.readFile(cursorPath, "utf-8");
|
|
144
|
-
const parsed = JSON.parse(raw) as Partial<TelemetryCursor>;
|
|
145
|
-
if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
|
|
146
|
-
return {
|
|
147
|
-
version: parsed.version ?? "1",
|
|
148
|
-
files: parsed.files as Record<string, CursorEntry>,
|
|
149
|
-
...(typeof parsed.codex_next_rollout === "string"
|
|
150
|
-
? { codex_next_rollout: parsed.codex_next_rollout }
|
|
151
|
-
: {}),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
} catch {
|
|
155
|
-
// Missing / unparseable — start fresh.
|
|
156
|
-
}
|
|
157
|
-
return emptyCursor();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function saveCursor(cursorPath: string, cursor: TelemetryCursor): Promise<void> {
|
|
161
|
-
// Atomic write: tmp + rename. The Rust impl uses the same .tmp suffix; we
|
|
162
|
-
// keep it for cross-implementation grep-ability.
|
|
163
|
-
await fs.mkdir(path.dirname(cursorPath), { recursive: true });
|
|
164
|
-
const tmp = `${cursorPath}.tmp`;
|
|
165
|
-
await fs.writeFile(tmp, JSON.stringify(cursor, null, 2), "utf-8");
|
|
166
|
-
await fs.rename(tmp, cursorPath);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ── Local opt-in fallback ─────────────────────────────────────────────────────
|
|
170
|
-
|
|
171
|
-
async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
|
|
172
|
-
return (await readLocalTelemetryPreference(menubarPath)) === true;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Tri-state read of the locally-stored consent.
|
|
177
|
-
*
|
|
178
|
-
* `~/.hq/menubar.json` → `telemetryEnabled` is written by the installer the
|
|
179
|
-
* moment the user answers the prompt, and that local write ALWAYS succeeds —
|
|
180
|
-
* unlike the paired server write, which fires before the person entity exists
|
|
181
|
-
* and 404s. So this file is frequently the ONLY durable record of the user's
|
|
182
|
-
* actual choice.
|
|
183
|
-
*
|
|
184
|
-
* `undefined` means absent / unreadable / not a boolean — "we hold no answer" —
|
|
185
|
-
* as distinct from `false`, which is a real opt-out. The self-heal path must
|
|
186
|
-
* never conflate the two: it replays an answer, it does not invent one.
|
|
187
|
-
*/
|
|
188
|
-
async function readLocalTelemetryPreference(
|
|
189
|
-
menubarPath: string,
|
|
190
|
-
): Promise<boolean | undefined> {
|
|
191
|
-
return (await readLocalConsentRecord(menubarPath)).enabled;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* The locally-cached consent plus the account it belongs to.
|
|
196
|
-
*
|
|
197
|
-
* `telemetryOptInPersonUid` binds the answer to the `prs_*` that gave it.
|
|
198
|
-
* `menubar.json` is a per-MACHINE file, so when two people sign in under the
|
|
199
|
-
* same OS user it holds whoever answered LAST — replaying it unconditionally
|
|
200
|
-
* would opt in an account that never consented. The binding is what makes the
|
|
201
|
-
* replay safe; an unbound (legacy) record cannot be proven to belong to the
|
|
202
|
-
* current caller and is therefore never replayed.
|
|
203
|
-
*/
|
|
204
|
-
async function readLocalConsentRecord(
|
|
205
|
-
menubarPath: string,
|
|
206
|
-
): Promise<{ enabled?: boolean; personUid?: string }> {
|
|
207
|
-
try {
|
|
208
|
-
const raw = await fs.readFile(menubarPath, "utf-8");
|
|
209
|
-
const parsed = JSON.parse(raw) as {
|
|
210
|
-
telemetryEnabled?: unknown;
|
|
211
|
-
telemetryOptInPersonUid?: unknown;
|
|
212
|
-
};
|
|
213
|
-
return {
|
|
214
|
-
enabled:
|
|
215
|
-
typeof parsed.telemetryEnabled === "boolean" ? parsed.telemetryEnabled : undefined,
|
|
216
|
-
personUid:
|
|
217
|
-
typeof parsed.telemetryOptInPersonUid === "string" &&
|
|
218
|
-
parsed.telemetryOptInPersonUid.length > 0
|
|
219
|
-
? parsed.telemetryOptInPersonUid
|
|
220
|
-
: undefined,
|
|
221
|
-
};
|
|
222
|
-
} catch {
|
|
223
|
-
return {};
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ── Sanitizer ─────────────────────────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
/** Top-level fields the server accepts. Keep aligned with `KEEP_FIELDS` in
|
|
230
|
-
* `apps/hq-pro/src/vault-service/handlers/usage.ts` — any drift will surface
|
|
231
|
-
* as an `unexpected-event-field` rejection in `UsageIngestResult.skipped`. */
|
|
232
|
-
const KEEP_TOP_LEVEL = [
|
|
233
|
-
"sessionId",
|
|
234
|
-
"timestamp",
|
|
235
|
-
"uuid",
|
|
236
|
-
"cwd",
|
|
237
|
-
"gitBranch",
|
|
238
|
-
"userType",
|
|
239
|
-
] as const;
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* MCP tool_use blocks name their tool `mcp__<server>__<tool>` in a Claude Code
|
|
243
|
-
* session row's `message.content[]`. This recovers the SERVER segment only —
|
|
244
|
-
* the server/connector name (e.g. `slack`, `figma`). The tool segment and the
|
|
245
|
-
* block's `input` (args/URLs/payloads) are deliberately NEVER read.
|
|
246
|
-
*
|
|
247
|
-
* The `__` (double underscore) is the server↔tool delimiter, so the server is
|
|
248
|
-
* everything between the `mcp__` prefix and the LAST `__`. Using `lastIndexOf`
|
|
249
|
-
* (rather than a first-`__` split or a single-underscore-only regex) preserves
|
|
250
|
-
* legal underscores in server names — `_foo`, `foo_`, and `foo__bar` all parse
|
|
251
|
-
* correctly instead of being truncated or dropped. Returns `undefined` for a
|
|
252
|
-
* name that is not an `mcp__<server>__<tool>` with a non-empty server segment.
|
|
253
|
-
*/
|
|
254
|
-
const MCP_PREFIX = "mcp__";
|
|
255
|
-
function mcpServerName(toolName: string): string | undefined {
|
|
256
|
-
if (!toolName.startsWith(MCP_PREFIX)) return undefined;
|
|
257
|
-
const rest = toolName.slice(MCP_PREFIX.length);
|
|
258
|
-
const delim = rest.lastIndexOf("__");
|
|
259
|
-
if (delim <= 0) return undefined; // no tool segment, or empty server
|
|
260
|
-
return rest.slice(0, delim);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* SECURITY-CRITICAL (owner-telemetry-v2 US-007). Extract the MCP servers /
|
|
265
|
-
* connectors / integrations a session row touched as a NAMES-AND-COUNTS-ONLY map
|
|
266
|
-
* `{ <serverName>: <count> }` — e.g. `{ slack: 2, figma: 1 }`.
|
|
267
|
-
*
|
|
268
|
-
* This mirrors the `hasArgs` redaction precedent in skill-telemetry.ts: we look
|
|
269
|
-
* ONLY at the tool-use block's `name` (to recover the server segment of
|
|
270
|
-
* `mcp__<server>__<tool>`) and count occurrences. We NEVER read the block's
|
|
271
|
-
* `input` (which carries the args/URLs/payloads), never emit the tool name, and
|
|
272
|
-
* never emit any argument text. So no args, URLs, payloads, or secrets can reach
|
|
273
|
-
* the wire — only a service name and how many times it was invoked in this row.
|
|
274
|
-
*
|
|
275
|
-
* Returns `undefined` when the row touched no MCP server, so the caller omits the
|
|
276
|
-
* `services` field entirely (backward-compatible: old-shaped rows carry nothing
|
|
277
|
-
* new, and the server treats absence as "no services").
|
|
278
|
-
*/
|
|
279
|
-
export function extractServices(
|
|
280
|
-
message: unknown,
|
|
281
|
-
): Record<string, number> | undefined {
|
|
282
|
-
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
283
|
-
return undefined;
|
|
284
|
-
}
|
|
285
|
-
const content = (message as Record<string, unknown>).content;
|
|
286
|
-
if (!Array.isArray(content)) return undefined;
|
|
287
|
-
|
|
288
|
-
// Null-prototype map: server names come from untrusted session content and can
|
|
289
|
-
// legally be `constructor`, `toString`, etc. A plain `{}` would read those off
|
|
290
|
-
// Object.prototype, so `counts[server] ?? 0` would yield a function and the
|
|
291
|
-
// increment would produce a non-numeric count — poisoning the whole row (the
|
|
292
|
-
// backend then drops it as `invalid-services`, losing its token/session data).
|
|
293
|
-
const counts: Record<string, number> = Object.create(null);
|
|
294
|
-
for (const blk of content as unknown[]) {
|
|
295
|
-
if (!blk || typeof blk !== "object" || Array.isArray(blk)) continue;
|
|
296
|
-
const b = blk as Record<string, unknown>;
|
|
297
|
-
// Only tool_use blocks name an MCP tool. We read `name` and NOTHING ELSE —
|
|
298
|
-
// the block's `input` (args/URLs/payloads) is never touched.
|
|
299
|
-
if (b.type !== "tool_use" || typeof b.name !== "string") continue;
|
|
300
|
-
const server = mcpServerName(b.name);
|
|
301
|
-
if (!server) continue;
|
|
302
|
-
counts[server] = (counts[server] ?? 0) + 1;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return Object.keys(counts).length > 0 ? counts : undefined;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Build an outgoing event row matching the server's KEEP allowlist.
|
|
310
|
-
*
|
|
311
|
-
* Two transforms:
|
|
312
|
-
* 1. Top-level fields are copied straight through (string identity).
|
|
313
|
-
* 2. `message.model` and `message.usage.{input_tokens, output_tokens,
|
|
314
|
-
* cache_creation_input_tokens, cache_read_input_tokens}` are promoted to
|
|
315
|
-
* camelCase top-level fields. The original `message` object — which
|
|
316
|
-
* carries prompt/response text, thinking, and tool data — is dropped.
|
|
317
|
-
*
|
|
318
|
-
* `services` (US-007): the MCP servers / connectors / integrations this row
|
|
319
|
-
* touched, as a names-and-counts-only map (see `extractServices`). NAMES + COUNTS
|
|
320
|
-
* ONLY — never args/URLs/payloads/secrets, matching the `hasArgs` precedent.
|
|
321
|
-
* Omitted when the row touched no MCP server (the server treats absence as none).
|
|
322
|
-
*
|
|
323
|
-
* `companyUid` (US-002): when the caller has resolved the row's `cwd` to an
|
|
324
|
-
* owning company (`resolveCompanyForCwd`), it passes that `cmp_*` uid here and
|
|
325
|
-
* it is stamped on the wire row. It is on the server's KEEP allowlist
|
|
326
|
-
* (`apps/hq-pro/src/vault-service/handlers/usage.ts`). When `companyUid` is
|
|
327
|
-
* undefined (cwd maps to no company repo) the field is OMITTED — the server
|
|
328
|
-
* treats absence as unattributed/personal. The reserved value `unattributed`
|
|
329
|
-
* is never produced (it can only come from a resolved manifest `cmp_*` uid).
|
|
330
|
-
*
|
|
331
|
-
* Returns `null` when the input isn't an object. Empty results (e.g. a row
|
|
332
|
-
* with no recognised fields) are still returned as `{}` and emitted; the
|
|
333
|
-
* server accepts empty rows and they're useful as a "Claude Code was run at
|
|
334
|
-
* this time" heartbeat.
|
|
335
|
-
*/
|
|
336
|
-
export function sanitizeRow(
|
|
337
|
-
row: unknown,
|
|
338
|
-
companyUid?: string,
|
|
339
|
-
): Record<string, unknown> | null {
|
|
340
|
-
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
341
|
-
const obj = row as Record<string, unknown>;
|
|
342
|
-
const out: Record<string, unknown> = {};
|
|
343
|
-
|
|
344
|
-
for (const key of KEEP_TOP_LEVEL) {
|
|
345
|
-
if (key in obj) {
|
|
346
|
-
out[key] = obj[key];
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Stamp the resolved company attribution. Omit when unresolved so the server
|
|
351
|
-
// reads it as unattributed/personal. Never the reserved `unattributed`.
|
|
352
|
-
if (companyUid !== undefined) {
|
|
353
|
-
out.companyUid = companyUid;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const message = obj.message;
|
|
357
|
-
if (message && typeof message === "object" && !Array.isArray(message)) {
|
|
358
|
-
const m = message as Record<string, unknown>;
|
|
359
|
-
if ("model" in m) out.model = m.model;
|
|
360
|
-
const usage = m.usage;
|
|
361
|
-
if (usage && typeof usage === "object" && !Array.isArray(usage)) {
|
|
362
|
-
const u = usage as Record<string, unknown>;
|
|
363
|
-
if ("input_tokens" in u) out.inputTokens = u.input_tokens;
|
|
364
|
-
if ("output_tokens" in u) out.outputTokens = u.output_tokens;
|
|
365
|
-
if ("cache_creation_input_tokens" in u) {
|
|
366
|
-
out.cacheCreationInputTokens = u.cache_creation_input_tokens;
|
|
367
|
-
}
|
|
368
|
-
if ("cache_read_input_tokens" in u) {
|
|
369
|
-
out.cacheReadInputTokens = u.cache_read_input_tokens;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// services/integrations touched (US-007). Names + counts only — extracted from
|
|
375
|
-
// the message's tool_use block NAMES, never their inputs. Omitted when none, so
|
|
376
|
-
// the wire row is unchanged for rows that touched no MCP server.
|
|
377
|
-
const services = extractServices(message);
|
|
378
|
-
if (services !== undefined) {
|
|
379
|
-
out.services = services;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
return out;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// ── File walker ───────────────────────────────────────────────────────────────
|
|
386
|
-
|
|
387
|
-
/** Recursively collect every `.jsonl` file under `root`. Skips errors silently
|
|
388
|
-
* (missing dir, EACCES on a stray subdir) — anything we can't enter is
|
|
389
|
-
* treated as absent rather than fatal, matching the Rust glob behavior. */
|
|
390
|
-
async function listJsonlFiles(root: string): Promise<string[]> {
|
|
391
|
-
const out: string[] = [];
|
|
392
|
-
async function walk(dir: string): Promise<void> {
|
|
393
|
-
let entries;
|
|
394
|
-
try {
|
|
395
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
396
|
-
} catch {
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
for (const ent of entries) {
|
|
400
|
-
const full = path.join(dir, ent.name);
|
|
401
|
-
if (ent.isDirectory()) {
|
|
402
|
-
await walk(full);
|
|
403
|
-
} else if (ent.isFile() && ent.name.endsWith(".jsonl")) {
|
|
404
|
-
out.push(full);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
await walk(root);
|
|
409
|
-
return out;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// ── Codex rollout adapter ────────────────────────────────────────────────────
|
|
413
|
-
|
|
414
|
-
const MAX_ID_BYTES = 256;
|
|
415
|
-
const MAX_TIMESTAMP_BYTES = 128;
|
|
416
|
-
const MAX_PATH_BYTES = 4 * 1024;
|
|
417
|
-
const MAX_MODEL_BYTES = 256;
|
|
418
|
-
|
|
419
|
-
function boundedString(value: unknown, maxBytes: number): string | undefined {
|
|
420
|
-
return typeof value === "string" &&
|
|
421
|
-
value.length > 0 &&
|
|
422
|
-
Buffer.byteLength(value, "utf-8") <= maxBytes
|
|
423
|
-
? value
|
|
424
|
-
: undefined;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function tokenCount(value: unknown): number {
|
|
428
|
-
return typeof value === "number" &&
|
|
429
|
-
Number.isSafeInteger(value) &&
|
|
430
|
-
value >= 0
|
|
431
|
-
? value
|
|
432
|
-
: 0;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function cloneCodexContext(context: CodexUsageContext): CodexUsageContext {
|
|
436
|
-
return { ...context };
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function codexModel(context: CodexUsageContext): string | undefined {
|
|
440
|
-
return context.turn_model ?? context.collaboration_model ?? context.session_model;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function codexGitBranch(payload: Record<string, unknown>): string | undefined {
|
|
444
|
-
const direct =
|
|
445
|
-
boundedString(payload.gitBranch, MAX_PATH_BYTES) ??
|
|
446
|
-
boundedString(payload.git_branch, MAX_PATH_BYTES);
|
|
447
|
-
if (direct) return direct;
|
|
448
|
-
const git = payload.git;
|
|
449
|
-
return git && typeof git === "object" && !Array.isArray(git)
|
|
450
|
-
? boundedString((git as Record<string, unknown>).branch, MAX_PATH_BYTES)
|
|
451
|
-
: undefined;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function collaborationModel(
|
|
455
|
-
payload: Record<string, unknown>,
|
|
456
|
-
): string | undefined {
|
|
457
|
-
const settings = payload.settings;
|
|
458
|
-
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
459
|
-
return undefined;
|
|
460
|
-
}
|
|
461
|
-
return boundedString((settings as Record<string, unknown>).model, MAX_MODEL_BYTES);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function stableCodexEventId(
|
|
465
|
-
rolloutIdentity: string,
|
|
466
|
-
startOffset: number,
|
|
467
|
-
endOffset: number,
|
|
468
|
-
): string {
|
|
469
|
-
const offsets = Buffer.alloc(16);
|
|
470
|
-
offsets.writeBigUInt64BE(BigInt(startOffset), 0);
|
|
471
|
-
offsets.writeBigUInt64BE(BigInt(endOffset), 8);
|
|
472
|
-
return "codex-" + createHash("sha256")
|
|
473
|
-
.update(rolloutIdentity)
|
|
474
|
-
.update(Buffer.from([0]))
|
|
475
|
-
.update(offsets)
|
|
476
|
-
.digest("hex");
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** Adapt a Codex rollout record to the Claude-shaped sanitizer input. */
|
|
480
|
-
function codexUsageRow(
|
|
481
|
-
row: unknown,
|
|
482
|
-
context: CodexUsageContext,
|
|
483
|
-
rolloutIdentity: string,
|
|
484
|
-
startOffset: number,
|
|
485
|
-
endOffset: number,
|
|
486
|
-
): Record<string, unknown> | null {
|
|
487
|
-
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
488
|
-
const obj = row as Record<string, unknown>;
|
|
489
|
-
const kind = obj.type;
|
|
490
|
-
const rawPayload = obj.payload;
|
|
491
|
-
const payload =
|
|
492
|
-
rawPayload && typeof rawPayload === "object" && !Array.isArray(rawPayload)
|
|
493
|
-
? (rawPayload as Record<string, unknown>)
|
|
494
|
-
: undefined;
|
|
495
|
-
|
|
496
|
-
if (kind === "session_meta") {
|
|
497
|
-
if (!payload) return null;
|
|
498
|
-
context.session_id = boundedString(payload.id, MAX_ID_BYTES) ?? context.session_id;
|
|
499
|
-
context.cwd = boundedString(payload.cwd, MAX_PATH_BYTES);
|
|
500
|
-
context.git_branch = codexGitBranch(payload);
|
|
501
|
-
context.session_model = boundedString(payload.model, MAX_MODEL_BYTES);
|
|
502
|
-
return null;
|
|
503
|
-
}
|
|
504
|
-
if (kind === "turn_context") {
|
|
505
|
-
context.turn_model = payload
|
|
506
|
-
? boundedString(payload.model, MAX_MODEL_BYTES)
|
|
507
|
-
: undefined;
|
|
508
|
-
const mode = payload?.collaboration_mode;
|
|
509
|
-
if (mode && typeof mode === "object" && !Array.isArray(mode)) {
|
|
510
|
-
const model = collaborationModel(mode as Record<string, unknown>);
|
|
511
|
-
if (model) context.collaboration_model = model;
|
|
512
|
-
}
|
|
513
|
-
return null;
|
|
514
|
-
}
|
|
515
|
-
if (kind === "collaboration_mode") {
|
|
516
|
-
context.collaboration_model = payload
|
|
517
|
-
? collaborationModel(payload)
|
|
518
|
-
: undefined;
|
|
519
|
-
return null;
|
|
520
|
-
}
|
|
521
|
-
if (kind !== "event_msg" || !payload) return null;
|
|
522
|
-
if (payload.type === "collaboration_mode") {
|
|
523
|
-
context.collaboration_model = collaborationModel(payload);
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
if (payload.type !== "token_count") return null;
|
|
527
|
-
|
|
528
|
-
const info = payload.info;
|
|
529
|
-
if (!info || typeof info !== "object" || Array.isArray(info)) return null;
|
|
530
|
-
const last = (info as Record<string, unknown>).last_token_usage;
|
|
531
|
-
if (!last || typeof last !== "object" || Array.isArray(last)) return null;
|
|
532
|
-
const usage = last as Record<string, unknown>;
|
|
533
|
-
const outputTokens =
|
|
534
|
-
tokenCount(usage.output_tokens) + tokenCount(usage.reasoning_output_tokens);
|
|
535
|
-
const model = codexModel(context);
|
|
536
|
-
const normalized: Record<string, unknown> = {
|
|
537
|
-
uuid:
|
|
538
|
-
boundedString(obj.uuid, MAX_ID_BYTES) ??
|
|
539
|
-
stableCodexEventId(rolloutIdentity, startOffset, endOffset),
|
|
540
|
-
message: {
|
|
541
|
-
usage: {
|
|
542
|
-
input_tokens: tokenCount(usage.input_tokens),
|
|
543
|
-
output_tokens: outputTokens,
|
|
544
|
-
...(typeof usage.cached_input_tokens === "number"
|
|
545
|
-
? { cache_read_input_tokens: tokenCount(usage.cached_input_tokens) }
|
|
546
|
-
: {}),
|
|
547
|
-
},
|
|
548
|
-
...(model ? { model } : {}),
|
|
549
|
-
},
|
|
550
|
-
};
|
|
551
|
-
if (context.session_id) normalized.sessionId = context.session_id;
|
|
552
|
-
const timestamp = boundedString(obj.timestamp, MAX_TIMESTAMP_BYTES);
|
|
553
|
-
if (timestamp) normalized.timestamp = timestamp;
|
|
554
|
-
if (context.cwd) normalized.cwd = context.cwd;
|
|
555
|
-
if (context.git_branch) normalized.gitBranch = context.git_branch;
|
|
556
|
-
return normalized;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
async function sortFilesFreshestFirst(files: string[]): Promise<string[]> {
|
|
560
|
-
const ranked = await Promise.all(
|
|
561
|
-
files.map(async (filePath) => {
|
|
562
|
-
try {
|
|
563
|
-
return { filePath, mtime: (await fs.stat(filePath)).mtimeMs };
|
|
564
|
-
} catch {
|
|
565
|
-
return { filePath, mtime: 0 };
|
|
566
|
-
}
|
|
567
|
-
}),
|
|
568
|
-
);
|
|
569
|
-
ranked.sort(
|
|
570
|
-
(left, right) =>
|
|
571
|
-
right.mtime - left.mtime || left.filePath.localeCompare(right.filePath),
|
|
572
|
-
);
|
|
573
|
-
return ranked.map(({ filePath }) => filePath);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
async function listCodexRolloutFiles(codexRoot: string): Promise<string[]> {
|
|
577
|
-
const [live, archived] = await Promise.all([
|
|
578
|
-
listJsonlFiles(path.join(codexRoot, "sessions")),
|
|
579
|
-
listJsonlFiles(path.join(codexRoot, "archived_sessions")),
|
|
580
|
-
]);
|
|
581
|
-
return sortFilesFreshestFirst(
|
|
582
|
-
[...live, ...archived].filter((file) =>
|
|
583
|
-
path.basename(file).startsWith("rollout-"),
|
|
584
|
-
),
|
|
585
|
-
);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// ── Batching primitives ───────────────────────────────────────────────────────
|
|
589
|
-
|
|
590
|
-
const MAX_BATCH_EVENTS = 100;
|
|
591
|
-
const MAX_BATCH_BYTES = 240 * 1024;
|
|
592
|
-
const MAX_SCAN_BYTES_PER_SOURCE = 4 * 1024 * 1024;
|
|
593
|
-
const MAX_BATCHES_PER_RUN = 4;
|
|
594
|
-
const MAX_PARTIAL_LINE_BYTES = 64 * 1024;
|
|
595
|
-
const ROW_TRUNCATION_SUFFIX = "...[truncated]";
|
|
596
|
-
|
|
597
|
-
interface RowSource {
|
|
598
|
-
filePath: string;
|
|
599
|
-
endOffset: number;
|
|
600
|
-
mtime: number;
|
|
601
|
-
context?: CodexUsageContext;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
/**
|
|
605
|
-
* Byte cost of the fixed wire-payload skeleton:
|
|
606
|
-
* {"machineId":"…","installerVersion":"…","events":[]}
|
|
607
|
-
*
|
|
608
|
-
* The Rust implementation re-serializes the entire growing batch on every
|
|
609
|
-
* row to check size — O(n²) bytes of JSON.stringify work per batch, which
|
|
610
|
-
* for 60K events takes ~4 minutes wall-clock in V8. This computes the same
|
|
611
|
-
* payload size incrementally instead: skeleton + Σ(per-row JSON length) +
|
|
612
|
-
* commas between rows. Same threshold semantics, O(n) total cost.
|
|
613
|
-
*/
|
|
614
|
-
function envelopeBytes(machineId: string, installerVersion: string): number {
|
|
615
|
-
// Serialize the empty-events envelope once and measure it. Captures the
|
|
616
|
-
// exact JSON whitespace / escaping V8 produces so we match what would
|
|
617
|
-
// actually go over the wire.
|
|
618
|
-
return Buffer.byteLength(
|
|
619
|
-
JSON.stringify({ machineId, installerVersion, events: [] }),
|
|
620
|
-
"utf-8",
|
|
621
|
-
);
|
|
622
|
-
}
|
|
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
|
-
}
|
|
666
|
-
|
|
667
|
-
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* Scan, sanitize, and POST new Claude Code and Codex usage rows.
|
|
671
|
-
*
|
|
672
|
-
* Fire-and-forget from the caller's perspective: errors are caught internally
|
|
673
|
-
* and surfaced only via `log`. The returned summary lets observers (e.g.
|
|
674
|
-
* sync-runner) decide whether to record a "telemetry attempted" breadcrumb,
|
|
675
|
-
* but no consumer is expected to react to it.
|
|
676
|
-
*/
|
|
677
|
-
export async function collectAndSendTelemetry(
|
|
678
|
-
opts: CollectTelemetryOptions,
|
|
679
|
-
): Promise<CollectTelemetryResult> {
|
|
680
|
-
const home = os.homedir();
|
|
681
|
-
const claudeProjectsRoot = opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
|
|
682
|
-
const codexRoot = opts.codexRoot ?? path.join(home, ".codex");
|
|
683
|
-
const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
|
|
684
|
-
const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
|
|
685
|
-
const log = opts.log ?? (() => {});
|
|
686
|
-
const maxScanBytesPerSource =
|
|
687
|
-
opts.maxScanBytesPerSource ?? MAX_SCAN_BYTES_PER_SOURCE;
|
|
688
|
-
const maxBatchesPerRun = opts.maxBatchesPerRun ?? MAX_BATCHES_PER_RUN;
|
|
689
|
-
|
|
690
|
-
// Company attribution (US-002): parse the manifest ONCE per run and reuse the
|
|
691
|
-
// repo-path→companyUid map for every event below. No per-event manifest read.
|
|
692
|
-
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
693
|
-
const repoCompanyMap: RepoCompanyMap = opts.hqRoot
|
|
694
|
-
? await buildRepoCompanyMap(opts.hqRoot)
|
|
695
|
-
: { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
|
|
696
|
-
const fallbackCompanyUid = opts.fallbackCompany?.startsWith("cmp_")
|
|
697
|
-
? opts.fallbackCompany
|
|
698
|
-
: opts.fallbackCompany
|
|
699
|
-
? repoCompanyMap.bySlug.get(opts.fallbackCompany)
|
|
700
|
-
: undefined;
|
|
701
|
-
|
|
702
|
-
// 1. Opt-in check (server-authoritative, with local fallback + self-heal).
|
|
703
|
-
let enabled: boolean;
|
|
704
|
-
let optInSource: CollectTelemetryResult["optInSource"];
|
|
705
|
-
try {
|
|
706
|
-
const resp = await opts.client.getTelemetryOptIn();
|
|
707
|
-
enabled = resp.enabled === true;
|
|
708
|
-
optInSource = "server";
|
|
709
|
-
|
|
710
|
-
// Self-heal a consent the user gave but that never reached the server.
|
|
711
|
-
//
|
|
712
|
-
// The installer writes the answer to `~/.hq/menubar.json` (always succeeds)
|
|
713
|
-
// AND posts it to `/v1/usage/opt-in` — but that post fires before the
|
|
714
|
-
// person entity exists, so it 404s and the attribute is never written.
|
|
715
|
-
// Absence then reads as `false`, the emitter goes silent, and the person
|
|
716
|
-
// shows as "not opted in" forever. Measured: 22 of 33 active Indigo members
|
|
717
|
-
// had no attribute at all, against only 2 genuine opt-outs.
|
|
718
|
-
//
|
|
719
|
-
// `unset` (never answered) is the ONLY state we heal. An explicit server
|
|
720
|
-
// `false` is a real opt-out and is left strictly alone. We also require a
|
|
721
|
-
// local answer to actually exist — we never invent consent, we only replay
|
|
722
|
-
// the answer the user already gave.
|
|
723
|
-
//
|
|
724
|
-
// And it must be THIS account's answer. `menubar.json` is per-MACHINE, so
|
|
725
|
-
// when two people sign in under the same OS user it holds whoever answered
|
|
726
|
-
// last; replaying that for the second account would opt in someone who
|
|
727
|
-
// never consented. The replay therefore requires the cached record to name
|
|
728
|
-
// the same `prs_*` the server says we are. A legacy record with no binding
|
|
729
|
-
// cannot be proven to belong to this caller and is skipped — for those
|
|
730
|
-
// machines the installer's own post-sign-in upload is the recovery path,
|
|
731
|
-
// and it is correctly account-scoped because it runs right after that
|
|
732
|
-
// person authenticates.
|
|
733
|
-
if (resp.unset === true && opts.client.setTelemetryOptIn) {
|
|
734
|
-
const record = await readLocalConsentRecord(menubarPath);
|
|
735
|
-
const local = record.enabled;
|
|
736
|
-
const boundToCaller =
|
|
737
|
-
record.personUid !== undefined &&
|
|
738
|
-
resp.personUid !== undefined &&
|
|
739
|
-
record.personUid === resp.personUid;
|
|
740
|
-
if (local !== undefined && !boundToCaller) {
|
|
741
|
-
log(
|
|
742
|
-
"[telemetry] skipping consent re-assert: the locally cached answer is not bound to the signed-in account",
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
if (local !== undefined && boundToCaller) {
|
|
746
|
-
try {
|
|
747
|
-
// `onlyIfUnset` keeps the replay atomic. Reading `unset` and writing
|
|
748
|
-
// are two requests, so without it another device could record a real
|
|
749
|
-
// opt-out in between and this stale local answer would overwrite it.
|
|
750
|
-
// The server tests "still unset?" as part of the write instead.
|
|
751
|
-
const ack = await opts.client.setTelemetryOptIn(local, { onlyIfUnset: true });
|
|
752
|
-
if (ack && ack.applied === false) {
|
|
753
|
-
// Lost the race — a real answer was recorded first and it stands.
|
|
754
|
-
// Keep the server's answer for this run rather than the local one.
|
|
755
|
-
log(
|
|
756
|
-
"[telemetry] consent was recorded elsewhere before the re-assert landed; deferring to the server",
|
|
757
|
-
);
|
|
758
|
-
} else {
|
|
759
|
-
enabled = local;
|
|
760
|
-
optInSource = "menubar-reasserted";
|
|
761
|
-
log(
|
|
762
|
-
`[telemetry] server had no recorded consent; re-asserted the local install-time answer (enabled=${local})`,
|
|
763
|
-
);
|
|
764
|
-
}
|
|
765
|
-
} catch (err) {
|
|
766
|
-
// Non-fatal: fall through on the server's answer. Next run retries.
|
|
767
|
-
log(
|
|
768
|
-
`[telemetry] failed to re-assert local opt-in (${(err as Error).message ?? err})`,
|
|
769
|
-
);
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
} catch (err) {
|
|
774
|
-
log(`[telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
|
|
775
|
-
enabled = await readLocalTelemetryEnabled(menubarPath);
|
|
776
|
-
optInSource = "menubar-fallback";
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
if (!enabled) {
|
|
780
|
-
return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// 2. Cursor + file enumeration.
|
|
784
|
-
const cursor = await loadCursor(cursorPath);
|
|
785
|
-
const loadedFiles = { ...cursor.files };
|
|
786
|
-
const rotationResets: Record<string, CursorEntry> = {};
|
|
787
|
-
const newlyCommitted: Record<string, CursorEntry> = {};
|
|
788
|
-
|
|
789
|
-
const [claudeFilesUnsorted, codexFiles] = await Promise.all([
|
|
790
|
-
listJsonlFiles(claudeProjectsRoot),
|
|
791
|
-
listCodexRolloutFiles(codexRoot),
|
|
792
|
-
]);
|
|
793
|
-
const claudeFiles = await sortFilesFreshestFirst(claudeFilesUnsorted);
|
|
794
|
-
// Codex goes first so a fresh Linux install cannot spend its entire bounded
|
|
795
|
-
// pass replaying an older Claude backlog before reporting current Codex use.
|
|
796
|
-
const files: Array<{ filePath: string; kind: "claude" | "codex" }> = [
|
|
797
|
-
...codexFiles.map((filePath) => ({ filePath, kind: "codex" as const })),
|
|
798
|
-
...claudeFiles.map((filePath) => ({ filePath, kind: "claude" as const })),
|
|
799
|
-
];
|
|
800
|
-
|
|
801
|
-
// 3. Walk each file, sanitize new rows, batch, flush at the server contract.
|
|
802
|
-
//
|
|
803
|
-
// Byte accounting is incremental: we track `batchBytes` as the projected
|
|
804
|
-
// serialized size of the current batch (envelope + per-row JSON + commas).
|
|
805
|
-
// Each row contributes its own JSON.stringify length once; we never
|
|
806
|
-
// re-serialize the growing batch. This is the O(n) replacement for the
|
|
807
|
-
// O(n²) projected-payload check the Rust impl uses (which spent ~4 min
|
|
808
|
-
// on a 60K-event first-run in the E2E smoke against hq-prod).
|
|
809
|
-
const ENVELOPE_BYTES = envelopeBytes(opts.machineId, opts.installerVersion);
|
|
810
|
-
let batchEvents: Array<Record<string, unknown>> = [];
|
|
811
|
-
let batchSources: RowSource[] = [];
|
|
812
|
-
let batchBytes = ENVELOPE_BYTES;
|
|
813
|
-
let eventsSent = 0;
|
|
814
|
-
let batchesSent = 0;
|
|
815
|
-
let uploadFailed = false;
|
|
816
|
-
let batchLimitReached = false;
|
|
817
|
-
const scannedBytes = { claude: 0, codex: 0 };
|
|
818
|
-
|
|
819
|
-
const commitSources = (sources: RowSource[]): void => {
|
|
820
|
-
const maxPerFile = new Map<
|
|
821
|
-
string,
|
|
822
|
-
{ mtime: number; offset: number; context?: CodexUsageContext }
|
|
823
|
-
>();
|
|
824
|
-
for (const source of sources) {
|
|
825
|
-
const current = maxPerFile.get(source.filePath);
|
|
826
|
-
if (!current || source.endOffset > current.offset) {
|
|
827
|
-
maxPerFile.set(source.filePath, {
|
|
828
|
-
mtime: source.mtime,
|
|
829
|
-
offset: source.endOffset,
|
|
830
|
-
...(source.context
|
|
831
|
-
? { context: cloneCodexContext(source.context) }
|
|
832
|
-
: {}),
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
for (const [filePath, entry] of maxPerFile) {
|
|
837
|
-
const current = newlyCommitted[filePath];
|
|
838
|
-
if (!current || entry.offset > current.offset) {
|
|
839
|
-
newlyCommitted[filePath] = entry;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
};
|
|
843
|
-
|
|
844
|
-
const flush = async (): Promise<boolean> => {
|
|
845
|
-
const events = batchEvents;
|
|
846
|
-
const sources = batchSources;
|
|
847
|
-
batchEvents = [];
|
|
848
|
-
batchSources = [];
|
|
849
|
-
batchBytes = ENVELOPE_BYTES;
|
|
850
|
-
if (events.length === 0) {
|
|
851
|
-
commitSources(sources);
|
|
852
|
-
return true;
|
|
853
|
-
}
|
|
854
|
-
try {
|
|
855
|
-
await opts.client.postUsage({
|
|
856
|
-
machineId: opts.machineId,
|
|
857
|
-
installerVersion: opts.installerVersion,
|
|
858
|
-
events,
|
|
859
|
-
});
|
|
860
|
-
batchesSent++;
|
|
861
|
-
eventsSent += events.length;
|
|
862
|
-
commitSources(sources);
|
|
863
|
-
return true;
|
|
864
|
-
} catch (err) {
|
|
865
|
-
log(
|
|
866
|
-
"[telemetry] postUsage failed (" +
|
|
867
|
-
((err as Error).message ?? err) +
|
|
868
|
-
") — cursor not advanced for " +
|
|
869
|
-
sources.length +
|
|
870
|
-
" rows",
|
|
871
|
-
);
|
|
872
|
-
return false;
|
|
873
|
-
}
|
|
874
|
-
};
|
|
875
|
-
|
|
876
|
-
filesLoop: for (const { filePath, kind } of files) {
|
|
877
|
-
if (uploadFailed || batchLimitReached) break;
|
|
878
|
-
const sourceBudgetRemaining =
|
|
879
|
-
maxScanBytesPerSource - scannedBytes[kind];
|
|
880
|
-
if (sourceBudgetRemaining <= 0) continue;
|
|
881
|
-
|
|
882
|
-
let stat;
|
|
883
|
-
try {
|
|
884
|
-
stat = await fs.stat(filePath);
|
|
885
|
-
} catch {
|
|
886
|
-
continue;
|
|
887
|
-
}
|
|
888
|
-
const currentSize = stat.size;
|
|
889
|
-
const currentMtime = Math.floor(stat.mtimeMs / 1000);
|
|
890
|
-
const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
|
|
891
|
-
let offset = stored.offset;
|
|
892
|
-
let codexContext: CodexUsageContext | undefined =
|
|
893
|
-
kind === "codex" ? cloneCodexContext(stored.context ?? {}) : undefined;
|
|
894
|
-
const rotated =
|
|
895
|
-
currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
|
|
896
|
-
if (rotated) {
|
|
897
|
-
offset = 0;
|
|
898
|
-
if (kind === "codex") codexContext = {};
|
|
899
|
-
rotationResets[filePath] = { offset: 0, mtime: currentMtime };
|
|
900
|
-
}
|
|
901
|
-
if (offset >= currentSize && !rotated) continue;
|
|
902
|
-
|
|
903
|
-
let content: string;
|
|
904
|
-
let bytesRead = 0;
|
|
905
|
-
let readBuffer = Buffer.alloc(0);
|
|
906
|
-
try {
|
|
907
|
-
const fileHandle = await fs.open(filePath, "r");
|
|
908
|
-
try {
|
|
909
|
-
const length = Math.max(
|
|
910
|
-
0,
|
|
911
|
-
Math.min(currentSize - offset, sourceBudgetRemaining),
|
|
912
|
-
);
|
|
913
|
-
readBuffer = Buffer.alloc(length);
|
|
914
|
-
({ bytesRead } = await fileHandle.read(
|
|
915
|
-
readBuffer,
|
|
916
|
-
0,
|
|
917
|
-
length,
|
|
918
|
-
offset,
|
|
919
|
-
));
|
|
920
|
-
content = readBuffer.toString("utf-8", 0, bytesRead);
|
|
921
|
-
} finally {
|
|
922
|
-
await fileHandle.close();
|
|
923
|
-
}
|
|
924
|
-
} catch {
|
|
925
|
-
continue;
|
|
926
|
-
}
|
|
927
|
-
if (bytesRead === 0) continue;
|
|
928
|
-
|
|
929
|
-
// Resume a previously discarded oversized Codex row using raw byte
|
|
930
|
-
// positions. Decoding from the middle of a multibyte UTF-8 codepoint would
|
|
931
|
-
// otherwise skew byte offsets and could skip or replay later rows.
|
|
932
|
-
if (codexContext?.discarding_partial_line) {
|
|
933
|
-
const newlineIndex = readBuffer.subarray(0, bytesRead).indexOf(0x0a);
|
|
934
|
-
const discardedBytes = newlineIndex >= 0 ? newlineIndex + 1 : bytesRead;
|
|
935
|
-
codexContext.discarding_partial_line = newlineIndex < 0;
|
|
936
|
-
scannedBytes[kind] += discardedBytes;
|
|
937
|
-
batchSources.push({
|
|
938
|
-
filePath,
|
|
939
|
-
endOffset: offset + discardedBytes,
|
|
940
|
-
mtime: currentMtime,
|
|
941
|
-
context: cloneCodexContext(codexContext),
|
|
942
|
-
});
|
|
943
|
-
if (newlineIndex < 0) continue;
|
|
944
|
-
offset += discardedBytes;
|
|
945
|
-
bytesRead -= discardedBytes;
|
|
946
|
-
readBuffer = readBuffer.subarray(discardedBytes);
|
|
947
|
-
content = readBuffer.toString("utf-8", 0, bytesRead);
|
|
948
|
-
if (bytesRead === 0) continue;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
const reachedPhysicalEof = offset + bytesRead >= currentSize;
|
|
952
|
-
const segments = content.split("\n");
|
|
953
|
-
const lineEndOffsets: number[] = [];
|
|
954
|
-
let cumulative = 0;
|
|
955
|
-
for (let i = 0; i < segments.length; i++) {
|
|
956
|
-
cumulative += Buffer.byteLength(segments[i], "utf-8");
|
|
957
|
-
if (i < segments.length - 1) cumulative += 1;
|
|
958
|
-
lineEndOffsets.push(offset + cumulative);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
for (let i = 0; i < segments.length; i++) {
|
|
962
|
-
const startOffset = i === 0 ? offset : lineEndOffsets[i - 1];
|
|
963
|
-
const hasNewline = i < segments.length - 1;
|
|
964
|
-
const endOffset =
|
|
965
|
-
!hasNewline && !reachedPhysicalEof
|
|
966
|
-
? offset + bytesRead
|
|
967
|
-
: lineEndOffsets[i];
|
|
968
|
-
const lineBytes = endOffset - startOffset;
|
|
969
|
-
if (!hasNewline && !reachedPhysicalEof) {
|
|
970
|
-
const discarding = codexContext?.discarding_partial_line === true;
|
|
971
|
-
if (discarding || lineBytes >= MAX_PARTIAL_LINE_BYTES) {
|
|
972
|
-
if (codexContext) codexContext.discarding_partial_line = true;
|
|
973
|
-
scannedBytes[kind] += lineBytes;
|
|
974
|
-
batchSources.push({
|
|
975
|
-
filePath,
|
|
976
|
-
endOffset,
|
|
977
|
-
mtime: currentMtime,
|
|
978
|
-
...(codexContext
|
|
979
|
-
? { context: cloneCodexContext(codexContext) }
|
|
980
|
-
: {}),
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
break;
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
scannedBytes[kind] += lineBytes;
|
|
987
|
-
const source = (): RowSource => ({
|
|
988
|
-
filePath,
|
|
989
|
-
endOffset,
|
|
990
|
-
mtime: currentMtime,
|
|
991
|
-
...(codexContext
|
|
992
|
-
? { context: cloneCodexContext(codexContext) }
|
|
993
|
-
: {}),
|
|
994
|
-
});
|
|
995
|
-
if (codexContext?.discarding_partial_line) {
|
|
996
|
-
codexContext.discarding_partial_line = false;
|
|
997
|
-
batchSources.push(source());
|
|
998
|
-
continue;
|
|
999
|
-
}
|
|
1000
|
-
const trimmed = segments[i].trim();
|
|
1001
|
-
if (trimmed.length === 0) {
|
|
1002
|
-
batchSources.push(source());
|
|
1003
|
-
continue;
|
|
1004
|
-
}
|
|
1005
|
-
let parsed: unknown;
|
|
1006
|
-
try {
|
|
1007
|
-
parsed = JSON.parse(trimmed);
|
|
1008
|
-
} catch {
|
|
1009
|
-
batchSources.push(source());
|
|
1010
|
-
continue;
|
|
1011
|
-
}
|
|
1012
|
-
const sourceRow =
|
|
1013
|
-
kind === "codex"
|
|
1014
|
-
? codexUsageRow(
|
|
1015
|
-
parsed,
|
|
1016
|
-
codexContext!,
|
|
1017
|
-
filePath,
|
|
1018
|
-
startOffset,
|
|
1019
|
-
endOffset,
|
|
1020
|
-
)
|
|
1021
|
-
: parsed;
|
|
1022
|
-
const rowCwd =
|
|
1023
|
-
sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
|
|
1024
|
-
? (sourceRow as Record<string, unknown>).cwd
|
|
1025
|
-
: undefined;
|
|
1026
|
-
const companyUid =
|
|
1027
|
-
resolveCompanyForCwd(
|
|
1028
|
-
typeof rowCwd === "string" ? rowCwd : undefined,
|
|
1029
|
-
repoCompanyMap,
|
|
1030
|
-
) ?? fallbackCompanyUid;
|
|
1031
|
-
const sanitized = sanitizeRow(sourceRow, companyUid);
|
|
1032
|
-
if (!sanitized) {
|
|
1033
|
-
batchSources.push(source());
|
|
1034
|
-
continue;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
|
|
1038
|
-
const wasOversized = jsonBytes(sanitized) > maxRowBytes;
|
|
1039
|
-
const bounded = boundRowForPost(sanitized, maxRowBytes);
|
|
1040
|
-
if (!bounded) {
|
|
1041
|
-
log(
|
|
1042
|
-
"[telemetry] oversized row dropped before send (" +
|
|
1043
|
-
filePath +
|
|
1044
|
-
":" +
|
|
1045
|
-
(i + 1) +
|
|
1046
|
-
")",
|
|
1047
|
-
);
|
|
1048
|
-
batchSources.push(source());
|
|
1049
|
-
continue;
|
|
1050
|
-
}
|
|
1051
|
-
if (wasOversized) {
|
|
1052
|
-
log(
|
|
1053
|
-
"[telemetry] oversized row truncated before send (" +
|
|
1054
|
-
filePath +
|
|
1055
|
-
":" +
|
|
1056
|
-
(i + 1) +
|
|
1057
|
-
")",
|
|
1058
|
-
);
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
const rowJsonBytes = jsonBytes(bounded);
|
|
1062
|
-
const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
|
|
1063
|
-
if (
|
|
1064
|
-
batchEvents.length > 0 &&
|
|
1065
|
-
(batchEvents.length >= MAX_BATCH_EVENTS ||
|
|
1066
|
-
batchBytes + addCost > MAX_BATCH_BYTES)
|
|
1067
|
-
) {
|
|
1068
|
-
if (!(await flush())) {
|
|
1069
|
-
uploadFailed = true;
|
|
1070
|
-
break filesLoop;
|
|
1071
|
-
}
|
|
1072
|
-
if (batchesSent >= maxBatchesPerRun) {
|
|
1073
|
-
batchLimitReached = true;
|
|
1074
|
-
break filesLoop;
|
|
1075
|
-
}
|
|
1076
|
-
batchBytes = ENVELOPE_BYTES + rowJsonBytes;
|
|
1077
|
-
} else {
|
|
1078
|
-
batchBytes += addCost;
|
|
1079
|
-
}
|
|
1080
|
-
batchEvents.push(bounded);
|
|
1081
|
-
batchSources.push(source());
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
if (!uploadFailed && !batchLimitReached) {
|
|
1086
|
-
uploadFailed = !(await flush());
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// 4. Persist cursor: loaded < rotation_resets < newly_committed.
|
|
1090
|
-
const finalFiles: Record<string, CursorEntry> = { ...loadedFiles };
|
|
1091
|
-
for (const [fp, entry] of Object.entries(rotationResets)) finalFiles[fp] = entry;
|
|
1092
|
-
for (const [fp, entry] of Object.entries(newlyCommitted)) finalFiles[fp] = entry;
|
|
1093
|
-
|
|
1094
|
-
await saveCursor(cursorPath, {
|
|
1095
|
-
version: "1",
|
|
1096
|
-
files: finalFiles,
|
|
1097
|
-
...(cursor.codex_next_rollout
|
|
1098
|
-
? { codex_next_rollout: cursor.codex_next_rollout }
|
|
1099
|
-
: {}),
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
return {
|
|
1103
|
-
enabled: true,
|
|
1104
|
-
optInSource,
|
|
1105
|
-
filesScanned: files.length,
|
|
1106
|
-
eventsSent,
|
|
1107
|
-
batchesSent,
|
|
1108
|
-
};
|
|
1109
|
-
}
|