mixdog 1.2.2 → 1.2.3
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 +18 -0
- package/package.json +2 -1
- package/scripts/provider-pingpong-live.mjs +509 -0
- package/src/agents/advisor/AGENT.md +1 -1
- package/src/agents/advisor/agent.json +1 -1
- package/src/defaults/shipped-history.json +5 -0
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/defaults/skills/setup/references/actions.md +44 -22
- package/src/defaults/skills/setup/references/surfaces.md +10 -8
- package/src/headless-exec.mjs +15 -8
- package/src/lib/keychain-cjs.cjs +7 -4
- package/src/output-styles/common.md +11 -10
- package/src/rules/agent/AGENT.md +5 -5
- package/src/rules/lead/LEAD.md +5 -5
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +92 -61
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +10 -1
- package/src/runtime/agent/orchestrator/config-providers-merge.mjs +7 -0
- package/src/runtime/agent/orchestrator/config.mjs +5 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -0
- package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +26 -7
- package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +112 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +48 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +24 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +7 -6
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +29 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-blocks.mjs +20 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-delta.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-start.mjs +11 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/message-events.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events.mjs +12 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-outcome.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-state.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-turn-result.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +58 -7
- package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +29 -10
- package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +16 -3
- package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +10 -61
- package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +12 -6
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +114 -72
- package/src/runtime/agent/orchestrator/providers/compat-request-policy.mjs +78 -2
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +37 -5
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-messages.mjs +43 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/cursor.mjs +9 -2
- package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +64 -1
- package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +70 -6
- package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +34 -7
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +32 -6
- package/src/runtime/agent/orchestrator/providers/deepseek.mjs +67 -0
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +11 -4
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +25 -8
- package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +87 -5
- package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +41 -5
- package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +13 -13
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +355 -37
- package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +93 -47
- package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +55 -10
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +26 -8
- package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/lib/action-input-contract.mjs +60 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +246 -42
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-server-tool-usage.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-system-blocks.mjs +27 -6
- package/src/runtime/agent/orchestrator/providers/lib/claude-bridge-schema.mjs +388 -0
- package/src/runtime/agent/orchestrator/providers/lib/inclusive-usage.mjs +54 -0
- package/src/runtime/agent/orchestrator/providers/lib/origin-bound-fetch.mjs +16 -0
- package/src/runtime/agent/orchestrator/providers/lib/responses-terminal-fields.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/lib/wire-pairing.mjs +48 -22
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +119 -18
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +13 -15
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +1 -1
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +33 -1
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +121 -61
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/chunk-events.mjs +35 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +37 -7
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/tool-call-acc.mjs +2 -9
- package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +47 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +47 -25
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-reasoning-field.mjs +151 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +42 -24
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +63 -25
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-head-parity.json +2901 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-parity.fixtures.mjs +172 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +53 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +103 -26
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +23 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +11 -6
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
- package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +15 -23
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/output-items.mjs +23 -10
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +25 -4
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events.mjs +10 -0
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-items.mjs +18 -7
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/text-relay.mjs +9 -1
- package/src/runtime/agent/orchestrator/providers/openai-model-capabilities.mjs +80 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +71 -24
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload-parity.fixture.json +323 -0
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +44 -16
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +7 -16
- package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/refusals.mjs +60 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/tool-calls.mjs +51 -35
- package/src/runtime/agent/orchestrator/providers/openai-ws-response-state.mjs +30 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +12 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream/terminal-frames.mjs +25 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +11 -3
- package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +170 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +75 -70
- package/src/runtime/agent/orchestrator/providers/openrouter.mjs +75 -0
- package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +147 -8
- package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/transport-scope.d.mts +6 -0
- package/src/runtime/agent/orchestrator/providers/transport-scope.mjs +28 -0
- package/src/runtime/agent/orchestrator/providers/wire-gateway.mjs +157 -0
- package/src/runtime/agent/orchestrator/providers/wire-route.mjs +512 -0
- package/src/runtime/agent/orchestrator/providers/xai-tool-search-head-parity.json +1131 -0
- package/src/runtime/agent/orchestrator/providers/xai-tool-search.fixtures.mjs +183 -0
- package/src/runtime/agent/orchestrator/runtime-core/ask-user-tool-defs.mjs +100 -0
- package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-availability.mjs +21 -0
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +15 -0
- package/src/runtime/agent/orchestrator/runtime-core/provider-request-snapshot.mjs +7 -3
- package/src/runtime/agent/orchestrator/runtime-core/session-title.mjs +21 -1
- package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +14 -3
- package/src/runtime/agent/orchestrator/runtime-core/tool-bridge.mjs +526 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +10 -7
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +176 -71
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +62 -10
- package/src/runtime/agent/orchestrator/runtime-core/tool-exposure.mjs +96 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +14 -93
- package/src/runtime/agent/orchestrator/session/compact/summary-anchor.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/compact/user-message-kind.mjs +120 -0
- package/src/runtime/agent/orchestrator/session/context-tool-schema.mjs +17 -2
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +8 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch/admission.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +6 -1
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +91 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +56 -0
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +18 -8
- package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +21 -10
- package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +132 -0
- package/src/runtime/agent/orchestrator/session/loop/request-boundary.mjs +30 -0
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +63 -20
- package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-call-resolution.mjs +95 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +30 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +15 -4
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-exposure-migration.mjs +150 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +34 -12
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +16 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +208 -14
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +15 -3
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +25 -3
- package/src/runtime/agent/orchestrator/session/manager/session-fallback-title.mjs +82 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +19 -2
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/side-question-store.mjs +96 -0
- package/src/runtime/agent/orchestrator/session/side-question.mjs +192 -0
- package/src/runtime/agent/orchestrator/session/store/lifecycle-barriers.mjs +17 -3
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +58 -2
- package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +41 -28
- package/src/runtime/agent/orchestrator/session/store/session-observers.mjs +4 -2
- package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/store/write-admission.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/store-summary-archived-agent.mjs +34 -15
- package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
- package/src/runtime/agent/orchestrator/session/store.mjs +19 -3
- package/src/runtime/agent/orchestrator/session/tool-batch/execute-call.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/tool-batch/record-outcome.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-schema-rejection.mjs +77 -0
- package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +8 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +14 -10
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +49 -34
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +73 -39
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +18 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -4
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +88 -12
- package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +2 -0
- package/src/runtime/computer-bridge/action-schema.mjs +44 -21
- package/src/runtime/github/tool.mjs +92 -2
- package/src/runtime/memory/index.mjs +1 -0
- package/src/runtime/memory/lib/http-router/ingest-routes.mjs +22 -1
- package/src/runtime/memory/lib/ingest/transcript-offsets.mjs +2 -0
- package/src/runtime/memory/lib/pg/adapter.mjs +98 -1
- package/src/runtime/memory/lib/pg/process.mjs +31 -26
- package/src/runtime/memory/lib/pg/run-child.mjs +49 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +108 -3
- package/src/runtime/memory/lib/runtime-fetcher.mjs +12 -13
- package/src/runtime/memory/lib/transcript-ingest.mjs +14 -0
- package/src/runtime/office/README.md +1 -1
- package/src/runtime/office/authoring/html-document-author.mjs +2 -0
- package/src/runtime/office/authoring/pptx-contact-sheet.mjs +2 -0
- package/src/runtime/office/authoring/pptx-html-measure.mjs +2 -0
- package/src/runtime/office/authoring/pptx-html-runner.mjs +2 -0
- package/src/runtime/office/authoring/xlsx-html-runner.mjs +2 -0
- package/src/runtime/office/core/office-actions-read.mjs +3 -0
- package/src/runtime/office/core/office-render-preview.mjs +2 -0
- package/src/runtime/office/core/office-session-dispatch.mjs +7 -0
- package/src/runtime/office/core/office-sessionless-actions.mjs +8 -0
- package/src/runtime/office/core/office-sessions.mjs +5 -0
- package/src/runtime/office/core/office-transactions.mjs +3 -0
- package/src/runtime/office/core/office-write-ledger.mjs +98 -0
- package/src/runtime/office/design/library/templates/mixdog-executive.mixdog-edit.pptx +0 -0
- package/src/runtime/office/index.mjs +30 -0
- package/src/runtime/office/pdf/pdf-edit-document.mjs +2 -0
- package/src/runtime/office/pdf/pdf-render.mjs +23 -7
- package/src/runtime/office/pdf/pdf-security.mjs +2 -0
- package/src/runtime/office/portable/portable-opc.mjs +4 -1
- package/src/runtime/office/quality/visual-diff.mjs +2 -0
- package/src/runtime/shared/bounded-file-read.mjs +13 -0
- package/src/runtime/shared/bounded-line-diff.mjs +182 -0
- package/src/runtime/shared/buffered-appender.mjs +18 -0
- package/src/runtime/shared/config.mjs +78 -18
- package/src/runtime/shared/err-text.mjs +6 -0
- package/src/runtime/shared/file-permissions.d.mts +6 -1
- package/src/runtime/shared/file-permissions.mjs +54 -6
- package/src/runtime/shared/llm/anthropic-effort.mjs +6 -0
- package/src/runtime/shared/llm/usage-accounting.mjs +5 -2
- package/src/runtime/shared/llm/usage-ledger-rollup.mjs +3 -0
- package/src/runtime/shared/loopback-listener.mjs +9 -0
- package/src/runtime/shared/provider-api-key.mjs +2 -0
- package/src/runtime/shared/review-diff.mjs +52 -0
- package/src/runtime/shared/session-sidebar-host.d.mts +4 -0
- package/src/runtime/shared/session-sidebar-host.mjs +38 -0
- package/src/runtime/shared/slow-span.mjs +114 -0
- package/src/runtime/shared/statusline/statusline-segments.mjs +28 -4
- package/src/runtime/shared/tool-card-model/card-header.mjs +6 -1
- package/src/runtime/shared/tool-card-model/generic-detail.mjs +39 -5
- package/src/runtime/shared/tool-card-model.mjs +5 -2
- package/src/runtime/shared/tool-output-limit.mjs +4 -0
- package/src/runtime/shared/tool-primitives.mjs +13 -0
- package/src/runtime/shared/tool-result-summary.mjs +8 -1
- package/src/runtime/shared/tool-surface.mjs +18 -3
- package/src/runtime/shared/tool-work-units.mjs +40 -4
- package/src/runtime/shared/transcript-writer/purge.mjs +74 -0
- package/src/runtime/shared/transcript-writer.mjs +10 -2
- package/src/runtime/shared/turn-changes.mjs +268 -100
- package/src/runtime/shared/turn-review-reply.mjs +8 -39
- package/src/runtime/shared/turn-snapshot-store.mjs +19 -108
- package/src/runtime/shared/turn-snapshot.mjs +857 -683
- package/src/runtime/shared/turn-worktree-snapshot.mjs +183 -255
- package/src/runtime/shared/user-data-guard.mjs +262 -49
- package/src/runtime/shared/worktrees/carry.mjs +531 -0
- package/src/runtime/shared/worktrees/contract.mjs +34 -0
- package/src/runtime/shared/worktrees/file-recovery.mjs +68 -0
- package/src/runtime/shared/worktrees/git-head.mjs +205 -0
- package/src/runtime/shared/worktrees/git.mjs +8 -3
- package/src/runtime/shared/worktrees/maintenance.mjs +1 -1
- package/src/runtime/shared/worktrees/manager.mjs +156 -8
- package/src/runtime/shared/worktrees/merge.mjs +31 -21
- package/src/runtime/shared/worktrees/paths.d.mts +6 -0
- package/src/runtime/terminal-bridge/client.mjs +44 -11
- package/src/runtime/terminal-bridge/tool-defs.mjs +13 -6
- package/src/runtime/tidy/apply.mjs +47 -2
- package/src/runtime/tidy/run-engines.mjs +11 -5
- package/src/session-runtime/agent-tool-routing.mjs +19 -13
- package/src/session-runtime/boot/apis.mjs +13 -5
- package/src/session-runtime/boot/facade.mjs +1 -0
- package/src/session-runtime/boot/infrastructure.mjs +6 -1
- package/src/session-runtime/boot/session.mjs +0 -1
- package/src/session-runtime/boot/tools.mjs +4 -1
- package/src/session-runtime/cwd-plugins/cwd-apply.mjs +12 -5
- package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +65 -0
- package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +3 -1
- package/src/session-runtime/internal-tool-executor/knowledge-tools.mjs +11 -4
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +115 -18
- package/src/session-runtime/internal-tool-executor.mjs +6 -2
- package/src/session-runtime/lifecycle/cold-heir.mjs +188 -0
- package/src/session-runtime/lifecycle/inheritance.mjs +71 -27
- package/src/session-runtime/lifecycle/session-catalog.mjs +12 -3
- package/src/session-runtime/lifecycle/session-switch/resume.mjs +6 -0
- package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +29 -6
- package/src/session-runtime/lifecycle/teardown.mjs +72 -7
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/session-runtime/mcp-glue/connect.mjs +17 -5
- package/src/session-runtime/model-route/set-route.mjs +2 -2
- package/src/session-runtime/native-web-search-usage.mjs +51 -0
- package/src/session-runtime/native-web-search.mjs +10 -1
- package/src/session-runtime/new-session-config.mjs +4 -2
- package/src/session-runtime/provider-auth/login.mjs +4 -0
- package/src/session-runtime/resource-scope-decorate.mjs +10 -3
- package/src/session-runtime/runtime-review-api.mjs +8 -2
- package/src/session-runtime/services/agent-tool/index-write-queue.mjs +81 -20
- package/src/session-runtime/services/agent-tool/lead-worker-index/forgotten-leads.mjs +29 -0
- package/src/session-runtime/services/agent-tool/lead-worker-index/index-file.mjs +1 -1
- package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +73 -7
- package/src/session-runtime/services/agent-tool/tag-registry.mjs +15 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +10 -1
- package/src/session-runtime/services/agent-tool/worker-index.mjs +46 -7
- package/src/session-runtime/services/agent-tool.mjs +3 -0
- package/src/session-runtime/services/channel-admin.mjs +19 -3
- package/src/session-runtime/services/memory-runtime-proxy/calls.mjs +4 -0
- package/src/session-runtime/services/memory-runtime-proxy.mjs +1 -0
- package/src/session-runtime/services/provider-admin.mjs +81 -15
- package/src/session-runtime/session-transcript-purge.mjs +32 -0
- package/src/session-runtime/session-turn-api.mjs +14 -5
- package/src/session-runtime/settings-builtin-tools-api.mjs +9 -0
- package/src/session-runtime/setup-tool/executor.mjs +36 -6
- package/src/session-runtime/setup-tool/extended-actions.mjs +86 -6
- package/src/session-runtime/setup-tool/settings-contract.mjs +13 -3
- package/src/session-runtime/setup-tool/tool-defs.mjs +13 -4
- package/src/session-runtime/skills-api.mjs +3 -5
- package/src/session-runtime/tool-surface.mjs +5 -1
- package/src/session-runtime/turn/compaction-gate.mjs +25 -0
- package/src/session-runtime/turn/session-ops.mjs +7 -5
- package/src/session-runtime/turn/side-question-ops.mjs +22 -0
- package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +5 -1
- package/src/session-runtime/turn/turn-run/turn-prepare.mjs +8 -3
- package/src/session-runtime/turn/turn-run/turn-settle.mjs +10 -0
- package/src/session-runtime/turn/turn-run/turn-timing.mjs +4 -3
- package/src/session-runtime/turn/turn-run.mjs +25 -9
- package/src/session-runtime/usage-stats-api.mjs +43 -11
- package/src/session-runtime/workspace/project-branches.mjs +7 -5
- package/src/session-runtime/workspace/transition-steps.mjs +129 -31
- package/src/session-runtime/workspace/transitions.mjs +190 -15
- package/src/standalone/daemon-agent-control.mjs +9 -1
- package/src/standalone/daemon-local-session-bridge.mjs +14 -1
- package/src/standalone/daemon-owner.mjs +17 -4
- package/src/standalone/daemon-pg-stop.mjs +51 -0
- package/src/standalone/daemon-stop-budget.d.mts +2 -0
- package/src/standalone/daemon-stop-budget.mjs +18 -0
- package/src/standalone/daemon-stored-session-views.mjs +2 -0
- package/src/standalone/daemon-telemetry.mjs +19 -0
- package/src/standalone/daemon.mjs +61 -1
- package/src/standalone/desktop-service-registry.mjs +5 -0
- package/src/standalone/local-session-runtime.mjs +70 -19
- package/src/standalone/session-client.mjs +42 -0
- package/src/standalone/session-protocol.mjs +7 -0
- package/src/standalone/session-proxy/projection.mjs +41 -4
- package/src/standalone/session-runtime-inline-host/boot-loaders.mjs +15 -1
- package/src/standalone/session-runtime-inline-host.mjs +1 -0
- package/src/standalone/session-service/session-calls/session-action.mjs +18 -3
- package/src/standalone/session-service-api.mjs +6 -1
- package/src/standalone/session-state-patch.d.mts +2 -1
- package/src/standalone/session-state-patch.mjs +41 -1
- package/src/tui/App.jsx +25 -0
- package/src/tui/app/app-view/floating-panel.jsx +26 -1
- package/src/tui/app/btw-controller.mjs +118 -0
- package/src/tui/app/create-app-pickers.mjs +2 -0
- package/src/tui/app/shell-layout/row-budgets.mjs +11 -1
- package/src/tui/app/shell-layout/surfaces.mjs +3 -1
- package/src/tui/app/slash-commands.mjs +13 -3
- package/src/tui/app/slash-dispatch/session-commands.mjs +19 -6
- package/src/tui/app/use-global-key-input/approval-shortcut.mjs +12 -0
- package/src/tui/app/use-global-key-input.mjs +5 -12
- package/src/tui/app/use-prompt-handlers.mjs +11 -0
- package/src/tui/components/BtwPanel.jsx +53 -0
- package/src/tui/components/QuestionPanel.jsx +275 -0
- package/src/tui/components/question-panel-model.mjs +156 -0
- package/src/tui/dist/index.mjs +2413 -1465
- package/src/tui/session/context-state.mjs +4 -2
- package/src/tui/session/session-api/commands.mjs +10 -0
- package/src/tui/session/session-api/controls.mjs +2 -2
- package/src/tui/session/session-api/integrations.mjs +9 -2
- package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
- package/src/tui/session/session-api/lifecycle.mjs +2 -1
- package/src/tui/session/session-api/settings.mjs +6 -0
- package/src/tui/session/session-api.mjs +1 -0
- package/src/tui/session/tool-approval.mjs +10 -7
- package/src/tui/session/transcript-spill.mjs +14 -7
- package/src/tui/session/turn-stream.mjs +174 -31
- package/src/tui/session/turn.mjs +12 -5
- package/src/workflows/default/WORKFLOW.md +3 -1
|
@@ -1,27 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
4
2
|
import {
|
|
5
3
|
_resetTurnWorktreeSnapshotsForTest,
|
|
4
|
+
canonicalPath,
|
|
6
5
|
createTurnWorktreeSnapshot,
|
|
7
|
-
diffTurnWorktreeHead,
|
|
8
|
-
freezeTurnWorktreeChanges,
|
|
9
6
|
noteTurnWorktreeChange,
|
|
10
7
|
refreshTurnWorktreeSnapshot,
|
|
11
8
|
recordTurnWorktreeFileChange,
|
|
9
|
+
resumeTurnWorktreeFilePatch,
|
|
12
10
|
resumeTurnWorktreeSnapshot,
|
|
13
|
-
revertTurnWorktreeFile,
|
|
14
|
-
revertTurnWorktreePaths,
|
|
15
|
-
revertTurnWorktreeSnapshot,
|
|
16
11
|
} from './turn-worktree-snapshot.mjs';
|
|
17
12
|
import {
|
|
18
13
|
clearSessionChangeHistory as clearStoredChangeHistory,
|
|
19
14
|
loadSessionSnapshotRecords,
|
|
20
15
|
loadTurnChangeRecord,
|
|
21
16
|
loadTurnChangeRecords,
|
|
22
|
-
loadTurnReviewSummary,
|
|
23
17
|
loadTurnSnapshotRecord,
|
|
24
|
-
saveTurnReviewSummary,
|
|
25
18
|
saveTurnSnapshotRecord,
|
|
26
19
|
sessionChangeHistoryCleared,
|
|
27
20
|
updateTurnChangeRecord,
|
|
@@ -29,11 +22,15 @@ import {
|
|
|
29
22
|
} from './turn-snapshot-store.mjs';
|
|
30
23
|
import {
|
|
31
24
|
applyTurnChangeFiles,
|
|
25
|
+
changeDisplayPatch,
|
|
32
26
|
changePathKey,
|
|
33
27
|
findChangeFile,
|
|
28
|
+
isBinaryChangeContent,
|
|
34
29
|
measureLegacyChangeFile,
|
|
35
30
|
namedChangePatch,
|
|
31
|
+
repositoryObjectFormat,
|
|
36
32
|
sessionChangeReview,
|
|
33
|
+
splitPatchSections,
|
|
37
34
|
storedChangeFile,
|
|
38
35
|
trackedChangeFile,
|
|
39
36
|
turnChangesReply,
|
|
@@ -47,21 +44,37 @@ import {
|
|
|
47
44
|
primeReviewOwner,
|
|
48
45
|
recordReviewOwner,
|
|
49
46
|
} from './session-baseline-store.mjs';
|
|
50
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
boundReviewPatch,
|
|
49
|
+
createReviewPatchBudget,
|
|
50
|
+
filePatchReply,
|
|
51
|
+
patchOmittedFile,
|
|
52
|
+
REVIEW_FILE_PATCH_SHOW_MAX_BYTES,
|
|
53
|
+
reviewDiffTooLarge,
|
|
54
|
+
reviewSectionTruncated,
|
|
55
|
+
} from './review-diff.mjs';
|
|
51
56
|
import { PATCH_EDIT_MAX_BYTES } from './bounded-file-read.mjs';
|
|
52
57
|
import { cleanString as clean } from './clean.mjs';
|
|
58
|
+
import { createLineDiffBudget, lineDiffHunks, unifiedHunksBody } from './bounded-line-diff.mjs';
|
|
53
59
|
|
|
54
60
|
// Turn-scoped review registry.
|
|
55
61
|
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// the
|
|
59
|
-
//
|
|
60
|
-
//
|
|
62
|
+
// Every file tool write folds its exact before/after bytes into the session's
|
|
63
|
+
// tracker as ordered own segments per file: a write that starts from the
|
|
64
|
+
// bytes the file's last segment ended with extends that segment, any other
|
|
65
|
+
// opens a new one, so whatever changed the file in between (a foreign edit)
|
|
66
|
+
// is in no segment. The turn review, its change record and every Undo/
|
|
67
|
+
// Reapply/latest-turn revert work from those segments only, reverse-applied
|
|
68
|
+
// newest first against the current disk with conflict detection: a foreign
|
|
69
|
+
// edit outside the turn's hunks survives an Undo, and one overlapping them
|
|
70
|
+
// fails it instead of being overwritten. Each file counts the summed line
|
|
71
|
+
// deltas of its own writes.
|
|
72
|
+
//
|
|
73
|
+
// A Git worktree also gets a shadow-index snapshot at turn start: the durable
|
|
74
|
+
// baseline of the session-wide review.
|
|
61
75
|
//
|
|
62
|
-
// Worker
|
|
63
|
-
//
|
|
64
|
-
// to a worktree snapshot's authoritative totals.
|
|
76
|
+
// Worker trackers remain separate until the worker completes: then its
|
|
77
|
+
// segments, owned paths and counts join the owning Lead turn's.
|
|
65
78
|
|
|
66
79
|
const DISABLED = /^(0|false|off)$/i.test(String(process.env.MIXDOG_TURN_SNAPSHOT || ''));
|
|
67
80
|
const TURN_CACHE_MAX = 32;
|
|
@@ -74,6 +87,9 @@ const MAX_AGENT_REVIEW_BYTES_PER_TURN = 4 * 1024 * 1024;
|
|
|
74
87
|
const _turnsBySession = new Map();
|
|
75
88
|
const _diffTrackersBySession = new Map();
|
|
76
89
|
let _agentTurnSeq = 0;
|
|
90
|
+
// Orders every tracked write across trackers, so a worker's segments merge into
|
|
91
|
+
// its Lead's in the order the writes happened.
|
|
92
|
+
let _writeSeq = 0;
|
|
77
93
|
// `${sessionId}\0${checkpointId}` → seals in flight. A sealing turn's change
|
|
78
94
|
// record is `pending` and its session refuses Undo/Reapply until it lands.
|
|
79
95
|
const _pendingFreezes = new Map();
|
|
@@ -143,28 +159,58 @@ function ownedPathsUnder(root, owned) {
|
|
|
143
159
|
return out;
|
|
144
160
|
}
|
|
145
161
|
|
|
146
|
-
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
162
|
+
/** Fold one write's line counts into `stats` (owned entry key → summed
|
|
163
|
+
* counts). A rename moves the source's counts to its destination; a change
|
|
164
|
+
* that reports no counts (an Undo/Reapply fold) adds nothing. */
|
|
165
|
+
function foldOwnStats(stats, raw, source, destination) {
|
|
166
|
+
const measured = Number.isFinite(raw?.additions) || Number.isFinite(raw?.deletions);
|
|
167
|
+
const prior = stats.get(source.key);
|
|
168
|
+
if (destination) stats.delete(source.key);
|
|
169
|
+
const target = destination || source;
|
|
170
|
+
const existing = destination ? stats.get(destination.key) : null;
|
|
171
|
+
if (!prior && !existing && !measured) return;
|
|
172
|
+
stats.set(target.key, {
|
|
173
|
+
path: target.path,
|
|
174
|
+
display: target.display,
|
|
175
|
+
additions: (prior?.additions || 0) + (existing?.additions || 0) + (Number(raw?.additions) || 0),
|
|
176
|
+
deletions: (prior?.deletions || 0) + (existing?.deletions || 0) + (Number(raw?.deletions) || 0),
|
|
177
|
+
});
|
|
157
178
|
}
|
|
158
179
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
180
|
+
function mergeOwnStats(target, source) {
|
|
181
|
+
for (const [key, entry] of source) {
|
|
182
|
+
const prior = target.get(key);
|
|
183
|
+
target.set(
|
|
184
|
+
key,
|
|
185
|
+
prior
|
|
186
|
+
? { ...prior, additions: prior.additions + entry.additions, deletions: prior.deletions + entry.deletions }
|
|
187
|
+
: { ...entry }
|
|
188
|
+
);
|
|
166
189
|
}
|
|
167
|
-
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Lookup of the tracker's own-write counts by a reviewed file path: relative
|
|
193
|
+
* to `root`, or (`display`) the tools' display path a tracked patch shows. */
|
|
194
|
+
function ownCountIndex(tracker, root, { display = false } = {}) {
|
|
195
|
+
const keyOf = (path) => pathKey(display ? path : resolve(root, path));
|
|
196
|
+
const index = new Map();
|
|
197
|
+
for (const entry of tracker?.ownStats?.values() || []) index.set(keyOf(display ? entry.display : entry.path), entry);
|
|
198
|
+
return (path) => (path ? index.get(keyOf(path)) : undefined);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Files counted by the summed per-call deltas of their own writes (a
|
|
202
|
+
* rename's old path included). A file no own write reported counts for
|
|
203
|
+
* keeps the counts it has. */
|
|
204
|
+
function withOwnCounts(files, find) {
|
|
205
|
+
return files.map((file) => {
|
|
206
|
+
const hits = [find(file.path), file.oldPath ? find(file.oldPath) : undefined].filter(Boolean);
|
|
207
|
+
if (hits.length === 0) return file;
|
|
208
|
+
return {
|
|
209
|
+
...file,
|
|
210
|
+
additions: hits.reduce((sum, hit) => sum + hit.additions, 0),
|
|
211
|
+
deletions: hits.reduce((sum, hit) => sum + hit.deletions, 0),
|
|
212
|
+
};
|
|
213
|
+
});
|
|
168
214
|
}
|
|
169
215
|
|
|
170
216
|
// A change whose bytes were never captured (binary/oversized) carries
|
|
@@ -219,23 +265,30 @@ function createDiffTracker(meta = {}) {
|
|
|
219
265
|
// The seal (refresh, record, freeze) in flight, if any.
|
|
220
266
|
freezing: null,
|
|
221
267
|
revision: 0,
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
268
|
+
// Current path key → { segments }: the file's own segments in write
|
|
269
|
+
// order, each `{ before, after, firstSeq, lastSeq }` whose sides are
|
|
270
|
+
// `{ path, displayPath, content }` (content null = absent).
|
|
271
|
+
chains: new Map(),
|
|
272
|
+
// Current path key → { chain, owned, stats } of a chain an Undo restored,
|
|
273
|
+
// kept so a Reapply puts it back.
|
|
274
|
+
undoneChains: new Map(),
|
|
275
|
+
// The review rendered for one revision (trackerView).
|
|
276
|
+
view: null,
|
|
227
277
|
worktreeSnapshot: null,
|
|
228
278
|
// Session-owned relative paths. These outlive the before/after buffers on
|
|
229
279
|
// purpose: they are what scopes a revert once the exact content is gone.
|
|
230
280
|
ownedPaths: new Set(),
|
|
281
|
+
// Owned entry key → { path, display, additions, deletions }: the summed
|
|
282
|
+
// line deltas of every own write of the file. Outlives the buffers too.
|
|
283
|
+
ownStats: new Map(),
|
|
231
284
|
};
|
|
232
285
|
}
|
|
233
286
|
|
|
234
287
|
function releaseDiffTrackerContent(tracker) {
|
|
235
288
|
if (!tracker) return;
|
|
236
|
-
tracker.
|
|
237
|
-
tracker.
|
|
238
|
-
tracker.
|
|
289
|
+
tracker.chains.clear();
|
|
290
|
+
tracker.undoneChains.clear();
|
|
291
|
+
tracker.view = null;
|
|
239
292
|
}
|
|
240
293
|
|
|
241
294
|
function resetDiffTracker(sessionId, meta = {}) {
|
|
@@ -248,253 +301,271 @@ function resetDiffTracker(sessionId, meta = {}) {
|
|
|
248
301
|
|
|
249
302
|
function trackedContentBytes(tracker) {
|
|
250
303
|
let total = 0;
|
|
251
|
-
for (const
|
|
252
|
-
|
|
304
|
+
for (const chain of tracker.chains.values()) {
|
|
305
|
+
for (const { before, after } of chain.segments) {
|
|
306
|
+
total += contentByteLength(before.content) + contentByteLength(after.content);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
253
309
|
return total;
|
|
254
310
|
}
|
|
255
311
|
|
|
256
|
-
|
|
257
|
-
function
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
312
|
+
/** A segment that leaves its file where and as it found it. */
|
|
313
|
+
function noopSegment({ before, after }) {
|
|
314
|
+
return pathKey(before.path) === pathKey(after.path) && sameContent(before.content, after.content);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Join one own segment to the chain of the file it starts at. It extends the
|
|
318
|
+
* chain's last segment when it starts from that segment's exact after bytes;
|
|
319
|
+
* otherwise something else changed the file in between, and it opens a new
|
|
320
|
+
* segment so that change stays out of both. A move onto a path another chain
|
|
321
|
+
* holds lands as a delete of its source and a write of its destination. */
|
|
322
|
+
function appendSegment(tracker, segment) {
|
|
323
|
+
const startKey = pathKey(segment.before.path);
|
|
324
|
+
const endKey = pathKey(segment.after.path);
|
|
325
|
+
if (startKey !== endKey && tracker.chains.has(endKey)) {
|
|
326
|
+
appendSegment(tracker, { ...segment, after: { ...segment.before, content: null } });
|
|
327
|
+
appendSegment(tracker, { ...segment, before: { ...segment.after, content: null } });
|
|
328
|
+
return;
|
|
263
329
|
}
|
|
330
|
+
const chain = tracker.chains.get(startKey) || { segments: [] };
|
|
331
|
+
tracker.chains.delete(startKey);
|
|
332
|
+
const last = chain.segments.at(-1);
|
|
333
|
+
if (last && sameContent(last.after.content, segment.before.content)) {
|
|
334
|
+
last.after = segment.after;
|
|
335
|
+
last.lastSeq = segment.lastSeq;
|
|
336
|
+
} else {
|
|
337
|
+
chain.segments.push({ ...segment });
|
|
338
|
+
}
|
|
339
|
+
tracker.chains.set(endKey, chain);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const isBinarySide = (content) => isOmittedContent(content) || isBinaryChangeContent(content);
|
|
343
|
+
|
|
344
|
+
/** What a chain records, relative to `root`: its segments that change
|
|
345
|
+
* something — of a binary file only the last, since bytes from before a
|
|
346
|
+
* foreign rewrite cannot be restored around it — as steps with the
|
|
347
|
+
* root-relative paths of their sides ('' outside the root). `start`/`end`
|
|
348
|
+
* are the sides the change begins and ends at (a created file has no old
|
|
349
|
+
* side, a deleted one no new side). Null when the chain changes nothing. */
|
|
350
|
+
function chainEdit(chain, root) {
|
|
351
|
+
let segments = chain.segments.filter((segment) => !noopSegment(segment));
|
|
352
|
+
if (segments.length === 0) return null;
|
|
353
|
+
const binary = segments.some(({ before, after }) => [before.content, after.content].some(isBinarySide));
|
|
354
|
+
if (binary) segments = segments.slice(-1);
|
|
355
|
+
const steps = segments.map(({ before, after }) => ({
|
|
356
|
+
before,
|
|
357
|
+
after,
|
|
358
|
+
oldPath: trackedRelativePath(root, before),
|
|
359
|
+
newPath: trackedRelativePath(root, after),
|
|
360
|
+
}));
|
|
361
|
+
const first = steps[0];
|
|
362
|
+
const last = steps.at(-1);
|
|
363
|
+
const start = first.before.content === null ? first.after : first.before;
|
|
364
|
+
const end = last.after.content === null ? last.before : last.after;
|
|
365
|
+
let status = 'M';
|
|
366
|
+
if (first.before.content === null && last.after.content !== null) status = 'A';
|
|
367
|
+
else if (last.after.content === null && first.before.content !== null) status = 'D';
|
|
368
|
+
else if (pathKey(start.path) !== pathKey(end.path)) status = 'R';
|
|
369
|
+
return {
|
|
370
|
+
steps,
|
|
371
|
+
start,
|
|
372
|
+
end,
|
|
373
|
+
status,
|
|
374
|
+
binary,
|
|
375
|
+
inside: steps.every((step) => step.oldPath && step.newPath),
|
|
376
|
+
omitted: steps.some(({ before, after }) => [before.content, after.content].some(isOmittedContent)),
|
|
377
|
+
};
|
|
264
378
|
}
|
|
265
379
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
380
|
+
/** A side's name in the review: relative to `root` inside it, else the
|
|
381
|
+
* tool's display path. */
|
|
382
|
+
const viewPath = (root, entry) => trackedRelativePath(root, entry) || displayPath(entry.displayPath || entry.path);
|
|
383
|
+
|
|
384
|
+
/** One step's display section, named as the review shows it, with its line
|
|
385
|
+
* counts: hunks, a `Binary files … differ` line, or the not-captured note. */
|
|
386
|
+
function renderStep({ before, after }, root, budget) {
|
|
387
|
+
const oldGit = `a/${viewPath(root, before)}`;
|
|
388
|
+
const newGit = `b/${viewPath(root, after)}`;
|
|
389
|
+
const oldHeader = before.content === null ? '/dev/null' : oldGit;
|
|
390
|
+
const newHeader = after.content === null ? '/dev/null' : newGit;
|
|
273
391
|
let head = `diff --git ${oldGit} ${newGit}\n`;
|
|
274
|
-
if (before
|
|
275
|
-
else if (
|
|
276
|
-
const
|
|
392
|
+
if (before.content === null) head += 'new file mode 100644\n';
|
|
393
|
+
else if (after.content === null) head += 'deleted file mode 100644\n';
|
|
394
|
+
const binaryLine = `Binary files ${oldHeader} and ${newHeader} differ\n`;
|
|
395
|
+
const omitted = [before.content, after.content].find(isOmittedContent);
|
|
277
396
|
if (omitted) {
|
|
278
397
|
let change = 'changed';
|
|
279
|
-
if (after
|
|
280
|
-
else if (before
|
|
281
|
-
|
|
398
|
+
if (after.content === null) change = 'deleted';
|
|
399
|
+
else if (before.content === null) change = 'added';
|
|
400
|
+
const note = `binary/large file ${change} (${omitted.size} bytes; content not captured)\n`;
|
|
401
|
+
return { text: `${head}${note}${binaryLine}`, additions: 0, deletions: 0 };
|
|
282
402
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (oldText === null || newText === null) {
|
|
286
|
-
return `${head}Binary files ${oldHeader} and ${newHeader} differ\n`;
|
|
403
|
+
if ([before.content, after.content].some(isBinaryChangeContent)) {
|
|
404
|
+
return { text: `${head}${binaryLine}`, additions: 0, deletions: 0 };
|
|
287
405
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
406
|
+
const text = (content) => (content === null ? '' : content.toString('utf8'));
|
|
407
|
+
const { hunks } = lineDiffHunks(text(before.content), text(after.content), budget, [before.content, after.content]);
|
|
408
|
+
let additions = 0;
|
|
409
|
+
let deletions = 0;
|
|
410
|
+
for (const line of hunks.flatMap((hunk) => hunk.lines)) {
|
|
411
|
+
if (line.startsWith('+')) additions += 1;
|
|
412
|
+
else if (line.startsWith('-')) deletions += 1;
|
|
295
413
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
414
|
+
return { text: `${head}${unifiedHunksBody(oldHeader, newHeader, hunks)}`, additions, deletions };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The tracker's own changes as the review shows them: a row per file with
|
|
418
|
+
* its own counts, and the display patch of its segments, named relative to
|
|
419
|
+
* the tracker's patch root (a worker without one: the tools' display
|
|
420
|
+
* paths). Beside a Git root a file outside it is left out, as from the
|
|
421
|
+
* record's undoable files. Cached per revision; `budget` bounds a render. */
|
|
422
|
+
function trackerView(tracker, budget = createLineDiffBudget()) {
|
|
423
|
+
const { root, kind } = trackerPatchRoot(tracker);
|
|
424
|
+
if (tracker.view?.revision === tracker.revision && tracker.view.root === root) return tracker.view;
|
|
425
|
+
const rows = [];
|
|
304
426
|
if (!tracker.valid) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const renamePairs = new Map();
|
|
309
|
-
const pairedDestinations = new Set();
|
|
310
|
-
for (const [destinationKey, originKey] of tracker.originByCurrentPath) {
|
|
311
|
-
if (
|
|
312
|
-
destinationKey === originKey ||
|
|
313
|
-
tracker.currentByPath.has(originKey) ||
|
|
314
|
-
!tracker.currentByPath.has(destinationKey) ||
|
|
315
|
-
!tracker.baselineByPath.has(originKey) ||
|
|
316
|
-
tracker.baselineByPath.has(destinationKey)
|
|
317
|
-
) {
|
|
318
|
-
continue;
|
|
427
|
+
// The content is gone: which files changed is known, their diff is not.
|
|
428
|
+
for (const path of ownedPathsUnder(root, tracker.ownedPaths)) {
|
|
429
|
+
rows.push({ path, status: 'M', additions: 0, deletions: 0, text: '' });
|
|
319
430
|
}
|
|
320
|
-
renamePairs.set(originKey, destinationKey);
|
|
321
|
-
pairedDestinations.add(destinationKey);
|
|
322
431
|
}
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
let unifiedDiff = '';
|
|
342
|
-
for (const [before, after] of pairs) {
|
|
343
|
-
const beforeContent = before?.content ?? null;
|
|
344
|
-
const afterContent = after?.content ?? null;
|
|
345
|
-
const samePath = before?.displayPath === after?.displayPath;
|
|
346
|
-
if (samePath && sameContent(beforeContent, afterContent)) continue;
|
|
347
|
-
const rendered = renderTrackedPair(
|
|
348
|
-
before || { displayPath: after?.displayPath, content: null },
|
|
349
|
-
after || { displayPath: before?.displayPath, content: null }
|
|
350
|
-
);
|
|
351
|
-
unifiedDiff += rendered;
|
|
352
|
-
}
|
|
353
|
-
const bounded = boundReviewPatch(unifiedDiff, MAX_PATCH_BYTES);
|
|
354
|
-
tracker.unifiedDiff = bounded.patch;
|
|
355
|
-
tracker.patchTruncated = bounded.truncated;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function trackedPairs(tracker) {
|
|
359
|
-
const pairs = [];
|
|
360
|
-
const handledOrigins = new Set();
|
|
361
|
-
for (const [destinationKey, after] of tracker.currentByPath) {
|
|
362
|
-
const originKey = tracker.originByCurrentPath.get(destinationKey) || destinationKey;
|
|
363
|
-
handledOrigins.add(originKey);
|
|
364
|
-
pairs.push({
|
|
365
|
-
originKey,
|
|
366
|
-
destinationKey,
|
|
367
|
-
before: tracker.baselineByPath.get(originKey) || null,
|
|
368
|
-
after,
|
|
432
|
+
for (const chain of tracker.chains.values()) {
|
|
433
|
+
const edit = chainEdit(chain, root);
|
|
434
|
+
if (!edit || (kind === 'git' && !edit.inside)) continue;
|
|
435
|
+
const rendered = edit.steps.map((step) => renderStep(step, root, budget));
|
|
436
|
+
const path = viewPath(root, edit.end);
|
|
437
|
+
const oldPath = viewPath(root, edit.start);
|
|
438
|
+
const omitted = edit.steps.flatMap(({ before, after }) => [before.content, after.content]).find(isOmittedContent);
|
|
439
|
+
rows.push({
|
|
440
|
+
path,
|
|
441
|
+
...(oldPath !== path ? { oldPath } : {}),
|
|
442
|
+
status: edit.status,
|
|
443
|
+
additions: rendered.reduce((sum, step) => sum + step.additions, 0),
|
|
444
|
+
deletions: rendered.reduce((sum, step) => sum + step.deletions, 0),
|
|
445
|
+
...(edit.binary ? { binary: true } : {}),
|
|
446
|
+
// Its bytes were never captured: listed, never diffed or undone.
|
|
447
|
+
...(omitted ? { omitted: true, size: omitted.size } : {}),
|
|
448
|
+
text: rendered.map((step) => step.text).join(''),
|
|
369
449
|
});
|
|
370
450
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return target;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function pairMatchesFile(pair, worktree, file) {
|
|
389
|
-
const requested = safeTrackedTarget(worktree, file);
|
|
390
|
-
return [pair.before, pair.after].some((entry) => {
|
|
391
|
-
if (!entry) return false;
|
|
392
|
-
const values = [entry.path, entry.displayPath].filter(Boolean);
|
|
393
|
-
return values.some((value) => safeTrackedTarget(worktree, value) === requested);
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
async function currentTrackedContent(path) {
|
|
398
|
-
try {
|
|
399
|
-
const stat = await lstat(path);
|
|
400
|
-
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
401
|
-
throw new Error('turn review revert only supports regular files');
|
|
402
|
-
}
|
|
403
|
-
return await readFile(path);
|
|
404
|
-
} catch (error) {
|
|
405
|
-
if (error?.code === 'ENOENT') return null;
|
|
406
|
-
throw error;
|
|
407
|
-
}
|
|
451
|
+
rows.sort((left, right) => left.path.localeCompare(right.path));
|
|
452
|
+
const bounded = boundReviewPatch(rows.map((row) => row.text).join(''), MAX_PATCH_BYTES);
|
|
453
|
+
const counted = root ? ownCountIndex(tracker, root) : ownCountIndex(tracker, '', { display: true });
|
|
454
|
+
tracker.view = {
|
|
455
|
+
revision: tracker.revision,
|
|
456
|
+
root,
|
|
457
|
+
files: withOwnCounts(
|
|
458
|
+
rows.map(({ text, ...row }) => row),
|
|
459
|
+
counted
|
|
460
|
+
),
|
|
461
|
+
patch: bounded.patch,
|
|
462
|
+
truncated: bounded.truncated,
|
|
463
|
+
};
|
|
464
|
+
return tracker.view;
|
|
408
465
|
}
|
|
409
466
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
);
|
|
422
|
-
const afterPath = safeTrackedTarget(
|
|
423
|
-
worktree,
|
|
424
|
-
pair.after?.path || pair.after?.displayPath || pair.before?.path || pair.before?.displayPath
|
|
467
|
+
/** True when `value` (absolute, or relative to `root`) names a path the chain
|
|
468
|
+
* touched. */
|
|
469
|
+
function chainMatchesPath(chain, root, value) {
|
|
470
|
+
const target = clean(value);
|
|
471
|
+
if (!target) return false;
|
|
472
|
+
const key = changePathKey(relative(root, isAbsolute(target) ? resolve(target) : resolve(root, target)));
|
|
473
|
+
return chain.segments.some((segment) =>
|
|
474
|
+
[segment.before, segment.after].some((entry) => {
|
|
475
|
+
const path = trackedRelativePath(root, entry);
|
|
476
|
+
return Boolean(path) && changePathKey(path) === key;
|
|
477
|
+
})
|
|
425
478
|
);
|
|
426
|
-
const expectedAfter = pair.after?.content ?? null;
|
|
427
|
-
const actualAfter = await currentTrackedContent(afterPath);
|
|
428
|
-
if (!sameContent(actualAfter, expectedAfter)) {
|
|
429
|
-
throw new Error('turn review revert refused because the file changed after this session edit');
|
|
430
|
-
}
|
|
431
|
-
if (beforePath !== afterPath && (await currentTrackedContent(beforePath)) !== null) {
|
|
432
|
-
throw new Error('turn review revert refused because the original path changed after this session edit');
|
|
433
|
-
}
|
|
434
|
-
return { beforePath, afterPath };
|
|
435
479
|
}
|
|
436
480
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
481
|
+
/** The owned entries (and their counts) of every path a chain touched. */
|
|
482
|
+
function chainOwnership(tracker, chain) {
|
|
483
|
+
const owned = [
|
|
484
|
+
...new Set(
|
|
485
|
+
chain.segments
|
|
486
|
+
.flatMap((segment) => [segment.before, segment.after])
|
|
487
|
+
.map((entry) => ownedPathEntry(entry.path, displayPath(entry.displayPath || entry.path)))
|
|
488
|
+
),
|
|
489
|
+
].filter((entry) => tracker.ownedPaths.has(entry));
|
|
490
|
+
const stats = owned
|
|
491
|
+
.map((entry) => [pathKey(entry), tracker.ownStats.get(pathKey(entry))])
|
|
492
|
+
.filter(([, entry]) => entry);
|
|
493
|
+
return { owned, stats };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** An Undo reverse-applied these chains: the turn no longer changes them, so
|
|
497
|
+
* they leave its review. Each is kept for a Reapply. */
|
|
498
|
+
function undoTrackedChains(tracker, chains) {
|
|
499
|
+
for (const [key, chain] of tracker.chains) {
|
|
500
|
+
if (!chains.includes(chain)) continue;
|
|
501
|
+
const ownership = chainOwnership(tracker, chain);
|
|
502
|
+
tracker.undoneChains.set(key, { chain, ...ownership });
|
|
503
|
+
for (const entry of ownership.owned) tracker.ownedPaths.delete(entry);
|
|
504
|
+
for (const [statKey] of ownership.stats) tracker.ownStats.delete(statKey);
|
|
505
|
+
tracker.chains.delete(key);
|
|
444
506
|
}
|
|
507
|
+
tracker.revision += 1;
|
|
445
508
|
}
|
|
446
509
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
tracker.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
// Validate every current file before the first write so an external edit
|
|
460
|
-
// cannot leave a turn-wide revert half-applied.
|
|
461
|
-
const validated = [];
|
|
462
|
-
for (const pair of pairs) validated.push(await validateTrackedPair(pair, worktree));
|
|
463
|
-
for (let index = 0; index < pairs.length; index += 1) {
|
|
464
|
-
await restoreTrackedPair(pairs[index], validated[index]);
|
|
465
|
-
commitTrackedPairRestore(tracker, pairs[index]);
|
|
510
|
+
/** A Reapply forward-applied the undone chains matching `files` again. */
|
|
511
|
+
function redoTrackedChains(tracker, root, files) {
|
|
512
|
+
for (const [key, undone] of tracker.undoneChains) {
|
|
513
|
+
const matches = files.some((file) =>
|
|
514
|
+
[file.path, file.oldPath].some((path) => chainMatchesPath(undone.chain, root, path))
|
|
515
|
+
);
|
|
516
|
+
if (!matches) continue;
|
|
517
|
+
tracker.undoneChains.delete(key);
|
|
518
|
+
tracker.chains.set(key, undone.chain);
|
|
519
|
+
for (const entry of undone.owned) tracker.ownedPaths.add(entry);
|
|
520
|
+
for (const [statKey, entry] of undone.stats) tracker.ownStats.set(statKey, entry);
|
|
466
521
|
}
|
|
467
522
|
tracker.revision += 1;
|
|
468
|
-
refreshUnifiedDiff(tracker);
|
|
469
|
-
if (!tracker.unifiedDiff) releaseDiffTrackerContent(tracker);
|
|
470
|
-
// The absolute paths now back at their turn-start bytes.
|
|
471
|
-
return [...new Set(validated.flatMap(({ beforePath, afterPath }) => [beforePath, afterPath]))];
|
|
472
523
|
}
|
|
473
524
|
|
|
474
|
-
/** A
|
|
475
|
-
|
|
476
|
-
|
|
525
|
+
/** A new write supersedes whatever an Undo set aside for the paths it
|
|
526
|
+
* touches (`keys`). */
|
|
527
|
+
function dropUndoneChains(tracker, keys) {
|
|
528
|
+
for (const [key, undone] of tracker.undoneChains) {
|
|
529
|
+
const touched = undone.chain.segments.some(
|
|
530
|
+
(segment) => keys.has(pathKey(segment.before.path)) || keys.has(pathKey(segment.after.path))
|
|
531
|
+
);
|
|
532
|
+
if (touched) tracker.undoneChains.delete(key);
|
|
533
|
+
}
|
|
477
534
|
}
|
|
478
535
|
|
|
479
536
|
/**
|
|
480
537
|
* Fold exact committed before/after file states into a session's current turn.
|
|
481
538
|
* A change is `{ path, displayPath, before, after }`; a rename additionally
|
|
482
|
-
* carries `{ newPath, newDisplayPath }`.
|
|
539
|
+
* carries `{ newPath, newDisplayPath }`. `budget` is the line-diff budget of
|
|
540
|
+
* the tool call the changes come from. Returns the turn's review patch.
|
|
483
541
|
*/
|
|
484
|
-
export function recordTurnDiffChanges(sessionId, changes = []) {
|
|
542
|
+
export function recordTurnDiffChanges(sessionId, changes = [], budget = createLineDiffBudget()) {
|
|
485
543
|
if (DISABLED) return '';
|
|
486
544
|
const id = clean(sessionId);
|
|
487
545
|
if (!id) return '';
|
|
488
546
|
const tracker = _diffTrackersBySession.get(id) || resetDiffTracker(id);
|
|
489
|
-
const list = Array.isArray(changes) ? changes : [];
|
|
490
547
|
const ownerTracker = tracker?.ownerSessionId ? _diffTrackersBySession.get(tracker.ownerSessionId) : tracker;
|
|
491
548
|
const snapshot = ownerTracker?.worktreeSnapshot;
|
|
549
|
+
const raws = Array.isArray(changes) ? changes : [];
|
|
550
|
+
// Git reports the turn's root canonically (macOS /var -> /private/var):
|
|
551
|
+
// each written path is spelled the same way, or it never relates to it.
|
|
552
|
+
const list = snapshot?.root ? raws.map(canonicalChangePaths) : raws;
|
|
492
553
|
// Independent of this tracker's validity: the session review needs every
|
|
493
554
|
// write's first-seen state even once the turn's buffers are gone.
|
|
494
555
|
captureReviewBaselines(id, tracker, ownerTracker, list, snapshot);
|
|
495
|
-
if (!tracker
|
|
496
|
-
foldTrackedChanges(tracker, list, snapshot);
|
|
497
|
-
return tracker.
|
|
556
|
+
if (!tracker) return '';
|
|
557
|
+
if (tracker.valid && !tracker.sealed) foldTrackedChanges(tracker, list, snapshot);
|
|
558
|
+
return trackerView(tracker, budget).patch;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function canonicalChangePaths(raw) {
|
|
562
|
+
const path = clean(raw?.path);
|
|
563
|
+
const newPath = clean(raw?.newPath);
|
|
564
|
+
return {
|
|
565
|
+
...raw,
|
|
566
|
+
...(isAbsolute(path) ? { path: canonicalPath(path) } : {}),
|
|
567
|
+
...(isAbsolute(newPath) ? { newPath: canonicalPath(newPath) } : {}),
|
|
568
|
+
};
|
|
498
569
|
}
|
|
499
570
|
|
|
500
571
|
const insidePath = (root, path) => {
|
|
@@ -528,9 +599,9 @@ function captureReviewBaselines(sessionId, tracker, ownerTracker, list, snapshot
|
|
|
528
599
|
captureSessionBaselines(sessionId, changes, { owner: tracker?.ownerSessionId || '' });
|
|
529
600
|
}
|
|
530
601
|
|
|
531
|
-
/** Fold exact before/after states into `tracker
|
|
532
|
-
* Lead turn's Git baseline, when
|
|
533
|
-
* path's first-before bytes. */
|
|
602
|
+
/** Fold exact before/after states into `tracker` as own segments (see
|
|
603
|
+
* appendSegment). `snapshot` — the owning Lead turn's Git baseline, when
|
|
604
|
+
* tools are writing — learns each written path's first-before bytes. */
|
|
534
605
|
function foldTrackedChanges(tracker, list, snapshot) {
|
|
535
606
|
// The content ceiling is enforced BEFORE anything is stored: an over-budget
|
|
536
607
|
// batch invalidates the tracker up front instead of retaining its buffers.
|
|
@@ -559,63 +630,67 @@ function foldTrackedChanges(tracker, list, snapshot) {
|
|
|
559
630
|
// attribution a shared worktree cannot otherwise reconstruct. It keeps the
|
|
560
631
|
// ABSOLUTE file: the display path is relative to the session cwd, which
|
|
561
632
|
// is not necessarily the repository root the scoped diff runs from.
|
|
562
|
-
|
|
633
|
+
const sourceEntry = ownedPathEntry(sourcePath, sourceDisplay);
|
|
634
|
+
tracker.ownedPaths.add(sourceEntry);
|
|
635
|
+
let destination = null;
|
|
563
636
|
if (destinationPath) {
|
|
564
|
-
|
|
637
|
+
const display = displayPath(raw?.newDisplayPath || destinationPath);
|
|
638
|
+
const entry = ownedPathEntry(destinationPath, display);
|
|
639
|
+
tracker.ownedPaths.add(entry);
|
|
640
|
+
destination = { key: pathKey(entry), path: entry, display };
|
|
565
641
|
}
|
|
642
|
+
foldOwnStats(
|
|
643
|
+
tracker.ownStats,
|
|
644
|
+
raw,
|
|
645
|
+
{ key: pathKey(sourceEntry), path: sourceEntry, display: sourceDisplay },
|
|
646
|
+
destination
|
|
647
|
+
);
|
|
566
648
|
if (!storeContent) continue;
|
|
567
649
|
const after = contentBuffer(raw?.after);
|
|
568
|
-
if (!destinationPath)
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (before !== null && after === null) {
|
|
579
|
-
if (!tracker.currentByPath.delete(sourceKey) && !tracker.baselineByPath.has(sourceKey)) {
|
|
580
|
-
tracker.baselineByPath.set(sourceKey, {
|
|
581
|
-
path: sourcePath,
|
|
582
|
-
displayPath: sourceDisplay,
|
|
583
|
-
content: before,
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
tracker.originByCurrentPath.delete(sourceKey);
|
|
587
|
-
continue;
|
|
588
|
-
}
|
|
589
|
-
if (before === null && after === null) continue;
|
|
590
|
-
recordFirstBaseline(tracker, sourceKey, { path: sourcePath, displayPath: sourceDisplay, content: before });
|
|
591
|
-
tracker.currentByPath.set(sourceKey, {
|
|
592
|
-
path: sourcePath,
|
|
593
|
-
displayPath: sourceDisplay,
|
|
594
|
-
content: after,
|
|
595
|
-
});
|
|
596
|
-
continue;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
const destinationKey = pathKey(destinationPath);
|
|
600
|
-
const destinationDisplay = displayPath(raw?.newDisplayPath || destinationPath);
|
|
601
|
-
recordFirstBaseline(tracker, sourceKey, { path: sourcePath, displayPath: sourceDisplay, content: before });
|
|
602
|
-
const originKey = tracker.originByCurrentPath.get(sourceKey) || sourceKey;
|
|
603
|
-
tracker.currentByPath.delete(sourceKey);
|
|
604
|
-
tracker.originByCurrentPath.delete(sourceKey);
|
|
605
|
-
tracker.currentByPath.set(destinationKey, {
|
|
606
|
-
path: destinationPath,
|
|
607
|
-
displayPath: destinationDisplay,
|
|
608
|
-
content: after,
|
|
650
|
+
if (!destinationPath && before === null && after === null) continue;
|
|
651
|
+
dropUndoneChains(tracker, new Set([sourceKey, ...(destination ? [pathKey(destinationPath)] : [])]));
|
|
652
|
+
const seq = (_writeSeq += 1);
|
|
653
|
+
appendSegment(tracker, {
|
|
654
|
+
before: { path: sourcePath, displayPath: sourceDisplay, content: before },
|
|
655
|
+
after: destination
|
|
656
|
+
? { path: destinationPath, displayPath: destination.display, content: after }
|
|
657
|
+
: { path: sourcePath, displayPath: sourceDisplay, content: after },
|
|
658
|
+
firstSeq: seq,
|
|
659
|
+
lastSeq: seq,
|
|
609
660
|
});
|
|
610
|
-
tracker.originByCurrentPath.delete(destinationKey);
|
|
611
|
-
if (destinationKey !== originKey) tracker.originByCurrentPath.set(destinationKey, originKey);
|
|
612
661
|
}
|
|
613
662
|
tracker.revision += 1;
|
|
614
663
|
if (trackedContentBytes(tracker) > MAX_TRACKED_CONTENT_BYTES) {
|
|
615
664
|
tracker.valid = false;
|
|
616
665
|
releaseDiffTrackerContent(tracker);
|
|
617
666
|
}
|
|
618
|
-
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Join a completed worker's own segments to its Lead's tracker: both sets
|
|
670
|
+
* replay in write order, so a worker write between two Lead writes of one
|
|
671
|
+
* file continues their segment instead of reading as a foreign gap. A
|
|
672
|
+
* worker whose content was dropped drops the Lead's exact content too. */
|
|
673
|
+
function mergeTrackedContent(target, source) {
|
|
674
|
+
// The worker's owned paths and counts joined as well: any view is stale.
|
|
675
|
+
target.revision += 1;
|
|
676
|
+
if (!target.valid) return;
|
|
677
|
+
if (!source.valid) {
|
|
678
|
+
target.valid = false;
|
|
679
|
+
releaseDiffTrackerContent(target);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const segmentsOf = (tracker) => [...tracker.chains.values()].flatMap((chain) => chain.segments);
|
|
683
|
+
const written = new Set(
|
|
684
|
+
segmentsOf(source).flatMap((segment) => [pathKey(segment.before.path), pathKey(segment.after.path)])
|
|
685
|
+
);
|
|
686
|
+
const segments = [...segmentsOf(target), ...segmentsOf(source)].sort((left, right) => left.firstSeq - right.firstSeq);
|
|
687
|
+
target.chains = new Map();
|
|
688
|
+
for (const segment of segments) appendSegment(target, segment);
|
|
689
|
+
dropUndoneChains(target, written);
|
|
690
|
+
if (trackedContentBytes(target) > MAX_TRACKED_CONTENT_BYTES) {
|
|
691
|
+
target.valid = false;
|
|
692
|
+
releaseDiffTrackerContent(target);
|
|
693
|
+
}
|
|
619
694
|
}
|
|
620
695
|
|
|
621
696
|
function trimAgentReviews(turn) {
|
|
@@ -680,13 +755,14 @@ export async function beginTurnSnapshot(worktree, sessionId, options = {}) {
|
|
|
680
755
|
// Turn start and Undo/Reapply share the per-session lock: a start waits for
|
|
681
756
|
// an in-flight mutation, and a mutation queued after this start re-checks
|
|
682
757
|
// under the lock and finds the turn running.
|
|
683
|
-
//
|
|
684
|
-
// this same step
|
|
685
|
-
// the
|
|
758
|
+
// Late-worker re-seals still queued for the previous turn run first, in
|
|
759
|
+
// this same step — including one a worker queued while an earlier one ran —
|
|
760
|
+
// so the workers' edits stay in that turn's record and nothing the new turn
|
|
761
|
+
// writes can reach it. (A queued re-seal implies a chain.)
|
|
686
762
|
const { token, tracker } = _changeMutations.has(ownerSessionId)
|
|
687
763
|
? await serializeChangeMutation(ownerSessionId, async () => {
|
|
688
764
|
const previous = _diffTrackersBySession.get(ownerSessionId);
|
|
689
|
-
|
|
765
|
+
while (previous?.resealQueued) await runQueuedReseal(ownerSessionId, previous).catch(() => {});
|
|
690
766
|
return openTurnSnapshot(worktree, ownerSessionId, options);
|
|
691
767
|
})
|
|
692
768
|
: openTurnSnapshot(worktree, ownerSessionId, options);
|
|
@@ -780,24 +856,29 @@ export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {})
|
|
|
780
856
|
};
|
|
781
857
|
}
|
|
782
858
|
|
|
783
|
-
/** Freeze successful worker apply_patch diffs into their owning Lead turn.
|
|
859
|
+
/** Freeze successful worker apply_patch diffs into their owning Lead turn.
|
|
860
|
+
* Everything a seal reads — the agent entry, the worker's owned paths and a
|
|
861
|
+
* late re-seal — is published before this returns, so a seal started right
|
|
862
|
+
* after it already includes the worker. */
|
|
784
863
|
export function completeAgentTurnReview(handle, patches = []) {
|
|
785
864
|
if (!handle || handle.completed === true) return false;
|
|
786
865
|
handle.completed = true;
|
|
787
|
-
const
|
|
866
|
+
const ownerSessionId = clean(handle.ownerSessionId);
|
|
788
867
|
const key = clean(handle.sessionId) || handle.id;
|
|
868
|
+
const turn = _turnsBySession.get(ownerSessionId);
|
|
789
869
|
const tracker = _diffTrackersBySession.get(key);
|
|
790
870
|
const matchingTracker =
|
|
791
|
-
tracker && tracker.ownerSessionId ===
|
|
871
|
+
tracker && tracker.ownerSessionId === ownerSessionId && tracker.ownerGeneration === handle.generation;
|
|
792
872
|
// A worker that runs its own session turn replaces the bound tracker with
|
|
793
873
|
// its turn's own one (no owner). beginAgentTurnReview reset any older
|
|
794
874
|
// tracker, so an unowned one here holds this execution's edits.
|
|
795
875
|
const source = matchingTracker || (tracker && !tracker.ownerSessionId) ? tracker : null;
|
|
876
|
+
const ownerTracker = _diffTrackersBySession.get(ownerSessionId);
|
|
796
877
|
try {
|
|
797
878
|
if (!turn || turn.generation !== handle.generation) return false;
|
|
798
879
|
const prior = turn.agents.get(key);
|
|
799
880
|
const tracked = Boolean(source) && source.revision > 0;
|
|
800
|
-
const patch = tracked ? source.
|
|
881
|
+
const patch = tracked ? trackerView(source).patch : mergePatches(patches);
|
|
801
882
|
if (!patch) {
|
|
802
883
|
if (tracked && prior) {
|
|
803
884
|
turn.agents.delete(key);
|
|
@@ -812,15 +893,17 @@ export function completeAgentTurnReview(handle, patches = []) {
|
|
|
812
893
|
tag: handle.tag || prior?.tag || null,
|
|
813
894
|
patch: tracked ? patch : mergePatches([prior?.patch, patch]),
|
|
814
895
|
});
|
|
815
|
-
const ownerTracker = _diffTrackersBySession.get(clean(handle.ownerSessionId));
|
|
816
896
|
if (source && ownerTracker && ownerTracker !== source) {
|
|
817
897
|
for (const path of source.ownedPaths) ownerTracker.ownedPaths.add(path);
|
|
898
|
+
mergeOwnStats(ownerTracker.ownStats, source.ownStats);
|
|
899
|
+
mergeTrackedContent(ownerTracker, source);
|
|
818
900
|
}
|
|
819
901
|
trimAgentReviews(turn);
|
|
820
|
-
// A worker that finishes after its Lead turn sealed
|
|
821
|
-
// change record (its edits are part of the turn's net
|
|
902
|
+
// A worker that finishes after its Lead turn sealed (or while it seals)
|
|
903
|
+
// re-seals that turn's change record (its edits are part of the turn's net
|
|
904
|
+
// worktree diff).
|
|
822
905
|
if (ownerTracker && ownerTracker !== tracker && ownerTracker.ownerGeneration === handle.generation) {
|
|
823
|
-
resealTurn(
|
|
906
|
+
resealTurn(ownerSessionId, ownerTracker);
|
|
824
907
|
}
|
|
825
908
|
return true;
|
|
826
909
|
} finally {
|
|
@@ -839,7 +922,7 @@ export function completeAgentTurnReview(handle, patches = []) {
|
|
|
839
922
|
async function persistTurnSnapshotRecord(ownerSessionId, tracker) {
|
|
840
923
|
const snapshot = tracker?.worktreeSnapshot;
|
|
841
924
|
if (!ownerSessionId || tracker.discarded || !snapshot?.baselineTree || !snapshot.root) return;
|
|
842
|
-
const owned = [...
|
|
925
|
+
const owned = [...ownedPathsUnder(snapshot.root, tracker.ownedPaths)];
|
|
843
926
|
if (owned.length === 0) return;
|
|
844
927
|
try {
|
|
845
928
|
await saveTurnSnapshotRecord(ownerSessionId, {
|
|
@@ -868,10 +951,11 @@ function attributedAgents(ownerSessionId, tracker) {
|
|
|
868
951
|
}));
|
|
869
952
|
}
|
|
870
953
|
|
|
871
|
-
/** Path of a tracked entry relative to `root`, or '' outside it
|
|
954
|
+
/** Path of a tracked entry relative to `root`, or '' outside it (or without
|
|
955
|
+
* a root). */
|
|
872
956
|
function trackedRelativePath(root, entry) {
|
|
873
957
|
const value = clean(entry?.path) || clean(entry?.displayPath);
|
|
874
|
-
if (!value) return '';
|
|
958
|
+
if (!root || !value) return '';
|
|
875
959
|
const rel = relative(root, isAbsolute(value) ? resolve(value) : resolve(root, value));
|
|
876
960
|
if (!rel || isAbsolute(rel) || rel.split(/[\\/]+/).includes('..')) return '';
|
|
877
961
|
return rel.replace(/\\/g, '/');
|
|
@@ -879,42 +963,79 @@ function trackedRelativePath(root, entry) {
|
|
|
879
963
|
|
|
880
964
|
/** A record file for an edit the worktree root cannot replay: outside it, or
|
|
881
965
|
* too large/binary to capture. Display-only (`viewPatch`), never undone. */
|
|
882
|
-
function untrackableRecordFile(
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
966
|
+
function untrackableRecordFile(edit, root, budget) {
|
|
967
|
+
const path = viewPath(root, edit.end);
|
|
968
|
+
const oldPath = viewPath(root, edit.start);
|
|
969
|
+
const named = edit.omitted
|
|
970
|
+
? []
|
|
971
|
+
: edit.steps.map(({ before, after }) =>
|
|
972
|
+
namedChangePatch({ before: before.content, after: after.content, oldName: path, newName: path, budget })
|
|
973
|
+
);
|
|
974
|
+
const viewPatch = named.map((step) => step.patch).join('');
|
|
890
975
|
return {
|
|
891
|
-
path
|
|
892
|
-
...(
|
|
893
|
-
status,
|
|
894
|
-
additions: named
|
|
895
|
-
deletions: named
|
|
896
|
-
...(
|
|
897
|
-
...(omitted ? { binary: true } : {}),
|
|
976
|
+
path,
|
|
977
|
+
...(oldPath !== path ? { oldPath } : {}),
|
|
978
|
+
status: edit.status,
|
|
979
|
+
additions: named.reduce((sum, step) => sum + step.additions, 0),
|
|
980
|
+
deletions: named.reduce((sum, step) => sum + step.deletions, 0),
|
|
981
|
+
...(viewPatch ? { viewPatch } : {}),
|
|
982
|
+
...(edit.omitted ? { binary: true } : {}),
|
|
898
983
|
revertable: false,
|
|
899
|
-
reason: omitted ? 'binary/large file content was not captured' : 'file is outside the recorded root',
|
|
984
|
+
reason: edit.omitted ? 'binary/large file content was not captured' : 'file is outside the recorded root',
|
|
900
985
|
};
|
|
901
986
|
}
|
|
902
987
|
|
|
903
|
-
/** The
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
988
|
+
/** The root a tracker's own patches are relative to, and a getter for the
|
|
989
|
+
* object format `git apply` hashes their binary content with there (one Git
|
|
990
|
+
* call, made only when asked). */
|
|
991
|
+
function trackerPatchRoot(tracker, fallback = '') {
|
|
992
|
+
const git = Boolean(clean(tracker?.worktreeSnapshot?.root));
|
|
993
|
+
const root = git ? tracker.worktreeSnapshot.root : clean(tracker?.worktreeRoot) || clean(fallback);
|
|
994
|
+
let format = null;
|
|
995
|
+
const objectFormat = () => {
|
|
996
|
+
format ||= git ? repositoryObjectFormat(root) : Promise.resolve('sha1');
|
|
997
|
+
return format;
|
|
998
|
+
};
|
|
999
|
+
return { root: root ? resolve(root) : '', kind: git ? 'git' : 'tracked', objectFormat };
|
|
912
1000
|
}
|
|
913
1001
|
|
|
914
|
-
/**
|
|
915
|
-
|
|
1002
|
+
/** One chain's edit (chainEdit) as a record file: one replayable patch
|
|
1003
|
+
* section per own segment, oldest first, or a view-only entry when it lies
|
|
1004
|
+
* outside the root or its bytes were never captured. `objectFormat` hashes a
|
|
1005
|
+
* binary patch, `budget` bounds the line diff. */
|
|
1006
|
+
function editChangeFile(edit, root, objectFormat, budget) {
|
|
1007
|
+
if (edit.omitted || !edit.inside) return untrackableRecordFile(edit, root, budget);
|
|
1008
|
+
const sections = edit.steps.map(({ before, after, oldPath, newPath }) =>
|
|
1009
|
+
trackedChangeFile({ oldPath, newPath, before: before.content, after: after.content, objectFormat, budget })
|
|
1010
|
+
);
|
|
1011
|
+
if (sections.length === 1) return sections[0];
|
|
1012
|
+
const path = trackedRelativePath(root, edit.end);
|
|
1013
|
+
const oldPath = trackedRelativePath(root, edit.start);
|
|
1014
|
+
const base = {
|
|
1015
|
+
path,
|
|
1016
|
+
...(oldPath !== path ? { oldPath } : {}),
|
|
1017
|
+
status: edit.status,
|
|
1018
|
+
additions: sections.reduce((sum, section) => sum + section.additions, 0),
|
|
1019
|
+
deletions: sections.reduce((sum, section) => sum + section.deletions, 0),
|
|
1020
|
+
};
|
|
1021
|
+
const blocked = sections.find((section) => section.revertable !== true);
|
|
1022
|
+
if (blocked) return { ...base, revertable: false, reason: blocked.reason };
|
|
1023
|
+
return { ...base, revertable: true, patch: Buffer.concat(sections.map((section) => section.patch)) };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** The object format the binary patches of `edits` hash with: resolved (one
|
|
1027
|
+
* Git call) only when one of them replays binary content. */
|
|
1028
|
+
async function editsObjectFormat(edits, patchRoot) {
|
|
1029
|
+
const binary = edits.some((edit) => edit.binary && edit.inside && !edit.omitted);
|
|
1030
|
+
return binary ? await patchRoot.objectFormat() : 'sha1';
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Record files of the tracker's own changes. `outside` are those beyond the
|
|
1034
|
+
* root (view-only beside a Git turn's files). */
|
|
1035
|
+
async function ownRecordFiles(tracker, patchRoot) {
|
|
1036
|
+
const { root } = patchRoot;
|
|
916
1037
|
if (!tracker.valid) {
|
|
917
|
-
|
|
1038
|
+
const files = [...ownedPathsUnder(root, tracker.ownedPaths)].map((path) => ({
|
|
918
1039
|
path,
|
|
919
1040
|
status: 'M',
|
|
920
1041
|
additions: 0,
|
|
@@ -922,63 +1043,41 @@ function trackedRecordFiles(tracker, root) {
|
|
|
922
1043
|
revertable: false,
|
|
923
1044
|
reason: 'file content exceeded the undo tracking limit',
|
|
924
1045
|
}));
|
|
1046
|
+
return { files, outside: [] };
|
|
925
1047
|
}
|
|
1048
|
+
const edits = [...tracker.chains.values()].map((chain) => chainEdit(chain, root)).filter(Boolean);
|
|
1049
|
+
const objectFormat = await editsObjectFormat(edits, patchRoot);
|
|
1050
|
+
// One line-diff budget for the whole seal pass.
|
|
1051
|
+
const budget = createLineDiffBudget();
|
|
926
1052
|
const files = [];
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
if (edit.omitted || !edit.oldPath || !edit.newPath) {
|
|
931
|
-
files.push(untrackableRecordFile(pair, edit));
|
|
932
|
-
continue;
|
|
933
|
-
}
|
|
934
|
-
files.push(
|
|
935
|
-
trackedChangeFile({ oldPath: edit.oldPath, newPath: edit.newPath, before: edit.before, after: edit.after })
|
|
936
|
-
);
|
|
937
|
-
}
|
|
938
|
-
return files;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
/** Edits of a Git turn that lie outside its worktree root: the baseline diff
|
|
942
|
-
* never sees them, so they are recorded view-only beside the Git files. */
|
|
943
|
-
function outsideRecordFiles(tracker, root) {
|
|
944
|
-
if (!tracker.valid) return [];
|
|
945
|
-
const files = [];
|
|
946
|
-
for (const pair of trackedPairs(tracker)) {
|
|
947
|
-
const edit = recordedPair(pair, root);
|
|
948
|
-
if (edit && (!edit.oldPath || !edit.newPath)) {
|
|
949
|
-
files.push(untrackableRecordFile(pair, edit));
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
return files;
|
|
1053
|
+
const outside = [];
|
|
1054
|
+
for (const edit of edits) (edit.inside ? files : outside).push(editChangeFile(edit, root, objectFormat, budget));
|
|
1055
|
+
return { files, outside };
|
|
953
1056
|
}
|
|
954
1057
|
|
|
955
|
-
/** Freeze the turn's
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
1058
|
+
/** Freeze the turn's own changes into its durable change record: per file,
|
|
1059
|
+
* the session's own segments (its completed workers' included) as one
|
|
1060
|
+
* replayable patch, never the worktree's net diff, counted by the summed
|
|
1061
|
+
* deltas of those writes (`counts: 'own'`; older records without the marker
|
|
1062
|
+
* carry net worktree patches and counts). */
|
|
959
1063
|
async function freezeTurnChanges(ownerSessionId, tracker, agents) {
|
|
960
1064
|
const checkpointId = clean(tracker.checkpointId);
|
|
961
1065
|
if (!checkpointId) return null;
|
|
962
|
-
|
|
963
|
-
if (
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
const result = await freezeTurnWorktreeChanges(tracker.worktreeSnapshot, scope);
|
|
968
|
-
if (result) {
|
|
969
|
-
frozen = { kind: 'git', ...result, outside: outsideRecordFiles(tracker, tracker.worktreeSnapshot.root) };
|
|
970
|
-
}
|
|
971
|
-
} else if (tracker.worktreeRoot) {
|
|
972
|
-
frozen = { kind: 'tracked', root: tracker.worktreeRoot, files: trackedRecordFiles(tracker, tracker.worktreeRoot) };
|
|
973
|
-
}
|
|
974
|
-
if (!frozen) return null;
|
|
1066
|
+
const patchRoot = trackerPatchRoot(tracker);
|
|
1067
|
+
if (!patchRoot.root) return null;
|
|
1068
|
+
const { files, outside } = await ownRecordFiles(tracker, patchRoot);
|
|
1069
|
+
const git = patchRoot.kind === 'git';
|
|
1070
|
+
const counted = ownCountIndex(tracker, patchRoot.root);
|
|
975
1071
|
const record = await upsertTurnChangeRecord(ownerSessionId, {
|
|
976
1072
|
checkpointId,
|
|
977
|
-
kind:
|
|
978
|
-
root:
|
|
1073
|
+
kind: patchRoot.kind,
|
|
1074
|
+
root: patchRoot.root,
|
|
979
1075
|
agents,
|
|
980
|
-
|
|
981
|
-
|
|
1076
|
+
counts: 'own',
|
|
1077
|
+
// A non-Git turn has no view-only side list: an edit it cannot replay
|
|
1078
|
+
// blocks its Undo like before.
|
|
1079
|
+
files: withOwnCounts(git ? files : [...files, ...outside], counted).map(storedChangeFile),
|
|
1080
|
+
outside: git ? withOwnCounts(outside, counted).map(storedChangeFile) : [],
|
|
982
1081
|
});
|
|
983
1082
|
// A fresh freeze measured the disk itself; nothing about it is unknown.
|
|
984
1083
|
_unknownChangeStates.delete(pendingFreezeKey(ownerSessionId, checkpointId));
|
|
@@ -992,13 +1091,14 @@ const pendingFreezeKey = (ownerSessionId, checkpointId) => `${ownerSessionId}\0$
|
|
|
992
1091
|
* baseline and freeze the change record. Single-flight per tracker; a seal
|
|
993
1092
|
* requested while another runs waits for it and then seals again only if a
|
|
994
1093
|
* completion wake reopened the turn meanwhile. `reseal` (a late worker) only
|
|
995
|
-
* re-freezes a turn that is still sealed
|
|
1094
|
+
* re-freezes a turn that is still sealed, after any seal in flight: that one
|
|
1095
|
+
* captured its agents before the worker was attributed.
|
|
996
1096
|
*/
|
|
997
1097
|
function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
|
|
998
1098
|
// Its history was deleted (clearSessionChangeHistory): never written again.
|
|
999
1099
|
if (tracker.discarded) return Promise.resolve();
|
|
1000
1100
|
if (tracker.freezing) {
|
|
1001
|
-
if (reseal) return tracker.freezing;
|
|
1101
|
+
if (reseal) return tracker.freezing.then(() => sealTurn(ownerSessionId, tracker, { reseal: true }));
|
|
1002
1102
|
return tracker.freezing.then(() => (tracker.sealed ? undefined : sealTurn(ownerSessionId, tracker)));
|
|
1003
1103
|
}
|
|
1004
1104
|
const epoch = tracker.sealEpoch;
|
|
@@ -1014,6 +1114,8 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
|
|
|
1014
1114
|
}
|
|
1015
1115
|
}
|
|
1016
1116
|
if (tracker.discarded || tracker.sealEpoch !== epoch || (reseal && !tracker.sealed)) return;
|
|
1117
|
+
// A re-seal never rewrites a turn once the next one opened.
|
|
1118
|
+
if (reseal && turnSuperseded(ownerSessionId, tracker)) return;
|
|
1017
1119
|
tracker.sealed = true;
|
|
1018
1120
|
await persistTurnSnapshotRecord(ownerSessionId, tracker);
|
|
1019
1121
|
try {
|
|
@@ -1021,11 +1123,8 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
|
|
|
1021
1123
|
} catch {
|
|
1022
1124
|
/* the change record adds Undo history; it never fails a turn */
|
|
1023
1125
|
}
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
// this session's bounded before/after content so Review can still undo its
|
|
1027
|
-
// own files after the turn completes.
|
|
1028
|
-
if (worktreeSnapshotUsable(tracker)) releaseDiffTrackerContent(tracker);
|
|
1126
|
+
// The bounded own before/after content stays until the next turn: a late
|
|
1127
|
+
// worker's re-seal and an Undo/Reapply of this turn fold into it.
|
|
1029
1128
|
})().finally(() => {
|
|
1030
1129
|
if (tracker.freezing === run) tracker.freezing = null;
|
|
1031
1130
|
const count = (_pendingFreezes.get(key) || 1) - 1;
|
|
@@ -1037,7 +1136,9 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
|
|
|
1037
1136
|
}
|
|
1038
1137
|
|
|
1039
1138
|
function resealTurn(ownerSessionId, tracker) {
|
|
1040
|
-
|
|
1139
|
+
// A turn still running seals with every published agent; one sealed or
|
|
1140
|
+
// sealing queues its re-seal (sealTurn runs it after a seal in flight).
|
|
1141
|
+
if (!(tracker.sealed || tracker.freezing) || !clean(tracker.checkpointId) || tracker.resealQueued) return;
|
|
1041
1142
|
// On the session lock: an in-flight Undo/Reapply lands before the re-freeze
|
|
1042
1143
|
// reads the worktree. A turn start queued ahead of it runs it before opening
|
|
1043
1144
|
// (beginTurnSnapshot), so one queued re-seal covers every late worker.
|
|
@@ -1045,10 +1146,17 @@ function resealTurn(ownerSessionId, tracker) {
|
|
|
1045
1146
|
void serializeChangeMutation(ownerSessionId, () => runQueuedReseal(ownerSessionId, tracker)).catch(() => {});
|
|
1046
1147
|
}
|
|
1047
1148
|
|
|
1149
|
+
/** True once a newer turn of the session opened over `tracker`'s. */
|
|
1150
|
+
function turnSuperseded(ownerSessionId, tracker) {
|
|
1151
|
+
const turn = _turnsBySession.get(ownerSessionId);
|
|
1152
|
+
return Boolean(turn) && turn.generation !== tracker.ownerGeneration;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1048
1155
|
/** Run `tracker`'s queued re-seal once, whichever lock step reaches it first. */
|
|
1049
1156
|
function runQueuedReseal(ownerSessionId, tracker) {
|
|
1050
1157
|
if (!tracker.resealQueued) return undefined;
|
|
1051
1158
|
tracker.resealQueued = false;
|
|
1159
|
+
if (turnSuperseded(ownerSessionId, tracker)) return undefined;
|
|
1052
1160
|
return sealTurn(ownerSessionId, tracker, { reseal: true });
|
|
1053
1161
|
}
|
|
1054
1162
|
|
|
@@ -1065,92 +1173,124 @@ export async function completeTurnSnapshot(sessionId, expected = null) {
|
|
|
1065
1173
|
return true;
|
|
1066
1174
|
}
|
|
1067
1175
|
|
|
1068
|
-
/**
|
|
1069
|
-
*
|
|
1070
|
-
* worktree cannot attribute concurrent mutations to one session, and a
|
|
1071
|
-
* non-Git worktree never had a baseline to begin with. In both cases the
|
|
1072
|
-
* exact apply_patch buffers are the only safe revert source.
|
|
1073
|
-
*/
|
|
1176
|
+
/** Is this turn's review labelled with its uncontended Git baseline? (The
|
|
1177
|
+
* review itself is always the tracker's own segments.) */
|
|
1074
1178
|
function worktreeSnapshotUsable(tracker) {
|
|
1075
1179
|
return Boolean(tracker && !tracker.worktreeContended && tracker.worktreeSnapshot);
|
|
1076
1180
|
}
|
|
1077
1181
|
|
|
1078
|
-
/**
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
const
|
|
1084
|
-
const relativeTarget =
|
|
1085
|
-
normalizedRoot && normalizedTarget.startsWith(`${normalizedRoot}/`)
|
|
1086
|
-
? normalizedTarget.slice(normalizedRoot.length + 1)
|
|
1087
|
-
: normalizedTarget;
|
|
1088
|
-
const normalizedOwned = pathKey(owned);
|
|
1089
|
-
return normalizedOwned === relativeTarget || normalizedOwned === normalizedTarget;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
/** Re-open the recorded baseline for a session whose tracker is gone. Returns
|
|
1093
|
-
* null when the record is missing or its tree object has been collected. */
|
|
1094
|
-
async function resumeRecordedSnapshot(ownerSessionId, { omitPatch = false } = {}) {
|
|
1095
|
-
if (!ownerSessionId) return null;
|
|
1096
|
-
let record = null;
|
|
1097
|
-
try {
|
|
1098
|
-
record = await loadTurnSnapshotRecord(ownerSessionId);
|
|
1099
|
-
} catch {
|
|
1100
|
-
return null;
|
|
1101
|
-
}
|
|
1182
|
+
/** The latest turn's review once its tracker is gone (the next turn replaced
|
|
1183
|
+
* it, the cache evicted it, the runtime restarted): its change record's
|
|
1184
|
+
* files still applied, with their own counts and recorded patches — never a
|
|
1185
|
+
* worktree diff, so a foreign edit inside an own file stays out. */
|
|
1186
|
+
async function recordedTurnReview(ownerSessionId, { omitPatch }) {
|
|
1187
|
+
const record = await latestTurnRecord(ownerSessionId);
|
|
1102
1188
|
if (!record) return null;
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
void saveTurnReviewSummary(ownerSessionId, resumed.record, resumed.snapshot);
|
|
1189
|
+
const files = (record.files || []).filter((file) => fileState(file) === 'applied');
|
|
1190
|
+
const patches = files.map(changeDisplayPatch);
|
|
1191
|
+
const rows = files.map((file, index) => {
|
|
1192
|
+
const row = {
|
|
1193
|
+
path: file.path,
|
|
1194
|
+
...(file.oldPath ? { oldPath: file.oldPath } : {}),
|
|
1195
|
+
status: file.status,
|
|
1196
|
+
additions: Number(file.additions) || 0,
|
|
1197
|
+
deletions: Number(file.deletions) || 0,
|
|
1198
|
+
...(file.binary ? { binary: true } : {}),
|
|
1199
|
+
};
|
|
1200
|
+
// The record dropped its patch for size: still listed too large.
|
|
1201
|
+
return recordPatchOmitted(file, patches[index]) ? patchOmittedFile(row) : row;
|
|
1202
|
+
});
|
|
1203
|
+
const shaped = omitPatch ? { files: rows, patch: '' } : perFileReview(rows, patches.join(''));
|
|
1119
1204
|
return {
|
|
1120
1205
|
supported: true,
|
|
1121
|
-
files:
|
|
1122
|
-
patch:
|
|
1123
|
-
...(
|
|
1206
|
+
files: shaped.files,
|
|
1207
|
+
patch: shaped.patch,
|
|
1208
|
+
...(omitPatch ? { patchOmitted: true } : {}),
|
|
1209
|
+
...(shaped.diffTooLarge ? { diffTooLarge: true } : {}),
|
|
1124
1210
|
snapshotKind: 'scoped',
|
|
1125
1211
|
revertMode: 'scoped',
|
|
1126
|
-
checkpointId: clean(
|
|
1127
|
-
patchTruncated:
|
|
1212
|
+
checkpointId: clean(record.checkpointId),
|
|
1213
|
+
patchTruncated: false,
|
|
1128
1214
|
authoritative: true,
|
|
1129
1215
|
agents: publicAgentReviews(ownerSessionId),
|
|
1130
1216
|
reason: 'recorded',
|
|
1131
1217
|
};
|
|
1132
1218
|
}
|
|
1133
1219
|
|
|
1134
|
-
/**
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1220
|
+
/** A record file kept without its patch for size. */
|
|
1221
|
+
const recordPatchOmitted = (file, patch) => file?.patchOmitted === true && !patch;
|
|
1222
|
+
|
|
1223
|
+
/** A review reply's files and patch under the per-file caps: a file whose
|
|
1224
|
+
* sections were cut for display, or pass the per-file cap or the total left,
|
|
1225
|
+
* is listed with its counts but without its patch (read on its own when it
|
|
1226
|
+
* is opened). `diffTooLarge` once nothing fit. */
|
|
1227
|
+
function perFileReview(files, patch) {
|
|
1228
|
+
const sections = new Map();
|
|
1229
|
+
for (const section of splitPatchSections(Buffer.from(patch || '', 'utf8'))) {
|
|
1230
|
+
const key = pathKey(section.newPath ?? section.oldPath ?? '');
|
|
1231
|
+
const entry = sections.get(key) || { parts: [], bytes: 0, cut: false };
|
|
1232
|
+
entry.parts.push(section.patch);
|
|
1233
|
+
entry.bytes += section.patch.length;
|
|
1234
|
+
entry.cut ||= reviewSectionTruncated(section.patch);
|
|
1235
|
+
sections.set(key, entry);
|
|
1236
|
+
}
|
|
1237
|
+
const budget = createReviewPatchBudget();
|
|
1238
|
+
const omitted = new Set();
|
|
1239
|
+
const kept = [];
|
|
1240
|
+
for (const [key, entry] of sections) {
|
|
1241
|
+
if (!entry.cut && budget.admit(entry.bytes)) kept.push(...entry.parts);
|
|
1242
|
+
else omitted.add(key);
|
|
1243
|
+
}
|
|
1244
|
+
const shaped = files.map((file) => (omitted.has(pathKey(file.path)) ? patchOmittedFile(file) : file));
|
|
1245
|
+
const text = Buffer.concat(kept).toString('utf8');
|
|
1246
|
+
return { files: shaped, patch: text, diffTooLarge: reviewDiffTooLarge(shaped, text) };
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** The latest turn's change record, its unrecordable file states applied. */
|
|
1250
|
+
async function latestTurnRecord(ownerSessionId) {
|
|
1251
|
+
const checkpointId = await latestRecordedCheckpoint(ownerSessionId);
|
|
1252
|
+
const stored = checkpointId ? await loadTurnChangeRecord(ownerSessionId, checkpointId).catch(() => null) : null;
|
|
1253
|
+
return withUnknownChangeStates(ownerSessionId, stored);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** One file's whole display patch in the tracker's view, rendered on its own
|
|
1257
|
+
* instead of within the reply's caps; '' when the view lists no such file. */
|
|
1258
|
+
function trackerFilePatch(tracker, path) {
|
|
1259
|
+
const { root, kind } = trackerPatchRoot(tracker);
|
|
1260
|
+
const key = pathKey(path);
|
|
1261
|
+
const budget = createLineDiffBudget();
|
|
1262
|
+
for (const chain of tracker.chains.values()) {
|
|
1263
|
+
const edit = chainEdit(chain, root);
|
|
1264
|
+
if (!edit || (kind === 'git' && !edit.inside) || pathKey(viewPath(root, edit.end)) !== key) continue;
|
|
1265
|
+
return edit.steps.map((step) => renderStep(step, root, budget).text).join('');
|
|
1266
|
+
}
|
|
1267
|
+
return '';
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/** One file of the latest turn's review with its whole patch, up to the
|
|
1271
|
+
* file-patch read's cap: the file a reply listed `patchOmitted`. */
|
|
1272
|
+
export async function getTurnReviewFilePatch(_worktree, sessionId, file) {
|
|
1273
|
+
const path = clean(file).replace(/\\/g, '/');
|
|
1274
|
+
if (DISABLED) return { supported: false, path, patch: '' };
|
|
1275
|
+
const ownerSessionId = clean(sessionId);
|
|
1276
|
+
if (!ownerSessionId || !path) return { supported: true, path, patch: '' };
|
|
1277
|
+
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1278
|
+
if (tracker) return filePatchReply(path, trackerFilePatch(tracker, path));
|
|
1279
|
+
// Only a file the recorded review lists, by its own path.
|
|
1280
|
+
const record = await latestTurnRecord(ownerSessionId);
|
|
1281
|
+
const match = record ? findChangeFile(record, path) : null;
|
|
1282
|
+
if (!match || fileState(match) !== 'applied' || changePathKey(match.path) !== changePathKey(relativeRecordPath(record, path))) {
|
|
1283
|
+
return filePatchReply(path, '');
|
|
1284
|
+
}
|
|
1285
|
+
const patch = changeDisplayPatch(match);
|
|
1286
|
+
if (recordPatchOmitted(match, patch)) return { ...filePatchReply(path, ''), ...patchOmittedFile({}) };
|
|
1287
|
+
return filePatchReply(path, patch);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** `value` as findChangeFile reads it: relative to the record's root. */
|
|
1291
|
+
function relativeRecordPath(record, value) {
|
|
1292
|
+
const target = clean(value).replace(/\\/g, '/');
|
|
1293
|
+
return isAbsolute(target) && record?.root ? relative(record.root, resolve(target)).replace(/\\/g, '/') : target;
|
|
1154
1294
|
}
|
|
1155
1295
|
|
|
1156
1296
|
function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
|
|
@@ -1163,69 +1303,38 @@ function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
|
|
|
1163
1303
|
}
|
|
1164
1304
|
}
|
|
1165
1305
|
|
|
1166
|
-
/**
|
|
1167
|
-
|
|
1168
|
-
const resumed = await resumeRecordedSnapshot(ownerSessionId);
|
|
1169
|
-
if (!resumed) return null;
|
|
1170
|
-
assertCheckpointMatches(expectedCheckpointId, resumed.record.checkpointId);
|
|
1171
|
-
const owned = resumed.record.toolFiles;
|
|
1172
|
-
const targets = file ? owned.filter((path) => recordedPathMatchesFile(path, resumed.record.root, file)) : owned;
|
|
1173
|
-
if (targets.length === 0) {
|
|
1174
|
-
throw new Error('turn review revert cannot attribute this file to the session');
|
|
1175
|
-
}
|
|
1176
|
-
const paths = await revertTurnWorktreePaths(resumed.snapshot, targets);
|
|
1177
|
-
return { checkpointId: clean(resumed.record.checkpointId), paths };
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
/** Return the authoritative Lead net diff plus attributed child reviews. */
|
|
1306
|
+
/** Return the Lead turn's own-file diff plus attributed child reviews, and
|
|
1307
|
+
* `historyTurns`: the retained turn records clearSessionChangeHistory deletes. */
|
|
1181
1308
|
export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
|
|
1182
|
-
if (DISABLED) return { supported: false, files: [], patch: '', agents: [] };
|
|
1309
|
+
if (DISABLED) return { supported: false, files: [], patch: '', agents: [], historyTurns: 0 };
|
|
1183
1310
|
const ownerSessionId = clean(sessionId);
|
|
1311
|
+
const review = await readTurnReview(ownerSessionId, options);
|
|
1312
|
+
const records = await loadTurnChangeRecords(ownerSessionId).catch(() => []);
|
|
1313
|
+
return { ...review, historyTurns: records.length };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function readTurnReview(ownerSessionId, options) {
|
|
1184
1317
|
const turn = _turnsBySession.get(ownerSessionId);
|
|
1185
1318
|
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
// Only the review bar's interval timer sends `poll: 'background'`; an
|
|
1189
|
-
// open review (asking for its patch, no `summary`) keeps a short gap.
|
|
1190
|
-
// Every other read (opening the review, a manual or tool-boundary
|
|
1191
|
-
// refresh, a client that sends no `poll`) captures the worktree now.
|
|
1192
|
-
const refreshOptions = options.poll === 'background' ? { visible: options.summary !== true } : { force: true };
|
|
1193
|
-
try {
|
|
1194
|
-
await refreshTurnWorktreeSnapshot(isolatedWorktreeSnapshot, refreshOptions);
|
|
1195
|
-
} catch {
|
|
1196
|
-
// Best-effort refresh: the review reads the last captured state.
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
const snapshot = isolatedWorktreeSnapshot;
|
|
1200
|
-
// Without a usable baseline the review falls back to this session's own
|
|
1201
|
-
// mutations — including a worktree that has no Git repository at all, which
|
|
1202
|
-
// used to leave the bar with no revert mode whatsoever.
|
|
1203
|
-
const trackedRevertAvailable = !snapshot && tracker?.valid === true && trackedPairs(tracker).length > 0;
|
|
1204
|
-
if (!snapshot && !trackedRevertAvailable) {
|
|
1205
|
-
// Both in-memory sources are gone: the next turn replaced the tracker, the
|
|
1206
|
-
// turn cache evicted it, or the runtime restarted. The recorded baseline is
|
|
1207
|
-
// still on disk and still exact. Rebuilt per read and then discarded, so a
|
|
1208
|
-
// summary read skips its patch text instead of dropping it afterwards.
|
|
1209
|
-
// A collapsed read that asked for no refresh (entering the session) takes
|
|
1210
|
-
// the last computed summary instead of queueing a rescan.
|
|
1211
|
-
if (options.refresh === false && options.summary === true) {
|
|
1212
|
-
const cached = await cachedReviewFromRecord(ownerSessionId);
|
|
1213
|
-
if (cached) return cached;
|
|
1214
|
-
}
|
|
1215
|
-
const recorded = await scopedReviewFromRecord(ownerSessionId, { omitPatch: options.summary === true });
|
|
1319
|
+
if (!tracker) {
|
|
1320
|
+
const recorded = await recordedTurnReview(ownerSessionId, { omitPatch: options.summary === true });
|
|
1216
1321
|
if (recorded) return recorded;
|
|
1217
1322
|
}
|
|
1323
|
+
const view = tracker ? trackerView(tracker) : null;
|
|
1324
|
+
const worktree = worktreeSnapshotUsable(tracker);
|
|
1218
1325
|
let revertMode = '';
|
|
1219
|
-
if (
|
|
1220
|
-
else if (
|
|
1326
|
+
if (worktree) revertMode = 'worktree';
|
|
1327
|
+
else if (tracker?.valid && view.files.length) revertMode = 'tracked';
|
|
1328
|
+
const shaped = perFileReview(view?.files || [], view?.patch || '');
|
|
1221
1329
|
return {
|
|
1222
1330
|
supported: true,
|
|
1223
|
-
files:
|
|
1224
|
-
patch:
|
|
1225
|
-
|
|
1331
|
+
files: shaped.files,
|
|
1332
|
+
patch: shaped.patch,
|
|
1333
|
+
...(shaped.diffTooLarge ? { diffTooLarge: true } : {}),
|
|
1334
|
+
snapshotKind: worktree ? 'worktree' : 'tool',
|
|
1226
1335
|
revertMode,
|
|
1227
1336
|
checkpointId: clean(turn?.checkpointId || tracker?.checkpointId),
|
|
1228
|
-
patchTruncated:
|
|
1337
|
+
patchTruncated: false,
|
|
1229
1338
|
authoritative: Boolean(turn && tracker),
|
|
1230
1339
|
agents: publicAgentReviews(ownerSessionId),
|
|
1231
1340
|
...(turn ? { generation: turn.generation } : { reason: 'no-turn' }),
|
|
@@ -1259,16 +1368,83 @@ export async function getSessionReviewDiff(worktree, sessionId) {
|
|
|
1259
1368
|
// session recorded before baselines existed) come from them.
|
|
1260
1369
|
loadTurnChangeRecords(ownerSessionId).catch(() => []),
|
|
1261
1370
|
]);
|
|
1262
|
-
|
|
1263
|
-
// `historyTurns`: the retained turn records clearSessionChangeHistory deletes.
|
|
1264
|
-
return { ...merged, historyTurns: records.length };
|
|
1371
|
+
return await sessionChangeReview(review, { baselines, records });
|
|
1265
1372
|
}
|
|
1266
1373
|
|
|
1267
|
-
|
|
1374
|
+
/** What the session review diffs inside a Git root: the stored scope (or
|
|
1375
|
+
* live tracker) of this worktree, the paths the session owns there, and its
|
|
1376
|
+
* earliest baseline tree with the recorded first states that complete it. */
|
|
1377
|
+
async function sessionReviewScope(worktree, ownerSessionId) {
|
|
1268
1378
|
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1269
1379
|
const scopes = await loadSessionSnapshotRecords(ownerSessionId).catch(() => []);
|
|
1270
1380
|
const stored = scopes.find((scope) => scopeMatchesWorktree(scope, worktree, tracker));
|
|
1271
1381
|
const root = stored?.root || tracker?.worktreeSnapshot?.root || clean(worktree);
|
|
1382
|
+
const ownedPaths = new Set(stored?.toolFiles || []);
|
|
1383
|
+
for (const path of ownedPathsUnder(root, tracker?.ownedPaths)) ownedPaths.add(path);
|
|
1384
|
+
const baselineTree = stored?.baselineTree || tracker?.worktreeSnapshot?.baselineTree;
|
|
1385
|
+
const baselineFiles = new Map((stored?.baselineFiles || []).map((entry) => [pathKey(entry.path), entry]));
|
|
1386
|
+
const storedPaths = new Set((stored?.toolFiles || []).map(pathKey));
|
|
1387
|
+
for (const entry of tracker?.worktreeSnapshot?.baselineFiles.values() || []) {
|
|
1388
|
+
if (!storedPaths.has(pathKey(entry.path))) baselineFiles.set(pathKey(entry.path), entry);
|
|
1389
|
+
}
|
|
1390
|
+
return { tracker, stored, root, ownedPaths, baselineTree, baselineFiles: [...baselineFiles.values()] };
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/** `value` (absolute, or relative to `root`) as a path relative to `root`;
|
|
1394
|
+
* null outside it. */
|
|
1395
|
+
function relativeInside(root, value) {
|
|
1396
|
+
const target = clean(value);
|
|
1397
|
+
if (!root || !target) return null;
|
|
1398
|
+
const rel = relative(resolve(root), isAbsolute(target) ? resolve(target) : resolve(root, target));
|
|
1399
|
+
if (!rel || isAbsolute(rel) || rel.split(/[\\/]+/).includes('..')) return null;
|
|
1400
|
+
return rel.replace(/\\/g, '/');
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** One file of the session review with its whole patch, up to the file-patch
|
|
1404
|
+
* read's cap: the file a reply listed `patchOmitted`. `file` is the listed
|
|
1405
|
+
* file's `id` or path, `oldPath` a rename's source. Inside the Git root it is
|
|
1406
|
+
* the session's baseline tree against the worktree now for that path alone;
|
|
1407
|
+
* elsewhere its first-seen state measured against the disk now. */
|
|
1408
|
+
export async function getSessionReviewFilePatch(worktree, sessionId, file, oldPath = '') {
|
|
1409
|
+
const path = clean(file).replace(/\\/g, '/');
|
|
1410
|
+
if (DISABLED) return { supported: false, path, patch: '' };
|
|
1411
|
+
const ownerSessionId = clean(sessionId);
|
|
1412
|
+
if (!ownerSessionId || !path) return { supported: true, path, patch: '' };
|
|
1413
|
+
const scope = await sessionReviewScope(worktree, ownerSessionId);
|
|
1414
|
+
const rel = scope.baselineTree ? relativeInside(scope.root, path) : null;
|
|
1415
|
+
if (rel !== null) {
|
|
1416
|
+
// Each target is authorized on its own: an unowned file is never read,
|
|
1417
|
+
// an unowned rename source never joins its diff.
|
|
1418
|
+
const owned = new Set([...scope.ownedPaths].map(pathKey));
|
|
1419
|
+
if (!owned.has(pathKey(rel))) return { supported: true, path, patch: '' };
|
|
1420
|
+
const source = relativeInside(scope.root, oldPath) || '';
|
|
1421
|
+
const oldRel = source && owned.has(pathKey(source)) ? source : '';
|
|
1422
|
+
const result = await resumeTurnWorktreeFilePatch(scope.root, scope.baselineTree, {
|
|
1423
|
+
paths: [...scope.ownedPaths],
|
|
1424
|
+
baselineFiles: scope.baselineFiles,
|
|
1425
|
+
path: rel,
|
|
1426
|
+
oldPath: oldRel,
|
|
1427
|
+
maxBytes: REVIEW_FILE_PATCH_SHOW_MAX_BYTES,
|
|
1428
|
+
}).catch(() => null);
|
|
1429
|
+
if (result) return { supported: true, path, ...result };
|
|
1430
|
+
}
|
|
1431
|
+
const [base, baselines, records] = await Promise.all([
|
|
1432
|
+
// Beside a Git baseline a file outside its root is measured from its
|
|
1433
|
+
// first state; the Git side's own files need no diff for that.
|
|
1434
|
+
rel === null && scope.baselineTree && scope.root
|
|
1435
|
+
? { supported: true, root: scope.root, files: [], patch: '', source: 'git' }
|
|
1436
|
+
: baselineSessionReviewDiff(worktree, ownerSessionId),
|
|
1437
|
+
loadSessionBaselines(ownerSessionId).catch(() => null),
|
|
1438
|
+
loadTurnChangeRecords(ownerSessionId).catch(() => []),
|
|
1439
|
+
]);
|
|
1440
|
+
return await sessionChangeReview(base, { baselines, records, file: path });
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
1444
|
+
const { tracker, stored, root, ownedPaths, baselineTree, baselineFiles } = await sessionReviewScope(
|
|
1445
|
+
worktree,
|
|
1446
|
+
ownerSessionId
|
|
1447
|
+
);
|
|
1272
1448
|
// A running turn is still mutating the worktree: shell, office and script
|
|
1273
1449
|
// edits only reach the checkpoint diff once it is refreshed, exactly as the
|
|
1274
1450
|
// turn review bar does.
|
|
@@ -1279,8 +1455,6 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
|
1279
1455
|
// Best-effort refresh: the checkpoint diff uses the last captured state.
|
|
1280
1456
|
}
|
|
1281
1457
|
}
|
|
1282
|
-
const ownedPaths = new Set(stored?.toolFiles || []);
|
|
1283
|
-
for (const path of sessionAttributedPaths(root, tracker)) ownedPaths.add(path);
|
|
1284
1458
|
if (ownedPaths.size === 0) {
|
|
1285
1459
|
// Authoritatively empty only when the session left ownership metadata
|
|
1286
1460
|
// behind (a stored scope, a live tracker, or a deleted history); with
|
|
@@ -1296,21 +1470,12 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
|
1296
1470
|
source: known ? 'empty' : 'absent',
|
|
1297
1471
|
};
|
|
1298
1472
|
}
|
|
1299
|
-
const baselineTree = stored?.baselineTree || tracker?.worktreeSnapshot?.baselineTree;
|
|
1300
1473
|
if (root && baselineTree) {
|
|
1301
|
-
const baselineFiles = new Map((stored?.baselineFiles || []).map((entry) => [pathKey(entry.path), entry]));
|
|
1302
|
-
const storedPaths = new Set((stored?.toolFiles || []).map(pathKey));
|
|
1303
|
-
for (const entry of tracker?.worktreeSnapshot?.baselineFiles.values() || []) {
|
|
1304
|
-
if (!storedPaths.has(pathKey(entry.path))) baselineFiles.set(pathKey(entry.path), entry);
|
|
1305
|
-
}
|
|
1306
1474
|
const snapshot = await resumeTurnWorktreeSnapshot(root, baselineTree, {
|
|
1307
1475
|
paths: [...ownedPaths],
|
|
1308
|
-
baselineFiles
|
|
1476
|
+
baselineFiles,
|
|
1309
1477
|
}).catch(() => null);
|
|
1310
1478
|
if (snapshot) {
|
|
1311
|
-
// The island counts only what is still uncommitted: the owned files
|
|
1312
|
-
// that differ from HEAD now, measured against HEAD.
|
|
1313
|
-
const uncommittedFiles = await diffTurnWorktreeHead(snapshot, [...ownedPaths]).catch(() => null);
|
|
1314
1479
|
return {
|
|
1315
1480
|
supported: true,
|
|
1316
1481
|
root,
|
|
@@ -1320,7 +1485,6 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
|
1320
1485
|
snapshotKind: 'session',
|
|
1321
1486
|
authoritative: true,
|
|
1322
1487
|
source: 'git',
|
|
1323
|
-
...(uncommittedFiles ? { uncommittedFiles } : {}),
|
|
1324
1488
|
};
|
|
1325
1489
|
}
|
|
1326
1490
|
}
|
|
@@ -1333,8 +1497,7 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
|
1333
1497
|
supported: true,
|
|
1334
1498
|
root,
|
|
1335
1499
|
files: [],
|
|
1336
|
-
patch:
|
|
1337
|
-
patchTruncated: tracker.patchTruncated === true,
|
|
1500
|
+
patch: '',
|
|
1338
1501
|
snapshotKind: 'session-tool',
|
|
1339
1502
|
authoritative: true,
|
|
1340
1503
|
source: 'tool',
|
|
@@ -1354,52 +1517,105 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
|
1354
1517
|
};
|
|
1355
1518
|
}
|
|
1356
1519
|
|
|
1357
|
-
|
|
1358
|
-
function unavailableTrackedRevertReason(tracker) {
|
|
1359
|
-
return tracker.worktreeContended
|
|
1360
|
-
? 'turn review revert is unavailable while sessions share a worktree'
|
|
1361
|
-
: 'turn review tracked file snapshot is unavailable';
|
|
1362
|
-
}
|
|
1520
|
+
const UNATTRIBUTED_FILE = 'turn review revert cannot attribute this file to the session';
|
|
1363
1521
|
|
|
1364
|
-
/**
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
|
|
1522
|
+
/** Reverse-apply the latest turn's own changes — every file its own tools
|
|
1523
|
+
* wrote (`file` null) or the own file the user picked. A sealed turn undoes
|
|
1524
|
+
* its change record (state included); a running one its tracker's own
|
|
1525
|
+
* segments. Resolves to the turn's checkpoint, the absolute paths touched and
|
|
1526
|
+
* whether the record already carries the new state. */
|
|
1527
|
+
async function revertLatestTurnUnlocked(worktree, ownerSessionId, file, expectedCheckpointId) {
|
|
1369
1528
|
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1370
1529
|
if (tracker) assertCheckpointMatches(expectedCheckpointId, tracker.checkpointId);
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
const
|
|
1375
|
-
if (
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1530
|
+
if (!tracker || tracker.sealed) {
|
|
1531
|
+
const checkpointId = clean(tracker?.checkpointId) || (await latestRecordedCheckpoint(ownerSessionId));
|
|
1532
|
+
if (!tracker) assertCheckpointMatches(expectedCheckpointId, checkpointId);
|
|
1533
|
+
const record = checkpointId ? await loadTurnChangeRecord(ownerSessionId, checkpointId) : null;
|
|
1534
|
+
if (record) return await revertRecordedTurn(ownerSessionId, withUnknownChangeStates(ownerSessionId, record), file);
|
|
1535
|
+
if (!tracker) throw new Error('turn worktree snapshot is unavailable');
|
|
1536
|
+
}
|
|
1537
|
+
const paths = await revertTrackedTurn(tracker, worktree, file);
|
|
1538
|
+
return { checkpointId: clean(tracker.checkpointId), paths, recorded: false };
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/** The latest turn on record: the stored Git review's, else the newest change record's. */
|
|
1542
|
+
async function latestRecordedCheckpoint(ownerSessionId) {
|
|
1543
|
+
const snapshot = await loadTurnSnapshotRecord(ownerSessionId).catch(() => null);
|
|
1544
|
+
if (clean(snapshot?.checkpointId)) return clean(snapshot.checkpointId);
|
|
1545
|
+
const records = await loadTurnChangeRecords(ownerSessionId).catch(() => []);
|
|
1546
|
+
return clean(records.at(-1)?.checkpointId);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const recordFilePaths = (root, files) =>
|
|
1550
|
+
files.flatMap((entry) => [entry.path, entry.oldPath]).filter(Boolean).map((path) => resolve(root, path));
|
|
1551
|
+
|
|
1552
|
+
/** Undo a sealed turn's applied record files (all, or the one named). */
|
|
1553
|
+
async function revertRecordedTurn(ownerSessionId, record, file) {
|
|
1554
|
+
let targets;
|
|
1555
|
+
if (file) {
|
|
1556
|
+
const match = findChangeFile(record, file);
|
|
1557
|
+
if (!match) throw new Error(UNATTRIBUTED_FILE);
|
|
1558
|
+
if (fileState(match) !== 'applied') throw new Error(`${match.path} is already undone`);
|
|
1559
|
+
targets = [match];
|
|
1560
|
+
} else {
|
|
1561
|
+
targets = (record.files || []).filter((entry) => fileState(entry) === 'applied');
|
|
1387
1562
|
}
|
|
1388
|
-
if (
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1563
|
+
if (targets.length > 0) await mutateRecordFiles(ownerSessionId, record, targets, true);
|
|
1564
|
+
return { checkpointId: clean(record.checkpointId), paths: recordFilePaths(record.root, targets), recorded: true };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/** Undo a running turn's own segments (every file, or the one matching
|
|
1568
|
+
* `file`), newest first. */
|
|
1569
|
+
async function revertTrackedTurn(tracker, worktree, file) {
|
|
1570
|
+
if (!tracker.valid) throw new Error('turn review tracked file snapshot is unavailable');
|
|
1571
|
+
const patchRoot = trackerPatchRoot(tracker, resolve(clean(worktree) || '.'));
|
|
1572
|
+
const git = patchRoot.kind === 'git';
|
|
1573
|
+
let targets = [...tracker.chains.values()]
|
|
1574
|
+
.map((chain) => ({ chain, edit: chainEdit(chain, patchRoot.root) }))
|
|
1575
|
+
// Beside a Git root, an edit outside it is view-only, never undone.
|
|
1576
|
+
.filter(({ edit }) => edit && (!git || edit.inside));
|
|
1577
|
+
if (file) {
|
|
1578
|
+
const target = targets.find(({ chain }) => chainMatchesPath(chain, patchRoot.root, file));
|
|
1579
|
+
if (!target) throw new Error(UNATTRIBUTED_FILE);
|
|
1580
|
+
targets = [target];
|
|
1581
|
+
}
|
|
1582
|
+
if (targets.length === 0) return [];
|
|
1583
|
+
for (const { edit } of targets) {
|
|
1584
|
+
const omitted = edit.steps.flatMap(({ before, after }) => [before.content, after.content]).find(isOmittedContent);
|
|
1585
|
+
if (!omitted) continue;
|
|
1586
|
+
throw new Error(
|
|
1587
|
+
`turn review revert is unavailable for ${edit.start.displayPath}: binary/large file content was not captured (${omitted.size} bytes)`
|
|
1393
1588
|
);
|
|
1394
1589
|
}
|
|
1395
|
-
const
|
|
1396
|
-
|
|
1397
|
-
|
|
1590
|
+
const edits = targets.map(({ edit }) => edit);
|
|
1591
|
+
const objectFormat = await editsObjectFormat(edits, patchRoot);
|
|
1592
|
+
const budget = createLineDiffBudget();
|
|
1593
|
+
const files = edits.map((edit) => storedChangeFile(editChangeFile(edit, patchRoot.root, objectFormat, budget)));
|
|
1594
|
+
const blocked = files.find((entry) => entry.revertable !== true);
|
|
1595
|
+
if (blocked) throw new Error(`turn review revert is unavailable for ${blocked.path}: ${blocked.reason}`);
|
|
1596
|
+
await applyTurnChangeFiles({ root: patchRoot.root, kind: patchRoot.kind, counts: 'own' }, files, {
|
|
1597
|
+
reverse: true,
|
|
1598
|
+
});
|
|
1599
|
+
undoTrackedChains(
|
|
1600
|
+
tracker,
|
|
1601
|
+
targets.map(({ chain }) => chain)
|
|
1602
|
+
);
|
|
1603
|
+
const paths = recordFilePaths(patchRoot.root, files);
|
|
1604
|
+
noteTurnWorktreeChange(paths);
|
|
1605
|
+
if (tracker.worktreeSnapshot) {
|
|
1606
|
+
try {
|
|
1607
|
+
await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
|
|
1608
|
+
} catch {
|
|
1609
|
+
// Best-effort refresh: the next review read captures afresh.
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return paths;
|
|
1398
1613
|
}
|
|
1399
1614
|
|
|
1400
|
-
/** Record a composer
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1615
|
+
/** Record a running turn's composer Undo in the record an earlier seal of the
|
|
1616
|
+
* same turn left (a completion wake reopened it): a record file whose every
|
|
1617
|
+
* side (a rename's old path too) was undone is `reverted`; every other file
|
|
1618
|
+
* keeps its state. */
|
|
1403
1619
|
async function recordComposerRevert(ownerSessionId, { checkpointId, paths }) {
|
|
1404
1620
|
if (!ownerSessionId || !checkpointId) return;
|
|
1405
1621
|
const record = await loadTurnChangeRecord(ownerSessionId, checkpointId);
|
|
@@ -1425,26 +1641,28 @@ async function recordComposerRevert(ownerSessionId, { checkpointId, paths }) {
|
|
|
1425
1641
|
/** Composer Undo of the latest turn, on the per-session change lock shared
|
|
1426
1642
|
* with historical Undo/Reapply, recorded in the turn's change record so the
|
|
1427
1643
|
* historical view offers Reapply rather than a second Undo. */
|
|
1428
|
-
function revertLatestTurn(worktree, sessionId, file,
|
|
1644
|
+
function revertLatestTurn(worktree, sessionId, file, expectedCheckpointId) {
|
|
1429
1645
|
const ownerSessionId = clean(sessionId);
|
|
1430
1646
|
return serializeChangeMutation(ownerSessionId, async () => {
|
|
1431
|
-
// A seal still freezing would record the pre-restore
|
|
1647
|
+
// A seal still freezing would record the pre-restore state after this.
|
|
1432
1648
|
const freezing = _diffTrackersBySession.get(ownerSessionId)?.freezing;
|
|
1433
1649
|
if (freezing) await freezing.catch(() => {});
|
|
1434
|
-
const restored = await revertLatestTurnUnlocked(worktree, ownerSessionId, file,
|
|
1435
|
-
await recordComposerRevert(ownerSessionId, restored);
|
|
1650
|
+
const restored = await revertLatestTurnUnlocked(worktree, ownerSessionId, file, expectedCheckpointId);
|
|
1651
|
+
if (!restored.recorded) await recordComposerRevert(ownerSessionId, restored);
|
|
1436
1652
|
return await getTurnReviewDiff(worktree, ownerSessionId);
|
|
1437
1653
|
});
|
|
1438
1654
|
}
|
|
1439
1655
|
|
|
1440
|
-
/**
|
|
1656
|
+
/** Undo this turn's own changes to one reviewed file (never a restore to HEAD
|
|
1657
|
+
* or to the turn-start bytes: foreign edits outside its hunks stay). */
|
|
1441
1658
|
export function revertTurnReviewFile(worktree, sessionId, file, expectedCheckpointId = '') {
|
|
1442
|
-
return
|
|
1659
|
+
if (!clean(file)) return Promise.reject(new TypeError('turn review file path is required'));
|
|
1660
|
+
return revertLatestTurn(worktree, sessionId, file, expectedCheckpointId);
|
|
1443
1661
|
}
|
|
1444
1662
|
|
|
1445
|
-
/**
|
|
1663
|
+
/** Undo this turn's own changes to every file its tools wrote. */
|
|
1446
1664
|
export function revertTurnReview(worktree, sessionId, expectedCheckpointId = '') {
|
|
1447
|
-
return revertLatestTurn(worktree, sessionId, null,
|
|
1665
|
+
return revertLatestTurn(worktree, sessionId, null, expectedCheckpointId);
|
|
1448
1666
|
}
|
|
1449
1667
|
|
|
1450
1668
|
// ── Historical turn changes (per-turn Undo/Reapply) ─────────────────────────
|
|
@@ -1483,9 +1701,10 @@ export async function getTurnChanges(sessionId, options = {}) {
|
|
|
1483
1701
|
// An old record's outside-root file kept no counts: measure it so the card
|
|
1484
1702
|
// agrees with the session review and the island.
|
|
1485
1703
|
if (record?.files?.length) {
|
|
1704
|
+
const budget = createLineDiffBudget();
|
|
1486
1705
|
record = {
|
|
1487
1706
|
...record,
|
|
1488
|
-
files: await Promise.all(record.files.map((file) => measureLegacyChangeFile(file, record.root))),
|
|
1707
|
+
files: await Promise.all(record.files.map((file) => measureLegacyChangeFile(file, record.root, budget))),
|
|
1489
1708
|
};
|
|
1490
1709
|
}
|
|
1491
1710
|
let status = 'missing';
|
|
@@ -1553,88 +1772,68 @@ function withUnknownChangeStates(ownerSessionId, record) {
|
|
|
1553
1772
|
return keys ? { ...record, files: markUnknownStates(record.files, keys) } : record;
|
|
1554
1773
|
}
|
|
1555
1774
|
|
|
1556
|
-
/**
|
|
1557
|
-
* Undo/Reapply
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1560
|
-
|
|
1775
|
+
/** Bring the latest-turn review (composer bar, session Diff) in line with an
|
|
1776
|
+
* Undo/Reapply that just landed: signal the moved paths, fold the record's
|
|
1777
|
+
* own chains out of (or back into) the turn's tracker when it is that turn,
|
|
1778
|
+
* re-capture the Git snapshot (sealed ones too) and persist its
|
|
1779
|
+
* attribution. Best-effort: the mutation itself already landed. */
|
|
1780
|
+
async function refreshLatestTurnReview(ownerSessionId, record, targets, reverse) {
|
|
1781
|
+
noteTurnWorktreeChange(recordFilePaths(record.root, targets));
|
|
1561
1782
|
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1562
|
-
if (!tracker
|
|
1563
|
-
|
|
1564
|
-
const
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
if (inside(move.source) && inside(move.destination)) moves.push(move);
|
|
1783
|
+
if (!tracker) return;
|
|
1784
|
+
if (tracker.valid && clean(tracker.checkpointId) === clean(record.checkpointId)) {
|
|
1785
|
+
const root = resolve(record.root);
|
|
1786
|
+
if (reverse) {
|
|
1787
|
+
const matches = (chain) =>
|
|
1788
|
+
targets.some((file) => [file.path, file.oldPath].some((path) => chainMatchesPath(chain, root, path)));
|
|
1789
|
+
undoTrackedChains(tracker, [...tracker.chains.values()].filter(matches));
|
|
1790
|
+
} else {
|
|
1791
|
+
redoTrackedChains(tracker, root, targets);
|
|
1792
|
+
}
|
|
1573
1793
|
}
|
|
1574
|
-
if (
|
|
1794
|
+
if (!tracker.worktreeSnapshot) return;
|
|
1575
1795
|
try {
|
|
1576
|
-
|
|
1796
|
+
await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
|
|
1577
1797
|
} catch {
|
|
1578
|
-
|
|
1798
|
+
// Best-effort refresh: persist the last captured state.
|
|
1579
1799
|
}
|
|
1580
|
-
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
/** One moved file as a tracked change: its pre-mutation bytes and what the
|
|
1584
|
-
* mutation left at its destination. */
|
|
1585
|
-
async function movedFileChange(root, move) {
|
|
1586
|
-
const change = {
|
|
1587
|
-
path: move.source,
|
|
1588
|
-
displayPath: trackedRelativePath(root, { path: move.source }),
|
|
1589
|
-
before: move.before,
|
|
1590
|
-
after: await currentTrackedContent(move.destination),
|
|
1591
|
-
};
|
|
1592
|
-
if (move.destination === move.source) return change;
|
|
1593
|
-
return {
|
|
1594
|
-
...change,
|
|
1595
|
-
newPath: move.destination,
|
|
1596
|
-
newDisplayPath: trackedRelativePath(root, { path: move.destination }),
|
|
1597
|
-
};
|
|
1800
|
+
if (tracker.sealed) await persistTurnSnapshotRecord(ownerSessionId, tracker);
|
|
1598
1801
|
}
|
|
1599
1802
|
|
|
1600
|
-
/**
|
|
1601
|
-
*
|
|
1602
|
-
*
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
tracker.valid = false;
|
|
1612
|
-
releaseDiffTrackerContent(tracker);
|
|
1613
|
-
refreshUnifiedDiff(tracker);
|
|
1803
|
+
/** Reverse-apply (`reverse`) or forward-apply the record files `targets`
|
|
1804
|
+
* against the current disk, all-or-nothing: `git apply` checks every hunk
|
|
1805
|
+
* (every binary file's exact bytes) before it writes, so an overlapping
|
|
1806
|
+
* foreign edit fails the whole call and nothing is overwritten. Then their
|
|
1807
|
+
* state is recorded. Resolves to the updated record. */
|
|
1808
|
+
async function mutateRecordFiles(ownerSessionId, record, targets, reverse) {
|
|
1809
|
+
const verb = reverse ? 'undone' : 'reapplied';
|
|
1810
|
+
const checkpointId = clean(record.checkpointId);
|
|
1811
|
+
const blocked = targets.find((entry) => entry.revertable !== true);
|
|
1812
|
+
if (blocked) {
|
|
1813
|
+
throw new Error(`${blocked.path} cannot be ${verb}: ${blocked.reason || 'no replayable patch was recorded'}`);
|
|
1614
1814
|
}
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
* too) and persist its attribution. Best-effort: the mutation itself
|
|
1621
|
-
* already landed. */
|
|
1622
|
-
async function refreshLatestTurnReview(ownerSessionId, root, targets, fold) {
|
|
1623
|
-
noteTurnWorktreeChange(
|
|
1624
|
-
targets
|
|
1625
|
-
.flatMap((entry) => [entry.path, entry.oldPath])
|
|
1626
|
-
.filter(Boolean)
|
|
1627
|
-
.map((path) => resolve(root, path))
|
|
1628
|
-
);
|
|
1629
|
-
if (fold?.tracker.valid) await foldReviewMutation(fold);
|
|
1630
|
-
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1631
|
-
if (!tracker?.worktreeSnapshot) return;
|
|
1815
|
+
await applyTurnChangeFiles(record, targets, { reverse });
|
|
1816
|
+
// State lands only after git applied every target; a state that cannot
|
|
1817
|
+
// land takes the worktree change back with it.
|
|
1818
|
+
const keys = new Set(targets.map((entry) => changePathKey(entry.path)));
|
|
1819
|
+
let updated;
|
|
1632
1820
|
try {
|
|
1633
|
-
await
|
|
1634
|
-
} catch {
|
|
1635
|
-
|
|
1821
|
+
updated = await writeTurnChangeState(ownerSessionId, checkpointId, keys, reverse ? 'reverted' : 'applied');
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
try {
|
|
1824
|
+
await applyTurnChangeFiles(record, targets, { reverse: !reverse });
|
|
1825
|
+
} catch (rollbackError) {
|
|
1826
|
+
await markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys);
|
|
1827
|
+
await refreshLatestTurnReview(ownerSessionId, record, targets, reverse);
|
|
1828
|
+
throw new Error(
|
|
1829
|
+
`turn changes were ${verb} but could not be recorded (${error.message}), and restoring the worktree failed (${rollbackError.message}); Undo/Reapply of those files is disabled`,
|
|
1830
|
+
{ cause: error }
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
throw error;
|
|
1636
1834
|
}
|
|
1637
|
-
|
|
1835
|
+
await refreshLatestTurnReview(ownerSessionId, record, targets, reverse);
|
|
1836
|
+
return updated;
|
|
1638
1837
|
}
|
|
1639
1838
|
|
|
1640
1839
|
/** Undo (`reverse`) or Reapply one file or the whole turn, all-or-nothing. */
|
|
@@ -1663,32 +1862,7 @@ async function mutateTurnChanges(sessionId, checkpointIdValue, fileValue, revisi
|
|
|
1663
1862
|
targets = (record.files || []).filter((entry) => fileState(entry) === from);
|
|
1664
1863
|
if (targets.length === 0) throw new Error(`this turn has no changes to be ${verb}`);
|
|
1665
1864
|
}
|
|
1666
|
-
const
|
|
1667
|
-
if (blocked) {
|
|
1668
|
-
throw new Error(`${blocked.path} cannot be ${verb}: ${blocked.reason || 'no replayable patch was recorded'}`);
|
|
1669
|
-
}
|
|
1670
|
-
const fold = await trackedReviewFold(ownerSessionId, record.root, targets, reverse);
|
|
1671
|
-
const root = await applyTurnChangeFiles(record, targets, { reverse });
|
|
1672
|
-
// State lands only after git applied every target; a state that cannot
|
|
1673
|
-
// land takes the worktree change back with it.
|
|
1674
|
-
const keys = new Set(targets.map((entry) => changePathKey(entry.path)));
|
|
1675
|
-
let updated;
|
|
1676
|
-
try {
|
|
1677
|
-
updated = await writeTurnChangeState(ownerSessionId, checkpointId, keys, reverse ? 'reverted' : 'applied');
|
|
1678
|
-
} catch (error) {
|
|
1679
|
-
try {
|
|
1680
|
-
await applyTurnChangeFiles(record, targets, { reverse: !reverse });
|
|
1681
|
-
} catch (rollbackError) {
|
|
1682
|
-
await markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys);
|
|
1683
|
-
await refreshLatestTurnReview(ownerSessionId, root, targets, fold);
|
|
1684
|
-
throw new Error(
|
|
1685
|
-
`turn changes were ${verb} but could not be recorded (${error.message}), and restoring the worktree failed (${rollbackError.message}); Undo/Reapply of those files is disabled`,
|
|
1686
|
-
{ cause: error }
|
|
1687
|
-
);
|
|
1688
|
-
}
|
|
1689
|
-
throw error;
|
|
1690
|
-
}
|
|
1691
|
-
await refreshLatestTurnReview(ownerSessionId, root, targets, fold);
|
|
1865
|
+
const updated = await mutateRecordFiles(ownerSessionId, record, targets, reverse);
|
|
1692
1866
|
return turnChangesReply({
|
|
1693
1867
|
status: updated ? 'ready' : 'missing',
|
|
1694
1868
|
checkpointId,
|