@animalabs/agent-framework 0.22.1 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/agent.d.ts +192 -19
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +404 -65
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server-core.d.ts +41 -0
- package/dist/src/api/mcp-server-core.d.ts.map +1 -0
- package/dist/src/api/mcp-server-core.js +593 -0
- package/dist/src/api/mcp-server-core.js.map +1 -0
- package/dist/src/api/mcp-server.d.ts +2 -36
- package/dist/src/api/mcp-server.d.ts.map +1 -1
- package/dist/src/api/mcp-server.js +15 -595
- package/dist/src/api/mcp-server.js.map +1 -1
- package/dist/src/api/server.d.ts +9 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +50 -26
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/api/types.d.ts +9 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/framework.d.ts +393 -113
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1748 -582
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/gate-script.d.ts.map +1 -1
- package/dist/src/gate/gate-script.js.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +26 -7
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +102 -22
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/errors.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +4 -3
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +54 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -0
- package/dist/src/mcpl/log-value.js +109 -0
- package/dist/src/mcpl/log-value.js.map +1 -0
- package/dist/src/mcpl/prose-stream-router.d.ts +6 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +4 -4
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +38 -3
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +81 -16
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +26 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +87 -23
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +2 -2
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/refusal-lines.d.ts +37 -0
- package/dist/src/mcpl/refusal-lines.d.ts.map +1 -0
- package/dist/src/mcpl/refusal-lines.js +61 -0
- package/dist/src/mcpl/refusal-lines.js.map +1 -0
- package/dist/src/mcpl/scope-manager.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -2
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +66 -11
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +1 -1
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
- package/dist/src/mcpl/tool-classes.js +3 -2
- package/dist/src/mcpl/tool-classes.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +23 -5
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +103 -10
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +30 -26
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +4 -2
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +22 -4
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +63 -25
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +8 -4
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +39 -12
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +43 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +264 -39
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts +17 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +57 -14
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +27 -2
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/provider-waits.d.ts.map +1 -1
- package/dist/src/provider-waits.js.map +1 -1
- package/dist/src/queue.d.ts.map +1 -1
- package/dist/src/record-journal.d.ts.map +1 -1
- package/dist/src/record-journal.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/recovery/offline-branch.d.ts.map +1 -1
- package/dist/src/recovery/offline-branch.js +29 -24
- package/dist/src/recovery/offline-branch.js.map +1 -1
- package/dist/src/recovery/recover-cli.js.map +1 -1
- package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -1
- package/dist/src/runtime/liveness-watchdog.js.map +1 -1
- package/dist/src/shutdown.d.ts.map +1 -1
- package/dist/src/shutdown.js.map +1 -1
- package/dist/src/stored-removal.d.ts +73 -0
- package/dist/src/stored-removal.d.ts.map +1 -0
- package/dist/src/stored-removal.js +100 -0
- package/dist/src/stored-removal.js.map +1 -0
- package/dist/src/thinking-token-stamp.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +1 -1
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/types/agent.d.ts +42 -6
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +10 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +8 -4
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +61 -10
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +1139 -3
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/batched-wake-locus.test.js.map +1 -1
- package/dist/test/channel-label-history.test.js.map +1 -1
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/conversation-router.test.js.map +1 -1
- package/dist/test/conversation-routing.test.js +240 -6
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/discord-awareness-outbox.test.js +104 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/discord-typing-keepalive.test.d.ts +2 -0
- package/dist/test/discord-typing-keepalive.test.d.ts.map +1 -0
- package/dist/test/discord-typing-keepalive.test.js +77 -0
- package/dist/test/discord-typing-keepalive.test.js.map +1 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate-passthrough.test.js.map +1 -1
- package/dist/test/event-gate-policy-mutation.test.js.map +1 -1
- package/dist/test/event-gate-rate-limit.test.js.map +1 -1
- package/dist/test/event-gate-script.test.js.map +1 -1
- package/dist/test/event-gate-selfwake.test.js.map +1 -1
- package/dist/test/event-gate-tags.test.js.map +1 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +363 -42
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +2 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
- package/dist/test/helpers/coalescing-fixture.js +8 -2
- package/dist/test/helpers/coalescing-fixture.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
- package/dist/test/helpers/mock-membrane.js.map +1 -1
- package/dist/test/hide-then-turn.test.d.ts +2 -0
- package/dist/test/hide-then-turn.test.d.ts.map +1 -0
- package/dist/test/hide-then-turn.test.js +101 -0
- package/dist/test/hide-then-turn.test.js.map +1 -0
- package/dist/test/history-module-overview.test.js.map +1 -1
- package/dist/test/history-module-semantic.test.js.map +1 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.js.map +1 -1
- package/dist/test/host-quiesce.test.js +125 -0
- package/dist/test/host-quiesce.test.js.map +1 -1
- package/dist/test/inference-exhaustion-rewind.test.js +81 -3
- package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
- package/dist/test/inference-failure-bounds.test.js +1 -0
- package/dist/test/inference-failure-bounds.test.js.map +1 -1
- package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -1
- package/dist/test/liveness-watchdog.test.js.map +1 -1
- package/dist/test/maintenance-timer.test.js +1564 -2
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcp-server-bin.test.d.ts +2 -0
- package/dist/test/mcp-server-bin.test.d.ts.map +1 -0
- package/dist/test/mcp-server-bin.test.js +142 -0
- package/dist/test/mcp-server-bin.test.js.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.js +1 -1
- package/dist/test/mcpl-child-env.test.js.map +1 -1
- package/dist/test/mcpl-coalesced-content-parity.test.d.ts +2 -0
- package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalesced-content-parity.test.js +532 -0
- package/dist/test/mcpl-coalesced-content-parity.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-reregistration.test.js +2 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.js.map +1 -1
- package/dist/test/mcpl-rejection-log-lines.test.d.ts +2 -0
- package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +1 -0
- package/dist/test/mcpl-rejection-log-lines.test.js +349 -0
- package/dist/test/mcpl-rejection-log-lines.test.js.map +1 -0
- package/dist/test/mcpl-stderr-tail.test.d.ts +2 -0
- package/dist/test/mcpl-stderr-tail.test.d.ts.map +1 -0
- package/dist/test/mcpl-stderr-tail.test.js +210 -0
- package/dist/test/mcpl-stderr-tail.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -1
- package/dist/test/mcpl-transport-close.test.d.ts +2 -0
- package/dist/test/mcpl-transport-close.test.d.ts.map +1 -0
- package/dist/test/mcpl-transport-close.test.js +373 -0
- package/dist/test/mcpl-transport-close.test.js.map +1 -0
- package/dist/test/mcpl-transport-spawn.test.d.ts +2 -0
- package/dist/test/mcpl-transport-spawn.test.d.ts.map +1 -0
- package/dist/test/mcpl-transport-spawn.test.js +58 -0
- package/dist/test/mcpl-transport-spawn.test.js.map +1 -0
- package/dist/test/mcpl-ws-transport.test.js +1 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.js +344 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -1
- package/dist/test/operator-surgery.test.js +197 -0
- package/dist/test/operator-surgery.test.js.map +1 -1
- package/dist/test/overbudget-drain-breaker.test.js +1 -0
- package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
- package/dist/test/pack-installs-alone.test.d.ts +2 -0
- package/dist/test/pack-installs-alone.test.d.ts.map +1 -0
- package/dist/test/pack-installs-alone.test.js +284 -0
- package/dist/test/pack-installs-alone.test.js.map +1 -0
- package/dist/test/physical-window-restart.test.d.ts +6 -4
- package/dist/test/physical-window-restart.test.d.ts.map +1 -1
- package/dist/test/physical-window-restart.test.js +275 -10
- package/dist/test/physical-window-restart.test.js.map +1 -1
- package/dist/test/placed-reaction-baseline.test.js.map +1 -1
- package/dist/test/present-while-acting.test.js +949 -6
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
- package/dist/test/provider-waits.test.js +67 -4
- package/dist/test/provider-waits.test.js.map +1 -1
- package/dist/test/puppet-tool-call.test.js +2 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/reaction-baseline-platform.test.js.map +1 -1
- package/dist/test/record-journal.test.js.map +1 -1
- package/dist/test/refusal-rewind.test.js +9 -0
- package/dist/test/refusal-rewind.test.js.map +1 -1
- package/dist/test/refusal-single-call.test.js.map +1 -1
- package/dist/test/release-bump.test.d.ts +2 -0
- package/dist/test/release-bump.test.d.ts.map +1 -0
- package/dist/test/release-bump.test.js +248 -0
- package/dist/test/release-bump.test.js.map +1 -0
- package/dist/test/release-changelog.test.js.map +1 -1
- package/dist/test/release-holds.test.d.ts +2 -0
- package/dist/test/release-holds.test.d.ts.map +1 -0
- package/dist/test/release-holds.test.js +456 -0
- package/dist/test/release-holds.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.js.map +1 -1
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -1
- package/dist/test/stop-with-host-operations.test.d.ts +20 -0
- package/dist/test/stop-with-host-operations.test.d.ts.map +1 -0
- package/dist/test/stop-with-host-operations.test.js +1174 -0
- package/dist/test/stop-with-host-operations.test.js.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +162 -50
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -1
- package/dist/test/surgery-marks-receipt.test.js.map +1 -1
- package/dist/test/tool-batch-hook.test.js.map +1 -1
- package/dist/test/tool-result-guard.test.js +131 -9
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -1
- package/dist/test/trunk-channel-routing.test.js +330 -7
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/test/tune-out-e2e.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.js.map +1 -1
- package/dist/test/unsent-compiles.test.d.ts +2 -0
- package/dist/test/unsent-compiles.test.d.ts.map +1 -0
- package/dist/test/unsent-compiles.test.js +139 -0
- package/dist/test/unsent-compiles.test.js.map +1 -0
- package/dist/test/workspace-branch-continuation.test.js.map +1 -1
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -1
- package/dist/test/workspace-read-text.test.js.map +1 -1
- package/dist/test/workspace-three-way.test.js +907 -7
- package/dist/test/workspace-three-way.test.js.map +1 -1
- package/dist/test/workspace-watcher.test.js.map +1 -1
- package/dist/test/xml-round-persist.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -6
- package/src/agent.ts +411 -66
- package/src/api/mcp-server-core.ts +681 -0
- package/src/api/mcp-server.ts +16 -685
- package/src/api/server.ts +51 -29
- package/src/api/types.ts +9 -1
- package/src/framework.ts +1899 -641
- package/src/index.ts +2 -2
- package/src/mcpl/channel-registry.ts +126 -31
- package/src/mcpl/feature-set-manager.ts +4 -3
- package/src/mcpl/log-value.ts +116 -0
- package/src/mcpl/prose-stream-router.ts +6 -6
- package/src/mcpl/push-coalescer.ts +76 -12
- package/src/mcpl/push-handler.ts +95 -23
- package/src/mcpl/refusal-lines.ts +59 -0
- package/src/mcpl/server-connection.ts +70 -11
- package/src/mcpl/tool-classes.ts +3 -2
- package/src/mcpl/transport.ts +105 -14
- package/src/mcpl/types.ts +5 -1
- package/src/modules/discord/index.ts +4 -2
- package/src/modules/workspace/disk-agreement.ts +74 -18
- package/src/modules/workspace/index.ts +41 -11
- package/src/modules/workspace/observe.ts +265 -39
- package/src/modules/workspace/reconcile.ts +71 -14
- package/src/modules/workspace/types.ts +26 -1
- package/src/recovery/offline-branch.ts +32 -28
- package/src/stored-removal.ts +126 -0
- package/src/types/agent.ts +42 -6
- package/src/types/framework.ts +10 -0
- package/src/types/module.ts +8 -4
- package/src/types/trace.ts +62 -11
|
@@ -12,7 +12,7 @@ import { describe, test } from 'node:test';
|
|
|
12
12
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../src/module-registry.js';
|
|
13
13
|
import assert from 'node:assert/strict';
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
|
-
import { chmodSync, constants as fsConstants, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, truncateSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { chmodSync, constants as fsConstants, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, truncateSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import { open, statfs } from 'node:fs/promises';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { createRequire, syncBuiltinESMExports } from 'node:module';
|
|
@@ -20,8 +20,9 @@ import { tmpdir } from 'node:os';
|
|
|
20
20
|
import { basename, dirname, join } from 'node:path';
|
|
21
21
|
import { JsStore } from '@animalabs/chronicle';
|
|
22
22
|
import { WorkspaceModule } from '../src/modules/workspace/index.js';
|
|
23
|
-
import { DiskAgreement } from '../src/modules/workspace/disk-agreement.js';
|
|
24
|
-
import {
|
|
23
|
+
import { DISK_AGREEMENT_RECORD_TYPE, DiskAgreement } from '../src/modules/workspace/disk-agreement.js';
|
|
24
|
+
import { RecordJournal } from '../src/record-journal.js';
|
|
25
|
+
import { confirmAbsent, fingerprintVouches, filesystemTrustsCtime, fsTypeTrustsCtime, observePath, provenAbsent, readPath, walkScope } from '../src/modules/workspace/observe.js';
|
|
25
26
|
import { EffectFailed, syncDirectories, unlinkContained, writeContained } from '../src/modules/workspace/effects.js';
|
|
26
27
|
import { pushPaths, settleAdoptionBeforeMutation } from '../src/modules/workspace/reconcile.js';
|
|
27
28
|
const FAULT_FIXTURE = join(import.meta.dirname, 'fixtures/workspace-fault.mjs');
|
|
@@ -1844,7 +1845,7 @@ describe('incomplete walks', () => {
|
|
|
1844
1845
|
await call(m, 'sync', {});
|
|
1845
1846
|
assert.notEqual(env.store.treeGet(TREE, 'cache/x.txt'), null);
|
|
1846
1847
|
});
|
|
1847
|
-
test('a capped walk
|
|
1848
|
+
test('a capped walk proves absent what a listing lacks, and leaves unverified what it did not take', async (t) => {
|
|
1848
1849
|
const env = new Env(t);
|
|
1849
1850
|
const m = await env.open();
|
|
1850
1851
|
for (const f of ['a.txt', 'b.txt', 'c.txt', 'd.txt'])
|
|
@@ -1852,9 +1853,908 @@ describe('incomplete walks', () => {
|
|
|
1852
1853
|
env.rmDisk('c.txt');
|
|
1853
1854
|
const mount = m.mounts.get('work');
|
|
1854
1855
|
const pass = await m.withMount(mount, () => m.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true }, { cap: 2 }));
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
assert.
|
|
1856
|
+
// The root's listing, read whole, has no c.txt: a deletion is seen
|
|
1857
|
+
// wherever the walk lists, whichever files it took.
|
|
1858
|
+
assert.equal(env.store.treeGet(TREE, 'c.txt'), null);
|
|
1859
|
+
// d.txt is there, past the cap: unverified by this pass, and kept.
|
|
1860
|
+
assert.equal(pass.reports.get('d.txt')?.state, 'unverified');
|
|
1861
|
+
assert.notEqual(env.store.treeGet(TREE, 'd.txt'), null);
|
|
1862
|
+
assert.ok(pass.incomplete.some((r) => /walk cap \(2\) was reached/.test(r.reason)));
|
|
1863
|
+
});
|
|
1864
|
+
});
|
|
1865
|
+
describe('a mount larger than its walk cap (agent-framework #272)', () => {
|
|
1866
|
+
const syncedNames = (env) => env.store.treeList(TREE).map((e) => e.path).sort();
|
|
1867
|
+
test('repeated bare syncs resume where the last stopped, so together they take the whole mount', async (t) => {
|
|
1868
|
+
const env = new Env(t);
|
|
1869
|
+
// Written out of order: the walk takes each directory's files by name,
|
|
1870
|
+
// then its subdirectories by name.
|
|
1871
|
+
for (const f of ['b/y.txt', 'z.txt', 'a.txt', 'b/x.txt', 'm.txt'])
|
|
1872
|
+
env.writeDisk(f, f);
|
|
1873
|
+
const m = await env.open({ walkCap: 2 });
|
|
1874
|
+
const first = await call(m, 'sync', {});
|
|
1875
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'm.txt'], 'the first two in the walk order');
|
|
1876
|
+
assert.ok(first.incomplete.some((r) => /walk cap \(2\) was reached.*resumes after work\/m\.txt/.test(r.reason)), JSON.stringify(first.incomplete));
|
|
1877
|
+
await call(m, 'sync', {});
|
|
1878
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b/x.txt', 'm.txt', 'z.txt'], 'the next two, after m.txt');
|
|
1879
|
+
// The last one, then the walk wraps to the start for the second: the
|
|
1880
|
+
// mount is covered, and a.txt is looked at again.
|
|
1881
|
+
env.writeDisk('a.txt', 'edited before the wrap');
|
|
1882
|
+
await call(m, 'sync', {});
|
|
1883
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b/x.txt', 'b/y.txt', 'm.txt', 'z.txt']);
|
|
1884
|
+
assert.equal(await contentOf(m, 'a.txt'), 'edited before the wrap', 'the third sync wrapped to a.txt');
|
|
1885
|
+
});
|
|
1886
|
+
test('the walk takes files by name, whatever order they were written in', async (t) => {
|
|
1887
|
+
const env = new Env(t);
|
|
1888
|
+
// Neither the order written nor its reverse starts with the three
|
|
1889
|
+
// smallest names. Node's readdir already returns these names sorted on
|
|
1890
|
+
// Linux, so this pins the order where readdir doesn't sort them; the next
|
|
1891
|
+
// test is the one that fails on Linux if the walk stops sorting.
|
|
1892
|
+
for (const f of ['g.txt', 'c.txt', 'h.txt', 'a.txt', 'f.txt', 'b.txt', 'e.txt', 'd.txt'])
|
|
1893
|
+
env.writeDisk(f, f);
|
|
1894
|
+
const m = await env.open({ walkCap: 3 });
|
|
1895
|
+
await call(m, 'sync', {});
|
|
1896
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt', 'c.txt']);
|
|
1897
|
+
await call(m, 'sync', {});
|
|
1898
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt', 'c.txt', 'd.txt', 'e.txt', 'f.txt']);
|
|
1899
|
+
});
|
|
1900
|
+
test('names that sort apart by UTF-8 byte and by UTF-16 code unit are each taken once: the walk visits files in the order it resumes by', async (t) => {
|
|
1901
|
+
const env = new Env(t);
|
|
1902
|
+
// U+FF21 comes before U+1F600 by UTF-8 byte, the order Node's readdir
|
|
1903
|
+
// returns on Linux, and after it by UTF-16 code unit, the walk's order.
|
|
1904
|
+
for (const f of ['b.txt', '\uFF21.txt', '\u{1F600}.txt'])
|
|
1905
|
+
env.writeDisk(f, f);
|
|
1906
|
+
const m = await env.open({ walkCap: 1 });
|
|
1907
|
+
const taken = [];
|
|
1908
|
+
for (let i = 0; i < 3; i++) {
|
|
1909
|
+
const before = new Set(syncedNames(env));
|
|
1910
|
+
await call(m, 'sync', {});
|
|
1911
|
+
taken.push(...syncedNames(env).filter((n) => !before.has(n)));
|
|
1912
|
+
}
|
|
1913
|
+
assert.deepEqual(taken, ['b.txt', '\u{1F600}.txt', '\uFF21.txt']);
|
|
1914
|
+
});
|
|
1915
|
+
test('a disk edit past the cap reaches the workspace on a later bare sync', async (t) => {
|
|
1916
|
+
const env = new Env(t);
|
|
1917
|
+
const m = await env.open({ walkCap: 2 });
|
|
1918
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt'])
|
|
1919
|
+
await seedSynced(env, m, f, f);
|
|
1920
|
+
env.writeDisk('c.txt', 'edited past the cap');
|
|
1921
|
+
await call(m, 'sync', {});
|
|
1922
|
+
await call(m, 'sync', {});
|
|
1923
|
+
assert.equal(await contentOf(m, 'c.txt'), 'edited past the cap');
|
|
1924
|
+
});
|
|
1925
|
+
test('a deletion in a directory larger than the cap is proven by a listing read whole', async (t) => {
|
|
1926
|
+
const env = new Env(t);
|
|
1927
|
+
const m = await env.open({ walkCap: 2 });
|
|
1928
|
+
for (const f of ['big/a.txt', 'big/b.txt', 'big/c.txt', 'big/d.txt', 'big/e.txt'])
|
|
1929
|
+
await seedSynced(env, m, f, f);
|
|
1930
|
+
env.rmDisk('big/e.txt');
|
|
1931
|
+
// The first bare sync takes a.txt and b.txt; big's listing lacks e.txt.
|
|
1932
|
+
await call(m, 'sync', {});
|
|
1933
|
+
assert.equal(env.store.treeGet(TREE, 'big/e.txt'), null);
|
|
1934
|
+
assert.notEqual(env.store.treeGet(TREE, 'big/d.txt'), null);
|
|
1935
|
+
});
|
|
1936
|
+
test('a listing, and an agent-action scan, prove a deletion in a directory larger than the cap too', async (t) => {
|
|
1937
|
+
for (const via of ['ls', 'agent-action']) {
|
|
1938
|
+
const env = new Env(t);
|
|
1939
|
+
const m = await env.open({ walkCap: 2, ...(via === 'agent-action' ? { watch: 'on-agent-action' } : {}) });
|
|
1940
|
+
for (const f of ['big/a.txt', 'big/b.txt', 'big/c.txt', 'big/d.txt', 'big/e.txt'])
|
|
1941
|
+
await seedSynced(env, m, f, f);
|
|
1942
|
+
env.rmDisk('big/e.txt');
|
|
1943
|
+
// Each takes a.txt and b.txt; big's listing, read whole, lacks e.txt.
|
|
1944
|
+
if (via === 'ls')
|
|
1945
|
+
await call(m, 'ls', { path: 'work/big' });
|
|
1946
|
+
else
|
|
1947
|
+
await m.onToolBatchComplete('agent');
|
|
1948
|
+
assert.equal(env.store.treeGet(TREE, 'big/e.txt'), null, via);
|
|
1949
|
+
assert.notEqual(env.store.treeGet(TREE, 'big/d.txt'), null, via);
|
|
1950
|
+
}
|
|
1951
|
+
});
|
|
1952
|
+
test('a listing starts at its scope every time, and leaves no place behind', async (t) => {
|
|
1953
|
+
const env = new Env(t);
|
|
1954
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt'])
|
|
1955
|
+
env.writeDisk(f, f);
|
|
1956
|
+
const m = await env.open({ walkCap: 2 });
|
|
1957
|
+
const listed = async () => [...(await listing(m)).keys()].sort();
|
|
1958
|
+
assert.deepEqual(await listed(), ['a.txt', 'b.txt']);
|
|
1959
|
+
assert.deepEqual(await listed(), ['a.txt', 'b.txt'], 'the same files again');
|
|
1960
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), undefined);
|
|
1961
|
+
});
|
|
1962
|
+
test('a directory sync resumes for its own scope', async (t) => {
|
|
1963
|
+
const env = new Env(t);
|
|
1964
|
+
for (const f of ['d/a.txt', 'd/b.txt', 'd/c.txt', 'top.txt'])
|
|
1965
|
+
env.writeDisk(f, f);
|
|
1966
|
+
const m = await env.open({ walkCap: 2 });
|
|
1967
|
+
const first = await call(m, 'sync', { path: 'work/d' });
|
|
1968
|
+
assert.deepEqual(syncedNames(env), ['d/a.txt', 'd/b.txt']);
|
|
1969
|
+
assert.ok(first.incomplete.some((r) => r.path === 'd' && r.reason === 'the walk cap (2) was reached: the next sync of this directory resumes after work/d/b.txt'), JSON.stringify(first.incomplete));
|
|
1970
|
+
await call(m, 'sync', { path: 'work/d' });
|
|
1971
|
+
assert.deepEqual(syncedNames(env), ['d/a.txt', 'd/b.txt', 'd/c.txt']);
|
|
1972
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), undefined, 'the mount root keeps its own place');
|
|
1973
|
+
});
|
|
1974
|
+
test('the place survives a restart: the next bare sync resumes after it', async (t) => {
|
|
1975
|
+
const env = new Env(t);
|
|
1976
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt', 'd.txt'])
|
|
1977
|
+
env.writeDisk(f, f);
|
|
1978
|
+
let m = await env.open({ walkCap: 2 });
|
|
1979
|
+
await call(m, 'sync', {});
|
|
1980
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt']);
|
|
1981
|
+
m = await env.restart(m, { walkCap: 2 });
|
|
1982
|
+
await call(m, 'sync', {});
|
|
1983
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt', 'c.txt', 'd.txt'], 'c.txt and d.txt, not a.txt and b.txt again');
|
|
1984
|
+
});
|
|
1985
|
+
test('walkCap must be a positive integer, and the refusal names the value it was given', () => {
|
|
1986
|
+
const refused = [
|
|
1987
|
+
[0, '0'], [-1, '-1'], [1.5, '1.5'], [Number.POSITIVE_INFINITY, 'Infinity'], [Number.NaN, 'NaN'],
|
|
1988
|
+
[2 ** 53, '9007199254740992'], ['5000', '"5000"'], [10n, '10n'], [null, 'null'],
|
|
1989
|
+
];
|
|
1990
|
+
for (const [walkCap, shown] of refused) {
|
|
1991
|
+
assert.throws(() => new WorkspaceModule({ mounts: [{ name: 'work', path: '/tmp', mode: 'read-write', walkCap: walkCap }] }), (err) => err.message.startsWith('[workspace] Mount "work": walkCap must be a positive integer')
|
|
1992
|
+
&& err.message.endsWith(`, not ${shown}`), `walkCap ${String(walkCap)}`);
|
|
1993
|
+
}
|
|
1994
|
+
assert.doesNotThrow(() => new WorkspaceModule({ mounts: [{ name: 'work', path: '/tmp', mode: 'read-write', walkCap: 12_000 }] }));
|
|
1995
|
+
});
|
|
1996
|
+
test('a walk whose last file uses up the cap covered its scope: no place, nothing past the cap', async (t) => {
|
|
1997
|
+
const env = new Env(t);
|
|
1998
|
+
for (const f of ['a.txt', 'b.txt'])
|
|
1999
|
+
env.writeDisk(f, f);
|
|
2000
|
+
const m = await env.open({ walkCap: 2 });
|
|
2001
|
+
const synced = await call(m, 'sync', {});
|
|
2002
|
+
assert.equal(synced.incomplete, undefined, JSON.stringify(synced.incomplete));
|
|
2003
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), undefined);
|
|
2004
|
+
});
|
|
2005
|
+
test('a resumed pass that takes every file reports nothing past the cap and clears its place', async (t) => {
|
|
2006
|
+
const env = new Env(t);
|
|
2007
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt'])
|
|
2008
|
+
env.writeDisk(f, f);
|
|
2009
|
+
let m = await env.open({ walkCap: 2 });
|
|
2010
|
+
await call(m, 'sync', {});
|
|
2011
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), 'b.txt');
|
|
2012
|
+
// A larger cap: the pass resumes after b.txt, takes c.txt, wraps and
|
|
2013
|
+
// takes a.txt and b.txt, and stops short of the cap.
|
|
2014
|
+
m = await env.restart(m, { walkCap: 5 });
|
|
2015
|
+
const synced = await call(m, 'sync', {});
|
|
2016
|
+
assert.equal(synced.incomplete, undefined, JSON.stringify(synced.incomplete));
|
|
2017
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), undefined);
|
|
2018
|
+
});
|
|
2019
|
+
test('directories before and after the place: the after stretch takes a later sibling, the wrap an earlier one', async (t) => {
|
|
2020
|
+
const env = new Env(t);
|
|
2021
|
+
for (const f of ['a/1.txt', 'a/2.txt', 'a/3.txt', 'm/1.txt', 'm/2.txt'])
|
|
2022
|
+
env.writeDisk(f, f);
|
|
2023
|
+
const m = await env.open({ walkCap: 2 });
|
|
2024
|
+
await call(m, 'sync', {});
|
|
2025
|
+
assert.deepEqual(syncedNames(env), ['a/1.txt', 'a/2.txt']);
|
|
2026
|
+
// After a/2.txt: a/3.txt, then m/, the sibling after a/.
|
|
2027
|
+
await call(m, 'sync', {});
|
|
2028
|
+
assert.deepEqual(syncedNames(env), ['a/1.txt', 'a/2.txt', 'a/3.txt', 'm/1.txt']);
|
|
2029
|
+
// After m/1.txt: m/2.txt, then the wrap reaches a/, which lies before the place.
|
|
2030
|
+
env.writeDisk('a/1.txt', 'edited before the wrap');
|
|
2031
|
+
await call(m, 'sync', {});
|
|
2032
|
+
assert.deepEqual(syncedNames(env), ['a/1.txt', 'a/2.txt', 'a/3.txt', 'm/1.txt', 'm/2.txt']);
|
|
2033
|
+
assert.equal(await contentOf(m, 'a/1.txt'), 'edited before the wrap');
|
|
2034
|
+
});
|
|
2035
|
+
test('once the cap is reached no further directory is listed, so a deletion there waits for a pass that lists it', async (t) => {
|
|
2036
|
+
const env = new Env(t);
|
|
2037
|
+
const m = await env.open({ walkCap: 1 });
|
|
2038
|
+
await seedSynced(env, m, 'z/q.txt', 'q');
|
|
2039
|
+
env.rmDisk('z/q.txt');
|
|
2040
|
+
for (const f of ['a.txt', 'b.txt'])
|
|
2041
|
+
env.writeDisk(f, f);
|
|
2042
|
+
const synced = await call(m, 'sync', {});
|
|
2043
|
+
assert.notEqual(env.store.treeGet(TREE, 'z/q.txt'), null, 'z/ was not listed: nothing proven');
|
|
2044
|
+
assert.ok(synced.incomplete.some((r) => r.mount === 'work' && r.path === 'z' && /not visited/.test(r.reason)), JSON.stringify(synced.incomplete));
|
|
2045
|
+
});
|
|
2046
|
+
test('the capped pass says once where the next resumes, and each unreached path gets the reason of the region nearest it', async (t) => {
|
|
2047
|
+
const env = new Env(t);
|
|
2048
|
+
const m = await env.open({ walkCap: 1 });
|
|
2049
|
+
await seedSynced(env, m, 'z/q.txt', 'q');
|
|
2050
|
+
for (const f of ['a.txt', 'b.txt'])
|
|
2051
|
+
env.writeDisk(f, f);
|
|
2052
|
+
const synced = await call(m, 'sync', {});
|
|
2053
|
+
const resumes = synced.incomplete.filter((r) => /resumes after/.test(r.reason));
|
|
2054
|
+
assert.deepEqual(resumes.map((r) => [r.mount, r.path, r.reason]), [['work', '', 'the walk cap (1) was reached: the next sync of the mount resumes after work/a.txt']]);
|
|
2055
|
+
// z/q.txt sits beneath z/, which wasn't listed: its reason is z/'s, not
|
|
2056
|
+
// that of the root, which holds an untaken b.txt.
|
|
2057
|
+
const skipped = synced.skipped?.find((x) => x.path === 'z/q.txt');
|
|
2058
|
+
assert.match(skipped?.reason ?? '', /not visited: the walk cap \(1\) was reached/, JSON.stringify(synced.skipped));
|
|
2059
|
+
});
|
|
2060
|
+
test("the sync tool's description says how the walk takes turns", async (t) => {
|
|
2061
|
+
const env = new Env(t);
|
|
2062
|
+
const m = await env.open({});
|
|
2063
|
+
const sync = m.getTools().find((tool) => tool.name === 'sync');
|
|
2064
|
+
assert.ok(sync);
|
|
2065
|
+
assert.match(sync.description, /the next sync of the same mount or directory picks up where the last turn stopped/);
|
|
2066
|
+
assert.match(sync.description, /its own catch-up scan, at start and when its watcher reattaches, takes turns with its whole-mount syncs/);
|
|
2067
|
+
});
|
|
2068
|
+
test("a watched mount's capped sync names its catch-up scan as the other turn", async (t) => {
|
|
2069
|
+
const env = new Env(t);
|
|
2070
|
+
for (const f of ['a.txt', 'b.txt'])
|
|
2071
|
+
env.writeDisk(f, f);
|
|
2072
|
+
const m = await env.open({ walkCap: 1 });
|
|
2073
|
+
// Read as a watched mount from here; opened unwatched, so no watcher runs.
|
|
2074
|
+
m.mounts.get('work').config.watch = 'always';
|
|
2075
|
+
const synced = await call(m, 'sync', {});
|
|
2076
|
+
assert.ok(synced.incomplete.some((r) => r.path === ''
|
|
2077
|
+
&& r.reason === 'the walk cap (1) was reached: the next sync of the mount, or its catch-up scan if that comes first, resumes after work/a.txt'), JSON.stringify(synced.incomplete));
|
|
2078
|
+
});
|
|
2079
|
+
test("an agent-action scan takes the mount's start after every batch, and leaves the syncs' place alone", async (t) => {
|
|
2080
|
+
const env = new Env(t);
|
|
2081
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt', 'd.txt'])
|
|
2082
|
+
env.writeDisk(f, f);
|
|
2083
|
+
const m = await env.open({ watch: 'on-agent-action', walkCap: 2 });
|
|
2084
|
+
const agreement = m.agreementOrThrow();
|
|
2085
|
+
const stored = (p) => {
|
|
2086
|
+
const e = env.store.treeGet(TREE, p);
|
|
2087
|
+
return e ? env.store.getBlob(e.blobHash)?.toString('utf8') : null;
|
|
2088
|
+
};
|
|
2089
|
+
const capReports = () => env.events.filter((e) => e.type === 'workspace:agent-action-scan-incomplete').length;
|
|
2090
|
+
// What a shell tool did to the first file in the walk's order is in the
|
|
2091
|
+
// workspace after every batch, and each capped scan says where the cap
|
|
2092
|
+
// stopped it.
|
|
2093
|
+
for (const edit of ['edit one', 'edit number two', 'the third edit, longest']) {
|
|
2094
|
+
env.writeDisk('a.txt', edit);
|
|
2095
|
+
const reported = capReports();
|
|
2096
|
+
await m.onToolBatchComplete('agent');
|
|
2097
|
+
assert.equal(stored('a.txt'), edit);
|
|
2098
|
+
assert.equal(capReports(), reported + 1, 'the batch\'s scan reported its cap');
|
|
2099
|
+
}
|
|
2100
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt'], 'the same two files every time');
|
|
2101
|
+
assert.equal(agreement.walkResumesAfter('work', ''), undefined, 'and no place');
|
|
2102
|
+
// A bare sync keeps its place, and a batch's scan between two syncs
|
|
2103
|
+
// doesn't move it.
|
|
2104
|
+
await call(m, 'sync', {});
|
|
2105
|
+
assert.equal(agreement.walkResumesAfter('work', ''), 'b.txt');
|
|
2106
|
+
await m.onToolBatchComplete('agent');
|
|
2107
|
+
assert.equal(agreement.walkResumesAfter('work', ''), 'b.txt');
|
|
2108
|
+
await call(m, 'sync', {});
|
|
2109
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt', 'c.txt', 'd.txt']);
|
|
2110
|
+
// A file a shell tool deleted among them is gone after the batch.
|
|
2111
|
+
env.rmDisk('b.txt');
|
|
2112
|
+
await m.onToolBatchComplete('agent');
|
|
2113
|
+
assert.equal(stored('b.txt'), null);
|
|
2114
|
+
});
|
|
2115
|
+
test("a watched mount's catch-up scan resumes where the last stopped", async (t) => {
|
|
2116
|
+
const env = new Env(t);
|
|
2117
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt', 'd.txt'])
|
|
2118
|
+
env.writeDisk(f, f);
|
|
2119
|
+
const m = await env.open({ walkCap: 2 });
|
|
2120
|
+
await m.initialScan('work');
|
|
2121
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt']);
|
|
2122
|
+
await m.initialScan('work');
|
|
2123
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt', 'c.txt', 'd.txt']);
|
|
2124
|
+
});
|
|
2125
|
+
test('glob and grep take the same files every time and leave no place', async (t) => {
|
|
2126
|
+
const env = new Env(t);
|
|
2127
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt'])
|
|
2128
|
+
env.writeDisk(f, 'v');
|
|
2129
|
+
const m = await env.open({ walkCap: 2 });
|
|
2130
|
+
const globbed = async () => (await call(m, 'glob', { pattern: '**' })).matches.map((x) => x.path).sort();
|
|
2131
|
+
const first = await globbed();
|
|
2132
|
+
assert.deepEqual(await globbed(), first);
|
|
2133
|
+
const grepped = async () => JSON.stringify(await call(m, 'grep', { pattern: 'v' }));
|
|
2134
|
+
const once = await grepped();
|
|
2135
|
+
assert.equal(await grepped(), once);
|
|
2136
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), undefined);
|
|
2137
|
+
});
|
|
2138
|
+
test("a directory sync leaves the mount root's place as it was", async (t) => {
|
|
2139
|
+
const env = new Env(t);
|
|
2140
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt', 'd/x.txt', 'd/y.txt', 'd/z.txt'])
|
|
2141
|
+
env.writeDisk(f, f);
|
|
2142
|
+
const m = await env.open({ walkCap: 2 });
|
|
2143
|
+
await call(m, 'sync', {});
|
|
2144
|
+
const agreement = m.agreementOrThrow();
|
|
2145
|
+
assert.equal(agreement.walkResumesAfter('work', ''), 'b.txt');
|
|
2146
|
+
await call(m, 'sync', { path: 'work/d' });
|
|
2147
|
+
await call(m, 'sync', { path: 'work/d' });
|
|
2148
|
+
assert.equal(agreement.walkResumesAfter('work', ''), 'b.txt');
|
|
2149
|
+
});
|
|
2150
|
+
test("a pass that can't list its scope's directory leaves the place as it was", { skip: IS_ROOT ? 'root reads every directory' : false }, async (t) => {
|
|
2151
|
+
const env = new Env(t);
|
|
2152
|
+
for (const f of ['d/a.txt', 'd/b.txt', 'd/c.txt'])
|
|
2153
|
+
env.writeDisk(f, f);
|
|
2154
|
+
const m = await env.open({ walkCap: 2 });
|
|
2155
|
+
await call(m, 'sync', { path: 'work/d' });
|
|
2156
|
+
const agreement = m.agreementOrThrow();
|
|
2157
|
+
assert.equal(agreement.walkResumesAfter('work', 'd'), 'd/b.txt');
|
|
2158
|
+
chmodSync(env.disk('d'), 0o000);
|
|
2159
|
+
try {
|
|
2160
|
+
const blocked = await call(m, 'sync', { path: 'work/d' });
|
|
2161
|
+
assert.ok(blocked.incomplete?.some((r) => /cannot list/.test(r.reason)), JSON.stringify(blocked));
|
|
2162
|
+
assert.equal(agreement.walkResumesAfter('work', 'd'), 'd/b.txt', 'the place stands');
|
|
2163
|
+
}
|
|
2164
|
+
finally {
|
|
2165
|
+
chmodSync(env.disk('d'), 0o755);
|
|
2166
|
+
}
|
|
2167
|
+
await call(m, 'sync', { path: 'work/d' });
|
|
2168
|
+
assert.ok(syncedNames(env).includes('d/c.txt'), 'the next pass resumed after d/b.txt');
|
|
2169
|
+
});
|
|
2170
|
+
test('a directory replaced between the two stretches of a resumed walk proves nothing absent from its old listing', async (t) => {
|
|
2171
|
+
const env = new Env(t);
|
|
2172
|
+
const m = await env.open({ walkCap: 3 });
|
|
2173
|
+
for (const f of ['s/a.txt', 's/b.txt', 's/c.txt', 's/z.txt', 't/x.txt'])
|
|
2174
|
+
await seedSynced(env, m, f, f);
|
|
2175
|
+
await call(m, 'sync', {}); // takes s/a, s/b, s/c: the place is s/c.txt
|
|
2176
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), 's/c.txt');
|
|
2177
|
+
env.rmDisk('s/z.txt');
|
|
2178
|
+
// The next pass lists s/ (without z.txt) on the after stretch, then t/.
|
|
2179
|
+
// While t/ is listed, s/ is replaced by a directory that holds z.txt, so
|
|
2180
|
+
// the wrap's second visit to s/ meets another directory.
|
|
2181
|
+
let swapped = false;
|
|
2182
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2183
|
+
if (!swapped && String(path).endsWith(`${'/'}t`)) {
|
|
2184
|
+
swapped = true;
|
|
2185
|
+
renameSync(env.disk('s'), join(env.root, 's-away'));
|
|
2186
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt', 'z.txt'])
|
|
2187
|
+
env.writeDisk(`s/${f}`, `s/${f}`);
|
|
2188
|
+
}
|
|
2189
|
+
return readdir(path, ...rest);
|
|
2190
|
+
}, async () => {
|
|
2191
|
+
const synced = await call(m, 'sync', {});
|
|
2192
|
+
assert.ok(synced.incomplete?.some((r) => r.mount === 'work' && r.path === 's' && /changed while the walk ran/.test(r.reason)), JSON.stringify(synced.incomplete));
|
|
2193
|
+
// The pass took one file of its three, t/x.txt: nothing it left is the cap's.
|
|
2194
|
+
assert.ok(!synced.incomplete.some((r) => /walk cap/.test(r.reason)), JSON.stringify(synced.incomplete));
|
|
2195
|
+
});
|
|
2196
|
+
assert.ok(swapped);
|
|
2197
|
+
assert.notEqual(env.store.treeGet(TREE, 's/z.txt'), null, 's/z.txt is on disk in the new s/: not proven absent');
|
|
2198
|
+
});
|
|
2199
|
+
test("a directory sync's scope replaced between the two stretches proves nothing absent from its old listing", async (t) => {
|
|
2200
|
+
const env = new Env(t);
|
|
2201
|
+
const m = await env.open({ walkCap: 3 });
|
|
2202
|
+
for (const f of ['d/a.txt', 'd/b.txt', 'd/c.txt', 'd/z.txt', 'd/t/x.txt'])
|
|
2203
|
+
await seedSynced(env, m, f, f);
|
|
2204
|
+
await call(m, 'sync', { path: 'work/d' }); // takes d/a, d/b, d/c: the place is d/c.txt
|
|
2205
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', 'd'), 'd/c.txt');
|
|
2206
|
+
env.rmDisk('d/z.txt');
|
|
2207
|
+
// The scope's own directory is replaced while the after stretch lists
|
|
2208
|
+
// d/t/, so the wrap comes back to another d/, which holds z.txt.
|
|
2209
|
+
let swapped = false;
|
|
2210
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2211
|
+
if (!swapped && String(path).endsWith(`${'/'}d${'/'}t`)) {
|
|
2212
|
+
swapped = true;
|
|
2213
|
+
renameSync(env.disk('d'), join(env.root, 'd-away'));
|
|
2214
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt', 'z.txt', 't/x.txt'])
|
|
2215
|
+
env.writeDisk(`d/${f}`, `d/${f}`);
|
|
2216
|
+
}
|
|
2217
|
+
return readdir(path, ...rest);
|
|
2218
|
+
}, async () => {
|
|
2219
|
+
const synced = await call(m, 'sync', { path: 'work/d' });
|
|
2220
|
+
assert.ok(synced.incomplete?.some((r) => /changed while the walk ran/.test(r.reason) && /(^|\/)d$/.test(r.path)), JSON.stringify(synced.incomplete));
|
|
2221
|
+
});
|
|
2222
|
+
assert.ok(swapped);
|
|
2223
|
+
assert.notEqual(env.store.treeGet(TREE, 'd/z.txt'), null, 'd/z.txt is on disk in the new d/: not proven absent');
|
|
2224
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', 'd'), 'd/c.txt', 'a pass whose scope changed under it leaves the place');
|
|
2225
|
+
});
|
|
2226
|
+
test('when a directory is replaced, the listings read beneath it before stop proving anything too', async (t) => {
|
|
2227
|
+
const env = new Env(t);
|
|
2228
|
+
const m = await env.open({ walkCap: 3 });
|
|
2229
|
+
for (const f of ['d/a.txt', 'd/b.txt', 'd/c.txt', 'd/t/k.txt', 'd/u/x.txt'])
|
|
2230
|
+
await seedSynced(env, m, f, f);
|
|
2231
|
+
await call(m, 'sync', { path: 'work/d' }); // takes d/a, d/b, d/c: the place is d/c.txt
|
|
2232
|
+
env.rmDisk('d/t/k.txt');
|
|
2233
|
+
// The after stretch lists d/t/ (without k.txt), then d/ is replaced while
|
|
2234
|
+
// d/u/ is listed; the new d/t/ holds k.txt.
|
|
2235
|
+
let swapped = false;
|
|
2236
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2237
|
+
if (!swapped && String(path).endsWith(`${'/'}d${'/'}u`)) {
|
|
2238
|
+
swapped = true;
|
|
2239
|
+
renameSync(env.disk('d'), join(env.root, 'd-away'));
|
|
2240
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt', 't/k.txt', 'u/x.txt'])
|
|
2241
|
+
env.writeDisk(`d/${f}`, `d/${f}`);
|
|
2242
|
+
}
|
|
2243
|
+
return readdir(path, ...rest);
|
|
2244
|
+
}, () => call(m, 'sync', { path: 'work/d' }));
|
|
2245
|
+
assert.ok(swapped);
|
|
2246
|
+
assert.notEqual(env.store.treeGet(TREE, 'd/t/k.txt'), null, "d/t/'s listing came from the replaced d/: it proves nothing absent");
|
|
2247
|
+
});
|
|
2248
|
+
test("a replaced scope's subdirectory before the place isn't reported as the cap's", async (t) => {
|
|
2249
|
+
const env = new Env(t);
|
|
2250
|
+
const m = await env.open({ walkCap: 3 });
|
|
2251
|
+
const files = ['d/a/f.txt', 'd/m/m1.txt', 'd/m/m2.txt', 'd/m/m3.txt', 'd/m/m4.txt', 'd/z/q.txt'];
|
|
2252
|
+
for (const f of files)
|
|
2253
|
+
await seedSynced(env, m, f, f);
|
|
2254
|
+
await call(m, 'sync', { path: 'work/d' }); // takes d/a/f, d/m/m1, d/m/m2: the place is d/m/m2.txt
|
|
2255
|
+
// The after stretch takes d/m/m3 and d/m/m4 (two of three), then lists
|
|
2256
|
+
// d/z/, while d/ is replaced; the wrap then meets another d/.
|
|
2257
|
+
let swapped = false;
|
|
2258
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2259
|
+
if (!swapped && String(path).endsWith(`${'/'}d${'/'}z`)) {
|
|
2260
|
+
swapped = true;
|
|
2261
|
+
renameSync(env.disk('d'), join(env.root, 'd-away'));
|
|
2262
|
+
for (const f of files)
|
|
2263
|
+
env.writeDisk(f, f);
|
|
2264
|
+
}
|
|
2265
|
+
return readdir(path, ...rest);
|
|
2266
|
+
}, async () => {
|
|
2267
|
+
const synced = await call(m, 'sync', { path: 'work/d' });
|
|
2268
|
+
assert.ok(synced.incomplete?.some((r) => /changed while the walk ran/.test(r.reason) && /(^|\/)d$/.test(r.path)), JSON.stringify(synced.incomplete));
|
|
2269
|
+
assert.ok(!synced.incomplete.some((r) => /walk cap/.test(r.reason)), JSON.stringify(synced.incomplete));
|
|
2270
|
+
});
|
|
2271
|
+
assert.ok(swapped);
|
|
2272
|
+
});
|
|
2273
|
+
test('a subdirectory proven missing beneath a scope that is then replaced proves nothing absent', async (t) => {
|
|
2274
|
+
const env = new Env(t);
|
|
2275
|
+
const m = await env.open({ walkCap: 3 });
|
|
2276
|
+
const files = ['d/a.txt', 'd/b.txt', 'd/c.txt', 'd/k/x.txt', 'd/u/q.txt', 'd/z/y.txt'];
|
|
2277
|
+
for (const f of files)
|
|
2278
|
+
await seedSynced(env, m, f, f);
|
|
2279
|
+
await call(m, 'sync', { path: 'work/d' }); // takes d/a, d/b, d/c: the place is d/c.txt
|
|
2280
|
+
// While the after stretch lists d/k/, d/u/ is removed, so the walk proves
|
|
2281
|
+
// it missing; while it lists d/z/, d/ is replaced by a directory holding
|
|
2282
|
+
// every file again, u/q.txt included (as a delete-and-recreate would).
|
|
2283
|
+
let removed = false;
|
|
2284
|
+
let swapped = false;
|
|
2285
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2286
|
+
if (!removed && String(path).endsWith(`${'/'}d${'/'}k`)) {
|
|
2287
|
+
removed = true;
|
|
2288
|
+
rmSync(env.disk('d/u'), { recursive: true, force: true });
|
|
2289
|
+
}
|
|
2290
|
+
if (!swapped && String(path).endsWith(`${'/'}d${'/'}z`)) {
|
|
2291
|
+
swapped = true;
|
|
2292
|
+
renameSync(env.disk('d'), join(env.root, 'd-away'));
|
|
2293
|
+
for (const f of files)
|
|
2294
|
+
env.writeDisk(f, f);
|
|
2295
|
+
}
|
|
2296
|
+
return readdir(path, ...rest);
|
|
2297
|
+
}, () => call(m, 'sync', { path: 'work/d' }));
|
|
2298
|
+
assert.ok(removed && swapped);
|
|
2299
|
+
assert.notEqual(env.store.treeGet(TREE, 'd/u/q.txt'), null, 'd/u/q.txt is on disk in the new d/: not proven absent');
|
|
2300
|
+
});
|
|
2301
|
+
test('a directory listed on the after stretch that is a symlink on the wrap proves nothing absent from its old listing', async (t) => {
|
|
2302
|
+
const env = new Env(t);
|
|
2303
|
+
const m = await env.open({ walkCap: 3 });
|
|
2304
|
+
for (const f of ['s/a.txt', 's/b.txt', 's/c.txt', 's/z.txt', 't/x.txt'])
|
|
2305
|
+
await seedSynced(env, m, f, f);
|
|
2306
|
+
await call(m, 'sync', {}); // takes s/a, s/b, s/c: the place is s/c.txt
|
|
2307
|
+
env.rmDisk('s/z.txt');
|
|
2308
|
+
// While t/ is listed, s/ becomes a link to a directory that holds z.txt.
|
|
2309
|
+
// The wrap's check of s/ fails, so its listing from the after stretch
|
|
2310
|
+
// describes a directory that isn't there.
|
|
2311
|
+
let swapped = false;
|
|
2312
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2313
|
+
if (!swapped && String(path).endsWith(`${'/'}t`)) {
|
|
2314
|
+
swapped = true;
|
|
2315
|
+
renameSync(env.disk('s'), join(env.root, 's-away'));
|
|
2316
|
+
writeFileSync(join(env.root, 's-away', 'z.txt'), 's/z.txt');
|
|
2317
|
+
symlinkSync(join(env.root, 's-away'), env.disk('s'));
|
|
2318
|
+
}
|
|
2319
|
+
return readdir(path, ...rest);
|
|
2320
|
+
}, () => call(m, 'sync', {}));
|
|
2321
|
+
assert.ok(swapped);
|
|
2322
|
+
assert.notEqual(env.store.treeGet(TREE, 's/z.txt'), null, "s/'s old listing proves nothing once s/ isn't that directory");
|
|
2323
|
+
});
|
|
2324
|
+
test('a directory proven missing on the after stretch and back by the wrap proves nothing absent', async (t) => {
|
|
2325
|
+
const env = new Env(t);
|
|
2326
|
+
const m = await env.open({ walkCap: 2 });
|
|
2327
|
+
for (const f of ['m/1.txt', 'm/2.txt', 'm/3.txt', 'z/q.txt'])
|
|
2328
|
+
await seedSynced(env, m, f, f);
|
|
2329
|
+
await call(m, 'sync', {}); // takes m/1, m/2: the place is m/2.txt
|
|
2330
|
+
// The root's listing shows m/, which is gone by the time the after
|
|
2331
|
+
// stretch checks it, so the walk proves m/ missing; while it lists z/,
|
|
2332
|
+
// m/ comes back.
|
|
2333
|
+
let removed = false;
|
|
2334
|
+
let restored = false;
|
|
2335
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2336
|
+
if (!removed && String(path) === realpathSync(env.dir)) {
|
|
2337
|
+
const listed = await readdir(path, ...rest);
|
|
2338
|
+
removed = true;
|
|
2339
|
+
rmSync(env.disk('m'), { recursive: true });
|
|
2340
|
+
return listed;
|
|
2341
|
+
}
|
|
2342
|
+
if (!restored && String(path).endsWith(`${'/'}z`)) {
|
|
2343
|
+
restored = true;
|
|
2344
|
+
for (const f of ['m/1.txt', 'm/2.txt', 'm/3.txt'])
|
|
2345
|
+
env.writeDisk(f, f);
|
|
2346
|
+
}
|
|
2347
|
+
return readdir(path, ...rest);
|
|
2348
|
+
}, () => call(m, 'sync', {}));
|
|
2349
|
+
assert.ok(removed && restored);
|
|
2350
|
+
// The wrap lists the m/ there now and takes m/1.txt from it.
|
|
2351
|
+
for (const f of ['m/1.txt', 'm/2.txt', 'm/3.txt'])
|
|
2352
|
+
assert.notEqual(env.store.treeGet(TREE, f), null, `${f} is on disk`);
|
|
2353
|
+
});
|
|
2354
|
+
test('a link takes a place under the cap as a file does, so a followed link past the cap is read on a later pass', async (t) => {
|
|
2355
|
+
const env = new Env(t);
|
|
2356
|
+
for (const f of ['zz/t1.txt', 'zz/t2.txt', 'zz/t3.txt'])
|
|
2357
|
+
env.writeDisk(f, `target ${f}`);
|
|
2358
|
+
env.writeDisk('a.txt', 'a');
|
|
2359
|
+
for (const n of ['1', '2', '3'])
|
|
2360
|
+
symlinkSync(env.disk(`zz/t${n}.txt`), env.disk(`l${n}`));
|
|
2361
|
+
const m = await env.open({ walkCap: 2, followSymlinks: true });
|
|
2362
|
+
// The root's entries in order: a.txt, l1, l2, l3; the cap takes a.txt and l1.
|
|
2363
|
+
const first = await call(m, 'sync', {});
|
|
2364
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'l1'], JSON.stringify(first));
|
|
2365
|
+
assert.ok(first.incomplete.some((r) => r.path === '' && /before everything here was taken/.test(r.reason)), JSON.stringify(first.incomplete));
|
|
2366
|
+
// Like a file past the cap, an untracked link the pass didn't take isn't reported by path.
|
|
2367
|
+
assert.ok(!(first.skipped ?? []).some((x) => x.path === 'l2' || x.path === 'l3'), JSON.stringify(first.skipped));
|
|
2368
|
+
await call(m, 'sync', {}); // l2 and l3: the place is l3
|
|
2369
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'l1', 'l2', 'l3']);
|
|
2370
|
+
// A tracked link the next pass doesn't take isn't read through either:
|
|
2371
|
+
// the third pass takes zz/t1.txt and zz/t2.txt, not l3.
|
|
2372
|
+
env.writeDisk('zz/t3.txt', 'changed behind l3');
|
|
2373
|
+
await call(m, 'sync', {});
|
|
2374
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'l1', 'l2', 'l3', 'zz/t1.txt', 'zz/t2.txt'], 'the third pass took zz/t1.txt and zz/t2.txt');
|
|
2375
|
+
assert.equal(await contentOf(m, 'l3'), 'target zz/t3.txt');
|
|
2376
|
+
});
|
|
2377
|
+
test('a directory proven missing on the after stretch that is an in-mount link on the wrap proves nothing absent', async (t) => {
|
|
2378
|
+
const env = new Env(t);
|
|
2379
|
+
const m = await env.open({ walkCap: 2 });
|
|
2380
|
+
for (const f of ['m/1.txt', 'm/2.txt', 'm/3.txt', 'z/q.txt'])
|
|
2381
|
+
await seedSynced(env, m, f, f);
|
|
2382
|
+
await call(m, 'sync', {}); // takes m/1, m/2: the place is m/2.txt
|
|
2383
|
+
// After the root is listed, m/ moves to m-away/ (in the mount), so the
|
|
2384
|
+
// after stretch proves m/ missing; while z/ is listed, m becomes a link
|
|
2385
|
+
// to m-away/, which holds all three files.
|
|
2386
|
+
let moved = false;
|
|
2387
|
+
let linked = false;
|
|
2388
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2389
|
+
if (!moved && String(path) === realpathSync(env.dir)) {
|
|
2390
|
+
const listed = await readdir(path, ...rest);
|
|
2391
|
+
moved = true;
|
|
2392
|
+
renameSync(env.disk('m'), env.disk('m-away'));
|
|
2393
|
+
return listed;
|
|
2394
|
+
}
|
|
2395
|
+
if (!linked && String(path).endsWith(`${'/'}z`)) {
|
|
2396
|
+
linked = true;
|
|
2397
|
+
symlinkSync(env.disk('m-away'), env.disk('m'));
|
|
2398
|
+
}
|
|
2399
|
+
return readdir(path, ...rest);
|
|
2400
|
+
}, () => call(m, 'sync', {}));
|
|
2401
|
+
assert.ok(moved && linked);
|
|
2402
|
+
for (const f of ['m/1.txt', 'm/2.txt', 'm/3.txt'])
|
|
2403
|
+
assert.notEqual(env.store.treeGet(TREE, f), null, `${f} is on disk through the link m: not proven absent`);
|
|
2404
|
+
});
|
|
2405
|
+
test('a subdirectory proven missing on the after stretch stays missing when its parent is the same directory on the wrap', async (t) => {
|
|
2406
|
+
const env = new Env(t);
|
|
2407
|
+
const m = await env.open({ walkCap: 2 });
|
|
2408
|
+
for (const f of ['d/a.txt', 'd/b.txt', 'd/q/x.txt'])
|
|
2409
|
+
await seedSynced(env, m, f, f);
|
|
2410
|
+
await call(m, 'sync', {}); // takes d/a, d/b: the place is d/b.txt; d/q is left
|
|
2411
|
+
env.rmDisk('d/a.txt'); // so the next pass takes one of its two
|
|
2412
|
+
// d/ is listed with q/ in it; q/ is gone by the time the after stretch
|
|
2413
|
+
// checks it, so the walk proves d/q missing. d/ itself is untouched.
|
|
2414
|
+
let removed = false;
|
|
2415
|
+
let synced;
|
|
2416
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2417
|
+
if (!removed && String(path).endsWith(`${'/'}d`)) {
|
|
2418
|
+
const listed = await readdir(path, ...rest);
|
|
2419
|
+
removed = true;
|
|
2420
|
+
rmSync(env.disk('d/q'), { recursive: true });
|
|
2421
|
+
return listed;
|
|
2422
|
+
}
|
|
2423
|
+
return readdir(path, ...rest);
|
|
2424
|
+
}, async () => {
|
|
2425
|
+
synced = await call(m, 'sync', {});
|
|
2426
|
+
});
|
|
2427
|
+
assert.ok(removed);
|
|
2428
|
+
assert.ok(!(synced.incomplete ?? []).some((r) => /walk cap/.test(r.reason)), `the pass took one of its two: ${JSON.stringify(synced.incomplete)}`);
|
|
2429
|
+
assert.equal(env.store.treeGet(TREE, 'd/q/x.txt'), null, 'd/q was proven missing: d/q/x.txt is gone from disk');
|
|
2430
|
+
});
|
|
2431
|
+
test('a tracked link the cap leaves is unverified for the cap, as a tracked file is, and listed under incomplete only by its directory', async (t) => {
|
|
2432
|
+
const env = new Env(t);
|
|
2433
|
+
for (const f of ['zz/t1.txt', 'zz/t2.txt', 'zz/t3.txt'])
|
|
2434
|
+
env.writeDisk(f, `target ${f}`);
|
|
2435
|
+
env.writeDisk('a.txt', 'a');
|
|
2436
|
+
for (const n of ['1', '2', '3'])
|
|
2437
|
+
symlinkSync(env.disk(`zz/t${n}.txt`), env.disk(`l${n}`));
|
|
2438
|
+
const m = await env.open({ walkCap: 2, followSymlinks: true });
|
|
2439
|
+
await call(m, 'sync', {}); // a.txt, l1
|
|
2440
|
+
await call(m, 'sync', {}); // l2, l3: the place is l3
|
|
2441
|
+
const third = await call(m, 'sync', {}); // zz/t1.txt, zz/t2.txt
|
|
2442
|
+
for (const p of ['a.txt', 'l1', 'l2', 'l3']) {
|
|
2443
|
+
const reason = (third.skipped ?? []).find((x) => x.path === p)?.reason;
|
|
2444
|
+
assert.match(reason ?? '', /walk cap \(2\) was reached/, `${p}: ${reason}`);
|
|
2445
|
+
}
|
|
2446
|
+
assert.ok(!(third.incomplete ?? []).some((r) => ['l1', 'l2', 'l3'].includes(r.path)), JSON.stringify(third.incomplete));
|
|
2447
|
+
});
|
|
2448
|
+
test("a replaced directory's untaken links and ignored names are no longer counted, as the cap's or as passed over", async (t) => {
|
|
2449
|
+
const env = new Env(t);
|
|
2450
|
+
for (const f of ['s/a.txt', 's/b.txt', 's/c.txt', 's/x.log', 't/x.txt'])
|
|
2451
|
+
env.writeDisk(f, f);
|
|
2452
|
+
symlinkSync(env.disk('t/x.txt'), env.disk('s/aa-link'));
|
|
2453
|
+
const m = await env.open({ walkCap: 3, ignore: ['*.log'] });
|
|
2454
|
+
await call(m, 'sync', {}); // s/a.txt, s/aa-link, s/b.txt: the place is s/b.txt
|
|
2455
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), 's/b.txt');
|
|
2456
|
+
// The after stretch takes s/c.txt and lists t/, while s/ is replaced; the
|
|
2457
|
+
// wrap then meets another s/, so the old listing's a.txt, aa-link and
|
|
2458
|
+
// x.log (ignored) describe nothing there.
|
|
2459
|
+
let swapped = false;
|
|
2460
|
+
let synced;
|
|
2461
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2462
|
+
if (!swapped && String(path).endsWith(`${'/'}t`)) {
|
|
2463
|
+
swapped = true;
|
|
2464
|
+
renameSync(env.disk('s'), join(env.root, 's-away'));
|
|
2465
|
+
for (const f of ['s/a.txt', 's/b.txt', 's/c.txt'])
|
|
2466
|
+
env.writeDisk(f, f);
|
|
2467
|
+
}
|
|
2468
|
+
return readdir(path, ...rest);
|
|
2469
|
+
}, async () => {
|
|
2470
|
+
synced = await call(m, 'sync', {});
|
|
2471
|
+
});
|
|
2472
|
+
assert.ok(swapped);
|
|
2473
|
+
assert.ok(synced.incomplete.some((r) => r.path === 's' && /changed while the walk ran/.test(r.reason)), JSON.stringify(synced.incomplete));
|
|
2474
|
+
// The pass took two entries of its three: nothing is the cap's.
|
|
2475
|
+
assert.ok(!synced.incomplete.some((r) => /walk cap/.test(r.reason)), JSON.stringify(synced.incomplete));
|
|
2476
|
+
assert.equal(synced.passedOver, undefined, JSON.stringify(synced.passedOver));
|
|
2477
|
+
});
|
|
2478
|
+
test('a directory gone when the after stretch lists it, and back by the wrap, is listed afresh there', async (t) => {
|
|
2479
|
+
const env = new Env(t);
|
|
2480
|
+
const m = await env.open({ walkCap: 2 });
|
|
2481
|
+
for (const f of ['m/1.txt', 'm/2.txt', 'm/3.txt', 'z/q.txt'])
|
|
2482
|
+
await seedSynced(env, m, f, f);
|
|
2483
|
+
await call(m, 'sync', {}); // takes m/1, m/2: the place is m/2.txt
|
|
2484
|
+
// m/ is there when it's checked and gone when it's listed, so the after
|
|
2485
|
+
// stretch proves it missing; while z/ is listed, it comes back, with an
|
|
2486
|
+
// edit to m/1.txt the wrap takes.
|
|
2487
|
+
let removed = false;
|
|
2488
|
+
let restored = false;
|
|
2489
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2490
|
+
if (!removed && String(path).endsWith(`${'/'}m`)) {
|
|
2491
|
+
removed = true;
|
|
2492
|
+
rmSync(env.disk('m'), { recursive: true });
|
|
2493
|
+
}
|
|
2494
|
+
if (!restored && String(path).endsWith(`${'/'}z`)) {
|
|
2495
|
+
restored = true;
|
|
2496
|
+
env.writeDisk('m/1.txt', 'm/1.txt edited while it was gone');
|
|
2497
|
+
for (const f of ['m/2.txt', 'm/3.txt'])
|
|
2498
|
+
env.writeDisk(f, f);
|
|
2499
|
+
}
|
|
2500
|
+
return readdir(path, ...rest);
|
|
2501
|
+
}, () => call(m, 'sync', {}));
|
|
2502
|
+
assert.ok(removed && restored);
|
|
2503
|
+
assert.equal(await contentOf(m, 'm/1.txt'), 'm/1.txt edited while it was gone');
|
|
2504
|
+
});
|
|
2505
|
+
test('a directory sync whose own directory is gone when the after stretch lists it, and another by the wrap, proves nothing absent', async (t) => {
|
|
2506
|
+
const env = new Env(t);
|
|
2507
|
+
const m = await env.open({ walkCap: 2 });
|
|
2508
|
+
for (const f of ['d/1.txt', 'd/2.txt', 'd/3.txt'])
|
|
2509
|
+
await seedSynced(env, m, f, f);
|
|
2510
|
+
await call(m, 'sync', { path: 'work/d' }); // takes d/1, d/2: the place is d/2.txt
|
|
2511
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', 'd'), 'd/2.txt');
|
|
2512
|
+
// d/ is there when the walk checks it and gone when the after stretch
|
|
2513
|
+
// lists it, so the walk proves it missing; by the wrap's listing another
|
|
2514
|
+
// d/ holds the same three files.
|
|
2515
|
+
let calls = 0;
|
|
2516
|
+
let synced;
|
|
2517
|
+
await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
|
|
2518
|
+
if (String(path).endsWith(`${'/'}d`)) {
|
|
2519
|
+
calls++;
|
|
2520
|
+
if (calls === 1)
|
|
2521
|
+
renameSync(env.disk('d'), join(env.root, 'd-away'));
|
|
2522
|
+
if (calls === 2)
|
|
2523
|
+
for (const f of ['d/1.txt', 'd/2.txt', 'd/3.txt'])
|
|
2524
|
+
env.writeDisk(f, f);
|
|
2525
|
+
}
|
|
2526
|
+
return readdir(path, ...rest);
|
|
2527
|
+
}, async () => {
|
|
2528
|
+
synced = await call(m, 'sync', { path: 'work/d' });
|
|
2529
|
+
});
|
|
2530
|
+
assert.ok(calls >= 2, 'the wrap listed d/ again');
|
|
2531
|
+
for (const f of ['d/1.txt', 'd/2.txt', 'd/3.txt'])
|
|
2532
|
+
assert.notEqual(env.store.treeGet(TREE, f), null, `${f} is on disk in the new d/: not proven absent`);
|
|
2533
|
+
assert.ok(synced.incomplete.some((r) => r.path === 'd' && /changed while the walk ran/.test(r.reason)), JSON.stringify(synced.incomplete));
|
|
2534
|
+
});
|
|
2535
|
+
test('an accepted new root starts with no place', async (t) => {
|
|
2536
|
+
const env = new Env(t);
|
|
2537
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt'])
|
|
2538
|
+
env.writeDisk(f, f);
|
|
2539
|
+
const m = await env.open({ walkCap: 2 });
|
|
2540
|
+
await call(m, 'sync', {});
|
|
2541
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), 'b.txt');
|
|
2542
|
+
// Another directory takes the root's place, holding the same files.
|
|
2543
|
+
renameSync(env.dir, `${env.dir}-away`);
|
|
2544
|
+
mkdirSync(env.dir);
|
|
2545
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt'])
|
|
2546
|
+
env.writeDisk(f, f);
|
|
2547
|
+
await call(m, 'sync', { mount: 'work', acceptRoot: true });
|
|
2548
|
+
// From the new root's start: a.txt and b.txt. Resuming after the old
|
|
2549
|
+
// place would have taken c.txt, then a.txt.
|
|
2550
|
+
assert.equal(env.store.treeGet(TREE, 'c.txt'), null, 'c.txt not taken');
|
|
2551
|
+
assert.equal(m.agreementOrThrow().walkResumesAfter('work', ''), 'b.txt');
|
|
2552
|
+
});
|
|
2553
|
+
test('a pass that records only a place binds no root, so a replaced root is read as it is', async (t) => {
|
|
2554
|
+
const env = new Env(t);
|
|
2555
|
+
// A file too large to take in: the passes record no evidence for it.
|
|
2556
|
+
env.writeDisk('a.txt', 'more than one byte');
|
|
2557
|
+
const m = await env.open({ walkCap: 1, maxFileSize: 1 });
|
|
2558
|
+
const agreement = m.agreementOrThrow();
|
|
2559
|
+
await call(m, 'sync', {});
|
|
2560
|
+
assert.equal(agreement.walkResumesAfter('work', ''), undefined, 'the walk covered the mount');
|
|
2561
|
+
assert.equal(agreement.rootIdentity('work'), undefined, 'and bound no root');
|
|
2562
|
+
env.writeDisk('b.txt', 'more than one byte');
|
|
2563
|
+
await call(m, 'sync', {});
|
|
2564
|
+
assert.equal(agreement.walkResumesAfter('work', ''), 'a.txt', 'a capped walk keeps its place');
|
|
2565
|
+
assert.equal(agreement.rootIdentity('work'), undefined, 'and binds no root either');
|
|
2566
|
+
// Another directory takes the root's place. With no evidence to guard,
|
|
2567
|
+
// the mount reads it as it is, and the walk takes its turn after the
|
|
2568
|
+
// place there.
|
|
2569
|
+
renameSync(env.dir, `${env.dir}-away`);
|
|
2570
|
+
mkdirSync(env.dir);
|
|
2571
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt'])
|
|
2572
|
+
env.writeDisk(f, 'more than one byte');
|
|
2573
|
+
const synced = await call(m, 'sync', {});
|
|
2574
|
+
assert.ok(!synced.incomplete.some((r) => /disk last agreed with/.test(r.reason)), JSON.stringify(synced.incomplete));
|
|
2575
|
+
assert.equal(agreement.walkResumesAfter('work', ''), 'b.txt');
|
|
2576
|
+
});
|
|
2577
|
+
test("a whole mount's catch-up scan takes turns with its bare syncs", async (t) => {
|
|
2578
|
+
const env = new Env(t);
|
|
2579
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt', 'd.txt', 'e.txt', 'f.txt'])
|
|
2580
|
+
env.writeDisk(f, f);
|
|
2581
|
+
const m = await env.open({ walkCap: 2 });
|
|
2582
|
+
await call(m, 'sync', {});
|
|
2583
|
+
await m.initialScan('work');
|
|
2584
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt', 'c.txt', 'd.txt'], 'the scan took the next two');
|
|
2585
|
+
await call(m, 'sync', {});
|
|
2586
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt', 'c.txt', 'd.txt', 'e.txt', 'f.txt'], 'and the sync the two after those');
|
|
2587
|
+
});
|
|
2588
|
+
test('a pass that moves only its place makes no store sync', async (t) => {
|
|
2589
|
+
const env = new Env(t);
|
|
2590
|
+
for (const f of ['a.txt', 'b.txt', 'c.txt', 'd.txt'])
|
|
2591
|
+
env.writeDisk(f, f);
|
|
2592
|
+
const m = await env.open({ walkCap: 2 });
|
|
2593
|
+
// Without ctime trust no fingerprint vouches, so no pass re-records one
|
|
2594
|
+
// once the files are older than the racy window, however slowly this
|
|
2595
|
+
// runs: what's left to record is the place alone.
|
|
2596
|
+
m.ctimeTrust.set(realpathSync(env.dir), null);
|
|
2597
|
+
await call(m, 'sync', {});
|
|
2598
|
+
await call(m, 'sync', {});
|
|
2599
|
+
assert.deepEqual(syncedNames(env), ['a.txt', 'b.txt', 'c.txt', 'd.txt']);
|
|
2600
|
+
const agreement = m.agreementOrThrow();
|
|
2601
|
+
const sync = env.store.sync.bind(env.store);
|
|
2602
|
+
let syncs = 0;
|
|
2603
|
+
env.store.sync = () => {
|
|
2604
|
+
syncs++;
|
|
2605
|
+
return sync();
|
|
2606
|
+
};
|
|
2607
|
+
// Nothing on disk changes from here, so each pass records only where it
|
|
2608
|
+
// stopped: a place that's lost costs coverage, never a decision.
|
|
2609
|
+
const places = [];
|
|
2610
|
+
for (let i = 0; i < 4; i++) {
|
|
2611
|
+
await call(m, 'sync', {});
|
|
2612
|
+
places.push(agreement.walkResumesAfter('work', ''));
|
|
2613
|
+
}
|
|
2614
|
+
assert.deepEqual(places, ['b.txt', 'd.txt', 'b.txt', 'd.txt'], 'the place moved on every pass');
|
|
2615
|
+
assert.equal(syncs, 0, 'with no store sync');
|
|
2616
|
+
});
|
|
2617
|
+
test('a model of the walk: on random trees that change between passes, each pass takes the cyclic window after its place', async (t) => {
|
|
2618
|
+
const env = new Env(t);
|
|
2619
|
+
const rootReal = realpathSync(env.dir);
|
|
2620
|
+
const view = { root: env.dir, followSymlinks: false, maxFileSize: 1 << 20, ignore: [] };
|
|
2621
|
+
// A seeded generator, so a failure replays.
|
|
2622
|
+
let seed = 272;
|
|
2623
|
+
const rand = (n) => {
|
|
2624
|
+
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
|
|
2625
|
+
return (seed >>> 8) % n;
|
|
2626
|
+
};
|
|
2627
|
+
// U+FF21 sorts before U+1F600 by UTF-8 byte and after it by UTF-16 code
|
|
2628
|
+
// unit, the walk's order; U+E000 sorts before both by UTF-8 byte and
|
|
2629
|
+
// between them by UTF-16 code unit.
|
|
2630
|
+
const names = ['a', 'b', 'm', 'z', '\uFF21', '\u{1F600}', '\uE000'];
|
|
2631
|
+
const pick = (xs) => xs[rand(xs.length)];
|
|
2632
|
+
const randomPath = () => {
|
|
2633
|
+
const parts = [];
|
|
2634
|
+
for (let depth = rand(3); depth > 0; depth--)
|
|
2635
|
+
parts.push(pick(names));
|
|
2636
|
+
return [...parts, `${pick(names)}.${rand(4) === 0 ? 'lnk' : 'txt'}`].join('/');
|
|
2637
|
+
};
|
|
2638
|
+
const add = (path) => {
|
|
2639
|
+
const full = env.disk(path);
|
|
2640
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
2641
|
+
if (lstatSync(full, { throwIfNoEntry: false }))
|
|
2642
|
+
return;
|
|
2643
|
+
if (path.endsWith('.lnk'))
|
|
2644
|
+
symlinkSync('nowhere', full); // dangling: an entry that isn't a file
|
|
2645
|
+
else
|
|
2646
|
+
writeFileSync(full, path);
|
|
2647
|
+
};
|
|
2648
|
+
/** Every entry that isn't a directory, as readdir shows it now. */
|
|
2649
|
+
const entries = (dir = '') => {
|
|
2650
|
+
const out = [];
|
|
2651
|
+
for (const d of readdirSync(env.disk(dir), { withFileTypes: true })) {
|
|
2652
|
+
const path = dir ? `${dir}/${d.name}` : d.name;
|
|
2653
|
+
if (d.isDirectory())
|
|
2654
|
+
out.push(...entries(path));
|
|
2655
|
+
else
|
|
2656
|
+
out.push(path);
|
|
2657
|
+
}
|
|
2658
|
+
return out;
|
|
2659
|
+
};
|
|
2660
|
+
// The walk's order, worked out independently: in each directory, its
|
|
2661
|
+
// entries that aren't directories by name, then its subdirectories by
|
|
2662
|
+
// name, each with everything beneath it.
|
|
2663
|
+
const order = (a, b) => {
|
|
2664
|
+
const pa = a.split('/');
|
|
2665
|
+
const pb = b.split('/');
|
|
2666
|
+
for (let i = 0;; i++) {
|
|
2667
|
+
const leafA = i === pa.length - 1;
|
|
2668
|
+
const leafB = i === pb.length - 1;
|
|
2669
|
+
if (leafA !== leafB)
|
|
2670
|
+
return leafA ? -1 : 1;
|
|
2671
|
+
if (pa[i] !== pb[i])
|
|
2672
|
+
return pa[i] < pb[i] ? -1 : 1;
|
|
2673
|
+
if (leafA)
|
|
2674
|
+
return 0;
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
for (let tree = 0; tree < 25; tree++) {
|
|
2678
|
+
rmSync(env.dir, { recursive: true, force: true });
|
|
2679
|
+
mkdirSync(env.dir);
|
|
2680
|
+
for (let i = 4 + rand(16); i > 0; i--)
|
|
2681
|
+
add(randomPath());
|
|
2682
|
+
const cap = 1 + rand(8);
|
|
2683
|
+
let place;
|
|
2684
|
+
for (let pass = 0; pass < 12; pass++) {
|
|
2685
|
+
// Changes between passes: entries added, removed, the place itself
|
|
2686
|
+
// removed, and whole directories removed.
|
|
2687
|
+
for (let change = rand(4); change > 0; change--) {
|
|
2688
|
+
const now = entries();
|
|
2689
|
+
const kind = rand(4);
|
|
2690
|
+
if (kind === 0 || now.length === 0)
|
|
2691
|
+
add(randomPath());
|
|
2692
|
+
else if (kind === 1)
|
|
2693
|
+
rmSync(env.disk(pick(now)));
|
|
2694
|
+
else if (kind === 2 && place !== undefined && now.includes(place))
|
|
2695
|
+
rmSync(env.disk(place));
|
|
2696
|
+
else {
|
|
2697
|
+
const dirs = now.filter((p) => p.includes('/')).map((p) => p.slice(0, p.lastIndexOf('/')));
|
|
2698
|
+
if (dirs.length > 0)
|
|
2699
|
+
rmSync(env.disk(pick(dirs)), { recursive: true, force: true });
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
const all = entries().sort(order);
|
|
2703
|
+
const after = place === undefined ? [] : all.filter((p) => order(p, place) > 0);
|
|
2704
|
+
const upto = place === undefined ? all : all.filter((p) => order(p, place) <= 0);
|
|
2705
|
+
const window = [...after, ...upto].slice(0, cap);
|
|
2706
|
+
const walk = await walkScope(view, rootReal, '', true, cap, place === undefined ? {} : { resumeAfter: place });
|
|
2707
|
+
const taken = [...walk.files, ...walk.othersTaken];
|
|
2708
|
+
const where = `tree ${tree}, pass ${pass}, cap ${cap}, after ${place}`;
|
|
2709
|
+
assert.deepEqual(taken.sort(order), [...window].sort(order), `${where}: took the cyclic window`);
|
|
2710
|
+
for (const p of all)
|
|
2711
|
+
assert.equal(provenAbsent(walk, p), false, `${where}: ${p} is on disk`);
|
|
2712
|
+
if (walk.stoppedAfter !== undefined)
|
|
2713
|
+
assert.equal(walk.stoppedAfter, window.at(-1), `${where}: the place is the last entry taken`);
|
|
2714
|
+
else
|
|
2715
|
+
assert.ok(all.length <= cap, `${where}: no place only once the walk took everything`);
|
|
2716
|
+
place = walk.stoppedAfter;
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
});
|
|
2720
|
+
test('places go through a checkpoint, and a scope a walk covered stays cleared', (t) => {
|
|
2721
|
+
const env = new Env(t);
|
|
2722
|
+
const agreement = new DiskAgreement(env.store);
|
|
2723
|
+
agreement.open([{ name: 'work', root: env.dir }]);
|
|
2724
|
+
agreement.recordWalk('work', '', 'x.txt');
|
|
2725
|
+
agreement.recordWalk('work', 'd', 'd/y.txt');
|
|
2726
|
+
agreement.recordWalk('work', 'e', 'e/z.txt');
|
|
2727
|
+
agreement.recordWalk('work', 'e', null);
|
|
2728
|
+
for (let i = 0; i < 1100; i++)
|
|
2729
|
+
agreement.set('work', `f${i}.txt`, { kind: 'absent' });
|
|
2730
|
+
agreement.maybeCheckpoint();
|
|
2731
|
+
assert.equal(agreement.journal.entriesSinceCheckpoint, 0, 'a checkpoint was written');
|
|
2732
|
+
agreement.barrier();
|
|
2733
|
+
const reopened = new DiskAgreement(env.store);
|
|
2734
|
+
reopened.open([{ name: 'work', root: env.dir }]);
|
|
2735
|
+
assert.equal(reopened.walkResumesAfter('work', ''), 'x.txt');
|
|
2736
|
+
assert.equal(reopened.walkResumesAfter('work', 'd'), 'd/y.txt');
|
|
2737
|
+
assert.equal(reopened.walkResumesAfter('work', 'e'), undefined);
|
|
2738
|
+
});
|
|
2739
|
+
test("a place's journal entry carries no path, and an entry of a kind this version doesn't know changes nothing", (t) => {
|
|
2740
|
+
const env = new Env(t);
|
|
2741
|
+
const agreement = new DiskAgreement(env.store);
|
|
2742
|
+
agreement.open([{ name: 'work', root: env.dir }]);
|
|
2743
|
+
agreement.set('work', 'a.txt', { kind: 'absent' });
|
|
2744
|
+
agreement.recordWalk('work', '', 'a.txt');
|
|
2745
|
+
agreement.barrier();
|
|
2746
|
+
// agent-framework 0.22 replays an entry it doesn't know as a forget of
|
|
2747
|
+
// entry.path: a place with a path would forget that path's evidence.
|
|
2748
|
+
const journal = new RecordJournal(env.store, { type: DISK_AGREEMENT_RECORD_TYPE });
|
|
2749
|
+
const walks = journal.load().entries.map(({ entry }) => entry).filter((e) => e.t === 'walk');
|
|
2750
|
+
assert.equal(walks.length, 1);
|
|
2751
|
+
assert.equal('path' in walks[0], false, JSON.stringify(walks[0]));
|
|
2752
|
+
// A later version's entry this one doesn't know leaves the evidence alone.
|
|
2753
|
+
journal.append({ t: 'a-later-kind', mount: 'work', path: 'a.txt' });
|
|
2754
|
+
const reopened = new DiskAgreement(env.store);
|
|
2755
|
+
reopened.open([{ name: 'work', root: env.dir }]);
|
|
2756
|
+
assert.deepEqual(reopened.get('work', 'a.txt'), { kind: 'absent' });
|
|
2757
|
+
assert.equal(reopened.walkResumesAfter('work', ''), 'a.txt');
|
|
1858
2758
|
});
|
|
1859
2759
|
});
|
|
1860
2760
|
describe('the mount boundary', () => {
|