@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/bin/sync-runner.ts
DELETED
|
@@ -1,2190 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* hq-sync-runner — machine-targeted entrypoint for `@indigoai-us/hq-cloud`
|
|
4
|
-
* (ADR-0001).
|
|
5
|
-
*
|
|
6
|
-
* The AppBar Sync menubar (Tauri + Rust) spawns this binary as a subprocess
|
|
7
|
-
* and reads ndjson events from BOTH stdout and stderr (see "Channels"
|
|
8
|
-
* below). The protocol is intentionally narrow and versioned-by-shape, not
|
|
9
|
-
* by tooling — no chalk, no colors, no human prose. If you want to invoke
|
|
10
|
-
* sync as a human, use `hq sync` in `@indigoai-us/hq-cli`.
|
|
11
|
-
*
|
|
12
|
-
* Flags:
|
|
13
|
-
* --companies Fan out across every membership the caller has
|
|
14
|
-
* --company <slug-or-uid> Sync a single company (alternative to --companies)
|
|
15
|
-
* --on-conflict <strategy> abort | overwrite | keep (default: abort)
|
|
16
|
-
* --hq-root <path> Local HQ directory (default: $HOME/hq)
|
|
17
|
-
* --skip-personal Drop the personal target from the --companies
|
|
18
|
-
* fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
|
|
19
|
-
* (either truthy disables personal sync). No-op
|
|
20
|
-
* outside --companies mode.
|
|
21
|
-
* --json Ignored — ndjson on stdout is the default and
|
|
22
|
-
* only output mode. Accepted for symmetry with the
|
|
23
|
-
* AppBar's argv in case someone passes it.
|
|
24
|
-
*
|
|
25
|
-
* Channels (one JSON object per line):
|
|
26
|
-
* stdout — protocol stream:
|
|
27
|
-
* setup-needed caller signed in but has no person entity yet
|
|
28
|
-
* fanout-plan list of companies we're about to sync
|
|
29
|
-
* progress per-file download
|
|
30
|
-
* complete per-company summary
|
|
31
|
-
* all-complete aggregate summary after fanout
|
|
32
|
-
* stderr — diagnostic stream:
|
|
33
|
-
* error per-file or per-company error
|
|
34
|
-
* auth-error no valid token available (interactive login disabled)
|
|
35
|
-
*
|
|
36
|
-
* Why the split: error-class events go to stderr so the menubar's Sentry
|
|
37
|
-
* breadcrumb pipeline picks them up automatically (see hq-sync
|
|
38
|
-
* src-tauri/src/commands/sync.rs `ProcessEvent::Stderr` handler). The
|
|
39
|
-
* single Sentry capture at runner-exit then ships one #hq-alerts issue
|
|
40
|
-
* with the full per-file → company → exit error trail attached, instead
|
|
41
|
-
* of requiring per-event capture calls in the menubar.
|
|
42
|
-
*
|
|
43
|
-
* Exit code:
|
|
44
|
-
* 0 — event stream describes the outcome. The runner finished its protocol
|
|
45
|
-
* without any company throwing. Includes setup-needed, auth-error, and
|
|
46
|
-
* runs where every company completed OR cleanly returned `aborted: true`
|
|
47
|
-
* (a `--on-conflict abort` policy decision is not an error).
|
|
48
|
-
* 1 — argv parse error or unrecoverable pre-sync failure.
|
|
49
|
-
* 2 — at least one company threw a deterministic, non-transport failure
|
|
50
|
-
* mid-stream (e.g. mid-fanout 401, denied access, S3 5xx after retries).
|
|
51
|
-
* The all-complete event carries
|
|
52
|
-
* `partial: true` and per-company partial counts captured from
|
|
53
|
-
* `progress` events before the throw, so consumers parsing ndjson see
|
|
54
|
-
* what actually transferred. This is distinct from exit 0 with
|
|
55
|
-
* `partial: true` (clean conflict-aborts) — exit 2 is "something
|
|
56
|
-
* unexpected happened", which the Tauri menubar converts to a Sentry
|
|
57
|
-
* alert. Conflict-aborts intentionally do NOT alert.
|
|
58
|
-
*/
|
|
59
|
-
|
|
60
|
-
import * as os from "os";
|
|
61
|
-
import * as path from "path";
|
|
62
|
-
import * as fs from "fs";
|
|
63
|
-
import { randomUUID } from "node:crypto";
|
|
64
|
-
import { fileURLToPath } from "url";
|
|
65
|
-
import {
|
|
66
|
-
CognitoRefreshError,
|
|
67
|
-
getValidAccessToken,
|
|
68
|
-
invalidateCachedTokensByFingerprint,
|
|
69
|
-
loadCachedTokens,
|
|
70
|
-
VaultClient,
|
|
71
|
-
VaultAuthError,
|
|
72
|
-
type CognitoAuthConfig,
|
|
73
|
-
type CognitoTokens,
|
|
74
|
-
type VaultServiceConfig,
|
|
75
|
-
type Membership,
|
|
76
|
-
type EntityInfo,
|
|
77
|
-
type PendingInviteByEmail,
|
|
78
|
-
type MembershipSyncConfig,
|
|
79
|
-
type ExplicitGrant,
|
|
80
|
-
} from "../index.js";
|
|
81
|
-
import { pickCanonicalPersonEntity } from "../vault-client.js";
|
|
82
|
-
import {
|
|
83
|
-
resolvePullScope,
|
|
84
|
-
readPinnedPrefixes,
|
|
85
|
-
SESSIONS_SCOPE_PREFIX,
|
|
86
|
-
type PullScope,
|
|
87
|
-
} from "../sync/pull-scope.js";
|
|
88
|
-
import {
|
|
89
|
-
PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
|
|
90
|
-
computePersonalVaultPaths,
|
|
91
|
-
} from "../personal-vault.js";
|
|
92
|
-
import { sync as defaultSync } from "../cli/sync.js";
|
|
93
|
-
import type {
|
|
94
|
-
SyncOptions,
|
|
95
|
-
SyncResult,
|
|
96
|
-
SyncProgressEvent,
|
|
97
|
-
} from "../cli/sync.js";
|
|
98
|
-
import { share as defaultShare } from "../cli/share.js";
|
|
99
|
-
import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
100
|
-
import type { ConflictStrategy } from "../cli/conflict.js";
|
|
101
|
-
import type { UploadAuthor } from "../s3.js";
|
|
102
|
-
import {
|
|
103
|
-
setObjectIOFactory,
|
|
104
|
-
presignObjectIOFactory,
|
|
105
|
-
type ObjectIOFactory,
|
|
106
|
-
type PresignTransportClient,
|
|
107
|
-
} from "../object-io.js";
|
|
108
|
-
import { HQ_CLOUD_VERSION } from "../version.js";
|
|
109
|
-
import { collectAndSendTelemetry } from "../telemetry.js";
|
|
110
|
-
import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
|
|
111
|
-
import { collectAndSendOutcomeTelemetry } from "../outcome-telemetry.js";
|
|
112
|
-
import { emitCloudTelemetry } from "../telemetry-events.js";
|
|
113
|
-
import { reindexAfterSync } from "../qmd-reindex.js";
|
|
114
|
-
import type { ReindexOptions as QmdReindexOptions } from "../qmd-reindex.js";
|
|
115
|
-
import { reindex as defaultReindex } from "../cli/reindex.js";
|
|
116
|
-
import type { ReindexOptions, ReindexResult } from "../cli/reindex.js";
|
|
117
|
-
import type {
|
|
118
|
-
TelemetryEventsBatch,
|
|
119
|
-
TelemetryEventsIngestResult,
|
|
120
|
-
} from "../vault-client.js";
|
|
121
|
-
import { pruneConflictIndex } from "../lib/conflict-index.js";
|
|
122
|
-
import { materializeCodexAgents } from "../agent-codex-instructions.js";
|
|
123
|
-
import { getOrCreateMachineId } from "../lib/machine-id.js";
|
|
124
|
-
import {
|
|
125
|
-
isTransientNetworkError,
|
|
126
|
-
TRANSIENT_NETWORK_EXIT,
|
|
127
|
-
} from "../lib/net-errors.js";
|
|
128
|
-
import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
|
|
129
|
-
import { describeError } from "../lib/describe-error.js";
|
|
130
|
-
import type {
|
|
131
|
-
Clock,
|
|
132
|
-
LocalDeleteSnapshot,
|
|
133
|
-
TreeChangeBatch,
|
|
134
|
-
} from "../watcher.js";
|
|
135
|
-
import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
|
|
136
|
-
import {
|
|
137
|
-
type EventSyncHandles,
|
|
138
|
-
type StartEventSyncOptions,
|
|
139
|
-
} from "../sync/event-sync.js";
|
|
140
|
-
import { migratePersonalVaultJournal } from "../journal.js";
|
|
141
|
-
import {
|
|
142
|
-
createRunnerEmitter,
|
|
143
|
-
emitUncaughtRunnerError,
|
|
144
|
-
} from "./sync-runner-events.js";
|
|
145
|
-
import {
|
|
146
|
-
buildFanoutPlan,
|
|
147
|
-
emitFanoutPlan,
|
|
148
|
-
resolveMembershipsForRun,
|
|
149
|
-
resolveSkipCompanies,
|
|
150
|
-
type RunnerTarget,
|
|
151
|
-
type SetupNeededReason,
|
|
152
|
-
} from "./sync-runner-planning.js";
|
|
153
|
-
|
|
154
|
-
export { resolveSkipCompanies } from "./sync-runner-planning.js";
|
|
155
|
-
import {
|
|
156
|
-
seedActiveCompany,
|
|
157
|
-
type SeedActiveCompanyOptions,
|
|
158
|
-
type SeedActiveCompanyResult,
|
|
159
|
-
} from "../active-company.js";
|
|
160
|
-
import {
|
|
161
|
-
reconcileCompanyManifest,
|
|
162
|
-
type ManifestReconcileOptions,
|
|
163
|
-
type ManifestReconcileResult,
|
|
164
|
-
} from "../manifest-reconcile.js";
|
|
165
|
-
import { executeCompanyFanout } from "./sync-runner-company.js";
|
|
166
|
-
import { rollupAllComplete } from "./sync-runner-rollup.js";
|
|
167
|
-
import { emitTelemetry } from "./sync-runner-telemetry.js";
|
|
168
|
-
import {
|
|
169
|
-
runOneShotWithOperationLock,
|
|
170
|
-
runWatchLoop,
|
|
171
|
-
} from "./sync-runner-watch-loop.js";
|
|
172
|
-
export {
|
|
173
|
-
buildTargetedPullArgv,
|
|
174
|
-
buildTargetedPushArgv,
|
|
175
|
-
routeChangeToTarget,
|
|
176
|
-
} from "./sync-runner-watch-routes.js";
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Sync direction for a run.
|
|
180
|
-
*
|
|
181
|
-
* - `pull`: download-only (legacy `hq sync` behaviour, and the default for
|
|
182
|
-
* back-compat with pre-5.1.11 callers of the runner).
|
|
183
|
-
* - `push`: upload-only. Walks the company folder and sends every file whose
|
|
184
|
-
* local hash differs from the journal (skipUnchanged).
|
|
185
|
-
* - `both`: push first, then pull. "Sync Now" in the menubar app targets this.
|
|
186
|
-
* Push runs first so the subsequent pull doesn't redownload files we were
|
|
187
|
-
* about to replace; if a company aborts on push conflict, pull is skipped
|
|
188
|
-
* for that company but the fanout continues.
|
|
189
|
-
*/
|
|
190
|
-
export type Direction = "pull" | "push" | "both";
|
|
191
|
-
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
// Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
|
|
194
|
-
// imported) to avoid a circular dep between hq-cli and hq-cloud. If these
|
|
195
|
-
// drift, the symptom is "runner talks to a different stage than hq sync"
|
|
196
|
-
// — keep both files lined up.
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
const DEFAULT_COGNITO: CognitoAuthConfig = {
|
|
200
|
-
region: process.env.AWS_REGION ?? "us-east-1",
|
|
201
|
-
userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "vault-indigo-hq-prod",
|
|
202
|
-
clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "7acei2c8v870enheptb1j5foln",
|
|
203
|
-
port: process.env.HQ_COGNITO_CALLBACK_PORT
|
|
204
|
-
? Number(process.env.HQ_COGNITO_CALLBACK_PORT)
|
|
205
|
-
: 8765,
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
const DEFAULT_VAULT_API_URL =
|
|
209
|
-
process.env.HQ_VAULT_API_URL ?? "https://hqapi.hq.computer";
|
|
210
|
-
|
|
211
|
-
const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
|
|
212
|
-
const DEFAULT_TELEMETRY_TIMEOUT_MS = 120_000;
|
|
213
|
-
|
|
214
|
-
function resolveTelemetryTimeoutMs(override?: number): number {
|
|
215
|
-
if (override !== undefined && Number.isFinite(override) && override >= 0) {
|
|
216
|
-
return Math.trunc(override);
|
|
217
|
-
}
|
|
218
|
-
const raw = process.env.HQ_TELEMETRY_TIMEOUT_MS;
|
|
219
|
-
if (raw) {
|
|
220
|
-
const parsed = Number.parseInt(raw, 10);
|
|
221
|
-
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
222
|
-
}
|
|
223
|
-
return DEFAULT_TELEMETRY_TIMEOUT_MS;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function awaitTelemetryWithTimeout(
|
|
227
|
-
telemetry: Promise<void>,
|
|
228
|
-
timeoutMs: number,
|
|
229
|
-
onTimeout: () => void,
|
|
230
|
-
): Promise<void> {
|
|
231
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
232
|
-
const timeoutPromise = new Promise<"timeout">((resolve) => {
|
|
233
|
-
timeout = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
234
|
-
timeout.unref?.();
|
|
235
|
-
});
|
|
236
|
-
const result = await Promise.race([
|
|
237
|
-
telemetry.then(() => "done" as const),
|
|
238
|
-
timeoutPromise,
|
|
239
|
-
]);
|
|
240
|
-
if (timeout) clearTimeout(timeout);
|
|
241
|
-
if (result === "timeout") onTimeout();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Delete-propagation policy honored by the push leg of bidirectional sync.
|
|
246
|
-
*
|
|
247
|
-
* Default `"currency-gated"` in 5.25 — flipped from `"owned-only"` after
|
|
248
|
-
* one machine (Indigo / corey) ran the 5.24 code path through real syncs
|
|
249
|
-
* for a week without surfacing surprise behavior. Currency-gated does a
|
|
250
|
-
* per-file ETag HEAD before propagating any local-delete to S3: if the
|
|
251
|
-
* remote object's current ETag no longer matches the journal's last-
|
|
252
|
-
* recorded one, the delete is refused and the next pull leg re-pulls the
|
|
253
|
-
* file via the standard 3-way merge path. This is strictly safer than
|
|
254
|
-
* `owned-only` (which propagates any local-delete the journal can prove
|
|
255
|
-
* came from this device) — the only delete-class that changes behavior
|
|
256
|
-
* is "deleted locally + modified remotely by another device", which
|
|
257
|
-
* previously destroyed remote work and now becomes a pull-and-conflict.
|
|
258
|
-
*
|
|
259
|
-
* Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is
|
|
260
|
-
* also the rollback knob — anyone surprised by 5.25's flip can revert
|
|
261
|
-
* to `owned-only` without redeploying. `all` is the unsafe-mirror mode
|
|
262
|
-
* previously used by the runner pre-5.20 — included only as an
|
|
263
|
-
* emergency reconcile lever, not a recommended default.
|
|
264
|
-
*/
|
|
265
|
-
export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all";
|
|
266
|
-
|
|
267
|
-
export function resolveDeletePolicy(): DeletePropagationPolicy {
|
|
268
|
-
const env = process.env.HQ_SYNC_DELETE_POLICY;
|
|
269
|
-
if (env === "owned-only" || env === "all" || env === "currency-gated") {
|
|
270
|
-
return env;
|
|
271
|
-
}
|
|
272
|
-
return "currency-gated";
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Resolve whether to skip the personal target in a `--companies` fanout.
|
|
277
|
-
*
|
|
278
|
-
* Two inputs combine: the `--skip-personal` CLI flag (parsed into
|
|
279
|
-
* `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
|
|
280
|
-
* being truthy skips the personal target — flag wins on conflict (CLI
|
|
281
|
-
* flag is the explicit-for-this-invocation knob, env is the persistent
|
|
282
|
-
* default usually set by the menubar in the spawned child process).
|
|
283
|
-
*
|
|
284
|
-
* Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
|
|
285
|
-
* (including missing) is treated as falsy — same shape as classic
|
|
286
|
-
* Unix opt-in env conventions; conservative to avoid surprising opt-outs.
|
|
287
|
-
*
|
|
288
|
-
* Use case: the menubar app exposes a "Sync personal vault" toggle in
|
|
289
|
-
* Settings (default ON, matching the auto-provisioning UX). When the user
|
|
290
|
-
* flips it off, the menubar spawns `hq sync` with this env set so the
|
|
291
|
-
* fanout drops the personal target before walking the user's entire HQ
|
|
292
|
-
* tree (a sync that would otherwise scan thousands of files, including
|
|
293
|
-
* the new personal-vault default exclusions, just to do nothing useful).
|
|
294
|
-
*/
|
|
295
|
-
export function resolveSkipPersonal(flag: boolean): boolean {
|
|
296
|
-
if (flag) return true;
|
|
297
|
-
const env = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
|
|
298
|
-
return env === "1" || env === "true" || env === "yes";
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Resolve the object-transport factory for this sync session.
|
|
303
|
-
*
|
|
304
|
-
* Company vaults (`cmp_*`) ALWAYS use the presigned-URL transport: the client
|
|
305
|
-
* holds no raw AWS credentials (it fetches short-lived signed URLs) and every
|
|
306
|
-
* read/write is authorized server-side per-file, so it never hits the 2048-char
|
|
307
|
-
* STS session-policy ceiling that produced the HQ-59 lockout. The STS-direct-S3
|
|
308
|
-
* (`S3SdkObjectIO`) path for company vaults is RETIRED — there is no env
|
|
309
|
-
* override or rollback lever that can route a company vault back to direct S3.
|
|
310
|
-
* (The former `HQ_SYNC_PRESIGN_TRANSPORT` kill-switch is gone with this change.)
|
|
311
|
-
*
|
|
312
|
-
* Personal vaults (`prs_*`) KEEP the direct-S3/STS path: the membership-gated
|
|
313
|
-
* `list`/`presign` endpoints 403 for the membership-less vend-self model, and a
|
|
314
|
-
* single-owner personal vault has no ACL-scale problem. That cmp_/prs_ split
|
|
315
|
-
* lives inside {@link presignObjectIOFactory}.
|
|
316
|
-
*
|
|
317
|
-
* Returns `null` only when the client predates the presign methods (never in a
|
|
318
|
-
* shipped build); the caller then resets to the SDK default factory.
|
|
319
|
-
*/
|
|
320
|
-
export function selectObjectIOFactory(
|
|
321
|
-
client: Partial<PresignTransportClient>,
|
|
322
|
-
): ObjectIOFactory | null {
|
|
323
|
-
if (
|
|
324
|
-
typeof client.presign === "function" &&
|
|
325
|
-
typeof client.listFiles === "function"
|
|
326
|
-
) {
|
|
327
|
-
return presignObjectIOFactory(client as PresignTransportClient);
|
|
328
|
-
}
|
|
329
|
-
return null;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Personal-vault scope (exclusion list + path computer) lives in
|
|
333
|
-
// `../personal-vault.ts` so the `hq sync` CLI and this runner share the same
|
|
334
|
-
// rules. Re-exported here for back-compat with any callers still importing
|
|
335
|
-
// from sync-runner.js directly.
|
|
336
|
-
export { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths };
|
|
337
|
-
|
|
338
|
-
// ---------------------------------------------------------------------------
|
|
339
|
-
// Event protocol
|
|
340
|
-
// ---------------------------------------------------------------------------
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Every event the runner emits. Channel routing (stdout vs stderr) is
|
|
344
|
-
* decided inside `runRunner`'s `emit` helper based on the event's `type`
|
|
345
|
-
* — see the doc-block on the file header for the split.
|
|
346
|
-
*
|
|
347
|
-
* The `company` field is present on every event except `setup-needed` /
|
|
348
|
-
* `auth-error` / `fanout-plan` / `all-complete` (which describe the whole
|
|
349
|
-
* run) — consumers should treat its absence as "meta-event, not tied to a
|
|
350
|
-
* specific company".
|
|
351
|
-
*/
|
|
352
|
-
export type RunnerEvent =
|
|
353
|
-
| {
|
|
354
|
-
/**
|
|
355
|
-
* The run cannot proceed. `reason` and `pendingInviteCount` are additive
|
|
356
|
-
* and optional by design: a consumer that only reads `type` behaves
|
|
357
|
-
* exactly as before, while /setup and /hq-sync can tell the user WHY they
|
|
358
|
-
* look solo — most usefully, that an invite is still waiting.
|
|
359
|
-
*/
|
|
360
|
-
type: "setup-needed";
|
|
361
|
-
reason?: SetupNeededReason;
|
|
362
|
-
pendingInviteCount?: number;
|
|
363
|
-
}
|
|
364
|
-
| { type: "auth-error"; message: string }
|
|
365
|
-
| {
|
|
366
|
-
/**
|
|
367
|
-
* The resolved fanout plan. Carries whole `RunnerTarget`s: the runtime
|
|
368
|
-
* payload has always been the plan entries verbatim, so the previous
|
|
369
|
-
* `{uid, slug, name?}` declaration understated the wire contract (it
|
|
370
|
-
* already omitted `bucketName`, `personalMode`, and `journalSlug`).
|
|
371
|
-
* Consumers deserialize leniently and ignore fields they do not know.
|
|
372
|
-
*/
|
|
373
|
-
type: "fanout-plan";
|
|
374
|
-
companies: RunnerTarget[];
|
|
375
|
-
}
|
|
376
|
-
| ({
|
|
377
|
-
/**
|
|
378
|
-
* Stage-1 results for a single company's sync/share pass. Emitted once
|
|
379
|
-
* before any `progress` events for that company arrive — once for the
|
|
380
|
-
* pull phase (download counts) and once for the push phase (upload
|
|
381
|
-
* counts) when `--direction both`. Consumers (the menubar) sum the
|
|
382
|
-
* non-zero fields across all `plan` events seen for a fanout to render
|
|
383
|
-
* an accurate "X of Y files" denominator before transfers begin.
|
|
384
|
-
*/
|
|
385
|
-
type: "plan";
|
|
386
|
-
company: string;
|
|
387
|
-
} & Omit<Extract<SyncProgressEvent, { type: "plan" }>, "type">)
|
|
388
|
-
| ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
|
|
389
|
-
| ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
|
|
390
|
-
| {
|
|
391
|
-
/**
|
|
392
|
-
* A per-company transport failure that is retryable, not a deterministic
|
|
393
|
-
* sync error. This deliberately stays off the `error` channel: exit 2
|
|
394
|
-
* means a real partial sync failure, while this pass returns EX_TEMPFAIL
|
|
395
|
-
* (75) and is retried by the watch loop. Mirrors the non-error treatment
|
|
396
|
-
* of deliberate skip events.
|
|
397
|
-
*/
|
|
398
|
-
type: "transient-network";
|
|
399
|
-
company: string;
|
|
400
|
-
path: "(company)";
|
|
401
|
-
message: string;
|
|
402
|
-
}
|
|
403
|
-
| ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
|
|
404
|
-
| { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
|
|
405
|
-
| { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
|
|
406
|
-
| ({ type: "scope-materialization-gap"; company: string } & Omit<
|
|
407
|
-
Extract<SyncProgressEvent, { type: "scope-materialization-gap" }>,
|
|
408
|
-
"type"
|
|
409
|
-
>)
|
|
410
|
-
| { type: "ignore-excluded"; company: string; count: number; totalExcluded: number; samplePaths: string[] }
|
|
411
|
-
| ({ type: "skip-invalid-scoped-key"; company: string } & Omit<
|
|
412
|
-
Extract<SyncProgressEvent, { type: "skip-invalid-scoped-key" }>,
|
|
413
|
-
"type"
|
|
414
|
-
>)
|
|
415
|
-
| ({ type: "skip-junk-key-spelling"; company: string } & Omit<
|
|
416
|
-
Extract<SyncProgressEvent, { type: "skip-junk-key-spelling" }>,
|
|
417
|
-
"type"
|
|
418
|
-
>)
|
|
419
|
-
| ({ type: "not-shipped"; company: string } & Omit<
|
|
420
|
-
Extract<SyncProgressEvent, { type: "not-shipped" }>,
|
|
421
|
-
"type"
|
|
422
|
-
>)
|
|
423
|
-
| ({
|
|
424
|
-
type: "complete";
|
|
425
|
-
company: string;
|
|
426
|
-
/**
|
|
427
|
-
* Upload counters. Always emitted (0 when the run was pull-only) so
|
|
428
|
-
* downstream consumers don't need to conditionally read the field.
|
|
429
|
-
*/
|
|
430
|
-
filesUploaded: number;
|
|
431
|
-
bytesUploaded: number;
|
|
432
|
-
/**
|
|
433
|
-
* Push-side counters added in 5.25. Always emitted as numbers (0
|
|
434
|
-
* when no push leg ran). Tauri's `SyncCompleteEvent` carries them
|
|
435
|
-
* as Option<u32> for back-compat with <5.25 engines that don't
|
|
436
|
-
* include them; structural-typing-wise, the union just adds
|
|
437
|
-
* properties on top of `SyncResult`.
|
|
438
|
-
*/
|
|
439
|
-
filesTombstoned: number;
|
|
440
|
-
filesRefusedStale: number;
|
|
441
|
-
/**
|
|
442
|
-
* Paths corresponding to `filesRefusedStale`, capped at 50 (mirrors
|
|
443
|
-
* `newFiles` cap). Surfaced on the `complete` event so operators
|
|
444
|
-
* can triage the recurring `filesRefusedStale: 205` signal that
|
|
445
|
-
* the 5.33.0 deep-test flagged as untriageable — the count alone
|
|
446
|
-
* is impossible to investigate after the per-file
|
|
447
|
-
* `delete-refused-stale-etag` events scroll off.
|
|
448
|
-
*/
|
|
449
|
-
filesRefusedStalePaths: string[];
|
|
450
|
-
filesExcludedByPolicy: number;
|
|
451
|
-
} & SyncResult)
|
|
452
|
-
| {
|
|
453
|
-
type: "all-complete";
|
|
454
|
-
companiesAttempted: number;
|
|
455
|
-
filesDownloaded: number;
|
|
456
|
-
bytesDownloaded: number;
|
|
457
|
-
/** Always emitted; 0 when no push phase ran. */
|
|
458
|
-
filesUploaded: number;
|
|
459
|
-
bytesUploaded: number;
|
|
460
|
-
/**
|
|
461
|
-
* Conflict file paths aggregated across every company in the run.
|
|
462
|
-
* Always emitted; empty array when no conflicts were detected. Lets
|
|
463
|
-
* the menubar UI render a flat list without re-walking per-company
|
|
464
|
-
* `complete` events.
|
|
465
|
-
*/
|
|
466
|
-
conflictPaths: Array<{ company: string; path: string; direction: "pull" | "push" }>;
|
|
467
|
-
errors: Array<{ company: string; message: string }>;
|
|
468
|
-
/**
|
|
469
|
-
* Retryable company-leg transport failures. Kept separate from `errors`
|
|
470
|
-
* so consumers can surface an incomplete pass without calling it a
|
|
471
|
-
* deterministic sync error. Always present for backward-compatible,
|
|
472
|
-
* additive protocol evolution.
|
|
473
|
-
*/
|
|
474
|
-
transient: Array<{ company: string; message: string }>;
|
|
475
|
-
/**
|
|
476
|
-
* True when at least one company in the fanout did not complete cleanly
|
|
477
|
-
* — either it returned `aborted: true` (e.g. conflict-abort) or its sync
|
|
478
|
-
* function threw mid-stream (e.g. mid-fanout 401). When `partial: true`,
|
|
479
|
-
* the totals above include partial counts captured from `progress` events
|
|
480
|
-
* before the abort, NOT just companies that emitted a clean `complete`.
|
|
481
|
-
*
|
|
482
|
-
* Automated monitors should check this field — `errors.length > 0` alone
|
|
483
|
-
* isn't sufficient because a `aborted: true` return doesn't push to
|
|
484
|
-
* `errors` (it's a clean conflict-abort, not an exception).
|
|
485
|
-
*/
|
|
486
|
-
partial: boolean;
|
|
487
|
-
/**
|
|
488
|
-
* Per-company breakdown of the fanout. Always present, one entry per
|
|
489
|
-
* planned company, in fanout order. Lets consumers reconcile per-company
|
|
490
|
-
* partial counts with the aggregate without re-walking `complete` /
|
|
491
|
-
* `error` event streams. The `status` field is the canonical signal:
|
|
492
|
-
* - "complete" — sync returned cleanly, `aborted: false`
|
|
493
|
-
* - "aborted" — sync returned cleanly with `aborted: true` (conflict-abort)
|
|
494
|
-
* - "errored" — sync threw mid-stream; counts are sourced from progress
|
|
495
|
-
* events seen before the throw
|
|
496
|
-
* - "transient-network" — a retryable transport failure interrupted the
|
|
497
|
-
* leg; see `transient` for its diagnostic
|
|
498
|
-
*/
|
|
499
|
-
companies: Array<{
|
|
500
|
-
company: string;
|
|
501
|
-
status: "complete" | "aborted" | "errored" | "transient-network";
|
|
502
|
-
filesDownloaded: number;
|
|
503
|
-
bytesDownloaded: number;
|
|
504
|
-
filesUploaded: number;
|
|
505
|
-
bytesUploaded: number;
|
|
506
|
-
}>;
|
|
507
|
-
}
|
|
508
|
-
| {
|
|
509
|
-
/**
|
|
510
|
-
* Emitted at most ONCE per fanout, AFTER `all-complete`, when the
|
|
511
|
-
* post-sync conflict-ledger prune leaves one or more PRESERVED conflict
|
|
512
|
-
* variants that a human still has to resolve (`kept > 0`). The prune
|
|
513
|
-
* self-heals the ledger (drops orphaned + byte-identical false-positive
|
|
514
|
-
* rows), but genuine divergences are conservatively KEPT — and pre-fix
|
|
515
|
-
* that residual set was silent, so the reporter of feedback_d2082110
|
|
516
|
-
* finished a full sync with "20 older preserved conflict entries" still
|
|
517
|
-
* on disk and no signal they were there. This event reconciles and
|
|
518
|
-
* surfaces the remaining count so the operator knows to run
|
|
519
|
-
* `/resolve-conflicts`. Ledger-global (not per-company — the index lives
|
|
520
|
-
* at the HQ root). Not emitted when the ledger is clean (`kept === 0`).
|
|
521
|
-
*
|
|
522
|
-
* `count` is the number of preserved conflict rows still awaiting
|
|
523
|
-
* resolution; `samplePaths` carries up to 10 original (non-mirror) paths
|
|
524
|
-
* for display.
|
|
525
|
-
*/
|
|
526
|
-
type: "conflicts-remaining";
|
|
527
|
-
count: number;
|
|
528
|
-
samplePaths: string[];
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* The narrow VaultClient surface the runner actually uses. Declared here (not
|
|
533
|
-
* `Pick<VaultClient, ...>`) because `Pick` preserves the *entire* `entity`
|
|
534
|
-
* accessor object — but the runner only needs `entity.get`, and forcing test
|
|
535
|
-
* stubs to also implement `findBySlug`/`create` would be dishonest about the
|
|
536
|
-
* real dependency. Keep this interface in sync with the real VaultClient
|
|
537
|
-
* method signatures (both return types come straight from the SDK).
|
|
538
|
-
*/
|
|
539
|
-
export interface VaultClientSurface {
|
|
540
|
-
listMyMemberships: () => Promise<Membership[]>;
|
|
541
|
-
listMyPendingInvitesByEmail: () => Promise<PendingInviteByEmail[]>;
|
|
542
|
-
claimPendingInvitesByEmail: (personUid: string) => Promise<void>;
|
|
543
|
-
ensureMyPersonEntity: (hints: {
|
|
544
|
-
ownerSub: string;
|
|
545
|
-
displayName: string;
|
|
546
|
-
}) => Promise<EntityInfo>;
|
|
547
|
-
entity: {
|
|
548
|
-
get: (uid: string) => Promise<EntityInfo>;
|
|
549
|
-
listByType: (type: string) => Promise<EntityInfo[]>;
|
|
550
|
-
};
|
|
551
|
-
// US-005 scope resolution. Optional so older test stubs (and any
|
|
552
|
-
// VaultClientSurface impl that predates sync-config) still satisfy the
|
|
553
|
-
// interface; when absent, `resolvePullScope` degrades to `all`.
|
|
554
|
-
getMembershipSyncConfig?: (membershipId: string) => Promise<MembershipSyncConfig>;
|
|
555
|
-
listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
|
|
556
|
-
postTelemetryEvents?: (
|
|
557
|
-
batch: TelemetryEventsBatch,
|
|
558
|
-
options?: { timeoutMs?: number },
|
|
559
|
-
) => Promise<TelemetryEventsIngestResult>;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
interface RunnerDiagnostic {
|
|
563
|
-
component: string;
|
|
564
|
-
event: string;
|
|
565
|
-
message: string;
|
|
566
|
-
err?: unknown;
|
|
567
|
-
context?: Record<string, unknown>;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
type RunnerDiagnosticReporter = (diagnostic: RunnerDiagnostic) => void;
|
|
571
|
-
|
|
572
|
-
function errorDetails(err: unknown): Record<string, unknown> {
|
|
573
|
-
const details: Record<string, unknown> = { message: describeError(err) };
|
|
574
|
-
if (err instanceof Error) {
|
|
575
|
-
if (err.name) details.name = err.name;
|
|
576
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
577
|
-
if (code) details.code = code;
|
|
578
|
-
}
|
|
579
|
-
return details;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
function createRunnerDiagnosticReporter(
|
|
583
|
-
stderr: { write: (chunk: string) => boolean | void },
|
|
584
|
-
): RunnerDiagnosticReporter {
|
|
585
|
-
return (diagnostic) => {
|
|
586
|
-
try {
|
|
587
|
-
const payload: Record<string, unknown> = {
|
|
588
|
-
type: "error",
|
|
589
|
-
diagnostic: true,
|
|
590
|
-
component: diagnostic.component,
|
|
591
|
-
event: diagnostic.event,
|
|
592
|
-
path: `(${diagnostic.component})`,
|
|
593
|
-
message:
|
|
594
|
-
diagnostic.err === undefined
|
|
595
|
-
? diagnostic.message
|
|
596
|
-
: `${diagnostic.message}: ${describeError(diagnostic.err)}`,
|
|
597
|
-
...(diagnostic.context ?? {}),
|
|
598
|
-
};
|
|
599
|
-
if (diagnostic.err !== undefined) {
|
|
600
|
-
payload.err = errorDetails(diagnostic.err);
|
|
601
|
-
}
|
|
602
|
-
stderr.write(`${JSON.stringify(payload)}\n`);
|
|
603
|
-
} catch {
|
|
604
|
-
/* diagnostics must never affect runner control flow */
|
|
605
|
-
}
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// `resolvePullScope`, `readPinnedPrefixes`, and the `PullScope` type now live
|
|
610
|
-
// in `../sync/pull-scope.ts` so the menubar runner and `hq sync pull|now`
|
|
611
|
-
// (hq-cli) share ONE scope resolver — the drift between them was the root
|
|
612
|
-
// cause of the all→shared scope-shrink wedge (DEV-1768). Re-exported here so
|
|
613
|
-
// existing importers (and the runner test suite) keep their import path.
|
|
614
|
-
export { resolvePullScope, readPinnedPrefixes, SESSIONS_SCOPE_PREFIX };
|
|
615
|
-
export type { PullScope };
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* Backoff schedule (in ms) between attempts 2 and 3 of
|
|
619
|
-
* `listMembershipsWithRetry`. Short on purpose — memberships is a single
|
|
620
|
-
* API call gating the whole runner, and a transient blip (DNS hiccup,
|
|
621
|
-
* idle ALB connection reset) usually clears in <50ms. If the network is
|
|
622
|
-
* genuinely down, three attempts in <200ms total fail fast enough that
|
|
623
|
-
* the tray can show its error banner before the user notices a delay.
|
|
624
|
-
*/
|
|
625
|
-
const MEMBERSHIPS_RETRY_BACKOFFS_MS: readonly number[] = [50, 100];
|
|
626
|
-
|
|
627
|
-
const REAUTH_MESSAGE =
|
|
628
|
-
"Your HQ session needs a quick refresh. Sign in again to keep sync moving.";
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Internal-only result used between a watch pass and its owning loop. The
|
|
632
|
-
* outer process still exits 0 (auth-required is a handled UX state), while
|
|
633
|
-
* the loop stops instead of retrying a known-unusable session forever.
|
|
634
|
-
*/
|
|
635
|
-
export const AUTH_REQUIRED_PASS_EXIT = 18;
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Call `listMyMemberships()` with up to 3 attempts and a small linear
|
|
639
|
-
* backoff between them. The single network call that drives every cloud
|
|
640
|
-
* company target plus the personal-vault slot — a one-off network blip
|
|
641
|
-
* shouldn't kill the whole sync run.
|
|
642
|
-
*
|
|
643
|
-
* Auth failures (VaultAuthError) bypass retry entirely: re-vending creds
|
|
644
|
-
* is the caller's job, not retryable in-process. Re-throwing immediately
|
|
645
|
-
* preserves the existing auth-error event semantics in the outer
|
|
646
|
-
* try/catch.
|
|
647
|
-
*/
|
|
648
|
-
async function listMembershipsWithRetry(
|
|
649
|
-
client: VaultClientSurface,
|
|
650
|
-
): Promise<Membership[]> {
|
|
651
|
-
let lastErr: unknown;
|
|
652
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
653
|
-
if (attempt > 0) {
|
|
654
|
-
const delayMs = MEMBERSHIPS_RETRY_BACKOFFS_MS[attempt - 1] ?? 100;
|
|
655
|
-
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
|
656
|
-
}
|
|
657
|
-
try {
|
|
658
|
-
return await client.listMyMemberships();
|
|
659
|
-
} catch (err) {
|
|
660
|
-
if (err instanceof VaultAuthError) throw err;
|
|
661
|
-
lastErr = err;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
throw lastErr;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/** Minimal shape of the claims we read off the Cognito idToken. */
|
|
668
|
-
interface IdTokenClaims {
|
|
669
|
-
sub?: string;
|
|
670
|
-
email?: string;
|
|
671
|
-
name?: string;
|
|
672
|
-
given_name?: string;
|
|
673
|
-
family_name?: string;
|
|
674
|
-
/**
|
|
675
|
-
* Entity-bound machine-principal claims. A headless agent or Outpost
|
|
676
|
-
* authenticates with machine credentials; its idToken carries the machine's
|
|
677
|
-
* own entity binding here. The runner preserves those authenticated claims
|
|
678
|
-
* during planning. Agent claims additionally drive `--personal` target
|
|
679
|
-
* resolution to the agent's OWN entity (`custom:entityUid`, `agt_*`) instead
|
|
680
|
-
* of the person-only canonical pick.
|
|
681
|
-
*/
|
|
682
|
-
"custom:entityType"?: string;
|
|
683
|
-
"custom:entityUid"?: string;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
export interface RunnerDeps {
|
|
687
|
-
/** Where to write ndjson events. Defaults to `process.stdout`. */
|
|
688
|
-
stdout?: { write: (chunk: string) => boolean | void };
|
|
689
|
-
/** Where to write diagnostics. Defaults to `process.stderr`. */
|
|
690
|
-
stderr?: { write: (chunk: string) => boolean | void };
|
|
691
|
-
/** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
|
|
692
|
-
getAccessToken?: () => Promise<string>;
|
|
693
|
-
/** Invalidate only the rejected token generation. Injectable for tests. */
|
|
694
|
-
clearSession?: (tokenFingerprint?: string) => void;
|
|
695
|
-
/**
|
|
696
|
-
* Read the caller's identity claims (sub/email/name) off the cached Cognito
|
|
697
|
-
* idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null`
|
|
698
|
-
* when no cached tokens exist — the runner will then skip the claim-dance
|
|
699
|
-
* and fall through to the usual listMyMemberships path.
|
|
700
|
-
*/
|
|
701
|
-
getIdTokenClaims?: () => IdTokenClaims | null;
|
|
702
|
-
/**
|
|
703
|
-
* Produce a VaultClient-like object. Defaults to `new VaultClient(config)`.
|
|
704
|
-
* Tests inject a stub here — the runner only calls the methods listed in
|
|
705
|
-
* `VaultClientSurface`.
|
|
706
|
-
*/
|
|
707
|
-
createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
|
|
708
|
-
/** Sync function. Defaults to `cli/sync.sync`. */
|
|
709
|
-
sync?: (options: SyncOptions) => Promise<SyncResult>;
|
|
710
|
-
/** Native post-fanout reindex function. Defaults to `cli/reindex.reindex`. */
|
|
711
|
-
reindex?: (options: ReindexOptions) => ReindexResult;
|
|
712
|
-
/** QMD post-sync reindex function. Defaults to `qmd-reindex.reindexAfterSync`. */
|
|
713
|
-
qmdReindex?: (
|
|
714
|
-
hqRoot: string,
|
|
715
|
-
options: QmdReindexOptions,
|
|
716
|
-
) => ReturnType<typeof reindexAfterSync>;
|
|
717
|
-
/**
|
|
718
|
-
* Merge successfully pulled cloud companies into the local manifest after
|
|
719
|
-
* the complete fanout. Injectable so runner tests do not touch a developer's
|
|
720
|
-
* HQ root.
|
|
721
|
-
*/
|
|
722
|
-
reconcileManifest?: (
|
|
723
|
-
options: ManifestReconcileOptions,
|
|
724
|
-
) => Promise<ManifestReconcileResult>;
|
|
725
|
-
/** Internal test seam for `.hq/config.json` activeCompany seeding. */
|
|
726
|
-
seedActiveCompany?: (options: SeedActiveCompanyOptions) => SeedActiveCompanyResult;
|
|
727
|
-
/** Internal: set when runRunner is invoked under the per-root operation lock. */
|
|
728
|
-
operationLockAlreadyHeld?: boolean;
|
|
729
|
-
/** Internal watch-loop result override; one-shot callers keep exit 0. */
|
|
730
|
-
authRequiredExitCode?: number;
|
|
731
|
-
/** Internal watch-loop callback carrying uncapped per-path push outcomes. */
|
|
732
|
-
onPassResult?: (result: RunnerPassResult) => void;
|
|
733
|
-
/** Share function (push phase). Defaults to `cli/share.share`. */
|
|
734
|
-
share?: (options: ShareOptions) => Promise<ShareResult>;
|
|
735
|
-
/**
|
|
736
|
-
* Telemetry collector — runs just before the `all-complete` emit. Default
|
|
737
|
-
* implementation calls `collectAndSendTelemetry` from `../telemetry.js`
|
|
738
|
-
* using the real VaultClient; tests that inject `createVaultClient` are
|
|
739
|
-
* implicitly opted out (the default skips when the client isn't a real
|
|
740
|
-
* `VaultClient`). Tests that want to assert telemetry behavior should pass
|
|
741
|
-
* an explicit stub here.
|
|
742
|
-
*/
|
|
743
|
-
collectTelemetry?: () => Promise<void>;
|
|
744
|
-
/** Maximum time to wait for pre-completion telemetry. Defaults to 120s. */
|
|
745
|
-
telemetryTimeoutMs?: number;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
export interface RunnerPassResult {
|
|
749
|
-
pushPathResults: Array<{
|
|
750
|
-
relativePath: string;
|
|
751
|
-
status: "accepted" | "refused";
|
|
752
|
-
operation: "delete" | "tombstone";
|
|
753
|
-
reason?: string;
|
|
754
|
-
}>;
|
|
755
|
-
/** Company legs that threw before finishing their push/pull work. */
|
|
756
|
-
companyFailures?: Array<{ company: string; message: string }>;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
export interface RunnerPassOutcome {
|
|
760
|
-
exitCode: number;
|
|
761
|
-
result?: RunnerPassResult;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// ---------------------------------------------------------------------------
|
|
765
|
-
// JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
|
|
766
|
-
// We do NOT verify the signature here — Cognito already did that when it
|
|
767
|
-
// issued the token, and we only read the public claims (sub/email/name) to
|
|
768
|
-
// drive the claim-dance + create the person entity. If the token is tampered
|
|
769
|
-
// with, the downstream vault-service call will reject it (signature-verified
|
|
770
|
-
// there) long before any claimed value causes harm.
|
|
771
|
-
// ---------------------------------------------------------------------------
|
|
772
|
-
|
|
773
|
-
function decodeJwtClaims(jwt: string): IdTokenClaims | null {
|
|
774
|
-
const parts = jwt.split(".");
|
|
775
|
-
if (parts.length !== 3) return null;
|
|
776
|
-
try {
|
|
777
|
-
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
778
|
-
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
|
779
|
-
const json = Buffer.from(padded, "base64").toString("utf-8");
|
|
780
|
-
return JSON.parse(json) as IdTokenClaims;
|
|
781
|
-
} catch {
|
|
782
|
-
return null;
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
function defaultGetIdTokenClaims(): IdTokenClaims | null {
|
|
787
|
-
const tokens: CognitoTokens | null = loadCachedTokens();
|
|
788
|
-
if (!tokens?.idToken) return null;
|
|
789
|
-
return decodeJwtClaims(tokens.idToken);
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
function isQmdIndexableContentPath(relPath: string): boolean {
|
|
793
|
-
const normalized = relPath.split(path.sep).join("/");
|
|
794
|
-
return (
|
|
795
|
-
/^companies\/[^/]+\/knowledge\/.+\.md$/i.test(normalized) ||
|
|
796
|
-
/^companies\/[^/]+\/projects\/.+\.(?:md|json)$/i.test(normalized) ||
|
|
797
|
-
/^personal\/knowledge\/.+\.md$/i.test(normalized)
|
|
798
|
-
);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* Best-effort: claim any email-keyed pending invites that were sent before
|
|
803
|
-
* this user had a person entity. Mirrors the installer's vault-handoff flow.
|
|
804
|
-
*
|
|
805
|
-
* Silent on the happy path — only logs to stderr on soft failures (so a
|
|
806
|
-
* transient network blip doesn't block the sync). Never throws: a caller who
|
|
807
|
-
* can't list memberships despite an unclaimed invite is no worse off than the
|
|
808
|
-
* pre-claim-dance behavior (which was to emit setup-needed).
|
|
809
|
-
*/
|
|
810
|
-
async function runClaimDance(
|
|
811
|
-
client: VaultClientSurface,
|
|
812
|
-
claims: IdTokenClaims,
|
|
813
|
-
stderr: { write: (chunk: string) => boolean | void },
|
|
814
|
-
reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
|
|
815
|
-
): Promise<number> {
|
|
816
|
-
try {
|
|
817
|
-
const pending = await client.listMyPendingInvitesByEmail();
|
|
818
|
-
if (pending.length === 0) return 0;
|
|
819
|
-
|
|
820
|
-
const displayName =
|
|
821
|
-
claims.name ??
|
|
822
|
-
[claims.given_name, claims.family_name].filter(Boolean).join(" ") ??
|
|
823
|
-
claims.email ??
|
|
824
|
-
"";
|
|
825
|
-
const ownerSub = claims.sub ?? "";
|
|
826
|
-
if (!ownerSub || !displayName) {
|
|
827
|
-
reportDiagnostic({
|
|
828
|
-
component: "claim-dance",
|
|
829
|
-
event: "runner.claim_dance.skipped",
|
|
830
|
-
message: "skipping claim-dance — idToken missing sub/name",
|
|
831
|
-
context: { pendingInviteCount: pending.length },
|
|
832
|
-
});
|
|
833
|
-
// The invites are real and still pending; the caller must be able to say
|
|
834
|
-
// so even though we could not claim them.
|
|
835
|
-
return pending.length;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
const person = await client.ensureMyPersonEntity({
|
|
839
|
-
ownerSub,
|
|
840
|
-
displayName,
|
|
841
|
-
});
|
|
842
|
-
await client.claimPendingInvitesByEmail(person.uid);
|
|
843
|
-
return pending.length;
|
|
844
|
-
} catch (err) {
|
|
845
|
-
// Non-throwing by contract: a claim-dance failure must never take down a
|
|
846
|
-
// sync. But it stops being invisible — a systematic failure here is exactly
|
|
847
|
-
// what makes a joiner look permanently solo.
|
|
848
|
-
reportDiagnostic({
|
|
849
|
-
component: "claim-dance",
|
|
850
|
-
event: "runner.claim_dance.skipped",
|
|
851
|
-
message: "claim-dance skipped",
|
|
852
|
-
err,
|
|
853
|
-
context: {},
|
|
854
|
-
});
|
|
855
|
-
return 0;
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// ---------------------------------------------------------------------------
|
|
860
|
-
// argv parser — intentionally minimal (no commander/yargs dep)
|
|
861
|
-
// ---------------------------------------------------------------------------
|
|
862
|
-
|
|
863
|
-
interface ParsedArgs {
|
|
864
|
-
companies: boolean;
|
|
865
|
-
company?: string;
|
|
866
|
-
/**
|
|
867
|
-
* Personal-vault-only mode. Mutually exclusive with `--companies` and
|
|
868
|
-
* `--company`. Skips `listMyMemberships` (and therefore the claim-dance);
|
|
869
|
-
* builds a fanout plan containing ONLY the personal target. Designed as
|
|
870
|
-
* the runner-side entry point that replaces Rust's
|
|
871
|
-
* `personal.rs::run_personal_first_push` first-push walker — so the
|
|
872
|
-
* personal-vault scope (`computePersonalVaultPaths`) lives in exactly
|
|
873
|
-
* one place (this TS engine) and not duplicated across engines.
|
|
874
|
-
*/
|
|
875
|
-
personal: boolean;
|
|
876
|
-
onConflict: ConflictStrategy;
|
|
877
|
-
hqRoot: string;
|
|
878
|
-
direction: Direction;
|
|
879
|
-
/** Auto-sync (Beta): keep the runner alive after the first pass. */
|
|
880
|
-
watch: boolean;
|
|
881
|
-
/** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
|
|
882
|
-
pollRemoteMs?: number;
|
|
883
|
-
/**
|
|
884
|
-
* Event-driven push (Phase 1). When set (and `--watch` is on), the runner
|
|
885
|
-
* starts a {@link TreeWatcher} alongside the poll loop and pushes a targeted
|
|
886
|
-
* company/subtree within the debounce window of a local edit — instead of
|
|
887
|
-
* waiting up to a full `--poll-remote-ms` cycle. Gated OFF by default; the
|
|
888
|
-
* menubar passes it only for `@getindigo.ai` identities (see PRD decision).
|
|
889
|
-
* No-op without `--watch` (the one-shot path has nothing to keep alive).
|
|
890
|
-
*/
|
|
891
|
-
eventPush: boolean;
|
|
892
|
-
/**
|
|
893
|
-
* Drop the personal target from the fanout. Combined with the
|
|
894
|
-
* `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
|
|
895
|
-
* truthy disables personal sync for this run. No-op outside `--companies`
|
|
896
|
-
* mode (single-company runs never visit the personal target).
|
|
897
|
-
*/
|
|
898
|
-
skipPersonal: boolean;
|
|
899
|
-
/** Repeatable company/personal-relative path scope for push-only passes. */
|
|
900
|
-
scopePaths: string[];
|
|
901
|
-
/**
|
|
902
|
-
* Internal watcher-only delete roots. Unlike `scopePaths`, these remain
|
|
903
|
-
* meaningful after the corresponding local file or directory is absent.
|
|
904
|
-
*/
|
|
905
|
-
deleteScopePaths: string[];
|
|
906
|
-
/**
|
|
907
|
-
* Bounded wait (seconds) for the per-root operation lock when another op is
|
|
908
|
-
* already running. `0` → refuse immediately (pre-wait behavior); omitted →
|
|
909
|
-
* inherit `HQ_OP_LOCK_TIMEOUT` / infinite wait. Only meaningful on the
|
|
910
|
-
* one-shot path (the `--watch` runner is lock-exempt).
|
|
911
|
-
*/
|
|
912
|
-
lockTimeoutSec?: number;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
916
|
-
let companies = false;
|
|
917
|
-
let company: string | undefined;
|
|
918
|
-
let personal = false;
|
|
919
|
-
let onConflict: ConflictStrategy = "abort";
|
|
920
|
-
let hqRoot = DEFAULT_HQ_ROOT;
|
|
921
|
-
let direction: Direction = "pull";
|
|
922
|
-
let watch = false;
|
|
923
|
-
let pollRemoteMs: number | undefined;
|
|
924
|
-
let skipPersonal = false;
|
|
925
|
-
const scopePaths: string[] = [];
|
|
926
|
-
const deleteScopePaths: string[] = [];
|
|
927
|
-
let eventPush = false;
|
|
928
|
-
let lockTimeoutSec: number | undefined;
|
|
929
|
-
|
|
930
|
-
for (let i = 0; i < argv.length; i++) {
|
|
931
|
-
const arg = argv[i];
|
|
932
|
-
switch (arg) {
|
|
933
|
-
case "--companies":
|
|
934
|
-
companies = true;
|
|
935
|
-
break;
|
|
936
|
-
case "--company":
|
|
937
|
-
company = argv[++i];
|
|
938
|
-
if (!company) return { error: "--company requires a value" };
|
|
939
|
-
break;
|
|
940
|
-
case "--personal":
|
|
941
|
-
// Personal-vault-only mode. Skips listMyMemberships + claim-dance
|
|
942
|
-
// entirely; builds a fanout plan containing only the personal target.
|
|
943
|
-
// Replaces Rust's personal.rs::run_personal_first_push walker —
|
|
944
|
-
// the personal-vault scope (computePersonalVaultPaths) is owned
|
|
945
|
-
// by this engine, not duplicated across Rust + TS.
|
|
946
|
-
personal = true;
|
|
947
|
-
break;
|
|
948
|
-
case "--on-conflict": {
|
|
949
|
-
const val = argv[++i];
|
|
950
|
-
if (val !== "abort" && val !== "overwrite" && val !== "keep") {
|
|
951
|
-
return {
|
|
952
|
-
error: `--on-conflict must be one of abort|overwrite|keep, got: ${val ?? "(missing)"}`,
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
onConflict = val;
|
|
956
|
-
break;
|
|
957
|
-
}
|
|
958
|
-
case "--direction": {
|
|
959
|
-
const val = argv[++i];
|
|
960
|
-
if (val !== "pull" && val !== "push" && val !== "both") {
|
|
961
|
-
return {
|
|
962
|
-
error: `--direction must be one of pull|push|both, got: ${val ?? "(missing)"}`,
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
direction = val;
|
|
966
|
-
break;
|
|
967
|
-
}
|
|
968
|
-
case "--hq-root":
|
|
969
|
-
hqRoot = argv[++i];
|
|
970
|
-
if (!hqRoot) return { error: "--hq-root requires a value" };
|
|
971
|
-
break;
|
|
972
|
-
case "--watch":
|
|
973
|
-
watch = true;
|
|
974
|
-
break;
|
|
975
|
-
case "--poll-remote-ms": {
|
|
976
|
-
const val = argv[++i];
|
|
977
|
-
if (!val) return { error: "--poll-remote-ms requires a value" };
|
|
978
|
-
const n = Number(val);
|
|
979
|
-
if (!Number.isInteger(n) || n <= 0) {
|
|
980
|
-
return {
|
|
981
|
-
error: `--poll-remote-ms must be a positive integer (ms), got: ${val}`,
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
pollRemoteMs = n;
|
|
985
|
-
break;
|
|
986
|
-
}
|
|
987
|
-
case "--json":
|
|
988
|
-
// Accepted but ignored — ndjson is the only output mode.
|
|
989
|
-
break;
|
|
990
|
-
case "--skip-personal":
|
|
991
|
-
// Drop the personal target from the fanout. No-op outside
|
|
992
|
-
// --companies mode. Combined with HQ_SYNC_SKIP_PERSONAL env via
|
|
993
|
-
// resolveSkipPersonal().
|
|
994
|
-
skipPersonal = true;
|
|
995
|
-
break;
|
|
996
|
-
case "--event-push":
|
|
997
|
-
// Phase 1 event-driven push enable flag. Requires --watch (validated
|
|
998
|
-
// below). Gated OFF by default; the menubar only passes it for
|
|
999
|
-
// @getindigo.ai identities for the first release.
|
|
1000
|
-
eventPush = true;
|
|
1001
|
-
break;
|
|
1002
|
-
case "--scope-path": {
|
|
1003
|
-
const val = argv[++i];
|
|
1004
|
-
if (!val) return { error: "--scope-path requires a value" };
|
|
1005
|
-
scopePaths.push(val);
|
|
1006
|
-
break;
|
|
1007
|
-
}
|
|
1008
|
-
case "--delete-scope-path": {
|
|
1009
|
-
const val = argv[++i];
|
|
1010
|
-
if (!val) return { error: "--delete-scope-path requires a value" };
|
|
1011
|
-
deleteScopePaths.push(val);
|
|
1012
|
-
break;
|
|
1013
|
-
}
|
|
1014
|
-
case "--lock-timeout": {
|
|
1015
|
-
const val = argv[++i];
|
|
1016
|
-
if (!val) return { error: "--lock-timeout requires a value (seconds)" };
|
|
1017
|
-
const n = Number(val);
|
|
1018
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
1019
|
-
return {
|
|
1020
|
-
error: `--lock-timeout must be a non-negative integer (seconds), got: ${val}`,
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
lockTimeoutSec = n;
|
|
1024
|
-
break;
|
|
1025
|
-
}
|
|
1026
|
-
default:
|
|
1027
|
-
return { error: `Unknown argument: ${arg}` };
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
if (companies && company) {
|
|
1032
|
-
return { error: "Pass --companies OR --company <slug>, not both" };
|
|
1033
|
-
}
|
|
1034
|
-
if (personal && (companies || company)) {
|
|
1035
|
-
return {
|
|
1036
|
-
error: "--personal is mutually exclusive with --companies / --company",
|
|
1037
|
-
};
|
|
1038
|
-
}
|
|
1039
|
-
if (personal && skipPersonal) {
|
|
1040
|
-
return {
|
|
1041
|
-
error: "--personal and --skip-personal are contradictory",
|
|
1042
|
-
};
|
|
1043
|
-
}
|
|
1044
|
-
if (!companies && !company && !personal) {
|
|
1045
|
-
return { error: "Pass --companies, --company <slug>, or --personal" };
|
|
1046
|
-
}
|
|
1047
|
-
if (pollRemoteMs !== undefined && !watch) {
|
|
1048
|
-
return { error: "--poll-remote-ms requires --watch" };
|
|
1049
|
-
}
|
|
1050
|
-
if (eventPush && !watch) {
|
|
1051
|
-
return { error: "--event-push requires --watch" };
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
return {
|
|
1055
|
-
companies,
|
|
1056
|
-
company,
|
|
1057
|
-
personal,
|
|
1058
|
-
onConflict,
|
|
1059
|
-
hqRoot,
|
|
1060
|
-
direction,
|
|
1061
|
-
watch,
|
|
1062
|
-
pollRemoteMs,
|
|
1063
|
-
skipPersonal,
|
|
1064
|
-
scopePaths,
|
|
1065
|
-
deleteScopePaths,
|
|
1066
|
-
eventPush,
|
|
1067
|
-
lockTimeoutSec,
|
|
1068
|
-
};
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
// ---------------------------------------------------------------------------
|
|
1072
|
-
// Telemetry default — closes over the runner's vault client. Skipped when
|
|
1073
|
-
// the caller injected a `createVaultClient` stub, because we have no
|
|
1074
|
-
// guarantee the stub implements `getTelemetryOptIn` / `postUsage`. The real
|
|
1075
|
-
// `VaultClient` from `../vault-client.js` always does. All errors are
|
|
1076
|
-
// swallowed — telemetry must never abort or delay sync.
|
|
1077
|
-
// ---------------------------------------------------------------------------
|
|
1078
|
-
|
|
1079
|
-
export async function defaultCollectTelemetry(
|
|
1080
|
-
client: VaultClientSurface,
|
|
1081
|
-
clientIsStub: boolean,
|
|
1082
|
-
hqRoot: string,
|
|
1083
|
-
reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
|
|
1084
|
-
fallbackCompany?: string,
|
|
1085
|
-
): Promise<void> {
|
|
1086
|
-
if (clientIsStub) return;
|
|
1087
|
-
|
|
1088
|
-
// Person-entity gate (the "onboarding gate" the telemetry call-site must
|
|
1089
|
-
// sit behind). Both telemetry passes below resolve the caller's `personUid`
|
|
1090
|
-
// SERVER-side from the JWT and reject with a 4xx when the caller has not yet
|
|
1091
|
-
// been provisioned a person entity: `getTelemetryOptIn()` (the first call of
|
|
1092
|
-
// each pass) hits `GET /v1/usage/opt-in`, which 404s `no-person-entity` for
|
|
1093
|
-
// an unprovisioned caller, and the skill pass's `POST /v1/skill-invocations`
|
|
1094
|
-
// does the same. hq-pro logs every such reject as a Sentry *warning*, so an
|
|
1095
|
-
// unprovisioned-but-signed-in identity that runs telemetry on a loop — e.g.
|
|
1096
|
-
// a machine/daemon/outpost identity, or a single-company sync that fabricates
|
|
1097
|
-
// its membership and never runs the onboarding gate — emits a steady stream
|
|
1098
|
-
// of benign `no-person-entity` warnings (Sentry HQ-4N). The reject itself is
|
|
1099
|
-
// harmless (telemetry is best-effort and the response is swallowed), but the
|
|
1100
|
-
// noise is not. Probe the caller's OWN person entity with the orphan-safe
|
|
1101
|
-
// `entity.listByType("person")` — `GET /entity/by-type/person` returns `200`
|
|
1102
|
-
// with an empty list for an unprovisioned caller (no reject, no warning),
|
|
1103
|
-
// and a non-empty result is the EXACT predicate for "the server will resolve
|
|
1104
|
-
// a personUid" (same caller-owned-person union the usage handler's
|
|
1105
|
-
// `getEffectivePersonUid` uses). Skip both passes when there is none. The
|
|
1106
|
-
// probe is the same canonical-person check the runner already uses to pick
|
|
1107
|
-
// the personal-vault target. Fail OPEN: a thrown probe (transient/network)
|
|
1108
|
-
// falls through to run telemetry as before, so a provisioned user's
|
|
1109
|
-
// telemetry is never dropped by a flaky probe.
|
|
1110
|
-
try {
|
|
1111
|
-
const persons = await client.entity.listByType("person");
|
|
1112
|
-
if (pickCanonicalPersonEntity(persons) === null) return;
|
|
1113
|
-
} catch (err) {
|
|
1114
|
-
reportDiagnostic({
|
|
1115
|
-
component: "telemetry",
|
|
1116
|
-
event: "runner.telemetry.person_probe_failed",
|
|
1117
|
-
message: "telemetry person probe failed; continuing with telemetry",
|
|
1118
|
-
err,
|
|
1119
|
-
context: { hqRoot },
|
|
1120
|
-
});
|
|
1121
|
-
// Probe failed — preserve prior behavior and let telemetry run.
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// machineId: hq-cloud owns provisioning via `<hqRoot>/.hq/machine-id`
|
|
1125
|
-
// (see `src/lib/machine-id.ts`). The resolver migrates forward from
|
|
1126
|
-
// any legacy `~/.hq/menubar.json` value on first call, then becomes
|
|
1127
|
-
// self-sufficient. On a clean Linux outpost (no menubar app), a fresh
|
|
1128
|
-
// UUID is generated + persisted, so this row is attributable rather
|
|
1129
|
-
// than collapsing onto the legacy `"unknown"` sentinel.
|
|
1130
|
-
//
|
|
1131
|
-
// installerVersion: callers (the Tauri menubar) set this when spawning
|
|
1132
|
-
// the runner so the historical `installerVersion` dimension on CloudWatch
|
|
1133
|
-
// keeps reporting the menubar version, not the runner's package version.
|
|
1134
|
-
// CLI callers can leave it unset.
|
|
1135
|
-
//
|
|
1136
|
-
// Resolved once and shared by both telemetry passes below. If identity
|
|
1137
|
-
// resolution itself throws, skip telemetry entirely.
|
|
1138
|
-
let machineId: string;
|
|
1139
|
-
let installerVersion: string;
|
|
1140
|
-
try {
|
|
1141
|
-
machineId = getOrCreateMachineId(hqRoot);
|
|
1142
|
-
installerVersion = process.env.HQ_INSTALLER_VERSION ?? "hq-cloud";
|
|
1143
|
-
} catch (err) {
|
|
1144
|
-
reportDiagnostic({
|
|
1145
|
-
component: "telemetry",
|
|
1146
|
-
event: "runner.telemetry.identity_failed",
|
|
1147
|
-
message: "telemetry identity resolution failed; skipping telemetry",
|
|
1148
|
-
err,
|
|
1149
|
-
context: { hqRoot },
|
|
1150
|
-
});
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
try {
|
|
1155
|
-
await collectAndSendTelemetry({
|
|
1156
|
-
// The runtime guarantee here is that `clientIsStub === false` means
|
|
1157
|
-
// `client` came from `new VaultClient(vaultConfig)` (see runRunner),
|
|
1158
|
-
// which structurally satisfies `TelemetryClientSurface`.
|
|
1159
|
-
client: client as unknown as Parameters<typeof collectAndSendTelemetry>[0]["client"],
|
|
1160
|
-
machineId,
|
|
1161
|
-
installerVersion,
|
|
1162
|
-
// Resolve each event's cwd → owning company via <hqRoot>/companies/
|
|
1163
|
-
// manifest.yaml and stamp companyUid (US-002). In explicit single-company
|
|
1164
|
-
// runs, unmatched cwd values inherit that one scoped company; multi-company
|
|
1165
|
-
// and personal runs keep them unattributed.
|
|
1166
|
-
hqRoot,
|
|
1167
|
-
fallbackCompany,
|
|
1168
|
-
log: (message) =>
|
|
1169
|
-
reportDiagnostic({
|
|
1170
|
-
component: "telemetry",
|
|
1171
|
-
event: "runner.telemetry.usage_nonfatal",
|
|
1172
|
-
message,
|
|
1173
|
-
context: { hqRoot },
|
|
1174
|
-
}),
|
|
1175
|
-
});
|
|
1176
|
-
} catch (err) {
|
|
1177
|
-
reportDiagnostic({
|
|
1178
|
-
component: "telemetry",
|
|
1179
|
-
event: "runner.telemetry.usage_failed",
|
|
1180
|
-
message: "usage telemetry collector threw",
|
|
1181
|
-
err,
|
|
1182
|
-
context: { hqRoot },
|
|
1183
|
-
});
|
|
1184
|
-
// Fire-and-forget; nothing escapes the boundary.
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
// Skill-invocation telemetry runs as an independent pass (own cursor, own
|
|
1188
|
-
// endpoint) so a failure here can never affect token telemetry above.
|
|
1189
|
-
try {
|
|
1190
|
-
await collectAndSendSkillTelemetry({
|
|
1191
|
-
client: client as unknown as Parameters<typeof collectAndSendSkillTelemetry>[0]["client"],
|
|
1192
|
-
machineId,
|
|
1193
|
-
installerVersion,
|
|
1194
|
-
// Scope skill capture to the HQ project — only invocations whose cwd is
|
|
1195
|
-
// the HQ root are emitted; skill usage in unrelated repos is excluded.
|
|
1196
|
-
hqRoot,
|
|
1197
|
-
log: (message) =>
|
|
1198
|
-
reportDiagnostic({
|
|
1199
|
-
component: "telemetry",
|
|
1200
|
-
event: "runner.telemetry.skill_nonfatal",
|
|
1201
|
-
message,
|
|
1202
|
-
context: { hqRoot },
|
|
1203
|
-
}),
|
|
1204
|
-
});
|
|
1205
|
-
} catch (err) {
|
|
1206
|
-
reportDiagnostic({
|
|
1207
|
-
component: "telemetry",
|
|
1208
|
-
event: "runner.telemetry.skill_failed",
|
|
1209
|
-
message: "skill telemetry collector threw",
|
|
1210
|
-
err,
|
|
1211
|
-
context: { hqRoot },
|
|
1212
|
-
});
|
|
1213
|
-
// Fire-and-forget; nothing escapes the boundary.
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
// Outcome telemetry runs as an independent pass (own cursor, own endpoint,
|
|
1217
|
-
// outcome-leaderboard US-004) so a failure here can never affect token or
|
|
1218
|
-
// skill telemetry above. It diffs prd.json story `passes` + board.json project
|
|
1219
|
-
// `status` transitions against a local cursor and POSTs story-completed /
|
|
1220
|
-
// project-shipped events. No machineId/installerVersion — the /v1/outcome-events
|
|
1221
|
-
// contract accepts only { events }.
|
|
1222
|
-
try {
|
|
1223
|
-
await collectAndSendOutcomeTelemetry({
|
|
1224
|
-
client: client as unknown as Parameters<typeof collectAndSendOutcomeTelemetry>[0]["client"],
|
|
1225
|
-
// Scan <hqRoot>/companies/* for prd.json/board.json and resolve each
|
|
1226
|
-
// project's owning company via <hqRoot>/companies/manifest.yaml.
|
|
1227
|
-
hqRoot,
|
|
1228
|
-
log: (message) =>
|
|
1229
|
-
reportDiagnostic({
|
|
1230
|
-
component: "telemetry",
|
|
1231
|
-
event: "runner.telemetry.outcome_nonfatal",
|
|
1232
|
-
message,
|
|
1233
|
-
context: { hqRoot },
|
|
1234
|
-
}),
|
|
1235
|
-
});
|
|
1236
|
-
} catch (err) {
|
|
1237
|
-
reportDiagnostic({
|
|
1238
|
-
component: "telemetry",
|
|
1239
|
-
event: "runner.telemetry.outcome_failed",
|
|
1240
|
-
message: "outcome telemetry collector threw",
|
|
1241
|
-
err,
|
|
1242
|
-
context: { hqRoot },
|
|
1243
|
-
});
|
|
1244
|
-
// Fire-and-forget; nothing escapes the boundary.
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
// ---------------------------------------------------------------------------
|
|
1249
|
-
// runRunner — testable entrypoint
|
|
1250
|
-
// ---------------------------------------------------------------------------
|
|
1251
|
-
|
|
1252
|
-
export async function runRunner(
|
|
1253
|
-
argv: string[],
|
|
1254
|
-
deps: RunnerDeps = {},
|
|
1255
|
-
): Promise<number> {
|
|
1256
|
-
const stdout = deps.stdout ?? process.stdout;
|
|
1257
|
-
const stderr = deps.stderr ?? process.stderr;
|
|
1258
|
-
const reportDiagnostic = createRunnerDiagnosticReporter(stderr);
|
|
1259
|
-
|
|
1260
|
-
// ---- emit ---------------------------------------------------------------
|
|
1261
|
-
// Error-class events go to stderr; everything else to stdout.
|
|
1262
|
-
//
|
|
1263
|
-
// Why split: the AppBar Sync menubar (Tauri + Rust) feeds runner stderr
|
|
1264
|
-
// into Sentry as breadcrumbs and captures one Sentry event when the
|
|
1265
|
-
// runner exits non-zero. Routing `error` / `auth-error` events through
|
|
1266
|
-
// stderr makes them part of that breadcrumb trail automatically — the
|
|
1267
|
-
// menubar doesn't need a per-event capture call, and operators get the
|
|
1268
|
-
// full context (per-file errors → company error → exit) in a single
|
|
1269
|
-
// Sentry issue alerted to #hq-alerts.
|
|
1270
|
-
//
|
|
1271
|
-
// Non-error events (progress, complete, transient-network, fanout-plan,
|
|
1272
|
-
// all-complete, setup-needed) stay on stdout. They're the protocol stream the menubar
|
|
1273
|
-
// parses for UI updates; mixing them with error events on the same
|
|
1274
|
-
// channel was the original design (single ndjson stream, simpler to
|
|
1275
|
-
// tee), but error context belongs in the diagnostic channel.
|
|
1276
|
-
//
|
|
1277
|
-
// Backward compat: older menubar releases (pre-PR-#34) parse only
|
|
1278
|
-
// stdout for ndjson; with this change they will NOT receive error
|
|
1279
|
-
// events. The menubar's `HQ_CLOUD_VERSION` pin gates which runner
|
|
1280
|
-
// they spawn, so old menubars stay on the previous runner version
|
|
1281
|
-
// even after this one is published.
|
|
1282
|
-
const emit = createRunnerEmitter({ stdout, stderr });
|
|
1283
|
-
|
|
1284
|
-
// ---- argv -------------------------------------------------------------
|
|
1285
|
-
const parsed = parseArgs(argv);
|
|
1286
|
-
if ("error" in parsed) {
|
|
1287
|
-
stderr.write(`hq-sync-runner: ${parsed.error}\n`);
|
|
1288
|
-
return 1;
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
// ---- auth -------------------------------------------------------------
|
|
1292
|
-
// Resolve the access token up-front to surface auth-error early (before any
|
|
1293
|
-
// protocol events). Long-running multi-company syncs can outlast Cognito's
|
|
1294
|
-
// 60-min access token TTL, so the vaultConfig captures a *getter* — every
|
|
1295
|
-
// vault request resolves the latest token via getValidAccessToken, which
|
|
1296
|
-
// re-reads `~/.hq/cognito-tokens.json` and refreshes on demand. Without
|
|
1297
|
-
// this, a captured string goes stale mid-fanout (e.g. personal sync runs
|
|
1298
|
-
// last → STS expires after 13 min → refreshEntityContext → fetchEntity →
|
|
1299
|
-
// 401 against API Gateway's JWT authorizer because the captured token
|
|
1300
|
-
// expired while the menubar was happily rotating the on-disk token).
|
|
1301
|
-
const getAccessToken =
|
|
1302
|
-
deps.getAccessToken ??
|
|
1303
|
-
(() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
|
|
1304
|
-
const clearSession =
|
|
1305
|
-
deps.clearSession ?? invalidateCachedTokensByFingerprint;
|
|
1306
|
-
let initialAuthToken: string;
|
|
1307
|
-
try {
|
|
1308
|
-
initialAuthToken = await getAccessToken();
|
|
1309
|
-
} catch (err) {
|
|
1310
|
-
if (
|
|
1311
|
-
(err instanceof CognitoRefreshError && !err.requiresReauth) ||
|
|
1312
|
-
isTransientNetworkError(err)
|
|
1313
|
-
) {
|
|
1314
|
-
emit({
|
|
1315
|
-
type: "error",
|
|
1316
|
-
path: "(auth)",
|
|
1317
|
-
message: err instanceof Error ? err.message : String(err),
|
|
1318
|
-
});
|
|
1319
|
-
return TRANSIENT_NETWORK_EXIT;
|
|
1320
|
-
}
|
|
1321
|
-
emit({
|
|
1322
|
-
type: "auth-error",
|
|
1323
|
-
message: REAUTH_MESSAGE,
|
|
1324
|
-
});
|
|
1325
|
-
return deps.authRequiredExitCode ?? 0;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// ---- vault client -----------------------------------------------------
|
|
1329
|
-
// Stamp clientInfo so every vault request (incl. /sts/vend) carries
|
|
1330
|
-
// x-hq-client-name=hq-sync + x-hq-client-version=<hq-cloud version>. The
|
|
1331
|
-
// company-vend min-version gate (HQ-59, hq-pro) uses this to recognize a
|
|
1332
|
-
// compliant (>= 6.11.6) sync-runner and NOT force-upgrade it — the
|
|
1333
|
-
// belt-and-suspenders companion to skipping the cmp_ vend on the presign
|
|
1334
|
-
// path. Version is the hq-cloud package version (what the gate compares to
|
|
1335
|
-
// its floor), not the desktop app version.
|
|
1336
|
-
const vaultConfig: VaultServiceConfig = {
|
|
1337
|
-
apiUrl: DEFAULT_VAULT_API_URL,
|
|
1338
|
-
authToken: getAccessToken,
|
|
1339
|
-
region: DEFAULT_COGNITO.region,
|
|
1340
|
-
clientInfo: { name: "hq-sync", version: HQ_CLOUD_VERSION },
|
|
1341
|
-
};
|
|
1342
|
-
const client =
|
|
1343
|
-
deps.createVaultClient?.(vaultConfig) ?? new VaultClient(vaultConfig);
|
|
1344
|
-
|
|
1345
|
-
// ---- resolve identity claims -----------------------------------------
|
|
1346
|
-
// Read the cached idToken claims once. Two consumers downstream:
|
|
1347
|
-
// 1. The claim-dance (only fires in `--companies` mode for setup-needed
|
|
1348
|
-
// invitees).
|
|
1349
|
-
// 2. The S3 upload author (every share() call stamps `Metadata['created-by']`
|
|
1350
|
-
// with `claims.email` so the hq-console vault UI's CREATED BY column
|
|
1351
|
-
// attributes the file to the syncing user).
|
|
1352
|
-
// Resolved here (not inside `parsed.companies`) so single-company runs also
|
|
1353
|
-
// get author attribution. `null` is fine — share() simply omits the metadata.
|
|
1354
|
-
const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
|
|
1355
|
-
// A machine-principal token is minted immediately above and is an ID token.
|
|
1356
|
-
// Make its entity binding authoritative for this run: a stale human token
|
|
1357
|
-
// cache can otherwise make an authenticated machine look like a person
|
|
1358
|
-
// during fanout planning. Human callers still retain the cache as their
|
|
1359
|
-
// primary claims source because their bearer is normally an access token
|
|
1360
|
-
// without the full ID-token profile.
|
|
1361
|
-
const authenticatedClaims = decodeJwtClaims(initialAuthToken);
|
|
1362
|
-
const authenticatedMachinePrincipalClaims =
|
|
1363
|
-
authenticatedClaims &&
|
|
1364
|
-
(authenticatedClaims["custom:entityType"] === "agent" ||
|
|
1365
|
-
authenticatedClaims["custom:entityType"] === "outpost") &&
|
|
1366
|
-
typeof authenticatedClaims["custom:entityUid"] === "string"
|
|
1367
|
-
? authenticatedClaims
|
|
1368
|
-
: null;
|
|
1369
|
-
const claims =
|
|
1370
|
-
authenticatedMachinePrincipalClaims ?? getClaims() ?? authenticatedClaims;
|
|
1371
|
-
const uploadAuthor: UploadAuthor | undefined =
|
|
1372
|
-
claims?.sub && claims?.email
|
|
1373
|
-
? { userSub: claims.sub, email: claims.email }
|
|
1374
|
-
: undefined;
|
|
1375
|
-
const telemetrySessionId = `sync_${randomUUID()}`;
|
|
1376
|
-
void emitCloudTelemetry(
|
|
1377
|
-
client,
|
|
1378
|
-
{
|
|
1379
|
-
eventName: "sync_runner_session_started",
|
|
1380
|
-
source: "sync-runner",
|
|
1381
|
-
sessionId: telemetrySessionId,
|
|
1382
|
-
properties: {
|
|
1383
|
-
direction: parsed.direction,
|
|
1384
|
-
targetMode: parsed.personal
|
|
1385
|
-
? "personal"
|
|
1386
|
-
: parsed.company
|
|
1387
|
-
? "single_company"
|
|
1388
|
-
: "companies",
|
|
1389
|
-
watchState: parsed.watch ? "enabled" : "disabled",
|
|
1390
|
-
eventPushState: parsed.eventPush ? "enabled" : "disabled",
|
|
1391
|
-
scopePathCount: parsed.scopePaths?.length ?? 0,
|
|
1392
|
-
},
|
|
1393
|
-
},
|
|
1394
|
-
{
|
|
1395
|
-
claims,
|
|
1396
|
-
log: (message) =>
|
|
1397
|
-
reportDiagnostic({
|
|
1398
|
-
component: "telemetry-events",
|
|
1399
|
-
event: "runner.telemetry_events.nonfatal",
|
|
1400
|
-
message,
|
|
1401
|
-
context: { source: "sync-runner" },
|
|
1402
|
-
}),
|
|
1403
|
-
},
|
|
1404
|
-
);
|
|
1405
|
-
|
|
1406
|
-
const emitRunnerTerminalTelemetry = (properties: {
|
|
1407
|
-
status: "completed" | "partial" | "failed";
|
|
1408
|
-
attemptedCount?: number;
|
|
1409
|
-
companyCount?: number;
|
|
1410
|
-
errorCount?: number;
|
|
1411
|
-
filesDownloaded?: number;
|
|
1412
|
-
bytesDownloaded?: number;
|
|
1413
|
-
filesUploaded?: number;
|
|
1414
|
-
bytesUploaded?: number;
|
|
1415
|
-
}): void => {
|
|
1416
|
-
void emitCloudTelemetry(
|
|
1417
|
-
client,
|
|
1418
|
-
{
|
|
1419
|
-
eventName: "sync_runner_completed",
|
|
1420
|
-
source: "sync-runner",
|
|
1421
|
-
sessionId: telemetrySessionId,
|
|
1422
|
-
properties,
|
|
1423
|
-
},
|
|
1424
|
-
{
|
|
1425
|
-
claims,
|
|
1426
|
-
log: (message) =>
|
|
1427
|
-
reportDiagnostic({
|
|
1428
|
-
component: "telemetry-events",
|
|
1429
|
-
event: "runner.telemetry_events.nonfatal",
|
|
1430
|
-
message,
|
|
1431
|
-
context: { source: "sync-runner" },
|
|
1432
|
-
}),
|
|
1433
|
-
},
|
|
1434
|
-
);
|
|
1435
|
-
};
|
|
1436
|
-
|
|
1437
|
-
// ---- transport selection (presigned-URL for cmp_, direct-S3/STS for prs_) -
|
|
1438
|
-
// Company vaults ALWAYS use the presigned-URL transport now (HQ-59): the
|
|
1439
|
-
// STS-direct-S3 path for cmp_ is retired — no env override routes a company
|
|
1440
|
-
// vault back to direct S3. Personal vaults keep direct-S3 inside the factory
|
|
1441
|
-
// (the cmp_/prs_ split lives in presignObjectIOFactory). selectObjectIOFactory
|
|
1442
|
-
// runs ONCE here — every s3.ts call in this session's fanout resolves through
|
|
1443
|
-
// the installed factory. Setting it unconditionally (even to null, which the
|
|
1444
|
-
// default S3 SDK factory backs for a pre-presign client) keeps the choice
|
|
1445
|
-
// deterministic if a prior run mutated module state.
|
|
1446
|
-
const presignCapable = client as Partial<PresignTransportClient>;
|
|
1447
|
-
const objectIOFactory = selectObjectIOFactory(presignCapable);
|
|
1448
|
-
setObjectIOFactory(objectIOFactory);
|
|
1449
|
-
// HQ-59 vend-skip: when company vaults run over the presign transport (the
|
|
1450
|
-
// shipped case — selectObjectIOFactory only returns null for a pre-presign
|
|
1451
|
-
// client), tell resolveEntityContext to SKIP `POST /sts/vend` for cmp_
|
|
1452
|
-
// contexts. Two reasons, both load-bearing: (1) the presign transport never
|
|
1453
|
-
// reads STS creds, so the vend is dead weight; (2) a compliant sync-runner
|
|
1454
|
-
// must not call the company vend route at all — its presence is the
|
|
1455
|
-
// pre-6.11.6 signal the hq-pro min-version gate denies on. Gated on the SAME
|
|
1456
|
-
// value that picks the transport, so the vend-skip and the transport can
|
|
1457
|
-
// never diverge. If the factory is null (S3 SDK fallback), cmp_ keeps vending
|
|
1458
|
-
// because S3SdkObjectIO needs the creds. Personal vaults always vend self.
|
|
1459
|
-
vaultConfig.companyVaultUsesPresign = objectIOFactory !== null;
|
|
1460
|
-
|
|
1461
|
-
// ---- resolve targets --------------------------------------------------
|
|
1462
|
-
let memberships: Pick<Membership, "companyUid">[];
|
|
1463
|
-
try {
|
|
1464
|
-
const resolution = await resolveMembershipsForRun({
|
|
1465
|
-
personal: parsed.personal,
|
|
1466
|
-
companies: parsed.companies,
|
|
1467
|
-
company: parsed.company,
|
|
1468
|
-
client,
|
|
1469
|
-
claims,
|
|
1470
|
-
stderr,
|
|
1471
|
-
runClaimDance: (client, claims, stderr) =>
|
|
1472
|
-
runClaimDance(client, claims, stderr, reportDiagnostic),
|
|
1473
|
-
listMemberships: listMembershipsWithRetry,
|
|
1474
|
-
});
|
|
1475
|
-
if (resolution.status === "setup-needed") {
|
|
1476
|
-
emit({
|
|
1477
|
-
type: "setup-needed",
|
|
1478
|
-
reason: resolution.reason,
|
|
1479
|
-
...(resolution.pendingInviteCount === undefined
|
|
1480
|
-
? {}
|
|
1481
|
-
: { pendingInviteCount: resolution.pendingInviteCount }),
|
|
1482
|
-
});
|
|
1483
|
-
return 0;
|
|
1484
|
-
}
|
|
1485
|
-
memberships = resolution.memberships;
|
|
1486
|
-
} catch (err) {
|
|
1487
|
-
if (err instanceof VaultAuthError) {
|
|
1488
|
-
clearSession(err.tokenFingerprint);
|
|
1489
|
-
emit({
|
|
1490
|
-
type: "auth-error",
|
|
1491
|
-
message: REAUTH_MESSAGE,
|
|
1492
|
-
});
|
|
1493
|
-
return deps.authRequiredExitCode ?? 0;
|
|
1494
|
-
}
|
|
1495
|
-
// Surface the failure as an error event for observability either way.
|
|
1496
|
-
emit({
|
|
1497
|
-
type: "error",
|
|
1498
|
-
message: err instanceof Error ? err.message : String(err),
|
|
1499
|
-
path: "(discovery)",
|
|
1500
|
-
});
|
|
1501
|
-
emitRunnerTerminalTelemetry({ status: "failed" });
|
|
1502
|
-
// A transient network failure (offline, DNS blip, vault API briefly
|
|
1503
|
-
// unreachable) is NOT a crash — it self-heals on the next poll. Return the
|
|
1504
|
-
// retryable exit code so the watch loop stays alive instead of exiting and
|
|
1505
|
-
// being reported as "watcher exited unexpectedly (code=Some(1))" for every
|
|
1506
|
-
// blip (HQ-SYNC-1W). A one-shot run still exits non-zero. Any other failure
|
|
1507
|
-
// is treated as a hard error (exit 1), as before.
|
|
1508
|
-
return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
let targetPlan: Awaited<ReturnType<typeof buildFanoutPlan>>;
|
|
1512
|
-
try {
|
|
1513
|
-
targetPlan = await buildFanoutPlan({
|
|
1514
|
-
memberships,
|
|
1515
|
-
companies: parsed.companies,
|
|
1516
|
-
personal: parsed.personal,
|
|
1517
|
-
skipPersonal: parsed.skipPersonal,
|
|
1518
|
-
client,
|
|
1519
|
-
claims,
|
|
1520
|
-
resolveSkipPersonal,
|
|
1521
|
-
skipCompanies: resolveSkipCompanies(),
|
|
1522
|
-
});
|
|
1523
|
-
} catch (err) {
|
|
1524
|
-
if (err instanceof VaultAuthError) {
|
|
1525
|
-
clearSession(err.tokenFingerprint);
|
|
1526
|
-
emit({ type: "auth-error", message: REAUTH_MESSAGE });
|
|
1527
|
-
return deps.authRequiredExitCode ?? 0;
|
|
1528
|
-
}
|
|
1529
|
-
emit({
|
|
1530
|
-
type: "error",
|
|
1531
|
-
message: err instanceof Error ? err.message : String(err),
|
|
1532
|
-
path: "(discovery)",
|
|
1533
|
-
});
|
|
1534
|
-
emitRunnerTerminalTelemetry({ status: "failed" });
|
|
1535
|
-
return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
|
|
1536
|
-
}
|
|
1537
|
-
if (targetPlan.status === "setup-needed") {
|
|
1538
|
-
emit({ type: "setup-needed", reason: targetPlan.reason });
|
|
1539
|
-
return 0;
|
|
1540
|
-
}
|
|
1541
|
-
const plan = targetPlan.plan;
|
|
1542
|
-
const deferredUnresolved = targetPlan.deferredUnresolved;
|
|
1543
|
-
|
|
1544
|
-
emitFanoutPlan(emit, plan);
|
|
1545
|
-
|
|
1546
|
-
// One-time seed of the reserved personal-vault journal from the legacy
|
|
1547
|
-
// "personal" journal, so switching the vault slot off the colliding slug
|
|
1548
|
-
// does NOT trigger a mass re-upload of the HQ overlay on first run. No-op
|
|
1549
|
-
// when the new journal already exists or the legacy one is absent, and
|
|
1550
|
-
// harmless even when the personal-vault slot is not in the plan.
|
|
1551
|
-
migratePersonalVaultJournal();
|
|
1552
|
-
|
|
1553
|
-
// ---- fanout -----------------------------------------------------------
|
|
1554
|
-
const syncFn = deps.sync ?? defaultSync;
|
|
1555
|
-
const shareFn = deps.share ?? defaultShare;
|
|
1556
|
-
let fanout: Awaited<ReturnType<typeof executeCompanyFanout>>;
|
|
1557
|
-
try {
|
|
1558
|
-
fanout = await executeCompanyFanout({
|
|
1559
|
-
plan,
|
|
1560
|
-
direction: parsed.direction,
|
|
1561
|
-
hqRoot: parsed.hqRoot,
|
|
1562
|
-
onConflict: parsed.onConflict,
|
|
1563
|
-
client,
|
|
1564
|
-
vaultConfig,
|
|
1565
|
-
...(uploadAuthor ? { uploadAuthor } : {}),
|
|
1566
|
-
...(deps.operationLockAlreadyHeld ? { operationLockAlreadyHeld: true } : {}),
|
|
1567
|
-
syncFn,
|
|
1568
|
-
shareFn,
|
|
1569
|
-
resolveDeletePolicy,
|
|
1570
|
-
emit,
|
|
1571
|
-
scopePaths: parsed.scopePaths,
|
|
1572
|
-
deleteScopePaths: parsed.deleteScopePaths,
|
|
1573
|
-
telemetryClient: client,
|
|
1574
|
-
telemetryClaims: claims,
|
|
1575
|
-
telemetrySessionId,
|
|
1576
|
-
});
|
|
1577
|
-
} catch (err) {
|
|
1578
|
-
if (err instanceof VaultAuthError) {
|
|
1579
|
-
clearSession(err.tokenFingerprint);
|
|
1580
|
-
emit({ type: "auth-error", message: REAUTH_MESSAGE });
|
|
1581
|
-
return deps.authRequiredExitCode ?? 0;
|
|
1582
|
-
}
|
|
1583
|
-
throw err;
|
|
1584
|
-
}
|
|
1585
|
-
const { errors, transientCompanies, allConflicts, meaningfulOutcomeCount } = fanout;
|
|
1586
|
-
deps.onPassResult?.({
|
|
1587
|
-
pushPathResults: fanout.pushPathResults,
|
|
1588
|
-
companyFailures: fanout.companyFailures,
|
|
1589
|
-
});
|
|
1590
|
-
const rollup = rollupAllComplete(plan, fanout.stateByCompany);
|
|
1591
|
-
|
|
1592
|
-
// The personal-vault target runs last and can pull an older copy of the
|
|
1593
|
-
// manifest. Reconcile only after the entire pull fanout has settled so the
|
|
1594
|
-
// successfully materialized cloud directories restore their own entries.
|
|
1595
|
-
//
|
|
1596
|
-
// `--companies` only. A single-company run (`--company <slug>`) and a
|
|
1597
|
-
// `--personal` run both see just one leg of the fanout, so neither has the
|
|
1598
|
-
// full picture the manifest is reconciled against; treating a partial view as
|
|
1599
|
-
// authoritative is how entries get lost. A push-only pass materializes
|
|
1600
|
-
// nothing new locally, so it has nothing to reconcile either.
|
|
1601
|
-
if (parsed.companies && parsed.direction !== "push") {
|
|
1602
|
-
const completedCompanySlugs = new Set(
|
|
1603
|
-
Array.from(fanout.stateByCompany.entries())
|
|
1604
|
-
.filter(([, state]) => state.status === "complete")
|
|
1605
|
-
.map(([slug]) => slug),
|
|
1606
|
-
);
|
|
1607
|
-
// The personal vault is never a manifest entry. The reconciler filters it
|
|
1608
|
-
// too, but that is its own internal invariant — excluding it here means
|
|
1609
|
-
// the personal target is never even handed across the seam.
|
|
1610
|
-
const companyTargets = plan.filter(
|
|
1611
|
-
(target) => target.personalMode !== true && target.slug !== "personal",
|
|
1612
|
-
);
|
|
1613
|
-
try {
|
|
1614
|
-
const result = await (deps.reconcileManifest ?? reconcileCompanyManifest)({
|
|
1615
|
-
hqRoot: parsed.hqRoot,
|
|
1616
|
-
targets: companyTargets,
|
|
1617
|
-
completedCompanySlugs,
|
|
1618
|
-
reportDiagnostic: (diagnostic) =>
|
|
1619
|
-
reportDiagnostic({ component: "manifest-reconcile", ...diagnostic }),
|
|
1620
|
-
});
|
|
1621
|
-
// Only on an actual write. A steady-state sync stays silent, so an
|
|
1622
|
-
// unexpected diagnostic rate is itself the signal that skip-if-unchanged
|
|
1623
|
-
// has regressed.
|
|
1624
|
-
if (result.written) {
|
|
1625
|
-
reportDiagnostic({
|
|
1626
|
-
component: "manifest-reconcile",
|
|
1627
|
-
event: "manifest-reconciled",
|
|
1628
|
-
message: "restored company entries in companies/manifest.yaml",
|
|
1629
|
-
context: { added: result.added, updated: result.updated },
|
|
1630
|
-
});
|
|
1631
|
-
}
|
|
1632
|
-
} catch (err) {
|
|
1633
|
-
reportDiagnostic({
|
|
1634
|
-
component: "manifest-reconcile",
|
|
1635
|
-
event: "runner.manifest_reconcile.failed",
|
|
1636
|
-
message: "post-fanout manifest reconciliation failed",
|
|
1637
|
-
err,
|
|
1638
|
-
context: { hqRoot: parsed.hqRoot },
|
|
1639
|
-
});
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
// A joiner whose manifest entry was just restored still has nothing telling
|
|
1643
|
-
// HQ which company is current. Seed it only when the answer cannot be wrong
|
|
1644
|
-
// — deliberately its own try, so a manifest failure does not also cost the
|
|
1645
|
-
// user their routing.
|
|
1646
|
-
try {
|
|
1647
|
-
const localCompanySlugs = companyTargets
|
|
1648
|
-
.map((target) => target.slug)
|
|
1649
|
-
.filter((slug) => completedCompanySlugs.has(slug));
|
|
1650
|
-
const seeded = (deps.seedActiveCompany ?? seedActiveCompany)({
|
|
1651
|
-
hqRoot: parsed.hqRoot,
|
|
1652
|
-
companySlugs: localCompanySlugs,
|
|
1653
|
-
reportDiagnostic: (diagnostic) =>
|
|
1654
|
-
reportDiagnostic({ component: "active-company", ...diagnostic }),
|
|
1655
|
-
});
|
|
1656
|
-
if (seeded.written) {
|
|
1657
|
-
reportDiagnostic({
|
|
1658
|
-
component: "active-company",
|
|
1659
|
-
event: "active-company-seeded",
|
|
1660
|
-
message: "seeded activeCompany in .hq/config.json",
|
|
1661
|
-
context: { activeCompany: seeded.activeCompany },
|
|
1662
|
-
});
|
|
1663
|
-
}
|
|
1664
|
-
} catch (err) {
|
|
1665
|
-
reportDiagnostic({
|
|
1666
|
-
component: "active-company",
|
|
1667
|
-
event: "runner.active_company.failed",
|
|
1668
|
-
message: "seeding activeCompany failed",
|
|
1669
|
-
err,
|
|
1670
|
-
context: { hqRoot: parsed.hqRoot },
|
|
1671
|
-
});
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
if (rollup.needsReindex) {
|
|
1676
|
-
try {
|
|
1677
|
-
const reindexOptions = {
|
|
1678
|
-
repoRoot: parsed.hqRoot,
|
|
1679
|
-
...(deps.operationLockAlreadyHeld === true ? { skipLock: true } : {}),
|
|
1680
|
-
};
|
|
1681
|
-
const reindexResult = (deps.reindex ?? defaultReindex)(reindexOptions);
|
|
1682
|
-
void emitCloudTelemetry(
|
|
1683
|
-
client,
|
|
1684
|
-
{
|
|
1685
|
-
eventName: "reindex_run",
|
|
1686
|
-
source: "sync-runner",
|
|
1687
|
-
sessionId: telemetrySessionId,
|
|
1688
|
-
properties: {
|
|
1689
|
-
result: reindexResult.status === 0 ? "completed" : "failed",
|
|
1690
|
-
statusCode: reindexResult.status,
|
|
1691
|
-
changedCount: rollup.changedPaths.length,
|
|
1692
|
-
lockResult:
|
|
1693
|
-
deps.operationLockAlreadyHeld === true ? "skipped" : "attempted",
|
|
1694
|
-
},
|
|
1695
|
-
},
|
|
1696
|
-
{
|
|
1697
|
-
claims,
|
|
1698
|
-
log: (message) =>
|
|
1699
|
-
reportDiagnostic({
|
|
1700
|
-
component: "telemetry-events",
|
|
1701
|
-
event: "runner.telemetry_events.nonfatal",
|
|
1702
|
-
message,
|
|
1703
|
-
context: { source: "sync-runner" },
|
|
1704
|
-
}),
|
|
1705
|
-
},
|
|
1706
|
-
);
|
|
1707
|
-
} catch (err) {
|
|
1708
|
-
reportDiagnostic({
|
|
1709
|
-
component: "native-reindex",
|
|
1710
|
-
event: "runner.native_reindex.failed",
|
|
1711
|
-
message: "post-fanout native reindex failed",
|
|
1712
|
-
err,
|
|
1713
|
-
context: { hqRoot: parsed.hqRoot },
|
|
1714
|
-
});
|
|
1715
|
-
void emitCloudTelemetry(
|
|
1716
|
-
client,
|
|
1717
|
-
{
|
|
1718
|
-
eventName: "reindex_run",
|
|
1719
|
-
source: "sync-runner",
|
|
1720
|
-
sessionId: telemetrySessionId,
|
|
1721
|
-
properties: {
|
|
1722
|
-
result: "threw",
|
|
1723
|
-
changedCount: rollup.changedPaths.length,
|
|
1724
|
-
lockResult:
|
|
1725
|
-
deps.operationLockAlreadyHeld === true ? "skipped" : "attempted",
|
|
1726
|
-
},
|
|
1727
|
-
},
|
|
1728
|
-
{
|
|
1729
|
-
claims,
|
|
1730
|
-
log: (message) =>
|
|
1731
|
-
reportDiagnostic({
|
|
1732
|
-
component: "telemetry-events",
|
|
1733
|
-
event: "runner.telemetry_events.nonfatal",
|
|
1734
|
-
message,
|
|
1735
|
-
context: { source: "sync-runner" },
|
|
1736
|
-
}),
|
|
1737
|
-
},
|
|
1738
|
-
);
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
// Fire telemetry collector before the all-complete emit so the cursor at
|
|
1743
|
-
// `~/.hq/telemetry-cursor.json` is consistent with what the menubar sees.
|
|
1744
|
-
await awaitTelemetryWithTimeout(
|
|
1745
|
-
emitTelemetry({
|
|
1746
|
-
collectTelemetry: deps.collectTelemetry,
|
|
1747
|
-
defaultCollectTelemetry: () =>
|
|
1748
|
-
defaultCollectTelemetry(
|
|
1749
|
-
client,
|
|
1750
|
-
deps.createVaultClient !== undefined,
|
|
1751
|
-
parsed.hqRoot,
|
|
1752
|
-
reportDiagnostic,
|
|
1753
|
-
parsed.company,
|
|
1754
|
-
),
|
|
1755
|
-
onError: (err) =>
|
|
1756
|
-
reportDiagnostic({
|
|
1757
|
-
component: "telemetry",
|
|
1758
|
-
event: "runner.telemetry.collector_failed",
|
|
1759
|
-
message: "telemetry collector failed",
|
|
1760
|
-
err,
|
|
1761
|
-
context: { hqRoot: parsed.hqRoot },
|
|
1762
|
-
}),
|
|
1763
|
-
}),
|
|
1764
|
-
resolveTelemetryTimeoutMs(deps.telemetryTimeoutMs),
|
|
1765
|
-
() =>
|
|
1766
|
-
reportDiagnostic({
|
|
1767
|
-
component: "telemetry",
|
|
1768
|
-
event: "runner.telemetry.timeout",
|
|
1769
|
-
message: "telemetry collector timed out; continuing sync completion",
|
|
1770
|
-
context: { hqRoot: parsed.hqRoot },
|
|
1771
|
-
}),
|
|
1772
|
-
);
|
|
1773
|
-
|
|
1774
|
-
emit({
|
|
1775
|
-
type: "all-complete",
|
|
1776
|
-
companiesAttempted: plan.length,
|
|
1777
|
-
filesDownloaded: rollup.totalDownloaded,
|
|
1778
|
-
bytesDownloaded: rollup.totalDownloadedBytes,
|
|
1779
|
-
filesUploaded: rollup.totalUploaded,
|
|
1780
|
-
bytesUploaded: rollup.totalUploadedBytes,
|
|
1781
|
-
conflictPaths: allConflicts,
|
|
1782
|
-
errors,
|
|
1783
|
-
transient: transientCompanies,
|
|
1784
|
-
partial: rollup.partial,
|
|
1785
|
-
companies: rollup.companies,
|
|
1786
|
-
});
|
|
1787
|
-
if (
|
|
1788
|
-
rollup.partial ||
|
|
1789
|
-
errors.length > 0 ||
|
|
1790
|
-
meaningfulOutcomeCount > 0
|
|
1791
|
-
) {
|
|
1792
|
-
emitRunnerTerminalTelemetry({
|
|
1793
|
-
status: rollup.partial || errors.length > 0 ? "partial" : "completed",
|
|
1794
|
-
attemptedCount: plan.length,
|
|
1795
|
-
companyCount: rollup.companies.length,
|
|
1796
|
-
errorCount: errors.length,
|
|
1797
|
-
filesDownloaded: rollup.totalDownloaded,
|
|
1798
|
-
bytesDownloaded: rollup.totalDownloadedBytes,
|
|
1799
|
-
filesUploaded: rollup.totalUploaded,
|
|
1800
|
-
bytesUploaded: rollup.totalUploadedBytes,
|
|
1801
|
-
});
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
// Post-sync qmd reindex — runs AFTER `all-complete` is emitted so the
|
|
1805
|
-
// menubar/CLI already shows the sync as done; this is a best-effort tail
|
|
1806
|
-
// step that never affects the exit code. Only when files were actually
|
|
1807
|
-
// pulled in (nothing to reindex otherwise) and not explicitly disabled.
|
|
1808
|
-
// Self-contained: shells out to the global `qmd` binary, no dependency on
|
|
1809
|
-
// any (possibly stale) script inside the synced HQ tree. See qmd-reindex.ts.
|
|
1810
|
-
const qmdChangedPaths = rollup.changedPaths.filter(isQmdIndexableContentPath);
|
|
1811
|
-
if (qmdChangedPaths.length > 0 && process.env.HQ_QMD_REINDEX_ON_SYNC !== "0") {
|
|
1812
|
-
try {
|
|
1813
|
-
const qmdResult = (deps.qmdReindex ?? reindexAfterSync)(parsed.hqRoot, {
|
|
1814
|
-
changedPaths: qmdChangedPaths,
|
|
1815
|
-
forceCollectionRefresh: true,
|
|
1816
|
-
log: ({ event, message, err, context }) =>
|
|
1817
|
-
reportDiagnostic({
|
|
1818
|
-
component: "qmd-reindex",
|
|
1819
|
-
event,
|
|
1820
|
-
message,
|
|
1821
|
-
err,
|
|
1822
|
-
context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
|
|
1823
|
-
}),
|
|
1824
|
-
});
|
|
1825
|
-
void emitCloudTelemetry(
|
|
1826
|
-
client,
|
|
1827
|
-
{
|
|
1828
|
-
eventName: "qmd_reindex_run",
|
|
1829
|
-
source: "sync-runner",
|
|
1830
|
-
sessionId: telemetrySessionId,
|
|
1831
|
-
properties: {
|
|
1832
|
-
result: "completed",
|
|
1833
|
-
changedCount: qmdChangedPaths.length,
|
|
1834
|
-
qmdState: qmdResult.qmdAvailable ? "available" : "unavailable",
|
|
1835
|
-
collectionsAddedCount: qmdResult.collectionsAdded.length,
|
|
1836
|
-
pathDriftDetectedCount: qmdResult.pathDriftDetected?.length ?? 0,
|
|
1837
|
-
collectionsRepairedCount: qmdResult.collectionsRepaired?.length ?? 0,
|
|
1838
|
-
updateState: qmdResult.updated ? "updated" : "unchanged",
|
|
1839
|
-
embedState: qmdResult.embedded ? "embedded" : "not_embedded",
|
|
1840
|
-
dirtyState: qmdResult.pendingDirty ? "pending_dirty" : "clean",
|
|
1841
|
-
lockBusy: qmdResult.lockBusy === true ? 1 : 0,
|
|
1842
|
-
timedOut: qmdResult.timedOut === true ? 1 : 0,
|
|
1843
|
-
corruptionQuarantined: qmdResult.corruptionQuarantined === true ? 1 : 0,
|
|
1844
|
-
corruptionQuarantineFailed: qmdResult.corruptionQuarantineFailed === true ? 1 : 0,
|
|
1845
|
-
},
|
|
1846
|
-
},
|
|
1847
|
-
{
|
|
1848
|
-
claims,
|
|
1849
|
-
log: (message) =>
|
|
1850
|
-
reportDiagnostic({
|
|
1851
|
-
component: "telemetry-events",
|
|
1852
|
-
event: "runner.telemetry_events.nonfatal",
|
|
1853
|
-
message,
|
|
1854
|
-
context: { source: "sync-runner" },
|
|
1855
|
-
}),
|
|
1856
|
-
},
|
|
1857
|
-
);
|
|
1858
|
-
} catch (err) {
|
|
1859
|
-
reportDiagnostic({
|
|
1860
|
-
component: "qmd-reindex",
|
|
1861
|
-
event: "runner.qmd_reindex.failed",
|
|
1862
|
-
message: "post-sync qmd reindex threw",
|
|
1863
|
-
err,
|
|
1864
|
-
context: { hqRoot: parsed.hqRoot },
|
|
1865
|
-
});
|
|
1866
|
-
// Defensive: reindexAfterSync already swallows internally.
|
|
1867
|
-
void emitCloudTelemetry(
|
|
1868
|
-
client,
|
|
1869
|
-
{
|
|
1870
|
-
eventName: "qmd_reindex_run",
|
|
1871
|
-
source: "sync-runner",
|
|
1872
|
-
sessionId: telemetrySessionId,
|
|
1873
|
-
properties: {
|
|
1874
|
-
result: "threw",
|
|
1875
|
-
changedCount: qmdChangedPaths.length,
|
|
1876
|
-
},
|
|
1877
|
-
},
|
|
1878
|
-
{
|
|
1879
|
-
claims,
|
|
1880
|
-
log: (message) =>
|
|
1881
|
-
reportDiagnostic({
|
|
1882
|
-
component: "telemetry-events",
|
|
1883
|
-
event: "runner.telemetry_events.nonfatal",
|
|
1884
|
-
message,
|
|
1885
|
-
context: { source: "sync-runner" },
|
|
1886
|
-
}),
|
|
1887
|
-
},
|
|
1888
|
-
);
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
|
-
// Post-sync conflict-ledger prune — also a best-effort tail step (runs AFTER
|
|
1893
|
-
// `all-complete`, never touches the exit code). The producers only append a
|
|
1894
|
-
// conflict row after proving real divergence, but the ledger has no other
|
|
1895
|
-
// self-healing path: rows for byte-identical false positives (legacy or
|
|
1896
|
-
// since-reverted) and orphaned `.conflict-*` mirrors otherwise linger
|
|
1897
|
-
// forever and make `.hq-conflicts/index.json` over-report. One GC pass per
|
|
1898
|
-
// run keeps it converged. Idempotent and cheap on a clean ledger (a no-row
|
|
1899
|
-
// index returns immediately and never rewrites the file). Opt-out via
|
|
1900
|
-
// HQ_CONFLICT_PRUNE_ON_SYNC=0.
|
|
1901
|
-
if (process.env.HQ_CONFLICT_PRUNE_ON_SYNC !== "0") {
|
|
1902
|
-
try {
|
|
1903
|
-
const pruneResult = pruneConflictIndex(parsed.hqRoot);
|
|
1904
|
-
// Reconcile + surface the residual preserved conflict variants a human
|
|
1905
|
-
// still has to resolve. Pre-fix this set was silent — the reporter of
|
|
1906
|
-
// feedback_d2082110 finished a full sync with 20 preserved conflict
|
|
1907
|
-
// entries still on disk and no signal. Emitting after the prune means the
|
|
1908
|
-
// count reflects the self-healed (orphan/false-positive-dropped) ledger,
|
|
1909
|
-
// never the inflated raw one. Ledger-global; silent on a clean ledger.
|
|
1910
|
-
if (pruneResult.kept > 0) {
|
|
1911
|
-
emit({
|
|
1912
|
-
type: "conflicts-remaining",
|
|
1913
|
-
count: pruneResult.kept,
|
|
1914
|
-
samplePaths: pruneResult.keptSamplePaths,
|
|
1915
|
-
});
|
|
1916
|
-
}
|
|
1917
|
-
} catch (err) {
|
|
1918
|
-
reportDiagnostic({
|
|
1919
|
-
component: "conflict-index",
|
|
1920
|
-
event: "runner.conflict_index.prune_failed",
|
|
1921
|
-
message: "post-sync conflict index prune failed",
|
|
1922
|
-
err,
|
|
1923
|
-
context: { hqRoot: parsed.hqRoot },
|
|
1924
|
-
});
|
|
1925
|
-
// Defensive: a prune failure must never affect the sync outcome.
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
// Post-sync agent codex-instructions materialization — best-effort tail step
|
|
1930
|
-
// (runs AFTER `all-complete`, never affects the exit code). For an AGENT box
|
|
1931
|
-
// ONLY (`custom:entityType === "agent"`), project the synced personal/ agent
|
|
1932
|
-
// overlay (agents-profile.md + agent-capabilities/*.md) into the codex GLOBAL
|
|
1933
|
-
// instruction file ~/.codex/AGENTS.md, which `codex exec` natively merges on
|
|
1934
|
-
// every run. This is the codex mirror of Claude's ~/.claude/CLAUDE.md:
|
|
1935
|
-
// DURABLE (outside the hq-root, so `hq rescue` never touches it) and
|
|
1936
|
-
// fleet-SCALABLE (every box runs @latest each cycle). A human running
|
|
1937
|
-
// `hq sync` (person identity) never triggers it. Opt-out: set
|
|
1938
|
-
// HQ_AGENT_CODEX_MATERIALIZE=0. The write is idempotent and never destructive
|
|
1939
|
-
// (skips when the overlay is empty), and never throws (mirrors the qmd /
|
|
1940
|
-
// conflict-prune tail steps above).
|
|
1941
|
-
if (
|
|
1942
|
-
claims?.["custom:entityType"] === "agent" &&
|
|
1943
|
-
process.env.HQ_AGENT_CODEX_MATERIALIZE !== "0"
|
|
1944
|
-
) {
|
|
1945
|
-
try {
|
|
1946
|
-
materializeCodexAgents({
|
|
1947
|
-
hqRoot: parsed.hqRoot,
|
|
1948
|
-
log: ({ event, message, err, context }) =>
|
|
1949
|
-
reportDiagnostic({
|
|
1950
|
-
component: "agent-codex-instructions",
|
|
1951
|
-
event,
|
|
1952
|
-
message,
|
|
1953
|
-
err,
|
|
1954
|
-
context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
|
|
1955
|
-
}),
|
|
1956
|
-
});
|
|
1957
|
-
} catch (err) {
|
|
1958
|
-
reportDiagnostic({
|
|
1959
|
-
component: "agent-codex-instructions",
|
|
1960
|
-
event: "runner.agent_codex_instructions.failed",
|
|
1961
|
-
message: "post-sync agent codex-instructions materialization threw",
|
|
1962
|
-
err,
|
|
1963
|
-
context: { hqRoot: parsed.hqRoot },
|
|
1964
|
-
});
|
|
1965
|
-
// Defensive: materializeCodexAgents already swallows internally.
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
// Exit PARTIAL_SYNC_EXIT (2) only when something actually threw
|
|
1970
|
-
// (`errors.length > 0`). A clean conflict-abort sets `partial: true` in the
|
|
1971
|
-
// JSON but exits 0 — the Tauri menubar's non-zero-exit Sentry capture would
|
|
1972
|
-
// otherwise fire for normal user-policy outcomes. Consumers that want to
|
|
1973
|
-
// flag any non-clean outcome (clean-abort + thrown-error) read `partial`
|
|
1974
|
-
// from the JSON. The watch loop stays alive on this code (see exit-codes.ts).
|
|
1975
|
-
//
|
|
1976
|
-
// deferredUnresolved: a slug denylist was active but entity discovery blipped
|
|
1977
|
-
// for one or more memberships we could not prove were paused. Fail closed on
|
|
1978
|
-
// the plan (they were omitted) and return TRANSIENT so watch scoped drain
|
|
1979
|
-
// does not publish without a successful upload pass.
|
|
1980
|
-
if (errors.length > 0) return PARTIAL_SYNC_EXIT;
|
|
1981
|
-
if (transientCompanies.length > 0 || deferredUnresolved > 0) {
|
|
1982
|
-
return TRANSIENT_NETWORK_EXIT;
|
|
1983
|
-
}
|
|
1984
|
-
return 0;
|
|
1985
|
-
}
|
|
1986
|
-
|
|
1987
|
-
// ---------------------------------------------------------------------------
|
|
1988
|
-
// Entrypoint — only runs when invoked directly, not when imported for tests
|
|
1989
|
-
// ---------------------------------------------------------------------------
|
|
1990
|
-
|
|
1991
|
-
// Detect whether this module is the entry point. The obvious check
|
|
1992
|
-
// (`import.meta.url === file://${argv[1]}`) breaks for every real-world
|
|
1993
|
-
// install shape: npm-link'd binaries, global installs via Homebrew, and
|
|
1994
|
-
// pnpm's `node_modules/.bin` shims all leave `process.argv[1]` pointing
|
|
1995
|
-
// at a symlink named `hq-sync-runner` (no `.js` suffix) while
|
|
1996
|
-
// `import.meta.url` always resolves to the underlying `sync-runner.js`.
|
|
1997
|
-
//
|
|
1998
|
-
// Resolve both sides through realpath before comparing — that's the only
|
|
1999
|
-
// way to handle all symlink layouts without false negatives. If realpath
|
|
2000
|
-
// fails (argv[1] gone, permissions), fall through to `false` so we
|
|
2001
|
-
// don't run twice when imported as a library.
|
|
2002
|
-
const isDirectInvocation = (() => {
|
|
2003
|
-
if (!process.argv[1]) return false;
|
|
2004
|
-
try {
|
|
2005
|
-
const modulePath = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
2006
|
-
const argvPath = fs.realpathSync(process.argv[1]);
|
|
2007
|
-
return modulePath === argvPath;
|
|
2008
|
-
} catch {
|
|
2009
|
-
return false;
|
|
2010
|
-
}
|
|
2011
|
-
})();
|
|
2012
|
-
|
|
2013
|
-
/**
|
|
2014
|
-
* Test/event-driven seam (US-001).
|
|
2015
|
-
*
|
|
2016
|
-
* `runRunnerWithLoop` performs an unbounded, monotonic-cadence loop in
|
|
2017
|
-
* production. The initial pass is immediate; subsequent deadlines advance
|
|
2018
|
-
* from their prior deadline rather than pass completion. Watcher and receiver
|
|
2019
|
-
* signals interrupt the wait but still use the same guarded pass queue. To
|
|
2020
|
-
* test it deterministically, both waiting and monotonic time are injectable.
|
|
2021
|
-
*
|
|
2022
|
-
* A test (or US-003's wiring) injects a fake sleep that resolves immediately
|
|
2023
|
-
* and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
|
|
2024
|
-
* so the loop can be exercised without a real 10-minute wait.
|
|
2025
|
-
*/
|
|
2026
|
-
export interface RunnerLoopDeps {
|
|
2027
|
-
/** Sleep `ms` between passes. Default: host setTimeout. */
|
|
2028
|
-
sleep?: (ms: number) => Promise<void>;
|
|
2029
|
-
/**
|
|
2030
|
-
* Monotonic milliseconds source for cadence deadlines. Defaults to
|
|
2031
|
-
* `performance.now()`; tests inject it to prove missed-deadline behavior
|
|
2032
|
-
* without depending on wall-clock time.
|
|
2033
|
-
*/
|
|
2034
|
-
monotonicNow?: () => number;
|
|
2035
|
-
/**
|
|
2036
|
-
* 1-minute whole-box load average source for the adaptive remote-poll
|
|
2037
|
-
* backoff (used only when `--poll-remote-ms` is omitted). Defaults to
|
|
2038
|
-
* `os.loadavg()[0]`. Tests inject a fixed sample to assert the interval
|
|
2039
|
-
* scales with load without depending on the host's real load.
|
|
2040
|
-
*/
|
|
2041
|
-
sampleLoadAvg?: () => number;
|
|
2042
|
-
/**
|
|
2043
|
-
* Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
|
|
2044
|
-
* (and the event-push wiring) so the poll loop and the watcher-triggered
|
|
2045
|
-
* targeted push share one seam and one in-flight guard. The default ignores
|
|
2046
|
-
* `deps` and forwards just the argv to `runRunner`.
|
|
2047
|
-
*/
|
|
2048
|
-
runPass?: (passArgv: string[]) => Promise<number | RunnerPassOutcome>;
|
|
2049
|
-
/** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
|
|
2050
|
-
runBackupPrune?: (hqRoot: string) => Promise<void>;
|
|
2051
|
-
/**
|
|
2052
|
-
* Clock seam for the event-push watcher's debounce window. Defaults to
|
|
2053
|
-
* {@link systemClock}; tests inject a `FakeClock` to advance the window
|
|
2054
|
-
* deterministically. Only consulted when `--event-push` is on.
|
|
2055
|
-
*/
|
|
2056
|
-
clock?: Clock;
|
|
2057
|
-
/**
|
|
2058
|
-
* Factory for the file watcher used in event-push mode. Defaults to a real
|
|
2059
|
-
* {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
|
|
2060
|
-
* `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
|
|
2061
|
-
*/
|
|
2062
|
-
createWatcher?: (opts: {
|
|
2063
|
-
hqRoot: string;
|
|
2064
|
-
debounceMs: number;
|
|
2065
|
-
clock: Clock;
|
|
2066
|
-
captureLocalDeleteSnapshots: (
|
|
2067
|
-
relativePath: string,
|
|
2068
|
-
kind: "unlink" | "unlinkDir",
|
|
2069
|
-
) => LocalDeleteSnapshot[];
|
|
2070
|
-
}) => WatcherSurface;
|
|
2071
|
-
/**
|
|
2072
|
-
* Register a one-shot shutdown signal handler. Defaults to listening for
|
|
2073
|
-
* SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
|
|
2074
|
-
* clean teardown without sending real signals. The returned fn detaches the
|
|
2075
|
-
* handler (called during teardown so tests don't leak listeners).
|
|
2076
|
-
*/
|
|
2077
|
-
onShutdownSignal?: (handler: () => void) => () => void;
|
|
2078
|
-
/**
|
|
2079
|
-
* Factory for the Phase 2 pull-on-event receiver (US-009). Defaults to a
|
|
2080
|
-
* {@link NoopPushReceiver} — the daemon ships the receiver SEAM wired into
|
|
2081
|
-
* the lifecycle (start after the watcher, dispose before exit) but stays
|
|
2082
|
-
* DORMANT by default: the per-client SQS queue is provisioned server-side
|
|
2083
|
-
* (an unbuilt follow-up) and the receiver is feature-flag gated. A future
|
|
2084
|
-
* menubar/CLI release injects an {@link SqsPushReceiver} here once a queue
|
|
2085
|
-
* URL is available. Only consulted when `--event-push` is on.
|
|
2086
|
-
*
|
|
2087
|
-
* The factory is handed a {@link SyncEngineFn} that bridges a received
|
|
2088
|
-
* PushEvent to a TARGETED pull pass (`--company <slug> --direction pull`,
|
|
2089
|
-
* or a personal `--companies --direction pull`) routed by the event's
|
|
2090
|
-
* `relativePath`, funneled through the same in-flight guard as the poll
|
|
2091
|
-
* loop and the watcher push so a pull-on-event never overlaps an in-flight
|
|
2092
|
-
* pass.
|
|
2093
|
-
*/
|
|
2094
|
-
createReceiver?: (opts: {
|
|
2095
|
-
syncFn: SyncEngineFn;
|
|
2096
|
-
hqRoot: string;
|
|
2097
|
-
}) => PushReceiver;
|
|
2098
|
-
/**
|
|
2099
|
-
* Phase 3 (US-017/US-018/US-019): factory for the event-driven publish +
|
|
2100
|
-
* pull wiring, consulted only when `--event-push` is on AND the rollout
|
|
2101
|
-
* gate ({@link resolveEventSync}) passes for the signed-in account.
|
|
2102
|
-
* Defaults to the real {@link defaultStartEventSync}. Tests inject a stub
|
|
2103
|
-
* to assert gate behavior without network/AWS.
|
|
2104
|
-
*/
|
|
2105
|
-
startEventSync?: (
|
|
2106
|
-
opts: StartEventSyncOptions,
|
|
2107
|
-
) => Promise<EventSyncHandles | null>;
|
|
2108
|
-
/**
|
|
2109
|
-
* Identity-claims source for the Phase 3 rollout gate (the loop has no
|
|
2110
|
-
* RunnerDeps; mirror of RunnerDeps.getIdTokenClaims). Defaults to reading
|
|
2111
|
-
* the cached Cognito idToken.
|
|
2112
|
-
*/
|
|
2113
|
-
getIdTokenClaims?: () => IdTokenClaims | null;
|
|
2114
|
-
/**
|
|
2115
|
-
* Access-token source for the Phase 3 vault API calls (publish transport +
|
|
2116
|
-
* subscribe). Defaults to {@link getValidAccessToken} non-interactive.
|
|
2117
|
-
*/
|
|
2118
|
-
getAccessToken?: () => Promise<string>;
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
/**
|
|
2122
|
-
* The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
|
|
2123
|
-
* it; tests inject a lighter stub. Kept narrow so the loop never reaches past
|
|
2124
|
-
* the lifecycle + change-subscription contract.
|
|
2125
|
-
*
|
|
2126
|
-
* `onChange`'s listener receives an OPTIONAL changed relative path. The real
|
|
2127
|
-
* {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
|
|
2128
|
-
* the loop routes the targeted push to the personal vault. A path-aware
|
|
2129
|
-
* watcher (or a test stub) can pass the changed `companies/<slug>/...`
|
|
2130
|
-
* relative path so the loop targets just that company.
|
|
2131
|
-
*/
|
|
2132
|
-
export interface WatcherSurface {
|
|
2133
|
-
onChange(
|
|
2134
|
-
listener: (changedRelPath?: string, batch?: TreeChangeBatch) => void,
|
|
2135
|
-
): () => void;
|
|
2136
|
-
start(): void;
|
|
2137
|
-
stop(): void;
|
|
2138
|
-
dispose(): void;
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
export async function runRunnerWithLoop(
|
|
2142
|
-
argv: string[],
|
|
2143
|
-
deps: RunnerLoopDeps = {},
|
|
2144
|
-
): Promise<number> {
|
|
2145
|
-
const parsed = parseArgs(argv);
|
|
2146
|
-
const runtime = {
|
|
2147
|
-
runPassWithOperationLockAlreadyHeld: async (passArgv: string[]) => {
|
|
2148
|
-
let result: RunnerPassResult | undefined;
|
|
2149
|
-
const exitCode = await runRunner(passArgv, {
|
|
2150
|
-
operationLockAlreadyHeld: true,
|
|
2151
|
-
authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
|
|
2152
|
-
onPassResult: (passResult) => {
|
|
2153
|
-
result = passResult;
|
|
2154
|
-
},
|
|
2155
|
-
});
|
|
2156
|
-
return { exitCode, ...(result ? { result } : {}) };
|
|
2157
|
-
},
|
|
2158
|
-
authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
|
|
2159
|
-
defaultGetIdTokenClaims,
|
|
2160
|
-
defaultGetAccessToken: () =>
|
|
2161
|
-
getValidAccessToken(DEFAULT_COGNITO, { interactive: false }),
|
|
2162
|
-
apiUrl: DEFAULT_VAULT_API_URL,
|
|
2163
|
-
region: DEFAULT_COGNITO.region,
|
|
2164
|
-
};
|
|
2165
|
-
|
|
2166
|
-
if (!argv.includes("--watch")) {
|
|
2167
|
-
if ("error" in parsed) return runRunner(argv);
|
|
2168
|
-
return runOneShotWithOperationLock(argv, parsed, deps, runtime);
|
|
2169
|
-
}
|
|
2170
|
-
|
|
2171
|
-
if ("error" in parsed) return runRunner(argv);
|
|
2172
|
-
return runWatchLoop(argv, parsed, deps, runtime);
|
|
2173
|
-
}
|
|
2174
|
-
|
|
2175
|
-
if (isDirectInvocation) {
|
|
2176
|
-
runRunnerWithLoop(process.argv.slice(2))
|
|
2177
|
-
.then((code) => process.exit(code))
|
|
2178
|
-
.catch((err) => {
|
|
2179
|
-
// A top-level rejection means the runner died before (or instead of)
|
|
2180
|
-
// emitting its protocol stream. Emit a CLASSIFIED `error` event first so
|
|
2181
|
-
// the menubar runs it through `is_alertable_error` and suppresses the
|
|
2182
|
-
// benign cases, instead of treating every uncaught exit as an unexplained
|
|
2183
|
-
// "silent death" crash (HQ-SYNC-WEB-6).
|
|
2184
|
-
emitUncaughtRunnerError(err, {
|
|
2185
|
-
stdout: process.stdout,
|
|
2186
|
-
stderr: process.stderr,
|
|
2187
|
-
});
|
|
2188
|
-
process.exit(1);
|
|
2189
|
-
});
|
|
2190
|
-
}
|