@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/outcome-telemetry.ts
DELETED
|
@@ -1,639 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Outcome-event collector — story-completion + project-shipped emitter
|
|
3
|
-
* (outcome-leaderboard US-004).
|
|
4
|
-
*
|
|
5
|
-
* Sibling to `./telemetry.ts` (token usage) and `./skill-telemetry.ts` (skill
|
|
6
|
-
* invocations). Where those diff `~/.claude/projects/**\/*.jsonl` session logs,
|
|
7
|
-
* this one diffs HQ PROJECT STATE against a persisted cursor at
|
|
8
|
-
* `~/.hq/outcome-telemetry-cursor.json`:
|
|
9
|
-
*
|
|
10
|
-
* - `prd.json` `userStories[].passes` flipping `false → true`
|
|
11
|
-
* → a `story-completed` outcome event.
|
|
12
|
-
* - `board.json` project `status` transitioning to a DONE status
|
|
13
|
-
* → a `project-shipped` outcome event.
|
|
14
|
-
*
|
|
15
|
-
* After each successful sync (the `all-complete` arm of `bin/sync-runner.ts`,
|
|
16
|
-
* via `defaultCollectTelemetry`), it walks `<hqRoot>/companies/*` for those two
|
|
17
|
-
* files, compares each transition-eligible value against the cursor, and POSTs
|
|
18
|
-
* new transitions to `/v1/outcome-events`. The cursor is only advanced for the
|
|
19
|
-
* transitions the server 2xx'd, so a transient outage retries next sync.
|
|
20
|
-
*
|
|
21
|
-
* Trust model (identical to `./telemetry.ts`): the caller's `personUid` is
|
|
22
|
-
* resolved SERVER-side from the Cognito JWT — never from the body. The wire row
|
|
23
|
-
* carries ONLY the outcome type, the ISO-8601 `occurredAt`, the resolved
|
|
24
|
-
* `companyUid`, `repo`/`branch` context, a `dedupeKey`, and the type-specific
|
|
25
|
-
* refs (`projectName`, and `storyId` for stories). NO prd/board file content
|
|
26
|
-
* beyond project name + story id + the status transition ever leaves the machine
|
|
27
|
-
* — enforced by the `toWireRow` allowlist, matching the server's KEEP_FIELDS in
|
|
28
|
-
* `apps/hq-pro/src/vault-service/handlers/outcome-events.ts`.
|
|
29
|
-
*
|
|
30
|
-
* companyUid resolution mirrors `./telemetry.ts`: the manifest at
|
|
31
|
-
* `<hqRoot>/companies/manifest.yaml` is parsed ONCE per run (`buildRepoCompanyMap`)
|
|
32
|
-
* and a project's owning company `cmp_*` uid is looked up by the `companies/<slug>`
|
|
33
|
-
* directory the prd/board lives under (`RepoCompanyMap.bySlug`). A project whose
|
|
34
|
-
* company is not cloud-backed resolves to no uid and is SKIPPED — an outcome event
|
|
35
|
-
* requires a company (the ingest handler makes companyUid a required field).
|
|
36
|
-
*
|
|
37
|
-
* dedupeKey (idempotency anchor — replays + multi-machine syncs never
|
|
38
|
-
* double-count):
|
|
39
|
-
* - story-completed → `<companyUid>#<projectName>#<storyId>`
|
|
40
|
-
* - project-shipped → `<companyUid>#<projectName>`
|
|
41
|
-
* The server's conditional PutItem collapses a re-synced event with the same
|
|
42
|
-
* dedupeKey to a single stored item.
|
|
43
|
-
*
|
|
44
|
-
* Errors are swallowed by design — an outcome collector must never abort or
|
|
45
|
-
* delay a sync (matches the existing collectors). The opt-in gate is the same
|
|
46
|
-
* `getTelemetryOptIn()` used by usage/skill telemetry.
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
import { promises as fs } from "node:fs";
|
|
50
|
-
import * as os from "node:os";
|
|
51
|
-
import * as path from "node:path";
|
|
52
|
-
|
|
53
|
-
import {
|
|
54
|
-
buildRepoCompanyMap,
|
|
55
|
-
type RepoCompanyMap,
|
|
56
|
-
} from "./company-resolver.js";
|
|
57
|
-
import type {
|
|
58
|
-
OutcomeEventsBatch,
|
|
59
|
-
OutcomeEventsIngestResult,
|
|
60
|
-
TelemetryOptInResponse,
|
|
61
|
-
} from "./vault-client.js";
|
|
62
|
-
|
|
63
|
-
// ── Public surface ────────────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Minimal subset of `VaultClient` the collector needs. Declared as an interface
|
|
67
|
-
* so tests can inject a stub without a fetch mock. The real `VaultClient` from
|
|
68
|
-
* `./vault-client.js` satisfies this structurally.
|
|
69
|
-
*/
|
|
70
|
-
export interface OutcomeTelemetryClientSurface {
|
|
71
|
-
getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
|
|
72
|
-
postOutcomeEvents(batch: OutcomeEventsBatch): Promise<OutcomeEventsIngestResult>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface CollectOutcomeTelemetryOptions {
|
|
76
|
-
client: OutcomeTelemetryClientSurface;
|
|
77
|
-
/**
|
|
78
|
-
* HQ root — the collector scans `<hqRoot>/companies/*` for prd.json/board.json
|
|
79
|
-
* and resolves each project's owning company via `<hqRoot>/companies/
|
|
80
|
-
* manifest.yaml`. REQUIRED: with no hqRoot there is nothing to scan and no
|
|
81
|
-
* company to attribute, so the collector no-ops.
|
|
82
|
-
*/
|
|
83
|
-
hqRoot?: string;
|
|
84
|
-
/** Override `~/.hq/outcome-telemetry-cursor.json` for tests. */
|
|
85
|
-
cursorPath?: string;
|
|
86
|
-
/** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
|
|
87
|
-
menubarPath?: string;
|
|
88
|
-
/** Repo context stamped on every event (required common field). Defaults to `hq`. */
|
|
89
|
-
repo?: string;
|
|
90
|
-
/** Branch context stamped on every event (required common field). Defaults to `main`. */
|
|
91
|
-
branch?: string;
|
|
92
|
-
/** Injectable clock (ISO-8601) for deterministic `occurredAt` in tests. */
|
|
93
|
-
now?: () => string;
|
|
94
|
-
/** Diagnostic sink. No-op by default. */
|
|
95
|
-
log?: (msg: string) => void;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface CollectOutcomeTelemetryResult {
|
|
99
|
-
/** Whether the opt-in check resolved to true. When false, nothing else ran. */
|
|
100
|
-
enabled: boolean;
|
|
101
|
-
optInSource: "server" | "menubar-fallback" | "skipped";
|
|
102
|
-
/** prd.json + board.json files considered. */
|
|
103
|
-
filesScanned: number;
|
|
104
|
-
/** Total outcome events successfully POSTed. */
|
|
105
|
-
eventsSent: number;
|
|
106
|
-
/** Number of `POST /v1/outcome-events` requests made. */
|
|
107
|
-
batchesSent: number;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ── Cursor schema ─────────────────────────────────────────────────────────────
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The cursor records the LAST-SEEN state we have already emitted for, keyed by
|
|
114
|
-
* dedupeKey. A transition is emitted only when the current state differs from
|
|
115
|
-
* what the cursor holds — so a story that is already `passes:true` in the cursor
|
|
116
|
-
* (emitted on a prior sync) never re-emits, and a story that regresses
|
|
117
|
-
* true→false→true re-emits (a genuine new completion). The dedupeKey is the SAME
|
|
118
|
-
* key the server dedups on, so client-cursor + server-conditional-put are two
|
|
119
|
-
* layers of the same idempotency guarantee.
|
|
120
|
-
*
|
|
121
|
-
* Each entry ALSO pins a STABLE `occurredAt` for the transition. The server
|
|
122
|
-
* stores an outcome row under `personUid` + `occurredAt#type#dedupeKey`, so a
|
|
123
|
-
* replay that regenerates `occurredAt` (another machine, or a POST that
|
|
124
|
-
* succeeded but whose cursor-write then failed) would land under a DIFFERENT
|
|
125
|
-
* sort key and double-count. The `occurredAt` is therefore assigned ONCE when
|
|
126
|
-
* the transition is first detected, persisted to the cursor BEFORE the POST, and
|
|
127
|
-
* reused verbatim on every subsequent detection of the same logical transition —
|
|
128
|
-
* so the same transition always emits the same event time and replays collapse.
|
|
129
|
-
*/
|
|
130
|
-
interface OutcomeCursorEntry {
|
|
131
|
-
/** The emitted transition marker (`"story-completed"` / `"project-shipped"`). */
|
|
132
|
-
type: string;
|
|
133
|
-
/** Stable ISO-8601 event time, assigned on first detection and reused on replay. */
|
|
134
|
-
occurredAt: string;
|
|
135
|
-
/**
|
|
136
|
-
* True once the server 2xx'd this transition. A `pending:true` entry has a
|
|
137
|
-
* pinned `occurredAt` (so a replay reuses it) but has NOT yet been confirmed
|
|
138
|
-
* emitted, so it is still eligible to (re-)send next sync.
|
|
139
|
-
*/
|
|
140
|
-
pending?: boolean;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
interface OutcomeCursor {
|
|
144
|
-
version: string;
|
|
145
|
-
/** dedupeKey → the emitted/pending transition entry. */
|
|
146
|
-
emitted: Record<string, OutcomeCursorEntry>;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function emptyCursor(): OutcomeCursor {
|
|
150
|
-
return { version: "2", emitted: {} };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Coerce a raw parsed `emitted` map into the current entry shape. Older cursors
|
|
155
|
-
* (version "1") stored `dedupeKey → typeString`; those entries have no pinned
|
|
156
|
-
* `occurredAt`, so they are migrated to `{ type, occurredAt: <legacy sentinel> }`
|
|
157
|
-
* and treated as already-confirmed (a v1 cursor only ever held CONFIRMED
|
|
158
|
-
* transitions, so migrating them as non-pending preserves "never re-emit").
|
|
159
|
-
*/
|
|
160
|
-
function normalizeEmitted(raw: unknown): Record<string, OutcomeCursorEntry> {
|
|
161
|
-
const out: Record<string, OutcomeCursorEntry> = {};
|
|
162
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
|
163
|
-
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
164
|
-
if (typeof value === "string") {
|
|
165
|
-
// Legacy v1 entry: `dedupeKey → type`. No stored occurredAt; it was already
|
|
166
|
-
// confirmed-emitted, so keep it as a non-pending marker with no timestamp
|
|
167
|
-
// to reuse (empty string means "assign fresh if it somehow re-emits").
|
|
168
|
-
out[key] = { type: value, occurredAt: "" };
|
|
169
|
-
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
170
|
-
const v = value as Record<string, unknown>;
|
|
171
|
-
if (typeof v.type === "string") {
|
|
172
|
-
out[key] = {
|
|
173
|
-
type: v.type,
|
|
174
|
-
occurredAt: typeof v.occurredAt === "string" ? v.occurredAt : "",
|
|
175
|
-
...(v.pending === true ? { pending: true } : {}),
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return out;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async function loadCursor(cursorPath: string): Promise<OutcomeCursor> {
|
|
184
|
-
try {
|
|
185
|
-
const raw = await fs.readFile(cursorPath, "utf-8");
|
|
186
|
-
const parsed = JSON.parse(raw) as Partial<OutcomeCursor>;
|
|
187
|
-
if (
|
|
188
|
-
parsed &&
|
|
189
|
-
typeof parsed === "object" &&
|
|
190
|
-
parsed.emitted &&
|
|
191
|
-
typeof parsed.emitted === "object" &&
|
|
192
|
-
!Array.isArray(parsed.emitted)
|
|
193
|
-
) {
|
|
194
|
-
return {
|
|
195
|
-
version: parsed.version ?? "2",
|
|
196
|
-
emitted: normalizeEmitted(parsed.emitted),
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
} catch {
|
|
200
|
-
// Missing / unparseable — start fresh.
|
|
201
|
-
}
|
|
202
|
-
return emptyCursor();
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
async function saveCursor(cursorPath: string, cursor: OutcomeCursor): Promise<void> {
|
|
206
|
-
// Atomic write: tmp + rename (matches ./telemetry.ts).
|
|
207
|
-
await fs.mkdir(path.dirname(cursorPath), { recursive: true });
|
|
208
|
-
const tmp = `${cursorPath}.tmp`;
|
|
209
|
-
await fs.writeFile(tmp, JSON.stringify(cursor, null, 2), "utf-8");
|
|
210
|
-
await fs.rename(tmp, cursorPath);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ── Local opt-in fallback ─────────────────────────────────────────────────────
|
|
214
|
-
|
|
215
|
-
async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
|
|
216
|
-
try {
|
|
217
|
-
const raw = await fs.readFile(menubarPath, "utf-8");
|
|
218
|
-
const parsed = JSON.parse(raw) as { telemetryEnabled?: unknown };
|
|
219
|
-
return parsed.telemetryEnabled === true;
|
|
220
|
-
} catch {
|
|
221
|
-
return false;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── Transition detection ──────────────────────────────────────────────────────
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* A board project status is treated as SHIPPED for these values. The Indigo
|
|
229
|
-
* board uses both `done` and `completed` for finished projects; both count as a
|
|
230
|
-
* project-shipped transition. Kept lowercase-compared so a `Done`/`DONE`
|
|
231
|
-
* casing variant still matches.
|
|
232
|
-
*/
|
|
233
|
-
const DONE_STATUSES = new Set(["done", "completed", "shipped", "complete"]);
|
|
234
|
-
|
|
235
|
-
export function isDoneStatus(status: unknown): boolean {
|
|
236
|
-
return typeof status === "string" && DONE_STATUSES.has(status.trim().toLowerCase());
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** A single detected outcome transition, before it is shaped for the wire. */
|
|
240
|
-
export interface OutcomeTransition {
|
|
241
|
-
type: "story-completed" | "project-shipped";
|
|
242
|
-
companyUid: string;
|
|
243
|
-
projectName: string;
|
|
244
|
-
/** Present only for story-completed. */
|
|
245
|
-
storyId?: string;
|
|
246
|
-
dedupeKey: string;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
interface PrdShape {
|
|
250
|
-
name?: unknown;
|
|
251
|
-
userStories?: unknown;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
interface BoardShape {
|
|
255
|
-
projects?: unknown;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Extract the story-completed transitions from a parsed prd.json. A transition
|
|
260
|
-
* is a story whose `passes` is currently `true` — the cursor decides whether it
|
|
261
|
-
* is NEW (false→true since last sync) vs already-emitted. `passes` that is not
|
|
262
|
-
* boolean `true`, or a story with no string `id`, is not a completion.
|
|
263
|
-
*
|
|
264
|
-
* `projectName` is the prd's `name`; a prd without one is skipped (the ingest
|
|
265
|
-
* requires a non-empty projectName). NOTHING else from the prd — no
|
|
266
|
-
* description, acceptance criteria, files, notes — is read.
|
|
267
|
-
*/
|
|
268
|
-
export function extractStoryTransitions(
|
|
269
|
-
prd: unknown,
|
|
270
|
-
companyUid: string,
|
|
271
|
-
): OutcomeTransition[] {
|
|
272
|
-
if (!prd || typeof prd !== "object" || Array.isArray(prd)) return [];
|
|
273
|
-
const doc = prd as PrdShape;
|
|
274
|
-
const projectName = typeof doc.name === "string" ? doc.name.trim() : "";
|
|
275
|
-
if (!projectName) return [];
|
|
276
|
-
if (!Array.isArray(doc.userStories)) return [];
|
|
277
|
-
|
|
278
|
-
const out: OutcomeTransition[] = [];
|
|
279
|
-
for (const story of doc.userStories as unknown[]) {
|
|
280
|
-
if (!story || typeof story !== "object" || Array.isArray(story)) continue;
|
|
281
|
-
const s = story as Record<string, unknown>;
|
|
282
|
-
if (s.passes !== true) continue;
|
|
283
|
-
const storyId = typeof s.id === "string" ? s.id.trim() : "";
|
|
284
|
-
if (!storyId) continue;
|
|
285
|
-
out.push({
|
|
286
|
-
type: "story-completed",
|
|
287
|
-
companyUid,
|
|
288
|
-
projectName,
|
|
289
|
-
storyId,
|
|
290
|
-
dedupeKey: `${companyUid}#${projectName}#${storyId}`,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
return out;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Extract the project-shipped transitions from a parsed board.json. A transition
|
|
298
|
-
* is a project whose `status` is a DONE status (see `isDoneStatus`). The
|
|
299
|
-
* `projectName` is the project's `title` (falling back to `id`); a project with
|
|
300
|
-
* neither is skipped. NOTHING else from the board — description, scope, app,
|
|
301
|
-
* prd_path, timestamps — is read.
|
|
302
|
-
*/
|
|
303
|
-
export function extractProjectTransitions(
|
|
304
|
-
board: unknown,
|
|
305
|
-
companyUid: string,
|
|
306
|
-
): OutcomeTransition[] {
|
|
307
|
-
if (!board || typeof board !== "object" || Array.isArray(board)) return [];
|
|
308
|
-
const doc = board as BoardShape;
|
|
309
|
-
if (!Array.isArray(doc.projects)) return [];
|
|
310
|
-
|
|
311
|
-
const out: OutcomeTransition[] = [];
|
|
312
|
-
for (const project of doc.projects as unknown[]) {
|
|
313
|
-
if (!project || typeof project !== "object" || Array.isArray(project)) continue;
|
|
314
|
-
const p = project as Record<string, unknown>;
|
|
315
|
-
if (!isDoneStatus(p.status)) continue;
|
|
316
|
-
const title = typeof p.title === "string" ? p.title.trim() : "";
|
|
317
|
-
const id = typeof p.id === "string" ? p.id.trim() : "";
|
|
318
|
-
const projectName = title || id;
|
|
319
|
-
if (!projectName) continue;
|
|
320
|
-
out.push({
|
|
321
|
-
type: "project-shipped",
|
|
322
|
-
companyUid,
|
|
323
|
-
projectName,
|
|
324
|
-
dedupeKey: `${companyUid}#${projectName}`,
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
return out;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Shape a detected transition for the wire — the STRICT allowlist that proves
|
|
332
|
-
* no prd/board content beyond project name, story id, and the status transition
|
|
333
|
-
* is transmitted. Mirrors the server's KEEP_FIELDS + per-type ref rules in
|
|
334
|
-
* `apps/hq-pro/src/vault-service/handlers/outcome-events.ts`. `personUid` is
|
|
335
|
-
* never produced (resolved server-side from the JWT); any other field would be
|
|
336
|
-
* rejected 4xx by the ingest handler.
|
|
337
|
-
*/
|
|
338
|
-
export function toWireRow(
|
|
339
|
-
t: OutcomeTransition,
|
|
340
|
-
ctx: { occurredAt: string; repo: string; branch: string },
|
|
341
|
-
): Record<string, unknown> {
|
|
342
|
-
const row: Record<string, unknown> = {
|
|
343
|
-
type: t.type,
|
|
344
|
-
occurredAt: ctx.occurredAt,
|
|
345
|
-
companyUid: t.companyUid,
|
|
346
|
-
repo: ctx.repo,
|
|
347
|
-
branch: ctx.branch,
|
|
348
|
-
dedupeKey: t.dedupeKey,
|
|
349
|
-
projectName: t.projectName,
|
|
350
|
-
};
|
|
351
|
-
if (t.type === "story-completed" && t.storyId !== undefined) {
|
|
352
|
-
row.storyId = t.storyId;
|
|
353
|
-
}
|
|
354
|
-
return row;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ── Filesystem scan ────────────────────────────────────────────────────────────
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Resolve a company DIRECTORY name (`companies/<slug>`) to its owning company's
|
|
361
|
-
* `cmp_*` uid via the manifest slug→uid map. A slug with no cloud-backed
|
|
362
|
-
* manifest entry resolves to undefined and its projects are skipped.
|
|
363
|
-
*/
|
|
364
|
-
function companyUidForSlug(slug: string, map: RepoCompanyMap): string | undefined {
|
|
365
|
-
return map.bySlug.get(slug);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** List the immediate subdirectory names of `dir` (company slugs). */
|
|
369
|
-
async function listCompanySlugs(companiesRoot: string): Promise<string[]> {
|
|
370
|
-
try {
|
|
371
|
-
const entries = await fs.readdir(companiesRoot, { withFileTypes: true });
|
|
372
|
-
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
373
|
-
} catch {
|
|
374
|
-
return [];
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Recursively collect every `prd.json` under `root`. Errors are treated as
|
|
379
|
-
* absent (missing dir / EACCES), matching the other collectors' walkers. */
|
|
380
|
-
async function listPrdFiles(root: string): Promise<string[]> {
|
|
381
|
-
const out: string[] = [];
|
|
382
|
-
async function walk(dir: string): Promise<void> {
|
|
383
|
-
let entries;
|
|
384
|
-
try {
|
|
385
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
386
|
-
} catch {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
for (const ent of entries) {
|
|
390
|
-
const full = path.join(dir, ent.name);
|
|
391
|
-
if (ent.isDirectory()) {
|
|
392
|
-
await walk(full);
|
|
393
|
-
} else if (ent.isFile() && ent.name === "prd.json") {
|
|
394
|
-
out.push(full);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
await walk(root);
|
|
399
|
-
return out;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
async function readJsonFile(filePath: string): Promise<unknown | undefined> {
|
|
403
|
-
try {
|
|
404
|
-
const raw = await fs.readFile(filePath, "utf-8");
|
|
405
|
-
return JSON.parse(raw);
|
|
406
|
-
} catch {
|
|
407
|
-
return undefined;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// ── Field / row bounds ───────────────────────────────────────────────────────
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Per-string-field character ceiling enforced by the ingest handler (mirrors the
|
|
415
|
-
* 2048-char IAM/field limit). A projectName / storyId longer than this makes the
|
|
416
|
-
* server reject the WHOLE batch, so an over-long field on ONE local project must
|
|
417
|
-
* not be allowed to poison up to 199 other valid outcomes.
|
|
418
|
-
*/
|
|
419
|
-
export const MAX_FIELD_CHARS = 2048;
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Per-event serialized-byte ceiling (4 KB). The server rejects a whole batch if
|
|
423
|
-
* any single event JSON exceeds this, so an event that would blow the limit is
|
|
424
|
-
* dropped locally rather than sent.
|
|
425
|
-
*/
|
|
426
|
-
export const MAX_EVENT_BYTES = 4 * 1024;
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Decide whether a shaped wire row is within the ingest limits. The
|
|
430
|
-
* dedupe-relevant fields (companyUid / projectName / storyId, which compose the
|
|
431
|
-
* dedupeKey) CANNOT be truncated without changing the identity of the outcome,
|
|
432
|
-
* so an over-limit transition is SKIPPED wholesale rather than mangled — one
|
|
433
|
-
* malformed local project must never block the valid outcomes in its batch.
|
|
434
|
-
*
|
|
435
|
-
* Returns `null` when the row is acceptable, or a human-readable reason string
|
|
436
|
-
* when it must be skipped (so the caller can log the skip).
|
|
437
|
-
*/
|
|
438
|
-
export function wireRowRejectReason(row: Record<string, unknown>): string | null {
|
|
439
|
-
for (const [key, value] of Object.entries(row)) {
|
|
440
|
-
if (typeof value === "string" && value.length > MAX_FIELD_CHARS) {
|
|
441
|
-
return `field "${key}" exceeds ${MAX_FIELD_CHARS} chars (${value.length})`;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
const bytes = Buffer.byteLength(JSON.stringify(row), "utf-8");
|
|
445
|
-
if (bytes > MAX_EVENT_BYTES) {
|
|
446
|
-
return `event exceeds ${MAX_EVENT_BYTES} bytes (${bytes})`;
|
|
447
|
-
}
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// ── Batching ───────────────────────────────────────────────────────────────────
|
|
452
|
-
|
|
453
|
-
// The ingest caps a batch at 500 events; stay comfortably under it. A real sync
|
|
454
|
-
// flushes a small number of transitions, so this bound is rarely reached.
|
|
455
|
-
const MAX_BATCH_EVENTS = 200;
|
|
456
|
-
|
|
457
|
-
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Scan HQ project state, detect new story-completed / project-shipped
|
|
461
|
-
* transitions since the last sync, and POST them.
|
|
462
|
-
*
|
|
463
|
-
* Fire-and-forget from the caller's perspective: all errors are caught
|
|
464
|
-
* internally and surfaced only via `log`. The cursor advances ONLY for
|
|
465
|
-
* transitions whose batch the server accepted, so a failed POST re-sends next
|
|
466
|
-
* sync (and the server-side dedupe makes that re-send idempotent).
|
|
467
|
-
*/
|
|
468
|
-
export async function collectAndSendOutcomeTelemetry(
|
|
469
|
-
opts: CollectOutcomeTelemetryOptions,
|
|
470
|
-
): Promise<CollectOutcomeTelemetryResult> {
|
|
471
|
-
const home = os.homedir();
|
|
472
|
-
const cursorPath =
|
|
473
|
-
opts.cursorPath ?? path.join(home, ".hq", "outcome-telemetry-cursor.json");
|
|
474
|
-
const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
|
|
475
|
-
const repo = opts.repo ?? "hq";
|
|
476
|
-
const branch = opts.branch ?? "main";
|
|
477
|
-
const nowIso = opts.now ?? (() => new Date().toISOString());
|
|
478
|
-
const log = opts.log ?? (() => {});
|
|
479
|
-
|
|
480
|
-
// 1. Opt-in gate — same server-authoritative check as usage/skill telemetry,
|
|
481
|
-
// with the local menubar.json fallback.
|
|
482
|
-
let enabled: boolean;
|
|
483
|
-
let optInSource: CollectOutcomeTelemetryResult["optInSource"];
|
|
484
|
-
try {
|
|
485
|
-
const resp = await opts.client.getTelemetryOptIn();
|
|
486
|
-
enabled = resp.enabled === true;
|
|
487
|
-
optInSource = "server";
|
|
488
|
-
} catch (err) {
|
|
489
|
-
log(
|
|
490
|
-
`[outcome-telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`,
|
|
491
|
-
);
|
|
492
|
-
enabled = await readLocalTelemetryEnabled(menubarPath);
|
|
493
|
-
optInSource = "menubar-fallback";
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
if (!enabled) {
|
|
497
|
-
return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// With no hqRoot there is nothing to scan and no company to attribute.
|
|
501
|
-
if (!opts.hqRoot) {
|
|
502
|
-
return { enabled: true, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// 2. Resolve slug→companyUid ONCE per run (mirrors ./telemetry.ts).
|
|
506
|
-
const repoCompanyMap: RepoCompanyMap = await buildRepoCompanyMap(opts.hqRoot);
|
|
507
|
-
|
|
508
|
-
const companiesRoot = path.join(opts.hqRoot, "companies");
|
|
509
|
-
const slugs = await listCompanySlugs(companiesRoot);
|
|
510
|
-
|
|
511
|
-
// 3. Detect all current transitions across every cloud-backed company.
|
|
512
|
-
const cursor = await loadCursor(cursorPath);
|
|
513
|
-
const transitions: OutcomeTransition[] = [];
|
|
514
|
-
let filesScanned = 0;
|
|
515
|
-
|
|
516
|
-
for (const slug of slugs) {
|
|
517
|
-
const companyUid = companyUidForSlug(slug, repoCompanyMap);
|
|
518
|
-
// No cloud-backed company for this slug → its projects cannot be attributed
|
|
519
|
-
// (companyUid is a required ingest field), so skip the whole directory.
|
|
520
|
-
if (companyUid === undefined) continue;
|
|
521
|
-
|
|
522
|
-
const companyDir = path.join(companiesRoot, slug);
|
|
523
|
-
|
|
524
|
-
// 3a. Board projects (project-shipped). One board.json per company.
|
|
525
|
-
const boardPath = path.join(companyDir, "board.json");
|
|
526
|
-
const board = await readJsonFile(boardPath);
|
|
527
|
-
if (board !== undefined) {
|
|
528
|
-
filesScanned++;
|
|
529
|
-
for (const transition of extractProjectTransitions(board, companyUid)) {
|
|
530
|
-
transitions.push(transition);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// 3b. Project stories (story-completed). Many prd.json under projects/.
|
|
535
|
-
const prdFiles = await listPrdFiles(path.join(companyDir, "projects"));
|
|
536
|
-
for (const prdPath of prdFiles) {
|
|
537
|
-
const prd = await readJsonFile(prdPath);
|
|
538
|
-
if (prd === undefined) continue;
|
|
539
|
-
filesScanned++;
|
|
540
|
-
for (const transition of extractStoryTransitions(prd, companyUid)) {
|
|
541
|
-
transitions.push(transition);
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// 4. Keep only transitions the cursor has NOT already CONFIRMED emitting.
|
|
547
|
-
// Dedupe within this run too (a dedupeKey seen twice in one scan is one
|
|
548
|
-
// event). A `pending` cursor entry (occurredAt pinned, POST not yet 2xx'd)
|
|
549
|
-
// is still eligible — it re-sends with its ORIGINAL occurredAt.
|
|
550
|
-
const seenThisRun = new Set<string>();
|
|
551
|
-
const pending: OutcomeTransition[] = [];
|
|
552
|
-
for (const t of transitions) {
|
|
553
|
-
if (seenThisRun.has(t.dedupeKey)) continue;
|
|
554
|
-
seenThisRun.add(t.dedupeKey);
|
|
555
|
-
const entry = cursor.emitted[t.dedupeKey];
|
|
556
|
-
if (entry && entry.type === t.type && entry.pending !== true) continue; // confirmed already
|
|
557
|
-
pending.push(t);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
if (pending.length === 0) {
|
|
561
|
-
return { enabled: true, optInSource, filesScanned, eventsSent: 0, batchesSent: 0 };
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// 5. Assign a STABLE occurredAt per transition and shape the wire row.
|
|
565
|
-
// - If the cursor already pins an occurredAt for this dedupeKey+type
|
|
566
|
-
// (a prior sync detected it), REUSE it verbatim so a replay lands under
|
|
567
|
-
// the same server sort key and collapses to one row.
|
|
568
|
-
// - Otherwise assign a fresh occurredAt now and pin it in the cursor.
|
|
569
|
-
// Rows that would exceed the ingest field/size limits are SKIPPED (their
|
|
570
|
-
// dedupe-identifying fields can't be truncated without changing identity),
|
|
571
|
-
// so one malformed local project cannot block the valid outcomes.
|
|
572
|
-
const now = nowIso();
|
|
573
|
-
interface Sendable {
|
|
574
|
-
transition: OutcomeTransition;
|
|
575
|
-
row: Record<string, unknown>;
|
|
576
|
-
}
|
|
577
|
-
const sendable: Sendable[] = [];
|
|
578
|
-
for (const t of pending) {
|
|
579
|
-
const existing = cursor.emitted[t.dedupeKey];
|
|
580
|
-
const occurredAt =
|
|
581
|
-
existing && existing.type === t.type && existing.occurredAt
|
|
582
|
-
? existing.occurredAt
|
|
583
|
-
: now;
|
|
584
|
-
const row = toWireRow(t, { occurredAt, repo, branch });
|
|
585
|
-
const reject = wireRowRejectReason(row);
|
|
586
|
-
if (reject) {
|
|
587
|
-
log(
|
|
588
|
-
`[outcome-telemetry] skipping ${t.type} ${t.dedupeKey}: ${reject}`,
|
|
589
|
-
);
|
|
590
|
-
continue;
|
|
591
|
-
}
|
|
592
|
-
// Pin the stable occurredAt BEFORE the POST so a crash between POST-success
|
|
593
|
-
// and cursor-flush still reuses the same timestamp on the next detection.
|
|
594
|
-
cursor.emitted[t.dedupeKey] = { type: t.type, occurredAt, pending: true };
|
|
595
|
-
sendable.push({ transition: t, row });
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Persist the pinned-but-pending timestamps before sending, so a re-detection
|
|
599
|
-
// after a partial failure reuses the same occurredAt.
|
|
600
|
-
await saveCursor(cursorPath, cursor);
|
|
601
|
-
|
|
602
|
-
if (sendable.length === 0) {
|
|
603
|
-
return { enabled: true, optInSource, filesScanned, eventsSent: 0, batchesSent: 0 };
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// 6. Flush in server-sized batches; confirm the cursor per SUCCESSFUL batch.
|
|
607
|
-
let eventsSent = 0;
|
|
608
|
-
let batchesSent = 0;
|
|
609
|
-
|
|
610
|
-
for (let i = 0; i < sendable.length; i += MAX_BATCH_EVENTS) {
|
|
611
|
-
const chunk = sendable.slice(i, i + MAX_BATCH_EVENTS);
|
|
612
|
-
const events = chunk.map((s) => s.row);
|
|
613
|
-
try {
|
|
614
|
-
await opts.client.postOutcomeEvents({ events });
|
|
615
|
-
batchesSent++;
|
|
616
|
-
eventsSent += events.length;
|
|
617
|
-
// Confirm these transitions so they never re-send (server-side dedupe also
|
|
618
|
-
// collapses a replay if the cursor is later lost), keeping their pinned
|
|
619
|
-
// occurredAt.
|
|
620
|
-
for (const s of chunk) {
|
|
621
|
-
cursor.emitted[s.transition.dedupeKey] = {
|
|
622
|
-
type: s.transition.type,
|
|
623
|
-
occurredAt: s.row.occurredAt as string,
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
} catch (err) {
|
|
627
|
-
log(
|
|
628
|
-
`[outcome-telemetry] postOutcomeEvents failed (${(err as Error).message ?? err}) — ${chunk.length} rows re-send next sync`,
|
|
629
|
-
);
|
|
630
|
-
// Cursor entries stay `pending:true` with their pinned occurredAt — next
|
|
631
|
-
// sync retries with the SAME timestamp.
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// 7. Persist the cursor (confirmed transitions dropped `pending`).
|
|
636
|
-
await saveCursor(cursorPath, cursor);
|
|
637
|
-
|
|
638
|
-
return { enabled: true, optInSource, filesScanned, eventsSent, batchesSent };
|
|
639
|
-
}
|