@indigoai-us/hq-cloud 6.18.44-alpha.9 → 6.18.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner-planning.d.ts +12 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +18 -0
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +18 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +75 -6
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +22 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +33 -4
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +2 -1
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +6 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +10 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-windows-test-harness.d.ts +19 -0
- package/dist/cli/rescue-windows-test-harness.d.ts.map +1 -0
- package/dist/cli/rescue-windows-test-harness.js +443 -0
- package/dist/cli/rescue-windows-test-harness.js.map +1 -0
- package/dist/company-resolver.d.ts +22 -0
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +1 -1
- package/dist/company-resolver.js.map +1 -1
- package/dist/ignore.d.ts +4 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +10 -12
- package/dist/ignore.js.map +1 -1
- package/dist/journal-snapshot-decoder.js +15 -2
- package/dist/journal-snapshot-decoder.js.map +1 -1
- package/dist/sync/durable-apply.d.ts +2 -0
- package/dist/sync/durable-apply.d.ts.map +1 -1
- package/dist/sync/durable-apply.js +9 -12
- package/dist/sync/durable-apply.js.map +1 -1
- package/dist/sync/fsync-compat.d.ts +13 -0
- package/dist/sync/fsync-compat.d.ts.map +1 -0
- package/dist/sync/fsync-compat.js +33 -0
- package/dist/sync/fsync-compat.js.map +1 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -1
- package/dist/sync/multipart-uploader.js +2 -1
- package/dist/sync/multipart-uploader.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts.map +1 -1
- package/dist/sync/scope-inventory-client.js +7 -2
- package/dist/sync/scope-inventory-client.js.map +1 -1
- package/dist/telemetry.d.ts +31 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +177 -17
- package/dist/telemetry.js.map +1 -1
- package/dist/usage-company-fallback.d.ts +104 -0
- package/dist/usage-company-fallback.d.ts.map +1 -0
- package/dist/usage-company-fallback.js +599 -0
- package/dist/usage-company-fallback.js.map +1 -0
- package/dist/watch-owner-lease.d.ts +3 -0
- package/dist/watch-owner-lease.d.ts.map +1 -1
- package/dist/watch-owner-lease.js +14 -3
- package/dist/watch-owner-lease.js.map +1 -1
- package/package.json +2 -2
- package/dist/active-company.test.d.ts +0 -2
- package/dist/active-company.test.d.ts.map +0 -1
- package/dist/active-company.test.js +0 -149
- package/dist/active-company.test.js.map +0 -1
- package/dist/agent-codex-instructions.test.d.ts +0 -2
- package/dist/agent-codex-instructions.test.d.ts.map +0 -1
- package/dist/agent-codex-instructions.test.js +0 -271
- package/dist/agent-codex-instructions.test.js.map +0 -1
- package/dist/backup-prune.test.d.ts +0 -2
- package/dist/backup-prune.test.d.ts.map +0 -1
- package/dist/backup-prune.test.js +0 -154
- package/dist/backup-prune.test.js.map +0 -1
- package/dist/bandwidth.test.d.ts +0 -2
- package/dist/bandwidth.test.d.ts.map +0 -1
- package/dist/bandwidth.test.js +0 -342
- package/dist/bandwidth.test.js.map +0 -1
- package/dist/best-effort-error.test.d.ts +0 -2
- package/dist/best-effort-error.test.d.ts.map +0 -1
- package/dist/best-effort-error.test.js +0 -54
- package/dist/best-effort-error.test.js.map +0 -1
- package/dist/bin/area-ledger-migration.test.d.ts +0 -2
- package/dist/bin/area-ledger-migration.test.d.ts.map +0 -1
- package/dist/bin/area-ledger-migration.test.js +0 -410
- package/dist/bin/area-ledger-migration.test.js.map +0 -1
- package/dist/bin/backup-prune-runner.test.d.ts +0 -2
- package/dist/bin/backup-prune-runner.test.d.ts.map +0 -1
- package/dist/bin/backup-prune-runner.test.js +0 -22
- package/dist/bin/backup-prune-runner.test.js.map +0 -1
- package/dist/bin/journal-repair.test.d.ts +0 -2
- package/dist/bin/journal-repair.test.d.ts.map +0 -1
- package/dist/bin/journal-repair.test.js +0 -53
- package/dist/bin/journal-repair.test.js.map +0 -1
- package/dist/bin/layout-compatibility.test.d.ts +0 -2
- package/dist/bin/layout-compatibility.test.d.ts.map +0 -1
- package/dist/bin/layout-compatibility.test.js +0 -87
- package/dist/bin/layout-compatibility.test.js.map +0 -1
- package/dist/bin/outpost-heartbeat.test.d.ts +0 -7
- package/dist/bin/outpost-heartbeat.test.d.ts.map +0 -1
- package/dist/bin/outpost-heartbeat.test.js +0 -84
- package/dist/bin/outpost-heartbeat.test.js.map +0 -1
- package/dist/bin/outpost-session-host.test.d.ts +0 -7
- package/dist/bin/outpost-session-host.test.d.ts.map +0 -1
- package/dist/bin/outpost-session-host.test.js +0 -52
- package/dist/bin/outpost-session-host.test.js.map +0 -1
- package/dist/bin/sync-mutation.test.d.ts +0 -2
- package/dist/bin/sync-mutation.test.d.ts.map +0 -1
- package/dist/bin/sync-mutation.test.js +0 -64
- package/dist/bin/sync-mutation.test.js.map +0 -1
- package/dist/bin/sync-runner-args.test.d.ts +0 -2
- package/dist/bin/sync-runner-args.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-args.test.js +0 -82
- package/dist/bin/sync-runner-args.test.js.map +0 -1
- package/dist/bin/sync-runner-auth-wait.test.d.ts +0 -2
- package/dist/bin/sync-runner-auth-wait.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-auth-wait.test.js +0 -406
- package/dist/bin/sync-runner-auth-wait.test.js.map +0 -1
- package/dist/bin/sync-runner-backoff.regression.test.d.ts +0 -29
- package/dist/bin/sync-runner-backoff.regression.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-backoff.regression.test.js +0 -224
- package/dist/bin/sync-runner-backoff.regression.test.js.map +0 -1
- package/dist/bin/sync-runner-company.test.d.ts +0 -2
- package/dist/bin/sync-runner-company.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-company.test.js +0 -2439
- package/dist/bin/sync-runner-company.test.js.map +0 -1
- package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.d.ts +0 -2
- package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.js +0 -648
- package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.js.map +0 -1
- package/dist/bin/sync-runner-delete-snapshot.bench.test.d.ts +0 -2
- package/dist/bin/sync-runner-delete-snapshot.bench.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-delete-snapshot.bench.test.js +0 -329
- package/dist/bin/sync-runner-delete-snapshot.bench.test.js.map +0 -1
- package/dist/bin/sync-runner-events.test.d.ts +0 -2
- package/dist/bin/sync-runner-events.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-events.test.js +0 -364
- package/dist/bin/sync-runner-events.test.js.map +0 -1
- package/dist/bin/sync-runner-heap-telemetry.test.d.ts +0 -2
- package/dist/bin/sync-runner-heap-telemetry.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-heap-telemetry.test.js +0 -1966
- package/dist/bin/sync-runner-heap-telemetry.test.js.map +0 -1
- package/dist/bin/sync-runner-manifest.test.d.ts +0 -12
- package/dist/bin/sync-runner-manifest.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-manifest.test.js +0 -921
- package/dist/bin/sync-runner-manifest.test.js.map +0 -1
- package/dist/bin/sync-runner-planning.test.d.ts +0 -2
- package/dist/bin/sync-runner-planning.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-planning.test.js +0 -448
- package/dist/bin/sync-runner-planning.test.js.map +0 -1
- package/dist/bin/sync-runner-post-sync-flags.test.d.ts +0 -2
- package/dist/bin/sync-runner-post-sync-flags.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-post-sync-flags.test.js +0 -64
- package/dist/bin/sync-runner-post-sync-flags.test.js.map +0 -1
- package/dist/bin/sync-runner-publish-expansion.test.d.ts +0 -2
- package/dist/bin/sync-runner-publish-expansion.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-publish-expansion.test.js +0 -54
- package/dist/bin/sync-runner-publish-expansion.test.js.map +0 -1
- package/dist/bin/sync-runner-receiver-unresolved-company.test.d.ts +0 -2
- package/dist/bin/sync-runner-receiver-unresolved-company.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-receiver-unresolved-company.test.js +0 -379
- package/dist/bin/sync-runner-receiver-unresolved-company.test.js.map +0 -1
- package/dist/bin/sync-runner-rollup.test.d.ts +0 -2
- package/dist/bin/sync-runner-rollup.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-rollup.test.js +0 -24
- package/dist/bin/sync-runner-rollup.test.js.map +0 -1
- package/dist/bin/sync-runner-watch-loop.test.d.ts +0 -2
- package/dist/bin/sync-runner-watch-loop.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-watch-loop.test.js +0 -734
- package/dist/bin/sync-runner-watch-loop.test.js.map +0 -1
- package/dist/bin/sync-runner-watch-routes.test.d.ts +0 -2
- package/dist/bin/sync-runner-watch-routes.test.d.ts.map +0 -1
- package/dist/bin/sync-runner-watch-routes.test.js +0 -181
- package/dist/bin/sync-runner-watch-routes.test.js.map +0 -1
- package/dist/bin/sync-runner.test.d.ts +0 -10
- package/dist/bin/sync-runner.test.d.ts.map +0 -1
- package/dist/bin/sync-runner.test.js +0 -18984
- package/dist/bin/sync-runner.test.js.map +0 -1
- package/dist/cli/case-alias.test.d.ts +0 -2
- package/dist/cli/case-alias.test.d.ts.map +0 -1
- package/dist/cli/case-alias.test.js +0 -96
- package/dist/cli/case-alias.test.js.map +0 -1
- package/dist/cli/conflict-recovery.test.d.ts +0 -2
- package/dist/cli/conflict-recovery.test.d.ts.map +0 -1
- package/dist/cli/conflict-recovery.test.js +0 -201
- package/dist/cli/conflict-recovery.test.js.map +0 -1
- package/dist/cli/conflict-winner.test.d.ts +0 -7
- package/dist/cli/conflict-winner.test.d.ts.map +0 -1
- package/dist/cli/conflict-winner.test.js +0 -98
- package/dist/cli/conflict-winner.test.js.map +0 -1
- package/dist/cli/doctor.test.d.ts +0 -13
- package/dist/cli/doctor.test.d.ts.map +0 -1
- package/dist/cli/doctor.test.js +0 -607
- package/dist/cli/doctor.test.js.map +0 -1
- package/dist/cli/invite.test.d.ts +0 -5
- package/dist/cli/invite.test.d.ts.map +0 -1
- package/dist/cli/invite.test.js +0 -178
- package/dist/cli/invite.test.js.map +0 -1
- package/dist/cli/onepass-pull-planner-regressions.test.d.ts +0 -2
- package/dist/cli/onepass-pull-planner-regressions.test.d.ts.map +0 -1
- package/dist/cli/onepass-pull-planner-regressions.test.js +0 -300
- package/dist/cli/onepass-pull-planner-regressions.test.js.map +0 -1
- package/dist/cli/onepass-pull-planner.test.d.ts +0 -2
- package/dist/cli/onepass-pull-planner.test.d.ts.map +0 -1
- package/dist/cli/onepass-pull-planner.test.js +0 -266
- package/dist/cli/onepass-pull-planner.test.js.map +0 -1
- package/dist/cli/push-scan-exclusions.test.d.ts +0 -2
- package/dist/cli/push-scan-exclusions.test.d.ts.map +0 -1
- package/dist/cli/push-scan-exclusions.test.js +0 -99
- package/dist/cli/push-scan-exclusions.test.js.map +0 -1
- package/dist/cli/reindex-knowledge.test.d.ts +0 -12
- package/dist/cli/reindex-knowledge.test.d.ts.map +0 -1
- package/dist/cli/reindex-knowledge.test.js +0 -260
- package/dist/cli/reindex-knowledge.test.js.map +0 -1
- package/dist/cli/reindex.test.d.ts +0 -11
- package/dist/cli/reindex.test.d.ts.map +0 -1
- package/dist/cli/reindex.test.js +0 -811
- package/dist/cli/reindex.test.js.map +0 -1
- package/dist/cli/rescue-breaker.test.d.ts +0 -2
- package/dist/cli/rescue-breaker.test.d.ts.map +0 -1
- package/dist/cli/rescue-breaker.test.js +0 -812
- package/dist/cli/rescue-breaker.test.js.map +0 -1
- package/dist/cli/rescue-classify-ordering.test.d.ts +0 -2
- package/dist/cli/rescue-classify-ordering.test.d.ts.map +0 -1
- package/dist/cli/rescue-classify-ordering.test.js +0 -500
- package/dist/cli/rescue-classify-ordering.test.js.map +0 -1
- package/dist/cli/rescue-clone-diagnostics.test.d.ts +0 -2
- package/dist/cli/rescue-clone-diagnostics.test.d.ts.map +0 -1
- package/dist/cli/rescue-clone-diagnostics.test.js +0 -101
- package/dist/cli/rescue-clone-diagnostics.test.js.map +0 -1
- package/dist/cli/rescue-clone-network-retry.test.d.ts +0 -2
- package/dist/cli/rescue-clone-network-retry.test.d.ts.map +0 -1
- package/dist/cli/rescue-clone-network-retry.test.js +0 -415
- package/dist/cli/rescue-clone-network-retry.test.js.map +0 -1
- package/dist/cli/rescue-core-stamp.test.d.ts +0 -2
- package/dist/cli/rescue-core-stamp.test.d.ts.map +0 -1
- package/dist/cli/rescue-core-stamp.test.js +0 -364
- package/dist/cli/rescue-core-stamp.test.js.map +0 -1
- package/dist/cli/rescue-drift-reconcile.test.d.ts +0 -2
- package/dist/cli/rescue-drift-reconcile.test.d.ts.map +0 -1
- package/dist/cli/rescue-drift-reconcile.test.js +0 -162
- package/dist/cli/rescue-drift-reconcile.test.js.map +0 -1
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts +0 -2
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +0 -1
- package/dist/cli/rescue-drop-dir-symlink.test.js +0 -255
- package/dist/cli/rescue-drop-dir-symlink.test.js.map +0 -1
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts +0 -2
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +0 -1
- package/dist/cli/rescue-exec-bit-preserve.test.js +0 -177
- package/dist/cli/rescue-exec-bit-preserve.test.js.map +0 -1
- package/dist/cli/rescue-hq-root-guard.test.d.ts +0 -2
- package/dist/cli/rescue-hq-root-guard.test.d.ts.map +0 -1
- package/dist/cli/rescue-hq-root-guard.test.js +0 -210
- package/dist/cli/rescue-hq-root-guard.test.js.map +0 -1
- package/dist/cli/rescue-journal-reconcile.test.d.ts +0 -2
- package/dist/cli/rescue-journal-reconcile.test.d.ts.map +0 -1
- package/dist/cli/rescue-journal-reconcile.test.js +0 -186
- package/dist/cli/rescue-journal-reconcile.test.js.map +0 -1
- package/dist/cli/rescue-keep-local-ignore.test.d.ts +0 -2
- package/dist/cli/rescue-keep-local-ignore.test.d.ts.map +0 -1
- package/dist/cli/rescue-keep-local-ignore.test.js +0 -315
- package/dist/cli/rescue-keep-local-ignore.test.js.map +0 -1
- package/dist/cli/rescue-move.test.d.ts +0 -2
- package/dist/cli/rescue-move.test.d.ts.map +0 -1
- package/dist/cli/rescue-move.test.js +0 -166
- package/dist/cli/rescue-move.test.js.map +0 -1
- package/dist/cli/rescue-mtime-preserve.test.d.ts +0 -2
- package/dist/cli/rescue-mtime-preserve.test.d.ts.map +0 -1
- package/dist/cli/rescue-mtime-preserve.test.js +0 -186
- package/dist/cli/rescue-mtime-preserve.test.js.map +0 -1
- package/dist/cli/rescue-preserve-copy-symlink.test.d.ts +0 -2
- package/dist/cli/rescue-preserve-copy-symlink.test.d.ts.map +0 -1
- package/dist/cli/rescue-preserve-copy-symlink.test.js +0 -483
- package/dist/cli/rescue-preserve-copy-symlink.test.js.map +0 -1
- package/dist/cli/rescue-realpath.test.d.ts +0 -2
- package/dist/cli/rescue-realpath.test.d.ts.map +0 -1
- package/dist/cli/rescue-realpath.test.js +0 -109
- package/dist/cli/rescue-realpath.test.js.map +0 -1
- package/dist/cli/rescue-restore-flag.test.d.ts +0 -2
- package/dist/cli/rescue-restore-flag.test.d.ts.map +0 -1
- package/dist/cli/rescue-restore-flag.test.js +0 -82
- package/dist/cli/rescue-restore-flag.test.js.map +0 -1
- package/dist/cli/rescue-rsync-preflight.test.d.ts +0 -2
- package/dist/cli/rescue-rsync-preflight.test.d.ts.map +0 -1
- package/dist/cli/rescue-rsync-preflight.test.js +0 -579
- package/dist/cli/rescue-rsync-preflight.test.js.map +0 -1
- package/dist/cli/rescue-rsync-status.test.d.ts +0 -2
- package/dist/cli/rescue-rsync-status.test.d.ts.map +0 -1
- package/dist/cli/rescue-rsync-status.test.js +0 -406
- package/dist/cli/rescue-rsync-status.test.js.map +0 -1
- package/dist/cli/rescue-settings-reconcile.test.d.ts +0 -2
- package/dist/cli/rescue-settings-reconcile.test.d.ts.map +0 -1
- package/dist/cli/rescue-settings-reconcile.test.js +0 -610
- package/dist/cli/rescue-settings-reconcile.test.js.map +0 -1
- package/dist/cli/rescue-snapshot-copy.test.d.ts +0 -2
- package/dist/cli/rescue-snapshot-copy.test.d.ts.map +0 -1
- package/dist/cli/rescue-snapshot-copy.test.js +0 -790
- package/dist/cli/rescue-snapshot-copy.test.js.map +0 -1
- package/dist/cli/rescue-snapshot-recovery.test.d.ts +0 -2
- package/dist/cli/rescue-snapshot-recovery.test.d.ts.map +0 -1
- package/dist/cli/rescue-snapshot-recovery.test.js +0 -265
- package/dist/cli/rescue-snapshot-recovery.test.js.map +0 -1
- package/dist/cli/rescue-snapshot.test.d.ts +0 -2
- package/dist/cli/rescue-snapshot.test.d.ts.map +0 -1
- package/dist/cli/rescue-snapshot.test.js +0 -46
- package/dist/cli/rescue-snapshot.test.js.map +0 -1
- package/dist/cli/rescue-source-switch-floor.test.d.ts +0 -2
- package/dist/cli/rescue-source-switch-floor.test.d.ts.map +0 -1
- package/dist/cli/rescue-source-switch-floor.test.js +0 -247
- package/dist/cli/rescue-source-switch-floor.test.js.map +0 -1
- package/dist/cli/rescue-unchanged-delete.test.d.ts +0 -2
- package/dist/cli/rescue-unchanged-delete.test.d.ts.map +0 -1
- package/dist/cli/rescue-unchanged-delete.test.js +0 -305
- package/dist/cli/rescue-unchanged-delete.test.js.map +0 -1
- package/dist/cli/rescue.reindex.test.d.ts +0 -2
- package/dist/cli/rescue.reindex.test.d.ts.map +0 -1
- package/dist/cli/rescue.reindex.test.js +0 -72
- package/dist/cli/rescue.reindex.test.js.map +0 -1
- package/dist/cli/rescue.test.d.ts +0 -2
- package/dist/cli/rescue.test.d.ts.map +0 -1
- package/dist/cli/rescue.test.js +0 -127
- package/dist/cli/rescue.test.js.map +0 -1
- package/dist/cli/share-plan-limit-presign.test.d.ts +0 -2
- package/dist/cli/share-plan-limit-presign.test.d.ts.map +0 -1
- package/dist/cli/share-plan-limit-presign.test.js +0 -271
- package/dist/cli/share-plan-limit-presign.test.js.map +0 -1
- package/dist/cli/share-presign-transport.test.d.ts +0 -22
- package/dist/cli/share-presign-transport.test.d.ts.map +0 -1
- package/dist/cli/share-presign-transport.test.js +0 -388
- package/dist/cli/share-presign-transport.test.js.map +0 -1
- package/dist/cli/share.test.d.ts +0 -5
- package/dist/cli/share.test.d.ts.map +0 -1
- package/dist/cli/share.test.js +0 -12335
- package/dist/cli/share.test.js.map +0 -1
- package/dist/cli/streaming-pull-planner.bench.test.d.ts +0 -15
- package/dist/cli/streaming-pull-planner.bench.test.d.ts.map +0 -1
- package/dist/cli/streaming-pull-planner.bench.test.js +0 -273
- package/dist/cli/streaming-pull-planner.bench.test.js.map +0 -1
- package/dist/cli/sync-scope.test.d.ts +0 -22
- package/dist/cli/sync-scope.test.d.ts.map +0 -1
- package/dist/cli/sync-scope.test.js +0 -903
- package/dist/cli/sync-scope.test.js.map +0 -1
- package/dist/cli/sync.test.d.ts +0 -5
- package/dist/cli/sync.test.d.ts.map +0 -1
- package/dist/cli/sync.test.js +0 -9028
- package/dist/cli/sync.test.js.map +0 -1
- package/dist/cli/tombstones.test.d.ts +0 -2
- package/dist/cli/tombstones.test.d.ts.map +0 -1
- package/dist/cli/tombstones.test.js +0 -120
- package/dist/cli/tombstones.test.js.map +0 -1
- package/dist/cli/watch-event-push-conflict.test.d.ts +0 -25
- package/dist/cli/watch-event-push-conflict.test.d.ts.map +0 -1
- package/dist/cli/watch-event-push-conflict.test.js +0 -230
- package/dist/cli/watch-event-push-conflict.test.js.map +0 -1
- package/dist/client-info.test.d.ts +0 -11
- package/dist/client-info.test.d.ts.map +0 -1
- package/dist/client-info.test.js +0 -168
- package/dist/client-info.test.js.map +0 -1
- package/dist/cognito-auth-lock-no-hard-links.test.d.ts +0 -11
- package/dist/cognito-auth-lock-no-hard-links.test.d.ts.map +0 -1
- package/dist/cognito-auth-lock-no-hard-links.test.js +0 -76
- package/dist/cognito-auth-lock-no-hard-links.test.js.map +0 -1
- package/dist/cognito-auth.test.d.ts +0 -9
- package/dist/cognito-auth.test.d.ts.map +0 -1
- package/dist/cognito-auth.test.js +0 -745
- package/dist/cognito-auth.test.js.map +0 -1
- package/dist/company-resolver.test.d.ts +0 -7
- package/dist/company-resolver.test.d.ts.map +0 -1
- package/dist/company-resolver.test.js +0 -533
- package/dist/company-resolver.test.js.map +0 -1
- package/dist/company-slug.test.d.ts +0 -7
- package/dist/company-slug.test.d.ts.map +0 -1
- package/dist/company-slug.test.js +0 -43
- package/dist/company-slug.test.js.map +0 -1
- package/dist/company-vault-transport.test.d.ts +0 -12
- package/dist/company-vault-transport.test.d.ts.map +0 -1
- package/dist/company-vault-transport.test.js +0 -97
- package/dist/company-vault-transport.test.js.map +0 -1
- package/dist/context.test.d.ts +0 -7
- package/dist/context.test.d.ts.map +0 -1
- package/dist/context.test.js +0 -646
- package/dist/context.test.js.map +0 -1
- package/dist/entity-resolver.test.d.ts +0 -10
- package/dist/entity-resolver.test.d.ts.map +0 -1
- package/dist/entity-resolver.test.js +0 -244
- package/dist/entity-resolver.test.js.map +0 -1
- package/dist/getindigo-dead-refs-contract.test.d.ts +0 -2
- package/dist/getindigo-dead-refs-contract.test.d.ts.map +0 -1
- package/dist/getindigo-dead-refs-contract.test.js +0 -58
- package/dist/getindigo-dead-refs-contract.test.js.map +0 -1
- package/dist/hermetic-test-env.test.d.ts +0 -2
- package/dist/hermetic-test-env.test.d.ts.map +0 -1
- package/dist/hermetic-test-env.test.js +0 -61
- package/dist/hermetic-test-env.test.js.map +0 -1
- package/dist/hq-root-recovery.test.d.ts +0 -2
- package/dist/hq-root-recovery.test.d.ts.map +0 -1
- package/dist/hq-root-recovery.test.js +0 -65
- package/dist/hq-root-recovery.test.js.map +0 -1
- package/dist/ignore.test.d.ts +0 -10
- package/dist/ignore.test.d.ts.map +0 -1
- package/dist/ignore.test.js +0 -926
- package/dist/ignore.test.js.map +0 -1
- package/dist/index.test.d.ts +0 -2
- package/dist/index.test.d.ts.map +0 -1
- package/dist/index.test.js +0 -26
- package/dist/index.test.js.map +0 -1
- package/dist/journal-area-ledger-read-boundary.test.d.ts +0 -2
- package/dist/journal-area-ledger-read-boundary.test.d.ts.map +0 -1
- package/dist/journal-area-ledger-read-boundary.test.js +0 -420
- package/dist/journal-area-ledger-read-boundary.test.js.map +0 -1
- package/dist/journal-baseline.test.d.ts +0 -2
- package/dist/journal-baseline.test.d.ts.map +0 -1
- package/dist/journal-baseline.test.js +0 -218
- package/dist/journal-baseline.test.js.map +0 -1
- package/dist/journal-decode-window.test.d.ts +0 -2
- package/dist/journal-decode-window.test.d.ts.map +0 -1
- package/dist/journal-decode-window.test.js +0 -99
- package/dist/journal-decode-window.test.js.map +0 -1
- package/dist/journal-delta-writes.test.d.ts +0 -2
- package/dist/journal-delta-writes.test.d.ts.map +0 -1
- package/dist/journal-delta-writes.test.js +0 -159
- package/dist/journal-delta-writes.test.js.map +0 -1
- package/dist/journal-ledger-index.test.d.ts +0 -15
- package/dist/journal-ledger-index.test.d.ts.map +0 -1
- package/dist/journal-ledger-index.test.js +0 -269
- package/dist/journal-ledger-index.test.js.map +0 -1
- package/dist/journal-public-boundary.test.d.ts +0 -2
- package/dist/journal-public-boundary.test.d.ts.map +0 -1
- package/dist/journal-public-boundary.test.js +0 -216
- package/dist/journal-public-boundary.test.js.map +0 -1
- package/dist/journal-row-store.test.d.ts +0 -2
- package/dist/journal-row-store.test.d.ts.map +0 -1
- package/dist/journal-row-store.test.js +0 -729
- package/dist/journal-row-store.test.js.map +0 -1
- package/dist/journal-snapshot-memory-helpers.d.ts +0 -33
- package/dist/journal-snapshot-memory-helpers.d.ts.map +0 -1
- package/dist/journal-snapshot-memory-helpers.js +0 -212
- package/dist/journal-snapshot-memory-helpers.js.map +0 -1
- package/dist/journal-snapshot-memory.test.d.ts +0 -2
- package/dist/journal-snapshot-memory.test.d.ts.map +0 -1
- package/dist/journal-snapshot-memory.test.js +0 -47
- package/dist/journal-snapshot-memory.test.js.map +0 -1
- package/dist/journal-snapshot-v4-memory.test.d.ts +0 -2
- package/dist/journal-snapshot-v4-memory.test.d.ts.map +0 -1
- package/dist/journal-snapshot-v4-memory.test.js +0 -24
- package/dist/journal-snapshot-v4-memory.test.js.map +0 -1
- package/dist/journal-snapshot-v4-writer-memory.test.d.ts +0 -2
- package/dist/journal-snapshot-v4-writer-memory.test.d.ts.map +0 -1
- package/dist/journal-snapshot-v4-writer-memory.test.js +0 -76
- package/dist/journal-snapshot-v4-writer-memory.test.js.map +0 -1
- package/dist/journal-snapshot-v4-writer.test.d.ts +0 -2
- package/dist/journal-snapshot-v4-writer.test.d.ts.map +0 -1
- package/dist/journal-snapshot-v4-writer.test.js +0 -282
- package/dist/journal-snapshot-v4-writer.test.js.map +0 -1
- package/dist/journal-snapshot-v4.test.d.ts +0 -2
- package/dist/journal-snapshot-v4.test.d.ts.map +0 -1
- package/dist/journal-snapshot-v4.test.js +0 -281
- package/dist/journal-snapshot-v4.test.js.map +0 -1
- package/dist/journal-store.test.d.ts +0 -2
- package/dist/journal-store.test.d.ts.map +0 -1
- package/dist/journal-store.test.js +0 -1227
- package/dist/journal-store.test.js.map +0 -1
- package/dist/journal-streaming.heap.test.d.ts +0 -2
- package/dist/journal-streaming.heap.test.d.ts.map +0 -1
- package/dist/journal-streaming.heap.test.js +0 -190
- package/dist/journal-streaming.heap.test.js.map +0 -1
- package/dist/journal.test.d.ts +0 -9
- package/dist/journal.test.d.ts.map +0 -1
- package/dist/journal.test.js +0 -1074
- package/dist/journal.test.js.map +0 -1
- package/dist/kms10-etag-safe-pull.test.d.ts +0 -2
- package/dist/kms10-etag-safe-pull.test.d.ts.map +0 -1
- package/dist/kms10-etag-safe-pull.test.js +0 -53
- package/dist/kms10-etag-safe-pull.test.js.map +0 -1
- package/dist/lib/cloud-authoritative.test.d.ts +0 -2
- package/dist/lib/cloud-authoritative.test.d.ts.map +0 -1
- package/dist/lib/cloud-authoritative.test.js +0 -42
- package/dist/lib/cloud-authoritative.test.js.map +0 -1
- package/dist/lib/conflict-reconcile.test.d.ts +0 -7
- package/dist/lib/conflict-reconcile.test.d.ts.map +0 -1
- package/dist/lib/conflict-reconcile.test.js +0 -246
- package/dist/lib/conflict-reconcile.test.js.map +0 -1
- package/dist/lib/conflict.test.d.ts +0 -9
- package/dist/lib/conflict.test.d.ts.map +0 -1
- package/dist/lib/conflict.test.js +0 -340
- package/dist/lib/conflict.test.js.map +0 -1
- package/dist/lib/describe-error.test.d.ts +0 -2
- package/dist/lib/describe-error.test.d.ts.map +0 -1
- package/dist/lib/describe-error.test.js +0 -148
- package/dist/lib/describe-error.test.js.map +0 -1
- package/dist/lib/machine-id.test.d.ts +0 -8
- package/dist/lib/machine-id.test.d.ts.map +0 -1
- package/dist/lib/machine-id.test.js +0 -228
- package/dist/lib/machine-id.test.js.map +0 -1
- package/dist/lib/net-errors.test.d.ts +0 -2
- package/dist/lib/net-errors.test.d.ts.map +0 -1
- package/dist/lib/net-errors.test.js +0 -111
- package/dist/lib/net-errors.test.js.map +0 -1
- package/dist/lib/ontology-entity-meta.test.d.ts +0 -2
- package/dist/lib/ontology-entity-meta.test.d.ts.map +0 -1
- package/dist/lib/ontology-entity-meta.test.js +0 -79
- package/dist/lib/ontology-entity-meta.test.js.map +0 -1
- package/dist/lib/ontology-entity-push.test.d.ts +0 -2
- package/dist/lib/ontology-entity-push.test.d.ts.map +0 -1
- package/dist/lib/ontology-entity-push.test.js +0 -156
- package/dist/lib/ontology-entity-push.test.js.map +0 -1
- package/dist/lib/personal-connector-settings-sync.test.d.ts +0 -2
- package/dist/lib/personal-connector-settings-sync.test.d.ts.map +0 -1
- package/dist/lib/personal-connector-settings-sync.test.js +0 -34
- package/dist/lib/personal-connector-settings-sync.test.js.map +0 -1
- package/dist/lib/readlink-safe.test.d.ts +0 -2
- package/dist/lib/readlink-safe.test.d.ts.map +0 -1
- package/dist/lib/readlink-safe.test.js +0 -34
- package/dist/lib/readlink-safe.test.js.map +0 -1
- package/dist/lib/s3-content-checksum.test.d.ts +0 -2
- package/dist/lib/s3-content-checksum.test.d.ts.map +0 -1
- package/dist/lib/s3-content-checksum.test.js +0 -26
- package/dist/lib/s3-content-checksum.test.js.map +0 -1
- package/dist/lib/skill-register-landed-bytes.test.d.ts +0 -2
- package/dist/lib/skill-register-landed-bytes.test.d.ts.map +0 -1
- package/dist/lib/skill-register-landed-bytes.test.js +0 -83
- package/dist/lib/skill-register-landed-bytes.test.js.map +0 -1
- package/dist/local-path-codec.test.d.ts +0 -14
- package/dist/local-path-codec.test.d.ts.map +0 -1
- package/dist/local-path-codec.test.js +0 -122
- package/dist/local-path-codec.test.js.map +0 -1
- package/dist/machine-auth.test.d.ts +0 -14
- package/dist/machine-auth.test.d.ts.map +0 -1
- package/dist/machine-auth.test.js +0 -1492
- package/dist/machine-auth.test.js.map +0 -1
- package/dist/machine-token-cache-isolation.test.d.ts +0 -24
- package/dist/machine-token-cache-isolation.test.d.ts.map +0 -1
- package/dist/machine-token-cache-isolation.test.js +0 -284
- package/dist/machine-token-cache-isolation.test.js.map +0 -1
- package/dist/manifest/US-002.acceptance.test.d.ts +0 -11
- package/dist/manifest/US-002.acceptance.test.d.ts.map +0 -1
- package/dist/manifest/US-002.acceptance.test.js +0 -160
- package/dist/manifest/US-002.acceptance.test.js.map +0 -1
- package/dist/manifest/US-004.acceptance.test.d.ts +0 -12
- package/dist/manifest/US-004.acceptance.test.d.ts.map +0 -1
- package/dist/manifest/US-004.acceptance.test.js +0 -173
- package/dist/manifest/US-004.acceptance.test.js.map +0 -1
- package/dist/manifest/build-manifest-resident-parity.test.d.ts +0 -2
- package/dist/manifest/build-manifest-resident-parity.test.d.ts.map +0 -1
- package/dist/manifest/build-manifest-resident-parity.test.js +0 -69
- package/dist/manifest/build-manifest-resident-parity.test.js.map +0 -1
- package/dist/manifest/build-manifest.bench.test.d.ts +0 -22
- package/dist/manifest/build-manifest.bench.test.d.ts.map +0 -1
- package/dist/manifest/build-manifest.bench.test.js +0 -930
- package/dist/manifest/build-manifest.bench.test.js.map +0 -1
- package/dist/manifest/build-manifest.test.d.ts +0 -10
- package/dist/manifest/build-manifest.test.d.ts.map +0 -1
- package/dist/manifest/build-manifest.test.js +0 -1217
- package/dist/manifest/build-manifest.test.js.map +0 -1
- package/dist/manifest/contract.test.d.ts +0 -17
- package/dist/manifest/contract.test.d.ts.map +0 -1
- package/dist/manifest/contract.test.js +0 -346
- package/dist/manifest/contract.test.js.map +0 -1
- package/dist/manifest/import-graph.test.d.ts +0 -25
- package/dist/manifest/import-graph.test.d.ts.map +0 -1
- package/dist/manifest/import-graph.test.js +0 -120
- package/dist/manifest/import-graph.test.js.map +0 -1
- package/dist/manifest/ledger-shard-isolation.test.d.ts +0 -18
- package/dist/manifest/ledger-shard-isolation.test.d.ts.map +0 -1
- package/dist/manifest/ledger-shard-isolation.test.js +0 -153
- package/dist/manifest/ledger-shard-isolation.test.js.map +0 -1
- package/dist/manifest/snapshot-store.test.d.ts +0 -11
- package/dist/manifest/snapshot-store.test.d.ts.map +0 -1
- package/dist/manifest/snapshot-store.test.js +0 -206
- package/dist/manifest/snapshot-store.test.js.map +0 -1
- package/dist/manifest/upload-manifest.test.d.ts +0 -10
- package/dist/manifest/upload-manifest.test.d.ts.map +0 -1
- package/dist/manifest/upload-manifest.test.js +0 -1335
- package/dist/manifest/upload-manifest.test.js.map +0 -1
- package/dist/manifest-reconcile.test.d.ts +0 -2
- package/dist/manifest-reconcile.test.d.ts.map +0 -1
- package/dist/manifest-reconcile.test.js +0 -1029
- package/dist/manifest-reconcile.test.js.map +0 -1
- package/dist/object-io.test.d.ts +0 -11
- package/dist/object-io.test.d.ts.map +0 -1
- package/dist/object-io.test.js +0 -2376
- package/dist/object-io.test.js.map +0 -1
- package/dist/operation-lock.test.d.ts +0 -5
- package/dist/operation-lock.test.d.ts.map +0 -1
- package/dist/operation-lock.test.js +0 -796
- package/dist/operation-lock.test.js.map +0 -1
- package/dist/outcome-telemetry.test.d.ts +0 -9
- package/dist/outcome-telemetry.test.d.ts.map +0 -1
- package/dist/outcome-telemetry.test.js +0 -663
- package/dist/outcome-telemetry.test.js.map +0 -1
- package/dist/outposts/client.test.d.ts +0 -9
- package/dist/outposts/client.test.d.ts.map +0 -1
- package/dist/outposts/client.test.js +0 -415
- package/dist/outposts/client.test.js.map +0 -1
- package/dist/outposts/command.test.d.ts +0 -18
- package/dist/outposts/command.test.d.ts.map +0 -1
- package/dist/outposts/command.test.js +0 -1230
- package/dist/outposts/command.test.js.map +0 -1
- package/dist/outposts/replica-sync.test.d.ts +0 -2
- package/dist/outposts/replica-sync.test.d.ts.map +0 -1
- package/dist/outposts/replica-sync.test.js +0 -224
- package/dist/outposts/replica-sync.test.js.map +0 -1
- package/dist/outposts/self-deploy.test.d.ts +0 -2
- package/dist/outposts/self-deploy.test.d.ts.map +0 -1
- package/dist/outposts/self-deploy.test.js +0 -218
- package/dist/outposts/self-deploy.test.js.map +0 -1
- package/dist/outposts/session-heartbeat-bounds.test.d.ts +0 -25
- package/dist/outposts/session-heartbeat-bounds.test.d.ts.map +0 -1
- package/dist/outposts/session-heartbeat-bounds.test.js +0 -156
- package/dist/outposts/session-heartbeat-bounds.test.js.map +0 -1
- package/dist/outposts/session-heartbeat-guard.test.d.ts +0 -16
- package/dist/outposts/session-heartbeat-guard.test.d.ts.map +0 -1
- package/dist/outposts/session-heartbeat-guard.test.js +0 -88
- package/dist/outposts/session-heartbeat-guard.test.js.map +0 -1
- package/dist/outposts/session-heartbeat-publisher.test.d.ts +0 -10
- package/dist/outposts/session-heartbeat-publisher.test.d.ts.map +0 -1
- package/dist/outposts/session-heartbeat-publisher.test.js +0 -153
- package/dist/outposts/session-heartbeat-publisher.test.js.map +0 -1
- package/dist/outposts/session-heartbeat-retain-guard.test.d.ts +0 -18
- package/dist/outposts/session-heartbeat-retain-guard.test.d.ts.map +0 -1
- package/dist/outposts/session-heartbeat-retain-guard.test.js +0 -106
- package/dist/outposts/session-heartbeat-retain-guard.test.js.map +0 -1
- package/dist/outposts/session-heartbeat-scan.test.d.ts +0 -20
- package/dist/outposts/session-heartbeat-scan.test.d.ts.map +0 -1
- package/dist/outposts/session-heartbeat-scan.test.js +0 -241
- package/dist/outposts/session-heartbeat-scan.test.js.map +0 -1
- package/dist/outposts/session-heartbeat.test.d.ts +0 -11
- package/dist/outposts/session-heartbeat.test.d.ts.map +0 -1
- package/dist/outposts/session-heartbeat.test.js +0 -370
- package/dist/outposts/session-heartbeat.test.js.map +0 -1
- package/dist/outposts/session-host-auth.test.d.ts +0 -16
- package/dist/outposts/session-host-auth.test.d.ts.map +0 -1
- package/dist/outposts/session-host-auth.test.js +0 -284
- package/dist/outposts/session-host-auth.test.js.map +0 -1
- package/dist/outposts/session-host-command.test.d.ts +0 -10
- package/dist/outposts/session-host-command.test.d.ts.map +0 -1
- package/dist/outposts/session-host-command.test.js +0 -180
- package/dist/outposts/session-host-command.test.js.map +0 -1
- package/dist/outposts/session-host-connection.test.d.ts +0 -18
- package/dist/outposts/session-host-connection.test.d.ts.map +0 -1
- package/dist/outposts/session-host-connection.test.js +0 -392
- package/dist/outposts/session-host-connection.test.js.map +0 -1
- package/dist/outposts/session-host-fakes.d.ts +0 -61
- package/dist/outposts/session-host-fakes.d.ts.map +0 -1
- package/dist/outposts/session-host-fakes.js +0 -115
- package/dist/outposts/session-host-fakes.js.map +0 -1
- package/dist/outposts/session-host-ndjson.test.d.ts +0 -11
- package/dist/outposts/session-host-ndjson.test.d.ts.map +0 -1
- package/dist/outposts/session-host-ndjson.test.js +0 -73
- package/dist/outposts/session-host-ndjson.test.js.map +0 -1
- package/dist/outposts/session-host-protocol.test.d.ts +0 -15
- package/dist/outposts/session-host-protocol.test.d.ts.map +0 -1
- package/dist/outposts/session-host-protocol.test.js +0 -0
- package/dist/outposts/session-host-protocol.test.js.map +0 -1
- package/dist/outposts/session-host-server.test.d.ts +0 -10
- package/dist/outposts/session-host-server.test.d.ts.map +0 -1
- package/dist/outposts/session-host-server.test.js +0 -118
- package/dist/outposts/session-host-server.test.js.map +0 -1
- package/dist/outposts/session-host-sessions.test.d.ts +0 -18
- package/dist/outposts/session-host-sessions.test.d.ts.map +0 -1
- package/dist/outposts/session-host-sessions.test.js +0 -439
- package/dist/outposts/session-host-sessions.test.js.map +0 -1
- package/dist/outposts/terminal.test.d.ts +0 -2
- package/dist/outposts/terminal.test.d.ts.map +0 -1
- package/dist/outposts/terminal.test.js +0 -146
- package/dist/outposts/terminal.test.js.map +0 -1
- package/dist/outposts/transport.test.d.ts +0 -10
- package/dist/outposts/transport.test.d.ts.map +0 -1
- package/dist/outposts/transport.test.js +0 -205
- package/dist/outposts/transport.test.js.map +0 -1
- package/dist/packed-journal-phase2.test.d.ts +0 -2
- package/dist/packed-journal-phase2.test.d.ts.map +0 -1
- package/dist/packed-journal-phase2.test.js +0 -273
- package/dist/packed-journal-phase2.test.js.map +0 -1
- package/dist/path-ancestors.test.d.ts +0 -2
- package/dist/path-ancestors.test.d.ts.map +0 -1
- package/dist/path-ancestors.test.js +0 -21
- package/dist/path-ancestors.test.js.map +0 -1
- package/dist/personal-vault-exclusions.test.d.ts +0 -22
- package/dist/personal-vault-exclusions.test.d.ts.map +0 -1
- package/dist/personal-vault-exclusions.test.js +0 -294
- package/dist/personal-vault-exclusions.test.js.map +0 -1
- package/dist/personal-vault.test.d.ts +0 -18
- package/dist/personal-vault.test.d.ts.map +0 -1
- package/dist/personal-vault.test.js +0 -1099
- package/dist/personal-vault.test.js.map +0 -1
- package/dist/plan-limit-pause.test.d.ts +0 -2
- package/dist/plan-limit-pause.test.d.ts.map +0 -1
- package/dist/plan-limit-pause.test.js +0 -150
- package/dist/plan-limit-pause.test.js.map +0 -1
- package/dist/plan-limit.fixtures.d.ts +0 -34
- package/dist/plan-limit.fixtures.d.ts.map +0 -1
- package/dist/plan-limit.fixtures.js +0 -96
- package/dist/plan-limit.fixtures.js.map +0 -1
- package/dist/plan-limit.test.d.ts +0 -2
- package/dist/plan-limit.test.d.ts.map +0 -1
- package/dist/plan-limit.test.js +0 -132
- package/dist/plan-limit.test.js.map +0 -1
- package/dist/prefix-coalesce.test.d.ts +0 -2
- package/dist/prefix-coalesce.test.d.ts.map +0 -1
- package/dist/prefix-coalesce.test.js +0 -260
- package/dist/prefix-coalesce.test.js.map +0 -1
- package/dist/private-folder-transport.test.d.ts +0 -2
- package/dist/private-folder-transport.test.d.ts.map +0 -1
- package/dist/private-folder-transport.test.js +0 -33
- package/dist/private-folder-transport.test.js.map +0 -1
- package/dist/public-surface.test.d.ts +0 -15
- package/dist/public-surface.test.d.ts.map +0 -1
- package/dist/public-surface.test.js +0 -110
- package/dist/public-surface.test.js.map +0 -1
- package/dist/qmd-reindex.test.d.ts +0 -14
- package/dist/qmd-reindex.test.d.ts.map +0 -1
- package/dist/qmd-reindex.test.js +0 -899
- package/dist/qmd-reindex.test.js.map +0 -1
- package/dist/read-only-state-dir.test.d.ts +0 -27
- package/dist/read-only-state-dir.test.d.ts.map +0 -1
- package/dist/read-only-state-dir.test.js +0 -167
- package/dist/read-only-state-dir.test.js.map +0 -1
- package/dist/remote-pull.test.d.ts +0 -2
- package/dist/remote-pull.test.d.ts.map +0 -1
- package/dist/remote-pull.test.js +0 -1147
- package/dist/remote-pull.test.js.map +0 -1
- package/dist/s3-download-stream-hash.test.d.ts +0 -2
- package/dist/s3-download-stream-hash.test.d.ts.map +0 -1
- package/dist/s3-download-stream-hash.test.js +0 -70
- package/dist/s3-download-stream-hash.test.js.map +0 -1
- package/dist/s3.list-cost.test.d.ts +0 -2
- package/dist/s3.list-cost.test.d.ts.map +0 -1
- package/dist/s3.list-cost.test.js +0 -51
- package/dist/s3.list-cost.test.js.map +0 -1
- package/dist/s3.rename-retry.test.d.ts +0 -2
- package/dist/s3.rename-retry.test.d.ts.map +0 -1
- package/dist/s3.rename-retry.test.js +0 -217
- package/dist/s3.rename-retry.test.js.map +0 -1
- package/dist/s3.symlink-materialize.test.d.ts +0 -18
- package/dist/s3.symlink-materialize.test.d.ts.map +0 -1
- package/dist/s3.symlink-materialize.test.js +0 -579
- package/dist/s3.symlink-materialize.test.js.map +0 -1
- package/dist/s3.test.d.ts +0 -9
- package/dist/s3.test.d.ts.map +0 -1
- package/dist/s3.test.js +0 -2047
- package/dist/s3.test.js.map +0 -1
- package/dist/s3.windows-portability.test.d.ts +0 -12
- package/dist/s3.windows-portability.test.d.ts.map +0 -1
- package/dist/s3.windows-portability.test.js +0 -223
- package/dist/s3.windows-portability.test.js.map +0 -1
- package/dist/schemas/signal-types.test.d.ts +0 -2
- package/dist/schemas/signal-types.test.d.ts.map +0 -1
- package/dist/schemas/signal-types.test.js +0 -65
- package/dist/schemas/signal-types.test.js.map +0 -1
- package/dist/schemas/source-channels.test.d.ts +0 -2
- package/dist/schemas/source-channels.test.d.ts.map +0 -1
- package/dist/schemas/source-channels.test.js +0 -65
- package/dist/schemas/source-channels.test.js.map +0 -1
- package/dist/scope-shrink.test.d.ts +0 -13
- package/dist/scope-shrink.test.d.ts.map +0 -1
- package/dist/scope-shrink.test.js +0 -558
- package/dist/scope-shrink.test.js.map +0 -1
- package/dist/session-log-capture.test.d.ts +0 -2
- package/dist/session-log-capture.test.d.ts.map +0 -1
- package/dist/session-log-capture.test.js +0 -533
- package/dist/session-log-capture.test.js.map +0 -1
- package/dist/session-log-retention.test.d.ts +0 -2
- package/dist/session-log-retention.test.d.ts.map +0 -1
- package/dist/session-log-retention.test.js +0 -383
- package/dist/session-log-retention.test.js.map +0 -1
- package/dist/signals/get.test.d.ts +0 -5
- package/dist/signals/get.test.d.ts.map +0 -1
- package/dist/signals/get.test.js +0 -254
- package/dist/signals/get.test.js.map +0 -1
- package/dist/signals/list.test.d.ts +0 -9
- package/dist/signals/list.test.d.ts.map +0 -1
- package/dist/signals/list.test.js +0 -330
- package/dist/signals/list.test.js.map +0 -1
- package/dist/skill-telemetry.test.d.ts +0 -2
- package/dist/skill-telemetry.test.d.ts.map +0 -1
- package/dist/skill-telemetry.test.js +0 -1484
- package/dist/skill-telemetry.test.js.map +0 -1
- package/dist/sources/get.test.d.ts +0 -5
- package/dist/sources/get.test.d.ts.map +0 -1
- package/dist/sources/get.test.js +0 -236
- package/dist/sources/get.test.js.map +0 -1
- package/dist/sources/list.test.d.ts +0 -8
- package/dist/sources/list.test.d.ts.map +0 -1
- package/dist/sources/list.test.js +0 -318
- package/dist/sources/list.test.js.map +0 -1
- package/dist/sts-credential-refresh.test.d.ts +0 -2
- package/dist/sts-credential-refresh.test.d.ts.map +0 -1
- package/dist/sts-credential-refresh.test.js +0 -181
- package/dist/sts-credential-refresh.test.js.map +0 -1
- package/dist/swallowed-error-sites.test.d.ts +0 -2
- package/dist/swallowed-error-sites.test.d.ts.map +0 -1
- package/dist/swallowed-error-sites.test.js +0 -199
- package/dist/swallowed-error-sites.test.js.map +0 -1
- package/dist/swallowed-error-w32.test.d.ts +0 -2
- package/dist/swallowed-error-w32.test.d.ts.map +0 -1
- package/dist/swallowed-error-w32.test.js +0 -171
- package/dist/swallowed-error-w32.test.js.map +0 -1
- package/dist/swallowed-error-w33.test.d.ts +0 -2
- package/dist/swallowed-error-w33.test.d.ts.map +0 -1
- package/dist/swallowed-error-w33.test.js +0 -125
- package/dist/swallowed-error-w33.test.js.map +0 -1
- package/dist/swallowed-error-w34.test.d.ts +0 -2
- package/dist/swallowed-error-w34.test.d.ts.map +0 -1
- package/dist/swallowed-error-w34.test.js +0 -110
- package/dist/swallowed-error-w34.test.js.map +0 -1
- package/dist/swallowed-error-w35.test.d.ts +0 -2
- package/dist/swallowed-error-w35.test.d.ts.map +0 -1
- package/dist/swallowed-error-w35.test.js +0 -223
- package/dist/swallowed-error-w35.test.js.map +0 -1
- package/dist/sync/area-ledger-auto-migration.test.d.ts +0 -2
- package/dist/sync/area-ledger-auto-migration.test.d.ts.map +0 -1
- package/dist/sync/area-ledger-auto-migration.test.js +0 -291
- package/dist/sync/area-ledger-auto-migration.test.js.map +0 -1
- package/dist/sync/area-ledger-migration.test.d.ts +0 -2
- package/dist/sync/area-ledger-migration.test.d.ts.map +0 -1
- package/dist/sync/area-ledger-migration.test.js +0 -554
- package/dist/sync/area-ledger-migration.test.js.map +0 -1
- package/dist/sync/area-ledger-recovery.bench.test.d.ts +0 -2
- package/dist/sync/area-ledger-recovery.bench.test.d.ts.map +0 -1
- package/dist/sync/area-ledger-recovery.bench.test.js +0 -60
- package/dist/sync/area-ledger-recovery.bench.test.js.map +0 -1
- package/dist/sync/area-ledger-writer-lock.bench.test.d.ts +0 -2
- package/dist/sync/area-ledger-writer-lock.bench.test.d.ts.map +0 -1
- package/dist/sync/area-ledger-writer-lock.bench.test.js +0 -57
- package/dist/sync/area-ledger-writer-lock.bench.test.js.map +0 -1
- package/dist/sync/area-ledger.test.d.ts +0 -2
- package/dist/sync/area-ledger.test.d.ts.map +0 -1
- package/dist/sync/area-ledger.test.js +0 -1401
- package/dist/sync/area-ledger.test.js.map +0 -1
- package/dist/sync/area-resolver.test.d.ts +0 -2
- package/dist/sync/area-resolver.test.d.ts.map +0 -1
- package/dist/sync/area-resolver.test.js +0 -234
- package/dist/sync/area-resolver.test.js.map +0 -1
- package/dist/sync/base-version-resolver.test.d.ts +0 -2
- package/dist/sync/base-version-resolver.test.d.ts.map +0 -1
- package/dist/sync/base-version-resolver.test.js +0 -24
- package/dist/sync/base-version-resolver.test.js.map +0 -1
- package/dist/sync/candidate-uploader.test.d.ts +0 -2
- package/dist/sync/candidate-uploader.test.d.ts.map +0 -1
- package/dist/sync/candidate-uploader.test.js +0 -132
- package/dist/sync/candidate-uploader.test.js.map +0 -1
- package/dist/sync/child-process-sync-worker-env.test.d.ts +0 -2
- package/dist/sync/child-process-sync-worker-env.test.d.ts.map +0 -1
- package/dist/sync/child-process-sync-worker-env.test.js +0 -38
- package/dist/sync/child-process-sync-worker-env.test.js.map +0 -1
- package/dist/sync/child-process-sync-worker-errors.test.d.ts +0 -2
- package/dist/sync/child-process-sync-worker-errors.test.d.ts.map +0 -1
- package/dist/sync/child-process-sync-worker-errors.test.js +0 -121
- package/dist/sync/child-process-sync-worker-errors.test.js.map +0 -1
- package/dist/sync/child-process-sync-worker.test.d.ts +0 -2
- package/dist/sync/child-process-sync-worker.test.d.ts.map +0 -1
- package/dist/sync/child-process-sync-worker.test.js +0 -262
- package/dist/sync/child-process-sync-worker.test.js.map +0 -1
- package/dist/sync/content-hash.test.d.ts +0 -2
- package/dist/sync/content-hash.test.d.ts.map +0 -1
- package/dist/sync/content-hash.test.js +0 -16
- package/dist/sync/content-hash.test.js.map +0 -1
- package/dist/sync/delta-client.test.d.ts +0 -2
- package/dist/sync/delta-client.test.d.ts.map +0 -1
- package/dist/sync/delta-client.test.js +0 -97
- package/dist/sync/delta-client.test.js.map +0 -1
- package/dist/sync/durable-apply.test.d.ts +0 -2
- package/dist/sync/durable-apply.test.d.ts.map +0 -1
- package/dist/sync/durable-apply.test.js +0 -180
- package/dist/sync/durable-apply.test.js.map +0 -1
- package/dist/sync/event-sync.test.d.ts +0 -14
- package/dist/sync/event-sync.test.d.ts.map +0 -1
- package/dist/sync/event-sync.test.js +0 -1714
- package/dist/sync/event-sync.test.js.map +0 -1
- package/dist/sync/feature-flags.test.d.ts +0 -24
- package/dist/sync/feature-flags.test.d.ts.map +0 -1
- package/dist/sync/feature-flags.test.js +0 -594
- package/dist/sync/feature-flags.test.js.map +0 -1
- package/dist/sync/full-pass-cap-governor.bench.test.d.ts +0 -2
- package/dist/sync/full-pass-cap-governor.bench.test.d.ts.map +0 -1
- package/dist/sync/full-pass-cap-governor.bench.test.js +0 -179
- package/dist/sync/full-pass-cap-governor.bench.test.js.map +0 -1
- package/dist/sync/full-pass-caps.test.d.ts +0 -2
- package/dist/sync/full-pass-caps.test.d.ts.map +0 -1
- package/dist/sync/full-pass-caps.test.js +0 -550
- package/dist/sync/full-pass-caps.test.js.map +0 -1
- package/dist/sync/idle-full-reconcile.test.d.ts +0 -2
- package/dist/sync/idle-full-reconcile.test.d.ts.map +0 -1
- package/dist/sync/idle-full-reconcile.test.js +0 -62
- package/dist/sync/idle-full-reconcile.test.js.map +0 -1
- package/dist/sync/inbound-event-admission.test.d.ts +0 -2
- package/dist/sync/inbound-event-admission.test.d.ts.map +0 -1
- package/dist/sync/inbound-event-admission.test.js +0 -125
- package/dist/sync/inbound-event-admission.test.js.map +0 -1
- package/dist/sync/journal-ledger-stream.test.d.ts +0 -12
- package/dist/sync/journal-ledger-stream.test.d.ts.map +0 -1
- package/dist/sync/journal-ledger-stream.test.js +0 -134
- package/dist/sync/journal-ledger-stream.test.js.map +0 -1
- package/dist/sync/lease-client.test.d.ts +0 -2
- package/dist/sync/lease-client.test.d.ts.map +0 -1
- package/dist/sync/lease-client.test.js +0 -119
- package/dist/sync/lease-client.test.js.map +0 -1
- package/dist/sync/logger.test.d.ts +0 -19
- package/dist/sync/logger.test.d.ts.map +0 -1
- package/dist/sync/logger.test.js +0 -204
- package/dist/sync/logger.test.js.map +0 -1
- package/dist/sync/metrics.test.d.ts +0 -19
- package/dist/sync/metrics.test.d.ts.map +0 -1
- package/dist/sync/metrics.test.js +0 -357
- package/dist/sync/metrics.test.js.map +0 -1
- package/dist/sync/multipart-uploader.test.d.ts +0 -2
- package/dist/sync/multipart-uploader.test.d.ts.map +0 -1
- package/dist/sync/multipart-uploader.test.js +0 -135
- package/dist/sync/multipart-uploader.test.js.map +0 -1
- package/dist/sync/mutation-client.test.d.ts +0 -2
- package/dist/sync/mutation-client.test.d.ts.map +0 -1
- package/dist/sync/mutation-client.test.js +0 -100
- package/dist/sync/mutation-client.test.js.map +0 -1
- package/dist/sync/mutation-session.test.d.ts +0 -2
- package/dist/sync/mutation-session.test.d.ts.map +0 -1
- package/dist/sync/mutation-session.test.js +0 -190
- package/dist/sync/mutation-session.test.js.map +0 -1
- package/dist/sync/parallel-sync-coordinator.test.d.ts +0 -2
- package/dist/sync/parallel-sync-coordinator.test.d.ts.map +0 -1
- package/dist/sync/parallel-sync-coordinator.test.js +0 -564
- package/dist/sync/parallel-sync-coordinator.test.js.map +0 -1
- package/dist/sync/path-leases.test.d.ts +0 -2
- package/dist/sync/path-leases.test.d.ts.map +0 -1
- package/dist/sync/path-leases.test.js +0 -51
- package/dist/sync/path-leases.test.js.map +0 -1
- package/dist/sync/per-area-maintenance.test.d.ts +0 -2
- package/dist/sync/per-area-maintenance.test.d.ts.map +0 -1
- package/dist/sync/per-area-maintenance.test.js +0 -100
- package/dist/sync/per-area-maintenance.test.js.map +0 -1
- package/dist/sync/push-event.test.d.ts +0 -15
- package/dist/sync/push-event.test.d.ts.map +0 -1
- package/dist/sync/push-event.test.js +0 -282
- package/dist/sync/push-event.test.js.map +0 -1
- package/dist/sync/push-receiver.test.d.ts +0 -25
- package/dist/sync/push-receiver.test.d.ts.map +0 -1
- package/dist/sync/push-receiver.test.js +0 -2851
- package/dist/sync/push-receiver.test.js.map +0 -1
- package/dist/sync/push-transport.test.d.ts +0 -2
- package/dist/sync/push-transport.test.d.ts.map +0 -1
- package/dist/sync/push-transport.test.js +0 -70
- package/dist/sync/push-transport.test.js.map +0 -1
- package/dist/sync/push-walk-matchers.bench.test.d.ts +0 -2
- package/dist/sync/push-walk-matchers.bench.test.d.ts.map +0 -1
- package/dist/sync/push-walk-matchers.bench.test.js +0 -230
- package/dist/sync/push-walk-matchers.bench.test.js.map +0 -1
- package/dist/sync/realtime-network-integration.test.d.ts +0 -2
- package/dist/sync/realtime-network-integration.test.d.ts.map +0 -1
- package/dist/sync/realtime-network-integration.test.js +0 -83
- package/dist/sync/realtime-network-integration.test.js.map +0 -1
- package/dist/sync/realtime-rollout.test.d.ts +0 -2
- package/dist/sync/realtime-rollout.test.d.ts.map +0 -1
- package/dist/sync/realtime-rollout.test.js +0 -94
- package/dist/sync/realtime-rollout.test.js.map +0 -1
- package/dist/sync/realtime-telemetry.test.d.ts +0 -2
- package/dist/sync/realtime-telemetry.test.d.ts.map +0 -1
- package/dist/sync/realtime-telemetry.test.js +0 -300
- package/dist/sync/realtime-telemetry.test.js.map +0 -1
- package/dist/sync/receiver-origin-config.test.d.ts +0 -2
- package/dist/sync/receiver-origin-config.test.d.ts.map +0 -1
- package/dist/sync/receiver-origin-config.test.js +0 -39
- package/dist/sync/receiver-origin-config.test.js.map +0 -1
- package/dist/sync/reconcile-cursor.test.d.ts +0 -2
- package/dist/sync/reconcile-cursor.test.d.ts.map +0 -1
- package/dist/sync/reconcile-cursor.test.js +0 -139
- package/dist/sync/reconcile-cursor.test.js.map +0 -1
- package/dist/sync/remote-file-spool.test.d.ts +0 -2
- package/dist/sync/remote-file-spool.test.d.ts.map +0 -1
- package/dist/sync/remote-file-spool.test.js +0 -77
- package/dist/sync/remote-file-spool.test.js.map +0 -1
- package/dist/sync/scope-inventory-client.test.d.ts +0 -2
- package/dist/sync/scope-inventory-client.test.d.ts.map +0 -1
- package/dist/sync/scope-inventory-client.test.js +0 -103
- package/dist/sync/scope-inventory-client.test.js.map +0 -1
- package/dist/sync/secret-path-policy.test.d.ts +0 -2
- package/dist/sync/secret-path-policy.test.d.ts.map +0 -1
- package/dist/sync/secret-path-policy.test.js +0 -183
- package/dist/sync/secret-path-policy.test.js.map +0 -1
- package/dist/sync/snapshot-client.test.d.ts +0 -2
- package/dist/sync/snapshot-client.test.d.ts.map +0 -1
- package/dist/sync/snapshot-client.test.js +0 -169
- package/dist/sync/snapshot-client.test.js.map +0 -1
- package/dist/sync/state-store.test.d.ts +0 -2
- package/dist/sync/state-store.test.d.ts.map +0 -1
- package/dist/sync/state-store.test.js +0 -1556
- package/dist/sync/state-store.test.js.map +0 -1
- package/dist/sync/sync-contract.test.d.ts +0 -2
- package/dist/sync/sync-contract.test.d.ts.map +0 -1
- package/dist/sync/sync-contract.test.js +0 -141
- package/dist/sync/sync-contract.test.js.map +0 -1
- package/dist/sync/targeted-pull-prefixes.test.d.ts +0 -2
- package/dist/sync/targeted-pull-prefixes.test.d.ts.map +0 -1
- package/dist/sync/targeted-pull-prefixes.test.js +0 -33
- package/dist/sync/targeted-pull-prefixes.test.js.map +0 -1
- package/dist/sync/targeted-upsert-exact-list.test.d.ts +0 -2
- package/dist/sync/targeted-upsert-exact-list.test.d.ts.map +0 -1
- package/dist/sync/targeted-upsert-exact-list.test.js +0 -247
- package/dist/sync/targeted-upsert-exact-list.test.js.map +0 -1
- package/dist/sync/unchanged-push-events.test.d.ts +0 -2
- package/dist/sync/unchanged-push-events.test.d.ts.map +0 -1
- package/dist/sync/unchanged-push-events.test.js +0 -76
- package/dist/sync/unchanged-push-events.test.js.map +0 -1
- package/dist/sync/uploader-finalization.test.d.ts +0 -2
- package/dist/sync/uploader-finalization.test.d.ts.map +0 -1
- package/dist/sync/uploader-finalization.test.js +0 -107
- package/dist/sync/uploader-finalization.test.js.map +0 -1
- package/dist/sync-core.test.d.ts +0 -2
- package/dist/sync-core.test.d.ts.map +0 -1
- package/dist/sync-core.test.js +0 -189
- package/dist/sync-core.test.js.map +0 -1
- package/dist/sync-progress.test.d.ts +0 -2
- package/dist/sync-progress.test.d.ts.map +0 -1
- package/dist/sync-progress.test.js +0 -277
- package/dist/sync-progress.test.js.map +0 -1
- package/dist/telemetry-events.test.d.ts +0 -2
- package/dist/telemetry-events.test.d.ts.map +0 -1
- package/dist/telemetry-events.test.js +0 -113
- package/dist/telemetry-events.test.js.map +0 -1
- package/dist/telemetry.test.d.ts +0 -11
- package/dist/telemetry.test.d.ts.map +0 -1
- package/dist/telemetry.test.js +0 -1603
- package/dist/telemetry.test.js.map +0 -1
- package/dist/vault-client.test.d.ts +0 -7
- package/dist/vault-client.test.d.ts.map +0 -1
- package/dist/vault-client.test.js +0 -1334
- package/dist/vault-client.test.js.map +0 -1
- package/dist/watch-coverage.test.d.ts +0 -2
- package/dist/watch-coverage.test.d.ts.map +0 -1
- package/dist/watch-coverage.test.js +0 -56
- package/dist/watch-coverage.test.js.map +0 -1
- package/dist/watch-owner-lease.test.d.ts +0 -2
- package/dist/watch-owner-lease.test.d.ts.map +0 -1
- package/dist/watch-owner-lease.test.js +0 -353
- package/dist/watch-owner-lease.test.js.map +0 -1
- package/dist/watch-parent.test.d.ts +0 -2
- package/dist/watch-parent.test.d.ts.map +0 -1
- package/dist/watch-parent.test.js +0 -131
- package/dist/watch-parent.test.js.map +0 -1
- package/dist/watch-roots.test.d.ts +0 -18
- package/dist/watch-roots.test.d.ts.map +0 -1
- package/dist/watch-roots.test.js +0 -395
- package/dist/watch-roots.test.js.map +0 -1
- package/dist/watcher-continuity-hot-path.test.d.ts +0 -2
- package/dist/watcher-continuity-hot-path.test.d.ts.map +0 -1
- package/dist/watcher-continuity-hot-path.test.js +0 -40
- package/dist/watcher-continuity-hot-path.test.js.map +0 -1
- package/dist/watcher-event-gate.test.d.ts +0 -16
- package/dist/watcher-event-gate.test.d.ts.map +0 -1
- package/dist/watcher-event-gate.test.js +0 -191
- package/dist/watcher-event-gate.test.js.map +0 -1
- package/dist/watcher-replan-sweep.test.d.ts +0 -2
- package/dist/watcher-replan-sweep.test.d.ts.map +0 -1
- package/dist/watcher-replan-sweep.test.js +0 -245
- package/dist/watcher-replan-sweep.test.js.map +0 -1
- package/dist/watcher-stdout.test.d.ts +0 -2
- package/dist/watcher-stdout.test.d.ts.map +0 -1
- package/dist/watcher-stdout.test.js +0 -86
- package/dist/watcher-stdout.test.js.map +0 -1
- package/dist/watcher.test.d.ts +0 -2
- package/dist/watcher.test.d.ts.map +0 -1
- package/dist/watcher.test.js +0 -4301
- package/dist/watcher.test.js.map +0 -1
- package/dist/watcher.warmup-cancel.test.d.ts +0 -2
- package/dist/watcher.warmup-cancel.test.d.ts.map +0 -1
- package/dist/watcher.warmup-cancel.test.js +0 -59
- package/dist/watcher.warmup-cancel.test.js.map +0 -1
- package/dist/watcher.warmup-responsive.test.d.ts +0 -2
- package/dist/watcher.warmup-responsive.test.d.ts.map +0 -1
- package/dist/watcher.warmup-responsive.test.js +0 -62
- package/dist/watcher.warmup-responsive.test.js.map +0 -1
package/dist/watcher.test.js
DELETED
|
@@ -1,4301 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
3
|
-
import * as fs from "fs";
|
|
4
|
-
import * as os from "os";
|
|
5
|
-
import * as path from "path";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
|
-
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
8
|
-
import { watch } from "chokidar";
|
|
9
|
-
import { FakeClock, WatchPushDriver, TreeWatcher, ChokidarWatchBudget, createWatchPathFilter, toChokidarIgnored, PushEventEmitter, PUBLISH_OUTCOME_JOURNALD_CEILING_BYTES, PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP, resolveEventDebounceConfig, summarizePublishBatchOutcome, DEFAULT_EVENT_DEBOUNCE_MS, DEFAULT_EVENT_MAX_WAIT_MS, DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS, DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS, DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES, DEFAULT_PUBLISH_MIN_INTERVAL_MS, EVENT_DEBOUNCE_MS_ENV, EVENT_MAX_WAIT_MS_ENV, EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV, EVENT_SMALL_BATCH_MAX_PATHS_ENV, EVENT_SMALL_BATCH_MAX_BYTES_ENV, DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS, MAX_TREE_WATCHER_MAX_WATCHED_PATHS, TREE_WATCHER_MAX_WATCHED_PATHS_ENV, resolveMaxWatchedPaths, PUBLISH_MIN_INTERVAL_MS_ENV, resolvePublishMinIntervalMs, DurablePublishedContentHashes, DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS, replayDurablePublishedContentHashRecordsForTest, startScopedRecursiveWatch, startNativeRootWatch, startTreeWatch, selectTreeWatchBackend } from "./watcher.js";
|
|
10
|
-
import { StateStore, StateStoreLockError } from "./sync/state-store.js";
|
|
11
|
-
import { HttpPushTransport, PushScopeForbiddenError, RealtimeUnavailableError, } from "./sync/push-transport.js";
|
|
12
|
-
import { computePersonalVaultPaths } from "./personal-vault.js";
|
|
13
|
-
/**
|
|
14
|
-
* US-001 — Phase 1 test harness: watch-triggered push seam + latency assertion.
|
|
15
|
-
*
|
|
16
|
-
* These tests drive the debounce/coalesce core ({@link WatchPushDriver}) with an
|
|
17
|
-
* injected {@link FakeClock} and an injected spy push fn — no real chokidar
|
|
18
|
-
* watcher, no real S3, no real 10-minute sleep. US-002/US-003 build the real
|
|
19
|
-
* watcher + targeted-push fn on top of this same seam, so the tests here also
|
|
20
|
-
* serve as regression guards for the contract those stories depend on.
|
|
21
|
-
*/
|
|
22
|
-
const DEBOUNCE = 2000;
|
|
23
|
-
describe("scoped watcher resource errors", () => {
|
|
24
|
-
let root;
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-resource-error-")));
|
|
27
|
-
});
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
30
|
-
});
|
|
31
|
-
it("does not reopen a directory watch after EMFILE", () => {
|
|
32
|
-
const docs = path.join(root, "docs");
|
|
33
|
-
fs.mkdirSync(docs);
|
|
34
|
-
class FakeWatcher extends EventEmitter {
|
|
35
|
-
close() {
|
|
36
|
-
this.emit("close");
|
|
37
|
-
return this;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
const handles = new Map();
|
|
41
|
-
const watchedPaths = [];
|
|
42
|
-
const backend = startScopedRecursiveWatch(root, createWatchPathFilter(root, false), () => { }, () => { }, undefined, {
|
|
43
|
-
recursiveWatches: false,
|
|
44
|
-
listChildDirs: (dir) => dir === root ? [docs] : [],
|
|
45
|
-
watchDirectory: (dir) => {
|
|
46
|
-
const watchPath = dir.toString();
|
|
47
|
-
watchedPaths.push(watchPath);
|
|
48
|
-
const handle = new FakeWatcher();
|
|
49
|
-
handles.set(watchPath, handle);
|
|
50
|
-
return handle;
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
try {
|
|
54
|
-
expect(watchedPaths.filter((dir) => dir === docs)).toHaveLength(1);
|
|
55
|
-
handles.get(docs).emit("error", Object.assign(new Error("too many open files"), { code: "EMFILE" }));
|
|
56
|
-
expect(watchedPaths.filter((dir) => dir === docs)).toHaveLength(1);
|
|
57
|
-
}
|
|
58
|
-
finally {
|
|
59
|
-
backend.close();
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
function publishedHashRecords(count) {
|
|
64
|
-
const maxPaths = Math.max(1, Math.floor(count / 2));
|
|
65
|
-
let seed = 0x14c0ffee;
|
|
66
|
-
const next = () => {
|
|
67
|
-
seed = (seed * 1_664_525 + 1_013_904_223) >>> 0;
|
|
68
|
-
return seed;
|
|
69
|
-
};
|
|
70
|
-
return Array.from({ length: count }, (_, index) => {
|
|
71
|
-
const relativePath = `path-${next() % maxPaths}`;
|
|
72
|
-
return next() % 5 === 0
|
|
73
|
-
? { sequence: index + 1, type: 0x5032, flags: 0, payload: { relativePath } }
|
|
74
|
-
: {
|
|
75
|
-
sequence: index + 1,
|
|
76
|
-
type: 0x5031,
|
|
77
|
-
flags: 0,
|
|
78
|
-
payload: { relativePath, contentHash: `sha256:${next()}`, maxPaths },
|
|
79
|
-
};
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Build a driver wired to a FakeClock and a spy push fn. Returns everything a
|
|
84
|
-
* test needs to drive and assert against the seam. This helper is the reusable
|
|
85
|
-
* "test harness" the story asks for.
|
|
86
|
-
*/
|
|
87
|
-
function makeHarness(opts) {
|
|
88
|
-
const clock = new FakeClock();
|
|
89
|
-
const push = vi.fn(() => { });
|
|
90
|
-
const driver = new WatchPushDriver({
|
|
91
|
-
debounceMs: opts?.debounceMs ?? DEBOUNCE,
|
|
92
|
-
clock,
|
|
93
|
-
push,
|
|
94
|
-
});
|
|
95
|
-
return {
|
|
96
|
-
clock,
|
|
97
|
-
push,
|
|
98
|
-
driver,
|
|
99
|
-
/** Emit a synthetic file-change event into the driver. */
|
|
100
|
-
emitChange: () => driver.notifyChange(),
|
|
101
|
-
/** Advance virtual time. */
|
|
102
|
-
advance: (ms) => clock.advance(ms),
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
async function flushImmediate() {
|
|
106
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
107
|
-
}
|
|
108
|
-
describe("FakeClock", () => {
|
|
109
|
-
it("fires a timer exactly when its deadline is reached", () => {
|
|
110
|
-
const clock = new FakeClock();
|
|
111
|
-
const fn = vi.fn();
|
|
112
|
-
clock.setTimeout(fn, 100);
|
|
113
|
-
clock.advance(99);
|
|
114
|
-
expect(fn).not.toHaveBeenCalled();
|
|
115
|
-
clock.advance(1);
|
|
116
|
-
expect(fn).toHaveBeenCalledTimes(1);
|
|
117
|
-
});
|
|
118
|
-
it("does not fire a cleared timer", () => {
|
|
119
|
-
const clock = new FakeClock();
|
|
120
|
-
const fn = vi.fn();
|
|
121
|
-
const handle = clock.setTimeout(fn, 100);
|
|
122
|
-
clock.clearTimeout(handle);
|
|
123
|
-
clock.advance(1000);
|
|
124
|
-
expect(fn).not.toHaveBeenCalled();
|
|
125
|
-
expect(clock.pendingTimerCount()).toBe(0);
|
|
126
|
-
});
|
|
127
|
-
it("reports pending timer count for leak checks", () => {
|
|
128
|
-
const clock = new FakeClock();
|
|
129
|
-
clock.setTimeout(() => { }, 100);
|
|
130
|
-
clock.setTimeout(() => { }, 200);
|
|
131
|
-
expect(clock.pendingTimerCount()).toBe(2);
|
|
132
|
-
clock.advance(100);
|
|
133
|
-
expect(clock.pendingTimerCount()).toBe(1);
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
describe("TreeWatcher adaptive watch budget", () => {
|
|
137
|
-
it("uses half of Linux max_user_watches, bounded by the floor and ceiling", () => {
|
|
138
|
-
const readFileSync = vi.fn(() => "1048576\n");
|
|
139
|
-
expect(resolveMaxWatchedPaths(undefined, {
|
|
140
|
-
platform: "linux",
|
|
141
|
-
env: {},
|
|
142
|
-
readFileSync,
|
|
143
|
-
})).toBe(MAX_TREE_WATCHER_MAX_WATCHED_PATHS);
|
|
144
|
-
expect(readFileSync).toHaveBeenCalledWith("/proc/sys/fs/inotify/max_user_watches", "utf8");
|
|
145
|
-
expect(resolveMaxWatchedPaths(undefined, {
|
|
146
|
-
platform: "linux",
|
|
147
|
-
env: {},
|
|
148
|
-
readFileSync: () => "40000\n",
|
|
149
|
-
})).toBe(DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS);
|
|
150
|
-
expect(resolveMaxWatchedPaths(undefined, {
|
|
151
|
-
platform: "linux",
|
|
152
|
-
env: {},
|
|
153
|
-
readFileSync: () => "8192\n",
|
|
154
|
-
})).toBe(4096);
|
|
155
|
-
});
|
|
156
|
-
it("keeps an explicit environment override authoritative", () => {
|
|
157
|
-
expect(resolveMaxWatchedPaths(undefined, {
|
|
158
|
-
platform: "linux",
|
|
159
|
-
env: { [TREE_WATCHER_MAX_WATCHED_PATHS_ENV]: "500000" },
|
|
160
|
-
readFileSync: () => {
|
|
161
|
-
throw new Error("must not read when overridden");
|
|
162
|
-
},
|
|
163
|
-
})).toBe(500_000);
|
|
164
|
-
});
|
|
165
|
-
it("falls back to the established constant off Linux or when proc is unreadable", () => {
|
|
166
|
-
expect(resolveMaxWatchedPaths(undefined, {
|
|
167
|
-
platform: "darwin",
|
|
168
|
-
env: {},
|
|
169
|
-
readFileSync: () => "1048576\n",
|
|
170
|
-
})).toBe(DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS);
|
|
171
|
-
expect(resolveMaxWatchedPaths(undefined, {
|
|
172
|
-
platform: "linux",
|
|
173
|
-
env: {},
|
|
174
|
-
readFileSync: () => {
|
|
175
|
-
throw new Error("proc unavailable");
|
|
176
|
-
},
|
|
177
|
-
})).toBe(DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS);
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
describe("TreeWatcher pending backlog caps", () => {
|
|
181
|
-
it("collects only warm-up mutations plus journal-backed deletions in one local scan", () => {
|
|
182
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-"));
|
|
183
|
-
try {
|
|
184
|
-
for (let i = 0; i < 12; i++)
|
|
185
|
-
fs.writeFileSync(path.join(dir, `unchanged-${i}.md`), "old");
|
|
186
|
-
const settle = Date.now() + 50;
|
|
187
|
-
while (Date.now() < settle) { /* let old ctime settle below the cutoff */ }
|
|
188
|
-
const cutoff = Date.now();
|
|
189
|
-
while (Date.now() < cutoff + 50) { /* filesystems may round ctime down */ }
|
|
190
|
-
const fresh = path.join(dir, "created-during-warmup.md");
|
|
191
|
-
fs.writeFileSync(fresh, "new");
|
|
192
|
-
const capture = vi.fn(() => []);
|
|
193
|
-
const watcher = new TreeWatcher({
|
|
194
|
-
hqRoot: dir,
|
|
195
|
-
pathFilter: () => true,
|
|
196
|
-
captureLocalDeleteSnapshots: capture,
|
|
197
|
-
});
|
|
198
|
-
const result = watcher.collectWarmupCatchup(cutoff, ["gone-during-warmup.md"]);
|
|
199
|
-
expect([...result.batch.paths.values()]).toEqual([
|
|
200
|
-
"created-during-warmup.md",
|
|
201
|
-
"gone-during-warmup.md",
|
|
202
|
-
]);
|
|
203
|
-
expect(result.batch.changes?.get(path.join(dir, "gone-during-warmup.md")))
|
|
204
|
-
.toMatchObject({ kind: "unlink" });
|
|
205
|
-
expect(capture).toHaveBeenCalledWith("gone-during-warmup.md", "unlink");
|
|
206
|
-
expect(result.scannedPaths).toBe(13);
|
|
207
|
-
}
|
|
208
|
-
finally {
|
|
209
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
210
|
-
}
|
|
211
|
-
});
|
|
212
|
-
it("yields through a 50k-row warm-up journal catch-up without losing coverage", async () => {
|
|
213
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-yield-"));
|
|
214
|
-
try {
|
|
215
|
-
const watcher = new TreeWatcher({ hqRoot: dir, pathFilter: () => true });
|
|
216
|
-
const journalRows = Array.from({ length: 50_000 }, (_, index) => `personal/missing-${index}.md`);
|
|
217
|
-
const histogram = monitorEventLoopDelay({ resolution: 1 });
|
|
218
|
-
let eventLoopTicks = 0;
|
|
219
|
-
let keepTicking = true;
|
|
220
|
-
const tick = () => {
|
|
221
|
-
if (!keepTicking)
|
|
222
|
-
return;
|
|
223
|
-
eventLoopTicks += 1;
|
|
224
|
-
setImmediate(tick);
|
|
225
|
-
};
|
|
226
|
-
histogram.enable();
|
|
227
|
-
setImmediate(tick);
|
|
228
|
-
const result = await watcher.collectWarmupCatchupAsync(Date.now(), journalRows);
|
|
229
|
-
keepTicking = false;
|
|
230
|
-
await flushImmediate();
|
|
231
|
-
histogram.disable();
|
|
232
|
-
expect(result.scannedPaths).toBe(0);
|
|
233
|
-
expect(result.batch.paths.size).toBe(50_000);
|
|
234
|
-
expect(eventLoopTicks).toBeGreaterThan(1);
|
|
235
|
-
expect(Number(histogram.max) / 1_000_000).toBeLessThan(50);
|
|
236
|
-
}
|
|
237
|
-
finally {
|
|
238
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
it("uses async filesystem calls and prunes excluded trees during warm-up", async () => {
|
|
242
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-async-fs-"));
|
|
243
|
-
const allowed = path.join(dir, "core", "knowledge", "public");
|
|
244
|
-
const nodeModules = path.join(dir, "core", "workers", "demo", "node_modules", "pkg");
|
|
245
|
-
const target = path.join(dir, "core", "packages", "demo", "target", "debug");
|
|
246
|
-
fs.mkdirSync(allowed, { recursive: true });
|
|
247
|
-
fs.mkdirSync(nodeModules, { recursive: true });
|
|
248
|
-
fs.mkdirSync(target, { recursive: true });
|
|
249
|
-
fs.writeFileSync(path.join(allowed, "keep.md"), "keep");
|
|
250
|
-
fs.writeFileSync(path.join(nodeModules, "skip.js"), "skip");
|
|
251
|
-
fs.writeFileSync(path.join(target, "skip.o"), "skip");
|
|
252
|
-
try {
|
|
253
|
-
const watcher = new TreeWatcher({
|
|
254
|
-
hqRoot: dir,
|
|
255
|
-
pathFilter: createWatchPathFilter(dir, false),
|
|
256
|
-
});
|
|
257
|
-
const result = await watcher.collectWarmupCatchupAsync(Date.now() - 10_000);
|
|
258
|
-
expect(result.scannedPaths).toBeGreaterThan(0);
|
|
259
|
-
expect([...result.batch.paths.values()]).toEqual([
|
|
260
|
-
"core/knowledge/public/keep.md",
|
|
261
|
-
]);
|
|
262
|
-
}
|
|
263
|
-
finally {
|
|
264
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
it("stops a yielding warm-up catch-up without scheduling the remaining chunks", async () => {
|
|
268
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-stop-"));
|
|
269
|
-
const originalSetImmediate = globalThis.setImmediate;
|
|
270
|
-
let releaseFirstYield;
|
|
271
|
-
let firstYieldReached;
|
|
272
|
-
const firstYield = new Promise((resolve) => {
|
|
273
|
-
firstYieldReached = resolve;
|
|
274
|
-
});
|
|
275
|
-
const setImmediateSpy = vi.spyOn(globalThis, "setImmediate").mockImplementation(((callback, ...args) => {
|
|
276
|
-
if (!releaseFirstYield) {
|
|
277
|
-
releaseFirstYield = () => callback(...args);
|
|
278
|
-
firstYieldReached?.();
|
|
279
|
-
return undefined;
|
|
280
|
-
}
|
|
281
|
-
return originalSetImmediate(callback, ...args);
|
|
282
|
-
}));
|
|
283
|
-
try {
|
|
284
|
-
const watcher = new TreeWatcher({ hqRoot: dir, pathFilter: () => true });
|
|
285
|
-
const journalRows = Array.from({ length: 256 }, (_, index) => `personal/missing-${index}.md`);
|
|
286
|
-
const catchup = watcher.collectWarmupCatchupAsync(Date.now(), journalRows);
|
|
287
|
-
await firstYield;
|
|
288
|
-
watcher.stop();
|
|
289
|
-
releaseFirstYield?.();
|
|
290
|
-
const result = await catchup;
|
|
291
|
-
expect(result.cancelled).toBe(true);
|
|
292
|
-
expect(result.batch.paths.size).toBeLessThan(journalRows.length);
|
|
293
|
-
expect(setImmediateSpy).toHaveBeenCalledTimes(1);
|
|
294
|
-
}
|
|
295
|
-
finally {
|
|
296
|
-
setImmediateSpy.mockRestore();
|
|
297
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
298
|
-
}
|
|
299
|
-
});
|
|
300
|
-
it("keeps a warm-up epoch only for a queued deletion capture, and only while it runs", async () => {
|
|
301
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-epochs-"));
|
|
302
|
-
try {
|
|
303
|
-
for (let i = 0; i < 6; i++)
|
|
304
|
-
fs.writeFileSync(path.join(dir, `unchanged-${i}.md`), "old");
|
|
305
|
-
const settle = Date.now() + 50;
|
|
306
|
-
while (Date.now() < settle) { /* let old ctime settle below the cutoff */ }
|
|
307
|
-
const cutoff = Date.now();
|
|
308
|
-
while (Date.now() < cutoff + 50) { /* filesystems may round ctime down */ }
|
|
309
|
-
for (let i = 0; i < 4; i++)
|
|
310
|
-
fs.writeFileSync(path.join(dir, `fresh-${i}.md`), "new");
|
|
311
|
-
let resolveCapture;
|
|
312
|
-
const watcher = new TreeWatcher({
|
|
313
|
-
hqRoot: dir,
|
|
314
|
-
pathFilter: () => true,
|
|
315
|
-
queueLocalDeleteSnapshots: async () => {
|
|
316
|
-
await new Promise((resolve) => { resolveCapture = resolve; });
|
|
317
|
-
return [];
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
const result = watcher.collectWarmupCatchup(cutoff, ["gone-during-warmup.md"]);
|
|
321
|
-
expect([...result.batch.paths.values()].sort()).toEqual([
|
|
322
|
-
"fresh-0.md", "fresh-1.md", "fresh-2.md", "fresh-3.md", "gone-during-warmup.md",
|
|
323
|
-
]);
|
|
324
|
-
// Four live changes were recorded, but only the deletion has a capture
|
|
325
|
-
// in flight, so only the deletion holds an epoch.
|
|
326
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
|
|
327
|
-
await new Promise((resolve) => setImmediate(resolve)); // the queued job starts
|
|
328
|
-
resolveCapture();
|
|
329
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
330
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
|
|
331
|
-
watcher.dispose();
|
|
332
|
-
}
|
|
333
|
-
finally {
|
|
334
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
it("does not report a journal row as deleted when its file exists behind a symlinked directory", () => {
|
|
338
|
-
// The warm-up walk never descends through a symlinked directory (a Dirent
|
|
339
|
-
// symlink is not a directory), so rows journaled under such a path were
|
|
340
|
-
// never `observed` and every one of them came back as an `unlink` even
|
|
341
|
-
// though the file was right there. On one HQ, `.codex/claude -> ../.claude`
|
|
342
|
-
// made 11.4k present state files look deleted on every runner start: 11.4k
|
|
343
|
-
// delete roots, 11.4k queued delete-intent writes, and a delete plan that
|
|
344
|
-
// then refused each one. A journal row the walk missed must be stat'ed like
|
|
345
|
-
// any walked file: absent -> unlink, present and unchanged -> nothing,
|
|
346
|
-
// present and touched since the cutoff -> change.
|
|
347
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-symlink-dir-"));
|
|
348
|
-
try {
|
|
349
|
-
fs.mkdirSync(path.join(dir, ".claude", "state"), { recursive: true });
|
|
350
|
-
fs.writeFileSync(path.join(dir, ".claude", "state", "stable.txt"), "old");
|
|
351
|
-
fs.mkdirSync(path.join(dir, ".codex"));
|
|
352
|
-
fs.symlinkSync(path.join("..", ".claude"), path.join(dir, ".codex", "claude"), "dir");
|
|
353
|
-
const settle = Date.now() + 50;
|
|
354
|
-
while (Date.now() < settle) { /* let old ctime settle below the cutoff */ }
|
|
355
|
-
const cutoff = Date.now();
|
|
356
|
-
while (Date.now() < cutoff + 50) { /* filesystems may round ctime down */ }
|
|
357
|
-
fs.writeFileSync(path.join(dir, ".claude", "state", "fresh.txt"), "new");
|
|
358
|
-
const capture = vi.fn(() => []);
|
|
359
|
-
const watcher = new TreeWatcher({
|
|
360
|
-
hqRoot: dir,
|
|
361
|
-
pathFilter: () => true,
|
|
362
|
-
captureLocalDeleteSnapshots: capture,
|
|
363
|
-
});
|
|
364
|
-
const result = watcher.collectWarmupCatchup(cutoff, [
|
|
365
|
-
".codex/claude/state/stable.txt",
|
|
366
|
-
".codex/claude/state/fresh.txt",
|
|
367
|
-
".codex/claude/state/gone.txt",
|
|
368
|
-
]);
|
|
369
|
-
const kinds = Object.fromEntries([...result.batch.paths].map(([absolute, relative]) => [relative, result.batch.changes?.get(absolute)?.kind]));
|
|
370
|
-
expect(kinds).toEqual({
|
|
371
|
-
".claude/state/fresh.txt": "change",
|
|
372
|
-
".codex/claude/state/fresh.txt": "change",
|
|
373
|
-
".codex/claude/state/gone.txt": "unlink",
|
|
374
|
-
});
|
|
375
|
-
expect(capture).toHaveBeenCalledTimes(1);
|
|
376
|
-
expect(capture).toHaveBeenCalledWith(".codex/claude/state/gone.txt", "unlink");
|
|
377
|
-
}
|
|
378
|
-
finally {
|
|
379
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
380
|
-
}
|
|
381
|
-
});
|
|
382
|
-
it("canonicalizes a legacy journal key before adding its warm-up deletion to the batch", () => {
|
|
383
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-posix-key-"));
|
|
384
|
-
try {
|
|
385
|
-
const capture = vi.fn(() => []);
|
|
386
|
-
const watcher = new TreeWatcher({
|
|
387
|
-
hqRoot: dir,
|
|
388
|
-
pathFilter: () => true,
|
|
389
|
-
captureLocalDeleteSnapshots: capture,
|
|
390
|
-
});
|
|
391
|
-
const result = watcher.collectWarmupCatchup(Date.now(), [
|
|
392
|
-
"projects\\forecast\\x.csv",
|
|
393
|
-
]);
|
|
394
|
-
const absolute = path.join(dir, "projects", "forecast", "x.csv");
|
|
395
|
-
expect([...result.batch.paths.entries()]).toEqual([
|
|
396
|
-
[absolute, "projects/forecast/x.csv"],
|
|
397
|
-
]);
|
|
398
|
-
expect(capture).toHaveBeenCalledWith("projects/forecast/x.csv", "unlink");
|
|
399
|
-
}
|
|
400
|
-
finally {
|
|
401
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
402
|
-
}
|
|
403
|
-
});
|
|
404
|
-
it("recovers a warm-up path even when the event backlog dropped it", () => {
|
|
405
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-overflow-"));
|
|
406
|
-
try {
|
|
407
|
-
const retained = path.join(dir, "retained.md");
|
|
408
|
-
fs.writeFileSync(retained, "old");
|
|
409
|
-
const settle = Date.now() + 50;
|
|
410
|
-
while (Date.now() < settle) { /* let old ctime settle below the cutoff */ }
|
|
411
|
-
const cutoff = Date.now();
|
|
412
|
-
while (Date.now() < cutoff + 50) { /* filesystems may round ctime down */ }
|
|
413
|
-
const dropped = path.join(dir, "dropped-during-warmup.md");
|
|
414
|
-
fs.writeFileSync(dropped, "new");
|
|
415
|
-
const watcher = new TreeWatcher({
|
|
416
|
-
hqRoot: dir,
|
|
417
|
-
pathFilter: () => true,
|
|
418
|
-
maxPendingPaths: 1,
|
|
419
|
-
maxPendingBytes: 100_000,
|
|
420
|
-
});
|
|
421
|
-
watcher.handleEvent(retained);
|
|
422
|
-
watcher.handleEvent(dropped);
|
|
423
|
-
const result = watcher.collectWarmupCatchup(cutoff);
|
|
424
|
-
expect([...result.batch.paths.values()]).toEqual(["dropped-during-warmup.md"]);
|
|
425
|
-
}
|
|
426
|
-
finally {
|
|
427
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
it("honors the pending-path environment cap in the production constructor", () => {
|
|
431
|
-
vi.stubEnv("HQ_SYNC_MAX_PENDING_PATHS", "3");
|
|
432
|
-
vi.stubEnv("HQ_SYNC_MAX_PENDING_BYTES", "100000");
|
|
433
|
-
try {
|
|
434
|
-
const clock = new FakeClock();
|
|
435
|
-
const changed = vi.fn();
|
|
436
|
-
const watcher = new TreeWatcher({
|
|
437
|
-
hqRoot: ROOT,
|
|
438
|
-
clock,
|
|
439
|
-
debounceMs: DEBOUNCE,
|
|
440
|
-
pathFilter: () => true,
|
|
441
|
-
});
|
|
442
|
-
watcher.onChange(changed);
|
|
443
|
-
for (let i = 0; i < 4; i++) {
|
|
444
|
-
watcher.handleEvent(path.join(ROOT, `env-cap-${i}.md`));
|
|
445
|
-
}
|
|
446
|
-
clock.advance(DEBOUNCE);
|
|
447
|
-
expect(changed.mock.calls[0][1].paths.size).toBe(3);
|
|
448
|
-
expect(changed.mock.calls[0][1].overflowed).toBe(true);
|
|
449
|
-
}
|
|
450
|
-
finally {
|
|
451
|
-
vi.unstubAllEnvs();
|
|
452
|
-
}
|
|
453
|
-
});
|
|
454
|
-
it("honors the pending-byte environment cap in the production constructor", () => {
|
|
455
|
-
vi.stubEnv("HQ_SYNC_MAX_PENDING_PATHS", "100");
|
|
456
|
-
vi.stubEnv("HQ_SYNC_MAX_PENDING_BYTES", "1");
|
|
457
|
-
try {
|
|
458
|
-
const clock = new FakeClock();
|
|
459
|
-
const changed = vi.fn();
|
|
460
|
-
const watcher = new TreeWatcher({
|
|
461
|
-
hqRoot: ROOT,
|
|
462
|
-
clock,
|
|
463
|
-
debounceMs: DEBOUNCE,
|
|
464
|
-
pathFilter: () => true,
|
|
465
|
-
});
|
|
466
|
-
watcher.onChange(changed);
|
|
467
|
-
watcher.handleEvent(path.join(ROOT, "byte-cap.md"));
|
|
468
|
-
clock.advance(DEBOUNCE);
|
|
469
|
-
expect(changed.mock.calls[0][1].paths.size).toBe(0);
|
|
470
|
-
expect(changed.mock.calls[0][1].overflowed).toBe(true);
|
|
471
|
-
}
|
|
472
|
-
finally {
|
|
473
|
-
vi.unstubAllEnvs();
|
|
474
|
-
}
|
|
475
|
-
});
|
|
476
|
-
it.each([
|
|
477
|
-
["HQ_SYNC_MAX_PENDING_PATHS", "0"],
|
|
478
|
-
["HQ_SYNC_MAX_PENDING_PATHS", "0.5"],
|
|
479
|
-
["HQ_SYNC_MAX_PENDING_PATHS", "-1"],
|
|
480
|
-
["HQ_SYNC_MAX_PENDING_PATHS", "not-a-number"],
|
|
481
|
-
["HQ_SYNC_MAX_PENDING_BYTES", "0"],
|
|
482
|
-
["HQ_SYNC_MAX_PENDING_BYTES", "0.5"],
|
|
483
|
-
["HQ_SYNC_MAX_PENDING_BYTES", "-1"],
|
|
484
|
-
["HQ_SYNC_MAX_PENDING_BYTES", "not-a-number"],
|
|
485
|
-
])("warns once and falls back for invalid %s=%s", (name, value) => {
|
|
486
|
-
vi.stubEnv(name, value);
|
|
487
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
488
|
-
try {
|
|
489
|
-
new TreeWatcher({ hqRoot: ROOT });
|
|
490
|
-
expect(warn).toHaveBeenCalledOnce();
|
|
491
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining(name));
|
|
492
|
-
}
|
|
493
|
-
finally {
|
|
494
|
-
warn.mockRestore();
|
|
495
|
-
vi.unstubAllEnvs();
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
it("keeps a burst at the configured cap without marking it overflowed", () => {
|
|
499
|
-
vi.stubEnv("HQ_SYNC_MAX_PENDING_PATHS", "4");
|
|
500
|
-
vi.stubEnv("HQ_SYNC_MAX_PENDING_BYTES", "100000");
|
|
501
|
-
try {
|
|
502
|
-
const clock = new FakeClock();
|
|
503
|
-
const changed = vi.fn();
|
|
504
|
-
const overflow = vi.fn();
|
|
505
|
-
const watcher = new TreeWatcher({
|
|
506
|
-
hqRoot: ROOT,
|
|
507
|
-
clock,
|
|
508
|
-
debounceMs: DEBOUNCE,
|
|
509
|
-
pathFilter: () => true,
|
|
510
|
-
onBacklogOverflow: overflow,
|
|
511
|
-
});
|
|
512
|
-
watcher.onChange(changed);
|
|
513
|
-
for (let i = 0; i < 4; i++) {
|
|
514
|
-
watcher.handleEvent(path.join(ROOT, `below-cap-${i}.md`));
|
|
515
|
-
}
|
|
516
|
-
clock.advance(DEBOUNCE);
|
|
517
|
-
expect(overflow).not.toHaveBeenCalled();
|
|
518
|
-
expect(changed.mock.calls[0][1]).not.toHaveProperty("overflowed");
|
|
519
|
-
}
|
|
520
|
-
finally {
|
|
521
|
-
vi.unstubAllEnvs();
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
it("reports the hit cap and cumulative dropped count in every overflow episode", () => {
|
|
525
|
-
const clock = new FakeClock();
|
|
526
|
-
const overflow = vi.fn();
|
|
527
|
-
const watcher = new TreeWatcher({
|
|
528
|
-
hqRoot: ROOT,
|
|
529
|
-
clock,
|
|
530
|
-
debounceMs: DEBOUNCE,
|
|
531
|
-
pathFilter: () => true,
|
|
532
|
-
maxPendingPaths: 1,
|
|
533
|
-
maxPendingBytes: 100_000,
|
|
534
|
-
onBacklogOverflow: overflow,
|
|
535
|
-
});
|
|
536
|
-
watcher.handleEvent(path.join(ROOT, "kept-first.md"));
|
|
537
|
-
watcher.handleEvent(path.join(ROOT, "dropped-first.md"));
|
|
538
|
-
clock.advance(DEBOUNCE);
|
|
539
|
-
watcher.handleEvent(path.join(ROOT, "kept-second.md"));
|
|
540
|
-
clock.advance(10);
|
|
541
|
-
watcher.handleEvent(path.join(ROOT, "dropped-second.md"));
|
|
542
|
-
expect(overflow).toHaveBeenCalledTimes(2);
|
|
543
|
-
expect(overflow.mock.calls[0][0]).toMatchObject({
|
|
544
|
-
capHit: "paths",
|
|
545
|
-
totalDroppedPaths: 1,
|
|
546
|
-
maxPendingPaths: 1,
|
|
547
|
-
maxPendingBytes: 100_000,
|
|
548
|
-
pendingPaths: 1,
|
|
549
|
-
inFlightPassMs: 0,
|
|
550
|
-
});
|
|
551
|
-
expect(overflow.mock.calls[1][0]).toMatchObject({
|
|
552
|
-
capHit: "paths",
|
|
553
|
-
totalDroppedPaths: 2,
|
|
554
|
-
inFlightPassMs: 10,
|
|
555
|
-
});
|
|
556
|
-
});
|
|
557
|
-
it("logs every default overflow record with the cumulative total and hit cap", () => {
|
|
558
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
559
|
-
try {
|
|
560
|
-
const watcher = new TreeWatcher({
|
|
561
|
-
hqRoot: ROOT,
|
|
562
|
-
pathFilter: () => true,
|
|
563
|
-
maxPendingPaths: 1,
|
|
564
|
-
maxPendingBytes: 100_000,
|
|
565
|
-
});
|
|
566
|
-
watcher.handleEvent(path.join(ROOT, "kept.md"));
|
|
567
|
-
watcher.handleEvent(path.join(ROOT, "dropped-one.md"));
|
|
568
|
-
watcher.handleEvent(path.join(ROOT, "dropped-two.md"));
|
|
569
|
-
expect(warn).toHaveBeenCalledTimes(2);
|
|
570
|
-
expect(warn).toHaveBeenLastCalledWith(expect.stringContaining("backlog cap exceeded"), expect.objectContaining({
|
|
571
|
-
capHit: "paths",
|
|
572
|
-
totalDroppedPaths: 2,
|
|
573
|
-
pendingPaths: 1,
|
|
574
|
-
maxPendingPaths: 1,
|
|
575
|
-
maxPendingBytes: 100_000,
|
|
576
|
-
inFlightPassMs: expect.any(Number),
|
|
577
|
-
}));
|
|
578
|
-
}
|
|
579
|
-
finally {
|
|
580
|
-
warn.mockRestore();
|
|
581
|
-
}
|
|
582
|
-
});
|
|
583
|
-
it("omits route hints when an overflow exceeds the route-evidence cap", () => {
|
|
584
|
-
const clock = new FakeClock();
|
|
585
|
-
const changed = vi.fn();
|
|
586
|
-
const watcher = new TreeWatcher({
|
|
587
|
-
hqRoot: ROOT,
|
|
588
|
-
clock,
|
|
589
|
-
debounceMs: DEBOUNCE,
|
|
590
|
-
pathFilter: () => true,
|
|
591
|
-
maxPendingPaths: 1,
|
|
592
|
-
maxPendingBytes: 100_000,
|
|
593
|
-
onBacklogOverflow: () => { },
|
|
594
|
-
});
|
|
595
|
-
watcher.onChange(changed);
|
|
596
|
-
watcher.handleEvent(path.join(ROOT, "companies", "kept", "a.md"));
|
|
597
|
-
for (let i = 0; i < 33; i++) {
|
|
598
|
-
watcher.handleEvent(path.join(ROOT, "companies", `dropped-${i}`, "a.md"));
|
|
599
|
-
}
|
|
600
|
-
clock.advance(DEBOUNCE);
|
|
601
|
-
expect(changed.mock.calls[0][1]).toMatchObject({ overflowed: true });
|
|
602
|
-
expect(changed.mock.calls[0][1]).not.toHaveProperty("droppedRouteHints");
|
|
603
|
-
});
|
|
604
|
-
});
|
|
605
|
-
describe("US-001: WatchPushDriver — debounced push seam", () => {
|
|
606
|
-
it("calls the injected push fn exactly once within debounce+grace after a synthetic change", async () => {
|
|
607
|
-
const h = makeHarness();
|
|
608
|
-
h.emitChange();
|
|
609
|
-
// Before the window elapses, no push.
|
|
610
|
-
h.advance(DEBOUNCE - 1);
|
|
611
|
-
expect(h.push).not.toHaveBeenCalled();
|
|
612
|
-
// Crossing the debounce boundary fires the push exactly once.
|
|
613
|
-
h.advance(1);
|
|
614
|
-
await Promise.resolve();
|
|
615
|
-
expect(h.push).toHaveBeenCalledTimes(1);
|
|
616
|
-
});
|
|
617
|
-
it("coalesces N rapid changes within the window to exactly 1 push", async () => {
|
|
618
|
-
const h = makeHarness();
|
|
619
|
-
// 5 synthetic changes spread across less than one debounce window.
|
|
620
|
-
for (let i = 0; i < 5; i++) {
|
|
621
|
-
h.emitChange();
|
|
622
|
-
h.advance(100); // total 500ms << 2000ms debounce
|
|
623
|
-
}
|
|
624
|
-
// Window has not fully elapsed since the LAST change yet.
|
|
625
|
-
expect(h.push).not.toHaveBeenCalled();
|
|
626
|
-
// Let the quiet window after the final change elapse.
|
|
627
|
-
h.advance(DEBOUNCE);
|
|
628
|
-
await Promise.resolve();
|
|
629
|
-
expect(h.push).toHaveBeenCalledTimes(1);
|
|
630
|
-
});
|
|
631
|
-
it("treats two separate bursts as two pushes", async () => {
|
|
632
|
-
const h = makeHarness();
|
|
633
|
-
h.emitChange();
|
|
634
|
-
h.advance(DEBOUNCE);
|
|
635
|
-
await Promise.resolve();
|
|
636
|
-
expect(h.push).toHaveBeenCalledTimes(1);
|
|
637
|
-
h.emitChange();
|
|
638
|
-
h.advance(DEBOUNCE);
|
|
639
|
-
await Promise.resolve();
|
|
640
|
-
expect(h.push).toHaveBeenCalledTimes(2);
|
|
641
|
-
});
|
|
642
|
-
it("never overlaps an in-flight push; a mid-push change re-triggers one follow-up pass", async () => {
|
|
643
|
-
const clock = new FakeClock();
|
|
644
|
-
let release;
|
|
645
|
-
const inFlight = new Promise((r) => {
|
|
646
|
-
release = r;
|
|
647
|
-
});
|
|
648
|
-
let calls = 0;
|
|
649
|
-
const push = vi.fn(() => {
|
|
650
|
-
calls += 1;
|
|
651
|
-
// First push hangs until released; later pushes resolve immediately.
|
|
652
|
-
return calls === 1 ? inFlight : Promise.resolve();
|
|
653
|
-
});
|
|
654
|
-
const driver = new WatchPushDriver({ debounceMs: DEBOUNCE, clock, push });
|
|
655
|
-
// First burst -> first push starts and hangs.
|
|
656
|
-
driver.notifyChange();
|
|
657
|
-
clock.advance(DEBOUNCE);
|
|
658
|
-
await Promise.resolve();
|
|
659
|
-
expect(push).toHaveBeenCalledTimes(1);
|
|
660
|
-
expect(driver.isPushing()).toBe(true);
|
|
661
|
-
// A change arrives while the first push is in flight: must NOT start a
|
|
662
|
-
// concurrent push.
|
|
663
|
-
driver.notifyChange();
|
|
664
|
-
clock.advance(DEBOUNCE);
|
|
665
|
-
await Promise.resolve();
|
|
666
|
-
expect(push).toHaveBeenCalledTimes(1);
|
|
667
|
-
// Release the first push; the collapsed mid-push change re-arms.
|
|
668
|
-
release();
|
|
669
|
-
await inFlight;
|
|
670
|
-
await Promise.resolve();
|
|
671
|
-
// Re-armed window must elapse before the follow-up push fires.
|
|
672
|
-
clock.advance(DEBOUNCE);
|
|
673
|
-
await Promise.resolve();
|
|
674
|
-
expect(push).toHaveBeenCalledTimes(2);
|
|
675
|
-
driver.dispose();
|
|
676
|
-
});
|
|
677
|
-
it("F17: rejected watch push is caught instead of surfacing as an unhandled rejection", async () => {
|
|
678
|
-
const clock = new FakeClock();
|
|
679
|
-
const rejection = new Error("push failed");
|
|
680
|
-
const push = vi.fn(async () => {
|
|
681
|
-
throw rejection;
|
|
682
|
-
});
|
|
683
|
-
const driver = new WatchPushDriver({ debounceMs: DEBOUNCE, clock, push });
|
|
684
|
-
const unhandled = [];
|
|
685
|
-
const onUnhandled = (reason) => {
|
|
686
|
-
unhandled.push(reason);
|
|
687
|
-
};
|
|
688
|
-
process.prependListener("unhandledRejection", onUnhandled);
|
|
689
|
-
try {
|
|
690
|
-
driver.notifyChange();
|
|
691
|
-
clock.advance(DEBOUNCE);
|
|
692
|
-
await Promise.resolve();
|
|
693
|
-
await flushImmediate();
|
|
694
|
-
expect(push).toHaveBeenCalledTimes(1);
|
|
695
|
-
expect(driver.isPushing()).toBe(false);
|
|
696
|
-
expect(unhandled).toEqual([]);
|
|
697
|
-
}
|
|
698
|
-
finally {
|
|
699
|
-
process.removeListener("unhandledRejection", onUnhandled);
|
|
700
|
-
driver.dispose();
|
|
701
|
-
}
|
|
702
|
-
});
|
|
703
|
-
it("respects a custom debounce window", async () => {
|
|
704
|
-
const h = makeHarness({ debounceMs: 500 });
|
|
705
|
-
h.emitChange();
|
|
706
|
-
h.advance(499);
|
|
707
|
-
expect(h.push).not.toHaveBeenCalled();
|
|
708
|
-
h.advance(1);
|
|
709
|
-
await Promise.resolve();
|
|
710
|
-
expect(h.push).toHaveBeenCalledTimes(1);
|
|
711
|
-
});
|
|
712
|
-
});
|
|
713
|
-
describe("US-001: WatchPushDriver — teardown leaves no leaked timers", () => {
|
|
714
|
-
it("dispose() cancels a pending debounce timer", () => {
|
|
715
|
-
const h = makeHarness();
|
|
716
|
-
h.emitChange();
|
|
717
|
-
expect(h.clock.pendingTimerCount()).toBe(1);
|
|
718
|
-
h.driver.dispose();
|
|
719
|
-
expect(h.clock.pendingTimerCount()).toBe(0);
|
|
720
|
-
// A push must not fire after dispose, even if time advances.
|
|
721
|
-
h.advance(DEBOUNCE * 10);
|
|
722
|
-
expect(h.push).not.toHaveBeenCalled();
|
|
723
|
-
});
|
|
724
|
-
it("notifyChange() after dispose is a no-op and schedules nothing", () => {
|
|
725
|
-
const h = makeHarness();
|
|
726
|
-
h.driver.dispose();
|
|
727
|
-
h.emitChange();
|
|
728
|
-
expect(h.clock.pendingTimerCount()).toBe(0);
|
|
729
|
-
h.advance(DEBOUNCE * 10);
|
|
730
|
-
expect(h.push).not.toHaveBeenCalled();
|
|
731
|
-
});
|
|
732
|
-
it("dispose() is idempotent", () => {
|
|
733
|
-
const h = makeHarness();
|
|
734
|
-
h.emitChange();
|
|
735
|
-
h.driver.dispose();
|
|
736
|
-
h.driver.dispose();
|
|
737
|
-
expect(h.clock.pendingTimerCount()).toBe(0);
|
|
738
|
-
});
|
|
739
|
-
it("a fully drained burst leaves zero pending timers", async () => {
|
|
740
|
-
const h = makeHarness();
|
|
741
|
-
for (let i = 0; i < 5; i++)
|
|
742
|
-
h.emitChange();
|
|
743
|
-
h.advance(DEBOUNCE);
|
|
744
|
-
await Promise.resolve();
|
|
745
|
-
expect(h.push).toHaveBeenCalledTimes(1);
|
|
746
|
-
expect(h.clock.pendingTimerCount()).toBe(0);
|
|
747
|
-
});
|
|
748
|
-
});
|
|
749
|
-
/**
|
|
750
|
-
* US-002 — file-watcher module (debounced, ignore-aware, exclusion-aware).
|
|
751
|
-
*
|
|
752
|
-
* `createWatchPathFilter` is pure (no chokidar) so the exclusion logic is
|
|
753
|
-
* tested directly against absolute paths. `TreeWatcher` is then exercised via
|
|
754
|
-
* its `handleEvent` seam + FakeClock so debounce/coalesce/lifecycle assert
|
|
755
|
-
* deterministically without spinning a real chokidar instance.
|
|
756
|
-
*/
|
|
757
|
-
const ROOT = "/tmp/hq-root";
|
|
758
|
-
describe("US-002: createWatchPathFilter — ignore-list matching", () => {
|
|
759
|
-
const filter = createWatchPathFilter(ROOT, false);
|
|
760
|
-
it("emits for an ordinary tracked file", () => {
|
|
761
|
-
expect(filter(path.join(ROOT, "personal/notes.md"))).toBe(true);
|
|
762
|
-
});
|
|
763
|
-
it("does NOT emit for a .env file (DEFAULT_IGNORES secret)", () => {
|
|
764
|
-
expect(filter(path.join(ROOT, ".env"))).toBe(false);
|
|
765
|
-
expect(filter(path.join(ROOT, "personal/.env.local"))).toBe(false);
|
|
766
|
-
});
|
|
767
|
-
it("does NOT emit for AppleDouble files", () => {
|
|
768
|
-
expect(filter(path.join(ROOT, "._README.md"))).toBe(false);
|
|
769
|
-
expect(filter(path.join(ROOT, "core/connectors/._README.md"))).toBe(false);
|
|
770
|
-
});
|
|
771
|
-
it("does NOT emit for restore staging paths", () => {
|
|
772
|
-
expect(filter(path.join(ROOT, "core/packages.hq-restore-9980"), true)).toBe(false);
|
|
773
|
-
expect(filter(path.join(ROOT, "core/packages.hq-restore-9980/connectors/README.md"))).toBe(false);
|
|
774
|
-
expect(filter(path.join(ROOT, "core/README.md.hq-restore-probe-9980-m6bx"))).toBe(false);
|
|
775
|
-
});
|
|
776
|
-
it("does NOT emit for node_modules / dist build artifacts", () => {
|
|
777
|
-
expect(filter(path.join(ROOT, "node_modules/foo/index.js"))).toBe(false);
|
|
778
|
-
expect(filter(path.join(ROOT, "dist/bundle.js"))).toBe(false);
|
|
779
|
-
});
|
|
780
|
-
it("does NOT emit for the watched root itself or paths outside it", () => {
|
|
781
|
-
expect(filter(ROOT)).toBe(false);
|
|
782
|
-
expect(filter("/some/other/place/file.txt")).toBe(false);
|
|
783
|
-
});
|
|
784
|
-
});
|
|
785
|
-
describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
|
|
786
|
-
const personal = createWatchPathFilter(ROOT, true);
|
|
787
|
-
const nonPersonal = createWatchPathFilter(ROOT, false);
|
|
788
|
-
it("does NOT emit for PERSONAL_VAULT_DEFAULT_EXCLUSIONS in personalMode (output/, .beads/)", () => {
|
|
789
|
-
expect(personal(path.join(ROOT, "personal/output/x.txt"))).toBe(false);
|
|
790
|
-
expect(personal(path.join(ROOT, "personal/.beads/issues.db"))).toBe(false);
|
|
791
|
-
});
|
|
792
|
-
it("does NOT emit for PERSONAL_VAULT_EXCLUDED_TOP_LEVEL in personalMode (.git/companies/repos/workspace)", () => {
|
|
793
|
-
expect(personal(path.join(ROOT, "companies/indigo/board.json"))).toBe(false);
|
|
794
|
-
expect(personal(path.join(ROOT, "workspace/threads/x.json"))).toBe(false);
|
|
795
|
-
expect(personal(path.join(ROOT, "repos/private/foo/file.ts"))).toBe(false);
|
|
796
|
-
});
|
|
797
|
-
// ── DEV-1778 continuity-pointer carve-out ───────────────────────────────
|
|
798
|
-
it("DOES emit for workspace/threads/handoff.json (the continuity pointer)", () => {
|
|
799
|
-
expect(personal(path.join(ROOT, "workspace/threads/handoff.json"))).toBe(true);
|
|
800
|
-
});
|
|
801
|
-
it("emits for the pointer's active continuity thread file", () => {
|
|
802
|
-
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-active-thread-")));
|
|
803
|
-
const thread = path.join(root, "workspace", "threads", "x.json");
|
|
804
|
-
try {
|
|
805
|
-
fs.mkdirSync(path.dirname(thread), { recursive: true });
|
|
806
|
-
fs.writeFileSync(thread, "{}");
|
|
807
|
-
fs.writeFileSync(path.join(root, "workspace", "threads", "handoff.json"), JSON.stringify({ thread_path: "workspace/threads/x.json" }));
|
|
808
|
-
const scoped = createWatchPathFilter(root, true);
|
|
809
|
-
expect(scoped(thread)).toBe(true);
|
|
810
|
-
}
|
|
811
|
-
finally {
|
|
812
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
813
|
-
}
|
|
814
|
-
});
|
|
815
|
-
it("covers every present personal-vault root, while preserving .hqignore exclusions", () => {
|
|
816
|
-
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-scope-invariant-")));
|
|
817
|
-
const activeThread = path.join(root, "workspace", "threads", "x.json");
|
|
818
|
-
try {
|
|
819
|
-
fs.mkdirSync(path.join(root, "personal"), { recursive: true });
|
|
820
|
-
fs.mkdirSync(path.join(root, ".codex"), { recursive: true });
|
|
821
|
-
fs.mkdirSync(path.join(root, "companies", "local"), { recursive: true });
|
|
822
|
-
fs.mkdirSync(path.join(root, "workspace", "agency", "team"), { recursive: true });
|
|
823
|
-
fs.mkdirSync(path.join(root, "workspace", ".session-logs"), { recursive: true });
|
|
824
|
-
fs.mkdirSync(path.dirname(activeThread), { recursive: true });
|
|
825
|
-
fs.writeFileSync(path.join(root, "personal", "note.md"), "note");
|
|
826
|
-
fs.writeFileSync(path.join(root, ".codex", "settings.json"), "{}");
|
|
827
|
-
fs.writeFileSync(path.join(root, "companies", "manifest.yaml"), "companies: {}\n");
|
|
828
|
-
fs.writeFileSync(path.join(root, "companies", "local", "note.md"), "local");
|
|
829
|
-
fs.writeFileSync(path.join(root, "workspace", "agency", "team", "state.json"), "{}");
|
|
830
|
-
fs.writeFileSync(path.join(root, "workspace", ".session-logs", "session.jsonl"), "{}\n");
|
|
831
|
-
fs.writeFileSync(activeThread, "{}");
|
|
832
|
-
fs.writeFileSync(path.join(root, "workspace", "threads", "handoff.json"), JSON.stringify({ thread_path: "workspace/threads/x.json" }));
|
|
833
|
-
fs.writeFileSync(path.join(root, ".hqignore"), "workspace/agency/ignored/\n");
|
|
834
|
-
const filter = createWatchPathFilter(root, true);
|
|
835
|
-
const uncovered = computePersonalVaultPaths(root).filter((scopeRoot) => !filter(scopeRoot, fs.lstatSync(scopeRoot).isDirectory()));
|
|
836
|
-
// Scope roots must be watched, unless a future change introduces an
|
|
837
|
-
// explicit, reasoned exclusion. There are deliberately no such
|
|
838
|
-
// exclusions in this representative personal scope.
|
|
839
|
-
expect(uncovered).toEqual([]);
|
|
840
|
-
expect(filter(path.join(root, "workspace", "agency", "ignored"), true)).toBe(false);
|
|
841
|
-
}
|
|
842
|
-
finally {
|
|
843
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
844
|
-
}
|
|
845
|
-
});
|
|
846
|
-
it("still does NOT emit for other workspace/ files (carve-out is pointer-only)", () => {
|
|
847
|
-
// The pointer is the ONLY re-include — sibling threads, the journal, and
|
|
848
|
-
// unrelated workspace litter stay machine-local.
|
|
849
|
-
expect(personal(path.join(ROOT, "workspace/threads/T-old.json"))).toBe(false);
|
|
850
|
-
expect(personal(path.join(ROOT, "workspace/threads/INDEX.md"))).toBe(false);
|
|
851
|
-
expect(personal(path.join(ROOT, "workspace/locks/x.lock"))).toBe(false);
|
|
852
|
-
// The pointer queried as a directory is not an emit target either.
|
|
853
|
-
expect(personal(path.join(ROOT, "workspace/threads/handoff.json"), true)).toBe(false);
|
|
854
|
-
});
|
|
855
|
-
it("allows chokidar to DESCEND through the pointer's ancestor dirs (dir probe)", () => {
|
|
856
|
-
// Without this the Linux chokidar backend prunes `workspace/` before ever
|
|
857
|
-
// reaching the leaf. Ancestor dirs return true ONLY when queried as dirs.
|
|
858
|
-
expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
|
|
859
|
-
expect(personal(path.join(ROOT, "workspace/threads"), true)).toBe(true);
|
|
860
|
-
// A non-ancestor dir under workspace/ is still pruned.
|
|
861
|
-
expect(personal(path.join(ROOT, "workspace/locks"), true)).toBe(false);
|
|
862
|
-
// Ancestor dirs queried as FILES are not emit targets.
|
|
863
|
-
expect(personal(path.join(ROOT, "workspace"), false)).toBe(false);
|
|
864
|
-
});
|
|
865
|
-
// ── workspace/agency subtree carve-out (personal mode) ──────────────────
|
|
866
|
-
// The personal push uploads workspace/agency/** (computeAgencySyncPaths), so
|
|
867
|
-
// the personal watcher must emit those changes event-driven, not leave them
|
|
868
|
-
// for the 60s poll. Mirrors the continuity-pointer carve-out, for a subtree.
|
|
869
|
-
it("DOES emit for workspace/agency files in personalMode (synced subtree)", () => {
|
|
870
|
-
expect(personal(path.join(ROOT, "workspace/agency/indigo/nick/chat.jsonl"))).toBe(true);
|
|
871
|
-
expect(personal(path.join(ROOT, "workspace/agency/indigo/nick/status.json"))).toBe(true);
|
|
872
|
-
// The agency root itself, as a file event, also emits.
|
|
873
|
-
expect(personal(path.join(ROOT, "workspace/agency"))).toBe(true);
|
|
874
|
-
});
|
|
875
|
-
it("allows chokidar to DESCEND to workspace/agency in personalMode (dir probes)", () => {
|
|
876
|
-
// workspace/ and workspace/agency queried as dirs must return true so the
|
|
877
|
-
// chokidar backend descends instead of pruning workspace/ at the top.
|
|
878
|
-
expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
|
|
879
|
-
expect(personal(path.join(ROOT, "workspace/agency"), true)).toBe(true);
|
|
880
|
-
expect(personal(path.join(ROOT, "workspace/agency/indigo"), true)).toBe(true);
|
|
881
|
-
});
|
|
882
|
-
it("agency carve-out is exact-segment (does NOT leak to workspace/agencyX)", () => {
|
|
883
|
-
// Only workspace/agency (and its subtree) is re-included; a sibling whose
|
|
884
|
-
// name merely starts with 'agency' stays excluded like other workspace litter.
|
|
885
|
-
expect(personal(path.join(ROOT, "workspace/agencyX/foo.txt"))).toBe(false);
|
|
886
|
-
expect(personal(path.join(ROOT, "workspace/agency-notes.md"))).toBe(false);
|
|
887
|
-
});
|
|
888
|
-
it("agency carve-out does not re-include other workspace/ litter", () => {
|
|
889
|
-
// Regression: the new carve-out must not widen the workspace exclusion for
|
|
890
|
-
// anything other than agency + the continuity pointer.
|
|
891
|
-
expect(personal(path.join(ROOT, "workspace/locks/x.lock"))).toBe(false);
|
|
892
|
-
expect(personal(path.join(ROOT, "workspace/threads/T-old.json"))).toBe(false);
|
|
893
|
-
expect(personal(path.join(ROOT, "workspace/worktrees/x/a.ts"))).toBe(false);
|
|
894
|
-
});
|
|
895
|
-
// ── workspace/.session-logs subtree carve-out (personal mode) ────────────
|
|
896
|
-
// The personal push uploads workspace/.session-logs/** (reindex-captured
|
|
897
|
-
// Claude Code transcripts, computeSessionLogsSyncPaths), so the personal
|
|
898
|
-
// watcher must emit those changes event-driven. Mirrors the agency carve-out.
|
|
899
|
-
it("DOES emit for workspace/.session-logs files in personalMode (synced subtree)", () => {
|
|
900
|
-
expect(personal(path.join(ROOT, "workspace/.session-logs/session.jsonl"))).toBe(true);
|
|
901
|
-
expect(personal(path.join(ROOT, "workspace/.session-logs/tool-results/r.txt"))).toBe(true);
|
|
902
|
-
// The subtree root itself, as a file event, also emits.
|
|
903
|
-
expect(personal(path.join(ROOT, "workspace/.session-logs"))).toBe(true);
|
|
904
|
-
});
|
|
905
|
-
it("allows chokidar to DESCEND to workspace/.session-logs in personalMode (dir probes)", () => {
|
|
906
|
-
expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
|
|
907
|
-
expect(personal(path.join(ROOT, "workspace/.session-logs"), true)).toBe(true);
|
|
908
|
-
expect(personal(path.join(ROOT, "workspace/.session-logs/tool-results"), true)).toBe(true);
|
|
909
|
-
});
|
|
910
|
-
it("session-logs carve-out is exact-segment (does NOT leak to a similarly-named sibling)", () => {
|
|
911
|
-
expect(personal(path.join(ROOT, "workspace/.session-logs-old/foo.txt"))).toBe(false);
|
|
912
|
-
expect(personal(path.join(ROOT, "workspace/.session-logsX.md"))).toBe(false);
|
|
913
|
-
});
|
|
914
|
-
it("does not apply the carve-out in non-personal mode", () => {
|
|
915
|
-
// In non-personal mode the excluded-top-level layer never runs, so the
|
|
916
|
-
// carve-out is irrelevant; handoff.json passes via the ordinary ignore
|
|
917
|
-
// stack like any other tracked file (no special-casing needed).
|
|
918
|
-
expect(nonPersonal(path.join(ROOT, "workspace/threads/handoff.json"))).toBe(true);
|
|
919
|
-
});
|
|
920
|
-
it("DOES emit for an included top-level personal path in personalMode", () => {
|
|
921
|
-
expect(personal(path.join(ROOT, "personal/notes.md"))).toBe(true);
|
|
922
|
-
expect(personal(path.join(ROOT, "core/policies/x.md"))).toBe(true);
|
|
923
|
-
});
|
|
924
|
-
it("non-personal mode does NOT apply the excluded-top-level buckets", () => {
|
|
925
|
-
// companies/ is only excluded by the personal-vault layer; in non-personal
|
|
926
|
-
// mode the ignore stack alone governs and lets it through.
|
|
927
|
-
expect(nonPersonal(path.join(ROOT, "companies/indigo/board.json"))).toBe(true);
|
|
928
|
-
});
|
|
929
|
-
// ── Never-synced heavy buckets pruned in BOTH modes ─────────────────────
|
|
930
|
-
// In --companies mode personalMode=false, so the excluded-top-level layer
|
|
931
|
-
// never ran and the watcher descended into the local git worktrees
|
|
932
|
-
// (workspace/worktrees/, ~200k transient files from agent coding runs) and
|
|
933
|
-
// the on-box repos checkout (repos/). Neither ever vault-syncs in any mode,
|
|
934
|
-
// so watching them only burns inotify watches (near the OS limit) + CPU on
|
|
935
|
-
// build churn it discards, and can trip the overflow→full-reconcile fallback.
|
|
936
|
-
it("non-personal mode EXCLUDES never-synced heavy buckets (workspace/worktrees, repos)", () => {
|
|
937
|
-
expect(nonPersonal(path.join(ROOT, "workspace/worktrees/hq-console/src/a.ts"))).toBe(false);
|
|
938
|
-
// The bucket dir itself is pruned so chokidar never descends into it.
|
|
939
|
-
expect(nonPersonal(path.join(ROOT, "workspace/worktrees"), true)).toBe(false);
|
|
940
|
-
expect(nonPersonal(path.join(ROOT, "repos/private/hq-cloud/src/x.ts"))).toBe(false);
|
|
941
|
-
expect(nonPersonal(path.join(ROOT, "repos"), true)).toBe(false);
|
|
942
|
-
});
|
|
943
|
-
it("non-personal mode STILL watches synced content (companies/, workspace/agency, continuity)", () => {
|
|
944
|
-
expect(nonPersonal(path.join(ROOT, "companies/indigo/board.json"))).toBe(true);
|
|
945
|
-
expect(nonPersonal(path.join(ROOT, "workspace/agency/indigo/nick/status.json"))).toBe(true);
|
|
946
|
-
expect(nonPersonal(path.join(ROOT, "workspace/threads/handoff.json"))).toBe(true);
|
|
947
|
-
// chokidar must still descend into workspace/ to reach agency + continuity.
|
|
948
|
-
expect(nonPersonal(path.join(ROOT, "workspace"), true)).toBe(true);
|
|
949
|
-
// A worktrees SIBLING under workspace/ is unaffected (only worktrees pruned).
|
|
950
|
-
expect(nonPersonal(path.join(ROOT, "workspace/agency"), true)).toBe(true);
|
|
951
|
-
});
|
|
952
|
-
it("combined personal + company mode retains company trees without admitting unrelated workspace churn", () => {
|
|
953
|
-
const combined = createWatchPathFilter(ROOT, true, {
|
|
954
|
-
includeCompanyPaths: true,
|
|
955
|
-
});
|
|
956
|
-
expect(combined(path.join(ROOT, "personal/rt-hour.md"))).toBe(true);
|
|
957
|
-
expect(combined(path.join(ROOT, "companies/indigo/board.json"))).toBe(true);
|
|
958
|
-
expect(combined(path.join(ROOT, "workspace/locks/sync.lock"))).toBe(false);
|
|
959
|
-
expect(combined(path.join(ROOT, "workspace/agency/indigo/chat.jsonl"))).toBe(true);
|
|
960
|
-
});
|
|
961
|
-
it("personal mode still excludes worktrees + repos (regression, unchanged)", () => {
|
|
962
|
-
expect(personal(path.join(ROOT, "workspace/worktrees/x/a.ts"))).toBe(false);
|
|
963
|
-
expect(personal(path.join(ROOT, "repos/private/x/a.ts"))).toBe(false);
|
|
964
|
-
});
|
|
965
|
-
});
|
|
966
|
-
describe("US-002: chokidar descent scope", () => {
|
|
967
|
-
let dir;
|
|
968
|
-
beforeEach(() => {
|
|
969
|
-
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-scope-")));
|
|
970
|
-
// This is the real HQ allowlist shape. The synthetic heavy directories
|
|
971
|
-
// deliberately sit both under an in-scope company and under the two
|
|
972
|
-
// never-synced top-level buckets from #156.
|
|
973
|
-
fs.writeFileSync(path.join(dir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
974
|
-
fs.mkdirSync(path.join(dir, "companies", "acme", "knowledge", "node_modules", "heavy"), { recursive: true });
|
|
975
|
-
fs.writeFileSync(path.join(dir, "companies", "acme", "knowledge", "node_modules", "heavy", "index.js"), "module.exports = 1;\n");
|
|
976
|
-
fs.mkdirSync(path.join(dir, "repos", "checkout", "heavy"), { recursive: true });
|
|
977
|
-
fs.mkdirSync(path.join(dir, "workspace", "worktrees", "repo", "node_modules", "heavy"), { recursive: true });
|
|
978
|
-
});
|
|
979
|
-
afterEach(() => {
|
|
980
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
981
|
-
});
|
|
982
|
-
it("defers a file-shaped pre-stat candidate to the stat-aware directory verdict", () => {
|
|
983
|
-
const filter = createWatchPathFilter(dir, false);
|
|
984
|
-
const nodeModules = path.join(dir, "companies", "acme", "knowledge", "node_modules");
|
|
985
|
-
// `node_modules/` is directory-only. A no-stat chokidar probe cannot know
|
|
986
|
-
// whether this is a new file or a directory, so it must survive long
|
|
987
|
-
// enough to receive the stat-aware verdict below.
|
|
988
|
-
expect(filter(nodeModules, false)).toBe(true);
|
|
989
|
-
expect(filter(nodeModules, true)).toBe(false);
|
|
990
|
-
const ignored = toChokidarIgnored(filter, dir);
|
|
991
|
-
expect(ignored(nodeModules)).toBe(false);
|
|
992
|
-
expect(ignored(nodeModules, fs.lstatSync(nodeModules))).toBe(true);
|
|
993
|
-
// The allowlist's exact ancestor matcher remains the only carve-out: the
|
|
994
|
-
// watcher can still reach companies/<slug>/knowledge without opening every
|
|
995
|
-
// otherwise-file-shaped ignored directory below it.
|
|
996
|
-
expect(ignored(path.join(dir, "companies"))).toBe(false);
|
|
997
|
-
expect(ignored(path.join(dir, "companies", "acme"))).toBe(false);
|
|
998
|
-
});
|
|
999
|
-
it("prunes ignored directories during chokidar's pre-stat descent probe", async () => {
|
|
1000
|
-
const filter = createWatchPathFilter(dir, false);
|
|
1001
|
-
const chokidarWatcher = watch(dir, {
|
|
1002
|
-
ignored: toChokidarIgnored(filter, dir),
|
|
1003
|
-
ignoreInitial: true,
|
|
1004
|
-
persistent: false,
|
|
1005
|
-
});
|
|
1006
|
-
try {
|
|
1007
|
-
await new Promise((resolve, reject) => {
|
|
1008
|
-
chokidarWatcher.once("ready", resolve);
|
|
1009
|
-
chokidarWatcher.once("error", reject);
|
|
1010
|
-
});
|
|
1011
|
-
const descended = Object.keys(chokidarWatcher.getWatched())
|
|
1012
|
-
.map((watchedPath) => path.resolve(watchedPath))
|
|
1013
|
-
.sort();
|
|
1014
|
-
expect(descended).toContain(path.join(dir, "companies", "acme", "knowledge"));
|
|
1015
|
-
expect(descended).not.toContain(path.join(dir, "companies", "acme", "knowledge", "node_modules"));
|
|
1016
|
-
expect(descended).not.toContain(path.join(dir, "repos"));
|
|
1017
|
-
expect(descended).not.toContain(path.join(dir, "workspace", "worktrees"));
|
|
1018
|
-
}
|
|
1019
|
-
finally {
|
|
1020
|
-
await chokidarWatcher.close();
|
|
1021
|
-
}
|
|
1022
|
-
});
|
|
1023
|
-
});
|
|
1024
|
-
describe("chokidar polling symlink boundaries", () => {
|
|
1025
|
-
async function waitFor(check) {
|
|
1026
|
-
const deadline = Date.now() + 6000;
|
|
1027
|
-
while (Date.now() < deadline) {
|
|
1028
|
-
if (check())
|
|
1029
|
-
return;
|
|
1030
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1031
|
-
}
|
|
1032
|
-
expect(check()).toBe(true);
|
|
1033
|
-
}
|
|
1034
|
-
async function waitReady(backend) {
|
|
1035
|
-
await new Promise((resolve, reject) => {
|
|
1036
|
-
if (!backend.onReady)
|
|
1037
|
-
return resolve();
|
|
1038
|
-
const timer = setTimeout(() => reject(new Error("watcher did not become ready")), 6000);
|
|
1039
|
-
backend.onReady(() => {
|
|
1040
|
-
clearTimeout(timer);
|
|
1041
|
-
resolve();
|
|
1042
|
-
});
|
|
1043
|
-
});
|
|
1044
|
-
}
|
|
1045
|
-
it("polls ordinary paths without descending or budgeting symlink targets, while reporting the link entry", async () => {
|
|
1046
|
-
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-link-root-")));
|
|
1047
|
-
const external = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-link-target-")));
|
|
1048
|
-
const link = path.join(root, ".agents");
|
|
1049
|
-
const events = [];
|
|
1050
|
-
const budgetExceeded = vi.fn();
|
|
1051
|
-
const errors = [];
|
|
1052
|
-
try {
|
|
1053
|
-
fs.mkdirSync(path.join(external, "many", "nested", "directories", "under", "the", "alias"), { recursive: true });
|
|
1054
|
-
fs.mkdirSync(path.join(root, ".codex"), { recursive: true });
|
|
1055
|
-
fs.writeFileSync(path.join(root, ".codex", "config.toml"), "model = 'test'\n");
|
|
1056
|
-
fs.mkdirSync(path.join(root, "workspace", "agency", "scratch"), { recursive: true });
|
|
1057
|
-
fs.writeFileSync(path.join(root, "workspace", "agency", "scratch", "notes.md"), "notes\n");
|
|
1058
|
-
fs.symlinkSync(external, link, "dir");
|
|
1059
|
-
const filter = createWatchPathFilter(root, true);
|
|
1060
|
-
expect(filter(path.join(root, ".codex", "config.toml"))).toBe(true);
|
|
1061
|
-
expect(filter(path.join(root, "workspace", "agency", "scratch", "notes.md"))).toBe(true);
|
|
1062
|
-
const backend = startTreeWatch(root, filter, (absolutePath, kind) => events.push({ path: absolutePath, kind }), (error) => errors.push(error), 7, budgetExceeded, undefined, true);
|
|
1063
|
-
try {
|
|
1064
|
-
await waitReady(backend);
|
|
1065
|
-
expect(backend.mode).toBe("polling");
|
|
1066
|
-
// Root + .codex + workspace/agency/scratch; the link target costs no watches.
|
|
1067
|
-
expect(backend.watchedPathCount()).toBe(5);
|
|
1068
|
-
expect(budgetExceeded).not.toHaveBeenCalled();
|
|
1069
|
-
expect(errors).toEqual([]);
|
|
1070
|
-
fs.unlinkSync(link);
|
|
1071
|
-
await waitFor(() => events.some((event) => event.path === link && event.kind === "unlink"));
|
|
1072
|
-
fs.symlinkSync(external, link, "dir");
|
|
1073
|
-
await waitFor(() => events.some((event) => event.path === link && event.kind === "add"));
|
|
1074
|
-
expect(backend.watchedPathCount()).toBe(5);
|
|
1075
|
-
}
|
|
1076
|
-
finally {
|
|
1077
|
-
backend.close();
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
finally {
|
|
1081
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
1082
|
-
fs.rmSync(external, { recursive: true, force: true });
|
|
1083
|
-
}
|
|
1084
|
-
});
|
|
1085
|
-
it("continues watching when the supplied HQ root is itself a symlink", async () => {
|
|
1086
|
-
const realRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-real-root-")));
|
|
1087
|
-
const parent = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-root-link-")));
|
|
1088
|
-
const linkedRoot = path.join(parent, "hq");
|
|
1089
|
-
const events = [];
|
|
1090
|
-
const errors = [];
|
|
1091
|
-
fs.mkdirSync(path.join(realRoot, "workspace", "agency", "scratch"), { recursive: true });
|
|
1092
|
-
fs.symlinkSync(realRoot, linkedRoot, "dir");
|
|
1093
|
-
const filter = createWatchPathFilter(linkedRoot, true);
|
|
1094
|
-
const backend = startTreeWatch(linkedRoot, filter, (absolutePath) => events.push(absolutePath), (error) => errors.push(error), 20, () => { }, undefined, true);
|
|
1095
|
-
try {
|
|
1096
|
-
await waitReady(backend);
|
|
1097
|
-
const file = path.join(linkedRoot, "workspace", "agency", "scratch", "after-ready.md");
|
|
1098
|
-
fs.writeFileSync(file, "observed\n");
|
|
1099
|
-
await waitFor(() => events.includes(file));
|
|
1100
|
-
expect(errors).toEqual([]);
|
|
1101
|
-
}
|
|
1102
|
-
finally {
|
|
1103
|
-
backend.close();
|
|
1104
|
-
fs.rmSync(linkedRoot, { recursive: true, force: true });
|
|
1105
|
-
fs.rmSync(parent, { recursive: true, force: true });
|
|
1106
|
-
fs.rmSync(realRoot, { recursive: true, force: true });
|
|
1107
|
-
}
|
|
1108
|
-
});
|
|
1109
|
-
});
|
|
1110
|
-
describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
|
|
1111
|
-
function makeWatcher(opts) {
|
|
1112
|
-
const clock = new FakeClock();
|
|
1113
|
-
const changed = vi.fn();
|
|
1114
|
-
const watcher = new TreeWatcher({
|
|
1115
|
-
hqRoot: ROOT,
|
|
1116
|
-
debounceMs: opts?.debounceMs ?? DEBOUNCE,
|
|
1117
|
-
personalMode: opts?.personalMode ?? false,
|
|
1118
|
-
clock,
|
|
1119
|
-
});
|
|
1120
|
-
watcher.onChange(changed);
|
|
1121
|
-
return { clock, changed, watcher };
|
|
1122
|
-
}
|
|
1123
|
-
it("coalesces 5 rapid changes within the window into exactly 1 changed signal", () => {
|
|
1124
|
-
const { clock, changed, watcher } = makeWatcher();
|
|
1125
|
-
for (let i = 0; i < 5; i++) {
|
|
1126
|
-
watcher.handleEvent(path.join(ROOT, `personal/file-${i}.md`));
|
|
1127
|
-
clock.advance(20); // 100ms total << 2000ms debounce
|
|
1128
|
-
}
|
|
1129
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1130
|
-
clock.advance(DEBOUNCE);
|
|
1131
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1132
|
-
expect(watcher.pendingTimerCount()).toBe(0);
|
|
1133
|
-
});
|
|
1134
|
-
it("fires once after the quiet window for a single change", () => {
|
|
1135
|
-
const { clock, changed, watcher } = makeWatcher();
|
|
1136
|
-
watcher.handleEvent(path.join(ROOT, "personal/a.md"));
|
|
1137
|
-
clock.advance(DEBOUNCE - 1);
|
|
1138
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1139
|
-
clock.advance(1);
|
|
1140
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1141
|
-
});
|
|
1142
|
-
it("flushes a quiet, known-small batch through the 60s debounce window", () => {
|
|
1143
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-small-debounce-"));
|
|
1144
|
-
try {
|
|
1145
|
-
const small = path.join(dir, "small.md");
|
|
1146
|
-
fs.writeFileSync(small, "sixteen-byte-doc");
|
|
1147
|
-
const clock = new FakeClock();
|
|
1148
|
-
const changed = vi.fn();
|
|
1149
|
-
const watcher = new TreeWatcher({
|
|
1150
|
-
hqRoot: dir,
|
|
1151
|
-
debounceMs: 60_000,
|
|
1152
|
-
smallBatchDebounceMs: 60_000,
|
|
1153
|
-
smallBatchMaxPaths: 8,
|
|
1154
|
-
smallBatchMaxBytes: 1024 * 1024,
|
|
1155
|
-
clock,
|
|
1156
|
-
pathFilter: () => true,
|
|
1157
|
-
});
|
|
1158
|
-
watcher.onChange(changed);
|
|
1159
|
-
watcher.handleEvent(small);
|
|
1160
|
-
// The old 2.5s small-batch fast path must no longer fire.
|
|
1161
|
-
clock.advance(2_500);
|
|
1162
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1163
|
-
clock.advance(57_499);
|
|
1164
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1165
|
-
clock.advance(1);
|
|
1166
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1167
|
-
expect(changed.mock.calls[0][1].paths.get(small)).toBe("small.md");
|
|
1168
|
-
watcher.dispose();
|
|
1169
|
-
}
|
|
1170
|
-
finally {
|
|
1171
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1172
|
-
}
|
|
1173
|
-
});
|
|
1174
|
-
it("keeps a burst above the small-path cap on the 60s debounce", () => {
|
|
1175
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-burst-debounce-"));
|
|
1176
|
-
try {
|
|
1177
|
-
const clock = new FakeClock();
|
|
1178
|
-
const changed = vi.fn();
|
|
1179
|
-
const watcher = new TreeWatcher({
|
|
1180
|
-
hqRoot: dir,
|
|
1181
|
-
debounceMs: 60_000,
|
|
1182
|
-
smallBatchDebounceMs: 60_000,
|
|
1183
|
-
smallBatchMaxPaths: 8,
|
|
1184
|
-
smallBatchMaxBytes: 1024 * 1024,
|
|
1185
|
-
clock,
|
|
1186
|
-
pathFilter: () => true,
|
|
1187
|
-
});
|
|
1188
|
-
watcher.onChange(changed);
|
|
1189
|
-
for (let index = 0; index < 9; index += 1) {
|
|
1190
|
-
const file = path.join(dir, `burst-${index}.md`);
|
|
1191
|
-
fs.writeFileSync(file, "small");
|
|
1192
|
-
watcher.handleEvent(file);
|
|
1193
|
-
clock.advance(100);
|
|
1194
|
-
}
|
|
1195
|
-
// The final event landed at t=800ms; the loop's final 100ms advance is
|
|
1196
|
-
// outside the quiet window, so stop one millisecond before t=60_800ms.
|
|
1197
|
-
clock.advance(59_899);
|
|
1198
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1199
|
-
clock.advance(1);
|
|
1200
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1201
|
-
expect(changed.mock.calls[0][1].paths.size).toBe(9);
|
|
1202
|
-
watcher.dispose();
|
|
1203
|
-
}
|
|
1204
|
-
finally {
|
|
1205
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1206
|
-
}
|
|
1207
|
-
});
|
|
1208
|
-
it("preserves max-wait for a continuous small batch that never becomes quiet", () => {
|
|
1209
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-small-max-wait-"));
|
|
1210
|
-
try {
|
|
1211
|
-
const file = path.join(dir, "continuous.md");
|
|
1212
|
-
fs.writeFileSync(file, "small");
|
|
1213
|
-
const clock = new FakeClock();
|
|
1214
|
-
const changed = vi.fn();
|
|
1215
|
-
const watcher = new TreeWatcher({
|
|
1216
|
-
hqRoot: dir,
|
|
1217
|
-
debounceMs: 60_000,
|
|
1218
|
-
maxWaitMs: 120_000,
|
|
1219
|
-
smallBatchDebounceMs: 60_000,
|
|
1220
|
-
smallBatchMaxPaths: 8,
|
|
1221
|
-
smallBatchMaxBytes: 1024 * 1024,
|
|
1222
|
-
clock,
|
|
1223
|
-
pathFilter: () => true,
|
|
1224
|
-
});
|
|
1225
|
-
watcher.onChange(changed);
|
|
1226
|
-
for (let elapsed = 0; elapsed < 120_000; elapsed += 2_000) {
|
|
1227
|
-
watcher.handleEvent(file);
|
|
1228
|
-
if (elapsed < 118_000)
|
|
1229
|
-
clock.advance(2_000);
|
|
1230
|
-
}
|
|
1231
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1232
|
-
clock.advance(2_000);
|
|
1233
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1234
|
-
watcher.dispose();
|
|
1235
|
-
}
|
|
1236
|
-
finally {
|
|
1237
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1238
|
-
}
|
|
1239
|
-
});
|
|
1240
|
-
it("keeps a duplicate directory delete out of the short debounce lane", () => {
|
|
1241
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-delete-debounce-"));
|
|
1242
|
-
try {
|
|
1243
|
-
const deleted = path.join(dir, "deleted-directory");
|
|
1244
|
-
const clock = new FakeClock();
|
|
1245
|
-
const changed = vi.fn();
|
|
1246
|
-
const watcher = new TreeWatcher({
|
|
1247
|
-
hqRoot: dir,
|
|
1248
|
-
debounceMs: 60_000,
|
|
1249
|
-
smallBatchDebounceMs: 60_000,
|
|
1250
|
-
smallBatchMaxPaths: 8,
|
|
1251
|
-
smallBatchMaxBytes: 1024 * 1024,
|
|
1252
|
-
clock,
|
|
1253
|
-
pathFilter: () => true,
|
|
1254
|
-
});
|
|
1255
|
-
watcher.onChange(changed);
|
|
1256
|
-
watcher.handleEvent(deleted, "unlinkDir");
|
|
1257
|
-
watcher.handleEvent(deleted, "unlink");
|
|
1258
|
-
clock.advance(59_999);
|
|
1259
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1260
|
-
clock.advance(1);
|
|
1261
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1262
|
-
expect(changed.mock.calls[0][1].changes.get(deleted)).toMatchObject({
|
|
1263
|
-
kind: "unlinkDir",
|
|
1264
|
-
});
|
|
1265
|
-
watcher.dispose();
|
|
1266
|
-
}
|
|
1267
|
-
finally {
|
|
1268
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1269
|
-
}
|
|
1270
|
-
});
|
|
1271
|
-
it("keeps an overflowed batch out of the short debounce lane", () => {
|
|
1272
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-overflow-debounce-"));
|
|
1273
|
-
try {
|
|
1274
|
-
const first = path.join(dir, "first.md");
|
|
1275
|
-
const dropped = path.join(dir, "dropped.md");
|
|
1276
|
-
fs.writeFileSync(first, "small");
|
|
1277
|
-
fs.writeFileSync(dropped, "small");
|
|
1278
|
-
const clock = new FakeClock();
|
|
1279
|
-
const changed = vi.fn();
|
|
1280
|
-
const watcher = new TreeWatcher({
|
|
1281
|
-
hqRoot: dir,
|
|
1282
|
-
debounceMs: 60_000,
|
|
1283
|
-
smallBatchDebounceMs: 60_000,
|
|
1284
|
-
smallBatchMaxPaths: 8,
|
|
1285
|
-
smallBatchMaxBytes: 1024 * 1024,
|
|
1286
|
-
maxPendingPaths: 1,
|
|
1287
|
-
maxPendingBytes: 1024 * 1024,
|
|
1288
|
-
clock,
|
|
1289
|
-
pathFilter: () => true,
|
|
1290
|
-
onBacklogOverflow: () => undefined,
|
|
1291
|
-
});
|
|
1292
|
-
watcher.onChange(changed);
|
|
1293
|
-
watcher.handleEvent(first);
|
|
1294
|
-
watcher.handleEvent(dropped);
|
|
1295
|
-
clock.advance(59_999);
|
|
1296
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1297
|
-
clock.advance(1);
|
|
1298
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1299
|
-
expect(changed.mock.calls[0][1]).toMatchObject({ overflowed: true });
|
|
1300
|
-
watcher.dispose();
|
|
1301
|
-
}
|
|
1302
|
-
finally {
|
|
1303
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1304
|
-
}
|
|
1305
|
-
});
|
|
1306
|
-
it("does NOT emit for an ignored / excluded path", () => {
|
|
1307
|
-
const { clock, changed, watcher } = makeWatcher({ personalMode: true });
|
|
1308
|
-
watcher.handleEvent(path.join(ROOT, ".env"));
|
|
1309
|
-
watcher.handleEvent(path.join(ROOT, "personal/output/big.bin"));
|
|
1310
|
-
watcher.handleEvent(path.join(ROOT, "companies/indigo/board.json"));
|
|
1311
|
-
clock.advance(DEBOUNCE * 2);
|
|
1312
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1313
|
-
expect(watcher.pendingTimerCount()).toBe(0);
|
|
1314
|
-
});
|
|
1315
|
-
it("max-wait ceiling: a continuous event stream still emits one coalesced batch", () => {
|
|
1316
|
-
const clock = new FakeClock();
|
|
1317
|
-
const changed = vi.fn();
|
|
1318
|
-
const watcher = new TreeWatcher({
|
|
1319
|
-
hqRoot: ROOT,
|
|
1320
|
-
debounceMs: 60_000,
|
|
1321
|
-
maxWaitMs: 120_000,
|
|
1322
|
-
personalMode: false,
|
|
1323
|
-
clock,
|
|
1324
|
-
});
|
|
1325
|
-
watcher.onChange(changed);
|
|
1326
|
-
// Events every 10s — the quiet window (60s) never elapses, so only the
|
|
1327
|
-
// max-wait ceiling can fire. Events land at t=0s..110s.
|
|
1328
|
-
for (let i = 0; i < 12; i++) {
|
|
1329
|
-
watcher.handleEvent(path.join(ROOT, `personal/file-${i % 3}.md`));
|
|
1330
|
-
if (i < 11)
|
|
1331
|
-
clock.advance(10_000);
|
|
1332
|
-
}
|
|
1333
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1334
|
-
clock.advance(10_000); // reaches t=120s — the ceiling since the FIRST event
|
|
1335
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1336
|
-
// One coalesced batch carrying every distinct path of the stream.
|
|
1337
|
-
const batch = changed.mock.calls[0][1];
|
|
1338
|
-
expect([...batch.paths.values()].sort()).toEqual([
|
|
1339
|
-
"personal/file-0.md",
|
|
1340
|
-
"personal/file-1.md",
|
|
1341
|
-
"personal/file-2.md",
|
|
1342
|
-
]);
|
|
1343
|
-
// The ceiling resets between windows: a fresh burst gets a fresh window
|
|
1344
|
-
// and settles on the ordinary quiet period.
|
|
1345
|
-
watcher.handleEvent(path.join(ROOT, "personal/next.md"));
|
|
1346
|
-
clock.advance(60_000);
|
|
1347
|
-
expect(changed).toHaveBeenCalledTimes(2);
|
|
1348
|
-
expect(watcher.pendingTimerCount()).toBe(0);
|
|
1349
|
-
watcher.dispose();
|
|
1350
|
-
});
|
|
1351
|
-
it("max-wait ceiling never fires before one quiet window (floored at debounceMs)", () => {
|
|
1352
|
-
const clock = new FakeClock();
|
|
1353
|
-
const changed = vi.fn();
|
|
1354
|
-
const watcher = new TreeWatcher({
|
|
1355
|
-
hqRoot: ROOT,
|
|
1356
|
-
debounceMs: 60_000,
|
|
1357
|
-
maxWaitMs: 1, // absurdly low — must be floored to the quiet window
|
|
1358
|
-
personalMode: false,
|
|
1359
|
-
clock,
|
|
1360
|
-
});
|
|
1361
|
-
watcher.onChange(changed);
|
|
1362
|
-
watcher.handleEvent(path.join(ROOT, "personal/a.md"));
|
|
1363
|
-
clock.advance(59_999);
|
|
1364
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1365
|
-
clock.advance(1);
|
|
1366
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1367
|
-
watcher.dispose();
|
|
1368
|
-
});
|
|
1369
|
-
it("without maxWaitMs the legacy pure quiet-window behavior is preserved", () => {
|
|
1370
|
-
const { clock, changed, watcher } = makeWatcher();
|
|
1371
|
-
// Keep resetting the quiet window far past any would-be ceiling.
|
|
1372
|
-
for (let i = 0; i < 200; i++) {
|
|
1373
|
-
watcher.handleEvent(path.join(ROOT, "personal/a.md"));
|
|
1374
|
-
clock.advance(DEBOUNCE - 1);
|
|
1375
|
-
}
|
|
1376
|
-
expect(changed).not.toHaveBeenCalled();
|
|
1377
|
-
clock.advance(DEBOUNCE);
|
|
1378
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1379
|
-
});
|
|
1380
|
-
it("treats two separate bursts as two changed signals", () => {
|
|
1381
|
-
const { clock, changed, watcher } = makeWatcher();
|
|
1382
|
-
watcher.handleEvent(path.join(ROOT, "personal/a.md"));
|
|
1383
|
-
clock.advance(DEBOUNCE);
|
|
1384
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1385
|
-
watcher.handleEvent(path.join(ROOT, "personal/b.md"));
|
|
1386
|
-
clock.advance(DEBOUNCE);
|
|
1387
|
-
expect(changed).toHaveBeenCalledTimes(2);
|
|
1388
|
-
});
|
|
1389
|
-
it("R-F13: caps watcher backlog and keeps EMFILE polling fallback removed", () => {
|
|
1390
|
-
const clock = new FakeClock();
|
|
1391
|
-
const changed = vi.fn();
|
|
1392
|
-
const overflow = vi.fn();
|
|
1393
|
-
const watcher = new TreeWatcher({
|
|
1394
|
-
hqRoot: ROOT,
|
|
1395
|
-
debounceMs: DEBOUNCE,
|
|
1396
|
-
clock,
|
|
1397
|
-
pathFilter: () => true,
|
|
1398
|
-
maxPendingPaths: 3,
|
|
1399
|
-
maxPendingBytes: 10_000,
|
|
1400
|
-
onBacklogOverflow: overflow,
|
|
1401
|
-
});
|
|
1402
|
-
watcher.onChange(changed);
|
|
1403
|
-
for (let i = 0; i < 10; i++) {
|
|
1404
|
-
watcher.handleEvent(path.join(ROOT, `bulk-${i}.md`));
|
|
1405
|
-
}
|
|
1406
|
-
expect(overflow).toHaveBeenCalledTimes(1);
|
|
1407
|
-
clock.advance(DEBOUNCE);
|
|
1408
|
-
expect(changed).toHaveBeenCalledTimes(1);
|
|
1409
|
-
const batch = changed.mock.calls[0][1];
|
|
1410
|
-
expect(batch.paths.size).toBe(3);
|
|
1411
|
-
expect(batch.overflowed).toBe(true);
|
|
1412
|
-
expect(batch.droppedPaths).toBe(7);
|
|
1413
|
-
const source = fs.readFileSync(path.join(process.cwd(), "src/watcher.ts"), "utf8");
|
|
1414
|
-
expect(source).not.toContain("startPollingTreeWatch");
|
|
1415
|
-
expect(source).not.toContain("snapshotWatchTree");
|
|
1416
|
-
});
|
|
1417
|
-
it("overflow records one dropped-route hint per touched route so the drain can stay targeted", () => {
|
|
1418
|
-
const clock = new FakeClock();
|
|
1419
|
-
const changed = vi.fn();
|
|
1420
|
-
const watcher = new TreeWatcher({
|
|
1421
|
-
hqRoot: ROOT,
|
|
1422
|
-
debounceMs: DEBOUNCE,
|
|
1423
|
-
clock,
|
|
1424
|
-
pathFilter: () => true,
|
|
1425
|
-
maxPendingPaths: 2,
|
|
1426
|
-
maxPendingBytes: 10_000,
|
|
1427
|
-
onBacklogOverflow: () => { },
|
|
1428
|
-
});
|
|
1429
|
-
watcher.onChange(changed);
|
|
1430
|
-
// Two retained, then drops spanning two company routes + personal.
|
|
1431
|
-
watcher.handleEvent(path.join(ROOT, "companies", "indigo", "kept-a.md"));
|
|
1432
|
-
watcher.handleEvent(path.join(ROOT, "companies", "indigo", "kept-b.md"));
|
|
1433
|
-
watcher.handleEvent(path.join(ROOT, "companies", "indigo", "dropped-1.md"));
|
|
1434
|
-
watcher.handleEvent(path.join(ROOT, "companies", "acme", "dropped-2.md"));
|
|
1435
|
-
watcher.handleEvent(path.join(ROOT, "notes", "dropped-3.md"));
|
|
1436
|
-
clock.advance(DEBOUNCE);
|
|
1437
|
-
const batch = changed.mock.calls[0][1];
|
|
1438
|
-
expect(batch.overflowed).toBe(true);
|
|
1439
|
-
expect(batch.droppedPaths).toBe(3);
|
|
1440
|
-
// One representative relative path per route (companies/indigo,
|
|
1441
|
-
// companies/acme, personal) — enough to reconstruct the touched-route
|
|
1442
|
-
// set without holding the dropped paths themselves.
|
|
1443
|
-
expect(batch.droppedRouteHints?.sort()).toEqual([
|
|
1444
|
-
"companies/acme/dropped-2.md",
|
|
1445
|
-
"companies/indigo/dropped-1.md",
|
|
1446
|
-
"notes/dropped-3.md",
|
|
1447
|
-
]);
|
|
1448
|
-
// Next window starts clean: a non-overflow batch carries no hints.
|
|
1449
|
-
changed.mockClear();
|
|
1450
|
-
watcher.handleEvent(path.join(ROOT, "companies", "indigo", "later.md"));
|
|
1451
|
-
clock.advance(DEBOUNCE);
|
|
1452
|
-
const nextBatch = changed.mock.calls[0][1];
|
|
1453
|
-
expect(nextBatch.overflowed).toBeUndefined();
|
|
1454
|
-
expect(nextBatch.droppedRouteHints).toBeUndefined();
|
|
1455
|
-
});
|
|
1456
|
-
it("stops descending at the hard watch budget, reports, and degrades to polling", () => {
|
|
1457
|
-
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-cap-")));
|
|
1458
|
-
fs.mkdirSync(path.join(dir, "personal", "a", "deep"), { recursive: true });
|
|
1459
|
-
fs.mkdirSync(path.join(dir, "personal", "b", "deep"), { recursive: true });
|
|
1460
|
-
const clock = new FakeClock();
|
|
1461
|
-
const degraded = vi.fn();
|
|
1462
|
-
const changed = vi.fn();
|
|
1463
|
-
const descended = [];
|
|
1464
|
-
let backendClosed = false;
|
|
1465
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
1466
|
-
try {
|
|
1467
|
-
const watcher = new TreeWatcher({
|
|
1468
|
-
hqRoot: dir,
|
|
1469
|
-
clock,
|
|
1470
|
-
debounceMs: DEBOUNCE,
|
|
1471
|
-
maxWatchedPaths: 3,
|
|
1472
|
-
onDegraded: degraded,
|
|
1473
|
-
// This faked backend drives chokidar's real ignored predicate through
|
|
1474
|
-
// its pre-stat and post-stat calls, without needing an inotify slot on
|
|
1475
|
-
// a host already exhausted by another process.
|
|
1476
|
-
backendFactory: ({ hqRoot, shouldEmit, maxWatchedPaths, onWatchBudgetExceeded, }) => {
|
|
1477
|
-
const budget = new ChokidarWatchBudget(maxWatchedPaths, onWatchBudgetExceeded);
|
|
1478
|
-
const ignored = toChokidarIgnored(shouldEmit, hqRoot, budget);
|
|
1479
|
-
const visit = (candidate) => {
|
|
1480
|
-
if (ignored(candidate))
|
|
1481
|
-
return;
|
|
1482
|
-
const stats = fs.lstatSync(candidate);
|
|
1483
|
-
if (ignored(candidate, stats))
|
|
1484
|
-
return;
|
|
1485
|
-
if (!stats.isDirectory())
|
|
1486
|
-
return;
|
|
1487
|
-
descended.push(path.resolve(candidate));
|
|
1488
|
-
for (const entry of fs.readdirSync(candidate)) {
|
|
1489
|
-
visit(path.join(candidate, entry));
|
|
1490
|
-
}
|
|
1491
|
-
};
|
|
1492
|
-
visit(hqRoot);
|
|
1493
|
-
return {
|
|
1494
|
-
close: () => {
|
|
1495
|
-
backendClosed = true;
|
|
1496
|
-
},
|
|
1497
|
-
needsKnownKinds: false,
|
|
1498
|
-
watchedPathCount: () => descended.length,
|
|
1499
|
-
};
|
|
1500
|
-
},
|
|
1501
|
-
});
|
|
1502
|
-
watcher.onChange(changed);
|
|
1503
|
-
watcher.start();
|
|
1504
|
-
expect(descended).toHaveLength(3);
|
|
1505
|
-
expect(descended).not.toContain(path.join(dir, "personal", "a", "deep"));
|
|
1506
|
-
expect(degraded).toHaveBeenCalledWith(expect.objectContaining({
|
|
1507
|
-
reason: "watch_budget_exhausted",
|
|
1508
|
-
maxWatchedPaths: 3,
|
|
1509
|
-
watchedPaths: 3,
|
|
1510
|
-
offendingPath: expect.stringContaining(path.join("personal", "a", "deep")),
|
|
1511
|
-
}));
|
|
1512
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining('"type":"watch-scope-excluded"'));
|
|
1513
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining('"excludedRoot"'));
|
|
1514
|
-
expect(backendClosed).toBe(true);
|
|
1515
|
-
expect(watcher.isWatching()).toBe(false);
|
|
1516
|
-
clock.advance(DEBOUNCE);
|
|
1517
|
-
expect(changed).toHaveBeenCalledWith(undefined, expect.objectContaining({ overflowed: true }));
|
|
1518
|
-
}
|
|
1519
|
-
finally {
|
|
1520
|
-
warn.mockRestore();
|
|
1521
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1522
|
-
}
|
|
1523
|
-
});
|
|
1524
|
-
it.each(["ENOSPC", "EMFILE", "ENFILE"])("keeps event watching through polling when a backend emits runtime %s", (errorCode) => {
|
|
1525
|
-
const clock = new FakeClock();
|
|
1526
|
-
const degraded = vi.fn();
|
|
1527
|
-
const changed = vi.fn();
|
|
1528
|
-
const telemetry = [];
|
|
1529
|
-
const telemetryClient = {
|
|
1530
|
-
postTelemetryEvents: vi.fn(async (batch) => {
|
|
1531
|
-
telemetry.push(batch);
|
|
1532
|
-
return { ok: true, written: batch.events.length, skipped: [] };
|
|
1533
|
-
}),
|
|
1534
|
-
};
|
|
1535
|
-
let closeCalls = 0;
|
|
1536
|
-
const watcher = new TreeWatcher({
|
|
1537
|
-
hqRoot: ROOT,
|
|
1538
|
-
clock,
|
|
1539
|
-
debounceMs: DEBOUNCE,
|
|
1540
|
-
onDegraded: degraded,
|
|
1541
|
-
telemetryClient,
|
|
1542
|
-
backendFactory: ({ forcePolling, onError }) => {
|
|
1543
|
-
const backend = {
|
|
1544
|
-
close: () => {
|
|
1545
|
-
closeCalls += 1;
|
|
1546
|
-
},
|
|
1547
|
-
needsKnownKinds: false,
|
|
1548
|
-
mode: forcePolling ? "polling" : "chokidar",
|
|
1549
|
-
watchedPathCount: () => forcePolling ? 200 : 0,
|
|
1550
|
-
};
|
|
1551
|
-
if (!forcePolling) {
|
|
1552
|
-
queueMicrotask(() => onError(Object.assign(new Error("watch resource limit"), {
|
|
1553
|
-
code: errorCode,
|
|
1554
|
-
path: path.join(ROOT, "companies", "acme", "knowledge"),
|
|
1555
|
-
})));
|
|
1556
|
-
}
|
|
1557
|
-
return backend;
|
|
1558
|
-
},
|
|
1559
|
-
});
|
|
1560
|
-
watcher.onChange(changed);
|
|
1561
|
-
watcher.start();
|
|
1562
|
-
return new Promise((resolve) => queueMicrotask(resolve)).then(() => {
|
|
1563
|
-
expect(closeCalls).toBe(1);
|
|
1564
|
-
expect(watcher.isWatching()).toBe(true);
|
|
1565
|
-
expect(watcher.watchMode()).toBe("polling");
|
|
1566
|
-
expect(watcher.watchedPathCount()).toBe(200);
|
|
1567
|
-
const reason = errorCode === "ENOSPC" ? "inotify_enospc" : "watch_resource_exhausted";
|
|
1568
|
-
expect(degraded).toHaveBeenCalledWith(expect.objectContaining({
|
|
1569
|
-
reason,
|
|
1570
|
-
errorCode,
|
|
1571
|
-
offendingPath: path.join(ROOT, "companies", "acme", "knowledge"),
|
|
1572
|
-
}));
|
|
1573
|
-
expect(telemetry).toEqual([
|
|
1574
|
-
expect.objectContaining({
|
|
1575
|
-
events: [
|
|
1576
|
-
expect.objectContaining({
|
|
1577
|
-
eventName: "watcher_degraded",
|
|
1578
|
-
source: "watcher",
|
|
1579
|
-
properties: expect.objectContaining({
|
|
1580
|
-
reason,
|
|
1581
|
-
offendingTopLevel: "companies",
|
|
1582
|
-
}),
|
|
1583
|
-
}),
|
|
1584
|
-
],
|
|
1585
|
-
}),
|
|
1586
|
-
]);
|
|
1587
|
-
clock.advance(DEBOUNCE);
|
|
1588
|
-
expect(changed).toHaveBeenCalledWith(undefined, expect.objectContaining({ overflowed: true }));
|
|
1589
|
-
});
|
|
1590
|
-
});
|
|
1591
|
-
it("keeps a large synthetic watch set alive across multiple debounced passes", () => {
|
|
1592
|
-
const clock = new FakeClock();
|
|
1593
|
-
const changed = vi.fn();
|
|
1594
|
-
const handles = new Set(Array.from({ length: 20_000 }, (_, i) => `dir-${i}`));
|
|
1595
|
-
const watcher = new TreeWatcher({
|
|
1596
|
-
hqRoot: ROOT,
|
|
1597
|
-
clock,
|
|
1598
|
-
debounceMs: DEBOUNCE,
|
|
1599
|
-
backendFactory: () => ({
|
|
1600
|
-
close: () => handles.clear(),
|
|
1601
|
-
mode: "chokidar",
|
|
1602
|
-
needsKnownKinds: false,
|
|
1603
|
-
watchedPathCount: () => handles.size,
|
|
1604
|
-
}),
|
|
1605
|
-
});
|
|
1606
|
-
watcher.onChange(changed);
|
|
1607
|
-
watcher.start();
|
|
1608
|
-
for (let pass = 0; pass < 3; pass += 1) {
|
|
1609
|
-
watcher.handleEvent(path.join(ROOT, "personal", `change-${pass}.md`));
|
|
1610
|
-
clock.advance(DEBOUNCE);
|
|
1611
|
-
expect(watcher.isWatching()).toBe(true);
|
|
1612
|
-
expect(watcher.watchedPathCount()).toBe(20_000);
|
|
1613
|
-
}
|
|
1614
|
-
expect(changed).toHaveBeenCalledTimes(3);
|
|
1615
|
-
watcher.dispose();
|
|
1616
|
-
});
|
|
1617
|
-
it("continues initial discovery while the watched-path count is still growing", () => {
|
|
1618
|
-
const clock = new FakeClock();
|
|
1619
|
-
const degraded = vi.fn();
|
|
1620
|
-
let watchedPaths = 0;
|
|
1621
|
-
const watcher = new TreeWatcher({
|
|
1622
|
-
hqRoot: ROOT,
|
|
1623
|
-
clock,
|
|
1624
|
-
discoveryTimeoutMs: 100,
|
|
1625
|
-
onDegraded: degraded,
|
|
1626
|
-
backendFactory: () => ({
|
|
1627
|
-
close: () => { },
|
|
1628
|
-
mode: "chokidar",
|
|
1629
|
-
needsKnownKinds: false,
|
|
1630
|
-
watchedPathCount: () => watchedPaths,
|
|
1631
|
-
// Deliberately never ready: this models a large, still-discovering tree.
|
|
1632
|
-
onReady: () => () => { },
|
|
1633
|
-
}),
|
|
1634
|
-
});
|
|
1635
|
-
watcher.start();
|
|
1636
|
-
watchedPaths = 9_220;
|
|
1637
|
-
clock.advance(100);
|
|
1638
|
-
watchedPaths = 14_588;
|
|
1639
|
-
clock.advance(100);
|
|
1640
|
-
expect(watcher.isWatching()).toBe(true);
|
|
1641
|
-
expect(watcher.watchMode()).toBe("chokidar");
|
|
1642
|
-
expect(degraded).not.toHaveBeenCalled();
|
|
1643
|
-
watcher.dispose();
|
|
1644
|
-
});
|
|
1645
|
-
it("stands down loudly when polling discovery also times out", () => {
|
|
1646
|
-
const clock = new FakeClock();
|
|
1647
|
-
const degraded = vi.fn();
|
|
1648
|
-
let closeCalls = 0;
|
|
1649
|
-
const watcher = new TreeWatcher({
|
|
1650
|
-
hqRoot: ROOT,
|
|
1651
|
-
clock,
|
|
1652
|
-
discoveryTimeoutMs: 100,
|
|
1653
|
-
onDegraded: degraded,
|
|
1654
|
-
backendFactory: ({ forcePolling }) => ({
|
|
1655
|
-
close: () => { closeCalls += 1; },
|
|
1656
|
-
mode: forcePolling ? "polling" : "chokidar",
|
|
1657
|
-
needsKnownKinds: false,
|
|
1658
|
-
watchedPathCount: () => forcePolling ? 20 : 10,
|
|
1659
|
-
// Deliberately never invokes its listener: this simulates discovery
|
|
1660
|
-
// that hangs before chokidar can report ready.
|
|
1661
|
-
onReady: () => () => { },
|
|
1662
|
-
}),
|
|
1663
|
-
});
|
|
1664
|
-
watcher.start();
|
|
1665
|
-
clock.advance(100);
|
|
1666
|
-
expect(watcher.isWatching()).toBe(true);
|
|
1667
|
-
expect(watcher.watchMode()).toBe("polling");
|
|
1668
|
-
clock.advance(100);
|
|
1669
|
-
expect(watcher.isWatching()).toBe(false);
|
|
1670
|
-
expect(closeCalls).toBe(2);
|
|
1671
|
-
expect(degraded).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
1672
|
-
reason: "initial_discovery_timeout",
|
|
1673
|
-
action: "stopped",
|
|
1674
|
-
watchedPaths: 20,
|
|
1675
|
-
}));
|
|
1676
|
-
});
|
|
1677
|
-
it("does not retain one rename-kind entry for every changed file", () => {
|
|
1678
|
-
const clock = new FakeClock();
|
|
1679
|
-
const watcher = new TreeWatcher({
|
|
1680
|
-
hqRoot: ROOT,
|
|
1681
|
-
clock,
|
|
1682
|
-
pathFilter: () => true,
|
|
1683
|
-
maxPendingPaths: 20_000,
|
|
1684
|
-
maxPendingBytes: 20_000_000,
|
|
1685
|
-
});
|
|
1686
|
-
for (let i = 0; i < 10_000; i++) {
|
|
1687
|
-
watcher.handleEvent(path.join(ROOT, "personal", `file-${i}.md`));
|
|
1688
|
-
}
|
|
1689
|
-
expect(watcher.knownDirectoryCount()).toBe(0);
|
|
1690
|
-
watcher.stop();
|
|
1691
|
-
});
|
|
1692
|
-
});
|
|
1693
|
-
describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", () => {
|
|
1694
|
-
let dir;
|
|
1695
|
-
beforeEach(() => {
|
|
1696
|
-
// realpathSync resolves macOS's /var -> /private/var symlink so chokidar's
|
|
1697
|
-
// canonicalized event paths stay inside hqRoot (path.relative would
|
|
1698
|
-
// otherwise yield a `..`-prefixed path and the filter would reject them).
|
|
1699
|
-
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "treewatcher-")));
|
|
1700
|
-
});
|
|
1701
|
-
afterEach(() => {
|
|
1702
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1703
|
-
});
|
|
1704
|
-
it("start() is idempotent — a second start does not open a second watcher", () => {
|
|
1705
|
-
const w = new TreeWatcher({ hqRoot: dir });
|
|
1706
|
-
w.start();
|
|
1707
|
-
expect(w.isWatching()).toBe(true);
|
|
1708
|
-
w.start(); // no throw, no second instance
|
|
1709
|
-
expect(w.isWatching()).toBe(true);
|
|
1710
|
-
w.stop();
|
|
1711
|
-
expect(w.isWatching()).toBe(false);
|
|
1712
|
-
});
|
|
1713
|
-
it("stop() closes the watcher and clears pending timers; dispose() is permanent", () => {
|
|
1714
|
-
const clock = new FakeClock();
|
|
1715
|
-
const w = new TreeWatcher({ hqRoot: dir, clock });
|
|
1716
|
-
w.start();
|
|
1717
|
-
w.handleEvent(path.join(dir, "x.md"));
|
|
1718
|
-
expect(w.pendingTimerCount()).toBe(1);
|
|
1719
|
-
w.stop();
|
|
1720
|
-
expect(w.pendingTimerCount()).toBe(0);
|
|
1721
|
-
expect(w.isWatching()).toBe(false);
|
|
1722
|
-
// After dispose, events are inert and start is a no-op.
|
|
1723
|
-
w.dispose();
|
|
1724
|
-
w.handleEvent(path.join(dir, "y.md"));
|
|
1725
|
-
expect(w.pendingTimerCount()).toBe(0);
|
|
1726
|
-
w.start();
|
|
1727
|
-
expect(w.isWatching()).toBe(false);
|
|
1728
|
-
});
|
|
1729
|
-
it("emits a debounced changed signal for a real file write", async () => {
|
|
1730
|
-
const w = new TreeWatcher({ hqRoot: dir, debounceMs: 50 });
|
|
1731
|
-
const changed = vi.fn();
|
|
1732
|
-
w.onChange(changed);
|
|
1733
|
-
w.start();
|
|
1734
|
-
// Give chokidar a beat to set up its watch before writing.
|
|
1735
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
1736
|
-
fs.writeFileSync(path.join(dir, "hello.md"), "hi");
|
|
1737
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
1738
|
-
expect(changed).toHaveBeenCalled();
|
|
1739
|
-
w.dispose();
|
|
1740
|
-
}, 10_000);
|
|
1741
|
-
});
|
|
1742
|
-
describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
1743
|
-
let dir;
|
|
1744
|
-
beforeEach(() => {
|
|
1745
|
-
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pushemit-")));
|
|
1746
|
-
});
|
|
1747
|
-
afterEach(() => {
|
|
1748
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1749
|
-
});
|
|
1750
|
-
it("replays randomized hash records with the same LRU state as the legacy reducer", () => {
|
|
1751
|
-
const records = publishedHashRecords(4_096);
|
|
1752
|
-
expect(replayDurablePublishedContentHashRecordsForTest(records, true))
|
|
1753
|
-
.toEqual(replayDurablePublishedContentHashRecordsForTest(records, false));
|
|
1754
|
-
});
|
|
1755
|
-
it("replays legacy records followed by a batch with the exact single-record LRU state", () => {
|
|
1756
|
-
const batchEntries = [
|
|
1757
|
-
{ kind: "delete", relativePath: "alpha" },
|
|
1758
|
-
{ kind: "set", relativePath: "gamma", contentHash: "sha256:gamma", maxPaths: 2 },
|
|
1759
|
-
{ kind: "set", relativePath: "beta", contentHash: "sha256:beta-new", maxPaths: 2 },
|
|
1760
|
-
];
|
|
1761
|
-
const legacyThenBatch = [
|
|
1762
|
-
{ sequence: 1, type: 0x5031, flags: 0, payload: { relativePath: "alpha", contentHash: "sha256:alpha", maxPaths: 2 } },
|
|
1763
|
-
{ sequence: 2, type: 0x5031, flags: 0, payload: { relativePath: "beta", contentHash: "sha256:beta", maxPaths: 2 } },
|
|
1764
|
-
{ sequence: 3, type: 0x5033, flags: 0, payload: { entries: batchEntries } },
|
|
1765
|
-
];
|
|
1766
|
-
const equivalentSingles = [
|
|
1767
|
-
...legacyThenBatch.slice(0, 2),
|
|
1768
|
-
...batchEntries.map((entry, index) => ({
|
|
1769
|
-
sequence: index + 3,
|
|
1770
|
-
type: entry.kind === "set" ? 0x5031 : 0x5032,
|
|
1771
|
-
flags: 0,
|
|
1772
|
-
payload: entry.kind === "set"
|
|
1773
|
-
? { relativePath: entry.relativePath, contentHash: entry.contentHash, maxPaths: entry.maxPaths }
|
|
1774
|
-
: { relativePath: entry.relativePath },
|
|
1775
|
-
})),
|
|
1776
|
-
];
|
|
1777
|
-
const batched = replayDurablePublishedContentHashRecordsForTest(legacyThenBatch);
|
|
1778
|
-
expect(JSON.stringify(batched)).toBe(JSON.stringify(replayDurablePublishedContentHashRecordsForTest(equivalentSingles)));
|
|
1779
|
-
expect(batched).toEqual(replayDurablePublishedContentHashRecordsForTest(legacyThenBatch, false));
|
|
1780
|
-
});
|
|
1781
|
-
it("coalesces pull marks into one append and persists only the final mutation for a path", async () => {
|
|
1782
|
-
const clock = new FakeClock();
|
|
1783
|
-
const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync");
|
|
1784
|
-
const stateDir = path.join(dir, "coalesced-state");
|
|
1785
|
-
const emitter = makeEmitter({ clock, lastPublishedContentHashStateDir: stateDir });
|
|
1786
|
-
const finalContent = "final revision\n";
|
|
1787
|
-
const finalHash = `sha256:${createHash("sha256").update(finalContent).digest("hex")}`;
|
|
1788
|
-
try {
|
|
1789
|
-
for (let index = 0; index < 128; index++) {
|
|
1790
|
-
emitter.markContentPublished(`path-${index}.md`, `sha256:${index}`);
|
|
1791
|
-
}
|
|
1792
|
-
emitter.markContentPublished("same.md", "sha256:obsolete");
|
|
1793
|
-
emitter.markContentPublished("same.md", finalHash);
|
|
1794
|
-
clock.advance(DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS);
|
|
1795
|
-
await flushImmediate();
|
|
1796
|
-
expect(appendGenerated).toHaveBeenCalledTimes(1);
|
|
1797
|
-
await emitter.dispose();
|
|
1798
|
-
const samePath = path.join(dir, "same.md");
|
|
1799
|
-
fs.writeFileSync(samePath, finalContent);
|
|
1800
|
-
const published = [];
|
|
1801
|
-
await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch({
|
|
1802
|
-
paths: new Map([[samePath, "same.md"]]),
|
|
1803
|
-
});
|
|
1804
|
-
expect(published).toEqual([]);
|
|
1805
|
-
}
|
|
1806
|
-
finally {
|
|
1807
|
-
appendGenerated.mockRestore();
|
|
1808
|
-
}
|
|
1809
|
-
});
|
|
1810
|
-
it("drops a stale guarded mutation at flush time without dropping the rest of its batch", async () => {
|
|
1811
|
-
const clock = new FakeClock();
|
|
1812
|
-
const hashes = new DurablePublishedContentHashes(path.join(dir, "predicate-state"), "test", 4, {}, clock);
|
|
1813
|
-
let shouldPersist = true;
|
|
1814
|
-
const stale = hashes.setIfCurrent("stale.md", "sha256:stale", 4, () => shouldPersist);
|
|
1815
|
-
const kept = hashes.set("kept.md", "sha256:kept", 4, false);
|
|
1816
|
-
shouldPersist = false;
|
|
1817
|
-
await hashes.dispose();
|
|
1818
|
-
await expect(stale).resolves.toBe(false);
|
|
1819
|
-
await expect(kept).resolves.toBeUndefined();
|
|
1820
|
-
expect(hashes.get("stale.md")).toBeUndefined();
|
|
1821
|
-
expect(hashes.get("kept.md")).toBe("sha256:kept");
|
|
1822
|
-
});
|
|
1823
|
-
it("flushes pending durable pull marks while disposing", async () => {
|
|
1824
|
-
const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync");
|
|
1825
|
-
const emitter = makeEmitter({ lastPublishedContentHashStateDir: path.join(dir, "dispose-state") });
|
|
1826
|
-
try {
|
|
1827
|
-
emitter.markContentPublished("pending.md", "sha256:pending");
|
|
1828
|
-
await emitter.dispose();
|
|
1829
|
-
expect(appendGenerated).toHaveBeenCalledTimes(1);
|
|
1830
|
-
}
|
|
1831
|
-
finally {
|
|
1832
|
-
appendGenerated.mockRestore();
|
|
1833
|
-
}
|
|
1834
|
-
});
|
|
1835
|
-
it("reports and retries a failed durable hash append as one physical batch", async () => {
|
|
1836
|
-
const clock = new FakeClock();
|
|
1837
|
-
const onError = vi.fn();
|
|
1838
|
-
const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
|
|
1839
|
-
.mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
|
|
1840
|
-
const emitter = makeEmitter({
|
|
1841
|
-
clock,
|
|
1842
|
-
onError,
|
|
1843
|
-
lastPublishedContentHashStateDir: path.join(dir, "batch-lock-timeout-state"),
|
|
1844
|
-
});
|
|
1845
|
-
try {
|
|
1846
|
-
for (let index = 0; index < 128; index += 1) {
|
|
1847
|
-
emitter.markContentPublished(`batch-${index}.md`, `sha256:${index}`);
|
|
1848
|
-
}
|
|
1849
|
-
clock.advance(DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS);
|
|
1850
|
-
await flushImmediate();
|
|
1851
|
-
expect(onError).toHaveBeenCalledTimes(1);
|
|
1852
|
-
expect(onError.mock.calls[0]?.[0]).toMatchObject({ name: "StateStoreLockError" });
|
|
1853
|
-
expect((onError.mock.calls[0]?.[0]).message).toContain("128 mutations");
|
|
1854
|
-
expect(clock.pendingTimerCount()).toBe(1);
|
|
1855
|
-
}
|
|
1856
|
-
finally {
|
|
1857
|
-
await emitter.dispose();
|
|
1858
|
-
appendGenerated.mockRestore();
|
|
1859
|
-
}
|
|
1860
|
-
});
|
|
1861
|
-
it("does not revive a superseded mutation while its failed batch is retrying", async () => {
|
|
1862
|
-
const clock = new FakeClock();
|
|
1863
|
-
let releaseRetry;
|
|
1864
|
-
const retryGate = new Promise((resolve) => { releaseRetry = resolve; });
|
|
1865
|
-
let retryEntered;
|
|
1866
|
-
const retryStarted = new Promise((resolve) => { retryEntered = resolve; });
|
|
1867
|
-
const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
|
|
1868
|
-
const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
|
|
1869
|
-
.mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"))
|
|
1870
|
-
.mockImplementationOnce(async function (type, preparePayload, flags) {
|
|
1871
|
-
retryEntered();
|
|
1872
|
-
await retryGate;
|
|
1873
|
-
return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
|
|
1874
|
-
});
|
|
1875
|
-
const hashes = new DurablePublishedContentHashes(path.join(dir, "superseded-batch-retry-state"), "test", 4, {}, clock);
|
|
1876
|
-
try {
|
|
1877
|
-
const stale = hashes.setIfCurrent("same.md", "sha256:stale", 4, () => true);
|
|
1878
|
-
await expect(stale).resolves.toBe(false);
|
|
1879
|
-
expect(clock.pendingTimerCount()).toBe(1);
|
|
1880
|
-
clock.advance(250);
|
|
1881
|
-
await retryStarted;
|
|
1882
|
-
const newer = hashes.set("same.md", "sha256:newer", 4, false);
|
|
1883
|
-
releaseRetry();
|
|
1884
|
-
await flushImmediate();
|
|
1885
|
-
clock.advance(DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS);
|
|
1886
|
-
await expect(newer).resolves.toBeUndefined();
|
|
1887
|
-
expect(hashes.get("same.md")).toBe("sha256:newer");
|
|
1888
|
-
}
|
|
1889
|
-
finally {
|
|
1890
|
-
await hashes.dispose();
|
|
1891
|
-
appendGenerated.mockRestore();
|
|
1892
|
-
}
|
|
1893
|
-
});
|
|
1894
|
-
it("rechecks a guarded mutation when its failed batch retries", async () => {
|
|
1895
|
-
const clock = new FakeClock();
|
|
1896
|
-
let shouldPersist = true;
|
|
1897
|
-
const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
|
|
1898
|
-
.mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
|
|
1899
|
-
const hashes = new DurablePublishedContentHashes(path.join(dir, "guarded-batch-retry-state"), "test", 4, {}, clock);
|
|
1900
|
-
try {
|
|
1901
|
-
const stale = hashes.setIfCurrent("stale.md", "sha256:stale", 4, () => shouldPersist);
|
|
1902
|
-
await expect(stale).resolves.toBe(false);
|
|
1903
|
-
shouldPersist = false;
|
|
1904
|
-
clock.advance(250);
|
|
1905
|
-
await flushImmediate();
|
|
1906
|
-
expect(hashes.get("stale.md")).toBeUndefined();
|
|
1907
|
-
expect(appendGenerated).toHaveBeenCalledTimes(2);
|
|
1908
|
-
}
|
|
1909
|
-
finally {
|
|
1910
|
-
await hashes.dispose();
|
|
1911
|
-
appendGenerated.mockRestore();
|
|
1912
|
-
}
|
|
1913
|
-
});
|
|
1914
|
-
it("drains an active publish into the durable memo before disposing", async () => {
|
|
1915
|
-
let releasePublish;
|
|
1916
|
-
const publishGate = new Promise((resolve) => { releasePublish = resolve; });
|
|
1917
|
-
let publishEntered;
|
|
1918
|
-
const publishStarted = new Promise((resolve) => { publishEntered = resolve; });
|
|
1919
|
-
const published = [];
|
|
1920
|
-
const stateDir = path.join(dir, "dispose-publish-drain-state");
|
|
1921
|
-
const changed = path.join(dir, "dispose-publish-drain.md");
|
|
1922
|
-
fs.writeFileSync(changed, "drain me\n");
|
|
1923
|
-
const emitter = makeEmitter({
|
|
1924
|
-
lastPublishedContentHashStateDir: stateDir,
|
|
1925
|
-
transport: {
|
|
1926
|
-
start: async () => { },
|
|
1927
|
-
dispose: async () => { },
|
|
1928
|
-
connected: true,
|
|
1929
|
-
publish: async (event) => {
|
|
1930
|
-
publishEntered();
|
|
1931
|
-
await publishGate;
|
|
1932
|
-
published.push(event);
|
|
1933
|
-
},
|
|
1934
|
-
},
|
|
1935
|
-
});
|
|
1936
|
-
const publishing = emitter.emitForBatch({ paths: new Map([[changed, "dispose-publish-drain.md"]]) });
|
|
1937
|
-
await publishStarted;
|
|
1938
|
-
let disposed = false;
|
|
1939
|
-
const disposing = emitter.dispose().then(() => { disposed = true; });
|
|
1940
|
-
await Promise.resolve();
|
|
1941
|
-
expect(disposed).toBe(false);
|
|
1942
|
-
releasePublish();
|
|
1943
|
-
await Promise.all([publishing, disposing]);
|
|
1944
|
-
const afterRestart = [];
|
|
1945
|
-
await makeEmitter({
|
|
1946
|
-
published: afterRestart,
|
|
1947
|
-
lastPublishedContentHashStateDir: stateDir,
|
|
1948
|
-
}).emitForBatch({ paths: new Map([[changed, "dispose-publish-drain.md"]]) });
|
|
1949
|
-
expect(published).toHaveLength(1);
|
|
1950
|
-
expect(afterRestart).toEqual([]);
|
|
1951
|
-
});
|
|
1952
|
-
it("leaves the durable memo open when a publish outlives the shutdown drain", async () => {
|
|
1953
|
-
const clock = new FakeClock();
|
|
1954
|
-
const onError = vi.fn();
|
|
1955
|
-
let releasePublish;
|
|
1956
|
-
const publishGate = new Promise((resolve) => { releasePublish = resolve; });
|
|
1957
|
-
let publishEntered;
|
|
1958
|
-
const publishStarted = new Promise((resolve) => { publishEntered = resolve; });
|
|
1959
|
-
const stateDir = path.join(dir, "dispose-timeout-state");
|
|
1960
|
-
const changed = path.join(dir, "dispose-timeout.md");
|
|
1961
|
-
fs.writeFileSync(changed, "late publish\n");
|
|
1962
|
-
const emitter = makeEmitter({
|
|
1963
|
-
clock,
|
|
1964
|
-
onError,
|
|
1965
|
-
lastPublishedContentHashStateDir: stateDir,
|
|
1966
|
-
transport: {
|
|
1967
|
-
start: async () => { },
|
|
1968
|
-
dispose: async () => { },
|
|
1969
|
-
connected: true,
|
|
1970
|
-
publish: async () => {
|
|
1971
|
-
publishEntered();
|
|
1972
|
-
await publishGate;
|
|
1973
|
-
},
|
|
1974
|
-
},
|
|
1975
|
-
});
|
|
1976
|
-
const publishing = emitter.emitForBatch({ paths: new Map([[changed, "dispose-timeout.md"]]) });
|
|
1977
|
-
await publishStarted;
|
|
1978
|
-
const disposing = emitter.dispose();
|
|
1979
|
-
clock.advance(60_000);
|
|
1980
|
-
await disposing;
|
|
1981
|
-
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("timed out draining active publish") }), {});
|
|
1982
|
-
releasePublish();
|
|
1983
|
-
await publishing;
|
|
1984
|
-
await flushImmediate();
|
|
1985
|
-
const afterRestart = [];
|
|
1986
|
-
await makeEmitter({
|
|
1987
|
-
published: afterRestart,
|
|
1988
|
-
lastPublishedContentHashStateDir: stateDir,
|
|
1989
|
-
}).emitForBatch({ paths: new Map([[changed, "dispose-timeout.md"]]) });
|
|
1990
|
-
expect(afterRestart).toEqual([]);
|
|
1991
|
-
});
|
|
1992
|
-
it("returns one disposal barrier until the final durable batch is appended", async () => {
|
|
1993
|
-
let releaseAppend;
|
|
1994
|
-
const appendGate = new Promise((resolve) => { releaseAppend = resolve; });
|
|
1995
|
-
let appendEntered;
|
|
1996
|
-
const appendStarted = new Promise((resolve) => { appendEntered = resolve; });
|
|
1997
|
-
const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
|
|
1998
|
-
const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
|
|
1999
|
-
.mockImplementationOnce(async function (type, preparePayload, flags) {
|
|
2000
|
-
appendEntered();
|
|
2001
|
-
await appendGate;
|
|
2002
|
-
return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
|
|
2003
|
-
});
|
|
2004
|
-
const emitter = makeEmitter({ lastPublishedContentHashStateDir: path.join(dir, "concurrent-dispose-state") });
|
|
2005
|
-
try {
|
|
2006
|
-
emitter.markContentPublished("pending.md", "sha256:pending");
|
|
2007
|
-
const firstDispose = emitter.dispose();
|
|
2008
|
-
await appendStarted;
|
|
2009
|
-
let secondDisposed = false;
|
|
2010
|
-
const secondDispose = emitter.dispose();
|
|
2011
|
-
expect(secondDispose).toBe(firstDispose);
|
|
2012
|
-
void secondDispose.then(() => { secondDisposed = true; });
|
|
2013
|
-
await Promise.resolve();
|
|
2014
|
-
expect(secondDisposed).toBe(false);
|
|
2015
|
-
releaseAppend();
|
|
2016
|
-
await Promise.all([firstDispose, secondDispose]);
|
|
2017
|
-
expect(secondDisposed).toBe(true);
|
|
2018
|
-
expect(appendGenerated).toHaveBeenCalledTimes(1);
|
|
2019
|
-
}
|
|
2020
|
-
finally {
|
|
2021
|
-
appendGenerated.mockRestore();
|
|
2022
|
-
}
|
|
2023
|
-
});
|
|
2024
|
-
it("replays __proto__ hash records with the same state as the legacy reducer", async () => {
|
|
2025
|
-
const records = [
|
|
2026
|
-
{ sequence: 1, type: 0x5031, flags: 0, payload: { relativePath: "__proto__", contentHash: "sha256:proto", maxPaths: 4 } },
|
|
2027
|
-
{ sequence: 2, type: 0x5031, flags: 0, payload: { relativePath: "ordinary", contentHash: "sha256:ordinary", maxPaths: 4 } },
|
|
2028
|
-
];
|
|
2029
|
-
const keyed = replayDurablePublishedContentHashRecordsForTest(records, true);
|
|
2030
|
-
expect(keyed).toEqual(replayDurablePublishedContentHashRecordsForTest(records, false));
|
|
2031
|
-
expect(keyed.hashes["__proto__"]).toBe("sha256:proto");
|
|
2032
|
-
records.push({ sequence: 3, type: 0x5032, flags: 0, payload: { relativePath: "__proto__" } });
|
|
2033
|
-
records.push({ sequence: 4, type: 0x5032, flags: 0, payload: { relativePath: "ordinary" } });
|
|
2034
|
-
expect(replayDurablePublishedContentHashRecordsForTest(records, true))
|
|
2035
|
-
.toEqual(replayDurablePublishedContentHashRecordsForTest(records, false));
|
|
2036
|
-
// This restart uses DurablePublishedContentHashes.get("__proto__") to
|
|
2037
|
-
// decide whether a successfully acknowledged hash may be suppressed.
|
|
2038
|
-
const filePath = path.join(dir, "proto-sentinel.md");
|
|
2039
|
-
const stateDir = path.join(dir, "proto-state");
|
|
2040
|
-
const batch = { paths: new Map([[filePath, "__proto__"]]) };
|
|
2041
|
-
fs.writeFileSync(filePath, "proto content\n");
|
|
2042
|
-
await makeEmitter({ lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
|
|
2043
|
-
const afterRestart = [];
|
|
2044
|
-
await makeEmitter({ published: afterRestart, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
|
|
2045
|
-
expect(afterRestart).toEqual([]);
|
|
2046
|
-
});
|
|
2047
|
-
it("replays fifty thousand hash records with sub-quadratic scaling", () => {
|
|
2048
|
-
const replayFor = (count) => {
|
|
2049
|
-
const started = performance.now();
|
|
2050
|
-
replayDurablePublishedContentHashRecordsForTest(publishedHashRecords(count));
|
|
2051
|
-
return performance.now() - started;
|
|
2052
|
-
};
|
|
2053
|
-
const small = replayFor(5_000);
|
|
2054
|
-
const large = replayFor(50_000);
|
|
2055
|
-
// The keyed reducer does a constant number of Map/object operations per
|
|
2056
|
-
// record. Keep ample room for slow CI while rejecting a quadratic replay.
|
|
2057
|
-
expect(large).toBeLessThan(small * 20 + 100);
|
|
2058
|
-
});
|
|
2059
|
-
function makeEmitter(opts) {
|
|
2060
|
-
const published = opts?.published ?? [];
|
|
2061
|
-
const transport = opts?.transport ?? {
|
|
2062
|
-
start: async () => { },
|
|
2063
|
-
dispose: async () => { },
|
|
2064
|
-
connected: true,
|
|
2065
|
-
publish: async (event) => {
|
|
2066
|
-
published.push(event);
|
|
2067
|
-
},
|
|
2068
|
-
};
|
|
2069
|
-
return new PushEventEmitter({
|
|
2070
|
-
originTenantId: "tenant-indigo",
|
|
2071
|
-
originDeviceId: "device-a",
|
|
2072
|
-
transport,
|
|
2073
|
-
flagProvider: { isEnabled: () => true },
|
|
2074
|
-
now: () => new Date("2026-06-18T12:00:00.000Z"),
|
|
2075
|
-
clock: opts?.clock,
|
|
2076
|
-
getSequenceNumber: opts?.getSequenceNumber,
|
|
2077
|
-
onError: opts?.onError,
|
|
2078
|
-
lastPublishedContentHashMaxPaths: opts?.lastPublishedContentHashMaxPaths,
|
|
2079
|
-
publishMinIntervalMs: opts?.publishMinIntervalMs ?? 0,
|
|
2080
|
-
...(opts?.lastPublishedContentHashStateDir
|
|
2081
|
-
? { lastPublishedContentHashStateDir: opts.lastPublishedContentHashStateDir }
|
|
2082
|
-
: {}),
|
|
2083
|
-
...(opts?.initialPublishedContentHashes
|
|
2084
|
-
? { initialPublishedContentHashes: opts.initialPublishedContentHashes }
|
|
2085
|
-
: {}),
|
|
2086
|
-
...(opts?.publishedContentHashLookup
|
|
2087
|
-
? { publishedContentHashLookup: opts.publishedContentHashLookup }
|
|
2088
|
-
: {}),
|
|
2089
|
-
...(opts?.caseFoldingVolume !== undefined
|
|
2090
|
-
? { caseFoldingVolume: opts.caseFoldingVolume }
|
|
2091
|
-
: {}),
|
|
2092
|
-
});
|
|
2093
|
-
}
|
|
2094
|
-
it("lazily rechecks the journal baseline on a durable-cache miss without pinning a full snapshot", async () => {
|
|
2095
|
-
const published = [];
|
|
2096
|
-
const relativePath = "companies/indigo/seeded-lazily.md";
|
|
2097
|
-
const filePath = path.join(dir, relativePath);
|
|
2098
|
-
const content = "already uploaded\n";
|
|
2099
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
2100
|
-
fs.writeFileSync(filePath, content);
|
|
2101
|
-
const hash = `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
2102
|
-
const lookup = vi.fn((path) => path === relativePath ? hash : undefined);
|
|
2103
|
-
const emitter = makeEmitter({
|
|
2104
|
-
published,
|
|
2105
|
-
lastPublishedContentHashStateDir: path.join(dir, "lazy-baseline-state"),
|
|
2106
|
-
publishedContentHashLookup: lookup,
|
|
2107
|
-
});
|
|
2108
|
-
await emitter.emitForBatch({ paths: new Map([[filePath, relativePath]]) });
|
|
2109
|
-
expect(lookup).toHaveBeenCalledWith(relativePath);
|
|
2110
|
-
expect(published).toEqual([]);
|
|
2111
|
-
// A failed/unknown lazy lookup must never suppress a genuine local edit.
|
|
2112
|
-
const changedPath = path.join(dir, "companies/indigo/unknown-baseline.md");
|
|
2113
|
-
fs.writeFileSync(changedPath, "local edit\n");
|
|
2114
|
-
await emitter.emitForBatch({
|
|
2115
|
-
paths: new Map([[changedPath, "companies/indigo/unknown-baseline.md"]]),
|
|
2116
|
-
});
|
|
2117
|
-
expect(published).toHaveLength(1);
|
|
2118
|
-
});
|
|
2119
|
-
it("F13: watcher batch event publishing applies bounded backpressure", async () => {
|
|
2120
|
-
const paths = new Map();
|
|
2121
|
-
for (let i = 0; i < 32; i++) {
|
|
2122
|
-
const rel = `bulk-${i}.md`;
|
|
2123
|
-
const abs = path.join(dir, rel);
|
|
2124
|
-
fs.writeFileSync(abs, `file ${i}`);
|
|
2125
|
-
paths.set(abs, rel);
|
|
2126
|
-
}
|
|
2127
|
-
let inFlight = 0;
|
|
2128
|
-
let maxInFlight = 0;
|
|
2129
|
-
let releaseGate;
|
|
2130
|
-
const gate = new Promise((resolve) => {
|
|
2131
|
-
releaseGate = resolve;
|
|
2132
|
-
});
|
|
2133
|
-
const transport = {
|
|
2134
|
-
start: async () => { },
|
|
2135
|
-
dispose: async () => { },
|
|
2136
|
-
connected: true,
|
|
2137
|
-
publish: async () => {
|
|
2138
|
-
inFlight += 1;
|
|
2139
|
-
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
2140
|
-
await gate;
|
|
2141
|
-
inFlight -= 1;
|
|
2142
|
-
},
|
|
2143
|
-
};
|
|
2144
|
-
const emitter = new PushEventEmitter({
|
|
2145
|
-
originTenantId: "tenant-indigo",
|
|
2146
|
-
originDeviceId: "device-a",
|
|
2147
|
-
transport,
|
|
2148
|
-
flagProvider: { isEnabled: () => true },
|
|
2149
|
-
now: () => new Date("2026-06-18T12:00:00.000Z"),
|
|
2150
|
-
});
|
|
2151
|
-
const run = emitter.emitForBatch({ paths });
|
|
2152
|
-
try {
|
|
2153
|
-
for (let i = 0; i < 200 && maxInFlight <= 16; i++) {
|
|
2154
|
-
await flushImmediate();
|
|
2155
|
-
}
|
|
2156
|
-
releaseGate();
|
|
2157
|
-
await run;
|
|
2158
|
-
expect(maxInFlight).toBeLessThanOrEqual(16);
|
|
2159
|
-
}
|
|
2160
|
-
finally {
|
|
2161
|
-
releaseGate();
|
|
2162
|
-
await run.catch(() => { });
|
|
2163
|
-
}
|
|
2164
|
-
});
|
|
2165
|
-
it("skips directory changes silently without publishing or reporting an error", async () => {
|
|
2166
|
-
const published = [];
|
|
2167
|
-
const onError = vi.fn();
|
|
2168
|
-
const subdir = path.join(dir, "folder");
|
|
2169
|
-
fs.mkdirSync(subdir);
|
|
2170
|
-
const emitter = makeEmitter({ published, onError });
|
|
2171
|
-
await emitter.emitForBatch({ paths: new Map([[subdir, "folder"]]) });
|
|
2172
|
-
expect(published).toEqual([]);
|
|
2173
|
-
expect(onError).not.toHaveBeenCalled();
|
|
2174
|
-
});
|
|
2175
|
-
it("emits and publishes a delete tombstone when the changed file is gone", async () => {
|
|
2176
|
-
const published = [];
|
|
2177
|
-
const onError = vi.fn();
|
|
2178
|
-
const emitter = makeEmitter({
|
|
2179
|
-
published,
|
|
2180
|
-
onError,
|
|
2181
|
-
getSequenceNumber: () => 99,
|
|
2182
|
-
caseFoldingVolume: true,
|
|
2183
|
-
});
|
|
2184
|
-
const missing = path.join(dir, "deleted.md");
|
|
2185
|
-
await emitter.emitForBatch({ paths: new Map([[missing, "deleted.md"]]) });
|
|
2186
|
-
expect(onError).not.toHaveBeenCalled();
|
|
2187
|
-
expect(published).toEqual([
|
|
2188
|
-
{
|
|
2189
|
-
kind: "delete",
|
|
2190
|
-
relativePath: "deleted.md",
|
|
2191
|
-
originDeviceId: "device-a",
|
|
2192
|
-
originTenantId: "tenant-indigo",
|
|
2193
|
-
sequenceNumber: 99,
|
|
2194
|
-
eventTimestamp: "2026-06-18T12:00:00.000Z",
|
|
2195
|
-
},
|
|
2196
|
-
]);
|
|
2197
|
-
expect(published[0]).not.toHaveProperty("contentHash");
|
|
2198
|
-
expect(published[0]).not.toHaveProperty("mtime");
|
|
2199
|
-
expect(published[0]).not.toHaveProperty("sizeBytes");
|
|
2200
|
-
});
|
|
2201
|
-
it("clears a pull-applied delete marker after a re-upload without a realtime emit", async () => {
|
|
2202
|
-
const published = [];
|
|
2203
|
-
const emitter = makeEmitter({ published });
|
|
2204
|
-
const recreatedPath = path.join(dir, "recreated.md");
|
|
2205
|
-
fs.writeFileSync(recreatedPath, "recreated content");
|
|
2206
|
-
emitter.markContentDeleted("recreated.md");
|
|
2207
|
-
// A successful scoped upload clears the marker even when publishing was
|
|
2208
|
-
// unavailable, so the next genuine unlink cannot be swallowed.
|
|
2209
|
-
emitter.clearContentDeleted("recreated.md");
|
|
2210
|
-
fs.unlinkSync(recreatedPath);
|
|
2211
|
-
await emitter.emitForBatch({
|
|
2212
|
-
paths: new Map([[recreatedPath, "recreated.md"]]),
|
|
2213
|
-
});
|
|
2214
|
-
expect(published.map(({ kind, relativePath }) => ({ kind, relativePath })))
|
|
2215
|
-
.toEqual([{ kind: "delete", relativePath: "recreated.md" }]);
|
|
2216
|
-
await emitter.dispose();
|
|
2217
|
-
});
|
|
2218
|
-
it("keeps a case-only directory rename on the journal's existing remote spelling", async () => {
|
|
2219
|
-
const contentByPath = new Map([
|
|
2220
|
-
["a.md", "a content"],
|
|
2221
|
-
["b.md", "b content"],
|
|
2222
|
-
]);
|
|
2223
|
-
const renamedDir = path.join(dir, "dir");
|
|
2224
|
-
fs.mkdirSync(renamedDir, { recursive: true });
|
|
2225
|
-
const paths = new Map();
|
|
2226
|
-
const knownHashes = new Map();
|
|
2227
|
-
const remote = new Map();
|
|
2228
|
-
for (const [name, content] of contentByPath) {
|
|
2229
|
-
const localPath = path.join(renamedDir, name);
|
|
2230
|
-
fs.writeFileSync(localPath, content);
|
|
2231
|
-
const oldKey = `Dir/${name}`;
|
|
2232
|
-
const newKey = `dir/${name}`;
|
|
2233
|
-
knownHashes.set(oldKey, `sha256:${createHash("sha256").update(content).digest("hex")}`);
|
|
2234
|
-
remote.set(oldKey, content);
|
|
2235
|
-
paths.set(path.join(dir, "Dir", name), oldKey);
|
|
2236
|
-
paths.set(localPath, newKey);
|
|
2237
|
-
}
|
|
2238
|
-
// APFS and NTFS already resolve `Dir` to this folder; a case-sensitive
|
|
2239
|
-
// host gets the same lookup through a symlink.
|
|
2240
|
-
if (!fs.existsSync(path.join(dir, "Dir")))
|
|
2241
|
-
fs.symlinkSync("dir", path.join(dir, "Dir"), "dir");
|
|
2242
|
-
const published = [];
|
|
2243
|
-
const emitter = makeEmitter({
|
|
2244
|
-
published,
|
|
2245
|
-
publishedContentHashLookup: (relativePath) => knownHashes.get(relativePath),
|
|
2246
|
-
caseFoldingVolume: true,
|
|
2247
|
-
});
|
|
2248
|
-
await emitter.emitForBatch({ paths });
|
|
2249
|
-
for (const event of published) {
|
|
2250
|
-
if (event.kind === "delete")
|
|
2251
|
-
remote.delete(event.relativePath);
|
|
2252
|
-
else
|
|
2253
|
-
remote.set(event.relativePath, contentByPath.get(path.basename(event.relativePath)));
|
|
2254
|
-
}
|
|
2255
|
-
expect(published.some((event) => event.kind === "delete")).toBe(false);
|
|
2256
|
-
expect([...remote.entries()].sort(([a], [b]) => a.localeCompare(b))).toEqual([
|
|
2257
|
-
["Dir/a.md", "a content"],
|
|
2258
|
-
["Dir/b.md", "b content"],
|
|
2259
|
-
]);
|
|
2260
|
-
});
|
|
2261
|
-
it("canonicalizes native-separator event keys and leaves POSIX keys byte-identical", async () => {
|
|
2262
|
-
const published = [];
|
|
2263
|
-
const onError = vi.fn();
|
|
2264
|
-
const legacyPath = path.join(dir, "legacy.csv");
|
|
2265
|
-
const posixPath = path.join(dir, "already-posix.csv");
|
|
2266
|
-
fs.writeFileSync(legacyPath, "legacy\n");
|
|
2267
|
-
fs.writeFileSync(posixPath, "posix\n");
|
|
2268
|
-
const emitter = makeEmitter({ published, onError });
|
|
2269
|
-
const windowsKey = "projects\\forecast\\x.csv";
|
|
2270
|
-
await emitter.emitForBatch({
|
|
2271
|
-
paths: new Map([[legacyPath, windowsKey]]),
|
|
2272
|
-
});
|
|
2273
|
-
await emitter.emitForBatch({
|
|
2274
|
-
paths: new Map([[posixPath, "projects/forecast/already-posix.csv"]]),
|
|
2275
|
-
});
|
|
2276
|
-
if (path.sep === "\\") {
|
|
2277
|
-
expect(published.map((event) => event.relativePath)).toEqual([
|
|
2278
|
-
"projects/forecast/x.csv",
|
|
2279
|
-
"projects/forecast/already-posix.csv",
|
|
2280
|
-
]);
|
|
2281
|
-
expect(onError).not.toHaveBeenCalled();
|
|
2282
|
-
}
|
|
2283
|
-
else {
|
|
2284
|
-
// POSIX: `\\` is a filename character. Do not remap onto a nested key.
|
|
2285
|
-
expect(published.map((event) => event.relativePath)).toEqual([
|
|
2286
|
-
"projects/forecast/already-posix.csv",
|
|
2287
|
-
]);
|
|
2288
|
-
expect(onError).toHaveBeenCalled();
|
|
2289
|
-
}
|
|
2290
|
-
});
|
|
2291
|
-
it("canonicalizes a native-separator tombstone only when `\\` is path.sep", async () => {
|
|
2292
|
-
const published = [];
|
|
2293
|
-
const onError = vi.fn();
|
|
2294
|
-
const missing = path.join(dir, "deleted.csv");
|
|
2295
|
-
const emitter = makeEmitter({ published, onError });
|
|
2296
|
-
await emitter.emitForBatch({
|
|
2297
|
-
paths: new Map([[missing, "projects\\forecast\\deleted.csv"]]),
|
|
2298
|
-
});
|
|
2299
|
-
if (path.sep === "\\") {
|
|
2300
|
-
expect(published).toEqual([
|
|
2301
|
-
expect.objectContaining({
|
|
2302
|
-
kind: "delete",
|
|
2303
|
-
relativePath: "projects/forecast/deleted.csv",
|
|
2304
|
-
}),
|
|
2305
|
-
]);
|
|
2306
|
-
expect(onError).not.toHaveBeenCalled();
|
|
2307
|
-
}
|
|
2308
|
-
else {
|
|
2309
|
-
expect(published).toEqual([]);
|
|
2310
|
-
expect(onError).toHaveBeenCalled();
|
|
2311
|
-
}
|
|
2312
|
-
});
|
|
2313
|
-
it("publishes N unchanged watcher announcements exactly once", async () => {
|
|
2314
|
-
const published = [];
|
|
2315
|
-
const unchanged = path.join(dir, "unchanged-session-log.jsonl");
|
|
2316
|
-
fs.writeFileSync(unchanged, '{"tool":"read"}\n');
|
|
2317
|
-
const emitter = makeEmitter({ published });
|
|
2318
|
-
const batch = { paths: new Map([[unchanged, "workspace/.session-logs/unchanged-session-log.jsonl"]]) };
|
|
2319
|
-
for (let i = 0; i < 5; i += 1) {
|
|
2320
|
-
await emitter.emitForBatch(batch);
|
|
2321
|
-
}
|
|
2322
|
-
expect(published).toHaveLength(1);
|
|
2323
|
-
expect(published[0]).toMatchObject({
|
|
2324
|
-
kind: "upsert",
|
|
2325
|
-
relativePath: "workspace/.session-logs/unchanged-session-log.jsonl",
|
|
2326
|
-
});
|
|
2327
|
-
});
|
|
2328
|
-
it("publishes again when content changes", async () => {
|
|
2329
|
-
const published = [];
|
|
2330
|
-
const changed = path.join(dir, "changed-session-log.jsonl");
|
|
2331
|
-
fs.writeFileSync(changed, "A\n");
|
|
2332
|
-
const emitter = makeEmitter({ published });
|
|
2333
|
-
const batch = { paths: new Map([[changed, "workspace/.session-logs/changed-session-log.jsonl"]]) };
|
|
2334
|
-
await emitter.emitForBatch(batch);
|
|
2335
|
-
fs.writeFileSync(changed, "B\n");
|
|
2336
|
-
await emitter.emitForBatch(batch);
|
|
2337
|
-
expect(published).toHaveLength(2);
|
|
2338
|
-
expect(published.map((event) => event.contentHash)).toEqual([
|
|
2339
|
-
expect.stringMatching(/^sha256:/),
|
|
2340
|
-
expect.stringMatching(/^sha256:/),
|
|
2341
|
-
]);
|
|
2342
|
-
expect(published[0].contentHash).not.toBe(published[1].contentHash);
|
|
2343
|
-
});
|
|
2344
|
-
it("does not echo a pull-applied upsert but publishes a later local edit", async () => {
|
|
2345
|
-
const published = [];
|
|
2346
|
-
const applied = path.join(dir, "companies", "indigo", "remote-note.md");
|
|
2347
|
-
const relativePath = "companies/indigo/remote-note.md";
|
|
2348
|
-
const remoteContent = "from remote\n";
|
|
2349
|
-
fs.mkdirSync(path.dirname(applied), { recursive: true });
|
|
2350
|
-
fs.writeFileSync(applied, remoteContent);
|
|
2351
|
-
const emitter = makeEmitter({ published });
|
|
2352
|
-
// The pull completed its file write and journal update, then the runner
|
|
2353
|
-
// bridged that upsert into the live emitter before the watcher settled.
|
|
2354
|
-
emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
|
|
2355
|
-
await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2356
|
-
expect(published).toEqual([]);
|
|
2357
|
-
fs.writeFileSync(applied, "local follow-up\n");
|
|
2358
|
-
await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2359
|
-
expect(published).toHaveLength(1);
|
|
2360
|
-
expect(published[0]).toMatchObject({
|
|
2361
|
-
kind: "upsert",
|
|
2362
|
-
relativePath,
|
|
2363
|
-
contentHash: `sha256:${createHash("sha256")
|
|
2364
|
-
.update("local follow-up\n")
|
|
2365
|
-
.digest("hex")}`,
|
|
2366
|
-
});
|
|
2367
|
-
});
|
|
2368
|
-
it("publishes a local edit immediately after a pull mark despite the publish interval", async () => {
|
|
2369
|
-
const clock = new FakeClock();
|
|
2370
|
-
const published = [];
|
|
2371
|
-
const applied = path.join(dir, "companies", "indigo", "remote-interval-note.md");
|
|
2372
|
-
const relativePath = "companies/indigo/remote-interval-note.md";
|
|
2373
|
-
const remoteContent = "from remote\n";
|
|
2374
|
-
fs.mkdirSync(path.dirname(applied), { recursive: true });
|
|
2375
|
-
fs.writeFileSync(applied, remoteContent);
|
|
2376
|
-
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
|
|
2377
|
-
emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
|
|
2378
|
-
fs.writeFileSync(applied, "local follow-up\n");
|
|
2379
|
-
await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2380
|
-
expect(published).toHaveLength(1);
|
|
2381
|
-
expect(published[0]).toMatchObject({
|
|
2382
|
-
kind: "upsert",
|
|
2383
|
-
relativePath,
|
|
2384
|
-
contentHash: `sha256:${createHash("sha256")
|
|
2385
|
-
.update("local follow-up\n")
|
|
2386
|
-
.digest("hex")}`,
|
|
2387
|
-
});
|
|
2388
|
-
});
|
|
2389
|
-
it("keeps a newer pull mark when an older local publish completes", async () => {
|
|
2390
|
-
const published = [];
|
|
2391
|
-
const applied = path.join(dir, "companies", "indigo", "publish-race.md");
|
|
2392
|
-
const relativePath = "companies/indigo/publish-race.md";
|
|
2393
|
-
const oldContent = "local old bytes\n";
|
|
2394
|
-
const remoteContent = "remote newer bytes\n";
|
|
2395
|
-
fs.mkdirSync(path.dirname(applied), { recursive: true });
|
|
2396
|
-
fs.writeFileSync(applied, oldContent);
|
|
2397
|
-
let releasePublish;
|
|
2398
|
-
let publishStartedResolve;
|
|
2399
|
-
const publishStarted = new Promise((resolve) => {
|
|
2400
|
-
publishStartedResolve = resolve;
|
|
2401
|
-
});
|
|
2402
|
-
const publishGate = new Promise((resolve) => {
|
|
2403
|
-
releasePublish = resolve;
|
|
2404
|
-
});
|
|
2405
|
-
const emitter = makeEmitter({
|
|
2406
|
-
published,
|
|
2407
|
-
transport: {
|
|
2408
|
-
start: async () => { },
|
|
2409
|
-
dispose: async () => { },
|
|
2410
|
-
connected: true,
|
|
2411
|
-
publish: async (event) => {
|
|
2412
|
-
published.push(event);
|
|
2413
|
-
publishStartedResolve?.();
|
|
2414
|
-
await publishGate;
|
|
2415
|
-
},
|
|
2416
|
-
},
|
|
2417
|
-
});
|
|
2418
|
-
const oldPublish = emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2419
|
-
await publishStarted;
|
|
2420
|
-
emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
|
|
2421
|
-
releasePublish?.();
|
|
2422
|
-
await oldPublish;
|
|
2423
|
-
fs.writeFileSync(applied, remoteContent);
|
|
2424
|
-
await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2425
|
-
// The remote bytes remain suppressed: old publish completion could not
|
|
2426
|
-
// overwrite the newer pull mark in the per-path publication ledger.
|
|
2427
|
-
expect(published).toHaveLength(1);
|
|
2428
|
-
expect(published[0]).toMatchObject({
|
|
2429
|
-
kind: "upsert",
|
|
2430
|
-
relativePath,
|
|
2431
|
-
contentHash: `sha256:${createHash("sha256").update(oldContent).digest("hex")}`,
|
|
2432
|
-
});
|
|
2433
|
-
});
|
|
2434
|
-
it("bounds publishedContentHashGenerations to the published-hash cap (was an unbounded Map)", () => {
|
|
2435
|
-
const maxPaths = 8;
|
|
2436
|
-
const K = 12;
|
|
2437
|
-
const emitter = makeEmitter({ lastPublishedContentHashMaxPaths: maxPaths });
|
|
2438
|
-
for (let i = 0; i < maxPaths + K; i++) {
|
|
2439
|
-
emitter.markContentPublished(`companies/indigo/gen-${i}.md`, `sha256:h${i}`);
|
|
2440
|
-
}
|
|
2441
|
-
const sizes = emitter.censusSizes();
|
|
2442
|
-
// The generations map used to be a plain Map with no eviction, so it grew to
|
|
2443
|
-
// maxPaths + K and stayed there for the whole watch session — the unbounded
|
|
2444
|
-
// retention this fix removes. It must now hold no more than its bounded
|
|
2445
|
-
// sibling's cap.
|
|
2446
|
-
expect(sizes.publishedContentHashGenerations).toBeLessThanOrEqual(maxPaths);
|
|
2447
|
-
expect(sizes.lastPublishedContentHashes).toBeLessThanOrEqual(maxPaths);
|
|
2448
|
-
// Sanity: we really did drive more distinct paths than the cap.
|
|
2449
|
-
expect(maxPaths + K).toBeGreaterThan(maxPaths);
|
|
2450
|
-
});
|
|
2451
|
-
it("bounds the generations map without breaking pull-apply echo suppression for a hot path", async () => {
|
|
2452
|
-
const published = [];
|
|
2453
|
-
const maxPaths = 4;
|
|
2454
|
-
const emitter = makeEmitter({ published, lastPublishedContentHashMaxPaths: maxPaths });
|
|
2455
|
-
// Churn several cold paths first: each takes a fresh monotonic generation and
|
|
2456
|
-
// evicts older ones from the now-bounded map.
|
|
2457
|
-
for (let i = 0; i < maxPaths * 4; i++) {
|
|
2458
|
-
emitter.markContentPublished(`companies/indigo/cold-${i}.md`, `sha256:c${i}`);
|
|
2459
|
-
}
|
|
2460
|
-
// The pulled path is marked LAST, so it is the hot, retained entry.
|
|
2461
|
-
const applied = path.join(dir, "companies", "indigo", "hot-note.md");
|
|
2462
|
-
const relativePath = "companies/indigo/hot-note.md";
|
|
2463
|
-
const remoteContent = "from remote\n";
|
|
2464
|
-
fs.mkdirSync(path.dirname(applied), { recursive: true });
|
|
2465
|
-
fs.writeFileSync(applied, remoteContent);
|
|
2466
|
-
emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
|
|
2467
|
-
// The identical local write is still suppressed as an echo (contract #456).
|
|
2468
|
-
await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2469
|
-
expect(published).toEqual([]);
|
|
2470
|
-
// A genuinely new local edit still publishes.
|
|
2471
|
-
fs.writeFileSync(applied, "local follow-up\n");
|
|
2472
|
-
await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2473
|
-
expect(published).toHaveLength(1);
|
|
2474
|
-
});
|
|
2475
|
-
it("keeps generation currency fail-closed under the bounded map: a newer pull mark still wins the publish race", async () => {
|
|
2476
|
-
const published = [];
|
|
2477
|
-
const maxPaths = 4;
|
|
2478
|
-
const applied = path.join(dir, "companies", "indigo", "fail-closed-race.md");
|
|
2479
|
-
const relativePath = "companies/indigo/fail-closed-race.md";
|
|
2480
|
-
const oldContent = "local old bytes\n";
|
|
2481
|
-
const remoteContent = "remote newer bytes\n";
|
|
2482
|
-
fs.mkdirSync(path.dirname(applied), { recursive: true });
|
|
2483
|
-
fs.writeFileSync(applied, oldContent);
|
|
2484
|
-
let releasePublish;
|
|
2485
|
-
let publishStartedResolve;
|
|
2486
|
-
const publishStarted = new Promise((resolve) => {
|
|
2487
|
-
publishStartedResolve = resolve;
|
|
2488
|
-
});
|
|
2489
|
-
const publishGate = new Promise((resolve) => {
|
|
2490
|
-
releasePublish = resolve;
|
|
2491
|
-
});
|
|
2492
|
-
const emitter = makeEmitter({
|
|
2493
|
-
published,
|
|
2494
|
-
lastPublishedContentHashMaxPaths: maxPaths,
|
|
2495
|
-
transport: {
|
|
2496
|
-
start: async () => { },
|
|
2497
|
-
dispose: async () => { },
|
|
2498
|
-
connected: true,
|
|
2499
|
-
publish: async (event) => {
|
|
2500
|
-
published.push(event);
|
|
2501
|
-
publishStartedResolve?.();
|
|
2502
|
-
await publishGate;
|
|
2503
|
-
},
|
|
2504
|
-
},
|
|
2505
|
-
});
|
|
2506
|
-
// Mark the path with an UNRELATED hash before the publish starts, so the
|
|
2507
|
-
// in-flight publish captures a NON-ZERO generation yet is NOT echo-suppressed
|
|
2508
|
-
// (the file's bytes differ from the marked hash).
|
|
2509
|
-
emitter.markContentPublished(relativePath, "sha256:00decoy00");
|
|
2510
|
-
const oldPublish = emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2511
|
-
await publishStarted;
|
|
2512
|
-
// A pull applies newer bytes while the old publish is in flight, taking a
|
|
2513
|
-
// fresh monotonic generation the captured value can never match.
|
|
2514
|
-
emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
|
|
2515
|
-
releasePublish?.();
|
|
2516
|
-
await oldPublish;
|
|
2517
|
-
fs.writeFileSync(applied, remoteContent);
|
|
2518
|
-
await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2519
|
-
// The stale in-flight publish could not resurrect its captured generation to
|
|
2520
|
-
// overwrite the newer pull mark, so the remote bytes stay suppressed.
|
|
2521
|
-
expect(published.filter((event) => event.relativePath === relativePath)).toHaveLength(1);
|
|
2522
|
-
expect(published[published.length - 1]).toMatchObject({
|
|
2523
|
-
kind: "upsert",
|
|
2524
|
-
relativePath,
|
|
2525
|
-
contentHash: `sha256:${createHash("sha256").update(oldContent).digest("hex")}`,
|
|
2526
|
-
});
|
|
2527
|
-
});
|
|
2528
|
-
it("does not resurrect an evicted generation to record a stale publish (ABA at the eviction boundary)", async () => {
|
|
2529
|
-
const published = [];
|
|
2530
|
-
const maxPaths = 2;
|
|
2531
|
-
const applied = path.join(dir, "companies", "indigo", "aba-race.md");
|
|
2532
|
-
const relativePath = "companies/indigo/aba-race.md";
|
|
2533
|
-
const oldContent = "local old bytes\n";
|
|
2534
|
-
const remoteContent = "remote newer bytes\n";
|
|
2535
|
-
const oldHash = `sha256:${createHash("sha256").update(oldContent).digest("hex")}`;
|
|
2536
|
-
fs.mkdirSync(path.dirname(applied), { recursive: true });
|
|
2537
|
-
fs.writeFileSync(applied, oldContent);
|
|
2538
|
-
let releasePublish;
|
|
2539
|
-
let firstStartedResolve;
|
|
2540
|
-
const firstStarted = new Promise((resolve) => {
|
|
2541
|
-
firstStartedResolve = resolve;
|
|
2542
|
-
});
|
|
2543
|
-
const publishGate = new Promise((resolve) => {
|
|
2544
|
-
releasePublish = resolve;
|
|
2545
|
-
});
|
|
2546
|
-
let publishCount = 0;
|
|
2547
|
-
const emitter = makeEmitter({
|
|
2548
|
-
published,
|
|
2549
|
-
lastPublishedContentHashMaxPaths: maxPaths,
|
|
2550
|
-
transport: {
|
|
2551
|
-
start: async () => { },
|
|
2552
|
-
dispose: async () => { },
|
|
2553
|
-
connected: true,
|
|
2554
|
-
publish: async (event) => {
|
|
2555
|
-
published.push(event);
|
|
2556
|
-
publishCount += 1;
|
|
2557
|
-
// Gate only the FIRST publish so it is still in flight while the pull
|
|
2558
|
-
// mark and eviction happen underneath it.
|
|
2559
|
-
if (publishCount === 1) {
|
|
2560
|
-
firstStartedResolve?.();
|
|
2561
|
-
await publishGate;
|
|
2562
|
-
}
|
|
2563
|
-
},
|
|
2564
|
-
},
|
|
2565
|
-
});
|
|
2566
|
-
// The path has NEVER been marked, so the in-flight publish captures the
|
|
2567
|
-
// never-marked sentinel — the exact ABA precondition.
|
|
2568
|
-
const firstPublish = emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2569
|
-
await firstStarted;
|
|
2570
|
-
// A pull applies newer bytes for this path while the publish is in flight...
|
|
2571
|
-
emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
|
|
2572
|
-
// ...then enough other paths are marked to evict this path's generation entry,
|
|
2573
|
-
// collapsing it back to the absent sentinel.
|
|
2574
|
-
for (let i = 0; i < maxPaths * 4; i += 1) {
|
|
2575
|
-
emitter.markContentPublished(`companies/indigo/aba-churn-${i}.md`, `sha256:z${i}`);
|
|
2576
|
-
}
|
|
2577
|
-
releasePublish?.();
|
|
2578
|
-
await firstPublish;
|
|
2579
|
-
// The stale completion must NOT have recorded oldHash for this path. If it
|
|
2580
|
-
// had (the ABA bug), the genuine local revert to oldContent below would be
|
|
2581
|
-
// wrongly suppressed as an echo and never published — silent data loss.
|
|
2582
|
-
await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
|
|
2583
|
-
const forPath = published.filter((event) => event.relativePath === relativePath);
|
|
2584
|
-
expect(forPath).toHaveLength(2);
|
|
2585
|
-
expect(forPath[1]).toMatchObject({ kind: "upsert", relativePath, contentHash: oldHash });
|
|
2586
|
-
});
|
|
2587
|
-
it("stamps an upsert with the byte length of the content it hashes", async () => {
|
|
2588
|
-
const published = [];
|
|
2589
|
-
const file = path.join(dir, "sized-content.bin");
|
|
2590
|
-
const content = Buffer.from([0, 1, 2, 3, 4, 5, 6]);
|
|
2591
|
-
fs.writeFileSync(file, content);
|
|
2592
|
-
await makeEmitter({ published }).emitForBatch({
|
|
2593
|
-
paths: new Map([[file, "sized-content.bin"]]),
|
|
2594
|
-
});
|
|
2595
|
-
expect(published).toHaveLength(1);
|
|
2596
|
-
expect(published[0]).toMatchObject({
|
|
2597
|
-
kind: "upsert",
|
|
2598
|
-
sizeBytes: content.byteLength,
|
|
2599
|
-
contentHash: `sha256:${createHash("sha256").update(content).digest("hex")}`,
|
|
2600
|
-
});
|
|
2601
|
-
});
|
|
2602
|
-
it("coalesces ten rewrites of one path into an immediate and trailing latest publish", async () => {
|
|
2603
|
-
const clock = new FakeClock();
|
|
2604
|
-
const published = [];
|
|
2605
|
-
const rewritten = path.join(dir, "high-churn-prd.json");
|
|
2606
|
-
const relativePath = "personal/projects/churn/prd.json";
|
|
2607
|
-
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
|
|
2608
|
-
const batch = { paths: new Map([[rewritten, relativePath]]) };
|
|
2609
|
-
for (let version = 0; version < 10; version += 1) {
|
|
2610
|
-
fs.writeFileSync(rewritten, `{"version":${version}}\n`);
|
|
2611
|
-
await emitter.emitForBatch(batch);
|
|
2612
|
-
if (version < 9)
|
|
2613
|
-
clock.advance(5_000);
|
|
2614
|
-
}
|
|
2615
|
-
expect(published).toHaveLength(1);
|
|
2616
|
-
clock.advance(15_000);
|
|
2617
|
-
await emitter.emitForBatch({ paths: new Map() });
|
|
2618
|
-
expect(published).toHaveLength(2);
|
|
2619
|
-
expect(clock.pendingTimerCount()).toBe(0);
|
|
2620
|
-
expect(published[1]).toMatchObject({ kind: "upsert", relativePath });
|
|
2621
|
-
expect(published[1].contentHash).toBe(`sha256:${createHash("sha256").update('{"version":9}\n').digest("hex")}`);
|
|
2622
|
-
});
|
|
2623
|
-
it("suppresses a deferred continuation queued behind a realtime-unavailable denial", async () => {
|
|
2624
|
-
const clock = new FakeClock();
|
|
2625
|
-
const published = [];
|
|
2626
|
-
const deferred = path.join(dir, "deferred-after-denial.json");
|
|
2627
|
-
const active = path.join(dir, "active-denial.json");
|
|
2628
|
-
const deferredRelativePath = "personal/projects/deferred-after-denial.json";
|
|
2629
|
-
let releaseDenial;
|
|
2630
|
-
let activePublishStarted;
|
|
2631
|
-
const denialGate = new Promise((resolve) => {
|
|
2632
|
-
releaseDenial = resolve;
|
|
2633
|
-
});
|
|
2634
|
-
const activeStarted = new Promise((resolve) => {
|
|
2635
|
-
activePublishStarted = resolve;
|
|
2636
|
-
});
|
|
2637
|
-
const emitter = makeEmitter({
|
|
2638
|
-
published,
|
|
2639
|
-
clock,
|
|
2640
|
-
publishMinIntervalMs: 60_000,
|
|
2641
|
-
transport: {
|
|
2642
|
-
start: async () => { },
|
|
2643
|
-
dispose: async () => { },
|
|
2644
|
-
connected: true,
|
|
2645
|
-
publish: async (event) => {
|
|
2646
|
-
published.push(event);
|
|
2647
|
-
if (event.relativePath === "personal/projects/active-denial.json") {
|
|
2648
|
-
activePublishStarted?.();
|
|
2649
|
-
await denialGate;
|
|
2650
|
-
throw new RealtimeUnavailableError({ status: 403, operation: "test push" });
|
|
2651
|
-
}
|
|
2652
|
-
},
|
|
2653
|
-
},
|
|
2654
|
-
});
|
|
2655
|
-
fs.writeFileSync(deferred, '{"revision":1}\n');
|
|
2656
|
-
await emitter.emitForBatch({ paths: new Map([[deferred, deferredRelativePath]]) });
|
|
2657
|
-
fs.writeFileSync(deferred, '{"revision":2}\n');
|
|
2658
|
-
await emitter.emitForBatch({ paths: new Map([[deferred, deferredRelativePath]]) });
|
|
2659
|
-
fs.writeFileSync(active, '{"active":true}\n');
|
|
2660
|
-
const activeRun = emitter.emitForBatch({
|
|
2661
|
-
paths: new Map([[active, "personal/projects/active-denial.json"]]),
|
|
2662
|
-
});
|
|
2663
|
-
await activeStarted;
|
|
2664
|
-
clock.advance(60_000);
|
|
2665
|
-
releaseDenial?.();
|
|
2666
|
-
await activeRun;
|
|
2667
|
-
for (let i = 0; i < 6; i++)
|
|
2668
|
-
await Promise.resolve();
|
|
2669
|
-
expect(emitter.realtimeUnavailable).toBe(true);
|
|
2670
|
-
expect(published.map((event) => event.relativePath)).toEqual([
|
|
2671
|
-
deferredRelativePath,
|
|
2672
|
-
"personal/projects/active-denial.json",
|
|
2673
|
-
]);
|
|
2674
|
-
});
|
|
2675
|
-
it("keeps publishing other companies after a company membership denial", async () => {
|
|
2676
|
-
const deniedRelativePath = "companies/liverecover/projects/denied.md";
|
|
2677
|
-
const allowedRelativePath = "companies/indigo/projects/allowed.md";
|
|
2678
|
-
const denied = path.join(dir, deniedRelativePath);
|
|
2679
|
-
const allowed = path.join(dir, allowedRelativePath);
|
|
2680
|
-
fs.mkdirSync(path.dirname(denied), { recursive: true });
|
|
2681
|
-
fs.mkdirSync(path.dirname(allowed), { recursive: true });
|
|
2682
|
-
fs.writeFileSync(denied, "denied\n");
|
|
2683
|
-
fs.writeFileSync(allowed, "allowed\n");
|
|
2684
|
-
const observedPaths = [];
|
|
2685
|
-
const fetch = vi.fn(async (_url, init) => {
|
|
2686
|
-
const relativePath = JSON.parse(init?.body ?? "{}").relativePath;
|
|
2687
|
-
observedPaths.push(relativePath);
|
|
2688
|
-
return relativePath === deniedRelativePath
|
|
2689
|
-
? {
|
|
2690
|
-
ok: false,
|
|
2691
|
-
status: 403,
|
|
2692
|
-
text: async () => JSON.stringify({
|
|
2693
|
-
error: "cross-tenant",
|
|
2694
|
-
code: "cross-tenant-push-rejected",
|
|
2695
|
-
}),
|
|
2696
|
-
}
|
|
2697
|
-
: { ok: true, status: 200, text: async () => "" };
|
|
2698
|
-
});
|
|
2699
|
-
const onError = vi.fn();
|
|
2700
|
-
const emitter = makeEmitter({
|
|
2701
|
-
onError,
|
|
2702
|
-
transport: new HttpPushTransport({
|
|
2703
|
-
apiUrl: "https://api.example.com",
|
|
2704
|
-
authToken: "token",
|
|
2705
|
-
fetchImpl: fetch,
|
|
2706
|
-
}),
|
|
2707
|
-
});
|
|
2708
|
-
await emitter.emitForBatch({ paths: new Map([[denied, deniedRelativePath]]) });
|
|
2709
|
-
await emitter.emitForBatch({ paths: new Map([[allowed, allowedRelativePath]]) });
|
|
2710
|
-
expect(fetch).toHaveBeenCalledTimes(2);
|
|
2711
|
-
expect(observedPaths).toEqual([deniedRelativePath, allowedRelativePath]);
|
|
2712
|
-
expect(emitter.realtimeUnavailable).toBe(false);
|
|
2713
|
-
expect(onError).toHaveBeenCalledTimes(1);
|
|
2714
|
-
expect(onError).toHaveBeenCalledWith(expect.any(PushScopeForbiddenError), { relativePath: deniedRelativePath });
|
|
2715
|
-
});
|
|
2716
|
-
it("suppresses repeated membership-denied publishes for one company scope", async () => {
|
|
2717
|
-
const relativePath = "companies/liverecover/projects/retry-loop.md";
|
|
2718
|
-
const file = path.join(dir, relativePath);
|
|
2719
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2720
|
-
fs.writeFileSync(file, "still local\n");
|
|
2721
|
-
const fetch = vi.fn(async () => ({
|
|
2722
|
-
ok: false,
|
|
2723
|
-
status: 403,
|
|
2724
|
-
text: async () => JSON.stringify({
|
|
2725
|
-
error: "cross-tenant",
|
|
2726
|
-
code: "cross-tenant-push-rejected",
|
|
2727
|
-
}),
|
|
2728
|
-
}));
|
|
2729
|
-
const onError = vi.fn();
|
|
2730
|
-
const emitter = makeEmitter({
|
|
2731
|
-
onError,
|
|
2732
|
-
transport: new HttpPushTransport({
|
|
2733
|
-
apiUrl: "https://api.example.com",
|
|
2734
|
-
authToken: "token",
|
|
2735
|
-
fetchImpl: fetch,
|
|
2736
|
-
}),
|
|
2737
|
-
});
|
|
2738
|
-
const batch = { paths: new Map([[file, relativePath]]) };
|
|
2739
|
-
await emitter.emitForBatch(batch);
|
|
2740
|
-
await emitter.emitForBatch(batch);
|
|
2741
|
-
await emitter.emitForBatch(batch);
|
|
2742
|
-
expect(fetch).toHaveBeenCalledTimes(1);
|
|
2743
|
-
expect(onError).toHaveBeenCalledTimes(1);
|
|
2744
|
-
expect(onError).toHaveBeenCalledWith(expect.any(PushScopeForbiddenError), { relativePath });
|
|
2745
|
-
});
|
|
2746
|
-
it("publishes the uploaded deferred revision when the local file changes before flush", async () => {
|
|
2747
|
-
const clock = new FakeClock();
|
|
2748
|
-
const published = [];
|
|
2749
|
-
const rewritten = path.join(dir, "captured-deferred-revision.json");
|
|
2750
|
-
const relativePath = "personal/projects/captured/prd.json";
|
|
2751
|
-
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
|
|
2752
|
-
const batch = { paths: new Map([[rewritten, relativePath]]) };
|
|
2753
|
-
fs.writeFileSync(rewritten, '{"revision":"uploaded"}\n');
|
|
2754
|
-
await emitter.emitForBatch(batch);
|
|
2755
|
-
fs.writeFileSync(rewritten, '{"revision":"deferred-uploaded"}\n');
|
|
2756
|
-
await emitter.emitForBatch(batch);
|
|
2757
|
-
const expectedHash = `sha256:${createHash("sha256")
|
|
2758
|
-
.update('{"revision":"deferred-uploaded"}\n')
|
|
2759
|
-
.digest("hex")}`;
|
|
2760
|
-
// This local revision has not completed its scoped push yet. The trailing
|
|
2761
|
-
// announcement must remain bound to the previously uploaded bytes.
|
|
2762
|
-
fs.writeFileSync(rewritten, '{"revision":"newer-local"}\n');
|
|
2763
|
-
clock.advance(60_000);
|
|
2764
|
-
await emitter.emitForBatch({ paths: new Map() });
|
|
2765
|
-
expect(published).toHaveLength(2);
|
|
2766
|
-
expect(published[1]).toMatchObject({
|
|
2767
|
-
kind: "upsert",
|
|
2768
|
-
relativePath,
|
|
2769
|
-
contentHash: expectedHash,
|
|
2770
|
-
});
|
|
2771
|
-
expect(published[1].contentHash).not.toBe(`sha256:${createHash("sha256")
|
|
2772
|
-
.update('{"revision":"newer-local"}\n')
|
|
2773
|
-
.digest("hex")}`);
|
|
2774
|
-
});
|
|
2775
|
-
it("skips the trailing publish when the final rewrite restores the last published hash", async () => {
|
|
2776
|
-
const clock = new FakeClock();
|
|
2777
|
-
const published = [];
|
|
2778
|
-
const rewritten = path.join(dir, "reverted-prd.json");
|
|
2779
|
-
const relativePath = "personal/projects/reverted/prd.json";
|
|
2780
|
-
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
|
|
2781
|
-
const batch = { paths: new Map([[rewritten, relativePath]]) };
|
|
2782
|
-
fs.writeFileSync(rewritten, "A\n");
|
|
2783
|
-
await emitter.emitForBatch(batch);
|
|
2784
|
-
fs.writeFileSync(rewritten, "B\n");
|
|
2785
|
-
await emitter.emitForBatch(batch);
|
|
2786
|
-
fs.writeFileSync(rewritten, "A\n");
|
|
2787
|
-
await emitter.emitForBatch(batch);
|
|
2788
|
-
clock.advance(60_000);
|
|
2789
|
-
await emitter.emitForBatch({ paths: new Map() });
|
|
2790
|
-
expect(published).toHaveLength(1);
|
|
2791
|
-
expect(clock.pendingTimerCount()).toBe(0);
|
|
2792
|
-
expect(published[0]).toMatchObject({ kind: "upsert", relativePath });
|
|
2793
|
-
});
|
|
2794
|
-
it("publishes a delete immediately while an upsert is deferred", async () => {
|
|
2795
|
-
const clock = new FakeClock();
|
|
2796
|
-
const published = [];
|
|
2797
|
-
const deleted = path.join(dir, "delete-during-quiet.jsonl");
|
|
2798
|
-
const relativePath = "workspace/.session-logs/delete-during-quiet.jsonl";
|
|
2799
|
-
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
|
|
2800
|
-
const batch = { paths: new Map([[deleted, relativePath]]) };
|
|
2801
|
-
fs.writeFileSync(deleted, "A\n");
|
|
2802
|
-
await emitter.emitForBatch(batch);
|
|
2803
|
-
fs.writeFileSync(deleted, "B\n");
|
|
2804
|
-
await emitter.emitForBatch(batch);
|
|
2805
|
-
fs.rmSync(deleted);
|
|
2806
|
-
await emitter.emitForBatch(batch);
|
|
2807
|
-
expect(published.map((event) => event.kind)).toEqual(["upsert", "delete"]);
|
|
2808
|
-
clock.advance(60_000);
|
|
2809
|
-
await emitter.emitForBatch({ paths: new Map() });
|
|
2810
|
-
expect(published).toHaveLength(2);
|
|
2811
|
-
});
|
|
2812
|
-
it("does not make a second path wait behind another path's quiet interval", async () => {
|
|
2813
|
-
const published = [];
|
|
2814
|
-
const first = path.join(dir, "first-high-churn.jsonl");
|
|
2815
|
-
const second = path.join(dir, "independent.jsonl");
|
|
2816
|
-
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000 });
|
|
2817
|
-
fs.writeFileSync(first, "A\n");
|
|
2818
|
-
fs.writeFileSync(second, "B\n");
|
|
2819
|
-
await emitter.emitForBatch({ paths: new Map([[first, "workspace/.session-logs/first.jsonl"]]) });
|
|
2820
|
-
fs.writeFileSync(first, "A2\n");
|
|
2821
|
-
await emitter.emitForBatch({ paths: new Map([[first, "workspace/.session-logs/first.jsonl"]]) });
|
|
2822
|
-
await emitter.emitForBatch({ paths: new Map([[second, "workspace/.session-logs/second.jsonl"]]) });
|
|
2823
|
-
expect(published.map((event) => event.relativePath)).toEqual([
|
|
2824
|
-
"workspace/.session-logs/first.jsonl",
|
|
2825
|
-
"workspace/.session-logs/second.jsonl",
|
|
2826
|
-
]);
|
|
2827
|
-
});
|
|
2828
|
-
it("publishes a content revert because it compares only with the last publication", async () => {
|
|
2829
|
-
const published = [];
|
|
2830
|
-
const reverted = path.join(dir, "reverted-session-log.jsonl");
|
|
2831
|
-
fs.writeFileSync(reverted, "A\n");
|
|
2832
|
-
const emitter = makeEmitter({ published });
|
|
2833
|
-
const batch = { paths: new Map([[reverted, "workspace/.session-logs/reverted-session-log.jsonl"]]) };
|
|
2834
|
-
await emitter.emitForBatch(batch);
|
|
2835
|
-
fs.writeFileSync(reverted, "B\n");
|
|
2836
|
-
await emitter.emitForBatch(batch);
|
|
2837
|
-
fs.writeFileSync(reverted, "A\n");
|
|
2838
|
-
await emitter.emitForBatch(batch);
|
|
2839
|
-
expect(published).toHaveLength(3);
|
|
2840
|
-
expect(published[0].contentHash).toBe(published[2].contentHash);
|
|
2841
|
-
expect(published[0].contentHash).not.toBe(published[1].contentHash);
|
|
2842
|
-
});
|
|
2843
|
-
it("re-announces a revert after an intervening changed publication fails", async () => {
|
|
2844
|
-
const published = [];
|
|
2845
|
-
let fail = false;
|
|
2846
|
-
const changed = path.join(dir, "failed-revert-session-log.jsonl");
|
|
2847
|
-
fs.writeFileSync(changed, "A\n");
|
|
2848
|
-
const emitter = new PushEventEmitter({
|
|
2849
|
-
originTenantId: "tenant-indigo",
|
|
2850
|
-
originDeviceId: "device-a",
|
|
2851
|
-
flagProvider: { isEnabled: () => true },
|
|
2852
|
-
publishMinIntervalMs: 0,
|
|
2853
|
-
transport: {
|
|
2854
|
-
start: async () => { },
|
|
2855
|
-
dispose: async () => { },
|
|
2856
|
-
connected: true,
|
|
2857
|
-
publish: async (event) => {
|
|
2858
|
-
if (fail)
|
|
2859
|
-
throw new Error("push unavailable");
|
|
2860
|
-
published.push(event);
|
|
2861
|
-
},
|
|
2862
|
-
},
|
|
2863
|
-
onError: vi.fn(),
|
|
2864
|
-
});
|
|
2865
|
-
const batch = { paths: new Map([[changed, "workspace/.session-logs/failed-revert-session-log.jsonl"]]) };
|
|
2866
|
-
await emitter.emitForBatch(batch);
|
|
2867
|
-
fs.writeFileSync(changed, "B\n");
|
|
2868
|
-
fail = true;
|
|
2869
|
-
await emitter.emitForBatch(batch);
|
|
2870
|
-
fs.writeFileSync(changed, "A\n");
|
|
2871
|
-
fail = false;
|
|
2872
|
-
await emitter.emitForBatch(batch);
|
|
2873
|
-
expect(published).toHaveLength(2);
|
|
2874
|
-
expect(published[0].contentHash).toBe(published[1].contentHash);
|
|
2875
|
-
});
|
|
2876
|
-
it("always publishes deletes after an unchanged touch and clears the path state", async () => {
|
|
2877
|
-
const published = [];
|
|
2878
|
-
const deleted = path.join(dir, "deleted-after-touch.jsonl");
|
|
2879
|
-
fs.writeFileSync(deleted, "A\n");
|
|
2880
|
-
const emitter = makeEmitter({ published });
|
|
2881
|
-
const batch = { paths: new Map([[deleted, "workspace/.session-logs/deleted-after-touch.jsonl"]]) };
|
|
2882
|
-
await emitter.emitForBatch(batch);
|
|
2883
|
-
await emitter.emitForBatch(batch);
|
|
2884
|
-
fs.rmSync(deleted);
|
|
2885
|
-
await emitter.emitForBatch(batch);
|
|
2886
|
-
fs.writeFileSync(deleted, "A\n");
|
|
2887
|
-
await emitter.emitForBatch(batch);
|
|
2888
|
-
expect(published).toHaveLength(3);
|
|
2889
|
-
expect(published.map((event) => event.kind)).toEqual(["upsert", "delete", "upsert"]);
|
|
2890
|
-
});
|
|
2891
|
-
it("re-announces a same-content recreation after a failed delete", async () => {
|
|
2892
|
-
const published = [];
|
|
2893
|
-
let fail = false;
|
|
2894
|
-
const deleted = path.join(dir, "failed-delete-session-log.jsonl");
|
|
2895
|
-
fs.writeFileSync(deleted, "A\n");
|
|
2896
|
-
const emitter = new PushEventEmitter({
|
|
2897
|
-
originTenantId: "tenant-indigo",
|
|
2898
|
-
originDeviceId: "device-a",
|
|
2899
|
-
flagProvider: { isEnabled: () => true },
|
|
2900
|
-
transport: {
|
|
2901
|
-
start: async () => { },
|
|
2902
|
-
dispose: async () => { },
|
|
2903
|
-
connected: true,
|
|
2904
|
-
publish: async (event) => {
|
|
2905
|
-
if (fail)
|
|
2906
|
-
throw new Error("push unavailable");
|
|
2907
|
-
published.push(event);
|
|
2908
|
-
},
|
|
2909
|
-
},
|
|
2910
|
-
onError: vi.fn(),
|
|
2911
|
-
});
|
|
2912
|
-
const batch = { paths: new Map([[deleted, "workspace/.session-logs/failed-delete-session-log.jsonl"]]) };
|
|
2913
|
-
await emitter.emitForBatch(batch);
|
|
2914
|
-
fs.rmSync(deleted);
|
|
2915
|
-
fail = true;
|
|
2916
|
-
await emitter.emitForBatch(batch);
|
|
2917
|
-
fs.writeFileSync(deleted, "A\n");
|
|
2918
|
-
fail = false;
|
|
2919
|
-
await emitter.emitForBatch(batch);
|
|
2920
|
-
expect(published.map((event) => event.kind)).toEqual(["upsert", "upsert"]);
|
|
2921
|
-
});
|
|
2922
|
-
it("bounds last-published content state and only re-publishes an evicted path", async () => {
|
|
2923
|
-
const published = [];
|
|
2924
|
-
const emitter = makeEmitter({ published, lastPublishedContentHashMaxPaths: 2 });
|
|
2925
|
-
const paths = ["first.jsonl", "second.jsonl", "third.jsonl"].map((name) => {
|
|
2926
|
-
const absolute = path.join(dir, name);
|
|
2927
|
-
fs.writeFileSync(absolute, `${name}\n`);
|
|
2928
|
-
return [absolute, `workspace/.session-logs/${name}`];
|
|
2929
|
-
});
|
|
2930
|
-
for (const entry of paths)
|
|
2931
|
-
await emitter.emitForBatch({ paths: new Map([entry]) });
|
|
2932
|
-
await emitter.emitForBatch({ paths: new Map([paths[0]]) });
|
|
2933
|
-
await emitter.emitForBatch({ paths: new Map([paths[2]]) });
|
|
2934
|
-
expect(published.map((event) => event.relativePath)).toEqual([
|
|
2935
|
-
"workspace/.session-logs/first.jsonl",
|
|
2936
|
-
"workspace/.session-logs/second.jsonl",
|
|
2937
|
-
"workspace/.session-logs/third.jsonl",
|
|
2938
|
-
"workspace/.session-logs/first.jsonl",
|
|
2939
|
-
]);
|
|
2940
|
-
});
|
|
2941
|
-
it("restart simulation: preserves published hashes so an unchanged first pass emits zero upserts", async () => {
|
|
2942
|
-
const published = [];
|
|
2943
|
-
const journaled = path.join(dir, "pre-populated-journal.jsonl");
|
|
2944
|
-
fs.writeFileSync(journaled, "A\n");
|
|
2945
|
-
const batch = { paths: new Map([[journaled, "workspace/.session-logs/pre-populated-journal.jsonl"]]) };
|
|
2946
|
-
const stateDir = path.join(dir, "state");
|
|
2947
|
-
await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
|
|
2948
|
-
await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
|
|
2949
|
-
expect(published).toHaveLength(1);
|
|
2950
|
-
fs.writeFileSync(journaled, "B\n");
|
|
2951
|
-
await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
|
|
2952
|
-
expect(published).toHaveLength(2);
|
|
2953
|
-
expect(published[1].contentHash).not.toBe(published[0].contentHash);
|
|
2954
|
-
});
|
|
2955
|
-
it("restart simulation: publishes a tombstone for a delete during downtime", async () => {
|
|
2956
|
-
const published = [];
|
|
2957
|
-
const deleted = path.join(dir, "deleted-during-downtime.md");
|
|
2958
|
-
const batch = { paths: new Map([[deleted, "deleted-during-downtime.md"]]) };
|
|
2959
|
-
const stateDir = path.join(dir, "state");
|
|
2960
|
-
fs.writeFileSync(deleted, "A\n");
|
|
2961
|
-
await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
|
|
2962
|
-
fs.rmSync(deleted);
|
|
2963
|
-
await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
|
|
2964
|
-
expect(published.map((event) => event.kind)).toEqual(["upsert", "delete"]);
|
|
2965
|
-
});
|
|
2966
|
-
it("journal baseline suppresses unchanged files when durable state is first created", async () => {
|
|
2967
|
-
const published = [];
|
|
2968
|
-
const existing = path.join(dir, "journaled-before-upgrade.md");
|
|
2969
|
-
const relativePath = "companies/indigo/journaled-before-upgrade.md";
|
|
2970
|
-
fs.writeFileSync(existing, "already synced\n");
|
|
2971
|
-
const contentHash = `sha256:${createHash("sha256").update("already synced\n").digest("hex")}`;
|
|
2972
|
-
await makeEmitter({
|
|
2973
|
-
published,
|
|
2974
|
-
lastPublishedContentHashStateDir: path.join(dir, "baseline-state"),
|
|
2975
|
-
initialPublishedContentHashes: { [relativePath]: contentHash },
|
|
2976
|
-
}).emitForBatch({ paths: new Map([[existing, relativePath]]) });
|
|
2977
|
-
expect(published).toEqual([]);
|
|
2978
|
-
});
|
|
2979
|
-
it("does not seed suppression for a journaled local divergence", async () => {
|
|
2980
|
-
const published = [];
|
|
2981
|
-
const changed = path.join(dir, "changed-while-down.md");
|
|
2982
|
-
const relativePath = "companies/indigo/changed-while-down.md";
|
|
2983
|
-
fs.writeFileSync(changed, "changed while down\n");
|
|
2984
|
-
const contentHash = `sha256:${createHash("sha256").update("changed while down\n").digest("hex")}`;
|
|
2985
|
-
await makeEmitter({
|
|
2986
|
-
published,
|
|
2987
|
-
lastPublishedContentHashStateDir: path.join(dir, "divergent-state"),
|
|
2988
|
-
}).emitForBatch({ paths: new Map([[changed, relativePath]]) });
|
|
2989
|
-
expect(published).toHaveLength(1);
|
|
2990
|
-
expect(published[0]).toMatchObject({ kind: "upsert", contentHash });
|
|
2991
|
-
});
|
|
2992
|
-
it("fresh durable state publishes the first uploaded file", async () => {
|
|
2993
|
-
const published = [];
|
|
2994
|
-
const fresh = path.join(dir, "fresh-device.md");
|
|
2995
|
-
fs.writeFileSync(fresh, "first upload\n");
|
|
2996
|
-
await makeEmitter({
|
|
2997
|
-
published,
|
|
2998
|
-
lastPublishedContentHashStateDir: path.join(dir, "fresh-state"),
|
|
2999
|
-
}).emitForBatch({ paths: new Map([[fresh, "fresh-device.md"]]) });
|
|
3000
|
-
expect(published).toHaveLength(1);
|
|
3001
|
-
expect(published[0]).toMatchObject({ kind: "upsert", relativePath: "fresh-device.md" });
|
|
3002
|
-
});
|
|
3003
|
-
it("retries a durable publish-state lock timeout without rejecting the watcher announce", async () => {
|
|
3004
|
-
const clock = new FakeClock();
|
|
3005
|
-
const onError = vi.fn();
|
|
3006
|
-
const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
|
|
3007
|
-
const appendGeneratedIfAsync = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
|
|
3008
|
-
.mockImplementationOnce(function (type, preparePayload, flags) {
|
|
3009
|
-
return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
|
|
3010
|
-
})
|
|
3011
|
-
.mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
|
|
3012
|
-
const published = [];
|
|
3013
|
-
const changed = path.join(dir, "lock-timeout.md");
|
|
3014
|
-
fs.writeFileSync(changed, "lock timeout\n");
|
|
3015
|
-
try {
|
|
3016
|
-
await expect(makeEmitter({
|
|
3017
|
-
clock,
|
|
3018
|
-
onError,
|
|
3019
|
-
published,
|
|
3020
|
-
lastPublishedContentHashStateDir: path.join(dir, "lock-timeout-state"),
|
|
3021
|
-
}).emitForBatch({ paths: new Map([[changed, "lock-timeout.md"]]) })).resolves.toBeUndefined();
|
|
3022
|
-
expect(published).toHaveLength(1);
|
|
3023
|
-
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ name: "StateStoreLockError" }), { relativePath: "lock-timeout.md" });
|
|
3024
|
-
expect(appendGeneratedIfAsync).toHaveBeenCalledTimes(2); // invalidate before the guarded durable set
|
|
3025
|
-
const appendsBeforeRetry = appendGeneratedIfAsync.mock.calls.length;
|
|
3026
|
-
clock.advance(250);
|
|
3027
|
-
await Promise.resolve();
|
|
3028
|
-
await Promise.resolve();
|
|
3029
|
-
expect(appendGeneratedIfAsync).toHaveBeenCalledTimes(appendsBeforeRetry + 1);
|
|
3030
|
-
}
|
|
3031
|
-
finally {
|
|
3032
|
-
appendGeneratedIfAsync.mockRestore();
|
|
3033
|
-
}
|
|
3034
|
-
});
|
|
3035
|
-
it("cancels a stale durable-state retry after persisting a newer hash", async () => {
|
|
3036
|
-
const clock = new FakeClock();
|
|
3037
|
-
const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
|
|
3038
|
-
const appendGeneratedIfAsync = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
|
|
3039
|
-
.mockImplementationOnce(function (type, preparePayload, flags) {
|
|
3040
|
-
return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
|
|
3041
|
-
})
|
|
3042
|
-
.mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
|
|
3043
|
-
const stateDir = path.join(dir, "superseded-retry-state");
|
|
3044
|
-
const changed = path.join(dir, "superseded-retry.md");
|
|
3045
|
-
const published = [];
|
|
3046
|
-
fs.writeFileSync(changed, "A\n");
|
|
3047
|
-
try {
|
|
3048
|
-
const emitter = makeEmitter({ clock, published, lastPublishedContentHashStateDir: stateDir });
|
|
3049
|
-
await emitter.emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
|
|
3050
|
-
expect(clock.pendingTimerCount()).toBe(1);
|
|
3051
|
-
fs.writeFileSync(changed, "B\n");
|
|
3052
|
-
await emitter.emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
|
|
3053
|
-
expect(clock.pendingTimerCount()).toBe(0);
|
|
3054
|
-
const appendsAfterNewerSuccess = appendGeneratedIfAsync.mock.calls.length;
|
|
3055
|
-
clock.advance(10_000);
|
|
3056
|
-
await Promise.resolve();
|
|
3057
|
-
expect(appendGeneratedIfAsync).toHaveBeenCalledTimes(appendsAfterNewerSuccess);
|
|
3058
|
-
const afterRestart = [];
|
|
3059
|
-
await makeEmitter({
|
|
3060
|
-
published: afterRestart,
|
|
3061
|
-
lastPublishedContentHashStateDir: stateDir,
|
|
3062
|
-
}).emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
|
|
3063
|
-
expect(afterRestart).toEqual([]);
|
|
3064
|
-
}
|
|
3065
|
-
finally {
|
|
3066
|
-
appendGeneratedIfAsync.mockRestore();
|
|
3067
|
-
}
|
|
3068
|
-
});
|
|
3069
|
-
it("drops permanent durable-state persistence errors without scheduling retries", async () => {
|
|
3070
|
-
const clock = new FakeClock();
|
|
3071
|
-
const onError = vi.fn();
|
|
3072
|
-
const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
|
|
3073
|
-
const appendGeneratedIfAsync = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
|
|
3074
|
-
.mockImplementationOnce(function (type, preparePayload, flags) {
|
|
3075
|
-
return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
|
|
3076
|
-
})
|
|
3077
|
-
.mockRejectedValue(new Error("EACCES: state directory is unwritable"));
|
|
3078
|
-
const changed = path.join(dir, "permanent-state-error.md");
|
|
3079
|
-
fs.writeFileSync(changed, "cannot persist\n");
|
|
3080
|
-
try {
|
|
3081
|
-
await makeEmitter({
|
|
3082
|
-
clock,
|
|
3083
|
-
onError,
|
|
3084
|
-
lastPublishedContentHashStateDir: path.join(dir, "permanent-state-error"),
|
|
3085
|
-
}).emitForBatch({ paths: new Map([[changed, "permanent-state-error.md"]]) });
|
|
3086
|
-
expect(onError).toHaveBeenCalledTimes(1);
|
|
3087
|
-
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("EACCES") }), { relativePath: "permanent-state-error.md" });
|
|
3088
|
-
expect(clock.pendingTimerCount()).toBe(0);
|
|
3089
|
-
const appendsAfterFailure = appendGeneratedIfAsync.mock.calls.length;
|
|
3090
|
-
clock.advance(60_000);
|
|
3091
|
-
await Promise.resolve();
|
|
3092
|
-
expect(appendGeneratedIfAsync).toHaveBeenCalledTimes(appendsAfterFailure);
|
|
3093
|
-
expect(onError).toHaveBeenCalledTimes(1);
|
|
3094
|
-
}
|
|
3095
|
-
finally {
|
|
3096
|
-
appendGeneratedIfAsync.mockRestore();
|
|
3097
|
-
}
|
|
3098
|
-
});
|
|
3099
|
-
it("emits push telemetry only when publication fails", async () => {
|
|
3100
|
-
const posts = [];
|
|
3101
|
-
const telemetryClient = {
|
|
3102
|
-
postTelemetryEvents: vi.fn(async (batch) => {
|
|
3103
|
-
posts.push(batch);
|
|
3104
|
-
return { ok: true, written: batch.events.length, skipped: [] };
|
|
3105
|
-
}),
|
|
3106
|
-
};
|
|
3107
|
-
const changed = path.join(dir, "changed.md");
|
|
3108
|
-
fs.writeFileSync(changed, "changed");
|
|
3109
|
-
const success = new PushEventEmitter({
|
|
3110
|
-
originTenantId: "tenant-indigo",
|
|
3111
|
-
originDeviceId: "device-a",
|
|
3112
|
-
transport: {
|
|
3113
|
-
start: async () => { },
|
|
3114
|
-
dispose: async () => { },
|
|
3115
|
-
connected: true,
|
|
3116
|
-
publish: async () => { },
|
|
3117
|
-
},
|
|
3118
|
-
flagProvider: { isEnabled: () => true },
|
|
3119
|
-
telemetryClient,
|
|
3120
|
-
});
|
|
3121
|
-
await success.emitForBatch({ paths: new Map([[changed, "changed.md"]]) });
|
|
3122
|
-
await flushImmediate();
|
|
3123
|
-
expect(posts).toEqual([]);
|
|
3124
|
-
const failed = new PushEventEmitter({
|
|
3125
|
-
originTenantId: "tenant-indigo",
|
|
3126
|
-
originDeviceId: "device-a",
|
|
3127
|
-
transport: {
|
|
3128
|
-
start: async () => { },
|
|
3129
|
-
dispose: async () => { },
|
|
3130
|
-
connected: true,
|
|
3131
|
-
publish: async () => {
|
|
3132
|
-
throw new Error("network down");
|
|
3133
|
-
},
|
|
3134
|
-
},
|
|
3135
|
-
flagProvider: { isEnabled: () => true },
|
|
3136
|
-
telemetryClient,
|
|
3137
|
-
onError: vi.fn(),
|
|
3138
|
-
});
|
|
3139
|
-
await failed.emitForBatch({ paths: new Map([[changed, "changed.md"]]) });
|
|
3140
|
-
await flushImmediate();
|
|
3141
|
-
expect(posts.flatMap((post) => post.events)).toEqual([
|
|
3142
|
-
expect.objectContaining({
|
|
3143
|
-
eventName: "push_event_failed",
|
|
3144
|
-
properties: {
|
|
3145
|
-
kind: "upsert",
|
|
3146
|
-
count: 1,
|
|
3147
|
-
status: "failure",
|
|
3148
|
-
stage: "publish",
|
|
3149
|
-
},
|
|
3150
|
-
}),
|
|
3151
|
-
]);
|
|
3152
|
-
});
|
|
3153
|
-
it("emits one outcome record with the correct mixed-batch attempt denominator", async () => {
|
|
3154
|
-
const first = path.join(dir, "first.md");
|
|
3155
|
-
const second = path.join(dir, "second.md");
|
|
3156
|
-
fs.writeFileSync(first, "first");
|
|
3157
|
-
fs.writeFileSync(second, "second");
|
|
3158
|
-
const outcomes = [];
|
|
3159
|
-
const emitter = new PushEventEmitter({
|
|
3160
|
-
originTenantId: "tenant-indigo",
|
|
3161
|
-
originDeviceId: "device-a",
|
|
3162
|
-
transport: {
|
|
3163
|
-
start: async () => { },
|
|
3164
|
-
dispose: async () => { },
|
|
3165
|
-
connected: true,
|
|
3166
|
-
publish: async (event) => {
|
|
3167
|
-
if (event.relativePath === "second.md") {
|
|
3168
|
-
throw new Error("second publish failed");
|
|
3169
|
-
}
|
|
3170
|
-
},
|
|
3171
|
-
},
|
|
3172
|
-
flagProvider: { isEnabled: () => true },
|
|
3173
|
-
getSequenceNumber: (() => {
|
|
3174
|
-
let next = 41;
|
|
3175
|
-
return () => next++;
|
|
3176
|
-
})(),
|
|
3177
|
-
onError: vi.fn(),
|
|
3178
|
-
onPublishOutcome: (outcome) => outcomes.push(outcome),
|
|
3179
|
-
});
|
|
3180
|
-
await emitter.emitForBatch({
|
|
3181
|
-
paths: new Map([
|
|
3182
|
-
[first, "first.md"],
|
|
3183
|
-
[second, "second.md"],
|
|
3184
|
-
]),
|
|
3185
|
-
});
|
|
3186
|
-
expect(outcomes).toHaveLength(1);
|
|
3187
|
-
expect(outcomes[0]).toMatchObject({
|
|
3188
|
-
attempted: 2,
|
|
3189
|
-
succeeded: 1,
|
|
3190
|
-
failed: 1,
|
|
3191
|
-
});
|
|
3192
|
-
expect(outcomes[0].omitted).toBe(0);
|
|
3193
|
-
expect(outcomes[0].attempts).toContainEqual(expect.objectContaining({
|
|
3194
|
-
error: expect.objectContaining({ message: "second publish failed" }),
|
|
3195
|
-
}));
|
|
3196
|
-
});
|
|
3197
|
-
it("caps successful attempt evidence while retaining every failure and full-set latency statistics", () => {
|
|
3198
|
-
const successes = Array.from({ length: 1_000 }, (_, index) => ({
|
|
3199
|
-
sequenceNumber: index,
|
|
3200
|
-
elapsedMs: index + 1,
|
|
3201
|
-
abortedAtIssue: false,
|
|
3202
|
-
}));
|
|
3203
|
-
const failures = Array.from({ length: 16 }, (_, index) => ({
|
|
3204
|
-
sequenceNumber: 10_000 + index,
|
|
3205
|
-
elapsedMs: 60_000 + index,
|
|
3206
|
-
abortedAtIssue: false,
|
|
3207
|
-
error: {
|
|
3208
|
-
name: "AbortError",
|
|
3209
|
-
message: `publish timeout ${index}`,
|
|
3210
|
-
stack: "at Timeout._onTimeout (push-transport.js:195)",
|
|
3211
|
-
},
|
|
3212
|
-
}));
|
|
3213
|
-
const outcome = summarizePublishBatchOutcome([...successes, ...failures]);
|
|
3214
|
-
const serialized = JSON.stringify(outcome);
|
|
3215
|
-
const retainedFailures = outcome.attempts.filter((attempt) => attempt.error !== undefined);
|
|
3216
|
-
const retainedSuccesses = outcome.attempts.filter((attempt) => attempt.error === undefined);
|
|
3217
|
-
expect(outcome).toMatchObject({
|
|
3218
|
-
attempted: 1_016,
|
|
3219
|
-
succeeded: 1_000,
|
|
3220
|
-
failed: 16,
|
|
3221
|
-
omitted: 1_000 - PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP,
|
|
3222
|
-
successfulElapsedMs: { min: 1, median: 500.5, max: 1_000 },
|
|
3223
|
-
});
|
|
3224
|
-
expect(retainedSuccesses).toEqual(successes.slice(0, 3));
|
|
3225
|
-
expect(retainedFailures).toEqual(failures);
|
|
3226
|
-
for (const aggregate of ["attempted", "succeeded", "failed"]) {
|
|
3227
|
-
expect(serialized.indexOf(`"${aggregate}"`)).toBeLessThan(serialized.indexOf('"attempts"'));
|
|
3228
|
-
}
|
|
3229
|
-
expect(Buffer.byteLength(serialized)).toBeLessThan(PUBLISH_OUTCOME_JOURNALD_CEILING_BYTES);
|
|
3230
|
-
});
|
|
3231
|
-
});
|
|
3232
|
-
describe("TreeWatcher wire-key codec boundary (US-002)", () => {
|
|
3233
|
-
it("emits the canonical vault key for an encoded win32 farm name (verbatim local name on POSIX)", () => {
|
|
3234
|
-
const hqRoot = path.join(os.tmpdir(), "hq-watch-codec-test-root");
|
|
3235
|
-
const clock = new FakeClock();
|
|
3236
|
-
const watcher = new TreeWatcher({
|
|
3237
|
-
hqRoot,
|
|
3238
|
-
clock,
|
|
3239
|
-
debounceMs: 100,
|
|
3240
|
-
pathFilter: () => true,
|
|
3241
|
-
});
|
|
3242
|
-
const rels = [];
|
|
3243
|
-
watcher.onChange((_first, batch) => {
|
|
3244
|
-
if (batch)
|
|
3245
|
-
rels.push(...batch.paths.values());
|
|
3246
|
-
});
|
|
3247
|
-
watcher.handleEvent(path.join(hqRoot, ".claude", "skills", "indigo%3Acall-prep-brief"));
|
|
3248
|
-
clock.advance(200);
|
|
3249
|
-
// The batch rel is a WIRE identifier (PushEvent.relativePath / targeted
|
|
3250
|
-
// push route). On win32 the encoded local farm name must decode back to
|
|
3251
|
-
// the canonical key or event-driven push re-mints the junk key family;
|
|
3252
|
-
// on POSIX the local name IS the key verbatim (codec is identity there).
|
|
3253
|
-
const expectedRel = process.platform === "win32"
|
|
3254
|
-
? ".claude/skills/indigo:call-prep-brief"
|
|
3255
|
-
: ".claude/skills/indigo%3Acall-prep-brief";
|
|
3256
|
-
expect(rels).toEqual([expectedRel]);
|
|
3257
|
-
watcher.dispose();
|
|
3258
|
-
});
|
|
3259
|
-
});
|
|
3260
|
-
describe("TreeWatcher deferred delete snapshots", () => {
|
|
3261
|
-
it("does not let a thrown delete-capture callback kill later watcher delivery", () => {
|
|
3262
|
-
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-capture-error-"));
|
|
3263
|
-
const clock = new FakeClock();
|
|
3264
|
-
const watcher = new TreeWatcher({
|
|
3265
|
-
hqRoot,
|
|
3266
|
-
clock,
|
|
3267
|
-
debounceMs: 25,
|
|
3268
|
-
pathFilter: () => true,
|
|
3269
|
-
queueLocalDeleteSnapshots: () => {
|
|
3270
|
-
throw new Error("capture failed");
|
|
3271
|
-
},
|
|
3272
|
-
});
|
|
3273
|
-
const batches = [];
|
|
3274
|
-
watcher.onChange((_first, batch) => batches.push([...(batch?.paths.values() ?? [])]));
|
|
3275
|
-
expect(() => watcher.handleEvent(path.join(hqRoot, "deleted.md"), "unlink")).not.toThrow();
|
|
3276
|
-
expect(() => watcher.handleEvent(path.join(hqRoot, "later.md"), "add")).not.toThrow();
|
|
3277
|
-
clock.advance(25);
|
|
3278
|
-
expect(batches).toEqual([["deleted.md", "later.md"]]);
|
|
3279
|
-
watcher.dispose();
|
|
3280
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3281
|
-
});
|
|
3282
|
-
it("marks a queued unlink stale when a newer revision arrives", async () => {
|
|
3283
|
-
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-capture-stale-"));
|
|
3284
|
-
const clock = new FakeClock();
|
|
3285
|
-
let resolveCapture;
|
|
3286
|
-
let observedCurrent = true;
|
|
3287
|
-
const watcher = new TreeWatcher({
|
|
3288
|
-
hqRoot,
|
|
3289
|
-
clock,
|
|
3290
|
-
debounceMs: 25,
|
|
3291
|
-
pathFilter: () => true,
|
|
3292
|
-
queueLocalDeleteSnapshots: async (_relative, _kind, isCurrent) => {
|
|
3293
|
-
await new Promise((resolve) => { resolveCapture = resolve; });
|
|
3294
|
-
observedCurrent = isCurrent();
|
|
3295
|
-
return [];
|
|
3296
|
-
},
|
|
3297
|
-
});
|
|
3298
|
-
watcher.handleEvent(path.join(hqRoot, "recreated.md"), "unlink");
|
|
3299
|
-
await Promise.resolve();
|
|
3300
|
-
watcher.handleEvent(path.join(hqRoot, "recreated.md"), "add");
|
|
3301
|
-
resolveCapture();
|
|
3302
|
-
await Promise.resolve();
|
|
3303
|
-
expect(observedCurrent).toBe(false);
|
|
3304
|
-
watcher.dispose();
|
|
3305
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3306
|
-
});
|
|
3307
|
-
it("batches one watcher turn and keeps per-path current checks", async () => {
|
|
3308
|
-
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-capture-batch-"));
|
|
3309
|
-
const clock = new FakeClock();
|
|
3310
|
-
const observed = [];
|
|
3311
|
-
const queueBatch = async (captures) => {
|
|
3312
|
-
const results = captures.map((capture) => {
|
|
3313
|
-
const current = capture.isCurrent();
|
|
3314
|
-
observed.push({ relativePath: capture.relativePath, current });
|
|
3315
|
-
return current ? [{
|
|
3316
|
-
journalSlug: "indigo",
|
|
3317
|
-
journalPath: capture.relativePath,
|
|
3318
|
-
deleteScopeRoot: "",
|
|
3319
|
-
absolutePath: path.join(hqRoot, capture.relativePath),
|
|
3320
|
-
remoteEtag: "etag",
|
|
3321
|
-
localHash: "hash",
|
|
3322
|
-
localKind: "file",
|
|
3323
|
-
}] : [];
|
|
3324
|
-
});
|
|
3325
|
-
return results;
|
|
3326
|
-
};
|
|
3327
|
-
const watcher = new TreeWatcher({
|
|
3328
|
-
hqRoot,
|
|
3329
|
-
clock,
|
|
3330
|
-
debounceMs: 25,
|
|
3331
|
-
pathFilter: () => true,
|
|
3332
|
-
queueLocalDeleteSnapshotsBatch: queueBatch,
|
|
3333
|
-
});
|
|
3334
|
-
try {
|
|
3335
|
-
const recreated = path.join(hqRoot, "recreated.md");
|
|
3336
|
-
const deleted = path.join(hqRoot, "deleted.md");
|
|
3337
|
-
watcher.handleEvent(recreated, "unlink");
|
|
3338
|
-
watcher.handleEvent(recreated, "add");
|
|
3339
|
-
watcher.handleEvent(deleted, "unlink");
|
|
3340
|
-
await settle();
|
|
3341
|
-
expect(observed).toEqual([
|
|
3342
|
-
{ relativePath: "recreated.md", current: false },
|
|
3343
|
-
{ relativePath: "deleted.md", current: true },
|
|
3344
|
-
]);
|
|
3345
|
-
await settle();
|
|
3346
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
|
|
3347
|
-
}
|
|
3348
|
-
finally {
|
|
3349
|
-
watcher.dispose();
|
|
3350
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3351
|
-
}
|
|
3352
|
-
});
|
|
3353
|
-
// `deleteSnapshotEpochs` exists to let a newer revision of a path
|
|
3354
|
-
// invalidate a delete-snapshot capture that is still queued or running. A
|
|
3355
|
-
// long-lived runner touches hundreds of thousands of distinct paths, so a
|
|
3356
|
-
// key may live in that map only while such a capture is in flight; any
|
|
3357
|
-
// other write into it is a leak keyed by path.
|
|
3358
|
-
it("batches warmup missing-file snapshots through the queued capture path", async () => {
|
|
3359
|
-
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-warmup-capture-batch-"));
|
|
3360
|
-
const observed = [];
|
|
3361
|
-
const queueBatch = async (captures) => {
|
|
3362
|
-
observed.push(captures.map((capture) => capture.relativePath));
|
|
3363
|
-
return captures.map(() => []);
|
|
3364
|
-
};
|
|
3365
|
-
const watcher = new TreeWatcher({
|
|
3366
|
-
hqRoot,
|
|
3367
|
-
pathFilter: () => true,
|
|
3368
|
-
queueLocalDeleteSnapshotsBatch: queueBatch,
|
|
3369
|
-
});
|
|
3370
|
-
try {
|
|
3371
|
-
const catchup = await watcher.collectWarmupCatchupAsync(Date.now(), [
|
|
3372
|
-
"missing-one.md",
|
|
3373
|
-
"missing-two.md",
|
|
3374
|
-
]);
|
|
3375
|
-
await Promise.all([...(catchup.batch.changes?.values() ?? [])].map((change) => change.deleteSnapshotCapture ?? Promise.resolve([])));
|
|
3376
|
-
await settle();
|
|
3377
|
-
expect(observed).toEqual([["missing-one.md", "missing-two.md"]]);
|
|
3378
|
-
}
|
|
3379
|
-
finally {
|
|
3380
|
-
watcher.dispose();
|
|
3381
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3382
|
-
}
|
|
3383
|
-
});
|
|
3384
|
-
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
|
3385
|
-
it("retires a superseded capture's epoch when that capture completes", async () => {
|
|
3386
|
-
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-epoch-retire-"));
|
|
3387
|
-
const clock = new FakeClock();
|
|
3388
|
-
let resolveCapture;
|
|
3389
|
-
let observedCurrent = true;
|
|
3390
|
-
const watcher = new TreeWatcher({
|
|
3391
|
-
hqRoot,
|
|
3392
|
-
clock,
|
|
3393
|
-
debounceMs: 25,
|
|
3394
|
-
pathFilter: () => true,
|
|
3395
|
-
queueLocalDeleteSnapshots: async (_relative, _kind, isCurrent) => {
|
|
3396
|
-
await new Promise((resolve) => { resolveCapture = resolve; });
|
|
3397
|
-
observedCurrent = isCurrent();
|
|
3398
|
-
return [];
|
|
3399
|
-
},
|
|
3400
|
-
});
|
|
3401
|
-
try {
|
|
3402
|
-
const target = path.join(hqRoot, "recreated.md");
|
|
3403
|
-
watcher.handleEvent(target, "unlink");
|
|
3404
|
-
await Promise.resolve();
|
|
3405
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
|
|
3406
|
-
// The newer revision invalidates the running capture but must keep the
|
|
3407
|
-
// marker until that capture has actually observed it.
|
|
3408
|
-
watcher.handleEvent(target, "add");
|
|
3409
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
|
|
3410
|
-
resolveCapture();
|
|
3411
|
-
await settle();
|
|
3412
|
-
expect(observedCurrent).toBe(false);
|
|
3413
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
|
|
3414
|
-
}
|
|
3415
|
-
finally {
|
|
3416
|
-
watcher.dispose();
|
|
3417
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3418
|
-
}
|
|
3419
|
-
});
|
|
3420
|
-
it("never creates an epoch for a live event or for a synchronous capture", () => {
|
|
3421
|
-
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-epoch-live-"));
|
|
3422
|
-
const clock = new FakeClock();
|
|
3423
|
-
const capture = vi.fn(() => []);
|
|
3424
|
-
const watcher = new TreeWatcher({
|
|
3425
|
-
hqRoot,
|
|
3426
|
-
clock,
|
|
3427
|
-
debounceMs: 25,
|
|
3428
|
-
pathFilter: () => true,
|
|
3429
|
-
captureLocalDeleteSnapshots: capture,
|
|
3430
|
-
});
|
|
3431
|
-
try {
|
|
3432
|
-
watcher.handleEvent(path.join(hqRoot, "added.md"), "add");
|
|
3433
|
-
watcher.handleEvent(path.join(hqRoot, "edited.md"), "change");
|
|
3434
|
-
watcher.handleEvent(path.join(hqRoot, "made"), "addDir");
|
|
3435
|
-
watcher.handleEvent(path.join(hqRoot, "gone.md"), "unlink");
|
|
3436
|
-
watcher.handleEvent(path.join(hqRoot, "gone-dir"), "unlinkDir");
|
|
3437
|
-
clock.advance(25);
|
|
3438
|
-
expect(capture).toHaveBeenCalledTimes(2);
|
|
3439
|
-
// A synchronous capture has already run by the time the event returns:
|
|
3440
|
-
// there is nothing left to invalidate, so there is nothing to remember.
|
|
3441
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
|
|
3442
|
-
}
|
|
3443
|
-
finally {
|
|
3444
|
-
watcher.dispose();
|
|
3445
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3446
|
-
}
|
|
3447
|
-
});
|
|
3448
|
-
it("shares one epoch across overlapping captures of a path and retires it with the last", async () => {
|
|
3449
|
-
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-epoch-overlap-"));
|
|
3450
|
-
const clock = new FakeClock();
|
|
3451
|
-
const resolvers = [];
|
|
3452
|
-
const observed = [];
|
|
3453
|
-
const watcher = new TreeWatcher({
|
|
3454
|
-
hqRoot,
|
|
3455
|
-
clock,
|
|
3456
|
-
debounceMs: 25,
|
|
3457
|
-
pathFilter: () => true,
|
|
3458
|
-
queueLocalDeleteSnapshots: async (_relative, _kind, isCurrent) => {
|
|
3459
|
-
await new Promise((resolve) => { resolvers.push(resolve); });
|
|
3460
|
-
observed.push(isCurrent());
|
|
3461
|
-
return [];
|
|
3462
|
-
},
|
|
3463
|
-
});
|
|
3464
|
-
try {
|
|
3465
|
-
const target = path.join(hqRoot, "twice.md");
|
|
3466
|
-
watcher.handleEvent(target, "unlink");
|
|
3467
|
-
await Promise.resolve();
|
|
3468
|
-
watcher.handleEvent(target, "unlink"); // second delete in the window queues behind the first
|
|
3469
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
|
|
3470
|
-
resolvers.shift()();
|
|
3471
|
-
await settle();
|
|
3472
|
-
// The first capture was superseded by the second; the second is still
|
|
3473
|
-
// queued, so the path keeps its marker.
|
|
3474
|
-
expect(observed).toEqual([false]);
|
|
3475
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
|
|
3476
|
-
resolvers.shift()();
|
|
3477
|
-
await settle();
|
|
3478
|
-
expect(observed).toEqual([false, true]);
|
|
3479
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
|
|
3480
|
-
}
|
|
3481
|
-
finally {
|
|
3482
|
-
watcher.dispose();
|
|
3483
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3484
|
-
}
|
|
3485
|
-
});
|
|
3486
|
-
it("leaves no epoch for a path whose capture the full queue rejected", async () => {
|
|
3487
|
-
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-epoch-full-"));
|
|
3488
|
-
const clock = new FakeClock();
|
|
3489
|
-
const resolvers = [];
|
|
3490
|
-
const observed = [];
|
|
3491
|
-
const watcher = new TreeWatcher({
|
|
3492
|
-
hqRoot,
|
|
3493
|
-
clock,
|
|
3494
|
-
debounceMs: 25,
|
|
3495
|
-
pathFilter: () => true,
|
|
3496
|
-
maxPendingPaths: 2,
|
|
3497
|
-
queueLocalDeleteSnapshots: async (relative, _kind, isCurrent) => {
|
|
3498
|
-
await new Promise((resolve) => { resolvers.push(resolve); });
|
|
3499
|
-
observed.push(`${relative}:${isCurrent()}`);
|
|
3500
|
-
return [];
|
|
3501
|
-
},
|
|
3502
|
-
});
|
|
3503
|
-
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
3504
|
-
try {
|
|
3505
|
-
const a = path.join(hqRoot, "a.md");
|
|
3506
|
-
const b = path.join(hqRoot, "b.md");
|
|
3507
|
-
watcher.handleEvent(a, "unlink");
|
|
3508
|
-
await Promise.resolve();
|
|
3509
|
-
resolvers.shift()();
|
|
3510
|
-
await settle(); // a's capture is done; a is still pending in the window
|
|
3511
|
-
expect(observed).toEqual(["a.md:true"]);
|
|
3512
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
|
|
3513
|
-
watcher.handleEvent(b, "unlink");
|
|
3514
|
-
watcher.handleEvent(b, "unlink"); // two captures queued for b: the queue is full
|
|
3515
|
-
// A third delete of `a` finds the queue full. Nothing is in flight for
|
|
3516
|
-
// `a`, so nothing may be recorded for it either.
|
|
3517
|
-
watcher.handleEvent(a, "unlink");
|
|
3518
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
|
|
3519
|
-
// A full queue must still supersede a capture that IS in flight for the
|
|
3520
|
-
// path, exactly as it did before, and that path's marker still retires.
|
|
3521
|
-
watcher.handleEvent(b, "unlink");
|
|
3522
|
-
await settle(); // the first queued capture for b starts
|
|
3523
|
-
resolvers.shift()();
|
|
3524
|
-
await settle(); // ...and the second, queued behind it
|
|
3525
|
-
resolvers.shift()();
|
|
3526
|
-
await settle();
|
|
3527
|
-
expect(observed).toEqual(["a.md:true", "b.md:false", "b.md:false"]);
|
|
3528
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
|
|
3529
|
-
}
|
|
3530
|
-
finally {
|
|
3531
|
-
consoleError.mockRestore();
|
|
3532
|
-
watcher.dispose();
|
|
3533
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3534
|
-
}
|
|
3535
|
-
});
|
|
3536
|
-
});
|
|
3537
|
-
describe("TreeWatcher untracked delete drop", () => {
|
|
3538
|
-
// Atomic writers (`.name.<pid>.<random>.tmp` renamed into place, editor
|
|
3539
|
-
// swap files) create and remove a path within one debounce window. Such a
|
|
3540
|
-
// path has no journal row, so its unlink has nothing to delete remotely and
|
|
3541
|
-
// no revision to bind: queueing it only cost a delete-snapshot capture, a
|
|
3542
|
-
// pending-map slot, and a targeted push that found nothing on disk. On the
|
|
3543
|
-
// controller these were 370 of 380 agency watcher events per minute.
|
|
3544
|
-
function makeRoot() {
|
|
3545
|
-
return fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-untracked-"));
|
|
3546
|
-
}
|
|
3547
|
-
it("drops a file unlink that has no journal row before it reaches a batch", () => {
|
|
3548
|
-
const hqRoot = makeRoot();
|
|
3549
|
-
const clock = new FakeClock();
|
|
3550
|
-
const capture = vi.fn(() => []);
|
|
3551
|
-
const watcher = new TreeWatcher({
|
|
3552
|
-
hqRoot,
|
|
3553
|
-
clock,
|
|
3554
|
-
debounceMs: 25,
|
|
3555
|
-
pathFilter: () => true,
|
|
3556
|
-
captureLocalDeleteSnapshots: capture,
|
|
3557
|
-
isUntrackedDelete: (relativePath) => relativePath.endsWith(".tmp"),
|
|
3558
|
-
});
|
|
3559
|
-
const batches = [];
|
|
3560
|
-
watcher.onChange((_first, batch) => batches.push([...(batch?.paths.values() ?? [])]));
|
|
3561
|
-
try {
|
|
3562
|
-
watcher.handleEvent(path.join(hqRoot, "notes", ".draft.md.123.abc.tmp"), "unlink");
|
|
3563
|
-
watcher.handleEvent(path.join(hqRoot, "notes", "kept.md"), "unlink");
|
|
3564
|
-
clock.advance(25);
|
|
3565
|
-
expect(batches).toEqual([["notes/kept.md"]]);
|
|
3566
|
-
// The dropped path never paid for a delete-snapshot capture.
|
|
3567
|
-
expect(capture).toHaveBeenCalledTimes(1);
|
|
3568
|
-
expect(capture).toHaveBeenCalledWith("notes/kept.md", "unlink");
|
|
3569
|
-
expect(watcher.untrackedDeletesDropped()).toBe(1);
|
|
3570
|
-
// Neither path leaves an epoch behind: the kept unlink was captured
|
|
3571
|
-
// synchronously (nothing in flight to invalidate) and the dropped one
|
|
3572
|
-
// never had a capture at all.
|
|
3573
|
-
expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
|
|
3574
|
-
}
|
|
3575
|
-
finally {
|
|
3576
|
-
watcher.dispose();
|
|
3577
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3578
|
-
}
|
|
3579
|
-
});
|
|
3580
|
-
it("leaves no delete-snapshot epoch behind for an atomic write cancelled inside its own window", () => {
|
|
3581
|
-
// Every agency writer saves through `.<name>.<pid>.<random>.tmp` and
|
|
3582
|
-
// renames it into place, so the watcher sees an `add` and then an
|
|
3583
|
-
// `unlink` of a name it will never see again. 370 of those a minute on
|
|
3584
|
-
// the controller: if each one left a key in a per-path map, the map
|
|
3585
|
-
// would grow by half a million randomised paths a day.
|
|
3586
|
-
const hqRoot = makeRoot();
|
|
3587
|
-
const clock = new FakeClock();
|
|
3588
|
-
const queue = vi.fn(async () => []);
|
|
3589
|
-
const watcher = new TreeWatcher({
|
|
3590
|
-
hqRoot,
|
|
3591
|
-
clock,
|
|
3592
|
-
debounceMs: 25,
|
|
3593
|
-
pathFilter: () => true,
|
|
3594
|
-
queueLocalDeleteSnapshots: queue,
|
|
3595
|
-
isUntrackedDelete: (relativePath) => relativePath.endsWith(".tmp"),
|
|
3596
|
-
});
|
|
3597
|
-
const changed = vi.fn();
|
|
3598
|
-
watcher.onChange(changed);
|
|
3599
|
-
try {
|
|
3600
|
-
for (let i = 0; i < 250; i++) {
|
|
3601
|
-
const temp = path.join(hqRoot, "workspace", "agency", `.ledger.json.${i}.${(i * 7919).toString(36)}.tmp`);
|
|
3602
|
-
watcher.handleEvent(temp, "add");
|
|
3603
|
-
watcher.handleEvent(temp, "unlink");
|
|
3604
|
-
}
|
|
3605
|
-
clock.advance(1_000);
|
|
3606
|
-
expect(queue).not.toHaveBeenCalled();
|
|
3607
|
-
expect(changed).not.toHaveBeenCalled();
|
|
3608
|
-
expect(watcher.untrackedDeletesDropped()).toBe(250);
|
|
3609
|
-
expect(watcher.pendingTimerCount()).toBe(0);
|
|
3610
|
-
expect(watcher.censusSizes()).toMatchObject({ deleteSnapshotEpochs: 0, knownKinds: 0 });
|
|
3611
|
-
}
|
|
3612
|
-
finally {
|
|
3613
|
-
watcher.dispose();
|
|
3614
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3615
|
-
}
|
|
3616
|
-
});
|
|
3617
|
-
it("resolves a vanished rename to an unlink, purges the path's earlier queued add, and leaves no timer armed", () => {
|
|
3618
|
-
const hqRoot = makeRoot();
|
|
3619
|
-
const clock = new FakeClock();
|
|
3620
|
-
const seam = vi.fn((relativePath) => relativePath.endsWith(".tmp"));
|
|
3621
|
-
const watcher = new TreeWatcher({
|
|
3622
|
-
hqRoot,
|
|
3623
|
-
clock,
|
|
3624
|
-
debounceMs: 25,
|
|
3625
|
-
pathFilter: () => true,
|
|
3626
|
-
isUntrackedDelete: seam,
|
|
3627
|
-
});
|
|
3628
|
-
const changed = vi.fn();
|
|
3629
|
-
watcher.onChange(changed);
|
|
3630
|
-
try {
|
|
3631
|
-
const temp = path.join(hqRoot, "state.json.tmp");
|
|
3632
|
-
fs.writeFileSync(temp, "{}");
|
|
3633
|
-
watcher.handleEvent(temp, "rename"); // exists → add
|
|
3634
|
-
expect(watcher.pendingTimerCount()).toBe(1);
|
|
3635
|
-
fs.rmSync(temp);
|
|
3636
|
-
watcher.handleEvent(temp, "rename"); // ENOENT → unlink → untracked → dropped
|
|
3637
|
-
// The create and the delete cancelled out inside one window. Nothing is
|
|
3638
|
-
// left to emit, so the window closes without waking the push at all.
|
|
3639
|
-
expect(seam).toHaveBeenCalledWith("state.json.tmp");
|
|
3640
|
-
expect(watcher.pendingTimerCount()).toBe(0);
|
|
3641
|
-
clock.advance(1_000);
|
|
3642
|
-
expect(changed).not.toHaveBeenCalled();
|
|
3643
|
-
expect(watcher.untrackedDeletesDropped()).toBe(1);
|
|
3644
|
-
}
|
|
3645
|
-
finally {
|
|
3646
|
-
watcher.dispose();
|
|
3647
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3648
|
-
}
|
|
3649
|
-
});
|
|
3650
|
-
it("keeps journaled unlinks and never consults the seam for live files or directory deletes", () => {
|
|
3651
|
-
const hqRoot = makeRoot();
|
|
3652
|
-
const clock = new FakeClock();
|
|
3653
|
-
const seam = vi.fn(() => false);
|
|
3654
|
-
const capture = vi.fn(() => []);
|
|
3655
|
-
const watcher = new TreeWatcher({
|
|
3656
|
-
hqRoot,
|
|
3657
|
-
clock,
|
|
3658
|
-
debounceMs: 25,
|
|
3659
|
-
pathFilter: () => true,
|
|
3660
|
-
captureLocalDeleteSnapshots: capture,
|
|
3661
|
-
isUntrackedDelete: seam,
|
|
3662
|
-
});
|
|
3663
|
-
const batches = [];
|
|
3664
|
-
watcher.onChange((_first, batch) => batches.push(batch?.paths ?? new Map()));
|
|
3665
|
-
try {
|
|
3666
|
-
const live = path.join(hqRoot, "live.md");
|
|
3667
|
-
fs.writeFileSync(live, "x");
|
|
3668
|
-
watcher.handleEvent(live, "add");
|
|
3669
|
-
// A directory with no row of its own may still cover journaled
|
|
3670
|
-
// descendants; only the capture's prefix scan can tell, so directory
|
|
3671
|
-
// deletes always go through.
|
|
3672
|
-
watcher.handleEvent(path.join(hqRoot, "gone-dir"), "unlinkDir");
|
|
3673
|
-
watcher.handleEvent(path.join(hqRoot, "journaled.md"), "unlink");
|
|
3674
|
-
clock.advance(25);
|
|
3675
|
-
expect(seam).toHaveBeenCalledTimes(1);
|
|
3676
|
-
expect(seam).toHaveBeenCalledWith("journaled.md");
|
|
3677
|
-
expect([...(batches[0]?.values() ?? [])].sort()).toEqual(["gone-dir", "journaled.md", "live.md"]);
|
|
3678
|
-
expect(capture).toHaveBeenCalledWith("gone-dir", "unlinkDir");
|
|
3679
|
-
expect(capture).toHaveBeenCalledWith("journaled.md", "unlink");
|
|
3680
|
-
expect(watcher.untrackedDeletesDropped()).toBe(0);
|
|
3681
|
-
}
|
|
3682
|
-
finally {
|
|
3683
|
-
watcher.dispose();
|
|
3684
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3685
|
-
}
|
|
3686
|
-
});
|
|
3687
|
-
it("keeps the unlink when the seam throws", () => {
|
|
3688
|
-
const hqRoot = makeRoot();
|
|
3689
|
-
const clock = new FakeClock();
|
|
3690
|
-
const watcher = new TreeWatcher({
|
|
3691
|
-
hqRoot,
|
|
3692
|
-
clock,
|
|
3693
|
-
debounceMs: 25,
|
|
3694
|
-
pathFilter: () => true,
|
|
3695
|
-
isUntrackedDelete: () => {
|
|
3696
|
-
throw new Error("journal unavailable");
|
|
3697
|
-
},
|
|
3698
|
-
});
|
|
3699
|
-
const batches = [];
|
|
3700
|
-
watcher.onChange((_first, batch) => batches.push([...(batch?.paths.values() ?? [])]));
|
|
3701
|
-
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
3702
|
-
try {
|
|
3703
|
-
expect(() => watcher.handleEvent(path.join(hqRoot, "maybe.md"), "unlink")).not.toThrow();
|
|
3704
|
-
clock.advance(25);
|
|
3705
|
-
// Dropping is an optimisation; keeping the event is the safe default.
|
|
3706
|
-
expect(batches).toEqual([["maybe.md"]]);
|
|
3707
|
-
expect(watcher.untrackedDeletesDropped()).toBe(0);
|
|
3708
|
-
}
|
|
3709
|
-
finally {
|
|
3710
|
-
consoleError.mockRestore();
|
|
3711
|
-
watcher.dispose();
|
|
3712
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3713
|
-
}
|
|
3714
|
-
});
|
|
3715
|
-
it("a dropped unlink supersedes an in-flight queued capture for the same path", async () => {
|
|
3716
|
-
const hqRoot = makeRoot();
|
|
3717
|
-
const clock = new FakeClock();
|
|
3718
|
-
let untracked = false;
|
|
3719
|
-
let resolveCapture;
|
|
3720
|
-
let observedCurrent;
|
|
3721
|
-
const watcher = new TreeWatcher({
|
|
3722
|
-
hqRoot,
|
|
3723
|
-
clock,
|
|
3724
|
-
debounceMs: 25,
|
|
3725
|
-
pathFilter: () => true,
|
|
3726
|
-
isUntrackedDelete: () => untracked,
|
|
3727
|
-
queueLocalDeleteSnapshots: async (_relative, _kind, isCurrent) => {
|
|
3728
|
-
await new Promise((resolve) => { resolveCapture = resolve; });
|
|
3729
|
-
observedCurrent = isCurrent();
|
|
3730
|
-
return [];
|
|
3731
|
-
},
|
|
3732
|
-
});
|
|
3733
|
-
try {
|
|
3734
|
-
const target = path.join(hqRoot, "flip.md");
|
|
3735
|
-
watcher.handleEvent(target, "unlink"); // journal view cold: kept, capture queued
|
|
3736
|
-
await Promise.resolve();
|
|
3737
|
-
untracked = true;
|
|
3738
|
-
watcher.handleEvent(target, "unlink"); // journal view warm: no row → dropped
|
|
3739
|
-
resolveCapture();
|
|
3740
|
-
await Promise.resolve();
|
|
3741
|
-
// The queued capture must not bind a revision for a path the watcher
|
|
3742
|
-
// has since discarded.
|
|
3743
|
-
expect(observedCurrent).toBe(false);
|
|
3744
|
-
expect(watcher.pendingTimerCount()).toBe(0);
|
|
3745
|
-
expect(watcher.untrackedDeletesDropped()).toBe(1);
|
|
3746
|
-
}
|
|
3747
|
-
finally {
|
|
3748
|
-
watcher.dispose();
|
|
3749
|
-
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
3750
|
-
}
|
|
3751
|
-
});
|
|
3752
|
-
});
|
|
3753
|
-
// ---------------------------------------------------------------------------
|
|
3754
|
-
// Event-pass debounce configuration (HQ_SYNC_EVENT_DEBOUNCE_MS / _MAX_WAIT_MS)
|
|
3755
|
-
// ---------------------------------------------------------------------------
|
|
3756
|
-
describe("resolveEventDebounceConfig", () => {
|
|
3757
|
-
const ENV_KEYS = [
|
|
3758
|
-
EVENT_DEBOUNCE_MS_ENV,
|
|
3759
|
-
EVENT_MAX_WAIT_MS_ENV,
|
|
3760
|
-
EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV,
|
|
3761
|
-
EVENT_SMALL_BATCH_MAX_PATHS_ENV,
|
|
3762
|
-
EVENT_SMALL_BATCH_MAX_BYTES_ENV,
|
|
3763
|
-
];
|
|
3764
|
-
let saved;
|
|
3765
|
-
beforeEach(() => {
|
|
3766
|
-
saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
3767
|
-
for (const k of ENV_KEYS)
|
|
3768
|
-
delete process.env[k];
|
|
3769
|
-
});
|
|
3770
|
-
afterEach(() => {
|
|
3771
|
-
for (const k of ENV_KEYS) {
|
|
3772
|
-
if (saved[k] === undefined)
|
|
3773
|
-
delete process.env[k];
|
|
3774
|
-
else
|
|
3775
|
-
process.env[k] = saved[k];
|
|
3776
|
-
}
|
|
3777
|
-
});
|
|
3778
|
-
it("defaults to a 60s quiet window, a 60s small-batch window, and a 120s max-wait ceiling", () => {
|
|
3779
|
-
expect(DEFAULT_EVENT_DEBOUNCE_MS).toBe(60_000);
|
|
3780
|
-
expect(DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS).toBe(60_000);
|
|
3781
|
-
expect(DEFAULT_EVENT_MAX_WAIT_MS).toBe(120_000);
|
|
3782
|
-
expect(resolveEventDebounceConfig({})).toEqual({
|
|
3783
|
-
debounceMs: 60_000,
|
|
3784
|
-
maxWaitMs: 120_000,
|
|
3785
|
-
smallBatchDebounceMs: 60_000,
|
|
3786
|
-
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
3787
|
-
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
3788
|
-
});
|
|
3789
|
-
expect(resolveEventDebounceConfig()).toEqual({
|
|
3790
|
-
debounceMs: DEFAULT_EVENT_DEBOUNCE_MS,
|
|
3791
|
-
maxWaitMs: DEFAULT_EVENT_MAX_WAIT_MS,
|
|
3792
|
-
smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
|
|
3793
|
-
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
3794
|
-
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
3795
|
-
});
|
|
3796
|
-
});
|
|
3797
|
-
it("honors env overrides", () => {
|
|
3798
|
-
process.env[EVENT_DEBOUNCE_MS_ENV] = "5000";
|
|
3799
|
-
process.env[EVENT_MAX_WAIT_MS_ENV] = "30000";
|
|
3800
|
-
process.env[EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV] = "2000";
|
|
3801
|
-
process.env[EVENT_SMALL_BATCH_MAX_PATHS_ENV] = "6";
|
|
3802
|
-
process.env[EVENT_SMALL_BATCH_MAX_BYTES_ENV] = "4096";
|
|
3803
|
-
expect(resolveEventDebounceConfig()).toEqual({
|
|
3804
|
-
debounceMs: 5000,
|
|
3805
|
-
maxWaitMs: 30_000,
|
|
3806
|
-
smallBatchDebounceMs: 2000,
|
|
3807
|
-
smallBatchMaxPaths: 6,
|
|
3808
|
-
smallBatchMaxBytes: 4096,
|
|
3809
|
-
});
|
|
3810
|
-
});
|
|
3811
|
-
it("lets HQ_SYNC_EVENT_DEBOUNCE_MS override the quiet window independently", () => {
|
|
3812
|
-
expect(resolveEventDebounceConfig({
|
|
3813
|
-
[EVENT_DEBOUNCE_MS_ENV]: "90000",
|
|
3814
|
-
})).toEqual({
|
|
3815
|
-
debounceMs: 90_000,
|
|
3816
|
-
maxWaitMs: 120_000,
|
|
3817
|
-
smallBatchDebounceMs: 60_000,
|
|
3818
|
-
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
3819
|
-
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
3820
|
-
});
|
|
3821
|
-
});
|
|
3822
|
-
it("lets HQ_SYNC_EVENT_SMALL_BATCH_DEBOUNCE_MS override the small-batch window independently", () => {
|
|
3823
|
-
expect(resolveEventDebounceConfig({
|
|
3824
|
-
[EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV]: "10000",
|
|
3825
|
-
})).toEqual({
|
|
3826
|
-
debounceMs: 60_000,
|
|
3827
|
-
maxWaitMs: 120_000,
|
|
3828
|
-
smallBatchDebounceMs: 10_000,
|
|
3829
|
-
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
3830
|
-
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
3831
|
-
});
|
|
3832
|
-
});
|
|
3833
|
-
it("clamps a small-batch override above the quiet window down to the quiet window", () => {
|
|
3834
|
-
expect(resolveEventDebounceConfig({
|
|
3835
|
-
[EVENT_DEBOUNCE_MS_ENV]: "30000",
|
|
3836
|
-
[EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV]: "50000",
|
|
3837
|
-
})).toEqual({
|
|
3838
|
-
debounceMs: 30_000,
|
|
3839
|
-
maxWaitMs: 120_000,
|
|
3840
|
-
smallBatchDebounceMs: 30_000,
|
|
3841
|
-
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
3842
|
-
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
3843
|
-
});
|
|
3844
|
-
});
|
|
3845
|
-
it("treats 0, negative, and non-numeric values as unset (defaults)", () => {
|
|
3846
|
-
for (const bad of ["0", "-5", "banana", ""]) {
|
|
3847
|
-
process.env[EVENT_DEBOUNCE_MS_ENV] = bad;
|
|
3848
|
-
process.env[EVENT_MAX_WAIT_MS_ENV] = bad;
|
|
3849
|
-
expect(resolveEventDebounceConfig()).toEqual({
|
|
3850
|
-
debounceMs: DEFAULT_EVENT_DEBOUNCE_MS,
|
|
3851
|
-
maxWaitMs: DEFAULT_EVENT_MAX_WAIT_MS,
|
|
3852
|
-
smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
|
|
3853
|
-
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
3854
|
-
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
3855
|
-
});
|
|
3856
|
-
}
|
|
3857
|
-
});
|
|
3858
|
-
it("floors the max-wait ceiling at the quiet window", () => {
|
|
3859
|
-
process.env[EVENT_DEBOUNCE_MS_ENV] = "60000";
|
|
3860
|
-
process.env[EVENT_MAX_WAIT_MS_ENV] = "10000";
|
|
3861
|
-
expect(resolveEventDebounceConfig()).toEqual({
|
|
3862
|
-
debounceMs: 60_000,
|
|
3863
|
-
maxWaitMs: 60_000,
|
|
3864
|
-
smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
|
|
3865
|
-
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
3866
|
-
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
3867
|
-
});
|
|
3868
|
-
});
|
|
3869
|
-
it("accepts an injected env record without touching process.env", () => {
|
|
3870
|
-
expect(resolveEventDebounceConfig({
|
|
3871
|
-
[EVENT_DEBOUNCE_MS_ENV]: "1000",
|
|
3872
|
-
[EVENT_MAX_WAIT_MS_ENV]: "2000",
|
|
3873
|
-
[EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV]: "750",
|
|
3874
|
-
[EVENT_SMALL_BATCH_MAX_PATHS_ENV]: "4",
|
|
3875
|
-
[EVENT_SMALL_BATCH_MAX_BYTES_ENV]: "1024",
|
|
3876
|
-
})).toEqual({
|
|
3877
|
-
debounceMs: 1000,
|
|
3878
|
-
maxWaitMs: 2000,
|
|
3879
|
-
smallBatchDebounceMs: 750,
|
|
3880
|
-
smallBatchMaxPaths: 4,
|
|
3881
|
-
smallBatchMaxBytes: 1024,
|
|
3882
|
-
});
|
|
3883
|
-
});
|
|
3884
|
-
});
|
|
3885
|
-
describe("resolvePublishMinIntervalMs", () => {
|
|
3886
|
-
it("defaults to 60 seconds, accepts zero to disable coalescing, and rejects invalid values", () => {
|
|
3887
|
-
expect(resolvePublishMinIntervalMs({})).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
|
|
3888
|
-
expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "0" })).toBe(0);
|
|
3889
|
-
expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "invalid" })).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
|
|
3890
|
-
expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "-1" })).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
|
|
3891
|
-
});
|
|
3892
|
-
it("uses the environment override in milliseconds", () => {
|
|
3893
|
-
expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "1234.9" })).toBe(1234);
|
|
3894
|
-
});
|
|
3895
|
-
});
|
|
3896
|
-
/**
|
|
3897
|
-
* Linux folder-only backend.
|
|
3898
|
-
*
|
|
3899
|
-
* On Linux there is no recursive `fs.watch`, and chokidar's answer was one
|
|
3900
|
-
* inotify watch per directory PLUS one per file. On a real HQ root that was
|
|
3901
|
-
* ~74k directories and ~878k files — ~950k watches, ~16 KB each — while the
|
|
3902
|
-
* safety cap counted only the directories and so never fired. A directory
|
|
3903
|
-
* watch already reports content changes to the files directly inside it, so
|
|
3904
|
-
* the per-file watches bought nothing. These tests pin the replacement: the
|
|
3905
|
-
* scoped native backend driven with `recursiveWatches: false`, taking exactly
|
|
3906
|
-
* one shallow watch per in-scope directory and none per file.
|
|
3907
|
-
*/
|
|
3908
|
-
describe("Linux folder-only native backend (startScopedRecursiveWatch, recursiveWatches: false)", () => {
|
|
3909
|
-
let root;
|
|
3910
|
-
const isLinux = process.platform === "linux";
|
|
3911
|
-
beforeEach(() => {
|
|
3912
|
-
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "folder-watch-")));
|
|
3913
|
-
});
|
|
3914
|
-
afterEach(() => {
|
|
3915
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
3916
|
-
});
|
|
3917
|
-
/** In-scope tree: root, docs, docs/deep, notes — 4 dirs, 8 files, plus an excluded node_modules. */
|
|
3918
|
-
function seedTree() {
|
|
3919
|
-
const dirs = [
|
|
3920
|
-
path.join(root, "docs"),
|
|
3921
|
-
path.join(root, "docs", "deep"),
|
|
3922
|
-
path.join(root, "notes"),
|
|
3923
|
-
];
|
|
3924
|
-
for (const d of dirs)
|
|
3925
|
-
fs.mkdirSync(d, { recursive: true });
|
|
3926
|
-
const files = [];
|
|
3927
|
-
for (const d of [root, ...dirs]) {
|
|
3928
|
-
for (const n of ["one.md", "two.md"]) {
|
|
3929
|
-
const f = path.join(d, n);
|
|
3930
|
-
fs.writeFileSync(f, "x");
|
|
3931
|
-
files.push(f);
|
|
3932
|
-
}
|
|
3933
|
-
}
|
|
3934
|
-
const nm = path.join(root, "docs", "node_modules", "pkg");
|
|
3935
|
-
fs.mkdirSync(nm, { recursive: true });
|
|
3936
|
-
fs.writeFileSync(path.join(nm, "index.js"), "1");
|
|
3937
|
-
return { inScopeDirs: [root, ...dirs], files };
|
|
3938
|
-
}
|
|
3939
|
-
/** Count this process's live inotify watch descriptors (Linux only). */
|
|
3940
|
-
function inotifyWatchDescriptors() {
|
|
3941
|
-
let total = 0;
|
|
3942
|
-
for (const fd of fs.readdirSync("/proc/self/fdinfo")) {
|
|
3943
|
-
try {
|
|
3944
|
-
const info = fs.readFileSync(path.join("/proc/self/fdinfo", fd), "utf8");
|
|
3945
|
-
total += (info.match(/^inotify wd:/gm) ?? []).length;
|
|
3946
|
-
}
|
|
3947
|
-
catch {
|
|
3948
|
-
/* fd closed between readdir and read */
|
|
3949
|
-
}
|
|
3950
|
-
}
|
|
3951
|
-
return total;
|
|
3952
|
-
}
|
|
3953
|
-
async function waitFor(check, ms = 4000) {
|
|
3954
|
-
const deadline = Date.now() + ms;
|
|
3955
|
-
while (Date.now() < deadline) {
|
|
3956
|
-
if (check())
|
|
3957
|
-
return;
|
|
3958
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
3959
|
-
}
|
|
3960
|
-
expect(check()).toBe(true);
|
|
3961
|
-
}
|
|
3962
|
-
function waitForReady(backend) {
|
|
3963
|
-
return new Promise((resolve) => {
|
|
3964
|
-
if (backend.onReady)
|
|
3965
|
-
backend.onReady(resolve);
|
|
3966
|
-
else
|
|
3967
|
-
resolve();
|
|
3968
|
-
});
|
|
3969
|
-
}
|
|
3970
|
-
function startFolderOnly(opts = {}) {
|
|
3971
|
-
const shouldEmit = opts.includeAll
|
|
3972
|
-
? () => true
|
|
3973
|
-
: createWatchPathFilter(root, false);
|
|
3974
|
-
return startScopedRecursiveWatch(root, shouldEmit, opts.onEvent ?? (() => { }), opts.onError ?? ((err) => { throw err; }), undefined, {
|
|
3975
|
-
recursiveWatches: false,
|
|
3976
|
-
maxWatchedPaths: opts.maxWatchedPaths,
|
|
3977
|
-
onWatchBudgetExceeded: opts.onWatchBudgetExceeded,
|
|
3978
|
-
listChildDirs: opts.listChildDirs,
|
|
3979
|
-
watchDirectory: opts.watchDirectory,
|
|
3980
|
-
directoryIdentity: opts.directoryIdentity,
|
|
3981
|
-
});
|
|
3982
|
-
}
|
|
3983
|
-
it("takes exactly one watch per in-scope directory and none per file", async () => {
|
|
3984
|
-
const { inScopeDirs } = seedTree();
|
|
3985
|
-
const before = isLinux ? inotifyWatchDescriptors() : 0;
|
|
3986
|
-
const backend = startFolderOnly();
|
|
3987
|
-
try {
|
|
3988
|
-
await waitForReady(backend);
|
|
3989
|
-
expect(backend.mode).toBe("native");
|
|
3990
|
-
expect(backend.attachFailures).toBe(0);
|
|
3991
|
-
// 4 in-scope directories; 8 in-scope files and an excluded subtree add nothing.
|
|
3992
|
-
expect(backend.watchedPathCount()).toBe(inScopeDirs.length);
|
|
3993
|
-
if (isLinux) {
|
|
3994
|
-
// Measured immediately, before any drain window: a whole-tree
|
|
3995
|
-
// bootstrap watch would show up here as extra descriptors.
|
|
3996
|
-
expect(inotifyWatchDescriptors() - before).toBe(inScopeDirs.length);
|
|
3997
|
-
}
|
|
3998
|
-
}
|
|
3999
|
-
finally {
|
|
4000
|
-
backend.close();
|
|
4001
|
-
}
|
|
4002
|
-
if (isLinux)
|
|
4003
|
-
expect(inotifyWatchDescriptors()).toBe(before);
|
|
4004
|
-
});
|
|
4005
|
-
it("reports a content change to a file inside a watched folder without a file watch", async () => {
|
|
4006
|
-
const { files } = seedTree();
|
|
4007
|
-
const seen = [];
|
|
4008
|
-
const backend = startFolderOnly({ onEvent: (p) => seen.push(p) });
|
|
4009
|
-
try {
|
|
4010
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
4011
|
-
const target = files.find((f) => f.includes(path.join("docs", "deep")));
|
|
4012
|
-
fs.appendFileSync(target, "more");
|
|
4013
|
-
await waitFor(() => seen.includes(target));
|
|
4014
|
-
}
|
|
4015
|
-
finally {
|
|
4016
|
-
backend.close();
|
|
4017
|
-
}
|
|
4018
|
-
});
|
|
4019
|
-
it("attaches a folder created after start, then reports files written inside it", async () => {
|
|
4020
|
-
seedTree();
|
|
4021
|
-
const seen = [];
|
|
4022
|
-
const backend = startFolderOnly({ onEvent: (p) => seen.push(p) });
|
|
4023
|
-
try {
|
|
4024
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
4025
|
-
const before = backend.watchedPathCount();
|
|
4026
|
-
const created = path.join(root, "notes", "later");
|
|
4027
|
-
fs.mkdirSync(created);
|
|
4028
|
-
await waitFor(() => backend.watchedPathCount() === before + 1);
|
|
4029
|
-
const file = path.join(created, "fresh.md");
|
|
4030
|
-
fs.writeFileSync(file, "hello");
|
|
4031
|
-
await waitFor(() => seen.includes(file));
|
|
4032
|
-
}
|
|
4033
|
-
finally {
|
|
4034
|
-
backend.close();
|
|
4035
|
-
}
|
|
4036
|
-
});
|
|
4037
|
-
it("scans a folder that already holds files when it attaches, so nothing arrives silently", async () => {
|
|
4038
|
-
seedTree();
|
|
4039
|
-
const seen = [];
|
|
4040
|
-
const backend = startFolderOnly({ onEvent: (p) => seen.push(p) });
|
|
4041
|
-
try {
|
|
4042
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
4043
|
-
// Build the subtree OUTSIDE the watched root, then move it in with one
|
|
4044
|
-
// rename: the folder watch only ever sees the directory appear, never
|
|
4045
|
-
// the files inside it. Chokidar walked such a directory on addDir;
|
|
4046
|
-
// the folder-only backend must do the same.
|
|
4047
|
-
const staging = fs.mkdtempSync(path.join(os.tmpdir(), "folder-watch-stage-"));
|
|
4048
|
-
fs.mkdirSync(path.join(staging, "inner"));
|
|
4049
|
-
fs.writeFileSync(path.join(staging, "a.md"), "a");
|
|
4050
|
-
fs.writeFileSync(path.join(staging, "inner", "b.md"), "b");
|
|
4051
|
-
const dest = path.join(root, "docs", "moved");
|
|
4052
|
-
fs.renameSync(staging, dest);
|
|
4053
|
-
await waitFor(() => seen.includes(path.join(dest, "a.md")) &&
|
|
4054
|
-
seen.includes(path.join(dest, "inner", "b.md")));
|
|
4055
|
-
expect(backend.watchedPathCount()).toBe(4 + 2);
|
|
4056
|
-
}
|
|
4057
|
-
finally {
|
|
4058
|
-
backend.close();
|
|
4059
|
-
}
|
|
4060
|
-
});
|
|
4061
|
-
it("re-attaches when a watched folder is swapped for a new one at the same path (Linux inode change)", async () => {
|
|
4062
|
-
seedTree();
|
|
4063
|
-
const seen = [];
|
|
4064
|
-
const backend = startFolderOnly({ onEvent: (p) => seen.push(p) });
|
|
4065
|
-
try {
|
|
4066
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
4067
|
-
expect(backend.watchedPathCount()).toBe(4);
|
|
4068
|
-
// Atomic swap: the old `docs` moves out of the tree and a replacement
|
|
4069
|
-
// (built outside, so its creation is never observed) lands at the same
|
|
4070
|
-
// pathname — both renames complete before any watch callback runs. On
|
|
4071
|
-
// Linux the old handle follows the moved-away INODE, so without an
|
|
4072
|
-
// identity check the backend would keep it, consider `docs` covered,
|
|
4073
|
-
// and never hear another event from the replacement subtree.
|
|
4074
|
-
const staging = fs.mkdtempSync(path.join(os.tmpdir(), "folder-watch-swap-"));
|
|
4075
|
-
fs.mkdirSync(path.join(staging, "deep"));
|
|
4076
|
-
fs.writeFileSync(path.join(staging, "a.md"), "a");
|
|
4077
|
-
fs.writeFileSync(path.join(staging, "deep", "b.md"), "b");
|
|
4078
|
-
const parked = fs.mkdtempSync(path.join(os.tmpdir(), "folder-watch-parked-"));
|
|
4079
|
-
const docs = path.join(root, "docs");
|
|
4080
|
-
fs.renameSync(docs, path.join(parked, "docs"));
|
|
4081
|
-
fs.renameSync(staging, docs);
|
|
4082
|
-
// The replacement's existing files are announced, exactly like a
|
|
4083
|
-
// populated folder moved in from outside.
|
|
4084
|
-
await waitFor(() => seen.includes(path.join(docs, "a.md")) &&
|
|
4085
|
-
seen.includes(path.join(docs, "deep", "b.md")));
|
|
4086
|
-
// A later write inside the replacement is reported: the new inode is
|
|
4087
|
-
// watched, and the stale handles (docs + docs/deep) are gone — still
|
|
4088
|
-
// root, docs, docs/deep, notes.
|
|
4089
|
-
const after = path.join(docs, "deep", "after.md");
|
|
4090
|
-
fs.writeFileSync(after, "after");
|
|
4091
|
-
await waitFor(() => seen.includes(after));
|
|
4092
|
-
expect(backend.watchedPathCount()).toBe(4);
|
|
4093
|
-
// The moved-away tree must not keep reporting under its OLD path.
|
|
4094
|
-
seen.length = 0;
|
|
4095
|
-
fs.writeFileSync(path.join(parked, "docs", "stale.md"), "stale");
|
|
4096
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
4097
|
-
expect(seen.filter((p) => p.startsWith(docs + path.sep))).toEqual([]);
|
|
4098
|
-
fs.rmSync(parked, { recursive: true, force: true });
|
|
4099
|
-
}
|
|
4100
|
-
finally {
|
|
4101
|
-
backend.close();
|
|
4102
|
-
}
|
|
4103
|
-
});
|
|
4104
|
-
it("probeCoverage reports root-missing when the HQ root is gone", () => {
|
|
4105
|
-
seedTree();
|
|
4106
|
-
const backend = startFolderOnly();
|
|
4107
|
-
try {
|
|
4108
|
-
expect(backend.probeCoverage()).toEqual({ ok: true });
|
|
4109
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
4110
|
-
const probe = backend.probeCoverage();
|
|
4111
|
-
expect(probe.ok).toBe(false);
|
|
4112
|
-
if (!probe.ok)
|
|
4113
|
-
expect(probe.reason).toBe("root-missing");
|
|
4114
|
-
}
|
|
4115
|
-
finally {
|
|
4116
|
-
backend.close();
|
|
4117
|
-
}
|
|
4118
|
-
});
|
|
4119
|
-
it("covers a folder created after its parent was listed but before the plan attached (walk/attach race)", async () => {
|
|
4120
|
-
seedTree();
|
|
4121
|
-
const seen = [];
|
|
4122
|
-
const late = path.join(root, "notes", "late");
|
|
4123
|
-
const watchEvents = new Map();
|
|
4124
|
-
// The plan walk lists `notes`, then — before any watch existed on the
|
|
4125
|
-
// old code path — `notes/late` appears. The snapshot plan never holds
|
|
4126
|
-
// it, and a shallow watch on `notes` attached only after the whole walk
|
|
4127
|
-
// would never have seen it appear. On a real HQ root the walk takes
|
|
4128
|
-
// seconds; agents create directories the whole time.
|
|
4129
|
-
const listChildDirs = (dir) => {
|
|
4130
|
-
const children = fs
|
|
4131
|
-
.readdirSync(dir, { withFileTypes: true })
|
|
4132
|
-
.filter((e) => e.isDirectory())
|
|
4133
|
-
.map((e) => path.join(dir, e.name));
|
|
4134
|
-
if (dir === path.join(root, "notes"))
|
|
4135
|
-
fs.mkdirSync(late);
|
|
4136
|
-
return children;
|
|
4137
|
-
};
|
|
4138
|
-
const backend = startFolderOnly({
|
|
4139
|
-
onEvent: (p) => seen.push(p),
|
|
4140
|
-
listChildDirs,
|
|
4141
|
-
watchDirectory: ((dir, _options, listener) => {
|
|
4142
|
-
watchEvents.set(path.resolve(String(dir)), listener);
|
|
4143
|
-
const handle = new EventEmitter();
|
|
4144
|
-
handle.close = () => { };
|
|
4145
|
-
return handle;
|
|
4146
|
-
}),
|
|
4147
|
-
});
|
|
4148
|
-
try {
|
|
4149
|
-
await waitForReady(backend);
|
|
4150
|
-
watchEvents.get(path.join(root, "notes"))?.("rename", "late");
|
|
4151
|
-
// root, docs, docs/deep, notes — plus `late`, picked up by the live
|
|
4152
|
-
// watch on `notes` rather than by the plan.
|
|
4153
|
-
await waitFor(() => backend.watchedPathCount() === 5);
|
|
4154
|
-
const file = path.join(late, "inside.md");
|
|
4155
|
-
fs.writeFileSync(file, "x");
|
|
4156
|
-
watchEvents.get(late)?.("rename", "inside.md");
|
|
4157
|
-
await waitFor(() => seen.includes(file));
|
|
4158
|
-
}
|
|
4159
|
-
finally {
|
|
4160
|
-
backend.close();
|
|
4161
|
-
}
|
|
4162
|
-
});
|
|
4163
|
-
it("releases every watch beneath a deleted folder", async () => {
|
|
4164
|
-
seedTree();
|
|
4165
|
-
const backend = startFolderOnly();
|
|
4166
|
-
try {
|
|
4167
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
4168
|
-
expect(backend.watchedPathCount()).toBe(4);
|
|
4169
|
-
fs.rmSync(path.join(root, "docs"), { recursive: true, force: true });
|
|
4170
|
-
// docs and docs/deep both go; root and notes remain.
|
|
4171
|
-
await waitFor(() => backend.watchedPathCount() === 2);
|
|
4172
|
-
if (isLinux) {
|
|
4173
|
-
await waitFor(() => inotifyWatchDescriptors() <= 2);
|
|
4174
|
-
}
|
|
4175
|
-
}
|
|
4176
|
-
finally {
|
|
4177
|
-
backend.close();
|
|
4178
|
-
}
|
|
4179
|
-
});
|
|
4180
|
-
it("stops attaching at the budget and reports the exhaustion exactly once, counting real watches", async () => {
|
|
4181
|
-
seedTree();
|
|
4182
|
-
const exceeded = vi.fn();
|
|
4183
|
-
const backend = startFolderOnly({
|
|
4184
|
-
maxWatchedPaths: 2,
|
|
4185
|
-
onWatchBudgetExceeded: exceeded,
|
|
4186
|
-
});
|
|
4187
|
-
try {
|
|
4188
|
-
await waitForReady(backend);
|
|
4189
|
-
expect(backend.watchedPathCount()).toBe(2);
|
|
4190
|
-
expect(exceeded).toHaveBeenCalledTimes(1);
|
|
4191
|
-
expect(exceeded.mock.calls[0][0]).toMatchObject({
|
|
4192
|
-
watchedPaths: 2,
|
|
4193
|
-
maxWatchedPaths: 2,
|
|
4194
|
-
});
|
|
4195
|
-
expect(typeof exceeded.mock.calls[0][0].offendingPath).toBe("string");
|
|
4196
|
-
}
|
|
4197
|
-
finally {
|
|
4198
|
-
backend.close();
|
|
4199
|
-
}
|
|
4200
|
-
});
|
|
4201
|
-
it("selects the single-root backend on darwin and leaves linux and windows unchanged", () => {
|
|
4202
|
-
expect(selectTreeWatchBackend("linux", false)).toBe("native-shallow");
|
|
4203
|
-
expect(selectTreeWatchBackend("darwin", false)).toBe("native-root");
|
|
4204
|
-
expect(selectTreeWatchBackend("win32", false)).toBe("native-recursive");
|
|
4205
|
-
expect(selectTreeWatchBackend("freebsd", false)).toBe("chokidar");
|
|
4206
|
-
expect(selectTreeWatchBackend("linux", true)).toBe("polling");
|
|
4207
|
-
expect(selectTreeWatchBackend("darwin", true)).toBe("polling");
|
|
4208
|
-
});
|
|
4209
|
-
it("filters native-root callbacks before forwarding them to onEvent", () => {
|
|
4210
|
-
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "native-root-filter-")));
|
|
4211
|
-
const excludedDir = path.join(dir, "node_modules");
|
|
4212
|
-
fs.mkdirSync(path.join(excludedDir, "pkg"), { recursive: true });
|
|
4213
|
-
fs.writeFileSync(path.join(excludedDir, "pkg", "index.js"), "x");
|
|
4214
|
-
const callback = vi.fn();
|
|
4215
|
-
const handle = new EventEmitter();
|
|
4216
|
-
handle.close = vi.fn();
|
|
4217
|
-
let listener;
|
|
4218
|
-
const backend = startNativeRootWatch(dir, createWatchPathFilter(dir, false), callback, vi.fn(), {
|
|
4219
|
-
watchDirectory: ((root, options, onChange) => {
|
|
4220
|
-
expect(root).toBe(dir);
|
|
4221
|
-
expect(options).toEqual({ recursive: true, persistent: true });
|
|
4222
|
-
listener = onChange;
|
|
4223
|
-
return handle;
|
|
4224
|
-
}),
|
|
4225
|
-
});
|
|
4226
|
-
try {
|
|
4227
|
-
expect(backend.mode).toBe("native-root");
|
|
4228
|
-
expect(backend.watchedPathCount()).toBe(1);
|
|
4229
|
-
listener?.("rename", "companies/indigo/knowledge/note.md");
|
|
4230
|
-
listener?.("rename", "node_modules");
|
|
4231
|
-
listener?.("change", "workspace/node_modules/pkg/index.js");
|
|
4232
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
4233
|
-
expect(callback).toHaveBeenCalledWith(path.join(dir, "companies/indigo/knowledge/note.md"), "rename");
|
|
4234
|
-
}
|
|
4235
|
-
finally {
|
|
4236
|
-
backend.close();
|
|
4237
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
4238
|
-
}
|
|
4239
|
-
});
|
|
4240
|
-
it("switches a native-root watcher to polling after a systemic watch error", async () => {
|
|
4241
|
-
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "native-root-fallback-")));
|
|
4242
|
-
const closed = [];
|
|
4243
|
-
const backend = new TreeWatcher({
|
|
4244
|
-
hqRoot: dir,
|
|
4245
|
-
debounceMs: 50,
|
|
4246
|
-
backendFactory: ({ forcePolling, onError }) => {
|
|
4247
|
-
if (forcePolling) {
|
|
4248
|
-
return {
|
|
4249
|
-
close: () => closed.push("polling"),
|
|
4250
|
-
mode: "polling",
|
|
4251
|
-
needsKnownKinds: false,
|
|
4252
|
-
watchedPathCount: () => 1,
|
|
4253
|
-
};
|
|
4254
|
-
}
|
|
4255
|
-
const handle = new EventEmitter();
|
|
4256
|
-
handle.close = vi.fn(() => closed.push("native-root"));
|
|
4257
|
-
const native = startNativeRootWatch(dir, () => true, vi.fn(), onError, {
|
|
4258
|
-
watchDirectory: (() => {
|
|
4259
|
-
queueMicrotask(() => handle.emit("error", Object.assign(new Error("too many open files"), { code: "EMFILE" })));
|
|
4260
|
-
return handle;
|
|
4261
|
-
}),
|
|
4262
|
-
});
|
|
4263
|
-
return native;
|
|
4264
|
-
},
|
|
4265
|
-
});
|
|
4266
|
-
const log = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
4267
|
-
backend.start();
|
|
4268
|
-
try {
|
|
4269
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
4270
|
-
expect(backend.watchMode()).toBe("polling");
|
|
4271
|
-
expect(backend.watchedPathCount()).toBe(1);
|
|
4272
|
-
expect(closed).toContain("native-root");
|
|
4273
|
-
}
|
|
4274
|
-
finally {
|
|
4275
|
-
backend.dispose();
|
|
4276
|
-
log.mockRestore();
|
|
4277
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
4278
|
-
}
|
|
4279
|
-
});
|
|
4280
|
-
it("TreeWatcher on linux starts natively with one watch per directory (no chokidar, no file watches)", async () => {
|
|
4281
|
-
if (!isLinux)
|
|
4282
|
-
return;
|
|
4283
|
-
const { inScopeDirs } = seedTree();
|
|
4284
|
-
const before = inotifyWatchDescriptors();
|
|
4285
|
-
const w = new TreeWatcher({ hqRoot: root, debounceMs: 50 });
|
|
4286
|
-
const changed = vi.fn();
|
|
4287
|
-
w.onChange(changed);
|
|
4288
|
-
w.start();
|
|
4289
|
-
try {
|
|
4290
|
-
await new Promise((resolve) => w.onReady(resolve));
|
|
4291
|
-
expect(w.watchMode()).toBe("native");
|
|
4292
|
-
expect(inotifyWatchDescriptors() - before).toBe(inScopeDirs.length);
|
|
4293
|
-
fs.writeFileSync(path.join(root, "notes", "new.md"), "hi");
|
|
4294
|
-
await waitFor(() => changed.mock.calls.length > 0);
|
|
4295
|
-
}
|
|
4296
|
-
finally {
|
|
4297
|
-
w.dispose();
|
|
4298
|
-
}
|
|
4299
|
-
});
|
|
4300
|
-
});
|
|
4301
|
-
//# sourceMappingURL=watcher.test.js.map
|