@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
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { constants as fsConstants } from 'node:fs';
|
|
9
9
|
import type { Stats } from 'node:fs';
|
|
10
|
-
import { open, readFile, stat,
|
|
11
|
-
import { join, resolve, relative,
|
|
10
|
+
import { open, readFile, stat, lstat, realpath } from 'node:fs/promises';
|
|
11
|
+
import { join, resolve, relative, sep } from 'node:path';
|
|
12
12
|
import type { JsStore } from '@animalabs/chronicle';
|
|
13
13
|
import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
|
|
14
14
|
import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
|
|
15
15
|
import type {
|
|
16
|
+
AgentActionScan,
|
|
16
17
|
WorkspaceConfig,
|
|
17
18
|
MountConfig,
|
|
18
19
|
MountState,
|
|
@@ -35,7 +36,31 @@ import type {
|
|
|
35
36
|
} from './types.js';
|
|
36
37
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
37
38
|
import { MountWatcher, type FsChange } from './watcher.js';
|
|
38
|
-
import {
|
|
39
|
+
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
40
|
+
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
41
|
+
import { BranchIntents } from './branch-intent.js';
|
|
42
|
+
import { coveredByIgnore, filesystemTrustsCtime, locateRoot } from './observe.js';
|
|
43
|
+
import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js';
|
|
44
|
+
import {
|
|
45
|
+
type MountRuntime,
|
|
46
|
+
type PassOptions,
|
|
47
|
+
type PassResult,
|
|
48
|
+
type PathReport,
|
|
49
|
+
type PushOptions,
|
|
50
|
+
type PushResult,
|
|
51
|
+
type Scope,
|
|
52
|
+
type TreeOp,
|
|
53
|
+
pushPaths,
|
|
54
|
+
reconcilePass,
|
|
55
|
+
settleAdoptionBeforeMutation,
|
|
56
|
+
} from './reconcile.js';
|
|
57
|
+
|
|
58
|
+
/** Default for how long the after-batch scan may hold the agent's next inference. */
|
|
59
|
+
const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
60
|
+
/** Why a call is refused once the module has stopped. */
|
|
61
|
+
const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
|
|
62
|
+
/** The workspace's own deadline must end before the framework's hook bound. */
|
|
63
|
+
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
39
64
|
|
|
40
65
|
export type {
|
|
41
66
|
WorkspaceConfig,
|
|
@@ -603,9 +628,22 @@ export class WorkspaceModule implements Module {
|
|
|
603
628
|
|
|
604
629
|
private ctx: ModuleContext | null = null;
|
|
605
630
|
private store: JsStore | null = null;
|
|
631
|
+
/** Set once stop has drained and saved, cleared by start (running()). */
|
|
632
|
+
private stopped = false;
|
|
606
633
|
private config: WorkspaceConfig;
|
|
607
634
|
private mounts = new Map<string, MountState>();
|
|
608
635
|
private watchers = new Map<string, MountWatcher>();
|
|
636
|
+
/** What disk last agreed with (global records); created with the store. */
|
|
637
|
+
private agreement: DiskAgreement | null = null;
|
|
638
|
+
/** Branch-local intent per mount. */
|
|
639
|
+
private intents = new Map<string, BranchIntents>();
|
|
640
|
+
/** Each mount's turn: the tail of its serialized passes and writes. */
|
|
641
|
+
private mountTurns = new Map<string, Promise<void>>();
|
|
642
|
+
/** Whether an over-bound agentActionScanDeadlineMs has been warned about. */
|
|
643
|
+
private warnedScanDeadline = false;
|
|
644
|
+
/** Whether a mount root's filesystem maintains ctime, by canonical root. */
|
|
645
|
+
/** Per canonical root: the device of its filesystem when that maintains ctime, else null. */
|
|
646
|
+
private ctimeTrust = new Map<string, string | null>();
|
|
609
647
|
/** Persisted state decoded in start(), held until mounts exist to apply it
|
|
610
648
|
* to — in the Host ordering start() runs before initStore() creates the
|
|
611
649
|
* mounts, so restoration must be second-callback-safe like watcher
|
|
@@ -651,40 +689,119 @@ export class WorkspaceModule implements Module {
|
|
|
651
689
|
initStore(store: JsStore): void {
|
|
652
690
|
this.store = store;
|
|
653
691
|
|
|
654
|
-
// Register tree states for each mount
|
|
692
|
+
// Register tree states for each mount: the workspace tree, and the
|
|
693
|
+
// branch-local intent that qualifies it.
|
|
655
694
|
for (const mount of this.config.mounts) {
|
|
656
695
|
const treeStateId = `workspace/${mount.name}/tree`;
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
696
|
+
const intentTreeStateId = `workspace/${mount.name}/intent`;
|
|
697
|
+
for (const id of [treeStateId, intentTreeStateId]) {
|
|
698
|
+
try {
|
|
699
|
+
store.registerState({
|
|
700
|
+
id,
|
|
701
|
+
strategy: 'tree',
|
|
702
|
+
deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
|
|
703
|
+
fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
|
|
704
|
+
});
|
|
705
|
+
} catch {
|
|
706
|
+
// State already registered (restart scenario)
|
|
707
|
+
}
|
|
666
708
|
}
|
|
667
709
|
|
|
668
710
|
const mountState: MountState = {
|
|
669
711
|
config: mount,
|
|
670
712
|
treeStateId,
|
|
713
|
+
intentTreeStateId,
|
|
671
714
|
lastMaterializedSeq: 0,
|
|
672
715
|
suppressedPaths: new Set(),
|
|
673
716
|
initialSyncDone: false,
|
|
674
717
|
lastMaterializedBranchId: null,
|
|
675
|
-
materializedHashes: new Map(),
|
|
676
718
|
watcherReadyAt: null,
|
|
677
719
|
watcherError: null,
|
|
678
720
|
};
|
|
679
721
|
this.mounts.set(mount.name, mountState);
|
|
722
|
+
this.intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
|
|
680
723
|
}
|
|
681
724
|
|
|
725
|
+
// What disk last agreed with, per mount and path: global records, so a
|
|
726
|
+
// branch switch never rewinds it.
|
|
727
|
+
this.agreement = new DiskAgreement(store);
|
|
728
|
+
this.agreement.open(this.config.mounts.map((m) => ({ name: m.name, root: resolve(m.path) })));
|
|
729
|
+
|
|
682
730
|
this.applySavedState();
|
|
683
731
|
this.ensureRunning();
|
|
684
732
|
}
|
|
685
733
|
|
|
734
|
+
// ==========================================================================
|
|
735
|
+
// Reconciliation plumbing
|
|
736
|
+
// ==========================================================================
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Run `fn` with the mount to itself: observation, decision and effect never
|
|
740
|
+
* interleave with another pass or a tool write on the same mount (a disk
|
|
741
|
+
* observation taken before an autoMaterialize write and applied after it
|
|
742
|
+
* would ingest old bytes over the new entry). Not re-entrant: code holding
|
|
743
|
+
* a mount's turn calls the unlocked helpers.
|
|
744
|
+
*/
|
|
745
|
+
private withMount<T>(mount: MountState, fn: () => Promise<T>): Promise<T> {
|
|
746
|
+
const name = mount.config.name;
|
|
747
|
+
const previous = this.mountTurns.get(name) ?? Promise.resolve();
|
|
748
|
+
const run = previous.then(fn, fn);
|
|
749
|
+
this.mountTurns.set(name, run.then(() => undefined, () => undefined));
|
|
750
|
+
return run;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private agreementOrThrow(): DiskAgreement {
|
|
754
|
+
if (!this.agreement) throw new Error('Workspace store not initialized');
|
|
755
|
+
return this.agreement;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** The view reconciliation needs of a mount, refreshed per pass. */
|
|
759
|
+
private async runtime(mount: MountState): Promise<MountRuntime> {
|
|
760
|
+
const root = resolve(mount.config.path);
|
|
761
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
762
|
+
let trustedDev = this.ctimeTrust.get(rootReal);
|
|
763
|
+
if (trustedDev === undefined) {
|
|
764
|
+
const dev = (await stat(rootReal, { bigint: true }).catch(() => null))?.dev;
|
|
765
|
+
trustedDev = dev !== undefined && (await filesystemTrustsCtime(rootReal)) ? dev.toString() : null;
|
|
766
|
+
this.ctimeTrust.set(rootReal, trustedDev);
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
name: mount.config.name,
|
|
770
|
+
view: {
|
|
771
|
+
root,
|
|
772
|
+
rootIdentity: this.agreementOrThrow().rootIdentity(mount.config.name),
|
|
773
|
+
followSymlinks: mount.config.followSymlinks === true,
|
|
774
|
+
maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
775
|
+
ignore: mount.config.ignore ?? [],
|
|
776
|
+
},
|
|
777
|
+
rootReal,
|
|
778
|
+
ctimeTrustedDev: trustedDev,
|
|
779
|
+
treeStateId: mount.treeStateId,
|
|
780
|
+
intents: this.intents.get(mount.config.name)!,
|
|
781
|
+
readOnly: mount.config.mode === 'read-only',
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** One disk→store pass; the caller holds the mount's turn. Emits its events. */
|
|
786
|
+
private async passUnlocked(mount: MountState, scope: Scope, opts: PassOptions = {}): Promise<PassResult> {
|
|
787
|
+
const result = await reconcilePass(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), scope, opts);
|
|
788
|
+
if (scope.kind === 'dir' && scope.dir === '' && scope.recursive) mount.initialSyncDone = true;
|
|
789
|
+
this.emitFsEvents(mount.config.name, result.ops, result.newConflicts);
|
|
790
|
+
return result;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** One store→disk push; the caller holds the mount's turn. */
|
|
794
|
+
private async pushUnlocked(mount: MountState, paths: string[], opts: PushOptions = {}): Promise<PushResult> {
|
|
795
|
+
const watcher = this.watchers.get(mount.config.name);
|
|
796
|
+
return pushPaths(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), paths, {
|
|
797
|
+
...opts,
|
|
798
|
+
beforeEffect: (p) => watcher?.suppress(p),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
686
802
|
async start(ctx: ModuleContext): Promise<void> {
|
|
687
803
|
this.ctx = ctx;
|
|
804
|
+
this.stopped = false;
|
|
688
805
|
|
|
689
806
|
// Decode persisted state if restarting; application waits until mounts
|
|
690
807
|
// exist (see applySavedState) — in the Host ordering they don't yet.
|
|
@@ -709,9 +826,18 @@ export class WorkspaceModule implements Module {
|
|
|
709
826
|
|
|
710
827
|
for (const [name, meta] of Object.entries(this.savedState.mounts)) {
|
|
711
828
|
const mount = this.mounts.get(name);
|
|
712
|
-
|
|
829
|
+
// A mount materialized this session already (a call between initStore
|
|
830
|
+
// and start) has a pin and watermark newer than anything saved.
|
|
831
|
+
if (mount && mount.lastMaterializedBranchId === null && mount.lastMaterializedSeq === 0) {
|
|
713
832
|
mount.lastMaterializedSeq = meta.lastMaterializedSeq;
|
|
714
833
|
mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
|
|
834
|
+
// A store that ran #169's stop-time baselines (#109) saved them here
|
|
835
|
+
// too. They aren't carried over as evidence: #169 recorded no root,
|
|
836
|
+
// so nothing binds them to the directory they describe, and an empty
|
|
837
|
+
// mountpoint at the next start would read as every file deleted.
|
|
838
|
+
// Such a store gains its evidence from its first pass instead: a
|
|
839
|
+
// file disk agrees with is recorded, and anything else is a conflict
|
|
840
|
+
// or unknown provenance, never an adoption.
|
|
715
841
|
// watcherReadyAt intentionally not restored — each session must
|
|
716
842
|
// observe its own watcher attach, otherwise a stale timestamp
|
|
717
843
|
// would hide a new-session attach failure.
|
|
@@ -855,12 +981,10 @@ export class WorkspaceModule implements Module {
|
|
|
855
981
|
watcher.start();
|
|
856
982
|
this.watchers.set(name, watcher);
|
|
857
983
|
|
|
858
|
-
// Chokidar is started with ignoreInitial:true, so
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
// sessions see the existing catalog; restarts only see what appeared
|
|
863
|
-
// while the session was down.
|
|
984
|
+
// Chokidar is started with ignoreInitial:true, so changes made while
|
|
985
|
+
// the session was down would be invisible. One full pass catches up
|
|
986
|
+
// under the three-way rule: only real differences produce tree
|
|
987
|
+
// changes and events.
|
|
864
988
|
void this.initialScan(name);
|
|
865
989
|
}
|
|
866
990
|
|
|
@@ -878,9 +1002,9 @@ export class WorkspaceModule implements Module {
|
|
|
878
1002
|
if (!store || !mount) return;
|
|
879
1003
|
|
|
880
1004
|
try {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
this.
|
|
1005
|
+
// A full pass under the three-way rule: deletions on disk are confirmed
|
|
1006
|
+
// where nothing newer is pending, and nothing unvisited is touched.
|
|
1007
|
+
await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true }));
|
|
884
1008
|
} catch (err) {
|
|
885
1009
|
const msg = err instanceof Error ? err.message : String(err);
|
|
886
1010
|
this.ctx?.pushEvent({
|
|
@@ -892,6 +1016,24 @@ export class WorkspaceModule implements Module {
|
|
|
892
1016
|
}
|
|
893
1017
|
|
|
894
1018
|
async stop(): Promise<void> {
|
|
1019
|
+
// No new watcher-driven passes, then let every pass and write finish
|
|
1020
|
+
// before the store goes away — including any queued behind the ones in
|
|
1021
|
+
// flight (a tool call, a scan continuing past its deadline): drained
|
|
1022
|
+
// until no new tail appears.
|
|
1023
|
+
for (const watcher of this.watchers.values()) {
|
|
1024
|
+
await watcher.stop();
|
|
1025
|
+
}
|
|
1026
|
+
this.watchers.clear();
|
|
1027
|
+
for (;;) {
|
|
1028
|
+
const tails = [...this.mountTurns.values()];
|
|
1029
|
+
await Promise.all(tails);
|
|
1030
|
+
const now = [...this.mountTurns.values()];
|
|
1031
|
+
if (now.length === tails.length && now.every((tail, i) => tail === tails[i])) break;
|
|
1032
|
+
}
|
|
1033
|
+
// Nothing is queued now, and nothing more is taken: what follows saves
|
|
1034
|
+
// the pins and watermarks as the last work left them.
|
|
1035
|
+
this.stopped = true;
|
|
1036
|
+
|
|
895
1037
|
// Persist state
|
|
896
1038
|
if (this.ctx) {
|
|
897
1039
|
const activeBranchId = this.store ? this.store.currentBranch().id : undefined;
|
|
@@ -906,12 +1048,6 @@ export class WorkspaceModule implements Module {
|
|
|
906
1048
|
}
|
|
907
1049
|
this.ctx.setState(state);
|
|
908
1050
|
}
|
|
909
|
-
|
|
910
|
-
// Stop watchers
|
|
911
|
-
for (const watcher of this.watchers.values()) {
|
|
912
|
-
await watcher.stop();
|
|
913
|
-
}
|
|
914
|
-
this.watchers.clear();
|
|
915
1051
|
this.ctx = null;
|
|
916
1052
|
}
|
|
917
1053
|
|
|
@@ -988,7 +1124,9 @@ export class WorkspaceModule implements Module {
|
|
|
988
1124
|
},
|
|
989
1125
|
{
|
|
990
1126
|
name: 'delete',
|
|
991
|
-
description: 'Delete a file from the workspace.'
|
|
1127
|
+
description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
|
|
1128
|
+
+ 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
|
|
1129
|
+
+ 'or a sync of the path restores it.',
|
|
992
1130
|
inputSchema: {
|
|
993
1131
|
type: 'object' as const,
|
|
994
1132
|
properties: {
|
|
@@ -999,7 +1137,14 @@ export class WorkspaceModule implements Module {
|
|
|
999
1137
|
},
|
|
1000
1138
|
{
|
|
1001
1139
|
name: 'ls',
|
|
1002
|
-
description: 'List directory
|
|
1140
|
+
description: 'List a directory, checked against disk first. Each file has a state: synced; '
|
|
1141
|
+
+ 'workspace-draft (the workspace has changes disk lacks); workspace-deleted (deleted in the workspace, '
|
|
1142
|
+
+ 'still on disk); not-in-branch (on disk, absent from this branch\'s workspace); disk-only (binary or over '
|
|
1143
|
+
+ 'the size limit, never stored — shown with size and image type); conflict, with its kind (disk and the '
|
|
1144
|
+
+ 'workspace both changed; disk holds a different copy and nothing records which is newer; or a write was '
|
|
1145
|
+
+ 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace; '
|
|
1146
|
+
+ 'disk-missing-provenance-unknown; unverified (disk could not be checked). `incomplete` names regions '
|
|
1147
|
+
+ 'the listing could not see.',
|
|
1003
1148
|
inputSchema: {
|
|
1004
1149
|
type: 'object' as const,
|
|
1005
1150
|
properties: {
|
|
@@ -1010,7 +1155,7 @@ export class WorkspaceModule implements Module {
|
|
|
1010
1155
|
},
|
|
1011
1156
|
{
|
|
1012
1157
|
name: 'glob',
|
|
1013
|
-
description: 'Find files matching a glob pattern
|
|
1158
|
+
description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.',
|
|
1014
1159
|
inputSchema: {
|
|
1015
1160
|
type: 'object' as const,
|
|
1016
1161
|
properties: {
|
|
@@ -1022,7 +1167,11 @@ export class WorkspaceModule implements Module {
|
|
|
1022
1167
|
},
|
|
1023
1168
|
{
|
|
1024
1169
|
name: 'grep',
|
|
1025
|
-
description: 'Search file
|
|
1170
|
+
description: 'Search stored file text with a regex pattern, checked against disk first. Each result says '
|
|
1171
|
+
+ 'which version matched: the workspace\'s (with its state); the disk version kept with a conflict '
|
|
1172
|
+
+ '(conflicting-disk); or, once disk has changed since the conflict was recorded, that recorded version '
|
|
1173
|
+
+ '(recorded-disk). Disk versions it could not search are listed as skipped: disk-only files (binary or '
|
|
1174
|
+
+ 'over the size limit), and a conflict\'s disk version that is binary, oversize, or newer than the one recorded.',
|
|
1026
1175
|
inputSchema: {
|
|
1027
1176
|
type: 'object' as const,
|
|
1028
1177
|
properties: {
|
|
@@ -1047,24 +1196,52 @@ export class WorkspaceModule implements Module {
|
|
|
1047
1196
|
},
|
|
1048
1197
|
{
|
|
1049
1198
|
name: 'materialize',
|
|
1050
|
-
description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk.'
|
|
1199
|
+
description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
|
|
1200
|
+
+ 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk.',
|
|
1051
1201
|
inputSchema: {
|
|
1052
1202
|
type: 'object' as const,
|
|
1053
1203
|
properties: {
|
|
1054
|
-
path: { type: 'string', description: 'Specific path to materialize (optional — defaults to
|
|
1204
|
+
path: { type: 'string', description: 'Specific path to materialize (optional — defaults to what disk still owes: workspace edits, conflicts, workspace deletions, and files never checked against disk)' },
|
|
1055
1205
|
mount: { type: 'string', description: 'Specific mount (optional)' },
|
|
1056
|
-
force: { type: 'boolean', description: '
|
|
1206
|
+
force: { type: 'boolean', description: 'Overwrite files whose disk copy changed since it last agreed with the workspace (another writer, or a conflict), and materialize even if the current branch has diverged from the branch last written to disk (default false). Without it, divergent files are skipped and listed. A file the workspace has not changed is left as disk has it unless you name its path.' },
|
|
1207
|
+
applyDeletions: { type: 'boolean', description: 'Also delete from disk the files deleted in the workspace whose disk copy is one the workspace holds (default false). With force, also those whose disk copy changed since.' },
|
|
1057
1208
|
},
|
|
1058
1209
|
},
|
|
1059
1210
|
},
|
|
1060
1211
|
{
|
|
1061
1212
|
name: 'sync',
|
|
1062
|
-
description: '
|
|
1213
|
+
description: 'Bring disk changes into the workspace. Without a path, every mount (or the given one) is '
|
|
1214
|
+
+ 'rechecked in full and nothing pending in the workspace is discarded. With a path (file or directory), '
|
|
1215
|
+
+ 'the workspace takes disk\'s state there explicitly: it restores a workspace-deleted file, resolves '
|
|
1216
|
+
+ 'conflicts toward disk, replaces a workspace draft, and removes a workspace file disk does not have. '
|
|
1217
|
+
+ 'Each workspace change it gives up is listed under `discarded`, with the state it had and, as `op`, '
|
|
1218
|
+
+ 'what that did to the workspace copy (absent when it changed none: a deletion or conflict given up for '
|
|
1219
|
+
+ 'a disk copy the workspace can\'t hold). '
|
|
1220
|
+
+ 'A mount whose root is not the directory disk last agreed with (an unmounted drive, a replaced '
|
|
1221
|
+
+ 'directory) is unavailable and listed under `incomplete`. Reconnect the drive; acceptRoot is for a '
|
|
1222
|
+
+ 'directory replaced on purpose, or a drive back under a new device number, never for one still '
|
|
1223
|
+
+ 'unplugged, whose empty mountpoint would become the root. What the scan passed over by design and '
|
|
1224
|
+
+ 'reported nowhere else is counted under `passedOver`, by mount and reason: entries the mount\'s '
|
|
1225
|
+
+ 'ignore list covers that hold nothing the workspace tracks (an ignored directory counts once). So a '
|
|
1226
|
+
+ 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the '
|
|
1227
|
+
+ 'sync looked everywhere and nothing had changed. A path the mount ignores is synced when you name '
|
|
1228
|
+
+ 'it, and listed under `ignored`: a sync without a path passes over it. Naming an ignored directory '
|
|
1229
|
+
+ 'sets aside the patterns that cover it or a directory above it; one that covers only something '
|
|
1230
|
+
+ 'inside it still applies, and what it covers is passed over.',
|
|
1063
1231
|
inputSchema: {
|
|
1064
1232
|
type: 'object' as const,
|
|
1065
1233
|
properties: {
|
|
1066
|
-
path: { type: 'string', description: 'Specific path to
|
|
1234
|
+
path: { type: 'string', description: 'Specific path to take from disk (optional — defaults to a full recheck)' },
|
|
1067
1235
|
mount: { type: 'string', description: 'Specific mount (optional)' },
|
|
1236
|
+
acceptRoot: {
|
|
1237
|
+
type: 'boolean',
|
|
1238
|
+
description: 'Take each mount\'s root as it is now when it is not the directory disk last agreed with: '
|
|
1239
|
+
+ 'a directory replaced on purpose, or a drive that came back under a new device number (default false). '
|
|
1240
|
+
+ 'What was recorded about the old one is set aside, so every file is compared afresh: a file disk '
|
|
1241
|
+
+ 'and the workspace agree on agrees again, a differing one is a conflict, and a workspace file the '
|
|
1242
|
+
+ 'root lacks stays in the workspace. Not with path. Leave it off while a drive is merely unplugged: '
|
|
1243
|
+
+ 'its empty mountpoint would become the root.',
|
|
1244
|
+
},
|
|
1068
1245
|
},
|
|
1069
1246
|
},
|
|
1070
1247
|
},
|
|
@@ -1075,9 +1252,49 @@ export class WorkspaceModule implements Module {
|
|
|
1075
1252
|
// Tool Dispatch
|
|
1076
1253
|
// ==========================================================================
|
|
1077
1254
|
|
|
1255
|
+
private generatedTextFiles = new Map<string, {read: () => string; agentName: string}>();
|
|
1256
|
+
|
|
1257
|
+
/** Compare the same normalized physical path for registration and tool access. */
|
|
1258
|
+
private generatedFileKey(path: string): string {
|
|
1259
|
+
const slash = path.indexOf('/');
|
|
1260
|
+
const mountName = slash < 0 ? path : path.slice(0, slash);
|
|
1261
|
+
const mount = this.config.mounts.find(m => m.name === mountName);
|
|
1262
|
+
if (!mount) throw new Error(`Unknown mount: "${mountName}"`);
|
|
1263
|
+
const resolved = resolve(mount.path, slash < 0 ? '' : path.slice(slash + 1));
|
|
1264
|
+
if (!isContainedPath(mount.path, resolved)) throw new Error(`Path traversal detected: "${path}"`);
|
|
1265
|
+
return resolved;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
private findGeneratedTextFile(path: string) {
|
|
1269
|
+
if (!this.generatedTextFiles.size) return undefined;
|
|
1270
|
+
try { return this.generatedTextFiles.get(this.generatedFileKey(path)); }
|
|
1271
|
+
catch {
|
|
1272
|
+
// Not an alias of a valid generated file. Let the selected tool return
|
|
1273
|
+
// its established validation error (including image-specific errors).
|
|
1274
|
+
return undefined;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/** Generated files bypass stored blobs so every read reflects current definitions. */
|
|
1279
|
+
registerGeneratedTextFile(path: string, read: () => string, agentName: string): void {
|
|
1280
|
+
const [mount, ...parts] = path.split('/');
|
|
1281
|
+
if (!this.config.mounts.some(m => m.name === mount) || !parts.length
|
|
1282
|
+
|| parts.some(p => !p || p === '.' || p === '..') || this.generatedTextFiles.has(this.generatedFileKey(path)))
|
|
1283
|
+
throw new Error(`Duplicate/invalid generated path: ${path}`);
|
|
1284
|
+
this.generatedTextFiles.set(this.generatedFileKey(path), {read, agentName});
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1078
1287
|
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
1288
|
+
if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
|
|
1079
1289
|
try {
|
|
1080
1290
|
const input = call.input as Record<string, unknown>;
|
|
1291
|
+
const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
1292
|
+
if (generated) {
|
|
1293
|
+
if (call.callerAgentName !== generated.agentName) return {success:false,isError:true,error:'Generated file belongs to another agent'};
|
|
1294
|
+
if (call.name !== 'read') return {success:false,isError:true,error:'Generated file is read-only; edit the presentation configuration instead'};
|
|
1295
|
+
return await this.handleRead(input as unknown as ReadInput, generated.read());
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1081
1298
|
switch (call.name) {
|
|
1082
1299
|
case 'read': return await this.handleRead(input as unknown as ReadInput);
|
|
1083
1300
|
case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput);
|
|
@@ -1197,12 +1414,29 @@ export class WorkspaceModule implements Module {
|
|
|
1197
1414
|
* mount, through the same Chronicle-tree + auto-materialize path as the
|
|
1198
1415
|
* `write` tool. Public API for the framework's synthesized `save_image`
|
|
1199
1416
|
* tool and other peer callers that hold bytes rather than text.
|
|
1417
|
+
*
|
|
1418
|
+
* `branch`, when given, ties the write to that branch: the store's current
|
|
1419
|
+
* branch is checked where the tree entry is set, nothing yielding between
|
|
1420
|
+
* the check and the entry, so the file's tree entry lands on that branch or
|
|
1421
|
+
* the write is refused, with `data: { branchLeft: true }`. A mount has one
|
|
1422
|
+
* directory on disk, so with autoMaterialize the file's copy there is
|
|
1423
|
+
* written like any other write's. The tool-result guard writes a
|
|
1424
|
+
* withheld original this way, for the branch its stub is on
|
|
1425
|
+
* (agent-framework #277).
|
|
1426
|
+
*
|
|
1427
|
+
* A write whose tree entry is set but that then fails returns success
|
|
1428
|
+
* false with `data: { inChronicle: true }`: its copy on disk was refused, or
|
|
1429
|
+
* something after the entry (recording its intent, the event, the push)
|
|
1430
|
+
* threw. The workspace's tools read the file from the tree, so it is there
|
|
1431
|
+
* for them, on the branch that was current when the entry was set.
|
|
1200
1432
|
*/
|
|
1201
1433
|
async writeBinary(
|
|
1202
1434
|
mountPrefixedPath: string,
|
|
1203
1435
|
data: Buffer,
|
|
1204
1436
|
mimeType: string,
|
|
1437
|
+
options: { branch?: string } = {},
|
|
1205
1438
|
): Promise<ToolResult> {
|
|
1439
|
+
if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
|
|
1206
1440
|
let mount: MountState;
|
|
1207
1441
|
let relativePath: string;
|
|
1208
1442
|
try {
|
|
@@ -1222,24 +1456,36 @@ export class WorkspaceModule implements Module {
|
|
|
1222
1456
|
return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
|
|
1223
1457
|
}
|
|
1224
1458
|
const store = this.getStore();
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1459
|
+
return this.withMount(mount, async () => {
|
|
1460
|
+
await this.settleBeforeMutation(mount, relativePath);
|
|
1461
|
+
// Checked inside the mount's turn, after anything the write waited on:
|
|
1462
|
+
// commitToolChange sets the tree entry before it first yields.
|
|
1463
|
+
if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
|
|
1464
|
+
return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
|
|
1465
|
+
}
|
|
1466
|
+
const blobHash = store.storeBlob(data, mimeType);
|
|
1467
|
+
let committed = false;
|
|
1468
|
+
let failure: string | null;
|
|
1469
|
+
try {
|
|
1470
|
+
const materializeError = await this.commitToolChange(mount, relativePath,
|
|
1471
|
+
{ kind: 'set', blobHash, size: data.byteLength }, () => { committed = true; });
|
|
1472
|
+
failure = materializeError === null ? null
|
|
1473
|
+
: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${materializeError}.`;
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
if (!committed) throw error;
|
|
1476
|
+
failure = `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
1477
|
+
}
|
|
1478
|
+
if (failure !== null) {
|
|
1479
|
+
// The file is in the Chronicle tree, where the workspace's tools read
|
|
1480
|
+
// it. `inChronicle` lets a caller that needs the file readable tell
|
|
1481
|
+
// this from a write that never happened (agent-framework #281).
|
|
1482
|
+
return { success: false, error: failure, isError: true, data: { inChronicle: true } };
|
|
1483
|
+
}
|
|
1233
1484
|
return {
|
|
1234
|
-
success:
|
|
1235
|
-
|
|
1236
|
-
isError: true,
|
|
1485
|
+
success: true,
|
|
1486
|
+
data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
|
|
1237
1487
|
};
|
|
1238
|
-
}
|
|
1239
|
-
return {
|
|
1240
|
-
success: true,
|
|
1241
|
-
data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
|
|
1242
|
-
};
|
|
1488
|
+
});
|
|
1243
1489
|
}
|
|
1244
1490
|
|
|
1245
1491
|
/**
|
|
@@ -1250,6 +1496,7 @@ export class WorkspaceModule implements Module {
|
|
|
1250
1496
|
async readBinary(
|
|
1251
1497
|
mountPrefixedPath: string,
|
|
1252
1498
|
): Promise<{ data: Buffer } | { error: string }> {
|
|
1499
|
+
if (!this.running()) return { error: NOT_RUNNING };
|
|
1253
1500
|
let mount: MountState;
|
|
1254
1501
|
let relativePath: string;
|
|
1255
1502
|
try {
|
|
@@ -1258,12 +1505,14 @@ export class WorkspaceModule implements Module {
|
|
|
1258
1505
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
1259
1506
|
}
|
|
1260
1507
|
const store = this.getStore();
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1508
|
+
return this.withMount(mount, async () => {
|
|
1509
|
+
await this.ensureSynced(mount, relativePath);
|
|
1510
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
1511
|
+
if (!entry) return { error: `File not found: ${mountPrefixedPath}` };
|
|
1512
|
+
const blob = store.getBlob(entry.blobHash);
|
|
1513
|
+
if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
|
|
1514
|
+
return { data: blob };
|
|
1515
|
+
});
|
|
1267
1516
|
}
|
|
1268
1517
|
|
|
1269
1518
|
/**
|
|
@@ -1332,6 +1581,18 @@ export class WorkspaceModule implements Module {
|
|
|
1332
1581
|
return { mount, relativePath };
|
|
1333
1582
|
}
|
|
1334
1583
|
|
|
1584
|
+
/**
|
|
1585
|
+
* Whether the module takes work: stop hasn't finished. Work admitted while
|
|
1586
|
+
* stop drains still runs; once stop has saved the pins and watermarks,
|
|
1587
|
+
* nothing more changes them, so it takes nothing until it starts again. A
|
|
1588
|
+
* call before start is taken, as it always was (a generated file's read
|
|
1589
|
+
* needs no store at all): in the host's order, start runs first, and
|
|
1590
|
+
* initStore applies the saved pins.
|
|
1591
|
+
*/
|
|
1592
|
+
private running(): boolean {
|
|
1593
|
+
return !this.stopped;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1335
1596
|
private getStore(): JsStore {
|
|
1336
1597
|
if (!this.store) throw new Error('WorkspaceModule: store not initialized. Call initStore() first.');
|
|
1337
1598
|
return this.store;
|
|
@@ -1653,55 +1914,70 @@ export class WorkspaceModule implements Module {
|
|
|
1653
1914
|
}
|
|
1654
1915
|
|
|
1655
1916
|
/**
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1917
|
+
* Commit a tool's write or delete to the workspace tree, with the branch
|
|
1918
|
+
* intent it implies (store origin for a path disk never agreed with; a
|
|
1919
|
+
* tombstone for a deletion), and push it to disk when the mount opts in via
|
|
1920
|
+
* `autoMaterialize` — required for cross-agent pipelines, since another
|
|
1921
|
+
* agent's watcher on the same directory only sees real filesystem events.
|
|
1922
|
+
* The push follows materialize's rule: a disk copy that changed since it
|
|
1923
|
+
* last agreed is not overwritten or unlinked, and the refusal says why.
|
|
1660
1924
|
*
|
|
1661
|
-
* Returns
|
|
1662
|
-
*
|
|
1663
|
-
* of truth for downstream agents
|
|
1664
|
-
*
|
|
1665
|
-
* read-only mount) return null (success).
|
|
1925
|
+
* Returns the reason when the change reached the workspace but not disk, so
|
|
1926
|
+
* callers surface it in the tool result: the autoMaterialize contract is
|
|
1927
|
+
* that disk is the source of truth for downstream agents. The caller holds
|
|
1928
|
+
* the mount's turn and has settled the path first (settleBeforeMutation).
|
|
1666
1929
|
*/
|
|
1667
|
-
private async
|
|
1930
|
+
private async commitToolChange(
|
|
1668
1931
|
mount: MountState,
|
|
1669
1932
|
relativePath: string,
|
|
1670
|
-
|
|
1671
|
-
|
|
1933
|
+
change: { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string },
|
|
1934
|
+
onCommitted?: () => void,
|
|
1672
1935
|
): Promise<string | null> {
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
error: msg,
|
|
1702
|
-
} as ProcessEvent);
|
|
1703
|
-
return msg;
|
|
1936
|
+
const store = this.getStore();
|
|
1937
|
+
const intents = this.intents.get(mount.config.name)!;
|
|
1938
|
+
// The mutation's precondition, enforced here, synchronously with the
|
|
1939
|
+
// commit: whatever settleBeforeMutation's pass could or couldn't settle.
|
|
1940
|
+
settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
|
|
1941
|
+
const known = this.agreementOrThrow().get(mount.config.name, relativePath) !== undefined;
|
|
1942
|
+
if (change.kind === 'set') {
|
|
1943
|
+
store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
|
|
1944
|
+
// A later intent update or disk push can throw; the tree entry is
|
|
1945
|
+
// already set, on the branch current now. If the active branch changes
|
|
1946
|
+
// during the push, that branch's tree has the file and the new one may
|
|
1947
|
+
// not.
|
|
1948
|
+
onCommitted?.();
|
|
1949
|
+
intents.update(relativePath, (cur) => {
|
|
1950
|
+
const next = { ...cur };
|
|
1951
|
+
delete next.tombstone;
|
|
1952
|
+
// Disk has never agreed with this path: a missing disk copy is a draft
|
|
1953
|
+
// not yet materialized, not a deletion.
|
|
1954
|
+
if (!known) next.origin = 'store';
|
|
1955
|
+
return next;
|
|
1956
|
+
});
|
|
1957
|
+
} else {
|
|
1958
|
+
store.treeRemove(mount.treeStateId, relativePath);
|
|
1959
|
+
intents.update(relativePath, (cur) => {
|
|
1960
|
+
const next = { ...cur, tombstone: { hash: change.previousHash } };
|
|
1961
|
+
delete next.origin;
|
|
1962
|
+
return next;
|
|
1963
|
+
});
|
|
1704
1964
|
}
|
|
1965
|
+
if (!mount.config.autoMaterialize || mount.config.mode === 'read-only') return null;
|
|
1966
|
+
|
|
1967
|
+
const pushed = await this.pushUnlocked(mount, [relativePath], {
|
|
1968
|
+
applyDeletions: change.kind === 'remove',
|
|
1969
|
+
branchId: store.currentBranch().id,
|
|
1970
|
+
});
|
|
1971
|
+
const refused = pushed.skipped.find((s) => s.path === relativePath);
|
|
1972
|
+
if (!refused) return null;
|
|
1973
|
+
this.ctx?.pushEvent({
|
|
1974
|
+
type: 'workspace:materialize-failed',
|
|
1975
|
+
mount: mount.config.name,
|
|
1976
|
+
path: relativePath,
|
|
1977
|
+
op: change.kind === 'set' ? 'write' : 'delete',
|
|
1978
|
+
error: refused.reason,
|
|
1979
|
+
} as ProcessEvent);
|
|
1980
|
+
return refused.reason;
|
|
1705
1981
|
}
|
|
1706
1982
|
|
|
1707
1983
|
// ==========================================================================
|
|
@@ -1709,36 +1985,49 @@ export class WorkspaceModule implements Module {
|
|
|
1709
1985
|
// ==========================================================================
|
|
1710
1986
|
|
|
1711
1987
|
/**
|
|
1712
|
-
*
|
|
1988
|
+
* Before a tool reads or changes a path's workspace entry, a pending intent
|
|
1989
|
+
* on that path is settled by a pass over it, so the tool works from what
|
|
1990
|
+
* disk now holds (an edit sees a disk change the settled adoption took in).
|
|
1991
|
+
* The pass may not settle it (a branch that keeps changing, an unreadable
|
|
1992
|
+
* disk copy); the precondition that matters is enforced again,
|
|
1993
|
+
* synchronously, where the change commits (commitToolChange). The caller
|
|
1994
|
+
* holds the mount's turn.
|
|
1713
1995
|
*/
|
|
1714
|
-
private async
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1996
|
+
private async settleBeforeMutation(mount: MountState, relativePath: string): Promise<void> {
|
|
1997
|
+
if (this.agreementOrThrow().get(mount.config.name, relativePath)?.kind !== 'pending') return;
|
|
1998
|
+
await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
|
|
1999
|
+
}
|
|
1718
2000
|
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
2001
|
+
/**
|
|
2002
|
+
* Before a read: a path the workspace doesn't hold is observed under the
|
|
2003
|
+
* three-way rule, which ingests a new disk file but never brings back a
|
|
2004
|
+
* workspace deletion or a file absent from this branch. The caller holds
|
|
2005
|
+
* the mount's turn.
|
|
2006
|
+
*/
|
|
2007
|
+
private async ensureSynced(mount: MountState, relativePath: string): Promise<PathReport | undefined> {
|
|
2008
|
+
const store = this.getStore();
|
|
2009
|
+
if (store.treeGet(mount.treeStateId, relativePath)) return undefined;
|
|
2010
|
+
const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
|
|
2011
|
+
return pass.reports.get(relativePath);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/** Why the workspace holds no entry at a path, when disk explains it. */
|
|
2015
|
+
private notInWorkspace(path: string, report: PathReport | undefined): string {
|
|
2016
|
+
switch (report?.state) {
|
|
2017
|
+
case 'workspace-deleted':
|
|
2018
|
+
return `${path} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
|
|
2019
|
+
case 'not-in-branch':
|
|
2020
|
+
return `${path} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
|
|
2021
|
+
case 'disk-only':
|
|
2022
|
+
return `${path} is on disk but not stored in the workspace (binary or over the size limit)` +
|
|
2023
|
+
(report.mimeType?.startsWith('image/') ? '; read it with read_image.' : '.');
|
|
2024
|
+
case 'conflict':
|
|
2025
|
+
return `${path} is in conflict: the workspace deleted it and disk changed it since. ` +
|
|
2026
|
+
'Sync this path to take the disk version.';
|
|
2027
|
+
case 'unverified':
|
|
2028
|
+
return `${path} is not in the workspace, and disk could not be checked (${report.note ?? 'not observed'}).`;
|
|
2029
|
+
default:
|
|
2030
|
+
return `File not found: ${path}`;
|
|
1742
2031
|
}
|
|
1743
2032
|
}
|
|
1744
2033
|
|
|
@@ -1746,7 +2035,7 @@ export class WorkspaceModule implements Module {
|
|
|
1746
2035
|
// Tool Handlers
|
|
1747
2036
|
// ==========================================================================
|
|
1748
2037
|
|
|
1749
|
-
private async handleRead(input: ReadInput): Promise<ToolResult> {
|
|
2038
|
+
private async handleRead(input: ReadInput, generatedContent?: string): Promise<ToolResult> {
|
|
1750
2039
|
const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
|
|
1751
2040
|
const offsetChars = input.offsetChars ?? 0;
|
|
1752
2041
|
const limitChars = input.limitChars ?? 2000;
|
|
@@ -1760,22 +2049,21 @@ export class WorkspaceModule implements Module {
|
|
|
1760
2049
|
return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' };
|
|
1761
2050
|
}
|
|
1762
2051
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
return { success: false, error:
|
|
2052
|
+
let content = generatedContent;
|
|
2053
|
+
if (content === undefined) {
|
|
2054
|
+
const { mount, relativePath } = this.parsePath(input.path);
|
|
2055
|
+
const store = this.getStore();
|
|
2056
|
+
const found = await this.withMount(mount, async (): Promise<{ text: string } | { error: string }> => {
|
|
2057
|
+
const report = await this.ensureSynced(mount, relativePath);
|
|
2058
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2059
|
+
if (!entry) return { error: this.notInWorkspace(input.path, report) };
|
|
2060
|
+
const blob = store.getBlob(entry.blobHash);
|
|
2061
|
+
if (!blob) return { error: `Blob not found for: ${input.path}` };
|
|
2062
|
+
return { text: blob.toString('utf-8') };
|
|
2063
|
+
});
|
|
2064
|
+
if ('error' in found) return { success: false, error: found.error, isError: true };
|
|
2065
|
+
content = found.text;
|
|
1776
2066
|
}
|
|
1777
|
-
|
|
1778
|
-
const content = blob.toString('utf-8');
|
|
1779
2067
|
if (characterPaging) {
|
|
1780
2068
|
const start = Math.min(offsetChars, content.length);
|
|
1781
2069
|
const splitsPair = (at: number): boolean =>
|
|
@@ -1916,13 +2204,11 @@ export class WorkspaceModule implements Module {
|
|
|
1916
2204
|
}
|
|
1917
2205
|
|
|
1918
2206
|
const buffer = Buffer.from(input.content, 'utf-8');
|
|
1919
|
-
const
|
|
1920
|
-
|
|
1921
|
-
blobHash,
|
|
1922
|
-
size:
|
|
1923
|
-
mode: 0o644,
|
|
2207
|
+
const materializeError = await this.withMount(mount, async () => {
|
|
2208
|
+
await this.settleBeforeMutation(mount, relativePath);
|
|
2209
|
+
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
2210
|
+
return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength });
|
|
1924
2211
|
});
|
|
1925
|
-
const materializeError = await this.autoMaterialize(mount, relativePath, 'write', buffer);
|
|
1926
2212
|
if (materializeError) {
|
|
1927
2213
|
return {
|
|
1928
2214
|
success: false,
|
|
@@ -1948,62 +2234,65 @@ export class WorkspaceModule implements Module {
|
|
|
1948
2234
|
}
|
|
1949
2235
|
|
|
1950
2236
|
const store = this.getStore();
|
|
1951
|
-
|
|
2237
|
+
return this.withMount(mount, async (): Promise<ToolResult> => {
|
|
2238
|
+
const report = await this.ensureSynced(mount, relativePath);
|
|
2239
|
+
await this.settleBeforeMutation(mount, relativePath); // before the edit reads the entry
|
|
1952
2240
|
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2241
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2242
|
+
if (!entry) {
|
|
2243
|
+
return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
|
|
2244
|
+
}
|
|
1957
2245
|
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2246
|
+
const blob = store.getBlob(entry.blobHash);
|
|
2247
|
+
if (!blob) {
|
|
2248
|
+
return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
|
|
2249
|
+
}
|
|
1962
2250
|
|
|
1963
|
-
|
|
2251
|
+
let content = blob.toString('utf-8');
|
|
1964
2252
|
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2253
|
+
// Validate uniqueness
|
|
2254
|
+
if (!input.replaceAll) {
|
|
2255
|
+
const count = content.split(input.oldString).length - 1;
|
|
2256
|
+
if (count === 0) {
|
|
2257
|
+
return { success: false, error: `String not found in ${input.path}`, isError: true };
|
|
2258
|
+
}
|
|
2259
|
+
if (count > 1) {
|
|
2260
|
+
return {
|
|
2261
|
+
success: false,
|
|
2262
|
+
error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.`,
|
|
2263
|
+
isError: true,
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
1970
2266
|
}
|
|
1971
|
-
|
|
2267
|
+
|
|
2268
|
+
content = input.replaceAll
|
|
2269
|
+
? content.replaceAll(input.oldString, input.newString)
|
|
2270
|
+
: content.replace(input.oldString, input.newString);
|
|
2271
|
+
|
|
2272
|
+
const newBuffer = Buffer.from(content, 'utf-8');
|
|
2273
|
+
const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
|
|
2274
|
+
const materializeError = await this.commitToolChange(mount, relativePath, {
|
|
2275
|
+
kind: 'set',
|
|
2276
|
+
blobHash: newBlobHash,
|
|
2277
|
+
size: newBuffer.byteLength,
|
|
2278
|
+
mode: entry.mode,
|
|
2279
|
+
});
|
|
2280
|
+
if (materializeError) {
|
|
1972
2281
|
return {
|
|
1973
2282
|
success: false,
|
|
1974
|
-
error: `
|
|
2283
|
+
error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
|
|
1975
2284
|
isError: true,
|
|
1976
2285
|
};
|
|
1977
2286
|
}
|
|
1978
|
-
}
|
|
1979
|
-
|
|
1980
|
-
content = input.replaceAll
|
|
1981
|
-
? content.replaceAll(input.oldString, input.newString)
|
|
1982
|
-
: content.replace(input.oldString, input.newString);
|
|
1983
2287
|
|
|
1984
|
-
const newBuffer = Buffer.from(content, 'utf-8');
|
|
1985
|
-
const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
|
|
1986
|
-
store.treeSet(mount.treeStateId, relativePath, {
|
|
1987
|
-
blobHash: newBlobHash,
|
|
1988
|
-
size: Buffer.byteLength(content),
|
|
1989
|
-
mode: entry.mode,
|
|
1990
|
-
});
|
|
1991
|
-
const materializeError = await this.autoMaterialize(mount, relativePath, 'write', newBuffer);
|
|
1992
|
-
if (materializeError) {
|
|
1993
2288
|
return {
|
|
1994
|
-
success:
|
|
1995
|
-
|
|
1996
|
-
|
|
2289
|
+
success: true,
|
|
2290
|
+
data: {
|
|
2291
|
+
path: input.path,
|
|
2292
|
+
size: newBuffer.byteLength,
|
|
2293
|
+
},
|
|
1997
2294
|
};
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
return {
|
|
2001
|
-
success: true,
|
|
2002
|
-
data: {
|
|
2003
|
-
path: input.path,
|
|
2004
|
-
size: Buffer.byteLength(content),
|
|
2005
|
-
},
|
|
2006
|
-
};
|
|
2295
|
+
});
|
|
2007
2296
|
}
|
|
2008
2297
|
|
|
2009
2298
|
private async handleDelete(input: DeleteInput): Promise<ToolResult> {
|
|
@@ -2013,22 +2302,28 @@ export class WorkspaceModule implements Module {
|
|
|
2013
2302
|
}
|
|
2014
2303
|
|
|
2015
2304
|
const store = this.getStore();
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2305
|
+
return this.withMount(mount, async (): Promise<ToolResult> => {
|
|
2306
|
+
const report = await this.ensureSynced(mount, relativePath);
|
|
2307
|
+
await this.settleBeforeMutation(mount, relativePath);
|
|
2308
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2309
|
+
if (!entry) {
|
|
2310
|
+
return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
|
|
2311
|
+
}
|
|
2020
2312
|
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2313
|
+
const materializeError = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash });
|
|
2314
|
+
if (materializeError) {
|
|
2315
|
+
return {
|
|
2316
|
+
success: false,
|
|
2317
|
+
error: `Removed from Chronicle but did not unlink "${input.path}" from disk: ${materializeError}. ` +
|
|
2318
|
+
'The disk copy stays (ls shows its state) until materialize with applyDeletions removes it.',
|
|
2319
|
+
isError: true,
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2030
2322
|
|
|
2031
|
-
|
|
2323
|
+
// Its whole receipt is what it changed: kept in the stub if the guard
|
|
2324
|
+
// withholds the result (agent-framework #277).
|
|
2325
|
+
return { success: true, data: { path: input.path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
|
|
2326
|
+
});
|
|
2032
2327
|
}
|
|
2033
2328
|
|
|
2034
2329
|
private async handleLs(input: LsInput): Promise<ToolResult> {
|
|
@@ -2045,63 +2340,55 @@ export class WorkspaceModule implements Module {
|
|
|
2045
2340
|
}
|
|
2046
2341
|
|
|
2047
2342
|
const { mount, relativePath } = this.parsePath(input.path);
|
|
2343
|
+
const recursive = input.recursive === true;
|
|
2048
2344
|
|
|
2049
|
-
//
|
|
2050
|
-
//
|
|
2051
|
-
|
|
2052
|
-
await
|
|
2053
|
-
|
|
2054
|
-
|
|
2345
|
+
// Every listing checks the paths it shows against disk first, under the
|
|
2346
|
+
// three-way rule, whatever the mount's watch mode.
|
|
2347
|
+
return this.withMount(mount, async (): Promise<ToolResult> => {
|
|
2348
|
+
const pass = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive });
|
|
2349
|
+
const reports = [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
2350
|
+
const incomplete = pass.incomplete.length > 0 ? { incomplete: pass.incomplete } : {};
|
|
2055
2351
|
|
|
2056
|
-
|
|
2057
|
-
|
|
2352
|
+
if (recursive) {
|
|
2353
|
+
return {
|
|
2354
|
+
success: true,
|
|
2355
|
+
data: {
|
|
2356
|
+
path: input.path,
|
|
2357
|
+
entries: reports.map((r) => ({ path: r.path, ...stateView(r) })),
|
|
2358
|
+
count: reports.length,
|
|
2359
|
+
...incomplete,
|
|
2360
|
+
},
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2058
2363
|
|
|
2059
|
-
|
|
2364
|
+
// Non-recursive: the scope's files, and the directories beneath it that
|
|
2365
|
+
// disk or the workspace has.
|
|
2366
|
+
const prefix = relativePath ? relativePath + '/' : '';
|
|
2367
|
+
const dirNames = new Set(pass.dirs.map((d) => d.slice(prefix.length)));
|
|
2368
|
+
for (const entry of store.treeList(mount.treeStateId, prefix || undefined)) {
|
|
2369
|
+
const slash = entry.path.indexOf('/', prefix.length);
|
|
2370
|
+
if (slash >= 0) dirNames.add(entry.path.slice(prefix.length, slash));
|
|
2371
|
+
}
|
|
2372
|
+
const children: Array<Record<string, unknown>> = [
|
|
2373
|
+
...[...dirNames].sort().map((name) => ({ name, type: 'directory' })),
|
|
2374
|
+
...reports.map((r) => ({ name: r.path.slice(prefix.length), type: 'file', ...stateView(r) })),
|
|
2375
|
+
];
|
|
2060
2376
|
return {
|
|
2061
2377
|
success: true,
|
|
2062
2378
|
data: {
|
|
2063
2379
|
path: input.path,
|
|
2064
|
-
entries:
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
})),
|
|
2068
|
-
count: entries.length,
|
|
2380
|
+
entries: children,
|
|
2381
|
+
count: children.length,
|
|
2382
|
+
...incomplete,
|
|
2069
2383
|
},
|
|
2070
2384
|
};
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
// Non-recursive: deduplicate to show immediate children only
|
|
2074
|
-
const seen = new Set<string>();
|
|
2075
|
-
const children: Array<{ name: string; type: 'file' | 'directory' }> = [];
|
|
2076
|
-
|
|
2077
|
-
for (const entry of entries) {
|
|
2078
|
-
const rest = entry.path.slice(prefix.length);
|
|
2079
|
-
const slashIdx = rest.indexOf('/');
|
|
2080
|
-
if (slashIdx >= 0) {
|
|
2081
|
-
const dirName = rest.slice(0, slashIdx);
|
|
2082
|
-
if (!seen.has(dirName)) {
|
|
2083
|
-
seen.add(dirName);
|
|
2084
|
-
children.push({ name: dirName, type: 'directory' });
|
|
2085
|
-
}
|
|
2086
|
-
} else {
|
|
2087
|
-
children.push({ name: rest, type: 'file' });
|
|
2088
|
-
}
|
|
2089
|
-
}
|
|
2090
|
-
|
|
2091
|
-
return {
|
|
2092
|
-
success: true,
|
|
2093
|
-
data: {
|
|
2094
|
-
path: input.path,
|
|
2095
|
-
entries: children,
|
|
2096
|
-
count: children.length,
|
|
2097
|
-
},
|
|
2098
|
-
};
|
|
2385
|
+
});
|
|
2099
2386
|
}
|
|
2100
2387
|
|
|
2101
2388
|
private async handleGlob(input: GlobInput): Promise<ToolResult> {
|
|
2102
|
-
const store = this.getStore();
|
|
2103
2389
|
const regex = globToRegex(input.pattern);
|
|
2104
|
-
const matches: string
|
|
2390
|
+
const matches: Array<Record<string, unknown>> = [];
|
|
2391
|
+
const incomplete: Array<{ path: string; reason: string }> = [];
|
|
2105
2392
|
|
|
2106
2393
|
// Search across mounts
|
|
2107
2394
|
const mountsToSearch = input.path
|
|
@@ -2109,20 +2396,17 @@ export class WorkspaceModule implements Module {
|
|
|
2109
2396
|
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
|
|
2110
2397
|
|
|
2111
2398
|
for (const { mount, relativePath } of mountsToSearch) {
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
const prefix = relativePath ? relativePath + '/' : undefined;
|
|
2118
|
-
const entries = store.treeList(mount.treeStateId, prefix);
|
|
2119
|
-
|
|
2120
|
-
for (const entry of entries) {
|
|
2121
|
-
const testPath = relativePath ? entry.path.slice(relativePath.length + 1) : entry.path;
|
|
2399
|
+
const pass = await this.withMount(mount, () =>
|
|
2400
|
+
this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }));
|
|
2401
|
+
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
2402
|
+
const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path;
|
|
2122
2403
|
if (regex.test(testPath)) {
|
|
2123
|
-
matches.push(`${mount.config.name}/${
|
|
2404
|
+
matches.push({ path: `${mount.config.name}/${report.path}`, ...stateView(report) });
|
|
2124
2405
|
}
|
|
2125
2406
|
}
|
|
2407
|
+
for (const region of pass.incomplete) {
|
|
2408
|
+
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
2409
|
+
}
|
|
2126
2410
|
}
|
|
2127
2411
|
|
|
2128
2412
|
return {
|
|
@@ -2131,6 +2415,7 @@ export class WorkspaceModule implements Module {
|
|
|
2131
2415
|
pattern: input.pattern,
|
|
2132
2416
|
matches,
|
|
2133
2417
|
count: matches.length,
|
|
2418
|
+
...(incomplete.length > 0 ? { incomplete } : {}),
|
|
2134
2419
|
},
|
|
2135
2420
|
};
|
|
2136
2421
|
}
|
|
@@ -2152,61 +2437,107 @@ export class WorkspaceModule implements Module {
|
|
|
2152
2437
|
? [this.parsePath(input.path)]
|
|
2153
2438
|
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
|
|
2154
2439
|
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
} else {
|
|
2174
|
-
const prefix = relativePath ? relativePath + '/' : undefined;
|
|
2175
|
-
entries = store.treeList(mount.treeStateId, prefix);
|
|
2440
|
+
type GrepMatch = { line: number; text: string; context?: string[] };
|
|
2441
|
+
const results: Array<{ file: string; state: string; version: 'workspace' | 'conflicting-disk' | 'recorded-disk'; matches: GrepMatch[] }> = [];
|
|
2442
|
+
const skipped: Array<{ file: string; reason: string; size?: number; mimeType?: string }> = [];
|
|
2443
|
+
const incomplete: Array<{ path: string; reason: string }> = [];
|
|
2444
|
+
|
|
2445
|
+
const search = (content: string): GrepMatch[] => {
|
|
2446
|
+
const lines = content.split('\n');
|
|
2447
|
+
const found: GrepMatch[] = [];
|
|
2448
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2449
|
+
if (regex.test(lines[i])) {
|
|
2450
|
+
const match: GrepMatch = { line: i + 1, text: lines[i] };
|
|
2451
|
+
if (contextBefore > 0 || contextAfter > 0) {
|
|
2452
|
+
const start = Math.max(0, i - contextBefore);
|
|
2453
|
+
const end = Math.min(lines.length, i + contextAfter + 1);
|
|
2454
|
+
match.context = lines.slice(start, end);
|
|
2455
|
+
}
|
|
2456
|
+
found.push(match);
|
|
2457
|
+
}
|
|
2176
2458
|
}
|
|
2459
|
+
return found;
|
|
2460
|
+
};
|
|
2177
2461
|
|
|
2178
|
-
|
|
2179
|
-
|
|
2462
|
+
for (const { mount, relativePath } of mountsToSearch) {
|
|
2463
|
+
await this.withMount(mount, async () => {
|
|
2464
|
+
// A `path` naming a single file greps just that file; otherwise it is a
|
|
2465
|
+
// directory prefix. (A file path once produced the prefix "notes.md/"
|
|
2466
|
+
// and silently matched nothing.)
|
|
2467
|
+
const fileScope = relativePath !== '' && (
|
|
2468
|
+
store.treeGet(mount.treeStateId, relativePath) !== null ||
|
|
2469
|
+
await lstat(join(mount.config.path, relativePath)).then((s) => !s.isDirectory(), () => false)
|
|
2470
|
+
);
|
|
2471
|
+
// What follows reads the tree and branch intent to go with the pass's
|
|
2472
|
+
// reports, synchronously. They must be the branch the pass decided
|
|
2473
|
+
// on: one selected in between would label its content with the other
|
|
2474
|
+
// branch's states. So a pass whose branch is no longer selected runs
|
|
2475
|
+
// again, and a branch that keeps changing leaves the scope unsearched.
|
|
2476
|
+
let pass: PassResult | null = null;
|
|
2477
|
+
for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
|
|
2478
|
+
const next = await this.passUnlocked(mount, fileScope
|
|
2479
|
+
? { kind: 'paths', paths: [relativePath] }
|
|
2480
|
+
: { kind: 'dir', dir: relativePath, recursive: true });
|
|
2481
|
+
if (next.branchId === undefined || next.branchId === store.currentBranch().id) pass = next;
|
|
2482
|
+
}
|
|
2483
|
+
if (pass === null) {
|
|
2484
|
+
incomplete.push({
|
|
2485
|
+
path: `${mount.config.name}/${relativePath}`,
|
|
2486
|
+
reason: 'the selected branch kept changing while grep ran; nothing here was searched — grep again',
|
|
2487
|
+
});
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
for (const region of pass.incomplete) {
|
|
2491
|
+
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
2492
|
+
}
|
|
2180
2493
|
|
|
2181
|
-
const
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2494
|
+
const intents = this.intents.get(mount.config.name)!;
|
|
2495
|
+
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
2496
|
+
if (fileGlob && !fileGlob.test(report.path)) continue;
|
|
2497
|
+
const file = `${mount.config.name}/${report.path}`;
|
|
2498
|
+
if (report.state === 'disk-only') {
|
|
2499
|
+
skipped.push({
|
|
2500
|
+
file,
|
|
2501
|
+
reason: 'disk-only: binary or over the size limit, never stored',
|
|
2502
|
+
...(report.size !== undefined ? { size: report.size } : {}),
|
|
2503
|
+
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
2504
|
+
});
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
// The workspace's version, labelled with the path's state.
|
|
2508
|
+
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
2509
|
+
const blob = entry ? store.getBlob(entry.blobHash) : null;
|
|
2510
|
+
if (blob) {
|
|
2511
|
+
const found = search(blob.toString('utf-8'));
|
|
2512
|
+
if (found.length > 0) results.push({ file, state: report.state, version: 'workspace', matches: found });
|
|
2513
|
+
} else if (entry) {
|
|
2514
|
+
skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
|
|
2515
|
+
}
|
|
2516
|
+
if (report.state !== 'conflict') continue;
|
|
2517
|
+
// A conflict's disk side. The version recorded with the conflict is
|
|
2518
|
+
// disk's only while disk still holds it; once disk has changed
|
|
2519
|
+
// since, it is labelled as recorded, and a file disk holds now
|
|
2520
|
+
// (never stored) is listed as not searched. A binary or oversize
|
|
2521
|
+
// disk version was never stored either.
|
|
2522
|
+
const disk = intents.get(report.path)?.conflict?.disk;
|
|
2523
|
+
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
2524
|
+
const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
|
|
2525
|
+
if (diskBlob) {
|
|
2526
|
+
const found = search(diskBlob.toString('utf-8'));
|
|
2527
|
+
if (found.length > 0) results.push({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', matches: found });
|
|
2528
|
+
}
|
|
2529
|
+
if (disk?.stored && !diskBlob) {
|
|
2530
|
+
skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
|
|
2531
|
+
}
|
|
2532
|
+
if (changed) {
|
|
2533
|
+
if (report.diskNow === 'file') {
|
|
2534
|
+
skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' });
|
|
2198
2535
|
}
|
|
2199
|
-
|
|
2536
|
+
} else if (disk && !disk.stored) {
|
|
2537
|
+
skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
|
|
2200
2538
|
}
|
|
2201
2539
|
}
|
|
2202
|
-
|
|
2203
|
-
if (fileMatches.length > 0) {
|
|
2204
|
-
results.push({
|
|
2205
|
-
file: `${mount.config.name}/${entry.path}`,
|
|
2206
|
-
matches: fileMatches,
|
|
2207
|
-
});
|
|
2208
|
-
}
|
|
2209
|
-
}
|
|
2540
|
+
});
|
|
2210
2541
|
}
|
|
2211
2542
|
|
|
2212
2543
|
return {
|
|
@@ -2215,20 +2546,33 @@ export class WorkspaceModule implements Module {
|
|
|
2215
2546
|
pattern: input.pattern,
|
|
2216
2547
|
results,
|
|
2217
2548
|
totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
|
|
2549
|
+
...(skipped.length > 0 ? { skipped } : {}),
|
|
2550
|
+
...(incomplete.length > 0 ? { incomplete } : {}),
|
|
2218
2551
|
},
|
|
2219
2552
|
};
|
|
2220
2553
|
}
|
|
2221
2554
|
|
|
2222
|
-
private async handleStatus(
|
|
2555
|
+
private async handleStatus(input: StatusInput): Promise<ToolResult> {
|
|
2223
2556
|
const store = this.getStore();
|
|
2224
2557
|
const status: Record<string, unknown> = {};
|
|
2558
|
+
if (input.mount !== undefined && !this.mounts.has(input.mount)) {
|
|
2559
|
+
return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
|
|
2560
|
+
}
|
|
2225
2561
|
|
|
2226
2562
|
for (const [name, mount] of this.mounts) {
|
|
2563
|
+
if (input.mount !== undefined && name !== input.mount) continue;
|
|
2227
2564
|
const entries = store.treeList(mount.treeStateId);
|
|
2228
2565
|
const currentSeq = store.currentSequence();
|
|
2229
|
-
const
|
|
2230
|
-
|
|
2231
|
-
|
|
2566
|
+
const intents = this.intents.get(name)!.list();
|
|
2567
|
+
const conflictPaths = intents.filter(([, bi]) => bi.conflict).map(([p]) => p);
|
|
2568
|
+
// A workspace deletion is pending until a push confirms it on disk:
|
|
2569
|
+
// left there without applyDeletions, or unlinked but unconfirmed.
|
|
2570
|
+
const deletionPaths = intents.filter(([, bi]) => bi.tombstone).map(([p]) => p);
|
|
2571
|
+
// Pending is what disk owes by the evidence: every entry it doesn't
|
|
2572
|
+
// show on disk (a refused or failed push stays owed), conflicts and
|
|
2573
|
+
// deletions. An entry from before the evidence isn't counted until a
|
|
2574
|
+
// listing or a materialize checks it: unchecked isn't unpushed.
|
|
2575
|
+
const pending = new Set([...conflictPaths, ...deletionPaths, ...this.entriesByEvidence(mount).owed]);
|
|
2232
2576
|
|
|
2233
2577
|
const currentBranch = store.currentBranch();
|
|
2234
2578
|
status[name] = {
|
|
@@ -2238,24 +2582,90 @@ export class WorkspaceModule implements Module {
|
|
|
2238
2582
|
fileCount: entries.length,
|
|
2239
2583
|
lastMaterializedSeq: mount.lastMaterializedSeq,
|
|
2240
2584
|
currentSeq,
|
|
2241
|
-
pendingChanges:
|
|
2585
|
+
pendingChanges: pending.size,
|
|
2586
|
+
conflicts: conflictPaths.length,
|
|
2587
|
+
pendingWorkspaceDeletions: deletionPaths.length,
|
|
2242
2588
|
initialSyncDone: mount.initialSyncDone,
|
|
2243
2589
|
currentBranch: currentBranch.name,
|
|
2244
2590
|
lastMaterializedBranch: mount.lastMaterializedBranchId,
|
|
2245
2591
|
canMaterialize: this.mountMaterializeBlockReason(store, mount) === null,
|
|
2592
|
+
...(mount.lastAgentActionScan ? { lastAgentActionScan: mount.lastAgentActionScan } : {}),
|
|
2246
2593
|
};
|
|
2247
2594
|
}
|
|
2248
2595
|
|
|
2249
2596
|
return { success: true, data: status };
|
|
2250
2597
|
}
|
|
2251
2598
|
|
|
2599
|
+
/**
|
|
2600
|
+
* What a materialize of `mount` should consider: a named file, or every
|
|
2601
|
+
* tracked path beneath a named directory; otherwise what the evidence says
|
|
2602
|
+
* disk still owes, the entries never checked against disk, recorded
|
|
2603
|
+
* conflicts, and workspace deletions still on disk (applied with
|
|
2604
|
+
* `applyDeletions`, listed without it). The watermark plays no part: it is
|
|
2605
|
+
* lost at an unclean restart, and an entry the evidence shows on disk has
|
|
2606
|
+
* nothing to push. If disk changed since, a sync adopts that; a bare
|
|
2607
|
+
* materialize, forced or not, never reverts it (naming the path with force
|
|
2608
|
+
* does). `inPlace` says whether any tree entry was left out for that reason.
|
|
2609
|
+
*/
|
|
2610
|
+
private materializeSelection(mount: MountState, explicitPath: string): { paths: string[]; inPlace: boolean } {
|
|
2611
|
+
const store = this.getStore();
|
|
2612
|
+
const intents = new Map(this.intents.get(mount.config.name)!.list(explicitPath));
|
|
2613
|
+
const selected = new Set<string>();
|
|
2614
|
+
|
|
2615
|
+
if (explicitPath !== '') {
|
|
2616
|
+
if (store.treeGet(mount.treeStateId, explicitPath)) selected.add(explicitPath);
|
|
2617
|
+
for (const e of store.treeList(mount.treeStateId, explicitPath + '/')) selected.add(e.path);
|
|
2618
|
+
for (const [p, bi] of intents) if (bi.tombstone || bi.conflict) selected.add(p);
|
|
2619
|
+
return { paths: [...selected], inPlace: false };
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
const { owed, unchecked, agreed } = this.entriesByEvidence(mount);
|
|
2623
|
+
for (const path of [...owed, ...unchecked]) selected.add(path);
|
|
2624
|
+
for (const [p, bi] of intents) if (bi.conflict || bi.tombstone) selected.add(p);
|
|
2625
|
+
return { paths: [...selected], inPlace: agreed > 0 };
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/**
|
|
2629
|
+
* The workspace entries by what the evidence says of disk. `owed`: P
|
|
2630
|
+
* differs from the entry (a draft, or a push still pending), or there is no
|
|
2631
|
+
* P and the entry came from the workspace (a draft disk never had).
|
|
2632
|
+
* `unchecked`: no P and no workspace origin, an entry from before the
|
|
2633
|
+
* evidence; a push agrees it, writes it where disk lacks it, or refuses it
|
|
2634
|
+
* as a conflict. `agreed`: how many have P = S, so disk holds them. Status
|
|
2635
|
+
* counts the owed as pending; a materialize without a path takes up the
|
|
2636
|
+
* owed and the unchecked.
|
|
2637
|
+
*/
|
|
2638
|
+
private entriesByEvidence(mount: MountState): { owed: string[]; unchecked: string[]; agreed: number } {
|
|
2639
|
+
const store = this.getStore();
|
|
2640
|
+
const agreement = this.agreementOrThrow();
|
|
2641
|
+
const name = mount.config.name;
|
|
2642
|
+
const intents = this.intents.get(name)!;
|
|
2643
|
+
const owed: string[] = [];
|
|
2644
|
+
const unchecked: string[] = [];
|
|
2645
|
+
let agreed = 0;
|
|
2646
|
+
for (const e of store.treeList(mount.treeStateId)) {
|
|
2647
|
+
const p = agreement.get(name, e.path);
|
|
2648
|
+
if (p === undefined) (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
|
|
2649
|
+
else if (p.kind !== 'content' || p.hash !== e.blobHash) owed.push(e.path);
|
|
2650
|
+
else agreed++;
|
|
2651
|
+
}
|
|
2652
|
+
return { owed, unchecked, agreed };
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2252
2655
|
private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
|
|
2253
2656
|
const store = this.getStore();
|
|
2254
2657
|
|
|
2255
2658
|
const allWritten: Array<{ mount: string; path: string }> = [];
|
|
2659
|
+
const allDeleted: Array<{ mount: string; path: string }> = [];
|
|
2660
|
+
const pendingDeletions: Array<{ mount: string; path: string }> = [];
|
|
2661
|
+
|
|
2662
|
+
let explicit: { mount: MountState; relativePath: string } | null = null;
|
|
2663
|
+
if (input.path) explicit = this.parsePath(input.path);
|
|
2256
2664
|
|
|
2257
2665
|
let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
|
|
2258
|
-
if (
|
|
2666
|
+
if (explicit) {
|
|
2667
|
+
mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
|
|
2668
|
+
} else if (input.mount) {
|
|
2259
2669
|
const m = this.mounts.get(input.mount);
|
|
2260
2670
|
if (!m) {
|
|
2261
2671
|
return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
|
|
@@ -2270,67 +2680,100 @@ export class WorkspaceModule implements Module {
|
|
|
2270
2680
|
// Branch guard, scoped to the mounts actually being materialized: a
|
|
2271
2681
|
// linear continuation (current branch descends from the pinned branch at
|
|
2272
2682
|
// or after the pinned seq) passes; genuine divergence refuses unless
|
|
2273
|
-
// force. One mount's stale pin must never block another mount.
|
|
2683
|
+
// force. One mount's stale pin must never block another mount. It is
|
|
2684
|
+
// judged inside the mount's turn, on the same branch the selection is
|
|
2685
|
+
// made on: a materialize queued behind a pass would otherwise select on
|
|
2686
|
+
// whatever branch is current when its turn comes, unguarded. The push
|
|
2687
|
+
// then writes nothing if the branch changes after that.
|
|
2274
2688
|
const blocked: Array<{ mount: string; reason: string }> = [];
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
if (!reason) continue;
|
|
2278
|
-
if (input.force) {
|
|
2279
|
-
// Disk reflects another line of history, so an incremental diff from
|
|
2280
|
-
// the pinned seq is meaningless — reset tracking and re-materialize
|
|
2281
|
-
// the full tree, exactly like materializeMount() after a deliberate
|
|
2282
|
-
// branch switch.
|
|
2283
|
-
mount.lastMaterializedSeq = 0;
|
|
2284
|
-
mount.lastMaterializedBranchId = null;
|
|
2285
|
-
} else {
|
|
2286
|
-
blocked.push({ mount: name, reason });
|
|
2287
|
-
}
|
|
2288
|
-
}
|
|
2289
|
-
mountsToMaterialize = mountsToMaterialize.filter(
|
|
2290
|
-
({ name }) => !blocked.some((b) => b.mount === name),
|
|
2291
|
-
);
|
|
2292
|
-
if (mountsToMaterialize.length === 0 && blocked.length > 0) {
|
|
2293
|
-
return {
|
|
2294
|
-
success: false,
|
|
2295
|
-
error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
|
|
2296
|
-
isError: true,
|
|
2297
|
-
};
|
|
2298
|
-
}
|
|
2689
|
+
const guarded: string[] = [];
|
|
2690
|
+
let proceeded = 0;
|
|
2299
2691
|
|
|
2300
2692
|
for (const { name, mount } of mountsToMaterialize) {
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2693
|
+
if (mount.config.mode === 'read-only') {
|
|
2694
|
+
// Only a mount the caller named gets here read-only (a bare
|
|
2695
|
+
// materialize takes the read-write ones): say why nothing was written.
|
|
2696
|
+
blocked.push({ mount: name, reason: 'a read-only mount: materialize writes nothing to it' });
|
|
2697
|
+
continue;
|
|
2306
2698
|
}
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2699
|
+
const turn = await this.withMount(mount, async (): Promise<{ blocked: string } | { pushed: PushResult; inPlace: boolean }> => {
|
|
2700
|
+
const branchId = store.currentBranch().id;
|
|
2701
|
+
const reason = this.mountMaterializeBlockReason(store, mount);
|
|
2702
|
+
// Force passes a divergence. The selection follows the evidence, which
|
|
2703
|
+
// is per path and kept across branches, so nothing needs resetting:
|
|
2704
|
+
// the push re-pins to this branch.
|
|
2705
|
+
if (reason && !input.force) return { blocked: reason };
|
|
2706
|
+
const { paths, inPlace } = this.materializeSelection(mount, explicit?.relativePath ?? '');
|
|
2707
|
+
return {
|
|
2708
|
+
inPlace,
|
|
2709
|
+
pushed: await this.pushUnlocked(mount, paths, {
|
|
2710
|
+
force: input.force, named: explicit !== null, applyDeletions: input.applyDeletions, branchId,
|
|
2711
|
+
}),
|
|
2712
|
+
};
|
|
2713
|
+
});
|
|
2714
|
+
if ('blocked' in turn) {
|
|
2715
|
+
blocked.push({ mount: name, reason: turn.blocked });
|
|
2716
|
+
guarded.push(name);
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
proceeded++;
|
|
2720
|
+
const pushed = turn.pushed;
|
|
2721
|
+
|
|
2722
|
+
for (const p of pushed.written) allWritten.push({ mount: name, path: p });
|
|
2723
|
+
for (const p of pushed.deleted) allDeleted.push({ mount: name, path: p });
|
|
2724
|
+
for (const p of pushed.pendingDeletions) pendingDeletions.push({ mount: name, path: p });
|
|
2725
|
+
// Refusals ride the same skipped list as branch blocks: divergence must
|
|
2726
|
+
// be visible, not resolved silently either way.
|
|
2727
|
+
for (const s of pushed.skipped) {
|
|
2728
|
+
blocked.push({ mount: name, reason: `${s.path}: ${s.reason}` });
|
|
2315
2729
|
}
|
|
2316
2730
|
|
|
2731
|
+
// What disk still owes is kept by its evidence, so the watermark and
|
|
2732
|
+
// the pin only feed the branch guard. Both are the push's own: what it
|
|
2733
|
+
// planned from is what disk now reflects, whatever branch was selected
|
|
2734
|
+
// while it wrote. A push that planned nothing (the selected branch
|
|
2735
|
+
// changed after its paths were chosen) left disk as it was, so it
|
|
2736
|
+
// leaves both as they were too.
|
|
2737
|
+
if (pushed.branchId === undefined || pushed.sequence === undefined) continue;
|
|
2738
|
+
mount.lastMaterializedSeq = pushed.sequence;
|
|
2317
2739
|
// Track which branch we materialized on. Re-pin on a clean empty
|
|
2318
2740
|
// materialize too (previously-pinned mount, nothing pending): disk
|
|
2319
2741
|
// already reflects the current branch's tree, and leaving the old pin
|
|
2320
2742
|
// would keep force required forever after a cross-branch materialize
|
|
2321
|
-
// that happened to write nothing.
|
|
2322
|
-
|
|
2323
|
-
|
|
2743
|
+
// that happened to write nothing. A first materialize pins once files
|
|
2744
|
+
// of this branch are in place, whether it found them on disk or the
|
|
2745
|
+
// evidence shows them there: disk holds this branch's tree.
|
|
2746
|
+
if (pushed.written.length > 0 || pushed.unchanged.length > 0 || turn.inPlace || mount.lastMaterializedBranchId !== null) {
|
|
2747
|
+
mount.lastMaterializedBranchId = pushed.branchId;
|
|
2324
2748
|
}
|
|
2325
2749
|
}
|
|
2326
2750
|
|
|
2751
|
+
if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
|
|
2752
|
+
return {
|
|
2753
|
+
success: false,
|
|
2754
|
+
error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
|
|
2755
|
+
isError: true,
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2327
2759
|
return {
|
|
2328
2760
|
success: true,
|
|
2329
2761
|
data: {
|
|
2330
2762
|
materialized: allWritten,
|
|
2331
2763
|
count: allWritten.length,
|
|
2764
|
+
...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
|
|
2765
|
+
...(pendingDeletions.length > 0
|
|
2766
|
+
? {
|
|
2767
|
+
workspaceDeletionsLeftOnDisk: pendingDeletions,
|
|
2768
|
+
note: 'Files deleted in the workspace were left on disk; materialize with applyDeletions to remove them.',
|
|
2769
|
+
}
|
|
2770
|
+
: {}),
|
|
2332
2771
|
...(blocked.length > 0 ? { skipped: blocked } : {}),
|
|
2333
2772
|
},
|
|
2773
|
+
// What disk lost, then what it was written: kept in the stub if the
|
|
2774
|
+
// guard withholds the result (agent-framework #277). What was left or
|
|
2775
|
+
// skipped, and why, is in the full result.
|
|
2776
|
+
keepWhenWithheld: ['deleted', 'materialized'],
|
|
2334
2777
|
};
|
|
2335
2778
|
}
|
|
2336
2779
|
|
|
@@ -2340,6 +2783,7 @@ export class WorkspaceModule implements Module {
|
|
|
2340
2783
|
* Resets branch tracking to allow cross-branch materialization.
|
|
2341
2784
|
*/
|
|
2342
2785
|
async materializeMount(mountName: string): Promise<string[]> {
|
|
2786
|
+
if (!this.running()) return [];
|
|
2343
2787
|
const store = this.getStore();
|
|
2344
2788
|
const mount = this.mounts.get(mountName);
|
|
2345
2789
|
if (!mount || mount.config.mode === 'read-only') return [];
|
|
@@ -2348,57 +2792,136 @@ export class WorkspaceModule implements Module {
|
|
|
2348
2792
|
mount.lastMaterializedBranchId = null;
|
|
2349
2793
|
mount.lastMaterializedSeq = 0;
|
|
2350
2794
|
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
mount
|
|
2795
|
+
// force: this path only runs after a deliberate undo/redo/branch switch
|
|
2796
|
+
// on the framework's own _config mount — restoring disk to the branch
|
|
2797
|
+
// state IS the operator intent, so the freshness guard yields. named:
|
|
2798
|
+
// the whole tree is that deliberate scope, entries without evidence too.
|
|
2799
|
+
const pushed = await this.withMount(mount, () => {
|
|
2800
|
+
const branchId = store.currentBranch().id;
|
|
2801
|
+
return this.pushUnlocked(mount, store.treeList(mount.treeStateId).map((e) => e.path), { force: true, named: true, branchId });
|
|
2802
|
+
});
|
|
2803
|
+
// A push that planned nothing leaves the reset tracking as it is.
|
|
2804
|
+
if (pushed.branchId !== undefined && pushed.sequence !== undefined) {
|
|
2805
|
+
mount.lastMaterializedSeq = pushed.sequence;
|
|
2806
|
+
if (pushed.written.length > 0 || pushed.unchanged.length > 0) {
|
|
2807
|
+
mount.lastMaterializedBranchId = pushed.branchId;
|
|
2808
|
+
}
|
|
2358
2809
|
}
|
|
2359
|
-
return written;
|
|
2810
|
+
return pushed.written;
|
|
2360
2811
|
}
|
|
2361
2812
|
|
|
2362
2813
|
private async handleSync(input: SyncInput): Promise<ToolResult> {
|
|
2363
2814
|
const store = this.getStore();
|
|
2364
|
-
|
|
2815
|
+
// A path sync adopts on the branch it was asked on, read before any await.
|
|
2816
|
+
const askedOn = store.currentBranch().id;
|
|
2817
|
+
const allResults: Array<{
|
|
2818
|
+
mount: string;
|
|
2819
|
+
synced: string[];
|
|
2820
|
+
conflicts: Array<{ path: string; kind: string; diskCopy: string }>;
|
|
2821
|
+
/** A path sync's workspace changes given up for disk's state, each with the state it had,
|
|
2822
|
+
* and what that did to the workspace copy (absent when nothing). */
|
|
2823
|
+
discarded?: Array<{ path: string; was: string; op?: string }>;
|
|
2824
|
+
}> = [];
|
|
2365
2825
|
const allSkipped: Array<{ mount: string; path: string; reason: string }> = [];
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2826
|
+
const allIncomplete: Array<{ mount: string; path: string; reason: string }> = [];
|
|
2827
|
+
const allPassedOver: Array<{ mount: string; reason: string; count: number }> = [];
|
|
2828
|
+
const allIgnored: Array<{ mount: string; path: string; note: string }> = [];
|
|
2829
|
+
const rootsAccepted: string[] = [];
|
|
2830
|
+
if (input.acceptRoot && input.path) {
|
|
2831
|
+
return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// A path names its own mount; otherwise the given mount, or every mount.
|
|
2835
|
+
let targets: Array<{ name: string; mount: MountState; relativePath: string }>;
|
|
2836
|
+
if (input.path) {
|
|
2837
|
+
const { mount, relativePath } = this.parsePath(input.path);
|
|
2838
|
+
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
2839
|
+
} else if (input.mount) {
|
|
2369
2840
|
const m = this.mounts.get(input.mount);
|
|
2370
2841
|
if (!m) {
|
|
2371
2842
|
return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
|
|
2372
2843
|
}
|
|
2373
|
-
|
|
2844
|
+
targets = [{ name: input.mount, mount: m, relativePath: '' }];
|
|
2374
2845
|
} else {
|
|
2375
|
-
|
|
2846
|
+
targets = [...this.mounts.entries()].map(([name, mount]) => ({ name, mount, relativePath: '' }));
|
|
2376
2847
|
}
|
|
2377
2848
|
|
|
2378
|
-
for (const { name, mount } of
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2849
|
+
for (const { name, mount, relativePath } of targets) {
|
|
2850
|
+
const pass = await this.withMount(mount, async () => {
|
|
2851
|
+
if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
|
|
2852
|
+
if (!relativePath) {
|
|
2853
|
+
// A full recheck: every file rehashed, nothing pending discarded.
|
|
2854
|
+
return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true }, { rehash: true });
|
|
2855
|
+
}
|
|
2856
|
+
// An explicit path: the workspace takes disk's state there.
|
|
2857
|
+
const prefix = relativePath + '/';
|
|
2858
|
+
const isDir =
|
|
2859
|
+
await lstat(join(mount.config.path, relativePath)).then((s) => s.isDirectory(), () => false) ||
|
|
2860
|
+
store.treeList(mount.treeStateId, prefix).length > 0 ||
|
|
2861
|
+
this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
|
|
2862
|
+
this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
|
|
2863
|
+
// named: ignore patterns covering the path asked for don't apply
|
|
2864
|
+
// beneath it (a directory scope's walk; a path scope doesn't walk).
|
|
2865
|
+
const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
|
|
2866
|
+
if (!isDir) return this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
|
|
2867
|
+
const beneath = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }, opts);
|
|
2868
|
+
// Something at the path itself is no part of a directory scope: a file
|
|
2869
|
+
// disk has where the workspace holds a directory, or a workspace file
|
|
2870
|
+
// where disk has a directory. Its own pass takes disk's state there
|
|
2871
|
+
// too, after what lies beneath, so a file never sits over entries it
|
|
2872
|
+
// replaced.
|
|
2873
|
+
const onDisk = await lstat(join(mount.config.path, relativePath)).catch(() => null);
|
|
2874
|
+
const atPath = (onDisk !== null && !onDisk.isDirectory())
|
|
2875
|
+
|| store.treeGet(mount.treeStateId, relativePath) !== null
|
|
2876
|
+
|| this.agreementOrThrow().get(name, relativePath) !== undefined
|
|
2877
|
+
|| this.intents.get(name)!.get(relativePath) !== null;
|
|
2878
|
+
if (!atPath) return beneath;
|
|
2879
|
+
const itself = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
|
|
2880
|
+
const passedOver = new Map(beneath.passedOver);
|
|
2881
|
+
for (const [reason, count] of itself.passedOver) passedOver.set(reason, (passedOver.get(reason) ?? 0) + count);
|
|
2882
|
+
return {
|
|
2883
|
+
ops: [...beneath.ops, ...itself.ops],
|
|
2884
|
+
discarded: [...beneath.discarded, ...itself.discarded],
|
|
2885
|
+
newConflicts: [...beneath.newConflicts, ...itself.newConflicts],
|
|
2886
|
+
reports: new Map([...beneath.reports, ...itself.reports]),
|
|
2887
|
+
incomplete: [...beneath.incomplete, ...itself.incomplete],
|
|
2888
|
+
passedOver,
|
|
2889
|
+
dirs: beneath.dirs,
|
|
2890
|
+
branchId: itself.branchId ?? beneath.branchId,
|
|
2891
|
+
};
|
|
2892
|
+
});
|
|
2389
2893
|
|
|
2390
|
-
|
|
2894
|
+
const conflicts = [...pass.reports.values()]
|
|
2895
|
+
.filter((r) => r.state === 'conflict')
|
|
2896
|
+
.map((r) => ({ path: r.path, kind: r.conflict?.kind ?? 'both-changed', diskCopy: r.conflict?.diskCopy ?? 'referenced' }));
|
|
2897
|
+
if (pass.ops.length > 0 || conflicts.length > 0 || pass.discarded.length > 0) {
|
|
2391
2898
|
allResults.push({
|
|
2392
2899
|
mount: name,
|
|
2393
|
-
synced:
|
|
2394
|
-
conflicts
|
|
2900
|
+
synced: pass.ops.map((o) => o.path),
|
|
2901
|
+
conflicts,
|
|
2902
|
+
...(pass.discarded.length > 0 ? { discarded: pass.discarded } : {}),
|
|
2395
2903
|
});
|
|
2396
|
-
this.emitFsEvents(name, result.synced, result.conflicts);
|
|
2397
2904
|
}
|
|
2398
|
-
//
|
|
2399
|
-
// "
|
|
2400
|
-
for (const
|
|
2401
|
-
|
|
2905
|
+
// Say why a path wasn't taken in, so "nothing synced" and "your file was
|
|
2906
|
+
// refused" don't look identical from the outside.
|
|
2907
|
+
for (const r of pass.reports.values()) {
|
|
2908
|
+
if (r.state === 'unverified') {
|
|
2909
|
+
allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${r.note ?? 'not observed'}` });
|
|
2910
|
+
} else if (r.state === 'disk-only') {
|
|
2911
|
+
allSkipped.push({ mount: name, path: r.path, reason: 'disk-only: binary or over the size limit, never stored' });
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
for (const region of pass.incomplete) allIncomplete.push({ mount: name, ...region });
|
|
2915
|
+
for (const [reason, count] of pass.passedOver) allPassedOver.push({ mount: name, reason, count });
|
|
2916
|
+
// A path named explicitly is synced even where the mount's ignore list
|
|
2917
|
+
// covers it, and the result says so: a sync without a path passes
|
|
2918
|
+
// over it, so nothing else keeps it current (agent-framework #276).
|
|
2919
|
+
if (coveredByIgnore(relativePath, mount.config.ignore ?? [])) {
|
|
2920
|
+
allIgnored.push({
|
|
2921
|
+
mount: name,
|
|
2922
|
+
path: relativePath,
|
|
2923
|
+
note: 'this mount\'s ignore list covers it, so a sync without a path won\'t maintain it',
|
|
2924
|
+
});
|
|
2402
2925
|
}
|
|
2403
2926
|
}
|
|
2404
2927
|
|
|
@@ -2409,10 +2932,39 @@ export class WorkspaceModule implements Module {
|
|
|
2409
2932
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
2410
2933
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
2411
2934
|
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
2935
|
+
...(allIncomplete.length > 0 ? { incomplete: allIncomplete } : {}),
|
|
2936
|
+
...(allPassedOver.length > 0 ? { passedOver: allPassedOver } : {}),
|
|
2937
|
+
...(allIgnored.length > 0 ? { ignored: allIgnored } : {}),
|
|
2938
|
+
...(rootsAccepted.length > 0 ? { rootsAccepted } : {}),
|
|
2412
2939
|
},
|
|
2940
|
+
// What the workspace took from disk, gave up, or now holds as a
|
|
2941
|
+
// conflict, per mount, and any root it accepted: kept in the stub if
|
|
2942
|
+
// the guard withholds the result (agent-framework #277). What the scan
|
|
2943
|
+
// skipped or couldn't see is in the full result.
|
|
2944
|
+
// A root it accepted is short and telling: first, so a long results
|
|
2945
|
+
// list can't cut it away.
|
|
2946
|
+
keepWhenWithheld: ['rootsAccepted', 'results'],
|
|
2413
2947
|
};
|
|
2414
2948
|
}
|
|
2415
2949
|
|
|
2950
|
+
/**
|
|
2951
|
+
* Take the mount's root as it is now (acceptRoot), when it is a directory
|
|
2952
|
+
* other than the one disk last agreed under. Its evidence is set aside:
|
|
2953
|
+
* it described another directory. Returns whether a root was accepted.
|
|
2954
|
+
*/
|
|
2955
|
+
private async acceptRootUnlocked(mount: MountState): Promise<boolean> {
|
|
2956
|
+
const runtime = await this.runtime(mount);
|
|
2957
|
+
const found = await locateRoot({ ...runtime.view, rootIdentity: undefined }, runtime.rootReal);
|
|
2958
|
+
if ('reason' in found) return false; // still missing: the pass says so
|
|
2959
|
+
const agreement = this.agreementOrThrow();
|
|
2960
|
+
const recorded = agreement.rootIdentity(mount.config.name);
|
|
2961
|
+
if (recorded && sameRootIdentity(recorded, found.identity)) return false;
|
|
2962
|
+
if (recorded) agreement.acceptRoot(mount.config.name, found.identity);
|
|
2963
|
+
else agreement.recordRootIdentity(mount.config.name, found.identity);
|
|
2964
|
+
agreement.barrier();
|
|
2965
|
+
return true;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2416
2968
|
// ==========================================================================
|
|
2417
2969
|
// Internal: Filesystem Change Handling
|
|
2418
2970
|
// ==========================================================================
|
|
@@ -2420,74 +2972,163 @@ export class WorkspaceModule implements Module {
|
|
|
2420
2972
|
/**
|
|
2421
2973
|
* Handle filesystem changes detected by watcher (watch: 'always' mode).
|
|
2422
2974
|
*
|
|
2423
|
-
* The watcher
|
|
2424
|
-
*
|
|
2425
|
-
*
|
|
2426
|
-
* op type.
|
|
2975
|
+
* The watcher's ops only choose which paths to look at: each path is
|
|
2976
|
+
* observed again and decided under the three-way rule, so an event that
|
|
2977
|
+
* arrives late or out of order can't delete or revert anything by itself.
|
|
2427
2978
|
*/
|
|
2428
2979
|
private async handleFsChanges(mountName: string, changes: FsChange[]): Promise<void> {
|
|
2429
|
-
const store = this.store;
|
|
2430
2980
|
const mount = this.mounts.get(mountName);
|
|
2431
|
-
if (!store || !mount) return;
|
|
2981
|
+
if (!this.store || !this.running() || !mount) return;
|
|
2982
|
+
const paths = [...new Set(changes.map((c) => c.path))];
|
|
2983
|
+
if (paths.length === 0) return;
|
|
2984
|
+
await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'paths', paths }));
|
|
2985
|
+
}
|
|
2432
2986
|
|
|
2433
|
-
|
|
2434
|
-
|
|
2987
|
+
/**
|
|
2988
|
+
* After an agent's completed tool batch, before anything continues its turn:
|
|
2989
|
+
* scan every `watch: 'on-agent-action'` mount, so what the batch's tools did
|
|
2990
|
+
* on disk (a shell command's files, a deletion) is in the workspace when the
|
|
2991
|
+
* agent next looks. A scan that outlasts the deadline stops holding the
|
|
2992
|
+
* round: the miss is recorded on the mount's status and pushed as a
|
|
2993
|
+
* `workspace:agent-action-scan-incomplete` event, and the scan continues,
|
|
2994
|
+
* deciding on the branch selected when it applies.
|
|
2995
|
+
*/
|
|
2996
|
+
async onToolBatchComplete(_agentName?: string): Promise<void> {
|
|
2997
|
+
// Every agent's workspace is the store's current branch, so the agent
|
|
2998
|
+
// that ran the batch doesn't choose what is scanned.
|
|
2999
|
+
const mounts = [...this.mounts.values()].filter((m) => m.config.watch === 'on-agent-action');
|
|
3000
|
+
if (mounts.length === 0 || !this.store || !this.running()) return;
|
|
3001
|
+
const deadlineMs = this.agentActionScanDeadlineMs();
|
|
3002
|
+
await Promise.all(mounts.map(async (mount) => {
|
|
3003
|
+
const runningLate = mount.agentActionScanLate !== undefined;
|
|
3004
|
+
const scan = this.agentActionScan(mount);
|
|
3005
|
+
if (runningLate) {
|
|
3006
|
+
// An earlier batch's scan outlasted its deadline and still runs: this
|
|
3007
|
+
// round isn't held as well. Its own scan is queued after that one,
|
|
3008
|
+
// and records its outcome when it finishes. It is late from the
|
|
3009
|
+
// start, so a batch that comes while it runs isn't held either.
|
|
3010
|
+
this.agentActionScanLateUntil(mount, scan);
|
|
3011
|
+
this.agentActionScanMissed(mount, 'an earlier scan is still running past its deadline; this batch is scanned after it');
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
3015
|
+
const deadline = new Promise<'late'>((resolveDeadline) => {
|
|
3016
|
+
timer = setTimeout(() => resolveDeadline('late'), deadlineMs);
|
|
3017
|
+
});
|
|
3018
|
+
const outcome = await Promise.race([scan.settled.then(() => 'done' as const), deadline]);
|
|
3019
|
+
clearTimeout(timer);
|
|
3020
|
+
if (outcome === 'late') {
|
|
3021
|
+
this.agentActionScanLateUntil(mount, scan);
|
|
3022
|
+
this.agentActionScanMissed(mount, `deadline exceeded (${deadlineMs} ms); the scan continues`);
|
|
3023
|
+
}
|
|
3024
|
+
}));
|
|
3025
|
+
}
|
|
2435
3026
|
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
3027
|
+
/**
|
|
3028
|
+
* Mark a scan late, and the mount as running late until it settles. The
|
|
3029
|
+
* marker follows the newest late scan: a mount's scans run one after
|
|
3030
|
+
* another, so the newest is the last to settle, and a batch that comes
|
|
3031
|
+
* while any late scan of the chain still runs isn't held.
|
|
3032
|
+
*/
|
|
3033
|
+
private agentActionScanLateUntil(mount: MountState, scan: AgentActionScan): void {
|
|
3034
|
+
scan.late = true;
|
|
3035
|
+
const settled = scan.settled;
|
|
3036
|
+
mount.agentActionScanLate = settled;
|
|
3037
|
+
void settled.then(() => { if (mount.agentActionScanLate === settled) mount.agentActionScanLate = undefined; });
|
|
3038
|
+
}
|
|
2439
3039
|
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
if (existing) {
|
|
2446
|
-
store.treeRemove(mount.treeStateId, p);
|
|
2447
|
-
syncResult.synced.push({ path: p, op: 'deleted' });
|
|
2448
|
-
}
|
|
2449
|
-
}
|
|
3040
|
+
/** A batch's scan missed its deadline, or failed: said on status and as an event. */
|
|
3041
|
+
private agentActionScanMissed(mount: MountState, reason: string): void {
|
|
3042
|
+
mount.lastAgentActionScan = { at: Date.now(), complete: false, withinDeadline: false, reason };
|
|
3043
|
+
this.ctx?.pushEvent({ type: 'workspace:agent-action-scan-incomplete', mount: mount.config.name, reason } as ProcessEvent);
|
|
3044
|
+
}
|
|
2450
3045
|
|
|
2451
|
-
|
|
3046
|
+
/**
|
|
3047
|
+
* The mount's after-batch scan: one may run and one wait behind it. A batch
|
|
3048
|
+
* that comes while one waits shares it, since it starts after the batch's
|
|
3049
|
+
* tools ran. Each scan records its own outcome when it settles: on status
|
|
3050
|
+
* while it is the mount's newest scan (a scan queued behind it describes a
|
|
3051
|
+
* later batch), and as an event for regions it couldn't observe (the file
|
|
3052
|
+
* cap, an unreadable or changing region, a branch that kept changing) or a
|
|
3053
|
+
* failure, whichever scan it is.
|
|
3054
|
+
*/
|
|
3055
|
+
private agentActionScan(mount: MountState): AgentActionScan {
|
|
3056
|
+
if (mount.agentActionScanWaiting) return mount.agentActionScanWaiting;
|
|
3057
|
+
const scan: AgentActionScan = { late: false, settled: Promise.resolve() };
|
|
3058
|
+
const pass = this.withMount(mount, () => {
|
|
3059
|
+
if (mount.agentActionScanWaiting === scan) mount.agentActionScanWaiting = undefined;
|
|
3060
|
+
return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true });
|
|
3061
|
+
});
|
|
3062
|
+
scan.settled = pass.then(
|
|
3063
|
+
(result) => {
|
|
3064
|
+
if (result.incomplete.length > 0) {
|
|
3065
|
+
this.ctx?.pushEvent({
|
|
3066
|
+
type: 'workspace:agent-action-scan-incomplete',
|
|
3067
|
+
mount: mount.config.name,
|
|
3068
|
+
reason: 'the scan finished, but some regions could not be observed',
|
|
3069
|
+
incomplete: result.incomplete,
|
|
3070
|
+
} as ProcessEvent);
|
|
3071
|
+
}
|
|
3072
|
+
if (mount.agentActionScanLatest !== scan) return;
|
|
3073
|
+
const incomplete = result.incomplete.length > 0 ? { incomplete: result.incomplete } : {};
|
|
3074
|
+
mount.lastAgentActionScan = scan.late
|
|
3075
|
+
? { at: Date.now(), complete: true, withinDeadline: false, reason: 'finished after the deadline', ...incomplete }
|
|
3076
|
+
: { at: Date.now(), complete: true, withinDeadline: true, ...incomplete };
|
|
3077
|
+
},
|
|
3078
|
+
(err: unknown) => {
|
|
3079
|
+
const reason = `scan failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
3080
|
+
if (mount.agentActionScanLatest === scan) this.agentActionScanMissed(mount, reason);
|
|
3081
|
+
else this.ctx?.pushEvent({ type: 'workspace:agent-action-scan-incomplete', mount: mount.config.name, reason } as ProcessEvent);
|
|
3082
|
+
},
|
|
3083
|
+
);
|
|
3084
|
+
mount.agentActionScanWaiting = scan;
|
|
3085
|
+
mount.agentActionScanLatest = scan;
|
|
3086
|
+
return scan;
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
/** The scan deadline, kept below the framework's hook bound (warned once). */
|
|
3090
|
+
private agentActionScanDeadlineMs(): number {
|
|
3091
|
+
const configured = this.config.agentActionScanDeadlineMs ?? AGENT_ACTION_SCAN_DEADLINE_MS;
|
|
3092
|
+
if (configured <= AGENT_ACTION_SCAN_DEADLINE_MAX_MS) return configured;
|
|
3093
|
+
if (!this.warnedScanDeadline) {
|
|
3094
|
+
this.warnedScanDeadline = true;
|
|
3095
|
+
console.warn(
|
|
3096
|
+
`[workspace] agentActionScanDeadlineMs ${configured} leaves less than 1 s before the framework's ` +
|
|
3097
|
+
`${TOOL_BATCH_HOOK_TIMEOUT_MS} ms bound on a tool-batch hook; using ${AGENT_ACTION_SCAN_DEADLINE_MAX_MS} ms`,
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
return AGENT_ACTION_SCAN_DEADLINE_MAX_MS;
|
|
2452
3101
|
}
|
|
2453
3102
|
|
|
2454
3103
|
/**
|
|
2455
|
-
*
|
|
3104
|
+
* Push one ProcessEvent per op for a pass's tree changes and new conflicts.
|
|
3105
|
+
* A conflict rides the event for the disk change behind it (modified, or
|
|
3106
|
+
* deleted for a disk deletion of a file with newer workspace changes).
|
|
2456
3107
|
*/
|
|
2457
3108
|
private emitFsEvents(
|
|
2458
3109
|
mountName: string,
|
|
2459
|
-
|
|
2460
|
-
|
|
3110
|
+
ops: Array<{ path: string; op: TreeOp }>,
|
|
3111
|
+
newConflicts: Array<{ path: string; op: TreeOp }> = [],
|
|
2461
3112
|
): void {
|
|
2462
|
-
if (!this.ctx ||
|
|
2463
|
-
|
|
2464
|
-
const byOp = new Map<WorkspaceFsOp, string[]>();
|
|
2465
|
-
|
|
2466
|
-
const
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
const conflictsByPath = new Map<string, ConflictInfo>();
|
|
2477
|
-
for (const c of conflicts) conflictsByPath.set(c.path, c);
|
|
2478
|
-
|
|
2479
|
-
for (const [op, paths] of byOp) {
|
|
2480
|
-
const type = opToEventType(op);
|
|
2481
|
-
let eventConflicts: string[] | undefined;
|
|
2482
|
-
if (op === 'modified' && conflictsByPath.size > 0) {
|
|
2483
|
-
const matched = paths.filter(p => conflictsByPath.has(p.slice(mountName.length + 1)));
|
|
2484
|
-
if (matched.length > 0) eventConflicts = matched;
|
|
2485
|
-
}
|
|
3113
|
+
if (!this.ctx || (ops.length === 0 && newConflicts.length === 0)) return;
|
|
3114
|
+
|
|
3115
|
+
const byOp = new Map<WorkspaceFsOp, { paths: string[]; conflicts: string[] }>();
|
|
3116
|
+
const add = (op: TreeOp, path: string, conflict: boolean): void => {
|
|
3117
|
+
const group = byOp.get(op) ?? { paths: [], conflicts: [] };
|
|
3118
|
+
const full = `${mountName}/${path}`;
|
|
3119
|
+
if (!group.paths.includes(full)) group.paths.push(full);
|
|
3120
|
+
if (conflict) group.conflicts.push(full);
|
|
3121
|
+
byOp.set(op, group);
|
|
3122
|
+
};
|
|
3123
|
+
for (const { path, op } of ops) add(op, path, false);
|
|
3124
|
+
for (const { path, op } of newConflicts) add(op, path, true);
|
|
3125
|
+
|
|
3126
|
+
for (const [op, { paths, conflicts }] of byOp) {
|
|
2486
3127
|
const event = {
|
|
2487
|
-
type,
|
|
3128
|
+
type: opToEventType(op),
|
|
2488
3129
|
paths,
|
|
2489
3130
|
mount: mountName,
|
|
2490
|
-
...(
|
|
3131
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
2491
3132
|
} as WorkspaceCreatedEvent | WorkspaceModifiedEvent | WorkspaceDeletedEvent;
|
|
2492
3133
|
this.ctx.pushEvent(event as ProcessEvent);
|
|
2493
3134
|
}
|
|
@@ -2495,6 +3136,17 @@ export class WorkspaceModule implements Module {
|
|
|
2495
3136
|
|
|
2496
3137
|
}
|
|
2497
3138
|
|
|
3139
|
+
/** What a listing shows of a path beyond its name. */
|
|
3140
|
+
function stateView(report: PathReport): Record<string, unknown> {
|
|
3141
|
+
return {
|
|
3142
|
+
state: report.state,
|
|
3143
|
+
...(report.size !== undefined ? { size: report.size } : {}),
|
|
3144
|
+
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
3145
|
+
...(report.conflict ? { conflict: report.conflict } : {}),
|
|
3146
|
+
...(report.note ? { note: report.note } : {}),
|
|
3147
|
+
};
|
|
3148
|
+
}
|
|
3149
|
+
|
|
2498
3150
|
// ==========================================================================
|
|
2499
3151
|
// Utilities
|
|
2500
3152
|
// ==========================================================================
|