@animalabs/agent-framework 0.21.1 → 0.22.1
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 +54 -4
- package/dist/src/agent.d.ts +17 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +105 -43
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +41 -4
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +22 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +64 -9
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +4 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +215 -38
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/framework.d.ts +569 -59
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3091 -542
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +21 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +40 -7
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +7 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +26 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +28 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +17 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +31 -7
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +4 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +1 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +85 -22
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +32 -4
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +46 -13
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +5 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +41 -11
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +20 -9
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +29 -6
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +57 -16
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +32 -1
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +76 -11
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +5 -2
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +25 -7
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +47 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
- package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
- package/dist/src/mcpl/tool-pattern-check.js +79 -0
- package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +15 -5
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +39 -0
- package/dist/src/mcpl/visible-content.d.ts.map +1 -0
- package/dist/src/mcpl/visible-content.js +54 -0
- package/dist/src/mcpl/visible-content.js.map +1 -0
- package/dist/src/module-registry.d.ts +90 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +260 -18
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
- package/dist/src/modules/workspace/branch-intent.js +83 -0
- package/dist/src/modules/workspace/branch-intent.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.js +259 -0
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +84 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -0
- package/dist/src/modules/workspace/effects.js +368 -0
- package/dist/src/modules/workspace/effects.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +155 -17
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1101 -424
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +288 -0
- package/dist/src/modules/workspace/observe.d.ts.map +1 -0
- package/dist/src/modules/workspace/observe.js +825 -0
- package/dist/src/modules/workspace/observe.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts +238 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
- package/dist/src/modules/workspace/reconcile.js +909 -0
- package/dist/src/modules/workspace/reconcile.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +6 -59
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +6 -257
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +77 -10
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/operator-log.d.ts +41 -0
- 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 +216 -0
- package/dist/src/provider-waits.d.ts.map +1 -0
- package/dist/src/provider-waits.js +482 -0
- package/dist/src/provider-waits.js.map +1 -0
- package/dist/src/record-journal.d.ts +127 -0
- package/dist/src/record-journal.d.ts.map +1 -0
- package/dist/src/record-journal.js +202 -0
- package/dist/src/record-journal.js.map +1 -0
- package/dist/src/refusal-reactions.d.ts +14 -12
- package/dist/src/refusal-reactions.d.ts.map +1 -1
- package/dist/src/refusal-reactions.js +14 -12
- package/dist/src/refusal-reactions.js.map +1 -1
- package/dist/src/shutdown.d.ts +13 -0
- package/dist/src/shutdown.d.ts.map +1 -0
- package/dist/src/shutdown.js +46 -0
- package/dist/src/shutdown.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +74 -0
- package/dist/src/silent-heartbeat.d.ts.map +1 -0
- package/dist/src/silent-heartbeat.js +77 -0
- package/dist/src/silent-heartbeat.js.map +1 -0
- package/dist/src/tool-presentation.d.ts +50 -0
- package/dist/src/tool-presentation.d.ts.map +1 -0
- package/dist/src/tool-presentation.js +235 -0
- package/dist/src/tool-presentation.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +142 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +357 -36
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +13 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +21 -1
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +18 -2
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +16 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +12 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +26 -4
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +87 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/batched-wake-locus.test.d.ts +2 -0
- package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
- package/dist/test/batched-wake-locus.test.js +166 -0
- package/dist/test/batched-wake-locus.test.js.map +1 -0
- package/dist/test/code-execution.test.js +386 -2
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/component-defaults.test.d.ts +2 -0
- package/dist/test/component-defaults.test.d.ts.map +1 -0
- package/dist/test/component-defaults.test.js +54 -0
- package/dist/test/component-defaults.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +57 -1
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/discord-awareness-outbox.test.js +3 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.js +3 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate.test.js +118 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +975 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js +160 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
- package/dist/test/helpers/coalescing-fixture.js +3 -2
- package/dist/test/helpers/coalescing-fixture.js.map +1 -1
- package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
- package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
- package/dist/test/helpers/pytc-stop-rows.js +143 -0
- package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
- package/dist/test/inference-exhaustion-rewind.test.js +142 -14
- package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
- package/dist/test/inference-failure-bounds.test.d.ts +2 -0
- package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
- package/dist/test/inference-failure-bounds.test.js +1201 -0
- package/dist/test/inference-failure-bounds.test.js.map +1 -0
- package/dist/test/late-events-after-stop.test.d.ts +2 -0
- package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
- package/dist/test/late-events-after-stop.test.js +122 -0
- package/dist/test/late-events-after-stop.test.js.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +141 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-coalescing.test.js +97 -22
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
- package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
- package/dist/test/mcpl-config-diagnostics.test.js +452 -0
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
- package/dist/test/mcpl-empty-content.test.d.ts +2 -0
- package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
- package/dist/test/mcpl-empty-content.test.js +368 -0
- package/dist/test/mcpl-empty-content.test.js.map +1 -0
- package/dist/test/mcpl-manifest-changed.test.js +2 -1
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
- package/dist/test/mcpl-reference-fetcher.test.js +232 -4
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.js +69 -2
- package/dist/test/mcpl-reference-fields.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
- package/dist/test/mcpl-ws-transport.test.js +103 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -1
- package/dist/test/ops-alert.test.js +2 -1
- package/dist/test/ops-alert.test.js.map +1 -1
- package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
- package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
- package/dist/test/placed-reaction-baseline.test.js +312 -0
- package/dist/test/placed-reaction-baseline.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.js +41 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/provider-acceleration-cooldown.test.js +5 -1
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
- package/dist/test/provider-waits.test.d.ts +2 -0
- package/dist/test/provider-waits.test.d.ts.map +1 -0
- package/dist/test/provider-waits.test.js +2196 -0
- package/dist/test/provider-waits.test.js.map +1 -0
- package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
- package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
- package/dist/test/reaction-baseline-platform.test.js +124 -0
- package/dist/test/reaction-baseline-platform.test.js.map +1 -0
- package/dist/test/record-journal.test.d.ts +6 -0
- package/dist/test/record-journal.test.d.ts.map +1 -0
- package/dist/test/record-journal.test.js +269 -0
- package/dist/test/record-journal.test.js.map +1 -0
- package/dist/test/refusal-reaction-baseline.test.js +10 -3
- package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
- package/dist/test/save-recent-image.test.js +240 -2
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.d.ts +2 -0
- package/dist/test/shutdown-errors.test.d.ts.map +1 -0
- package/dist/test/shutdown-errors.test.js +662 -0
- package/dist/test/shutdown-errors.test.js.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.js +451 -0
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.js +49 -38
- package/dist/test/silent-heartbeat.test.js.map +1 -1
- package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
- package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +672 -0
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
- package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
- package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
- package/dist/test/surgery-marks-receipt.test.js +540 -0
- package/dist/test/surgery-marks-receipt.test.js.map +1 -0
- package/dist/test/tool-batch-hook.test.d.ts +8 -0
- package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
- package/dist/test/tool-batch-hook.test.js +275 -0
- package/dist/test/tool-batch-hook.test.js.map +1 -0
- package/dist/test/tool-presentation.test.d.ts +2 -0
- package/dist/test/tool-presentation.test.d.ts.map +1 -0
- package/dist/test/tool-presentation.test.js +268 -0
- package/dist/test/tool-presentation.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +2545 -19
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +2 -1
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +35 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
- package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
- package/dist/test/unconvertible-tool-result.test.js +358 -0
- package/dist/test/unconvertible-tool-result.test.js.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.js +252 -0
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
- package/dist/test/workspace-three-way.test.d.ts +12 -0
- package/dist/test/workspace-three-way.test.d.ts.map +1 -0
- package/dist/test/workspace-three-way.test.js +3194 -0
- package/dist/test/workspace-three-way.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +124 -45
- package/src/api/server.ts +43 -4
- package/src/code-execution/py-runner.ts +66 -9
- package/src/code-execution/runtime-py.ts +215 -38
- package/src/framework.ts +3191 -544
- package/src/gate/event-gate.ts +59 -7
- package/src/index.ts +18 -2
- package/src/mcpl/channel-registry.ts +24 -3
- package/src/mcpl/conversation-router.ts +34 -1
- package/src/mcpl/feature-set-manager.ts +36 -11
- package/src/mcpl/index.ts +4 -0
- package/src/mcpl/prose-stream-router.ts +79 -23
- package/src/mcpl/push-coalescer.ts +62 -13
- package/src/mcpl/push-handler.ts +41 -12
- package/src/mcpl/reference-fetcher.ts +18 -8
- package/src/mcpl/references.ts +57 -13
- package/src/mcpl/server-connection.ts +95 -13
- package/src/mcpl/server-registry.ts +24 -7
- package/src/mcpl/tool-lifecycle.ts +77 -2
- package/src/mcpl/tool-pattern-check.ts +106 -0
- package/src/mcpl/types.ts +15 -5
- package/src/mcpl/visible-content.ts +57 -0
- package/src/module-registry.ts +269 -25
- package/src/modules/workspace/branch-intent.ts +123 -0
- package/src/modules/workspace/disk-agreement.ts +325 -0
- package/src/modules/workspace/effects.ts +362 -0
- package/src/modules/workspace/index.ts +1098 -446
- package/src/modules/workspace/observe.ts +865 -0
- package/src/modules/workspace/reconcile.ts +1157 -0
- package/src/modules/workspace/sync.ts +6 -336
- package/src/modules/workspace/types.ts +75 -10
- package/src/operator-log.ts +30 -0
- package/src/provider-waits.ts +523 -0
- package/src/record-journal.ts +246 -0
- package/src/refusal-reactions.ts +14 -12
- package/src/shutdown.ts +49 -0
- package/src/silent-heartbeat.ts +141 -0
- package/src/tool-presentation.ts +188 -0
- package/src/tool-result-guard.ts +384 -34
- package/src/tool-result-history.ts +25 -1
- package/src/types/agent.ts +18 -2
- package/src/types/events.ts +16 -0
- package/src/types/framework.ts +12 -0
- package/src/types/module.ts +27 -4
- package/src/types/trace.ts +94 -3
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Physical disk-agreement evidence (P): for each mount and path, what disk held
|
|
3
|
+
* when it last agreed with the workspace store, or what the module last wrote
|
|
4
|
+
* there or adopted from it.
|
|
5
|
+
*
|
|
6
|
+
* P describes disk, and disk does not rewind with a branch switch, so P lives
|
|
7
|
+
* in global chronicle records (RecordJournal) rather than branch state: after
|
|
8
|
+
* an undo selects an older tree, P still says what disk holds, and a
|
|
9
|
+
* difference reads as a store-side change, never as a disk edit. Branch-local
|
|
10
|
+
* intent — tombstones, store origin, conflicts — lives in BranchIntents and
|
|
11
|
+
* rewinds with the tree.
|
|
12
|
+
*
|
|
13
|
+
* P is evidence about one directory, so each mount also records the identity
|
|
14
|
+
* (device and inode) of the root that evidence was gathered under. A root
|
|
15
|
+
* that isn't that directory any more (an unmounted drive whose mountpoint is
|
|
16
|
+
* now empty, a replaced directory) is an unavailable mount, never a disk on
|
|
17
|
+
* which every file was deleted; only an explicit acceptance takes it, and
|
|
18
|
+
* that starts its evidence afresh.
|
|
19
|
+
*
|
|
20
|
+
* Every transition is write-ahead (see reconcile.ts): an `intent` entry
|
|
21
|
+
* (`pending`, with the prior value and the expected one) is durable before the
|
|
22
|
+
* disk effect or tree adoption it announces, and the completion `set` is
|
|
23
|
+
* appended only after that effect is durable. One tree adoption has no
|
|
24
|
+
* intent: a drop, which removes the store's copy of a disk version the
|
|
25
|
+
* workspace can't hold, forgets P first, so a crash before the removal leaves
|
|
26
|
+
* P unknown, never a wrong adoption. A pending adoption resolves at its next
|
|
27
|
+
* observation, by the tree. A pending disk effect resolves there only when
|
|
28
|
+
* disk shows it didn't happen (the prior) or can't tell (`interrupted`, a
|
|
29
|
+
* sticky conflict resolved explicitly, or by disk and the store coming to
|
|
30
|
+
* agree); one that shows on disk is completed only by a push whose barriers
|
|
31
|
+
* succeed, since its completion releases a draft's protection.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { resolve } from 'node:path';
|
|
35
|
+
import type { JsStore } from '@animalabs/chronicle';
|
|
36
|
+
import { RecordJournal } from '../../record-journal.js';
|
|
37
|
+
|
|
38
|
+
/** Everything `stat` says about a file that can vouch for unchanged bytes. */
|
|
39
|
+
export interface Fingerprint {
|
|
40
|
+
size: number;
|
|
41
|
+
mtimeNs: string;
|
|
42
|
+
ctimeNs: string;
|
|
43
|
+
ino: string;
|
|
44
|
+
dev: string;
|
|
45
|
+
/** When the bytes were hashed (ms). Timestamps within 2 s of it can't vouch. */
|
|
46
|
+
hashedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type Agreed =
|
|
50
|
+
| { kind: 'absent' }
|
|
51
|
+
| { kind: 'content'; hash: string; size: number; fp?: Fingerprint };
|
|
52
|
+
|
|
53
|
+
export type Candidate = { kind: 'absent' } | { kind: 'content'; hash: string };
|
|
54
|
+
|
|
55
|
+
export interface Pending {
|
|
56
|
+
kind: 'pending';
|
|
57
|
+
/** What the module is about to do: change disk, or adopt disk into the tree. */
|
|
58
|
+
effect: 'disk' | 'adopt';
|
|
59
|
+
/** P before the intent (null: unknown). */
|
|
60
|
+
prior: Agreed | null;
|
|
61
|
+
expect: Candidate;
|
|
62
|
+
/** Branch the intent was recorded on (an adoption's meaning is branch-relative). */
|
|
63
|
+
branchId: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Interrupted {
|
|
67
|
+
kind: 'interrupted';
|
|
68
|
+
/** The values disk could have held after the interrupted effect. */
|
|
69
|
+
candidates: Candidate[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type Physical = Agreed | Pending | Interrupted;
|
|
73
|
+
|
|
74
|
+
/** Which directory a mount root is: its device and inode, as decimal strings. */
|
|
75
|
+
export interface RootIdentity {
|
|
76
|
+
dev: string;
|
|
77
|
+
ino: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether two configured roots name the same path, whatever their spelling
|
|
81
|
+
* (a trailing slash, a relative form). A symlinked alias is another path. */
|
|
82
|
+
function sameRootPath(a: string, b: string): boolean {
|
|
83
|
+
return resolve(a) === resolve(b);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sameRootIdentity(a: RootIdentity, b: RootIdentity): boolean {
|
|
87
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type JournalEntry =
|
|
91
|
+
| { t: 'mount'; mount: string; root: string }
|
|
92
|
+
/** The root's identity; with `reset`, an accepted new root, whose evidence starts empty. */
|
|
93
|
+
| { t: 'root'; mount: string; identity: RootIdentity; reset?: true }
|
|
94
|
+
| { t: 'set'; mount: string; path: string; value: Physical }
|
|
95
|
+
| { t: 'forget'; mount: string; path: string };
|
|
96
|
+
|
|
97
|
+
interface MountEvidence {
|
|
98
|
+
root: string;
|
|
99
|
+
identity?: RootIdentity;
|
|
100
|
+
paths: Map<string, Physical>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface Snapshot {
|
|
104
|
+
mounts: Record<string, { root: string; identity?: RootIdentity; paths: Record<string, Physical> }>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const DISK_AGREEMENT_RECORD_TYPE = 'workspace/disk-agreement';
|
|
108
|
+
|
|
109
|
+
/** Checkpoint once the tail outgrows this many entries, or the live paths, whichever is larger. */
|
|
110
|
+
const MIN_CHECKPOINT_TAIL = 1000;
|
|
111
|
+
|
|
112
|
+
export function candidateOf(value: Agreed): Candidate {
|
|
113
|
+
return value.kind === 'absent' ? { kind: 'absent' } : { kind: 'content', hash: value.hash };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function sameCandidate(a: Candidate, b: Candidate): boolean {
|
|
117
|
+
return a.kind === b.kind && (a.kind === 'absent' || a.hash === (b as { hash: string }).hash);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class DiskAgreement {
|
|
121
|
+
private readonly store: JsStore;
|
|
122
|
+
private readonly journal: RecordJournal<JournalEntry, Snapshot>;
|
|
123
|
+
private readonly mounts = new Map<string, MountEvidence>();
|
|
124
|
+
private configured: Array<{ name: string; root: string }> = [];
|
|
125
|
+
/**
|
|
126
|
+
* An append (or a rebuild) not yet made durable. Cleared only by a
|
|
127
|
+
* barrier that succeeds, so a failed barrier stays owed: a later pass with
|
|
128
|
+
* nothing new to record still settles it before deciding anything.
|
|
129
|
+
*/
|
|
130
|
+
private unsynced = false;
|
|
131
|
+
|
|
132
|
+
constructor(store: JsStore) {
|
|
133
|
+
this.store = store;
|
|
134
|
+
this.journal = new RecordJournal<JournalEntry, Snapshot>(store, { type: DISK_AGREEMENT_RECORD_TYPE });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Rebuild P from the journal for the configured mounts. A mount whose
|
|
139
|
+
* recorded root differs from its configured one starts empty: evidence
|
|
140
|
+
* about another directory says nothing about this one.
|
|
141
|
+
*/
|
|
142
|
+
open(mounts: Array<{ name: string; root: string }>): void {
|
|
143
|
+
this.configured = mounts;
|
|
144
|
+
this.rebuild();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Re-read the journal when an earlier write left it unreconciled, and throw
|
|
149
|
+
* once an append failed inside the store. P may then lack what that append
|
|
150
|
+
* recorded (the store may hold it where this process can't read it back),
|
|
151
|
+
* and a decision from stale P can record what reopening contradicts, such
|
|
152
|
+
* as a conflict that stays after the right evidence is back. So until the
|
|
153
|
+
* store is reopened, no evidence is read, written or checkpointed, and
|
|
154
|
+
* every pass and tool call that needs it fails.
|
|
155
|
+
*/
|
|
156
|
+
ensureReconciled(): void {
|
|
157
|
+
if (this.journal.needsReopen) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
'Disk-agreement evidence is unavailable until the store is reopened, as at the next start: ' +
|
|
160
|
+
'an append to its journal failed inside the store, and the evidence may lack what it recorded',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (this.journal.needsReconcile) this.rebuild();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Whether evidence recorded so far still awaits a barrier. */
|
|
167
|
+
get needsBarrier(): boolean {
|
|
168
|
+
return this.unsynced;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replay the journal into memory. Records that read back are not proven
|
|
173
|
+
* durable — a previous owner may have appended them and died before any
|
|
174
|
+
* barrier — so evidence rebuilt here owes one, whether at open or after an
|
|
175
|
+
* ambiguous append.
|
|
176
|
+
*/
|
|
177
|
+
private rebuild(): void {
|
|
178
|
+
this.unsynced = true;
|
|
179
|
+
const { snapshot, entries } = this.journal.load();
|
|
180
|
+
const replayed = new Map<string, MountEvidence>();
|
|
181
|
+
for (const [name, mount] of Object.entries(snapshot?.mounts ?? {})) {
|
|
182
|
+
replayed.set(name, {
|
|
183
|
+
root: mount.root,
|
|
184
|
+
...(mount.identity ? { identity: mount.identity } : {}),
|
|
185
|
+
paths: new Map(Object.entries(mount.paths)),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
for (const { entry } of entries) {
|
|
189
|
+
if (entry.t === 'mount') {
|
|
190
|
+
const existing = replayed.get(entry.mount);
|
|
191
|
+
if (!existing || !sameRootPath(existing.root, entry.root)) replayed.set(entry.mount, { root: entry.root, paths: new Map() });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const mount = replayed.get(entry.mount);
|
|
195
|
+
if (!mount) continue;
|
|
196
|
+
if (entry.t === 'root') applyRoot(mount, entry);
|
|
197
|
+
else if (entry.t === 'set') mount.paths.set(entry.path, entry.value);
|
|
198
|
+
else mount.paths.delete(entry.path);
|
|
199
|
+
}
|
|
200
|
+
this.mounts.clear();
|
|
201
|
+
for (const { name, root } of this.configured) {
|
|
202
|
+
const found = replayed.get(name);
|
|
203
|
+
if (found && sameRootPath(found.root, root)) {
|
|
204
|
+
this.mounts.set(name, found);
|
|
205
|
+
} else {
|
|
206
|
+
this.mounts.set(name, { root, paths: new Map() });
|
|
207
|
+
this.journal.append({ t: 'mount', mount: name, root });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The identity of the root this mount's evidence was gathered under, once any was. */
|
|
213
|
+
rootIdentity(mount: string): RootIdentity | undefined {
|
|
214
|
+
this.ensureReconciled();
|
|
215
|
+
return this.mounts.get(mount)?.identity;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Record the root's identity just before the first evidence a mount gains:
|
|
220
|
+
* the caller saw it as the root while gathering that evidence. A no-op once
|
|
221
|
+
* one is recorded; changing it is acceptRoot's alone.
|
|
222
|
+
*/
|
|
223
|
+
recordRootIdentity(mount: string, identity: RootIdentity): void {
|
|
224
|
+
if (this.rootIdentity(mount) !== undefined) return;
|
|
225
|
+
this.write({ t: 'root', mount, identity }, {});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Take the root as it is now, at the operator's word: record its identity
|
|
230
|
+
* and forget every path's evidence, which described another directory.
|
|
231
|
+
*/
|
|
232
|
+
acceptRoot(mount: string, identity: RootIdentity): void {
|
|
233
|
+
this.write({ t: 'root', mount, identity, reset: true }, {});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
get(mount: string, path: string): Physical | undefined {
|
|
237
|
+
this.ensureReconciled(); // an ambiguous append may have landed: read it back first
|
|
238
|
+
return this.mounts.get(mount)?.paths.get(path);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Every path with evidence under a prefix ('' for the whole mount). */
|
|
242
|
+
paths(mount: string, prefix = ''): Array<[string, Physical]> {
|
|
243
|
+
this.ensureReconciled();
|
|
244
|
+
const paths = this.mounts.get(mount)?.paths;
|
|
245
|
+
if (!paths) return [];
|
|
246
|
+
const out: Array<[string, Physical]> = [];
|
|
247
|
+
for (const [path, value] of paths) {
|
|
248
|
+
if (prefix === '' || path === prefix || path.startsWith(prefix + '/')) out.push([path, value]);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Record an intent. `durable` makes it (and every append before it) stable
|
|
255
|
+
* before the caller performs the effect; batches pass it on their last one.
|
|
256
|
+
*/
|
|
257
|
+
intend(mount: string, path: string, intent: Omit<Pending, 'kind'>, opts: { durable?: boolean } = {}): void {
|
|
258
|
+
this.write({ t: 'set', mount, path, value: { kind: 'pending', ...intent } }, { durable: opts.durable });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Record agreed evidence. `afterCommittedState` when it asserts a tree
|
|
263
|
+
* commit made just before (the commit is synced first).
|
|
264
|
+
*/
|
|
265
|
+
set(mount: string, path: string, value: Agreed | Interrupted, opts: { afterCommittedState?: boolean } = {}): void {
|
|
266
|
+
this.write({ t: 'set', mount, path, value }, { afterCommittedState: opts.afterCommittedState });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Forget a path: P becomes unknown, the most conservative evidence. */
|
|
270
|
+
forget(mount: string, path: string): void {
|
|
271
|
+
if (this.get(mount, path) === undefined) return;
|
|
272
|
+
this.write({ t: 'forget', mount, path }, {});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Put back exactly the evidence a path had before an intent whose effect
|
|
277
|
+
* never touched it — whatever kind it was (agreed, pending, interrupted, or
|
|
278
|
+
* unknown) — so the intent leaves no trace in what P says.
|
|
279
|
+
*/
|
|
280
|
+
restore(mount: string, path: string, value: Physical | undefined): void {
|
|
281
|
+
if (value === undefined) this.forget(mount, path);
|
|
282
|
+
else this.write({ t: 'set', mount, path, value }, {});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private write(entry: JournalEntry, opts: { durable?: boolean; afterCommittedState?: boolean }): void {
|
|
286
|
+
this.ensureReconciled();
|
|
287
|
+
this.unsynced = true; // until proven durable: a failed append may still have landed
|
|
288
|
+
this.journal.append(entry, opts);
|
|
289
|
+
if (opts.durable) this.unsynced = false; // synced after this append, and so everything before it
|
|
290
|
+
const mount = this.mounts.get((entry as { mount: string }).mount);
|
|
291
|
+
if (!mount) return;
|
|
292
|
+
if (entry.t === 'root') applyRoot(mount, entry);
|
|
293
|
+
else if (entry.t === 'set') mount.paths.set(entry.path, entry.value);
|
|
294
|
+
else if (entry.t === 'forget') mount.paths.delete(entry.path);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Checkpoint when the tail has outgrown the live evidence. */
|
|
298
|
+
maybeCheckpoint(): void {
|
|
299
|
+
// Like every entry point: an ambiguous write is read back first.
|
|
300
|
+
this.ensureReconciled();
|
|
301
|
+
let live = 0;
|
|
302
|
+
for (const mount of this.mounts.values()) live += mount.paths.size;
|
|
303
|
+
if (this.journal.entriesSinceCheckpoint < Math.max(MIN_CHECKPOINT_TAIL, live)) return;
|
|
304
|
+
const snapshot: Snapshot = { mounts: {} };
|
|
305
|
+
for (const [name, mount] of this.mounts) {
|
|
306
|
+
snapshot.mounts[name] = {
|
|
307
|
+
root: mount.root,
|
|
308
|
+
...(mount.identity ? { identity: mount.identity } : {}),
|
|
309
|
+
paths: Object.fromEntries(mount.paths),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
this.journal.checkpoint(snapshot);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Make every append so far stable. Throws, and stays owed, if the sync fails. */
|
|
316
|
+
barrier(): void {
|
|
317
|
+
this.store.sync();
|
|
318
|
+
this.unsynced = false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function applyRoot(mount: MountEvidence, entry: Extract<JournalEntry, { t: 'root' }>): void {
|
|
323
|
+
mount.identity = entry.identity;
|
|
324
|
+
if (entry.reset) mount.paths.clear();
|
|
325
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace's disk effects — a materialize's writes and unlinks, and the
|
|
3
|
+
* barriers that make them durable — under the mount boundary.
|
|
4
|
+
*
|
|
5
|
+
* Each effect is bound at its point of use, not at planning time: the parent
|
|
6
|
+
* directory is checked (located inside the mount, see observe.ts), the file is
|
|
7
|
+
* reached through it, and before anything is changed the effect checks that
|
|
8
|
+
* it acts on what was decided on — a regular file inside the mount, at this
|
|
9
|
+
* path, still holding the bytes planning saw (unless `force`, which overrides
|
|
10
|
+
* that freshness but never the boundary). Content is written only through a
|
|
11
|
+
* descriptor whose own location was confirmed inside the mount first, and
|
|
12
|
+
* only into a file with no other hard links: a write in place changes the
|
|
13
|
+
* file under every name it has, and the others may lie outside the mount (a
|
|
14
|
+
* package store's, say), so such a file is refused. Unlinking one name
|
|
15
|
+
* changes no other, so an unlink needs no such check.
|
|
16
|
+
*
|
|
17
|
+
* What an effect reports matters to the caller's evidence: a failure that
|
|
18
|
+
* never touched the path's entry leaves disk as planning saw it, so the
|
|
19
|
+
* path's evidence can be put back as it was; once the entry was touched
|
|
20
|
+
* (created, truncated, written or unlinked) the outcome is for the next
|
|
21
|
+
* observation to resolve. Directories created for a write don't touch the
|
|
22
|
+
* path's entry; they stay, empty, if the write is refused.
|
|
23
|
+
*
|
|
24
|
+
* Node has no openat, so a create, mkdir or unlink still resolves its path by
|
|
25
|
+
* name after the parent was checked: a parent replaced concurrently in that
|
|
26
|
+
* window can redirect that one operation, leaving an empty file or directory
|
|
27
|
+
* outside the mount or unlinking a same-named file there. Nor is there an
|
|
28
|
+
* unlink bound to an inode: an unlink checks the entry's identity again just
|
|
29
|
+
* before it, and a file replaced at its path after that check is the one
|
|
30
|
+
* unlinked. Content writes stay bound to the descriptor verified before
|
|
31
|
+
* modification: inside the mount, with no other hard links (a link another
|
|
32
|
+
* process makes to it after that check shares the write). The hash, which
|
|
33
|
+
* reads the whole file, comes before the checks of where the file is, and
|
|
34
|
+
* the root is checked again beside the parent, since the parent is found by
|
|
35
|
+
* path beneath the root's. A last look at the file (its descriptor's stat
|
|
36
|
+
* before a write, its entry's lstat before an unlink) refuses one written
|
|
37
|
+
* since it was first seen, so only an edit made in the moment between that
|
|
38
|
+
* look and the effect is overwritten or removed.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { createHash } from 'node:crypto';
|
|
42
|
+
import { constants as fsConstants } from 'node:fs';
|
|
43
|
+
import { lstat, open, realpath, stat, unlink } from 'node:fs/promises';
|
|
44
|
+
import type { FileHandle } from 'node:fs/promises';
|
|
45
|
+
import {
|
|
46
|
+
type CheckedDir,
|
|
47
|
+
type MountView,
|
|
48
|
+
NONBLOCK,
|
|
49
|
+
NOT_FOLLOWED,
|
|
50
|
+
OUTSIDE_PARENT,
|
|
51
|
+
ROOT_REPLACED,
|
|
52
|
+
contained,
|
|
53
|
+
errno,
|
|
54
|
+
checkDir,
|
|
55
|
+
checkRoot,
|
|
56
|
+
nameOf,
|
|
57
|
+
parentOf,
|
|
58
|
+
} from './observe.js';
|
|
59
|
+
|
|
60
|
+
/** What a write or unlink may find at its path when it acts. */
|
|
61
|
+
export type Expect =
|
|
62
|
+
| { kind: 'absent' }
|
|
63
|
+
/** The bytes planning decided on, by hash. */
|
|
64
|
+
| { kind: 'content'; hash: string }
|
|
65
|
+
/** Anything (force): freshness is overridden, the boundary is not. */
|
|
66
|
+
| { kind: 'any' };
|
|
67
|
+
|
|
68
|
+
/** Errors with which an unlink refuses outright, leaving the entry as it was. */
|
|
69
|
+
const UNLINK_REFUSALS = new Set(['EACCES', 'EPERM', 'EROFS', 'EBUSY']);
|
|
70
|
+
|
|
71
|
+
export class EffectFailed extends Error {
|
|
72
|
+
/** Whether the path's entry was touched before the failure. */
|
|
73
|
+
readonly touched: boolean;
|
|
74
|
+
|
|
75
|
+
constructor(message: string, touched: boolean) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.touched = touched;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const CHANGED = 'disk changed since it was checked';
|
|
82
|
+
const MOVED = 'the directory changed since it was checked';
|
|
83
|
+
const HARD_LINKED = 'a file with other hard links, which writing it in place would change too — force does not override '
|
|
84
|
+
+ 'this; to write this path alone, give it a copy of its own first (copy it to a new name, then move that over it) '
|
|
85
|
+
+ 'and materialize again';
|
|
86
|
+
|
|
87
|
+
function message(err: unknown): string {
|
|
88
|
+
return err instanceof Error ? err.message : String(err);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function noFollowFlag(view: MountView): number {
|
|
92
|
+
return !view.followSymlinks && typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The parent of a path, checked for an effect; proven absent; or why neither;
|
|
97
|
+
* with the root. The root comes first: still a directory, and still the one
|
|
98
|
+
* disk last agreed under, as any observation of it checks. An effect under a
|
|
99
|
+
* replaced root acts on nothing, whatever its caller checked before, and the
|
|
100
|
+
* parent is found by path beneath the root's path, so whoever acts checks the
|
|
101
|
+
* root again with the parent (`moved`): a root replaced after it was
|
|
102
|
+
* checked would otherwise lend the effect its replacement's directory.
|
|
103
|
+
*/
|
|
104
|
+
async function parentFor(view: MountView, rootReal: string, rel: string, create: boolean) {
|
|
105
|
+
const root = await checkRoot(view, rootReal);
|
|
106
|
+
if ('reason' in root) throw new EffectFailed(root.reason, false);
|
|
107
|
+
const checked = await checkDir(view, rootReal, parentOf(rel), { create });
|
|
108
|
+
if (checked.kind === 'unobserved') throw new EffectFailed(checked.outside ? OUTSIDE_PARENT : checked.reason, false);
|
|
109
|
+
return { ...checked, root: root.dir };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Why the parent, or the root it was found beneath, is no longer the
|
|
114
|
+
* directory its path names; null while each still is. The root is asked
|
|
115
|
+
* first, so a replaced root reads the same wherever it's found.
|
|
116
|
+
*/
|
|
117
|
+
async function moved(root: CheckedDir, parent: CheckedDir): Promise<string | null> {
|
|
118
|
+
if (!(await root.stillHere())) return ROOT_REPLACED;
|
|
119
|
+
return (await parent.stillHere()) ? null : MOVED;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A file as a stat describes it: which file, and the size and stamps a write changes. */
|
|
123
|
+
type Stamped = { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint };
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether the file at `target` is still the one `before` described: the same
|
|
127
|
+
* file, with the same size and stamps, so nothing wrote it since. Read just
|
|
128
|
+
* before an effect, after the slower checks, so an edit made while the file
|
|
129
|
+
* was hashed is never overwritten or deleted unseen; what remains is the
|
|
130
|
+
* moment between this and the effect, which Node can't close (see the header).
|
|
131
|
+
*/
|
|
132
|
+
function unchanged(before: Stamped, now: Stamped | null): boolean {
|
|
133
|
+
return now !== null && now.dev === before.dev && now.ino === before.ino && now.size === before.size
|
|
134
|
+
&& now.mtimeNs === before.mtimeNs && now.ctimeNs === before.ctimeNs;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The hash of what `target` holds, read through a descriptor that must be the file `id` names. */
|
|
138
|
+
async function hashAt(target: string, id: { dev: bigint; ino: bigint }, flags: number): Promise<string> {
|
|
139
|
+
let handle: FileHandle;
|
|
140
|
+
try {
|
|
141
|
+
handle = await open(target, fsConstants.O_RDONLY | NONBLOCK | flags);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
throw new EffectFailed(`cannot verify the disk copy (${errno(err) ?? message(err)})`, false);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const info = await handle.stat({ bigint: true });
|
|
147
|
+
if (info.dev !== id.dev || info.ino !== id.ino) throw new EffectFailed(CHANGED, false);
|
|
148
|
+
const hash = createHash('sha256');
|
|
149
|
+
const chunk = Buffer.alloc(1 << 20);
|
|
150
|
+
for (let offset = 0; ;) {
|
|
151
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, offset);
|
|
152
|
+
if (bytesRead === 0) break;
|
|
153
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
154
|
+
offset += bytesRead;
|
|
155
|
+
}
|
|
156
|
+
return hash.digest('hex');
|
|
157
|
+
} finally {
|
|
158
|
+
await handle.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Write `bytes` at a mount-relative path, creating missing parent
|
|
164
|
+
* directories, and make the file durable (its directories are synced by the
|
|
165
|
+
* caller, after all of a push's effects: syncDirectories).
|
|
166
|
+
*/
|
|
167
|
+
export async function writeContained(view: MountView, rootReal: string, rel: string, bytes: Buffer, expect: Expect): Promise<void> {
|
|
168
|
+
const checked = await parentFor(view, rootReal, rel, true);
|
|
169
|
+
if (checked.kind !== 'checked') throw new EffectFailed('a parent path is not a directory', false);
|
|
170
|
+
const parent = checked.dir;
|
|
171
|
+
const root = checked.root;
|
|
172
|
+
const target = parent.at(nameOf(rel));
|
|
173
|
+
const noFollow = noFollowFlag(view);
|
|
174
|
+
if (!view.followSymlinks && !noFollow) {
|
|
175
|
+
// No O_NOFOLLOW (Windows): refuse a symlink by lstat before opening.
|
|
176
|
+
if ((await lstat(target).catch(() => null))?.isSymbolicLink()) throw new EffectFailed(NOT_FOLLOWED, false);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let touched = false;
|
|
180
|
+
const create = async (): Promise<FileHandle> => {
|
|
181
|
+
try {
|
|
182
|
+
// Exclusive: never follows a symlink, never takes over a file that
|
|
183
|
+
// appeared since planning saw none.
|
|
184
|
+
const handle = await open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o644);
|
|
185
|
+
touched = true;
|
|
186
|
+
return handle;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
// O_EXCL refuses any entry: a file, a directory or a link.
|
|
189
|
+
if (errno(err) === 'EEXIST') throw new EffectFailed('something appeared at this path since it was checked', false);
|
|
190
|
+
throw new EffectFailed(`cannot create the file (${errno(err) ?? message(err)})`, false);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
let handle: FileHandle;
|
|
194
|
+
if (expect.kind === 'absent') {
|
|
195
|
+
handle = await create();
|
|
196
|
+
} else {
|
|
197
|
+
try {
|
|
198
|
+
// Opened without truncating: nothing changes until it is located.
|
|
199
|
+
handle = await open(target, fsConstants.O_WRONLY | NONBLOCK | noFollow);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const code = errno(err);
|
|
202
|
+
if (code === 'ENOENT' && expect.kind === 'any') handle = await create();
|
|
203
|
+
else if (code === 'ENOENT') throw new EffectFailed('the file was removed since it was checked', false);
|
|
204
|
+
else if (code === 'ELOOP' && !view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false);
|
|
205
|
+
else if (code === 'EISDIR') throw new EffectFailed('a directory', false);
|
|
206
|
+
else if (code === 'ENXIO') throw new EffectFailed('not a regular file', false); // a FIFO with no reader
|
|
207
|
+
else throw new EffectFailed(`cannot open the file (${code ?? message(err)})`, false);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let stage = 'write the file';
|
|
212
|
+
try {
|
|
213
|
+
// Before any byte: a regular file, and — unless forced — still holding
|
|
214
|
+
// what was decided on. The hash reads the whole file, so it comes first,
|
|
215
|
+
// and the checks of where the file is follow it.
|
|
216
|
+
const info = await handle.stat({ bigint: true });
|
|
217
|
+
if (!info.isFile()) throw new EffectFailed('not a regular file', touched);
|
|
218
|
+
if (expect.kind === 'content' && (await hashAt(target, info, noFollow)) !== expect.hash) throw new EffectFailed(CHANGED, touched);
|
|
219
|
+
// Then: canonically inside the mount, the one this path names (and not a
|
|
220
|
+
// symlink, where the mount follows none and the open couldn't refuse
|
|
221
|
+
// one), with no other names, in the directory still at its path beneath
|
|
222
|
+
// the root still at its own.
|
|
223
|
+
const real = await realpath(target).catch(() => null);
|
|
224
|
+
if (real === null) throw new EffectFailed(CHANGED, touched);
|
|
225
|
+
if (!contained(rootReal, real)) throw new EffectFailed(view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT, touched);
|
|
226
|
+
const named = await stat(real, { bigint: true }).catch(() => null);
|
|
227
|
+
if (named === null || named.dev !== info.dev || named.ino !== info.ino) throw new EffectFailed(CHANGED, touched);
|
|
228
|
+
if (!view.followSymlinks) {
|
|
229
|
+
const entry = await lstat(target, { bigint: true }).catch(() => null);
|
|
230
|
+
if (entry?.isSymbolicLink()) throw new EffectFailed(NOT_FOLLOWED, touched);
|
|
231
|
+
if (entry === null || !entry.isFile() || entry.dev !== info.dev || entry.ino !== info.ino) throw new EffectFailed(CHANGED, touched);
|
|
232
|
+
}
|
|
233
|
+
if (info.nlink > 1n) throw new EffectFailed(HARD_LINKED, touched);
|
|
234
|
+
const gone = await moved(root, parent);
|
|
235
|
+
if (gone !== null) throw new EffectFailed(gone, touched);
|
|
236
|
+
// Nothing wrote it while those ran.
|
|
237
|
+
if (!unchanged(info, await handle.stat({ bigint: true }).catch(() => null))) throw new EffectFailed(CHANGED, touched);
|
|
238
|
+
|
|
239
|
+
touched = true;
|
|
240
|
+
await handle.truncate(0);
|
|
241
|
+
await handle.writeFile(bytes);
|
|
242
|
+
stage = 'make the write durable (file fsync)';
|
|
243
|
+
await handle.sync();
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (err instanceof EffectFailed) throw err;
|
|
246
|
+
throw new EffectFailed(`could not ${stage}: ${message(err)}`, touched);
|
|
247
|
+
} finally {
|
|
248
|
+
await handle.close();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Unlink a mount-relative file, or confirm it gone (`absent` expected: a
|
|
254
|
+
* file found there is never removed). Returns the mount directory whose
|
|
255
|
+
* entries make the absence durable, for the caller's barrier: the file's
|
|
256
|
+
* parent, or — when the parent itself is gone — the directory proven to lack
|
|
257
|
+
* it, since that removal may not be durable either.
|
|
258
|
+
*/
|
|
259
|
+
export async function unlinkContained(view: MountView, rootReal: string, rel: string, expect: Expect): Promise<string> {
|
|
260
|
+
const checked = await parentFor(view, rootReal, rel, false);
|
|
261
|
+
if (checked.kind === 'absent') {
|
|
262
|
+
// No directory, so no file: proven beneath a root still at its path.
|
|
263
|
+
if (!(await checked.root.stillHere())) throw new EffectFailed(ROOT_REPLACED, false);
|
|
264
|
+
return checked.by;
|
|
265
|
+
}
|
|
266
|
+
const parent = checked.dir;
|
|
267
|
+
const root = checked.root;
|
|
268
|
+
const target = parent.at(nameOf(rel));
|
|
269
|
+
let info;
|
|
270
|
+
try {
|
|
271
|
+
info = await lstat(target, { bigint: true });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (errno(err) === 'ENOENT') {
|
|
274
|
+
const gone = await moved(root, parent);
|
|
275
|
+
if (gone !== null) throw new EffectFailed(gone, false);
|
|
276
|
+
return parent.rel;
|
|
277
|
+
}
|
|
278
|
+
throw new EffectFailed(`cannot stat (${errno(err) ?? message(err)})`, false);
|
|
279
|
+
}
|
|
280
|
+
// Confirming an unlink that already happened never removes a file that
|
|
281
|
+
// appeared since.
|
|
282
|
+
if (expect.kind === 'absent') {
|
|
283
|
+
const kind = info.isDirectory() ? 'a directory' : info.isSymbolicLink() ? 'a symlink' : 'a file';
|
|
284
|
+
throw new EffectFailed(`${kind} appeared at this path since it was checked`, false);
|
|
285
|
+
}
|
|
286
|
+
let file = info;
|
|
287
|
+
if (info.isSymbolicLink()) {
|
|
288
|
+
// The link itself is what is removed; a followed one must still lie inside.
|
|
289
|
+
if (!view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false);
|
|
290
|
+
const real = await realpath(target).catch(() => null);
|
|
291
|
+
if (real === null || !contained(rootReal, real)) throw new EffectFailed('a symlink that leaves the mount', false);
|
|
292
|
+
const through = await stat(real, { bigint: true }).catch(() => null);
|
|
293
|
+
if (through === null) throw new EffectFailed(CHANGED, false);
|
|
294
|
+
file = through;
|
|
295
|
+
} else if (info.isDirectory()) {
|
|
296
|
+
throw new EffectFailed('a directory', false);
|
|
297
|
+
}
|
|
298
|
+
if (expect.kind === 'content' && (await hashAt(target, file, noFollowFlag(view))) !== expect.hash) throw new EffectFailed(CHANGED, false);
|
|
299
|
+
const gone = await moved(root, parent);
|
|
300
|
+
if (gone !== null) throw new EffectFailed(gone, false);
|
|
301
|
+
// The entry checked is still the one at the path, unwritten since it was
|
|
302
|
+
// hashed, as close to the unlink as Node allows (see the header): a file
|
|
303
|
+
// replaced or edited while it was hashed isn't removed.
|
|
304
|
+
const again = await lstat(target, { bigint: true }).catch(() => null);
|
|
305
|
+
const named = again?.isSymbolicLink() ? await stat(target, { bigint: true }).catch(() => null) : again;
|
|
306
|
+
if (!unchanged(file, named)) throw new EffectFailed(CHANGED, false);
|
|
307
|
+
try {
|
|
308
|
+
await unlink(target);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
const code = errno(err);
|
|
311
|
+
// An unlink is all or nothing: one refused outright changed nothing, so
|
|
312
|
+
// the path's evidence can go back as it was. Anything else may have
|
|
313
|
+
// landed (a network filesystem's EIO, say): the next observation resolves it.
|
|
314
|
+
if (code !== 'ENOENT') throw new EffectFailed(`could not unlink the file: ${message(err)}`, !UNLINK_REFUSALS.has(code ?? ''));
|
|
315
|
+
}
|
|
316
|
+
return parent.rel;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Sync each mount directory (mount-relative), deepest first, so the entries
|
|
321
|
+
* a push created or removed in them are durable. Each is checked first and the
|
|
322
|
+
* directory it holds is the one synced — never a replacement at its path.
|
|
323
|
+
* Returns the ones that failed, with why. Where directories can't be synced
|
|
324
|
+
* at all (Windows, or a filesystem answering EINVAL/ENOTSUP), there is
|
|
325
|
+
* nothing to wait for.
|
|
326
|
+
*/
|
|
327
|
+
export async function syncDirectories(view: MountView, rootReal: string, rels: Iterable<string>): Promise<Map<string, string>> {
|
|
328
|
+
const failed = new Map<string, string>();
|
|
329
|
+
const depth = (rel: string): number => (rel === '' ? 0 : rel.split('/').length);
|
|
330
|
+
const sorted = [...new Set(rels)].sort((a, b) => depth(b) - depth(a));
|
|
331
|
+
// Each directory is found by path beneath the root's path: under a root
|
|
332
|
+
// that isn't the one disk agreed under, or that is replaced while they're
|
|
333
|
+
// synced, none of them is the mount's.
|
|
334
|
+
const root = await checkRoot(view, rootReal);
|
|
335
|
+
if ('reason' in root) {
|
|
336
|
+
for (const rel of sorted) failed.set(rel, root.reason);
|
|
337
|
+
return failed;
|
|
338
|
+
}
|
|
339
|
+
for (const rel of sorted) {
|
|
340
|
+
const checked = await checkDir(view, rootReal, rel);
|
|
341
|
+
if (checked.kind !== 'checked') {
|
|
342
|
+
failed.set(rel, checked.kind === 'absent' ? 'the directory is gone' : checked.reason);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
await checked.dir.sync();
|
|
347
|
+
if (!(await root.dir.stillHere())) failed.set(rel, ROOT_REPLACED);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
failed.set(rel, message(err));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return failed;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The directories whose entries make a written file reachable: its parent up to the mount root. */
|
|
356
|
+
export function chainOf(rel: string): string[] {
|
|
357
|
+
const chain: string[] = [];
|
|
358
|
+
for (let dir = parentOf(rel); ; dir = parentOf(dir)) {
|
|
359
|
+
chain.push(dir);
|
|
360
|
+
if (dir === '') return chain;
|
|
361
|
+
}
|
|
362
|
+
}
|