@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
|
@@ -1,1235 +0,0 @@
|
|
|
1
|
-
import * as os from "os";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import * as fs from "fs";
|
|
4
|
-
import { performance } from "node:perf_hooks";
|
|
5
|
-
import { inventoryManagedSnapshots } from "../backup-prune.js";
|
|
6
|
-
import { VaultClient } from "../index.js";
|
|
7
|
-
import { pickCanonicalPersonEntity } from "../vault-client.js";
|
|
8
|
-
import {
|
|
9
|
-
acquireOperationLock,
|
|
10
|
-
withOperationLock,
|
|
11
|
-
OperationLockedError,
|
|
12
|
-
OPERATION_LOCKED_EXIT,
|
|
13
|
-
} from "../operation-lock.js";
|
|
14
|
-
import { describeError } from "../lib/describe-error.js";
|
|
15
|
-
import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
|
|
16
|
-
import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
|
|
17
|
-
import { getOrCreateMachineId } from "../lib/machine-id.js";
|
|
18
|
-
import { localPathForVaultKey } from "../local-path-codec.js";
|
|
19
|
-
import {
|
|
20
|
-
TreeWatcher,
|
|
21
|
-
systemClock,
|
|
22
|
-
type LocalDeleteSnapshot,
|
|
23
|
-
type TreeChange,
|
|
24
|
-
type TreeChangeBatch,
|
|
25
|
-
} from "../watcher.js";
|
|
26
|
-
import {
|
|
27
|
-
clearLocalDeleteIntent,
|
|
28
|
-
markLocalDeleteIntent,
|
|
29
|
-
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
30
|
-
readJournal,
|
|
31
|
-
writeJournal,
|
|
32
|
-
} from "../journal.js";
|
|
33
|
-
import {
|
|
34
|
-
NoopPushReceiver,
|
|
35
|
-
type PushReceiver,
|
|
36
|
-
type SyncEngineFn,
|
|
37
|
-
} from "../sync/push-receiver.js";
|
|
38
|
-
import {
|
|
39
|
-
resolveEventSync,
|
|
40
|
-
startEventSync as defaultStartEventSync,
|
|
41
|
-
type EventSyncHandles,
|
|
42
|
-
} from "../sync/event-sync.js";
|
|
43
|
-
import type { TelemetryClaims } from "../telemetry-events.js";
|
|
44
|
-
import {
|
|
45
|
-
buildScopedDrainPullArgv,
|
|
46
|
-
buildScopedPushArgv,
|
|
47
|
-
buildTargetedPullArgv,
|
|
48
|
-
isWatchRouteSelected,
|
|
49
|
-
routeChangeToTarget,
|
|
50
|
-
routeKey,
|
|
51
|
-
type WatchRoute,
|
|
52
|
-
} from "./sync-runner-watch-routes.js";
|
|
53
|
-
import { resolveSkipCompanies } from "./sync-runner-planning.js";
|
|
54
|
-
import type {
|
|
55
|
-
Direction,
|
|
56
|
-
RunnerLoopDeps,
|
|
57
|
-
RunnerPassOutcome,
|
|
58
|
-
WatcherSurface,
|
|
59
|
-
} from "./sync-runner.js";
|
|
60
|
-
|
|
61
|
-
const FULL_RECONCILE_EVERY_TICKS = 10;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Adaptive remote-poll backoff bounds (whole-box CPU aware). When
|
|
65
|
-
* `--poll-remote-ms` is NOT passed, the watch loop scales its poll cadence to
|
|
66
|
-
* the host's overall load instead of using a fixed interval: ~60s when the box
|
|
67
|
-
* is idle, stretching toward ~10min under sustained full load, so the periodic
|
|
68
|
-
* pull yields CPU/I-O to whatever else is running (e.g. the user's live agent
|
|
69
|
-
* session). The poll is only a correctness backstop — event-push handles
|
|
70
|
-
* real-time — so a stale interval under load is safe. An explicit
|
|
71
|
-
* `--poll-remote-ms <n>` overrides this entirely and pins a fixed interval.
|
|
72
|
-
*/
|
|
73
|
-
const ADAPTIVE_POLL_FLOOR_MS = 60_000;
|
|
74
|
-
const ADAPTIVE_POLL_CEIL_MS = 600_000;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Map whole-box load to a poll interval in the [floor, ceil] range.
|
|
78
|
-
*
|
|
79
|
-
* `load1` is the 1-minute load average ({@link os.loadavg}[0]); dividing by the
|
|
80
|
-
* CPU count yields per-core saturation. ratio 0 (idle) → floor; ratio ≥ 1
|
|
81
|
-
* (fully saturated) → ceil; linear in between. Pure + injectable so the loop
|
|
82
|
-
* can be unit-tested without a real host or a 10-minute wait.
|
|
83
|
-
*/
|
|
84
|
-
export function adaptivePollMs(
|
|
85
|
-
load1: number,
|
|
86
|
-
cpuCount: number,
|
|
87
|
-
floorMs: number = ADAPTIVE_POLL_FLOOR_MS,
|
|
88
|
-
ceilMs: number = ADAPTIVE_POLL_CEIL_MS,
|
|
89
|
-
): number {
|
|
90
|
-
const cores = cpuCount > 0 ? cpuCount : 1;
|
|
91
|
-
const saturation = load1 > 0 ? load1 / cores : 0;
|
|
92
|
-
const ratio = Math.max(0, Math.min(1, saturation));
|
|
93
|
-
return Math.round(floorMs + ratio * (ceilMs - floorMs));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Advance the cadence from its previous monotonic deadline, rather than from
|
|
98
|
-
* the end of a pass. A slow pass is allowed one immediate catch-up pass; the
|
|
99
|
-
* returned deadline is always the first future slot, so missed slots cannot
|
|
100
|
-
* turn into an unbounded burst.
|
|
101
|
-
*/
|
|
102
|
-
export function advanceMonotonicDeadline(
|
|
103
|
-
previousDueAt: number,
|
|
104
|
-
now: number,
|
|
105
|
-
intervalMs: number,
|
|
106
|
-
): { nextDueAt: number; delayMs: number } {
|
|
107
|
-
let nextDueAt = previousDueAt + intervalMs;
|
|
108
|
-
if (nextDueAt > now) {
|
|
109
|
-
return { nextDueAt, delayMs: nextDueAt - now };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// The pass crossed at least one deadline. Run exactly one catch-up now,
|
|
113
|
-
// then retain only the first future cadence slot.
|
|
114
|
-
do {
|
|
115
|
-
nextDueAt += intervalMs;
|
|
116
|
-
} while (nextDueAt <= now);
|
|
117
|
-
return { nextDueAt, delayMs: 0 };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export interface ParsedLoopArgs {
|
|
121
|
-
hqRoot: string;
|
|
122
|
-
lockTimeoutSec?: number;
|
|
123
|
-
/** Resolved parser flag; environment policy is applied by resolveSkipPersonal. */
|
|
124
|
-
skipPersonal?: boolean;
|
|
125
|
-
/**
|
|
126
|
-
* The run's resolved sync direction, straight from `parseArgs` (the single
|
|
127
|
-
* source of truth — same default as the parser). The drain reads this to
|
|
128
|
-
* decide whether scoped pushes and/or the pull leg run; it must NOT re-parse
|
|
129
|
-
* `--direction` from argv, which mis-defaults an omitted flag and disagrees
|
|
130
|
-
* with the parser on duplicate flags.
|
|
131
|
-
*/
|
|
132
|
-
direction?: Direction;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export interface WatchLoopRuntime {
|
|
136
|
-
runPassWithOperationLockAlreadyHeld: (
|
|
137
|
-
passArgv: string[],
|
|
138
|
-
) => Promise<number | RunnerPassOutcome>;
|
|
139
|
-
authRequiredPassExit: number;
|
|
140
|
-
defaultGetIdTokenClaims: () => ({ email?: string } & TelemetryClaims) | null;
|
|
141
|
-
defaultGetAccessToken: () => Promise<string>;
|
|
142
|
-
apiUrl: string;
|
|
143
|
-
region: string;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
type JournalFiles = ReturnType<typeof readJournal>["files"];
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Select journal entries covered by one watcher delete. Exact file unlinks
|
|
150
|
-
* deliberately avoid enumeration; only directory unlinks expand descendants.
|
|
151
|
-
*/
|
|
152
|
-
export function journalEntriesForWatcherDelete(
|
|
153
|
-
files: JournalFiles,
|
|
154
|
-
key: string,
|
|
155
|
-
kind: "unlink" | "unlinkDir",
|
|
156
|
-
): Array<[string, JournalFiles[string]]> {
|
|
157
|
-
if (kind === "unlink") {
|
|
158
|
-
const entry = files[key];
|
|
159
|
-
return entry ? [[key, entry]] : [];
|
|
160
|
-
}
|
|
161
|
-
return Object.entries(files).filter(
|
|
162
|
-
([candidate]) =>
|
|
163
|
-
key === "" || candidate === key || candidate.startsWith(`${key}/`),
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Bulk-intent quarantine thresholds. Deliberately the same shape and values as
|
|
169
|
-
* the engine's `BULK_ASYMMETRY_*` constants (`src/cli/share.ts`) so operators
|
|
170
|
-
* learn one rule and one knob: a disappearance covering >= 10 journal entries
|
|
171
|
-
* AND >= 10% of the shard is treated as suspect rather than deliberate.
|
|
172
|
-
*/
|
|
173
|
-
const WATCHER_BULK_INTENT_MIN_ABS = 10;
|
|
174
|
-
const WATCHER_BULK_INTENT_RATIO = 0.1;
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Should a single `unlinkDir` expansion be quarantined instead of stamping a
|
|
178
|
-
* version-bound delete intent on every descendant journal entry?
|
|
179
|
-
*
|
|
180
|
-
* One filesystem event can cover an arbitrary subtree, so the producer is
|
|
181
|
-
* capable of minting thousands of individually-valid intents from a checkout
|
|
182
|
-
* swap, an unmount, a partial restore or an `rm -rf` — the 2026-07-30 mass
|
|
183
|
-
* deletion did exactly that. Quarantined entries are simply left intent-less,
|
|
184
|
-
* which the engine already refuses (`missing-delete-intent`); nothing is
|
|
185
|
-
* deleted locally or remotely and the next pull restores the tree.
|
|
186
|
-
*
|
|
187
|
-
* Pure so the threshold is unit-testable without a watcher or a journal.
|
|
188
|
-
*/
|
|
189
|
-
export function shouldQuarantineWatcherDeleteIntents(
|
|
190
|
-
expandedEntries: number,
|
|
191
|
-
journalEntries: number,
|
|
192
|
-
): boolean {
|
|
193
|
-
if (expandedEntries < WATCHER_BULK_INTENT_MIN_ABS) return false;
|
|
194
|
-
if (journalEntries <= 0) return false;
|
|
195
|
-
return expandedEntries / journalEntries >= WATCHER_BULK_INTENT_RATIO;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Same operator escape hatch as the engine breaker — one knob for both layers.
|
|
200
|
-
* Set when a genuinely intentional large folder removal must propagate.
|
|
201
|
-
*/
|
|
202
|
-
function isBulkIntentOverride(): boolean {
|
|
203
|
-
const v = (process.env.HQ_SYNC_DELETE_BULK_OVERRIDE ?? "").toLowerCase();
|
|
204
|
-
return v === "1" || v === "true" || v === "yes";
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function passExitCode(outcome: number | RunnerPassOutcome): number {
|
|
208
|
-
return typeof outcome === "number" ? outcome : outcome.exitCode;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function companyPassFailures(
|
|
212
|
-
outcome: number | RunnerPassOutcome,
|
|
213
|
-
): Array<{ company: string; message: string }> {
|
|
214
|
-
return typeof outcome === "number"
|
|
215
|
-
? []
|
|
216
|
-
: outcome.result?.companyFailures ?? [];
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export async function runOneShotWithOperationLock(
|
|
220
|
-
argv: string[],
|
|
221
|
-
parsed: ParsedLoopArgs,
|
|
222
|
-
deps: RunnerLoopDeps,
|
|
223
|
-
runtime: WatchLoopRuntime,
|
|
224
|
-
): Promise<number> {
|
|
225
|
-
const runOnce =
|
|
226
|
-
deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
|
|
227
|
-
try {
|
|
228
|
-
return passExitCode(
|
|
229
|
-
await withOperationLock(parsed.hqRoot, "sync", () => runOnce(argv), {
|
|
230
|
-
timeoutSec: parsed.lockTimeoutSec,
|
|
231
|
-
}),
|
|
232
|
-
);
|
|
233
|
-
} catch (err) {
|
|
234
|
-
if (err instanceof OperationLockedError) {
|
|
235
|
-
process.stderr.write(err.message + "\n");
|
|
236
|
-
return OPERATION_LOCKED_EXIT;
|
|
237
|
-
}
|
|
238
|
-
throw err;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export async function runWatchLoop(
|
|
243
|
-
argv: string[],
|
|
244
|
-
parsed: ParsedLoopArgs,
|
|
245
|
-
deps: RunnerLoopDeps,
|
|
246
|
-
runtime: WatchLoopRuntime,
|
|
247
|
-
): Promise<number> {
|
|
248
|
-
const sleep =
|
|
249
|
-
deps.sleep ??
|
|
250
|
-
((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
251
|
-
const monotonicNow = deps.monotonicNow ?? (() => performance.now());
|
|
252
|
-
const runPass =
|
|
253
|
-
deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
|
|
254
|
-
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
255
|
-
// An explicit --poll-remote-ms pins a fixed interval (menubar/CLI still set
|
|
256
|
-
// one). When it is omitted, the interval is resolved fresh each cycle from
|
|
257
|
-
// whole-box load via adaptivePollMs() — see the sleep at the loop's tail.
|
|
258
|
-
const explicitPollMs =
|
|
259
|
-
pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : undefined;
|
|
260
|
-
const sampleLoadAvg = deps.sampleLoadAvg ?? (() => os.loadavg()[0]);
|
|
261
|
-
const cpuCount = os.cpus().length;
|
|
262
|
-
const eventPush = argv.includes("--event-push");
|
|
263
|
-
const companiesMode = argv.includes("--companies");
|
|
264
|
-
const companyMode = argv.includes("--company");
|
|
265
|
-
const personalMode = argv.includes("--personal");
|
|
266
|
-
// Keep this at the watcher ingress rather than importing the runner's
|
|
267
|
-
// resolver: sync-runner imports this loop, and a reverse value import would
|
|
268
|
-
// create a runtime cycle. These are the parser's documented truthy values.
|
|
269
|
-
const skipPersonalEnv = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
|
|
270
|
-
const watchRouteSelection = {
|
|
271
|
-
companies: companiesMode,
|
|
272
|
-
company: companyMode,
|
|
273
|
-
personal: personalMode,
|
|
274
|
-
skipPersonal:
|
|
275
|
-
parsed.skipPersonal === true ||
|
|
276
|
-
skipPersonalEnv === "1" ||
|
|
277
|
-
skipPersonalEnv === "true" ||
|
|
278
|
-
skipPersonalEnv === "yes",
|
|
279
|
-
};
|
|
280
|
-
const hqRoot = parsed.hqRoot;
|
|
281
|
-
// Delayed and daily: this must never be attached to the 15s poll cadence.
|
|
282
|
-
// Stage 0 remains dry-run only; the standalone hq-backup-prune command is
|
|
283
|
-
// the explicitly-gated apply surface.
|
|
284
|
-
let nextBackupPruneAt = Date.now() + 60_000;
|
|
285
|
-
const runBackupPrune = deps.runBackupPrune ?? (async (root: string) => {
|
|
286
|
-
const backupRoot = path.join(process.env.HOME ?? "", ".hq", "backups");
|
|
287
|
-
const plan = inventoryManagedSnapshots(backupRoot, root, (m) => { try { process.kill(m.creatorPid, 0); return true; } catch { return false; } });
|
|
288
|
-
process.stderr.write(`hq-sync-runner: backup retention dry-run — ${plan.candidates.length} candidate(s), ${plan.protected.length} protected\n`);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
const passArgv = argv.filter((a, i) => {
|
|
292
|
-
if (a === "--watch") return false;
|
|
293
|
-
if (a === "--poll-remote-ms") return false;
|
|
294
|
-
if (a === "--event-push") return false;
|
|
295
|
-
if (a === "--scope-path") return false;
|
|
296
|
-
if (a === "--delete-scope-path") return false;
|
|
297
|
-
if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
|
|
298
|
-
if (i > 0 && argv[i - 1] === "--scope-path") return false;
|
|
299
|
-
if (i > 0 && argv[i - 1] === "--delete-scope-path") return false;
|
|
300
|
-
return true;
|
|
301
|
-
});
|
|
302
|
-
// Authoritative direction from parseArgs — never re-derive from argv (that
|
|
303
|
-
// mis-defaults an omitted --direction to "both" and picks the wrong
|
|
304
|
-
// occurrence on duplicates). Fall back to the parser's own default ("pull").
|
|
305
|
-
const originalDirection: Direction = parsed.direction ?? "pull";
|
|
306
|
-
const drainRunsPush =
|
|
307
|
-
originalDirection === "both" || originalDirection === "push";
|
|
308
|
-
const drainRunsPull =
|
|
309
|
-
originalDirection === "both" || originalDirection === "pull";
|
|
310
|
-
|
|
311
|
-
if (parsed.lockTimeoutSec === 0) {
|
|
312
|
-
try {
|
|
313
|
-
const handle = acquireOperationLock(hqRoot, "sync", {
|
|
314
|
-
timeoutSec: 0,
|
|
315
|
-
onWaitStart: () => undefined,
|
|
316
|
-
});
|
|
317
|
-
handle.release();
|
|
318
|
-
} catch (err) {
|
|
319
|
-
if (err instanceof OperationLockedError) {
|
|
320
|
-
process.stderr.write(err.message + "\n");
|
|
321
|
-
return OPERATION_LOCKED_EXIT;
|
|
322
|
-
}
|
|
323
|
-
throw err;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
type PassPreparation = () => void;
|
|
328
|
-
const runPassWithLock = async (
|
|
329
|
-
passArgvForRun: string[],
|
|
330
|
-
prepare?: PassPreparation,
|
|
331
|
-
): Promise<number | RunnerPassOutcome> => {
|
|
332
|
-
try {
|
|
333
|
-
const handle = acquireOperationLock(hqRoot, "sync", {
|
|
334
|
-
timeoutSec: 0,
|
|
335
|
-
onWaitStart: () => undefined,
|
|
336
|
-
});
|
|
337
|
-
try {
|
|
338
|
-
prepare?.();
|
|
339
|
-
return await runPass(passArgvForRun);
|
|
340
|
-
} finally {
|
|
341
|
-
handle.release();
|
|
342
|
-
}
|
|
343
|
-
} catch (err) {
|
|
344
|
-
if (!(err instanceof OperationLockedError)) throw err;
|
|
345
|
-
if (parsed.lockTimeoutSec === 0) {
|
|
346
|
-
process.stderr.write(err.message + "\n");
|
|
347
|
-
return OPERATION_LOCKED_EXIT;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
try {
|
|
352
|
-
return await withOperationLock(
|
|
353
|
-
hqRoot,
|
|
354
|
-
"sync",
|
|
355
|
-
() => {
|
|
356
|
-
prepare?.();
|
|
357
|
-
return runPass(passArgvForRun);
|
|
358
|
-
},
|
|
359
|
-
{ timeoutSec: parsed.lockTimeoutSec },
|
|
360
|
-
);
|
|
361
|
-
} catch (err) {
|
|
362
|
-
if (err instanceof OperationLockedError) {
|
|
363
|
-
process.stderr.write(err.message + "\n");
|
|
364
|
-
return OPERATION_LOCKED_EXIT;
|
|
365
|
-
}
|
|
366
|
-
throw err;
|
|
367
|
-
}
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
let stopped = false;
|
|
371
|
-
let resolveStopped: (() => void) | null = null;
|
|
372
|
-
const stoppedSignal = new Promise<void>((resolve) => {
|
|
373
|
-
resolveStopped = resolve;
|
|
374
|
-
});
|
|
375
|
-
// A signal can arrive while a guarded pass is running, before the next wait
|
|
376
|
-
// has registered. Keep a monotonic generation so that event is retained and
|
|
377
|
-
// consumed by the next scheduler wait instead of being lost.
|
|
378
|
-
let interruptGeneration = 0;
|
|
379
|
-
let observedInterruptGeneration = 0;
|
|
380
|
-
let resolveInterruptWait: (() => void) | null = null;
|
|
381
|
-
const interruptWait = (): void => {
|
|
382
|
-
if (stopped) return;
|
|
383
|
-
interruptGeneration += 1;
|
|
384
|
-
const resolve = resolveInterruptWait;
|
|
385
|
-
resolveInterruptWait = null;
|
|
386
|
-
resolve?.();
|
|
387
|
-
};
|
|
388
|
-
const waitForNextPass = async (delayMs: number): Promise<void> => {
|
|
389
|
-
if (interruptGeneration !== observedInterruptGeneration) {
|
|
390
|
-
observedInterruptGeneration = interruptGeneration;
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
await Promise.race([
|
|
395
|
-
sleep(delayMs),
|
|
396
|
-
new Promise<void>((resolve) => {
|
|
397
|
-
resolveInterruptWait = resolve;
|
|
398
|
-
// Do not lose an event that arrived between the first generation
|
|
399
|
-
// check and registration of this wait.
|
|
400
|
-
if (interruptGeneration !== observedInterruptGeneration) {
|
|
401
|
-
resolveInterruptWait = null;
|
|
402
|
-
resolve();
|
|
403
|
-
}
|
|
404
|
-
}),
|
|
405
|
-
stoppedSignal,
|
|
406
|
-
]);
|
|
407
|
-
resolveInterruptWait = null;
|
|
408
|
-
observedInterruptGeneration = interruptGeneration;
|
|
409
|
-
};
|
|
410
|
-
let activePass: Promise<number | RunnerPassOutcome> | null = null;
|
|
411
|
-
type QueuedPass = {
|
|
412
|
-
argv: string[];
|
|
413
|
-
prepare?: PassPreparation;
|
|
414
|
-
resolve: (outcome: number | RunnerPassOutcome) => void;
|
|
415
|
-
reject: (err: unknown) => void;
|
|
416
|
-
};
|
|
417
|
-
const pendingPasses: QueuedPass[] = [];
|
|
418
|
-
const resolveStoppedQueue = (): void => {
|
|
419
|
-
while (pendingPasses.length > 0) {
|
|
420
|
-
pendingPasses.shift()?.resolve(0);
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
const drainQueuedPasses = (): void => {
|
|
424
|
-
if (activePass !== null) return;
|
|
425
|
-
if (stopped) {
|
|
426
|
-
resolveStoppedQueue();
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
const next = pendingPasses.shift();
|
|
430
|
-
if (!next) return;
|
|
431
|
-
const current = startGuardedPass(next.argv, next.prepare);
|
|
432
|
-
void current.then(next.resolve, next.reject);
|
|
433
|
-
};
|
|
434
|
-
const startGuardedPass = (
|
|
435
|
-
passArgvForRun: string[],
|
|
436
|
-
prepare?: PassPreparation,
|
|
437
|
-
): Promise<number | RunnerPassOutcome> => {
|
|
438
|
-
const current = stopped
|
|
439
|
-
? Promise.resolve(0)
|
|
440
|
-
: runPassWithLock(passArgvForRun, prepare);
|
|
441
|
-
activePass = current;
|
|
442
|
-
void current
|
|
443
|
-
.finally(() => {
|
|
444
|
-
if (activePass === current) {
|
|
445
|
-
activePass = null;
|
|
446
|
-
drainQueuedPasses();
|
|
447
|
-
}
|
|
448
|
-
})
|
|
449
|
-
.catch(() => undefined);
|
|
450
|
-
return current;
|
|
451
|
-
};
|
|
452
|
-
const runGuarded = (
|
|
453
|
-
passArgvForRun: string[],
|
|
454
|
-
prepare?: PassPreparation,
|
|
455
|
-
): Promise<number | RunnerPassOutcome> => {
|
|
456
|
-
if (activePass === null && pendingPasses.length === 0) {
|
|
457
|
-
return startGuardedPass(passArgvForRun, prepare);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
return new Promise<number | RunnerPassOutcome>((resolve, reject) => {
|
|
461
|
-
pendingPasses.push({ argv: passArgvForRun, prepare, resolve, reject });
|
|
462
|
-
drainQueuedPasses();
|
|
463
|
-
});
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
let watcher: WatcherSurface | null = null;
|
|
467
|
-
let detachSignal: (() => void) | null = null;
|
|
468
|
-
const pendingWatcherPaths = new Map<string, string>();
|
|
469
|
-
const pendingWatcherChanges = new Map<string, TreeChange>();
|
|
470
|
-
let pendingWatcherOriginalBatch: TreeChangeBatch | null = null;
|
|
471
|
-
// Every coalesced watcher batch gets one durable in-process generation.
|
|
472
|
-
// A wake can cause the scheduler to revisit a drained batch, but an event
|
|
473
|
-
// announcement is an external side effect and must occur at most once for
|
|
474
|
-
// that batch after its targeted push succeeds.
|
|
475
|
-
let nextWatcherBatchGeneration = 0;
|
|
476
|
-
let pendingWatcherBatchGeneration: number | null = null;
|
|
477
|
-
const lastPublishedWatcherBatchGenerationByRoute = new Map<string, number>();
|
|
478
|
-
let pendingWatcherBareChange = false;
|
|
479
|
-
let pendingWatcherOverflowed = false;
|
|
480
|
-
let pendingWatcherDroppedPaths = 0;
|
|
481
|
-
let pendingWatcherDroppedBytes = 0;
|
|
482
|
-
const addPendingWatcherChange = (
|
|
483
|
-
changedRelPath?: string,
|
|
484
|
-
batch?: TreeChangeBatch,
|
|
485
|
-
): boolean => {
|
|
486
|
-
if (batch) {
|
|
487
|
-
const paths = new Map<string, string>();
|
|
488
|
-
const changes = new Map<string, TreeChange>();
|
|
489
|
-
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
490
|
-
const route = routeChangeToTarget(relativePath);
|
|
491
|
-
if (!route || !isWatchRouteSelected(route, watchRouteSelection)) continue;
|
|
492
|
-
paths.set(absolutePath, relativePath);
|
|
493
|
-
const change = batch.changes?.get(absolutePath);
|
|
494
|
-
if (change) changes.set(absolutePath, change);
|
|
495
|
-
}
|
|
496
|
-
if (paths.size === 0) return false;
|
|
497
|
-
batch = {
|
|
498
|
-
...batch,
|
|
499
|
-
paths,
|
|
500
|
-
changes,
|
|
501
|
-
};
|
|
502
|
-
} else if (changedRelPath) {
|
|
503
|
-
const route = routeChangeToTarget(changedRelPath);
|
|
504
|
-
if (!route || !isWatchRouteSelected(route, watchRouteSelection)) {
|
|
505
|
-
return false;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
pendingWatcherBatchGeneration ??= ++nextWatcherBatchGeneration;
|
|
509
|
-
if (batch) {
|
|
510
|
-
pendingWatcherOriginalBatch =
|
|
511
|
-
pendingWatcherPaths.size === 0 &&
|
|
512
|
-
!pendingWatcherBareChange &&
|
|
513
|
-
!pendingWatcherOverflowed &&
|
|
514
|
-
!batch.overflowed
|
|
515
|
-
? batch
|
|
516
|
-
: null;
|
|
517
|
-
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
518
|
-
pendingWatcherPaths.set(absolutePath, relativePath);
|
|
519
|
-
const change = batch.changes?.get(absolutePath);
|
|
520
|
-
if (change) pendingWatcherChanges.set(absolutePath, change);
|
|
521
|
-
}
|
|
522
|
-
if (batch.overflowed) {
|
|
523
|
-
pendingWatcherOverflowed = true;
|
|
524
|
-
pendingWatcherDroppedPaths += batch.droppedPaths ?? 0;
|
|
525
|
-
pendingWatcherDroppedBytes += batch.droppedBytes ?? 0;
|
|
526
|
-
}
|
|
527
|
-
return true;
|
|
528
|
-
}
|
|
529
|
-
if (changedRelPath) {
|
|
530
|
-
pendingWatcherOriginalBatch = null;
|
|
531
|
-
// Codec boundary: `changedRelPath` is a canonical vault key (the
|
|
532
|
-
// TreeWatcher decodes win32 farm names before emitting), so its local
|
|
533
|
-
// counterpart must be re-derived through the codec — a raw join would
|
|
534
|
-
// point at the impossible colon path on Windows.
|
|
535
|
-
const absolutePath = localPathForVaultKey(hqRoot, changedRelPath);
|
|
536
|
-
pendingWatcherPaths.set(absolutePath, changedRelPath);
|
|
537
|
-
// A bare path signal carries no delete intent; drop any change record
|
|
538
|
-
// a prior batch left for this path so the stale intent cannot leak
|
|
539
|
-
// into the coalesced batch (#227).
|
|
540
|
-
pendingWatcherChanges.delete(absolutePath);
|
|
541
|
-
return true;
|
|
542
|
-
}
|
|
543
|
-
pendingWatcherOriginalBatch = null;
|
|
544
|
-
pendingWatcherBareChange = true;
|
|
545
|
-
return true;
|
|
546
|
-
};
|
|
547
|
-
const takePendingWatcherChange = (): {
|
|
548
|
-
batch: TreeChangeBatch | null;
|
|
549
|
-
bare: boolean;
|
|
550
|
-
generation: number | null;
|
|
551
|
-
} => {
|
|
552
|
-
const batch =
|
|
553
|
-
pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
|
|
554
|
-
? pendingWatcherOriginalBatch
|
|
555
|
-
: pendingWatcherPaths.size > 0 || pendingWatcherOverflowed
|
|
556
|
-
? {
|
|
557
|
-
paths: new Map(pendingWatcherPaths),
|
|
558
|
-
...(pendingWatcherChanges.size > 0
|
|
559
|
-
? { changes: new Map(pendingWatcherChanges) }
|
|
560
|
-
: {}),
|
|
561
|
-
...(pendingWatcherOverflowed
|
|
562
|
-
? {
|
|
563
|
-
overflowed: true,
|
|
564
|
-
droppedPaths: pendingWatcherDroppedPaths,
|
|
565
|
-
droppedBytes: pendingWatcherDroppedBytes,
|
|
566
|
-
}
|
|
567
|
-
: {}),
|
|
568
|
-
}
|
|
569
|
-
: null;
|
|
570
|
-
const bare = pendingWatcherBareChange;
|
|
571
|
-
const generation = pendingWatcherBatchGeneration;
|
|
572
|
-
pendingWatcherPaths.clear();
|
|
573
|
-
pendingWatcherChanges.clear();
|
|
574
|
-
pendingWatcherOriginalBatch = null;
|
|
575
|
-
pendingWatcherBareChange = false;
|
|
576
|
-
pendingWatcherOverflowed = false;
|
|
577
|
-
pendingWatcherDroppedPaths = 0;
|
|
578
|
-
pendingWatcherDroppedBytes = 0;
|
|
579
|
-
pendingWatcherBatchGeneration = null;
|
|
580
|
-
return { batch, bare, generation };
|
|
581
|
-
};
|
|
582
|
-
const restorePendingWatcherPaths = (
|
|
583
|
-
paths: Map<string, string>,
|
|
584
|
-
generation: number | null,
|
|
585
|
-
): void => {
|
|
586
|
-
pendingWatcherBatchGeneration ??=
|
|
587
|
-
generation ?? ++nextWatcherBatchGeneration;
|
|
588
|
-
pendingWatcherOriginalBatch = null;
|
|
589
|
-
for (const [absolutePath, relativePath] of paths.entries()) {
|
|
590
|
-
pendingWatcherPaths.set(absolutePath, relativePath);
|
|
591
|
-
}
|
|
592
|
-
};
|
|
593
|
-
|
|
594
|
-
/**
|
|
595
|
-
* Journal roots the bulk-intent quarantine has refused for the current
|
|
596
|
-
* delete episode, keyed `slug\0journalKey`.
|
|
597
|
-
*
|
|
598
|
-
* Chokidar (the Linux backend) reports every removed child through `unlink`
|
|
599
|
-
* BEFORE the parent `unlinkDir`, so by the time the directory expansion is
|
|
600
|
-
* refused each descendant has already persisted its own version-bound intent
|
|
601
|
-
* and handed a snapshot to the pending batch. Declining the expansion alone
|
|
602
|
-
* leaves those intents standing and `prepareWatcherChanges` re-stamps them
|
|
603
|
-
* from the snapshots, so a large `rm -rf` still propagates in full under
|
|
604
|
-
* `HQ_SYNC_DELETE_POLICY=all` — the path this producer-side layer exists to
|
|
605
|
-
* cover, because it bypasses the engine breaker entirely. The trip therefore
|
|
606
|
-
* revokes the descendants' intents as well, and this set keeps the refusal
|
|
607
|
-
* in force for the rest of the episode (the native macOS/Windows backends
|
|
608
|
-
* make no child-before-parent guarantee, so descendants can also arrive
|
|
609
|
-
* after the directory event).
|
|
610
|
-
*/
|
|
611
|
-
const quarantinedDeleteRoots = new Set<string>();
|
|
612
|
-
const QUARANTINE_KEY_SEPARATOR = "\u0000";
|
|
613
|
-
const quarantineRootKey = (slug: string, journalKey: string): string =>
|
|
614
|
-
`${slug}${QUARANTINE_KEY_SEPARATOR}${journalKey}`;
|
|
615
|
-
const quarantineRootFor = (
|
|
616
|
-
slug: string,
|
|
617
|
-
journalKey: string,
|
|
618
|
-
): string | null => {
|
|
619
|
-
for (const root of quarantinedDeleteRoots) {
|
|
620
|
-
const separator = root.indexOf(QUARANTINE_KEY_SEPARATOR);
|
|
621
|
-
if (root.slice(0, separator) !== slug) continue;
|
|
622
|
-
const rootKey = root.slice(separator + 1);
|
|
623
|
-
if (
|
|
624
|
-
rootKey === "" ||
|
|
625
|
-
journalKey === rootKey ||
|
|
626
|
-
journalKey.startsWith(`${rootKey}/`)
|
|
627
|
-
) {
|
|
628
|
-
return root;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
return null;
|
|
632
|
-
};
|
|
633
|
-
|
|
634
|
-
const journalCoordinates = (
|
|
635
|
-
relativePath: string,
|
|
636
|
-
): { slug: string; key: string } | null => {
|
|
637
|
-
const route = routeChangeToTarget(relativePath);
|
|
638
|
-
if (!route || !isWatchRouteSelected(route, watchRouteSelection)) return null;
|
|
639
|
-
if (route.kind === "personal") {
|
|
640
|
-
return { slug: PERSONAL_VAULT_JOURNAL_SLUG, key: relativePath };
|
|
641
|
-
}
|
|
642
|
-
const prefix = `companies/${route.slug}`;
|
|
643
|
-
return {
|
|
644
|
-
slug: route.slug,
|
|
645
|
-
key:
|
|
646
|
-
relativePath === prefix
|
|
647
|
-
? ""
|
|
648
|
-
: relativePath.slice(prefix.length + 1),
|
|
649
|
-
};
|
|
650
|
-
};
|
|
651
|
-
|
|
652
|
-
const captureLocalDeleteSnapshots = (
|
|
653
|
-
relativePath: string,
|
|
654
|
-
kind: "unlink" | "unlinkDir",
|
|
655
|
-
): LocalDeleteSnapshot[] => {
|
|
656
|
-
const coordinates = journalCoordinates(relativePath);
|
|
657
|
-
const route = routeChangeToTarget(relativePath);
|
|
658
|
-
if (!coordinates || !route) return [];
|
|
659
|
-
const journal = readJournal(coordinates.slug);
|
|
660
|
-
const snapshots: LocalDeleteSnapshot[] = [];
|
|
661
|
-
const captureEntry = (
|
|
662
|
-
key: string,
|
|
663
|
-
entry: (typeof journal.files)[string] | undefined,
|
|
664
|
-
): void => {
|
|
665
|
-
if (!entry?.remoteEtag || !entry.kind || entry.removedAt !== undefined) {
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
const snapshot: LocalDeleteSnapshot = {
|
|
669
|
-
journalSlug: coordinates.slug,
|
|
670
|
-
journalPath: key,
|
|
671
|
-
deleteScopeRoot: coordinates.key,
|
|
672
|
-
absolutePath:
|
|
673
|
-
route.kind === "company"
|
|
674
|
-
? path.join(hqRoot, "companies", route.slug, key)
|
|
675
|
-
: path.join(hqRoot, key),
|
|
676
|
-
remoteEtag: entry.remoteEtag,
|
|
677
|
-
localHash: entry.hash,
|
|
678
|
-
localKind: entry.kind,
|
|
679
|
-
};
|
|
680
|
-
if (
|
|
681
|
-
markLocalDeleteIntent(
|
|
682
|
-
journal,
|
|
683
|
-
key,
|
|
684
|
-
snapshot.localHash,
|
|
685
|
-
snapshot.localKind,
|
|
686
|
-
snapshot.remoteEtag,
|
|
687
|
-
)
|
|
688
|
-
) {
|
|
689
|
-
snapshots.push(snapshot);
|
|
690
|
-
}
|
|
691
|
-
};
|
|
692
|
-
|
|
693
|
-
// Chokidar emits one unlink per file during rm -rf. Avoid walking the
|
|
694
|
-
// whole shard for every exact-key event; directory unlinks alone need
|
|
695
|
-
// descendant expansion.
|
|
696
|
-
const covered = journalEntriesForWatcherDelete(
|
|
697
|
-
journal.files,
|
|
698
|
-
coordinates.key,
|
|
699
|
-
kind,
|
|
700
|
-
);
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* Revoke every delete intent under this event, including ones a descendant
|
|
704
|
-
* `unlink` already persisted before the parent `unlinkDir` arrived. An
|
|
705
|
-
* intent-less disappearance is the safe state: the engine refuses it
|
|
706
|
-
* (`missing-delete-intent`) under every delete policy, nothing is deleted
|
|
707
|
-
* locally or remotely, and the next pull restores the subtree.
|
|
708
|
-
*/
|
|
709
|
-
const revokeCoveredDeleteIntents = (): number => {
|
|
710
|
-
let revoked = 0;
|
|
711
|
-
for (const [key] of covered) {
|
|
712
|
-
if (clearLocalDeleteIntent(journal, key)) revoked += 1;
|
|
713
|
-
}
|
|
714
|
-
if (revoked > 0) writeJournal(coordinates.slug, journal);
|
|
715
|
-
return revoked;
|
|
716
|
-
};
|
|
717
|
-
|
|
718
|
-
// A refused directory removal covers its descendants for the rest of the
|
|
719
|
-
// episode, whichever order the backend reports them in.
|
|
720
|
-
if (quarantineRootFor(coordinates.slug, coordinates.key)) {
|
|
721
|
-
revokeCoveredDeleteIntents();
|
|
722
|
-
return [];
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Bulk-intent quarantine: one directory event must not be able to mint
|
|
726
|
-
// intents across a large fraction of the shard. Leaving them intent-less
|
|
727
|
-
// is the safe state — the engine refuses intent-less disappearances and
|
|
728
|
-
// the next pull restores the subtree.
|
|
729
|
-
const stampable = covered.filter(
|
|
730
|
-
([, entry]) =>
|
|
731
|
-
entry?.remoteEtag && entry.kind && entry.removedAt === undefined,
|
|
732
|
-
);
|
|
733
|
-
if (
|
|
734
|
-
kind === "unlinkDir" &&
|
|
735
|
-
!isBulkIntentOverride() &&
|
|
736
|
-
shouldQuarantineWatcherDeleteIntents(
|
|
737
|
-
stampable.length,
|
|
738
|
-
Object.keys(journal.files).length,
|
|
739
|
-
)
|
|
740
|
-
) {
|
|
741
|
-
quarantinedDeleteRoots.add(
|
|
742
|
-
quarantineRootKey(coordinates.slug, coordinates.key),
|
|
743
|
-
);
|
|
744
|
-
const revoked = revokeCoveredDeleteIntents();
|
|
745
|
-
process.stderr.write(
|
|
746
|
-
`hq-sync-runner: refusing to record delete intent for ${stampable.length} ` +
|
|
747
|
-
`of ${Object.keys(journal.files).length} journal entries under ` +
|
|
748
|
-
`"${coordinates.key || "<root>"}" — a single directory removal that large ` +
|
|
749
|
-
`is treated as a possible mirror loss. Nothing was deleted. ` +
|
|
750
|
-
`Revoked ${revoked} intent(s) already recorded by descendant unlink ` +
|
|
751
|
-
`events. Set HQ_SYNC_DELETE_BULK_OVERRIDE=1 to propagate it ` +
|
|
752
|
-
`deliberately.\n`,
|
|
753
|
-
);
|
|
754
|
-
return [];
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
for (const [key, entry] of stampable) {
|
|
758
|
-
captureEntry(key, entry);
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// Persist at observation time, before the debounced batch can queue
|
|
762
|
-
// behind an in-flight pull. The pull planner treats this current,
|
|
763
|
-
// version-bound intent as authoritative and will not resurrect the path.
|
|
764
|
-
if (snapshots.length > 0) {
|
|
765
|
-
writeJournal(coordinates.slug, journal);
|
|
766
|
-
}
|
|
767
|
-
return snapshots;
|
|
768
|
-
};
|
|
769
|
-
|
|
770
|
-
const prepareWatcherChanges = (changes: Map<string, TreeChange>): void => {
|
|
771
|
-
const journals = new Map<string, ReturnType<typeof readJournal>>();
|
|
772
|
-
const dirty = new Set<string>();
|
|
773
|
-
const journalFor = (slug: string) => {
|
|
774
|
-
const loaded = journals.get(slug);
|
|
775
|
-
if (loaded) return loaded;
|
|
776
|
-
const journal = readJournal(slug);
|
|
777
|
-
journals.set(slug, journal);
|
|
778
|
-
return journal;
|
|
779
|
-
};
|
|
780
|
-
|
|
781
|
-
for (const [absolutePath, change] of changes.entries()) {
|
|
782
|
-
if (change.kind === "unlink" || change.kind === "unlinkDir") {
|
|
783
|
-
for (const snapshot of change.deleteSnapshots ?? []) {
|
|
784
|
-
// Snapshots captured by descendant unlinks before the directory
|
|
785
|
-
// expansion was refused are part of the same quarantined episode.
|
|
786
|
-
// Re-stamping them here would hand the engine exactly the bulk
|
|
787
|
-
// delete the quarantine declined.
|
|
788
|
-
if (quarantineRootFor(snapshot.journalSlug, snapshot.journalPath)) {
|
|
789
|
-
if (
|
|
790
|
-
clearLocalDeleteIntent(
|
|
791
|
-
journalFor(snapshot.journalSlug),
|
|
792
|
-
snapshot.journalPath,
|
|
793
|
-
)
|
|
794
|
-
) {
|
|
795
|
-
dirty.add(snapshot.journalSlug);
|
|
796
|
-
}
|
|
797
|
-
continue;
|
|
798
|
-
}
|
|
799
|
-
let absent = false;
|
|
800
|
-
try {
|
|
801
|
-
fs.lstatSync(snapshot.absolutePath);
|
|
802
|
-
} catch (err: unknown) {
|
|
803
|
-
const code =
|
|
804
|
-
err && typeof err === "object" && "code" in err
|
|
805
|
-
? (err as { code?: string }).code
|
|
806
|
-
: undefined;
|
|
807
|
-
if (code !== "ENOENT") continue;
|
|
808
|
-
absent = true;
|
|
809
|
-
}
|
|
810
|
-
if (!absent) {
|
|
811
|
-
if (
|
|
812
|
-
clearLocalDeleteIntent(
|
|
813
|
-
journalFor(snapshot.journalSlug),
|
|
814
|
-
snapshot.journalPath,
|
|
815
|
-
)
|
|
816
|
-
) {
|
|
817
|
-
dirty.add(snapshot.journalSlug);
|
|
818
|
-
}
|
|
819
|
-
continue;
|
|
820
|
-
}
|
|
821
|
-
if (
|
|
822
|
-
markLocalDeleteIntent(
|
|
823
|
-
journalFor(snapshot.journalSlug),
|
|
824
|
-
snapshot.journalPath,
|
|
825
|
-
snapshot.localHash,
|
|
826
|
-
snapshot.localKind,
|
|
827
|
-
snapshot.remoteEtag,
|
|
828
|
-
)
|
|
829
|
-
) {
|
|
830
|
-
dirty.add(snapshot.journalSlug);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
continue;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
const relativePath = path
|
|
837
|
-
.relative(hqRoot, absolutePath)
|
|
838
|
-
.split(path.sep)
|
|
839
|
-
.join("/");
|
|
840
|
-
const coordinates = journalCoordinates(relativePath);
|
|
841
|
-
if (
|
|
842
|
-
coordinates &&
|
|
843
|
-
clearLocalDeleteIntent(
|
|
844
|
-
journalFor(coordinates.slug),
|
|
845
|
-
coordinates.key,
|
|
846
|
-
)
|
|
847
|
-
) {
|
|
848
|
-
dirty.add(coordinates.slug);
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
for (const slug of dirty) {
|
|
853
|
-
writeJournal(slug, journals.get(slug)!);
|
|
854
|
-
}
|
|
855
|
-
};
|
|
856
|
-
|
|
857
|
-
const groupBatchByRoute = (
|
|
858
|
-
batch: TreeChangeBatch,
|
|
859
|
-
): Map<
|
|
860
|
-
string,
|
|
861
|
-
{
|
|
862
|
-
route: WatchRoute;
|
|
863
|
-
paths: Map<string, string>;
|
|
864
|
-
changes: Map<string, TreeChange>;
|
|
865
|
-
relPaths: string[];
|
|
866
|
-
deleteScopeRoots: string[];
|
|
867
|
-
}
|
|
868
|
-
> => {
|
|
869
|
-
const grouped = new Map<
|
|
870
|
-
string,
|
|
871
|
-
{
|
|
872
|
-
route: WatchRoute;
|
|
873
|
-
paths: Map<string, string>;
|
|
874
|
-
changes: Map<string, TreeChange>;
|
|
875
|
-
relPaths: string[];
|
|
876
|
-
deleteScopeRoots: string[];
|
|
877
|
-
}
|
|
878
|
-
>();
|
|
879
|
-
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
880
|
-
const route = routeChangeToTarget(relativePath);
|
|
881
|
-
if (!route) continue;
|
|
882
|
-
const key = routeKey(route);
|
|
883
|
-
const existing =
|
|
884
|
-
grouped.get(key) ??
|
|
885
|
-
{
|
|
886
|
-
route,
|
|
887
|
-
paths: new Map<string, string>(),
|
|
888
|
-
changes: new Map<string, TreeChange>(),
|
|
889
|
-
relPaths: [],
|
|
890
|
-
deleteScopeRoots: [],
|
|
891
|
-
};
|
|
892
|
-
existing.paths.set(absolutePath, relativePath);
|
|
893
|
-
const change = batch.changes?.get(absolutePath);
|
|
894
|
-
if (change) existing.changes.set(absolutePath, change);
|
|
895
|
-
existing.relPaths = [...new Set([...existing.paths.values()])];
|
|
896
|
-
existing.deleteScopeRoots = [
|
|
897
|
-
...new Set(
|
|
898
|
-
[...existing.changes.values()].flatMap((item) =>
|
|
899
|
-
(item.deleteSnapshots ?? []).flatMap((snapshot) =>
|
|
900
|
-
snapshot.deleteScopeRoot === undefined
|
|
901
|
-
? []
|
|
902
|
-
: [snapshot.deleteScopeRoot],
|
|
903
|
-
),
|
|
904
|
-
),
|
|
905
|
-
),
|
|
906
|
-
];
|
|
907
|
-
grouped.set(key, existing);
|
|
908
|
-
}
|
|
909
|
-
return grouped;
|
|
910
|
-
};
|
|
911
|
-
|
|
912
|
-
const publishBatchIfAvailable = (paths: Map<string, string>): boolean => {
|
|
913
|
-
if (!stopped && eventSync && paths.size > 0) {
|
|
914
|
-
eventSync.publishBatch({ paths: new Map(paths) });
|
|
915
|
-
return true;
|
|
916
|
-
}
|
|
917
|
-
return false;
|
|
918
|
-
};
|
|
919
|
-
|
|
920
|
-
const runScopedDrain = async (
|
|
921
|
-
batch: TreeChangeBatch | null,
|
|
922
|
-
watcherBatchGeneration: number | null,
|
|
923
|
-
): Promise<number> => {
|
|
924
|
-
if (drainRunsPush && batch && batch.paths.size > 0) {
|
|
925
|
-
const skipCompanies = resolveSkipCompanies();
|
|
926
|
-
const grouped = groupBatchByRoute(batch);
|
|
927
|
-
for (const group of grouped.values()) {
|
|
928
|
-
// Paused workspaces: drop the scoped push without uploading or
|
|
929
|
-
// publishing event-sync metadata (path/hash must not leak while Off).
|
|
930
|
-
if (
|
|
931
|
-
group.route.kind === "company" &&
|
|
932
|
-
skipCompanies.has(group.route.slug.toLowerCase())
|
|
933
|
-
) {
|
|
934
|
-
continue;
|
|
935
|
-
}
|
|
936
|
-
const scopedArgv = buildScopedPushArgv(
|
|
937
|
-
group.route,
|
|
938
|
-
group.relPaths,
|
|
939
|
-
passArgv,
|
|
940
|
-
group.deleteScopeRoots,
|
|
941
|
-
);
|
|
942
|
-
try {
|
|
943
|
-
const result = await runGuarded(
|
|
944
|
-
scopedArgv,
|
|
945
|
-
group.changes.size > 0
|
|
946
|
-
? () => prepareWatcherChanges(group.changes)
|
|
947
|
-
: undefined,
|
|
948
|
-
);
|
|
949
|
-
if (passExitCode(result) === 0) {
|
|
950
|
-
const refusedPaths = new Set(
|
|
951
|
-
typeof result === "number"
|
|
952
|
-
? []
|
|
953
|
-
: (result.result?.pushPathResults ?? [])
|
|
954
|
-
.filter((pathResult) => pathResult.status === "refused")
|
|
955
|
-
.map((pathResult) => pathResult.relativePath),
|
|
956
|
-
);
|
|
957
|
-
const route = routeKey(group.route);
|
|
958
|
-
const generation = watcherBatchGeneration ?? 0;
|
|
959
|
-
if (
|
|
960
|
-
lastPublishedWatcherBatchGenerationByRoute.get(route) !== generation
|
|
961
|
-
) {
|
|
962
|
-
const published = publishBatchIfAvailable(
|
|
963
|
-
new Map(
|
|
964
|
-
[...group.paths.entries()].filter(
|
|
965
|
-
([absolutePath, relativePath]) => {
|
|
966
|
-
if (refusedPaths.has(relativePath)) return false;
|
|
967
|
-
if (group.changes.get(absolutePath)?.kind !== "unlinkDir") {
|
|
968
|
-
return true;
|
|
969
|
-
}
|
|
970
|
-
return ![...refusedPaths].some((refusedPath) =>
|
|
971
|
-
refusedPath.startsWith(`${relativePath}/`),
|
|
972
|
-
);
|
|
973
|
-
},
|
|
974
|
-
),
|
|
975
|
-
),
|
|
976
|
-
);
|
|
977
|
-
if (published) {
|
|
978
|
-
lastPublishedWatcherBatchGenerationByRoute.set(route, generation);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
} else {
|
|
982
|
-
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
983
|
-
}
|
|
984
|
-
} catch (err) {
|
|
985
|
-
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
986
|
-
process.stderr.write(
|
|
987
|
-
`watch scoped push failed, will retry next tick: ${describeError(err)}\n`,
|
|
988
|
-
);
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
if (!drainRunsPull) return 0;
|
|
994
|
-
return passExitCode(
|
|
995
|
-
await runGuarded(buildScopedDrainPullArgv(passArgv)),
|
|
996
|
-
);
|
|
997
|
-
};
|
|
998
|
-
|
|
999
|
-
let receiver: PushReceiver | null = null;
|
|
1000
|
-
let eventSync: EventSyncHandles | null = null;
|
|
1001
|
-
|
|
1002
|
-
if (eventPush) {
|
|
1003
|
-
const clock = deps.clock ?? systemClock;
|
|
1004
|
-
const debounceMs = 2000;
|
|
1005
|
-
// The watcher owns the structured degradation event; give it the normal
|
|
1006
|
-
// runner telemetry client so a budget/ENOSPC fallback is observable even
|
|
1007
|
-
// when event-sync itself is not enrolled.
|
|
1008
|
-
const getClaims =
|
|
1009
|
-
deps.getIdTokenClaims ?? runtime.defaultGetIdTokenClaims;
|
|
1010
|
-
const claims = getClaims();
|
|
1011
|
-
const getAccessToken =
|
|
1012
|
-
deps.getAccessToken ?? runtime.defaultGetAccessToken;
|
|
1013
|
-
const watcherTelemetryClient = new VaultClient({
|
|
1014
|
-
apiUrl: runtime.apiUrl,
|
|
1015
|
-
authToken: getAccessToken,
|
|
1016
|
-
region: runtime.region,
|
|
1017
|
-
});
|
|
1018
|
-
const createWatcher =
|
|
1019
|
-
deps.createWatcher ??
|
|
1020
|
-
((opts) =>
|
|
1021
|
-
new TreeWatcher({
|
|
1022
|
-
hqRoot: opts.hqRoot,
|
|
1023
|
-
debounceMs: opts.debounceMs,
|
|
1024
|
-
clock: opts.clock,
|
|
1025
|
-
personalMode: !companiesMode,
|
|
1026
|
-
captureLocalDeleteSnapshots: opts.captureLocalDeleteSnapshots,
|
|
1027
|
-
telemetryClient: watcherTelemetryClient,
|
|
1028
|
-
telemetryClaims: claims,
|
|
1029
|
-
}));
|
|
1030
|
-
watcher = createWatcher({
|
|
1031
|
-
hqRoot,
|
|
1032
|
-
debounceMs,
|
|
1033
|
-
clock,
|
|
1034
|
-
captureLocalDeleteSnapshots,
|
|
1035
|
-
});
|
|
1036
|
-
|
|
1037
|
-
watcher.onChange((changedRelPath, batch) => {
|
|
1038
|
-
if (stopped) return;
|
|
1039
|
-
if (addPendingWatcherChange(changedRelPath, batch)) interruptWait();
|
|
1040
|
-
});
|
|
1041
|
-
watcher.start();
|
|
1042
|
-
|
|
1043
|
-
const receiverSyncFn: SyncEngineFn = async (ctx) => {
|
|
1044
|
-
if (stopped) return;
|
|
1045
|
-
// A received wake/high-water signal means there is bounded work now.
|
|
1046
|
-
// It wakes the scheduler but is still funneled through runGuarded below,
|
|
1047
|
-
// so it cannot overlap the pass that may currently own the operation
|
|
1048
|
-
// lock.
|
|
1049
|
-
interruptWait();
|
|
1050
|
-
const route = routeChangeToTarget(ctx.event.relativePath);
|
|
1051
|
-
if (!route) return;
|
|
1052
|
-
const targetedArgv = buildTargetedPullArgv(route, passArgv);
|
|
1053
|
-
const result = await runGuarded(targetedArgv);
|
|
1054
|
-
if (passExitCode(result) !== 0) {
|
|
1055
|
-
throw new Error(
|
|
1056
|
-
`targeted pull failed with exit code ${passExitCode(result)}`,
|
|
1057
|
-
);
|
|
1058
|
-
}
|
|
1059
|
-
};
|
|
1060
|
-
const createReceiver = deps.createReceiver ?? (() => new NoopPushReceiver());
|
|
1061
|
-
receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
|
|
1062
|
-
void Promise.resolve(receiver.start()).catch(() => undefined);
|
|
1063
|
-
|
|
1064
|
-
if (resolveEventSync(process.env.HQ_SYNC_EVENT_SYNC)) {
|
|
1065
|
-
const startES = deps.startEventSync ?? defaultStartEventSync;
|
|
1066
|
-
void (async () => {
|
|
1067
|
-
const eventSyncClient = new VaultClient({
|
|
1068
|
-
apiUrl: runtime.apiUrl,
|
|
1069
|
-
authToken: getAccessToken,
|
|
1070
|
-
region: runtime.region,
|
|
1071
|
-
});
|
|
1072
|
-
const handles = await startES({
|
|
1073
|
-
hqRoot,
|
|
1074
|
-
apiUrl: runtime.apiUrl,
|
|
1075
|
-
authToken: getAccessToken,
|
|
1076
|
-
deviceId: getOrCreateMachineId(hqRoot),
|
|
1077
|
-
resolveTenantId: async () => {
|
|
1078
|
-
const persons = await eventSyncClient.entity.listByType("person");
|
|
1079
|
-
const pick = pickCanonicalPersonEntity(persons);
|
|
1080
|
-
if (!pick?.uid) {
|
|
1081
|
-
throw new Error("no canonical person entity for this account");
|
|
1082
|
-
}
|
|
1083
|
-
return pick.uid;
|
|
1084
|
-
},
|
|
1085
|
-
syncFn: receiverSyncFn,
|
|
1086
|
-
log: (m) => process.stderr.write(`${m}\n`),
|
|
1087
|
-
telemetryClient: eventSyncClient,
|
|
1088
|
-
telemetryClaims: claims,
|
|
1089
|
-
});
|
|
1090
|
-
if (!handles) return;
|
|
1091
|
-
if (stopped) {
|
|
1092
|
-
void handles.dispose();
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
eventSync = handles;
|
|
1096
|
-
})().catch((err) => {
|
|
1097
|
-
process.stderr.write(
|
|
1098
|
-
`event-sync: wiring failed, continuing poll-only: ${describeError(err)}\n`,
|
|
1099
|
-
);
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
const shutdown = (): void => {
|
|
1105
|
-
if (stopped) return;
|
|
1106
|
-
stopped = true;
|
|
1107
|
-
resolveStoppedQueue();
|
|
1108
|
-
try {
|
|
1109
|
-
void receiver?.dispose();
|
|
1110
|
-
} catch {
|
|
1111
|
-
/* ignore */
|
|
1112
|
-
}
|
|
1113
|
-
try {
|
|
1114
|
-
void eventSync?.dispose();
|
|
1115
|
-
} catch {
|
|
1116
|
-
/* ignore */
|
|
1117
|
-
}
|
|
1118
|
-
eventSync = null;
|
|
1119
|
-
try {
|
|
1120
|
-
watcher?.dispose();
|
|
1121
|
-
} catch {
|
|
1122
|
-
/* ignore */
|
|
1123
|
-
}
|
|
1124
|
-
resolveStopped?.();
|
|
1125
|
-
};
|
|
1126
|
-
const onShutdownSignal =
|
|
1127
|
-
deps.onShutdownSignal ??
|
|
1128
|
-
((handler: () => void) => {
|
|
1129
|
-
const wrapped = () => handler();
|
|
1130
|
-
process.on("SIGTERM", wrapped);
|
|
1131
|
-
process.on("SIGINT", wrapped);
|
|
1132
|
-
return () => {
|
|
1133
|
-
process.off("SIGTERM", wrapped);
|
|
1134
|
-
process.off("SIGINT", wrapped);
|
|
1135
|
-
};
|
|
1136
|
-
});
|
|
1137
|
-
detachSignal = onShutdownSignal(shutdown);
|
|
1138
|
-
|
|
1139
|
-
// The initial reconcile is due immediately. Later passes advance from this
|
|
1140
|
-
// deadline, not from pass completion, so cadence stays monotonic.
|
|
1141
|
-
let nextDueAt = monotonicNow();
|
|
1142
|
-
let pollTick = 0;
|
|
1143
|
-
try {
|
|
1144
|
-
while (!stopped) {
|
|
1145
|
-
pollTick++;
|
|
1146
|
-
const pending = takePendingWatcherChange();
|
|
1147
|
-
// The quarantine is scoped to the delete episode it refused, and only
|
|
1148
|
-
// the roots already refused when this batch was taken belong to it.
|
|
1149
|
-
// Snapshot them here rather than clearing the whole set after the pass:
|
|
1150
|
-
// the pass is awaited, so a bulk removal observed while it runs adds its
|
|
1151
|
-
// root afterwards and its snapshots ride the NEXT batch. Clearing
|
|
1152
|
-
// unconditionally would drop that root before `prepareWatcherChanges`
|
|
1153
|
-
// ever sees the batch, re-stamping the revoked descendant intents and
|
|
1154
|
-
// handing the engine exactly the bulk delete the quarantine refused.
|
|
1155
|
-
const drainedQuarantineRoots = new Set(quarantinedDeleteRoots);
|
|
1156
|
-
const shouldRunFull =
|
|
1157
|
-
pollTick === 1 ||
|
|
1158
|
-
pending.bare ||
|
|
1159
|
-
pending.batch?.overflowed === true ||
|
|
1160
|
-
pollTick % FULL_RECONCILE_EVERY_TICKS === 0;
|
|
1161
|
-
const result = shouldRunFull
|
|
1162
|
-
? await runGuarded(passArgv)
|
|
1163
|
-
: await runScopedDrain(pending.batch, pending.generation);
|
|
1164
|
-
// This batch's episode has now been prepared and pushed, so a later
|
|
1165
|
-
// removal under the same path is judged fresh — a root that never
|
|
1166
|
-
// expired would silently refuse ordinary deletes from here on.
|
|
1167
|
-
for (const root of drainedQuarantineRoots) {
|
|
1168
|
-
quarantinedDeleteRoots.delete(root);
|
|
1169
|
-
}
|
|
1170
|
-
const exitCode = passExitCode(result);
|
|
1171
|
-
if (exitCode === runtime.authRequiredPassExit) {
|
|
1172
|
-
// The pass already emitted auth-error. Stop the unattended loop but
|
|
1173
|
-
// keep the process exit successful so desktop/manual consumers treat
|
|
1174
|
-
// this as a reauth state, not a crash.
|
|
1175
|
-
return 0;
|
|
1176
|
-
} else if (exitCode === TRANSIENT_NETWORK_EXIT) {
|
|
1177
|
-
// The machine was briefly offline (the discovery fetch failed after
|
|
1178
|
-
// retries). That is NOT a crash — stay alive and retry on the next
|
|
1179
|
-
// poll. Returning it here would exit the watcher and the menubar would
|
|
1180
|
-
// report "watcher exited unexpectedly" for every network blip
|
|
1181
|
-
// (HQ-SYNC-1W). Log to stderr so it still shows up as a breadcrumb.
|
|
1182
|
-
process.stderr.write(
|
|
1183
|
-
"hq-sync-runner: watch pass skipped — transient network failure; retrying next poll\n",
|
|
1184
|
-
);
|
|
1185
|
-
} else if (exitCode === PARTIAL_SYNC_EXIT) {
|
|
1186
|
-
// The pass completed but some per-file transfers threw. Also NOT a
|
|
1187
|
-
// crash: the per-file errors were already emitted as ndjson `error`
|
|
1188
|
-
// events (menubar breadcrumbs + journal), and exiting here just gets
|
|
1189
|
-
// the watcher hot-respawned and Sentry-captured on every failing pass
|
|
1190
|
-
// (the July 2026 code=Some(2) alert flood — see exit-codes.ts). Stay
|
|
1191
|
-
// alive; everything else keeps syncing and the failing paths re-attempt
|
|
1192
|
-
// on the next tick. A one-shot `hq sync` still exits 2.
|
|
1193
|
-
const failures = companyPassFailures(result);
|
|
1194
|
-
const failedCompanies = Array.from(
|
|
1195
|
-
new Set(failures.map((failure) => failure.company)),
|
|
1196
|
-
);
|
|
1197
|
-
for (const failure of failures) {
|
|
1198
|
-
process.stderr.write(
|
|
1199
|
-
`hq-sync-runner: COMPANY PASS FAILED company=${failure.company} message=${failure.message} (downloads for this company did not run)\n`,
|
|
1200
|
-
);
|
|
1201
|
-
}
|
|
1202
|
-
process.stderr.write(
|
|
1203
|
-
"hq-sync-runner: watch pass completed with per-file errors" +
|
|
1204
|
-
(failedCompanies.length > 0
|
|
1205
|
-
? `; failed-companies=${failedCompanies.join(",")}`
|
|
1206
|
-
: "") +
|
|
1207
|
-
"; staying alive, retrying next poll\n",
|
|
1208
|
-
);
|
|
1209
|
-
} else if (exitCode !== 0) {
|
|
1210
|
-
return exitCode;
|
|
1211
|
-
}
|
|
1212
|
-
if (Date.now() >= nextBackupPruneAt) {
|
|
1213
|
-
// Advance before invoking: a failed/unavailable backstop cannot become
|
|
1214
|
-
// an every-tick retry loop. It is diagnostic only and never restarts
|
|
1215
|
-
// the sync daemon.
|
|
1216
|
-
nextBackupPruneAt = Date.now() + 24 * 60 * 60 * 1000;
|
|
1217
|
-
try { await runBackupPrune(hqRoot); }
|
|
1218
|
-
catch (error) { process.stderr.write(`hq-sync-runner: backup retention dry-run failed — ${error instanceof Error ? error.message : String(error)}\n`); }
|
|
1219
|
-
}
|
|
1220
|
-
const nextPollMs =
|
|
1221
|
-
explicitPollMs ?? adaptivePollMs(sampleLoadAvg(), cpuCount);
|
|
1222
|
-
const schedule = advanceMonotonicDeadline(
|
|
1223
|
-
nextDueAt,
|
|
1224
|
-
monotonicNow(),
|
|
1225
|
-
nextPollMs,
|
|
1226
|
-
);
|
|
1227
|
-
nextDueAt = schedule.nextDueAt;
|
|
1228
|
-
await waitForNextPass(schedule.delayMs);
|
|
1229
|
-
}
|
|
1230
|
-
return 0;
|
|
1231
|
-
} finally {
|
|
1232
|
-
shutdown();
|
|
1233
|
-
detachSignal?.();
|
|
1234
|
-
}
|
|
1235
|
-
}
|