mixdog 0.9.147 → 0.9.148
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/package.json +23 -3
- package/scripts/build-voice-runtime-unix.sh +101 -0
- package/scripts/embedding-quality-probe.mjs +96 -0
- package/scripts/generate-voice-runtime-manifest.mjs +300 -0
- package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
- package/scripts/native-binary-arch.mjs +30 -13
- package/scripts/native-binary-arch.test.mjs +40 -4
- package/scripts/plan-voice-runtime-release.mjs +73 -0
- package/scripts/prepare-native-assets.mjs +50 -9
- package/scripts/prune-embedding-runtime.mjs +3 -3
- package/scripts/sync-voice-runtime-manifest.mjs +46 -0
- package/scripts/verify-release-assets.mjs +106 -6
- package/scripts/verify-voice-runtime-release.mjs +89 -0
- package/scripts/voice-runtime-config.json +52 -0
- package/src/app.mjs +42 -5
- package/src/headless-exec.mjs +58 -32
- package/src/headless-exec.test.mjs +74 -13
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/10-tool-workflow.md +7 -4
- package/src/rules/shared/30-exploration.md +2 -1
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
- package/src/runtime/agent/orchestrator/config.mjs +53 -18
- package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
- package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +17 -11
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +20 -7
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +132 -42
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -3
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
- package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
- package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +45 -8
- package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
- package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +178 -29
- package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
- package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
- package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
- package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
- package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
- package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
- package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/attachments/store.mjs +11 -3
- package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
- package/src/runtime/channels/lib/event-queue.mjs +51 -41
- package/src/runtime/channels/lib/scheduler.mjs +23 -28
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
- package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
- package/src/runtime/channels/lib/webhook.mjs +39 -20
- package/src/runtime/channels/lib/whisper-server.mjs +46 -1
- package/src/runtime/channels/lib/worker-main.mjs +3 -1
- package/src/runtime/media/adapters/gemini-video.mjs +20 -4
- package/src/runtime/media/jobs.mjs +13 -0
- package/src/runtime/media/renditions.mjs +7 -1
- package/src/runtime/media/store.mjs +42 -22
- package/src/runtime/memory/index.mjs +4 -9
- package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
- package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
- package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
- package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
- package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
- package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
- package/src/runtime/memory/lib/ko-morph.mjs +46 -15
- package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
- package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
- package/src/runtime/memory/lib/memory.mjs +50 -12
- package/src/runtime/memory/lib/pg/process.mjs +1 -10
- package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
- package/src/runtime/memory/lib/query-handlers.mjs +2 -4
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
- package/src/runtime/shared/background-tasks.mjs +15 -0
- package/src/runtime/shared/background-tasks.test.mjs +78 -0
- package/src/runtime/shared/child-guardian.mjs +2 -10
- package/src/runtime/shared/pid-liveness.mjs +27 -0
- package/src/runtime/shared/runtime-root.mjs +1 -11
- package/src/runtime/shared/service-discovery.mjs +1 -17
- package/src/runtime/shared/singleton-owner.mjs +1 -11
- package/src/runtime/shared/staged-update.mjs +2 -12
- package/src/runtime/shared/tool-status.mjs +5 -1
- package/src/runtime/shared/tool-surface.mjs +15 -7
- package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
- package/src/runtime/shared/turn-snapshot.mjs +137 -13
- package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
- package/src/runtime/shared/webhook-session-run.mjs +16 -2
- package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
- package/src/runtime/web-search/lib/state.mjs +15 -15
- package/src/runtime/web-search/lib/web-tools.mjs +46 -5
- package/src/session-runtime/context-status.mjs +4 -12
- package/src/session-runtime/runtime-core.mjs +20 -1
- package/src/standalone/agent-dispatch-broker.mjs +33 -2
- package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
- package/src/standalone/agent-tool/notify.mjs +3 -1
- package/src/standalone/agent-tool/spawn-flow.mjs +48 -66
- package/src/standalone/agent-tool/tag-registry.mjs +116 -33
- package/src/standalone/agent-tool/tool-def.mjs +2 -2
- package/src/standalone/agent-tool/worker-index.mjs +8 -11
- package/src/standalone/agent-tool/worker-rows.mjs +4 -1
- package/src/standalone/agent-tool.mjs +20 -55
- package/src/standalone/agent-watchdog-registry.mjs +28 -4
- package/src/standalone/channel-client.mjs +47 -15
- package/src/standalone/channel-transport.mjs +44 -25
- package/src/standalone/daemon.mjs +31 -2
- package/src/standalone/hook-bus/config.mjs +25 -9
- package/src/standalone/hook-bus/handlers.mjs +16 -52
- package/src/standalone/hook-bus.mjs +29 -23
- package/src/standalone/memory-runtime-proxy.mjs +1 -16
- package/src/standalone/session-client.mjs +86 -23
- package/src/standalone/session-protocol.mjs +0 -2
- package/src/standalone/session-runtime-host-health.test.mjs +5 -4
- package/src/standalone/session-runtime-host.mjs +101 -6
- package/src/standalone/session-runtime-record.mjs +1 -5
- package/src/standalone/session-runtime-worker.mjs +52 -57
- package/src/standalone/session-service.mjs +4 -1
- package/src/standalone/session-transport.mjs +56 -25
- package/src/tui/App.jsx +40 -49
- package/src/tui/app/app-view.jsx +14 -21
- package/src/tui/app/core-memory-picker.mjs +29 -16
- package/src/tui/app/create-app-pickers.mjs +15 -36
- package/src/tui/app/extension-pickers.mjs +60 -49
- package/src/tui/app/maintenance-pickers.mjs +100 -57
- package/src/tui/app/message-selector.mjs +6 -5
- package/src/tui/app/model-picker.mjs +30 -13
- package/src/tui/app/onboarding-steps.mjs +21 -12
- package/src/tui/app/panel-epoch.mjs +56 -0
- package/src/tui/app/panel-epoch.test.mjs +1009 -0
- package/src/tui/app/panel-surface.mjs +98 -0
- package/src/tui/app/project-picker.mjs +30 -29
- package/src/tui/app/prompt-submit.mjs +192 -97
- package/src/tui/app/prompt-submit.test.mjs +206 -0
- package/src/tui/app/provider-setup-picker.mjs +74 -43
- package/src/tui/app/resume-picker.mjs +11 -8
- package/src/tui/app/route-pickers.mjs +45 -28
- package/src/tui/app/settings-picker.mjs +69 -18
- package/src/tui/app/shell-layout.mjs +1 -3
- package/src/tui/app/slash-commands.mjs +0 -2
- package/src/tui/app/slash-dispatch.mjs +4 -2
- package/src/tui/app/text-entry-policy.mjs +25 -0
- package/src/tui/app/theme-effort-pickers.mjs +11 -16
- package/src/tui/app/usage-context-panels.mjs +21 -25
- package/src/tui/app/use-global-key-input.mjs +3 -2
- package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
- package/src/tui/app/use-prompt-handlers.mjs +3 -2
- package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
- package/src/tui/components/Picker.jsx +1 -1
- package/src/tui/components/TextEntryPanel.jsx +7 -2
- package/src/tui/dist/index.mjs +652 -561
- package/src/tui/lib/voice-setup.mjs +1 -2
- package/src/tui/session/agent-job-feed.mjs +73 -11
- package/src/tui/session/session-api-ext.mjs +5 -6
- package/src/tui/session/session-api.mjs +1 -0
- package/src/tui/session/tool-approval.mjs +15 -5
- package/src/tui/session/tui-steering-persist.mjs +33 -19
- package/src/tui/session/tui-steering-persist.test.mjs +104 -0
- package/src/tui/session-local.mjs +0 -22
- package/scripts/.tmp-coverage-report.mjs +0 -96
- package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
- package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
|
@@ -365,7 +365,7 @@ export function toolLoadingTargets(name, args = {}) {
|
|
|
365
365
|
|
|
366
366
|
const CATEGORY_ORDER = [
|
|
367
367
|
'Read', 'Search', 'Load', 'MCP', 'Skill', 'Web Research', 'Memory',
|
|
368
|
-
'Patch', 'Git', 'Shell', 'Agent', 'Task', '
|
|
368
|
+
'Patch', 'Git', 'Shell', 'Agent', 'Task', 'Setup', 'Other',
|
|
369
369
|
];
|
|
370
370
|
|
|
371
371
|
const TOOL_CATEGORY = new Map([
|
|
@@ -443,8 +443,6 @@ const CATEGORY_COPY = new Map([
|
|
|
443
443
|
['Shell', { active: 'Running', done: 'Ran', noun: 'command' }],
|
|
444
444
|
['Agent', { active: 'Calling', done: 'Called', noun: 'agent' }],
|
|
445
445
|
['Task', { active: 'Checking', done: 'Checked', noun: 'task' }],
|
|
446
|
-
['Schedule', { active: 'Running', done: 'Ran', noun: 'schedule' }],
|
|
447
|
-
['Channel', { active: 'Sending', done: 'Sent', noun: 'message' }],
|
|
448
446
|
['Setup', { active: 'Setting up', done: 'Set up', noun: 'item' }],
|
|
449
447
|
['Other', { active: 'Calling', done: 'Called', noun: 'tool' }],
|
|
450
448
|
]);
|
|
@@ -584,8 +582,11 @@ export function toolWorkUnit(name, args = {}, category = '') {
|
|
|
584
582
|
case 'job_wait':
|
|
585
583
|
return unitDescriptor('Shell', { count: queryCount(a, 'command', 'commands', 'cmd') || 1, noun: 'command' });
|
|
586
584
|
case 'git':
|
|
587
|
-
case 'git_stage':
|
|
588
585
|
return unitDescriptor('Git', { count: queryCount(a, 'command', 'commands') || 1, noun: 'Git command' });
|
|
586
|
+
// Staging is not "running a Git command": it selects change_ids out of an
|
|
587
|
+
// existing diff. Its own work unit keeps the two apart on the activity row.
|
|
588
|
+
case 'git_stage':
|
|
589
|
+
return unitDescriptor('Git', { count: queryCount(a, 'change_ids', 'change_id') || 1, active: 'Staging', done: 'Staged', noun: 'change' });
|
|
589
590
|
case 'agent':
|
|
590
591
|
case 'bridge': {
|
|
591
592
|
const type = String(a.type || a.action || '').toLowerCase();
|
|
@@ -604,8 +605,13 @@ export function toolWorkUnit(name, args = {}, category = '') {
|
|
|
604
605
|
}
|
|
605
606
|
case 'task': {
|
|
606
607
|
const action = String(a.action || '').toLowerCase();
|
|
607
|
-
|
|
608
|
-
|
|
608
|
+
const taskCount = queryCount(a, 'task_id', 'task_ids', 'id', 'ids') || 1;
|
|
609
|
+
// Waiting on a task, enumerating tasks, and cancelling one are distinct
|
|
610
|
+
// work; only `read`/`status` falls through to the neutral check verb.
|
|
611
|
+
if (action === 'cancel') return unitDescriptor('Task', { count: taskCount, active: 'Cancelling', done: 'Cancelled', noun: 'task' });
|
|
612
|
+
if (action === 'wait') return unitDescriptor('Task', { count: taskCount, active: 'Waiting for', done: 'Waited for', noun: 'task' });
|
|
613
|
+
if (action === 'list') return unitDescriptor('Task', { count: taskCount, active: 'Listing', done: 'Listed', noun: 'task' });
|
|
614
|
+
return unitDescriptor('Task', { count: taskCount, noun: 'task' });
|
|
609
615
|
}
|
|
610
616
|
case 'skill':
|
|
611
617
|
case 'skill_execute':
|
|
@@ -620,7 +626,9 @@ export function toolWorkUnit(name, args = {}, category = '') {
|
|
|
620
626
|
count: queryCount(a, 'symbols', 'symbol', 'query', 'file', 'path') || 1,
|
|
621
627
|
active: searching ? 'Mapping' : 'Reading',
|
|
622
628
|
done: searching ? 'Mapped' : 'Read',
|
|
623
|
-
|
|
629
|
+
// "code map", not "file": an overview/imports/impact pass reads
|
|
630
|
+
// structure, and sharing the plain read unit hid it behind file reads.
|
|
631
|
+
noun: searching ? 'symbol' : 'code map',
|
|
624
632
|
});
|
|
625
633
|
}
|
|
626
634
|
case 'request_user_input':
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Durable turn-snapshot index.
|
|
2
|
+
//
|
|
3
|
+
// The shadow repository already writes every turn baseline to disk as a Git
|
|
4
|
+
// tree object, but the map from a session to that tree lived only in runtime
|
|
5
|
+
// memory. Beginning the next turn, evicting the 32-entry turn cache or
|
|
6
|
+
// restarting the daemon therefore stripped a finished review of its revert
|
|
7
|
+
// source while the tree itself was still sitting on disk (user: 실행취소는 왜
|
|
8
|
+
// 안 먹는거).
|
|
9
|
+
//
|
|
10
|
+
// Persist the three values a revert actually needs — the worktree root, the
|
|
11
|
+
// baseline tree hash, and the paths this session itself mutated — so a review
|
|
12
|
+
// stays revertible for as long as the objects survive. The session-owned path
|
|
13
|
+
// list is what lets a SHARED worktree still revert: the baseline describes the
|
|
14
|
+
// whole tree, but only these paths may be restored from it.
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
// Same resolution as session-runtime/runtime-paths.mjs. Shared runtime code
|
|
21
|
+
// cannot import the session layer, so the rule is repeated instead of inverted.
|
|
22
|
+
const DATA_DIR = process.env.MIXDOG_DATA_DIR
|
|
23
|
+
|| join(process.env.MIXDOG_HOME || join(homedir(), '.mixdog'), 'data');
|
|
24
|
+
const RECORD_VERSION = 1;
|
|
25
|
+
// Matches the shadow repository's own `gc --prune` window: once the tree object
|
|
26
|
+
// is collected the record cannot restore anything anyway.
|
|
27
|
+
const RECORD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
28
|
+
// Enough to cover the reviews a user can still act on; the newest turn wins.
|
|
29
|
+
const MAX_TURNS_PER_SESSION = 8;
|
|
30
|
+
// A bounded directory: one file per session, oldest swept first.
|
|
31
|
+
const MAX_RECORD_FILES = 512;
|
|
32
|
+
|
|
33
|
+
let recordRoot = join(DATA_DIR, 'turn-snapshots');
|
|
34
|
+
// Per-session write chain. Two turns of one session never race their own file,
|
|
35
|
+
// and a read never observes a partially written record (writes land by rename).
|
|
36
|
+
const writeChains = new Map();
|
|
37
|
+
let sweepDone = false;
|
|
38
|
+
|
|
39
|
+
function clean(value) {
|
|
40
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recordPath(sessionId) {
|
|
44
|
+
const hash = createHash('sha256').update(sessionId).digest('hex').slice(0, 24);
|
|
45
|
+
return join(recordRoot, `${hash}.json`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function chain(sessionId, task) {
|
|
49
|
+
const previous = writeChains.get(sessionId) || Promise.resolve();
|
|
50
|
+
const next = previous.then(task, task);
|
|
51
|
+
writeChains.set(sessionId, next.then(() => undefined, () => undefined));
|
|
52
|
+
return next;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readRecordFile(path) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
58
|
+
if (!parsed || parsed.version !== RECORD_VERSION) return null;
|
|
59
|
+
if (!Array.isArray(parsed.turns)) return null;
|
|
60
|
+
return parsed;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Oldest-first sweep so a long-lived install cannot accumulate records. */
|
|
67
|
+
async function sweepRecords() {
|
|
68
|
+
if (sweepDone) return;
|
|
69
|
+
sweepDone = true;
|
|
70
|
+
try {
|
|
71
|
+
const names = await readdir(recordRoot);
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const kept = [];
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
if (!name.endsWith('.json')) continue;
|
|
76
|
+
const path = join(recordRoot, name);
|
|
77
|
+
try {
|
|
78
|
+
const info = await stat(path);
|
|
79
|
+
if (now - info.mtimeMs > RECORD_TTL_MS) {
|
|
80
|
+
await rm(path, { force: true });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
kept.push({ path, mtimeMs: info.mtimeMs });
|
|
84
|
+
} catch { /* a record that cannot be read is not worth keeping */ }
|
|
85
|
+
}
|
|
86
|
+
kept.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
|
87
|
+
for (const entry of kept.slice(0, Math.max(0, kept.length - MAX_RECORD_FILES))) {
|
|
88
|
+
await rm(entry.path, { force: true });
|
|
89
|
+
}
|
|
90
|
+
} catch { /* the store is best-effort: a revert falls back to memory */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fold one turn into a session's record. `generation` identifies the turn, so
|
|
95
|
+
* re-recording the same turn updates it in place rather than appending.
|
|
96
|
+
*/
|
|
97
|
+
export async function saveTurnSnapshotRecord(sessionId, turn) {
|
|
98
|
+
const id = clean(sessionId);
|
|
99
|
+
const baselineTree = clean(turn?.baselineTree);
|
|
100
|
+
const root = clean(turn?.root);
|
|
101
|
+
if (!id || !baselineTree || !root) return false;
|
|
102
|
+
return await chain(id, async () => {
|
|
103
|
+
try {
|
|
104
|
+
await mkdir(recordRoot, { recursive: true });
|
|
105
|
+
void sweepRecords();
|
|
106
|
+
const path = recordPath(id);
|
|
107
|
+
const existing = await readRecordFile(path);
|
|
108
|
+
const generation = Number(turn?.generation) || 0;
|
|
109
|
+
const turns = (existing?.turns || []).filter((entry) => entry?.generation !== generation);
|
|
110
|
+
turns.push({
|
|
111
|
+
generation,
|
|
112
|
+
root,
|
|
113
|
+
baselineTree,
|
|
114
|
+
// Relative, forward-slashed paths this session's own tools mutated.
|
|
115
|
+
toolFiles: [...new Set((Array.isArray(turn?.toolFiles) ? turn.toolFiles : [])
|
|
116
|
+
.map((value) => clean(value))
|
|
117
|
+
.filter(Boolean))],
|
|
118
|
+
sealed: turn?.sealed === true,
|
|
119
|
+
updatedAt: Date.now(),
|
|
120
|
+
});
|
|
121
|
+
turns.sort((left, right) => (left.generation || 0) - (right.generation || 0));
|
|
122
|
+
const payload = {
|
|
123
|
+
version: RECORD_VERSION,
|
|
124
|
+
sessionId: id,
|
|
125
|
+
turns: turns.slice(-MAX_TURNS_PER_SESSION),
|
|
126
|
+
};
|
|
127
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
128
|
+
await writeFile(temporary, JSON.stringify(payload), 'utf8');
|
|
129
|
+
await rename(temporary, path);
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The newest recorded turn that actually owns files. A turn that mutated
|
|
139
|
+
* nothing has nothing to revert, and returning it would hide the completed
|
|
140
|
+
* turn behind it — which is exactly the review the user is looking at.
|
|
141
|
+
*/
|
|
142
|
+
export async function loadTurnSnapshotRecord(sessionId) {
|
|
143
|
+
const id = clean(sessionId);
|
|
144
|
+
if (!id) return null;
|
|
145
|
+
const record = await readRecordFile(recordPath(id));
|
|
146
|
+
if (!record) return null;
|
|
147
|
+
for (let index = record.turns.length - 1; index >= 0; index -= 1) {
|
|
148
|
+
const turn = record.turns[index];
|
|
149
|
+
if (!turn?.baselineTree || !turn?.root) continue;
|
|
150
|
+
if (!Array.isArray(turn.toolFiles) || turn.toolFiles.length === 0) continue;
|
|
151
|
+
return turn;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function deleteTurnSnapshotRecords(sessionId) {
|
|
157
|
+
const id = clean(sessionId);
|
|
158
|
+
if (!id) return;
|
|
159
|
+
await chain(id, async () => {
|
|
160
|
+
try { await rm(recordPath(id), { force: true }); } catch { /* best effort */ }
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function _setTurnSnapshotStoreRootForTest(directory) {
|
|
165
|
+
recordRoot = clean(directory) || join(DATA_DIR, 'turn-snapshots');
|
|
166
|
+
writeChains.clear();
|
|
167
|
+
sweepDone = false;
|
|
168
|
+
}
|
|
@@ -5,9 +5,12 @@ import {
|
|
|
5
5
|
_resetTurnWorktreeSnapshotsForTest,
|
|
6
6
|
createTurnWorktreeSnapshot,
|
|
7
7
|
refreshTurnWorktreeSnapshot,
|
|
8
|
+
resumeTurnWorktreeSnapshot,
|
|
8
9
|
revertTurnWorktreeFile,
|
|
10
|
+
revertTurnWorktreePaths,
|
|
9
11
|
revertTurnWorktreeSnapshot,
|
|
10
12
|
} from './turn-worktree-snapshot.mjs';
|
|
13
|
+
import { loadTurnSnapshotRecord, saveTurnSnapshotRecord } from './turn-snapshot-store.mjs';
|
|
11
14
|
|
|
12
15
|
// Turn-scoped review registry.
|
|
13
16
|
//
|
|
@@ -96,6 +99,9 @@ function createDiffTracker(meta = {}) {
|
|
|
96
99
|
originByCurrentPath: new Map(),
|
|
97
100
|
unifiedDiff: '',
|
|
98
101
|
worktreeSnapshot: null,
|
|
102
|
+
// Session-owned relative paths. These outlive the before/after buffers on
|
|
103
|
+
// purpose: they are what scopes a revert once the exact content is gone.
|
|
104
|
+
ownedPaths: new Set(),
|
|
99
105
|
};
|
|
100
106
|
}
|
|
101
107
|
|
|
@@ -346,6 +352,13 @@ export function recordTurnDiffChanges(sessionId, changes = []) {
|
|
|
346
352
|
const before = contentBuffer(raw?.before);
|
|
347
353
|
const after = contentBuffer(raw?.after);
|
|
348
354
|
const destinationPath = clean(raw?.newPath);
|
|
355
|
+
// WHICH files this session wrote has to survive releasing their content —
|
|
356
|
+
// on turn completion, and on the tracked-size ceiling. That list is the
|
|
357
|
+
// attribution a shared worktree cannot otherwise reconstruct.
|
|
358
|
+
tracker.ownedPaths.add(sourceDisplay);
|
|
359
|
+
if (destinationPath) {
|
|
360
|
+
tracker.ownedPaths.add(displayPath(raw?.newDisplayPath || destinationPath));
|
|
361
|
+
}
|
|
349
362
|
if (!destinationPath) {
|
|
350
363
|
if (before === null && after !== null) {
|
|
351
364
|
tracker.originByCurrentPath.delete(sourceKey);
|
|
@@ -549,14 +562,37 @@ export function completeAgentTurnReview(handle, patches = []) {
|
|
|
549
562
|
}
|
|
550
563
|
}
|
|
551
564
|
|
|
565
|
+
/** Persist the durable half of a finished review: the baseline tree hash plus
|
|
566
|
+
* the paths this session owns. Memory keeps the fast path; the record is what
|
|
567
|
+
* survives the next turn, a cache eviction and a runtime restart. A CONTENDED
|
|
568
|
+
* worktree is recorded too — the baseline is still exact, and the owned paths
|
|
569
|
+
* are precisely the attribution that contention destroys. */
|
|
570
|
+
async function persistTurnSnapshotRecord(ownerSessionId, tracker) {
|
|
571
|
+
const snapshot = tracker?.worktreeSnapshot;
|
|
572
|
+
if (!ownerSessionId || !snapshot?.baselineTree || !snapshot.root) return;
|
|
573
|
+
const owned = [...tracker.ownedPaths];
|
|
574
|
+
if (owned.length === 0) return;
|
|
575
|
+
try {
|
|
576
|
+
await saveTurnSnapshotRecord(ownerSessionId, {
|
|
577
|
+
generation: _turnsBySession.get(ownerSessionId)?.generation || 0,
|
|
578
|
+
root: snapshot.root,
|
|
579
|
+
baselineTree: snapshot.baselineTree,
|
|
580
|
+
toolFiles: owned,
|
|
581
|
+
sealed: true,
|
|
582
|
+
});
|
|
583
|
+
} catch { /* the record adds to a review, it is never required by one */ }
|
|
584
|
+
}
|
|
585
|
+
|
|
552
586
|
/** Freeze a completed Lead turn while retaining only its bounded review data. */
|
|
553
587
|
export async function completeTurnSnapshot(sessionId) {
|
|
554
|
-
const
|
|
588
|
+
const ownerSessionId = clean(sessionId);
|
|
589
|
+
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
555
590
|
if (!tracker) return false;
|
|
556
591
|
if (worktreeSnapshotUsable(tracker)) {
|
|
557
592
|
try { await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot); } catch {}
|
|
558
593
|
}
|
|
559
594
|
tracker.sealed = true;
|
|
595
|
+
await persistTurnSnapshotRecord(ownerSessionId, tracker);
|
|
560
596
|
// Only a usable Git baseline can replace the exact apply_patch buffers. A
|
|
561
597
|
// contended worktree — and a worktree with no Git baseline at all — retains
|
|
562
598
|
// this session's bounded before/after content so Review can still undo its
|
|
@@ -590,6 +626,71 @@ function worktreeSnapshotUsable(tracker) {
|
|
|
590
626
|
return Boolean(tracker && !tracker.worktreeContended && tracker.worktreeSnapshot);
|
|
591
627
|
}
|
|
592
628
|
|
|
629
|
+
/** Match a user-supplied file against a recorded session-owned path. */
|
|
630
|
+
function recordedPathMatchesFile(owned, root, file) {
|
|
631
|
+
const target = clean(file);
|
|
632
|
+
if (!target) return false;
|
|
633
|
+
const normalizedRoot = pathKey(root);
|
|
634
|
+
const normalizedTarget = pathKey(target);
|
|
635
|
+
const relativeTarget = normalizedRoot && normalizedTarget.startsWith(`${normalizedRoot}/`)
|
|
636
|
+
? normalizedTarget.slice(normalizedRoot.length + 1)
|
|
637
|
+
: normalizedTarget;
|
|
638
|
+
const normalizedOwned = pathKey(owned);
|
|
639
|
+
return normalizedOwned === relativeTarget || normalizedOwned === normalizedTarget;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Re-open the recorded baseline for a session whose tracker is gone. Returns
|
|
643
|
+
* null when the record is missing or its tree object has been collected. */
|
|
644
|
+
async function resumeRecordedSnapshot(ownerSessionId) {
|
|
645
|
+
if (!ownerSessionId) return null;
|
|
646
|
+
let record = null;
|
|
647
|
+
try {
|
|
648
|
+
record = await loadTurnSnapshotRecord(ownerSessionId);
|
|
649
|
+
} catch {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
if (!record) return null;
|
|
653
|
+
const snapshot = await resumeTurnWorktreeSnapshot(record.root, record.baselineTree, {
|
|
654
|
+
paths: record.toolFiles,
|
|
655
|
+
}).catch(() => null);
|
|
656
|
+
if (!snapshot) return null;
|
|
657
|
+
return { record, snapshot };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** A review rebuilt from the durable record. Same shape as the live one, but
|
|
661
|
+
* scoped to the recording session's own paths so a worktree shared with other
|
|
662
|
+
* sessions stays attributable instead of losing its revert entirely. */
|
|
663
|
+
async function scopedReviewFromRecord(ownerSessionId) {
|
|
664
|
+
const resumed = await resumeRecordedSnapshot(ownerSessionId);
|
|
665
|
+
if (!resumed) return null;
|
|
666
|
+
return {
|
|
667
|
+
supported: true,
|
|
668
|
+
files: resumed.snapshot.files || [],
|
|
669
|
+
patch: resumed.snapshot.patch || '',
|
|
670
|
+
snapshotKind: 'scoped',
|
|
671
|
+
revertMode: 'scoped',
|
|
672
|
+
patchTruncated: resumed.snapshot.patchTruncated === true,
|
|
673
|
+
authoritative: true,
|
|
674
|
+
agents: publicAgentReviews(ownerSessionId),
|
|
675
|
+
reason: 'recorded',
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Restore recorded paths — every one of them, or just the file the user picked. */
|
|
680
|
+
async function revertFromRecord(worktree, ownerSessionId, file) {
|
|
681
|
+
const resumed = await resumeRecordedSnapshot(ownerSessionId);
|
|
682
|
+
if (!resumed) return null;
|
|
683
|
+
const owned = resumed.record.toolFiles;
|
|
684
|
+
const targets = file
|
|
685
|
+
? owned.filter((path) => recordedPathMatchesFile(path, resumed.record.root, file))
|
|
686
|
+
: owned;
|
|
687
|
+
if (targets.length === 0) {
|
|
688
|
+
throw new Error('turn review revert cannot attribute this file to the session');
|
|
689
|
+
}
|
|
690
|
+
await revertTurnWorktreePaths(resumed.snapshot, targets);
|
|
691
|
+
return await getTurnReviewDiff(worktree, ownerSessionId);
|
|
692
|
+
}
|
|
693
|
+
|
|
593
694
|
/** Return the authoritative Lead net diff plus attributed child reviews. */
|
|
594
695
|
export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
|
|
595
696
|
if (DISABLED) return { supported: false, files: [], patch: '', agents: [] };
|
|
@@ -609,6 +710,13 @@ export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
|
|
|
609
710
|
const trackedRevertAvailable = !snapshot
|
|
610
711
|
&& tracker?.valid === true
|
|
611
712
|
&& trackedPairs(tracker).length > 0;
|
|
713
|
+
if (!snapshot && !trackedRevertAvailable) {
|
|
714
|
+
// Both in-memory sources are gone: the next turn replaced the tracker, the
|
|
715
|
+
// turn cache evicted it, or the runtime restarted. The recorded baseline is
|
|
716
|
+
// still on disk and still exact.
|
|
717
|
+
const recorded = await scopedReviewFromRecord(ownerSessionId);
|
|
718
|
+
if (recorded) return recorded;
|
|
719
|
+
}
|
|
612
720
|
return {
|
|
613
721
|
supported: true,
|
|
614
722
|
files: snapshot?.files || [],
|
|
@@ -635,15 +743,23 @@ export async function revertTurnReviewFile(_worktree, sessionId, file) {
|
|
|
635
743
|
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
636
744
|
if (tracker && !worktreeSnapshotUsable(tracker)) {
|
|
637
745
|
const pairs = trackedPairs(tracker);
|
|
638
|
-
if (pairs.length
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
746
|
+
if (pairs.length > 0) {
|
|
747
|
+
const pair = pairs.find((entry) => pairMatchesFile(entry, _worktree, file));
|
|
748
|
+
if (!pair) throw new Error('turn review tracked file snapshot is unavailable');
|
|
749
|
+
await revertTrackedPairs(tracker, _worktree, [pair]);
|
|
750
|
+
return await getTurnReviewDiff(_worktree, ownerSessionId);
|
|
751
|
+
}
|
|
752
|
+
const recorded = await revertFromRecord(_worktree, ownerSessionId, file);
|
|
753
|
+
if (recorded) return recorded;
|
|
754
|
+
throw new Error(unavailableTrackedRevertReason(tracker));
|
|
755
|
+
}
|
|
756
|
+
if (tracker?.worktreeSnapshot) {
|
|
757
|
+
await revertTurnWorktreeFile(tracker.worktreeSnapshot, file);
|
|
642
758
|
return await getTurnReviewDiff(_worktree, ownerSessionId);
|
|
643
759
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
760
|
+
const recorded = await revertFromRecord(_worktree, ownerSessionId, file);
|
|
761
|
+
if (recorded) return recorded;
|
|
762
|
+
throw new Error('turn worktree snapshot is unavailable');
|
|
647
763
|
}
|
|
648
764
|
|
|
649
765
|
/** Restore every file in the reviewed turn to its turn-start state. */
|
|
@@ -652,13 +768,21 @@ export async function revertTurnReview(_worktree, sessionId) {
|
|
|
652
768
|
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
653
769
|
if (tracker && !worktreeSnapshotUsable(tracker)) {
|
|
654
770
|
const pairs = trackedPairs(tracker);
|
|
655
|
-
if (pairs.length
|
|
656
|
-
|
|
771
|
+
if (pairs.length > 0) {
|
|
772
|
+
await revertTrackedPairs(tracker, _worktree, pairs);
|
|
773
|
+
return await getTurnReviewDiff(_worktree, ownerSessionId);
|
|
774
|
+
}
|
|
775
|
+
const recorded = await revertFromRecord(_worktree, ownerSessionId, null);
|
|
776
|
+
if (recorded) return recorded;
|
|
777
|
+
throw new Error(unavailableTrackedRevertReason(tracker));
|
|
778
|
+
}
|
|
779
|
+
if (tracker?.worktreeSnapshot) {
|
|
780
|
+
await revertTurnWorktreeSnapshot(tracker.worktreeSnapshot);
|
|
657
781
|
return await getTurnReviewDiff(_worktree, ownerSessionId);
|
|
658
782
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
783
|
+
const recorded = await revertFromRecord(_worktree, ownerSessionId, null);
|
|
784
|
+
if (recorded) return recorded;
|
|
785
|
+
throw new Error('turn worktree snapshot is unavailable');
|
|
662
786
|
}
|
|
663
787
|
|
|
664
788
|
export function _resetTurnSnapshotForTest() {
|
|
@@ -10,14 +10,23 @@ import {
|
|
|
10
10
|
rm,
|
|
11
11
|
writeFile,
|
|
12
12
|
} from 'node:fs/promises';
|
|
13
|
-
import {
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
14
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
15
15
|
|
|
16
16
|
const COMMAND_TIMEOUT_MS = 30_000;
|
|
17
17
|
const COMMAND_MAX_BYTES = 8 * 1024 * 1024;
|
|
18
18
|
const PATCH_MAX_BYTES = 2 * 1024 * 1024;
|
|
19
19
|
const MAX_UNTRACKED_FILE_BYTES = 2 * 1024 * 1024;
|
|
20
|
-
|
|
20
|
+
// Baselines used to live in the OS temp directory, where a reboot or a disk
|
|
21
|
+
// cleaner could remove the only copy of a turn's revert source. Keep them with
|
|
22
|
+
// the rest of the runtime data (same resolution as
|
|
23
|
+
// session-runtime/runtime-paths.mjs; shared code cannot import that layer).
|
|
24
|
+
const DATA_DIR = process.env.MIXDOG_DATA_DIR
|
|
25
|
+
|| join(process.env.MIXDOG_HOME || join(homedir(), '.mixdog'), 'data');
|
|
26
|
+
const SNAPSHOT_ROOT = join(DATA_DIR, 'turn-worktree-snapshots-v1');
|
|
27
|
+
// A baseline tree is unreachable by design (no commit, no ref), so collection
|
|
28
|
+
// is what bounds how long a review stays revertible.
|
|
29
|
+
const SHADOW_GC_PRUNE = '7.days.ago';
|
|
21
30
|
const states = new Map();
|
|
22
31
|
|
|
23
32
|
function clean(value) {
|
|
@@ -417,11 +426,14 @@ function parseNumstat(text) {
|
|
|
417
426
|
return out;
|
|
418
427
|
}
|
|
419
428
|
|
|
420
|
-
async function diffTreesUnlocked(state, baselineTree, currentTree) {
|
|
429
|
+
async function diffTreesUnlocked(state, baselineTree, currentTree, paths = null) {
|
|
421
430
|
if (!baselineTree || !currentTree || baselineTree === currentTree) {
|
|
422
431
|
return { patch: '', files: [], patchTruncated: false, currentTree };
|
|
423
432
|
}
|
|
424
|
-
|
|
433
|
+
// A scoped review restricts the range to the paths its session owns: the
|
|
434
|
+
// baseline describes the whole worktree, concurrent sessions included.
|
|
435
|
+
const pathspec = Array.isArray(paths) && paths.length > 0 ? paths : ['.'];
|
|
436
|
+
const rangeArgs = ['--find-renames', baselineTree, currentTree, '--', ...pathspec];
|
|
425
437
|
const [nameStatus, numstat] = await Promise.all([
|
|
426
438
|
runGit(shadowArgs(state, ['diff', '--name-status', '-z', ...rangeArgs]), { cwd: state.root }),
|
|
427
439
|
runGit(shadowArgs(state, ['diff', '--numstat', '-z', ...rangeArgs]), { cwd: state.root }),
|
|
@@ -462,6 +474,26 @@ async function diffTreesUnlocked(state, baselineTree, currentTree) {
|
|
|
462
474
|
return { patch, files, patchTruncated, currentTree };
|
|
463
475
|
}
|
|
464
476
|
|
|
477
|
+
// One maintenance pass per shadow repository per process, off the turn's
|
|
478
|
+
// critical path. `--auto` does nothing until the repository actually needs it,
|
|
479
|
+
// so the common case costs a process spawn and nothing else.
|
|
480
|
+
const maintainedShadowRepositories = new Set();
|
|
481
|
+
|
|
482
|
+
function scheduleShadowGc(state) {
|
|
483
|
+
if (maintainedShadowRepositories.has(state.gitDir)) return;
|
|
484
|
+
maintainedShadowRepositories.add(state.gitDir);
|
|
485
|
+
const timer = setTimeout(() => {
|
|
486
|
+
// Deliberately outside the state lock: collection can run for minutes and
|
|
487
|
+
// git serializes itself through gc.lock, so a clash just retries later.
|
|
488
|
+
void runGit(shadowArgs(state, ['gc', '--auto', `--prune=${SHADOW_GC_PRUNE}`, '--quiet']), {
|
|
489
|
+
cwd: state.root,
|
|
490
|
+
allowFailure: true,
|
|
491
|
+
timeoutMs: 300_000,
|
|
492
|
+
}).catch(() => {});
|
|
493
|
+
}, 60_000);
|
|
494
|
+
timer.unref?.();
|
|
495
|
+
}
|
|
496
|
+
|
|
465
497
|
export async function createTurnWorktreeSnapshot(worktree) {
|
|
466
498
|
const root = await repositoryRoot(worktree).catch(() => null);
|
|
467
499
|
if (!root) return null;
|
|
@@ -469,6 +501,7 @@ export async function createTurnWorktreeSnapshot(worktree) {
|
|
|
469
501
|
return await withStateLock(state, async () => {
|
|
470
502
|
const baselineTree = await captureTreeUnlocked(state);
|
|
471
503
|
if (!baselineTree) return null;
|
|
504
|
+
scheduleShadowGc(state);
|
|
472
505
|
return {
|
|
473
506
|
state,
|
|
474
507
|
root,
|
|
@@ -477,16 +510,57 @@ export async function createTurnWorktreeSnapshot(worktree) {
|
|
|
477
510
|
patch: '',
|
|
478
511
|
files: [],
|
|
479
512
|
patchTruncated: false,
|
|
513
|
+
scopePaths: null,
|
|
480
514
|
};
|
|
481
515
|
});
|
|
482
516
|
}
|
|
483
517
|
|
|
518
|
+
/** Re-open a recorded baseline after the runtime lost its in-memory tracker.
|
|
519
|
+
* The tree object is the durable half of a review; `paths` scopes both this
|
|
520
|
+
* diff and any later revert to what the recording session actually owns. */
|
|
521
|
+
export async function resumeTurnWorktreeSnapshot(worktree, baselineTree, { paths = null } = {}) {
|
|
522
|
+
const tree = clean(baselineTree);
|
|
523
|
+
if (!tree) return null;
|
|
524
|
+
const root = await repositoryRoot(worktree).catch(() => null);
|
|
525
|
+
if (!root) return null;
|
|
526
|
+
const state = stateForRoot(root);
|
|
527
|
+
return await withStateLock(state, async () => {
|
|
528
|
+
await ensureState(state);
|
|
529
|
+
// A collected or never-written baseline restores nothing. Report that as
|
|
530
|
+
// "no snapshot" instead of failing every later call made against it.
|
|
531
|
+
const kind = await runGit(shadowArgs(state, ['cat-file', '-t', tree]), {
|
|
532
|
+
cwd: state.root,
|
|
533
|
+
allowFailure: true,
|
|
534
|
+
});
|
|
535
|
+
if (kind.code !== 0 || clean(kind.stdout) !== 'tree') return null;
|
|
536
|
+
const scopePaths = Array.isArray(paths) && paths.length > 0 ? [...new Set(paths)] : null;
|
|
537
|
+
const snapshot = {
|
|
538
|
+
state,
|
|
539
|
+
root,
|
|
540
|
+
baselineTree: tree,
|
|
541
|
+
currentTree: tree,
|
|
542
|
+
patch: '',
|
|
543
|
+
files: [],
|
|
544
|
+
patchTruncated: false,
|
|
545
|
+
scopePaths,
|
|
546
|
+
};
|
|
547
|
+
const currentTree = await captureTreeUnlocked(state);
|
|
548
|
+
Object.assign(snapshot, await diffTreesUnlocked(state, tree, currentTree, scopePaths));
|
|
549
|
+
return snapshot;
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
484
553
|
export async function refreshTurnWorktreeSnapshot(snapshot) {
|
|
485
554
|
if (!snapshot?.state || !snapshot.baselineTree) return null;
|
|
486
555
|
return await withStateLock(snapshot.state, async () => {
|
|
487
556
|
const currentTree = await captureTreeUnlocked(snapshot.state);
|
|
488
557
|
if (currentTree === snapshot.currentTree) return snapshot;
|
|
489
|
-
const review = await diffTreesUnlocked(
|
|
558
|
+
const review = await diffTreesUnlocked(
|
|
559
|
+
snapshot.state,
|
|
560
|
+
snapshot.baselineTree,
|
|
561
|
+
currentTree,
|
|
562
|
+
snapshot.scopePaths,
|
|
563
|
+
);
|
|
490
564
|
Object.assign(snapshot, review);
|
|
491
565
|
return snapshot;
|
|
492
566
|
});
|
|
@@ -531,19 +605,29 @@ async function restorePathFromTree(snapshot, rel) {
|
|
|
531
605
|
}
|
|
532
606
|
}
|
|
533
607
|
|
|
608
|
+
async function revertPathsUnlocked(snapshot, targets) {
|
|
609
|
+
for (const target of targets) {
|
|
610
|
+
await restorePathFromTree(snapshot, target);
|
|
611
|
+
}
|
|
612
|
+
const currentTree = await captureTreeUnlocked(snapshot.state);
|
|
613
|
+
Object.assign(snapshot, await diffTreesUnlocked(
|
|
614
|
+
snapshot.state,
|
|
615
|
+
snapshot.baselineTree,
|
|
616
|
+
currentTree,
|
|
617
|
+
snapshot.scopePaths,
|
|
618
|
+
));
|
|
619
|
+
return snapshot;
|
|
620
|
+
}
|
|
621
|
+
|
|
534
622
|
export async function revertTurnWorktreeFile(snapshot, value) {
|
|
535
623
|
if (!snapshot?.state || !snapshot.baselineTree) throw new Error('turn worktree snapshot is unavailable');
|
|
536
624
|
return await withStateLock(snapshot.state, async () => {
|
|
537
625
|
const rel = safeRelativePath(snapshot.root, value);
|
|
538
626
|
const entry = snapshot.files.find((file) =>
|
|
539
627
|
pathKey(file.path) === pathKey(rel) || pathKey(file.oldPath) === pathKey(rel));
|
|
540
|
-
const targets = [...new Set([entry?.path || rel, entry?.oldPath].filter(Boolean))]
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
const currentTree = await captureTreeUnlocked(snapshot.state);
|
|
545
|
-
Object.assign(snapshot, await diffTreesUnlocked(snapshot.state, snapshot.baselineTree, currentTree));
|
|
546
|
-
return snapshot;
|
|
628
|
+
const targets = [...new Set([entry?.path || rel, entry?.oldPath].filter(Boolean))]
|
|
629
|
+
.map((target) => safeRelativePath(snapshot.root, target));
|
|
630
|
+
return await revertPathsUnlocked(snapshot, targets);
|
|
547
631
|
});
|
|
548
632
|
}
|
|
549
633
|
|
|
@@ -557,12 +641,20 @@ export async function revertTurnWorktreeSnapshot(snapshot) {
|
|
|
557
641
|
.flatMap((file) => [file.path, file.oldPath])
|
|
558
642
|
.filter(Boolean))]
|
|
559
643
|
.map((target) => safeRelativePath(snapshot.root, target));
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
644
|
+
return await revertPathsUnlocked(snapshot, targets);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Restore only the listed paths. This is the session-owned revert: a shared
|
|
649
|
+
* worktree cannot attribute the whole-tree diff, but the paths this session's
|
|
650
|
+
* own tools wrote are exactly attributable. */
|
|
651
|
+
export async function revertTurnWorktreePaths(snapshot, values) {
|
|
652
|
+
if (!snapshot?.state || !snapshot.baselineTree) throw new Error('turn worktree snapshot is unavailable');
|
|
653
|
+
const requested = (Array.isArray(values) ? values : []).filter(Boolean);
|
|
654
|
+
if (requested.length === 0) throw new Error('turn review revert has no restorable files');
|
|
655
|
+
return await withStateLock(snapshot.state, async () => {
|
|
656
|
+
const targets = [...new Set(requested.map((value) => safeRelativePath(snapshot.root, value)))];
|
|
657
|
+
return await revertPathsUnlocked(snapshot, targets);
|
|
566
658
|
});
|
|
567
659
|
}
|
|
568
660
|
|