@zergai/ztc 0.4.0-beta.1 → 0.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +398 -45
- package/RUST_THIRD_PARTY_LICENSES.txt +1080 -822
- package/THIRD_PARTY_NOTICES.md +273 -12
- package/bin/zmux-darwin-arm64 +0 -0
- package/bin/ztc-audio-darwin-arm64 +0 -0
- package/bin/ztc-capture-darwin-arm64 +0 -0
- package/bin/ztc-computer-darwin-arm64 +0 -0
- package/bin/ztc-llm-darwin-arm64 +0 -0
- package/bin/ztc-tts-darwin-arm64 +0 -0
- package/bin/ztc-tui-darwin-arm64 +0 -0
- package/bin/ztc-zlicky-overlay-darwin-arm64 +0 -0
- package/bin/zterm-darwin-arm64 +0 -0
- package/dist/App.js +1206 -209
- package/dist/agent/agent.d.ts +74 -8
- package/dist/agent/agent.js +1073 -184
- package/dist/agent/backends/account_rotation.d.ts +4 -0
- package/dist/agent/backends/account_rotation.js +8 -2
- package/dist/agent/backends/anthropic.d.ts +13 -0
- package/dist/agent/backends/anthropic.js +169 -28
- package/dist/agent/backends/claude_cli.js +18 -8
- package/dist/agent/backends/codex_app_server.d.ts +1 -0
- package/dist/agent/backends/codex_app_server.js +55 -3
- package/dist/agent/backends/codex_auth.d.ts +1 -0
- package/dist/agent/backends/codex_auth.js +67 -13
- package/dist/agent/backends/codex_buffered_recovery.d.ts +27 -0
- package/dist/agent/backends/codex_buffered_recovery.js +293 -0
- package/dist/agent/backends/gemini.js +3 -1
- package/dist/agent/backends/http_error.d.ts +26 -0
- package/dist/agent/backends/http_error.js +221 -0
- package/dist/agent/backends/index.d.ts +9 -2
- package/dist/agent/backends/index.js +22 -2
- package/dist/agent/backends/openai.d.ts +0 -6
- package/dist/agent/backends/openai.js +5 -2
- package/dist/agent/backends/openai_compatible.d.ts +5 -4
- package/dist/agent/backends/openai_compatible.js +26 -19
- package/dist/agent/backends/openai_responses.d.ts +8 -3
- package/dist/agent/backends/openai_responses.js +77 -33
- package/dist/agent/backends/provider_ids.d.ts +1 -0
- package/dist/agent/backends/provider_ids.js +4 -0
- package/dist/agent/backends/request_metrics.js +12 -2
- package/dist/agent/backends/types.d.ts +109 -6
- package/dist/agent/backends/types.js +5 -1
- package/dist/agent/backends/zrouter_stream.d.ts +6 -1
- package/dist/agent/backends/zrouter_stream.js +160 -42
- package/dist/agent/background_registry.d.ts +2 -0
- package/dist/agent/background_registry.js +1 -0
- package/dist/agent/claim_observation.d.ts +6 -0
- package/dist/agent/claim_observation.js +54 -0
- package/dist/agent/commands/accounts.js +46 -177
- package/dist/agent/commands/attention.d.ts +2 -0
- package/dist/agent/commands/attention.js +67 -0
- package/dist/agent/commands/auth.js +18 -2
- package/dist/agent/commands/board.d.ts +2 -0
- package/dist/agent/commands/board.js +169 -0
- package/dist/agent/commands/clipboard.js +50 -1
- package/dist/agent/commands/compact.js +21 -13
- package/dist/agent/commands/config.js +4 -1
- package/dist/agent/commands/context.js +6 -1
- package/dist/agent/commands/desktop.d.ts +2 -0
- package/dist/agent/commands/desktop.js +29 -0
- package/dist/agent/commands/effort.js +14 -6
- package/dist/agent/commands/environment.d.ts +3 -0
- package/dist/agent/commands/environment.js +109 -0
- package/dist/agent/commands/evidence.d.ts +2 -0
- package/dist/agent/commands/evidence.js +9 -0
- package/dist/agent/commands/fork.js +4 -3
- package/dist/agent/commands/goal.d.ts +33 -1
- package/dist/agent/commands/goal.js +174 -14
- package/dist/agent/commands/goal_exit.d.ts +36 -0
- package/dist/agent/commands/goal_exit.js +120 -0
- package/dist/agent/commands/help.js +3 -1
- package/dist/agent/commands/history.js +3 -1
- package/dist/agent/commands/inbox.d.ts +2 -0
- package/dist/agent/commands/inbox.js +148 -0
- package/dist/agent/commands/index.js +16 -1
- package/dist/agent/commands/input_mode.js +8 -6
- package/dist/agent/commands/job.d.ts +2 -0
- package/dist/agent/commands/job.js +93 -0
- package/dist/agent/commands/keybindings.js +4 -1
- package/dist/agent/commands/label.d.ts +2 -0
- package/dist/agent/commands/label.js +77 -0
- package/dist/agent/commands/layout.d.ts +2 -0
- package/dist/agent/commands/layout.js +20 -0
- package/dist/agent/commands/mirror.js +16 -1
- package/dist/agent/commands/model.js +12 -2
- package/dist/agent/commands/permissions.d.ts +3 -1
- package/dist/agent/commands/permissions.js +79 -89
- package/dist/agent/commands/plan.js +5 -15
- package/dist/agent/commands/profile.js +21 -2
- package/dist/agent/commands/provider_selection.js +20 -1
- package/dist/agent/commands/rename.js +1 -1
- package/dist/agent/commands/resources.d.ts +2 -0
- package/dist/agent/commands/resources.js +67 -0
- package/dist/agent/commands/rewind.d.ts +3 -0
- package/dist/agent/commands/rewind.js +16 -0
- package/dist/agent/commands/runbooks.d.ts +2 -0
- package/dist/agent/commands/runbooks.js +73 -0
- package/dist/agent/commands/runbooks_lazy.d.ts +2 -0
- package/dist/agent/commands/runbooks_lazy.js +8 -0
- package/dist/agent/commands/sessions.d.ts +1 -1
- package/dist/agent/commands/sessions.js +33 -5
- package/dist/agent/commands/settings.js +185 -39
- package/dist/agent/commands/setup.d.ts +2 -0
- package/dist/agent/commands/setup.js +78 -0
- package/dist/agent/commands/share.d.ts +2 -0
- package/dist/agent/commands/share.js +64 -0
- package/dist/agent/commands/status.js +14 -2
- package/dist/agent/commands/types.d.ts +99 -6
- package/dist/agent/commands/update.js +8 -0
- package/dist/agent/commands/usage.d.ts +2 -0
- package/dist/agent/commands/usage.js +8 -0
- package/dist/agent/factory.d.ts +11 -0
- package/dist/agent/factory.js +113 -88
- package/dist/agent/loop_policy.d.ts +6 -3
- package/dist/agent/loop_policy.js +12 -3
- package/dist/agent/measurement_guidance.d.ts +2 -0
- package/dist/agent/measurement_guidance.js +5 -0
- package/dist/agent/request_encoder.d.ts +8 -1
- package/dist/agent/request_encoder.js +95 -23
- package/dist/agent/runtime/benchmark_comparison.d.ts +30 -0
- package/dist/agent/runtime/benchmark_comparison.js +94 -0
- package/dist/agent/runtime/benchmark_contract.d.ts +48 -0
- package/dist/agent/runtime/benchmark_contract.js +90 -0
- package/dist/agent/runtime/checked_commands.d.ts +32 -0
- package/dist/agent/runtime/checked_commands.js +119 -0
- package/dist/agent/runtime/command_execution_policy.d.ts +26 -0
- package/dist/agent/runtime/command_execution_policy.js +91 -0
- package/dist/agent/runtime/command_jobs.d.ts +36 -18
- package/dist/agent/runtime/command_jobs.js +151 -13
- package/dist/agent/runtime/command_panes.d.ts +59 -0
- package/dist/agent/runtime/command_panes.js +398 -0
- package/dist/agent/runtime/command_wait.d.ts +19 -0
- package/dist/agent/runtime/command_wait.js +167 -0
- package/dist/agent/runtime/cpu_benchmark.d.ts +30 -0
- package/dist/agent/runtime/cpu_benchmark.js +126 -0
- package/dist/agent/runtime/delegated_evidence.d.ts +26 -0
- package/dist/agent/runtime/delegated_evidence.js +80 -0
- package/dist/agent/runtime/evidence.d.ts +127 -0
- package/dist/agent/runtime/evidence.js +468 -0
- package/dist/agent/runtime/execution_context.d.ts +7 -0
- package/dist/agent/runtime/execution_context.js +39 -3
- package/dist/agent/runtime/json_verifier_recipe.d.ts +8 -0
- package/dist/agent/runtime/json_verifier_recipe.js +66 -0
- package/dist/agent/runtime/measurements.d.ts +110 -0
- package/dist/agent/runtime/measurements.js +317 -0
- package/dist/agent/runtime/policy.d.ts +4 -0
- package/dist/agent/runtime/policy.js +31 -20
- package/dist/agent/runtime/provider_native_evidence.d.ts +20 -0
- package/dist/agent/runtime/provider_native_evidence.js +86 -0
- package/dist/agent/runtime/recovery.d.ts +2 -0
- package/dist/agent/runtime/recovery.js +45 -2
- package/dist/agent/runtime/remote_broker_transport.d.ts +10 -0
- package/dist/agent/runtime/remote_broker_transport.js +84 -0
- package/dist/agent/runtime/remote_deletion_receipts.d.ts +3 -0
- package/dist/agent/runtime/remote_deletion_receipts.js +43 -0
- package/dist/agent/runtime/remote_journal_replay.d.ts +13 -0
- package/dist/agent/runtime/remote_journal_replay.js +233 -0
- package/dist/agent/runtime/remote_mutation_journal.d.ts +25 -0
- package/dist/agent/runtime/remote_mutation_journal.js +208 -0
- package/dist/agent/runtime/remote_resource_endpoint.d.ts +7 -0
- package/dist/agent/runtime/remote_resource_endpoint.js +6 -0
- package/dist/agent/runtime/remote_resource_errors.d.ts +5 -0
- package/dist/agent/runtime/remote_resource_errors.js +9 -0
- package/dist/agent/runtime/remote_resource_script.d.ts +2 -0
- package/dist/agent/runtime/remote_resource_script.js +1009 -0
- package/dist/agent/runtime/remote_resources.d.ts +29 -0
- package/dist/agent/runtime/remote_resources.js +160 -0
- package/dist/agent/runtime/source_verification.d.ts +12 -0
- package/dist/agent/runtime/source_verification.js +210 -0
- package/dist/agent/runtime/source_verification_contract.d.ts +75 -0
- package/dist/agent/runtime/source_verification_contract.js +203 -0
- package/dist/agent/runtime/tool_checkpoints.d.ts +11 -0
- package/dist/agent/runtime/tool_checkpoints.js +26 -0
- package/dist/agent/runtime/tool_effects.d.ts +5 -0
- package/dist/agent/runtime/tool_progress.d.ts +12 -0
- package/dist/agent/runtime/tool_progress.js +76 -0
- package/dist/agent/runtime/tool_runtime.d.ts +16 -1
- package/dist/agent/runtime/tool_runtime.js +204 -10
- package/dist/agent/runtime/tracing.d.ts +1 -1
- package/dist/agent/runtime/verifier_capture.d.ts +9 -0
- package/dist/agent/runtime/verifier_capture.js +100 -0
- package/dist/agent/runtime/verifier_contract.d.ts +24 -0
- package/dist/agent/runtime/verifier_contract.js +41 -0
- package/dist/agent/subagent.d.ts +20 -10
- package/dist/agent/subagent.js +266 -29
- package/dist/agent/tool_response_directory.d.ts +24 -0
- package/dist/agent/tool_response_directory.js +61 -0
- package/dist/agent/tool_response_source.d.ts +8 -0
- package/dist/agent/tool_response_source.js +49 -0
- package/dist/agent/tool_response_store.d.ts +75 -4
- package/dist/agent/tool_response_store.js +583 -64
- package/dist/agent/tools/ask_user.d.ts +3 -1
- package/dist/agent/tools/ask_user.js +30 -11
- package/dist/agent/tools/background.js +1 -0
- package/dist/agent/tools/benchmark_cpu.d.ts +2 -0
- package/dist/agent/tools/benchmark_cpu.js +102 -0
- package/dist/agent/tools/compare_benchmarks.d.ts +2 -0
- package/dist/agent/tools/compare_benchmarks.js +53 -0
- package/dist/agent/tools/deferred.js +1 -0
- package/dist/agent/tools/delegate.js +18 -2
- package/dist/agent/tools/helper_requirements.d.ts +7 -0
- package/dist/agent/tools/helper_requirements.js +19 -0
- package/dist/agent/tools/index.d.ts +1 -0
- package/dist/agent/tools/index.js +3 -0
- package/dist/agent/tools/measurement_report.d.ts +22 -0
- package/dist/agent/tools/measurement_report.js +190 -0
- package/dist/agent/tools/pane.d.ts +0 -7
- package/dist/agent/tools/pane.js +5 -4
- package/dist/agent/tools/project_context.d.ts +3 -0
- package/dist/agent/tools/project_context.js +45 -0
- package/dist/agent/tools/recall_tool_response.d.ts +3 -5
- package/dist/agent/tools/recall_tool_response.js +19 -29
- package/dist/agent/tools/registry.d.ts +1 -0
- package/dist/agent/tools/registry.js +23 -0
- package/dist/agent/tools/related_sessions.d.ts +2 -0
- package/dist/agent/tools/related_sessions.js +156 -0
- package/dist/agent/tools/routine.js +13 -3
- package/dist/agent/tools/session_evidence.d.ts +3 -0
- package/dist/agent/tools/session_evidence.js +72 -0
- package/dist/agent/tools/shell.d.ts +4 -3
- package/dist/agent/tools/shell.js +174 -47
- package/dist/agent/tools/tool_search.js +3 -2
- package/dist/agent/tools/types.d.ts +28 -0
- package/dist/agent/tools/verify_artifact.d.ts +2 -0
- package/dist/agent/tools/verify_artifact.js +60 -0
- package/dist/agent/tools/workflow.js +1 -0
- package/dist/app/activity.d.ts +6 -1
- package/dist/app/activity.js +81 -12
- package/dist/app/agent_runner.d.ts +6 -1
- package/dist/app/agent_runner.js +34 -19
- package/dist/app/command_context.js +106 -10
- package/dist/app/controller.d.ts +15 -4
- package/dist/app/controller.js +29 -3
- package/dist/app/escape_stop.d.ts +11 -0
- package/dist/app/escape_stop.js +15 -0
- package/dist/app/liveness.d.ts +6 -4
- package/dist/app/liveness.js +13 -3
- package/dist/app/model_catalog_cache.d.ts +5 -0
- package/dist/app/model_catalog_cache.js +138 -0
- package/dist/app/ordered_transcript.js +43 -4
- package/dist/app/prompt_turn.d.ts +14 -0
- package/dist/app/prompt_turn.js +53 -0
- package/dist/app/recap.d.ts +3 -0
- package/dist/app/recap.js +9 -3
- package/dist/app/restart.d.ts +3 -2
- package/dist/app/restart.js +22 -7
- package/dist/app/rewind.d.ts +3 -3
- package/dist/app/rewind.js +3 -3
- package/dist/app/selection_completion.d.ts +17 -2
- package/dist/app/selection_completion.js +84 -9
- package/dist/app/session_manager.d.ts +38 -6
- package/dist/app/session_manager.js +263 -60
- package/dist/app/session_messages.d.ts +3 -0
- package/dist/app/session_messages.js +60 -0
- package/dist/app/task_scope_router.d.ts +20 -0
- package/dist/app/task_scope_router.js +173 -0
- package/dist/app/transcript_window.d.ts +2 -0
- package/dist/app/transcript_window.js +4 -0
- package/dist/app/workspace_admission_details.d.ts +3 -0
- package/dist/app/workspace_admission_details.js +26 -0
- package/dist/app/workspace_layout_controller.d.ts +11 -0
- package/dist/app/workspace_layout_controller.js +88 -0
- package/dist/app/workspace_monitor.d.ts +1 -0
- package/dist/app/workspace_monitor.js +216 -0
- package/dist/app/workspace_presets.d.ts +29 -0
- package/dist/app/workspace_presets.js +192 -0
- package/dist/attention/queue.d.ts +37 -0
- package/dist/attention/queue.js +183 -0
- package/dist/bench/rf_shared.d.ts +2 -0
- package/dist/board/binding.d.ts +42 -0
- package/dist/board/binding.js +38 -0
- package/dist/board/client.d.ts +42 -0
- package/dist/board/client.js +60 -0
- package/dist/cli.js +1 -1
- package/dist/cli_args.js +19 -17
- package/dist/cli_main.js +110 -13
- package/dist/cli_ratatui.d.ts +183 -16
- package/dist/cli_ratatui.js +3989 -1255
- package/dist/components/ActivityLine.d.ts +3 -2
- package/dist/components/ActivityLine.js +2 -2
- package/dist/components/InputArea.d.ts +19 -1
- package/dist/components/InputArea.js +264 -190
- package/dist/components/MessageList.d.ts +2 -0
- package/dist/components/MessageList.js +3 -1
- package/dist/components/SingleMessage.d.ts +2 -0
- package/dist/components/SingleMessage.js +4 -2
- package/dist/components/StatusBar.d.ts +4 -2
- package/dist/components/StatusBar.js +3 -1
- package/dist/conductor_branch.js +3 -0
- package/dist/config/api_retry.d.ts +6 -1
- package/dist/config/api_retry.js +19 -1
- package/dist/config/defaults.d.ts +2 -1
- package/dist/config/defaults.js +9 -2
- package/dist/config/message_display.d.ts +1 -0
- package/dist/config/message_display.js +17 -2
- package/dist/config/permissions.d.ts +21 -0
- package/dist/config/permissions.js +89 -0
- package/dist/config/resolved.d.ts +2 -2
- package/dist/config/types.d.ts +32 -5
- package/dist/config.d.ts +38 -4
- package/dist/config.js +290 -42
- package/dist/context/project_intelligence.d.ts +44 -0
- package/dist/context/project_intelligence.js +246 -0
- package/dist/desktop/cli_launch.d.ts +8 -0
- package/dist/desktop/cli_launch.js +6 -0
- package/dist/desktop/cli_shim.d.ts +8 -0
- package/dist/desktop/cli_shim.js +120 -0
- package/dist/desktop/runtime.d.ts +16 -0
- package/dist/desktop/runtime.js +161 -0
- package/dist/desktop/worker_launch.d.ts +7 -0
- package/dist/desktop/worker_launch.js +7 -0
- package/dist/diagnostics/cargo_mutants_selection.d.ts +27 -0
- package/dist/diagnostics/cargo_mutants_selection.js +48 -0
- package/dist/diagnostics/native_terminal_benchmark.d.ts +58 -0
- package/dist/diagnostics/native_terminal_benchmark.js +1247 -0
- package/dist/diagnostics/session_evidence_package.d.ts +40 -0
- package/dist/diagnostics/session_evidence_package.js +234 -0
- package/dist/diagnostics/session_export.d.ts +93 -0
- package/dist/diagnostics/session_export.js +634 -0
- package/dist/emulation/tool_adapters.js +48 -18
- package/dist/emulation/ztc_addendum.d.ts +1 -0
- package/dist/emulation/ztc_addendum.js +13 -12
- package/dist/environment/service_runner.d.ts +1 -0
- package/dist/environment/service_runner.js +68 -0
- package/dist/environment/store.d.ts +120 -0
- package/dist/environment/store.js +387 -0
- package/dist/events.d.ts +40 -1
- package/dist/events.js +10 -17
- package/dist/goal_contract.d.ts +25 -0
- package/dist/goal_contract.js +44 -0
- package/dist/headless.d.ts +31 -0
- package/dist/headless.js +578 -70
- package/dist/headless_cli.js +8 -0
- package/dist/headless_cli_args.d.ts +1 -0
- package/dist/headless_cli_args.js +1 -0
- package/dist/intake/cli.d.ts +1 -0
- package/dist/intake/cli.js +33 -0
- package/dist/intake/format.d.ts +2 -0
- package/dist/intake/format.js +14 -0
- package/dist/intake/notifications.d.ts +16 -0
- package/dist/intake/notifications.js +132 -0
- package/dist/intake/store.d.ts +56 -0
- package/dist/intake/store.js +238 -0
- package/dist/library.d.ts +8 -0
- package/dist/library.js +19 -10
- package/dist/mcp/client.d.ts +1 -0
- package/dist/mcp/client.js +20 -0
- package/dist/modes/builtin/plan.yaml +2 -2
- package/dist/modes/handlers.js +1 -1
- package/dist/onboarding/first_run.d.ts +3 -0
- package/dist/onboarding/first_run.js +33 -25
- package/dist/onboarding/setup.d.ts +32 -0
- package/dist/onboarding/setup.js +133 -0
- package/dist/onboarding/setup_agent_health.d.ts +17 -0
- package/dist/onboarding/setup_agent_health.js +79 -0
- package/dist/onboarding/setup_environment.d.ts +66 -0
- package/dist/onboarding/setup_environment.js +219 -0
- package/dist/onboarding/setup_workspace.d.ts +17 -0
- package/dist/onboarding/setup_workspace.js +129 -0
- package/dist/optimize/adapters/index.d.ts +4 -1
- package/dist/optimize/adapters/index.js +7 -0
- package/dist/optimize/adapters/kernelbench.js +14 -5
- package/dist/optimize/adapters/popcorn.js +3 -2
- package/dist/optimize/adapters/profile_benchmark.js +13 -7
- package/dist/optimize/adapters/rfbench.js +25 -14
- package/dist/optimize/adapters/types.d.ts +3 -0
- package/dist/optimize/adapters/zergpod.js +5 -2
- package/dist/optimize/baseline.js +10 -21
- package/dist/optimize/branch.js +9 -12
- package/dist/optimize/combine.js +2 -3
- package/dist/optimize/executor.js +34 -48
- package/dist/optimize/measurements.d.ts +55 -0
- package/dist/optimize/measurements.js +173 -0
- package/dist/optimize/results.d.ts +1 -0
- package/dist/optimize/results.js +11 -33
- package/dist/optimize/tge.js +2 -1
- package/dist/panes/initial_input.d.ts +19 -0
- package/dist/panes/initial_input.js +206 -0
- package/dist/panes/labels.d.ts +16 -0
- package/dist/panes/labels.js +42 -0
- package/dist/panes/model_catalog.d.ts +3 -3
- package/dist/panes/model_catalog.js +28 -4
- package/dist/panes/pane_registry.js +6 -2
- package/dist/panes/types.d.ts +3 -0
- package/dist/panes/ztc_launch.js +19 -0
- package/dist/plugin/optimization.d.ts +2 -0
- package/dist/plugin/optimization.js +1 -0
- package/dist/plugin/runtime.d.ts +2 -0
- package/dist/plugin/simulation.d.ts +6 -1
- package/dist/plugin/simulation_contract.d.ts +836 -1
- package/dist/plugin/simulation_documents.d.ts +22 -0
- package/dist/plugin/simulation_documents.js +1 -0
- package/dist/plugin/simulation_interventions.d.ts +103 -0
- package/dist/plugin/simulation_interventions.js +1 -0
- package/dist/plugin/simulation_living.d.ts +1 -0
- package/dist/plugin/simulation_living.js +3 -0
- package/dist/plugin/simulation_meetings.d.ts +121 -0
- package/dist/plugin/simulation_meetings.js +1 -0
- package/dist/plugin/simulation_story_review.d.ts +111 -0
- package/dist/plugin/simulation_story_review.js +1 -0
- package/dist/plugin/simulation_storyteller.d.ts +91 -0
- package/dist/plugin/simulation_storyteller.js +1 -0
- package/dist/plugin/simulation_structured.d.ts +2 -0
- package/dist/plugin/simulation_structured.js +2 -0
- package/dist/plugin/simulation_toolkit.d.ts +21 -0
- package/dist/plugin/simulation_toolkit.js +24 -0
- package/dist/prompts/tool_delegate_task.j2 +1 -1
- package/dist/remote-broker/Dockerfile +10 -0
- package/dist/remote-broker/archive.py +1227 -0
- package/dist/remote-broker/cutover.py +761 -0
- package/dist/remote-broker/requirements.txt +5 -0
- package/dist/remote-broker/resources.json +6 -0
- package/dist/remote-broker/resources.write-staging.example.json +18 -0
- package/dist/remote-broker/retained.py +686 -0
- package/dist/remote-broker/retained_cleanup.py +550 -0
- package/dist/remote-broker/server.py +648 -0
- package/dist/remote-broker/worker.py +1007 -0
- package/dist/router_auth.d.ts +31 -0
- package/dist/router_auth.js +155 -9
- package/dist/runbooks/catalog.d.ts +3 -0
- package/dist/runbooks/catalog.js +26 -0
- package/dist/runbooks/library.d.ts +11 -0
- package/dist/runbooks/library.js +95 -0
- package/dist/runbooks/package.d.ts +43 -0
- package/dist/runbooks/package.js +102 -0
- package/dist/session.d.ts +65 -13
- package/dist/session.js +548 -43
- package/dist/session_handoff.d.ts +25 -0
- package/dist/session_handoff.js +322 -0
- package/dist/session_handoff_contract.d.ts +89 -0
- package/dist/session_handoff_contract.js +179 -0
- package/dist/session_media.d.ts +41 -0
- package/dist/session_media.js +357 -0
- package/dist/session_ownership.d.ts +16 -0
- package/dist/session_ownership.js +221 -0
- package/dist/session_public.d.ts +10 -2
- package/dist/session_public.js +6 -1
- package/dist/simulation/dynamic_history.d.ts +42 -0
- package/dist/simulation/dynamic_history.js +173 -0
- package/dist/simulation/engines/swarm/pane_manager.js +7 -6
- package/dist/simulation/inference.d.ts +3 -1
- package/dist/simulation/inference.js +19 -1
- package/dist/simulation/intervention_generation.d.ts +27 -0
- package/dist/simulation/intervention_generation.js +246 -0
- package/dist/simulation/intervention_material.d.ts +4 -0
- package/dist/simulation/intervention_material.js +81 -0
- package/dist/simulation/living/context.d.ts +3130 -0
- package/dist/simulation/living/context.js +944 -0
- package/dist/simulation/living/definer.d.ts +12 -0
- package/dist/simulation/living/definer.js +223 -0
- package/dist/simulation/living/definition.d.ts +257 -0
- package/dist/simulation/living/definition.js +341 -0
- package/dist/simulation/living/frame.d.ts +259 -0
- package/dist/simulation/living/frame.js +223 -0
- package/dist/simulation/living/identifiers.d.ts +2 -0
- package/dist/simulation/living/identifiers.js +12 -0
- package/dist/simulation/living/index.d.ts +15 -0
- package/dist/simulation/living/index.js +15 -0
- package/dist/simulation/living/inference.d.ts +219 -0
- package/dist/simulation/living/inference.js +576 -0
- package/dist/simulation/living/mechanics.d.ts +54 -0
- package/dist/simulation/living/mechanics.js +588 -0
- package/dist/simulation/living/model-input.d.ts +25 -0
- package/dist/simulation/living/model-input.js +215 -0
- package/dist/simulation/living/open-world.d.ts +257 -0
- package/dist/simulation/living/open-world.js +102 -0
- package/dist/simulation/living/projection.d.ts +7 -0
- package/dist/simulation/living/projection.js +31 -0
- package/dist/simulation/living/recall.d.ts +17 -0
- package/dist/simulation/living/recall.js +25 -0
- package/dist/simulation/living/reduce.d.ts +15 -0
- package/dist/simulation/living/reduce.js +1853 -0
- package/dist/simulation/living/replay.d.ts +6 -0
- package/dist/simulation/living/replay.js +22 -0
- package/dist/simulation/living/runtime.d.ts +140 -0
- package/dist/simulation/living/runtime.js +3020 -0
- package/dist/simulation/living/shared-kinds.d.ts +24 -0
- package/dist/simulation/living/shared-kinds.js +96 -0
- package/dist/simulation/living/types.d.ts +2785 -0
- package/dist/simulation/living/types.js +669 -0
- package/dist/simulation/llm.d.ts +3 -1
- package/dist/simulation/llm.js +90 -30
- package/dist/simulation/memory/embedding.d.ts +1 -1
- package/dist/simulation/memory/embedding.js +9 -3
- package/dist/simulation/memory/filesystem.d.ts +2 -0
- package/dist/simulation/memory/filesystem.js +5 -1
- package/dist/simulation/memory/filesystem_file_tree.d.ts +4 -1
- package/dist/simulation/memory/filesystem_file_tree.js +41 -10
- package/dist/simulation/memory/filesystem_invariants.js +1 -2
- package/dist/simulation/memory/filesystem_types.d.ts +1 -0
- package/dist/simulation/memory/memory.d.ts +2 -0
- package/dist/simulation/memory/memory.js +5 -1
- package/dist/simulation/orchestration/intent.js +0 -2
- package/dist/simulation/orchestration/lifecycle.js +0 -2
- package/dist/simulation/plugin_host.d.ts +25 -1
- package/dist/simulation/plugin_host.js +334 -17
- package/dist/simulation/session_documents.d.ts +20 -0
- package/dist/simulation/session_documents.js +62 -0
- package/dist/simulation/session_input_validation.d.ts +43 -0
- package/dist/simulation/session_input_validation.js +364 -0
- package/dist/simulation/session_inputs.d.ts +32 -0
- package/dist/simulation/session_inputs.js +154 -0
- package/dist/simulation/telemetry.d.ts +11 -14
- package/dist/simulation/telemetry.js +177 -54
- package/dist/simulation/toolkit/bounded_turns.d.ts +13 -0
- package/dist/simulation/toolkit/bounded_turns.js +80 -0
- package/dist/simulation/toolkit/dynamic_agents.d.ts +25 -0
- package/dist/simulation/toolkit/dynamic_agents.js +218 -0
- package/dist/simulation/toolkit/dynamic_digest.d.ts +1 -0
- package/dist/simulation/toolkit/dynamic_digest.js +8 -0
- package/dist/simulation/toolkit/dynamic_documents.d.ts +7 -0
- package/dist/simulation/toolkit/dynamic_documents.js +71 -0
- package/dist/simulation/toolkit/dynamic_driver.d.ts +8 -0
- package/dist/simulation/toolkit/dynamic_driver.js +269 -0
- package/dist/simulation/toolkit/dynamic_game.d.ts +53 -0
- package/dist/simulation/toolkit/dynamic_game.js +805 -0
- package/dist/simulation/toolkit/dynamic_knowledge.d.ts +15 -0
- package/dist/simulation/toolkit/dynamic_knowledge.js +91 -0
- package/dist/simulation/toolkit/dynamic_mechanics.d.ts +22 -0
- package/dist/simulation/toolkit/dynamic_mechanics.js +910 -0
- package/dist/simulation/toolkit/dynamic_meetings.d.ts +239 -0
- package/dist/simulation/toolkit/dynamic_meetings.js +350 -0
- package/dist/simulation/toolkit/dynamic_progress.d.ts +131 -0
- package/dist/simulation/toolkit/dynamic_progress.js +214 -0
- package/dist/simulation/toolkit/dynamic_projection.d.ts +9 -0
- package/dist/simulation/toolkit/dynamic_projection.js +219 -0
- package/dist/simulation/toolkit/dynamic_runtime.d.ts +412 -0
- package/dist/simulation/toolkit/dynamic_runtime.js +6536 -0
- package/dist/simulation/toolkit/dynamic_state.d.ts +111 -0
- package/dist/simulation/toolkit/dynamic_state.js +1967 -0
- package/dist/simulation/toolkit/dynamic_story_review.d.ts +508 -0
- package/dist/simulation/toolkit/dynamic_story_review.js +333 -0
- package/dist/simulation/toolkit/dynamic_storyteller.d.ts +77 -0
- package/dist/simulation/toolkit/dynamic_storyteller.js +114 -0
- package/dist/simulation/toolkit/dynamic_storyteller_runtime.d.ts +26 -0
- package/dist/simulation/toolkit/dynamic_storyteller_runtime.js +126 -0
- package/dist/simulation/toolkit/dynamic_world.d.ts +22 -0
- package/dist/simulation/toolkit/dynamic_world.js +377 -0
- package/dist/simulation/toolkit/structured_output.d.ts +5 -0
- package/dist/simulation/toolkit/structured_output.js +321 -0
- package/dist/simulation/types/world.d.ts +2 -0
- package/dist/simulation/world/artifact_store.js +1 -1
- package/dist/simulation/world/memory_manager.d.ts +5 -0
- package/dist/simulation/world/memory_manager.js +17 -2
- package/dist/simulation/world/state.d.ts +3 -2
- package/dist/simulation/world/state.js +15 -5
- package/dist/skills/bundled/measurement-validation/SKILL.md +144 -0
- package/dist/terminal_attach.d.ts +1 -0
- package/dist/terminal_attach.js +113 -0
- package/dist/tool_progress.d.ts +3 -0
- package/dist/tool_progress.js +27 -0
- package/dist/types.d.ts +145 -10
- package/dist/types.js +3 -0
- package/dist/ui/core/input.d.ts +3 -0
- package/dist/ui/core/input_segments.d.ts +18 -1
- package/dist/ui/core/input_segments.js +131 -47
- package/dist/ui/core/input_state.d.ts +12 -0
- package/dist/ui/core/layout_yoga.d.ts +1 -1
- package/dist/ui/core/turn_metadata.d.ts +5 -0
- package/dist/ui/core/turn_metadata.js +22 -0
- package/dist/ui/ink/render.d.ts +2 -1
- package/dist/ui/ink/render.js +6 -5
- package/dist/ui/ratatui/headless_pane_manager.d.ts +14 -1
- package/dist/ui/ratatui/headless_pane_manager.js +293 -104
- package/dist/ui/ratatui/index.d.ts +1 -0
- package/dist/ui/ratatui/input.d.ts +4 -0
- package/dist/ui/ratatui/input.js +15 -0
- package/dist/ui/ratatui/ipc_path.d.ts +2 -0
- package/dist/ui/ratatui/ipc_path.js +29 -1
- package/dist/ui/ratatui/mux_persistence.d.ts +3 -1
- package/dist/ui/ratatui/mux_persistence.js +139 -1
- package/dist/ui/ratatui/pane_relay.d.ts +1 -1
- package/dist/ui/ratatui/pane_relay.js +55 -27
- package/dist/ui/ratatui/process.d.ts +17 -1
- package/dist/ui/ratatui/process.js +80 -7
- package/dist/ui/ratatui/protocol.d.ts +236 -1
- package/dist/ui/ratatui/protocol.js +90 -0
- package/dist/ui/scrollback.d.ts +2 -1
- package/dist/ui/scrollback.js +9 -4
- package/dist/ui/stream_typewriter.d.ts +8 -3
- package/dist/ui/stream_typewriter.js +30 -1
- package/dist/ui/streaming_scrollback.d.ts +1 -0
- package/dist/ui/streaming_scrollback.js +1 -1
- package/dist/ui/transcript_render_cache.js +10 -0
- package/dist/ui/views/activity_line.d.ts +5 -3
- package/dist/ui/views/activity_line.js +65 -7
- package/dist/ui/views/app.d.ts +6 -3
- package/dist/ui/views/app.js +17 -6
- package/dist/ui/views/completion_menu.js +10 -5
- package/dist/ui/views/input_area.d.ts +7 -3
- package/dist/ui/views/input_area.js +15 -9
- package/dist/ui/views/message_list.d.ts +4 -1
- package/dist/ui/views/message_list.js +89 -19
- package/dist/ui/views/queue_panel.d.ts +3 -1
- package/dist/ui/views/queue_panel.js +9 -2
- package/dist/ui/views/status_bar.d.ts +4 -2
- package/dist/ui/views/status_bar.js +28 -13
- package/dist/ui/views/theme.d.ts +2 -0
- package/dist/ui/views/theme.js +2 -0
- package/dist/ui/widgets/color_panel.js +18 -0
- package/dist/ui/widgets/form.js +8 -0
- package/dist/ui/widgets/log_view.js +7 -0
- package/dist/ui/widgets/navigator_panel.js +8 -0
- package/dist/ui/widgets/prompt_viewer.js +2 -0
- package/dist/ui/widgets/questionnaire.d.ts +2 -0
- package/dist/ui/widgets/questionnaire.js +79 -16
- package/dist/ui/widgets/resume_panel.d.ts +2 -0
- package/dist/ui/widgets/resume_panel.js +26 -15
- package/dist/ui/widgets/rewind_panel.d.ts +2 -2
- package/dist/ui/widgets/rewind_panel.js +4 -2
- package/dist/ui/widgets/select.js +16 -27
- package/dist/ui/widgets/settings_panel.js +16 -0
- package/dist/ui/widgets/status_line_panel.d.ts +1 -0
- package/dist/ui/widgets/status_line_panel.js +30 -4
- package/dist/ui/widgets/text_input.d.ts +2 -0
- package/dist/ui/widgets/text_input.js +15 -1
- package/dist/ui/widgets/types.d.ts +2 -0
- package/dist/usage_projection.d.ts +24 -0
- package/dist/usage_projection.js +130 -0
- package/dist/usage_snapshot.d.ts +36 -0
- package/dist/usage_snapshot.js +131 -0
- package/dist/utils/assistant_message.d.ts +2 -0
- package/dist/utils/assistant_message.js +5 -0
- package/dist/utils/atomic_write.d.ts +6 -2
- package/dist/utils/atomic_write.js +29 -4
- package/dist/utils/checked_shell.d.ts +25 -0
- package/dist/utils/checked_shell.js +107 -0
- package/dist/utils/compaction.d.ts +4 -1
- package/dist/utils/compaction.js +31 -7
- package/dist/utils/context.d.ts +4 -4
- package/dist/utils/context.js +14 -12
- package/dist/utils/deepseek.d.ts +2 -2
- package/dist/utils/deepseek.js +13 -5
- package/dist/utils/ephemeral.js +4 -0
- package/dist/utils/ephemeral_tools.d.ts +18 -0
- package/dist/utils/ephemeral_tools.js +97 -0
- package/dist/utils/jev_client.d.ts +4 -0
- package/dist/utils/jev_client.js +15 -0
- package/dist/utils/model_cache.d.ts +15 -0
- package/dist/utils/model_cache.js +143 -0
- package/dist/utils/models.d.ts +4 -2
- package/dist/utils/models.js +12 -16
- package/dist/utils/reasoning.d.ts +8 -1
- package/dist/utils/reasoning.js +31 -11
- package/dist/utils/router_catalog.d.ts +49 -0
- package/dist/utils/router_catalog.js +271 -0
- package/dist/utils/sandbox.d.ts +9 -0
- package/dist/utils/sandbox.js +118 -15
- package/dist/utils/shell.d.ts +7 -0
- package/dist/utils/shell.js +98 -34
- package/dist/utils/subprocess_env.d.ts +3 -0
- package/dist/utils/subprocess_env.js +27 -1
- package/dist/web/accounts_mirror.d.ts +3 -1
- package/dist/web/accounts_mirror.js +13 -9
- package/dist/web/child_publication.d.ts +17 -0
- package/dist/web/child_publication.js +45 -0
- package/dist/web/mirror_authenticator.d.ts +31 -11
- package/dist/web/mirror_authenticator.js +67 -17
- package/dist/web/mirror_client.d.ts +31 -4
- package/dist/web/mirror_client.js +189 -23
- package/dist/web/mirror_stream.d.ts +84 -22
- package/dist/web/mirror_stream.js +676 -95
- package/dist/web/observer_query_redaction.d.ts +6 -0
- package/dist/web/observer_query_redaction.js +58 -0
- package/dist/web/pane_registry.d.ts +2 -0
- package/dist/web/pane_registry.js +86 -22
- package/dist/web/tool_image.d.ts +6 -0
- package/dist/web/tool_image.js +17 -0
- package/dist/web/view_plugin.d.ts +14 -0
- package/dist/web/view_plugin.js +23 -1
- package/dist/worker/executor.js +31 -12
- package/dist/worker/index.d.ts +1 -0
- package/dist/worker/toolsets.js +3 -1
- package/dist/worker/types.d.ts +12 -1
- package/dist/workflows/registry.js +1 -0
- package/dist/workflows/registry_handle.d.ts +2 -0
- package/dist/workflows/runtime.d.ts +2 -0
- package/dist/workflows/runtime.js +1 -0
- package/dist/workspace_agent.d.ts +7 -0
- package/dist/workspace_agent.js +36 -0
- package/dist/workspace_collision.d.ts +76 -0
- package/dist/workspace_collision.js +688 -0
- package/dist/workspace_layout.d.ts +18 -0
- package/dist/workspace_layout.js +121 -0
- package/dist/workspace_runtime.d.ts +44 -0
- package/dist/workspace_runtime.js +82 -0
- package/dist/workspace_session_context.d.ts +62 -0
- package/dist/workspace_session_context.js +160 -0
- package/dist/zlm-embed.mjs +32518 -9615
- package/dist/zlm.js +1 -0
- package/node_modules/@zerg/model-contracts/LICENSE.md +375 -0
- package/node_modules/@zerg/model-contracts/README.md +90 -0
- package/node_modules/@zerg/model-contracts/index.d.ts +20 -0
- package/node_modules/@zerg/model-contracts/index.js +223 -0
- package/node_modules/@zerg/model-contracts/package.json +8 -0
- package/node_modules/glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/glob/node_modules/brace-expansion/package.json +1 -1
- package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
- package/package.json +45 -24
- package/packages/model-contracts/LICENSE.md +375 -0
- package/packages/model-contracts/README.md +90 -0
- package/packages/model-contracts/index.d.ts +20 -0
- package/packages/model-contracts/index.js +223 -0
- package/packages/model-contracts/package.json +8 -0
- package/remote-broker/Dockerfile +10 -0
- package/remote-broker/README.md +308 -0
- package/remote-broker/archive.py +1227 -0
- package/remote-broker/cutover.py +761 -0
- package/remote-broker/requirements.txt +5 -0
- package/remote-broker/resources.example.json +6 -0
- package/remote-broker/resources.write-staging.example.json +18 -0
- package/remote-broker/retained.py +686 -0
- package/remote-broker/retained_cleanup.py +550 -0
- package/remote-broker/server.py +648 -0
- package/scripts/build-remote-broker.mjs +13 -0
- package/scripts/ensure-local-packages.mjs +6 -0
- package/scripts/node-pty-helpers.mjs +103 -0
|
@@ -0,0 +1,1227 @@
|
|
|
1
|
+
"""Operator-only confirmed receipt maintenance; no broker RPC exposes this CLI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import runpy
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Iterator, Literal
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
19
|
+
|
|
20
|
+
L = logging.getLogger(__name__)
|
|
21
|
+
INSTALLATION = Path(__file__).resolve().parent
|
|
22
|
+
MAX_RECORD_BYTES = 1024 * 1024
|
|
23
|
+
MAX_SOURCE_BYTES = 256 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Endpoint(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
28
|
+
host: str = Field(min_length=1, max_length=253)
|
|
29
|
+
user: str = Field(min_length=1, max_length=100)
|
|
30
|
+
port: int = Field(ge=1, le=65535)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Resource(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
35
|
+
id: str = Field(min_length=1, max_length=100)
|
|
36
|
+
root: str = Field(min_length=1, max_length=4096)
|
|
37
|
+
endpoint: Endpoint
|
|
38
|
+
write_staging_directory: str | None = Field(default=None, max_length=4096)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Policy(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
43
|
+
version: Literal[1]
|
|
44
|
+
receipt_archive_index: bool = False
|
|
45
|
+
resources: list[Resource] = Field(min_length=1, max_length=100)
|
|
46
|
+
legacy_write_staging_directories: list[str] = Field(
|
|
47
|
+
default_factory=list, max_length=100
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class View(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
53
|
+
configured_path: str = Field(min_length=1, max_length=4096)
|
|
54
|
+
directory: str = Field(min_length=1, max_length=4096)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Manifest(BaseModel):
|
|
58
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
59
|
+
version: Literal[1]
|
|
60
|
+
clients_quiesced: Literal[True]
|
|
61
|
+
state_directory: str = Field(min_length=1, max_length=4096)
|
|
62
|
+
resource_policy_file: str = Field(min_length=1, max_length=4096)
|
|
63
|
+
worker_file: str = Field(min_length=1, max_length=4096)
|
|
64
|
+
client_journals: list[str] = Field(min_length=1, max_length=100)
|
|
65
|
+
staging_views: list[View] = Field(min_length=1, max_length=201)
|
|
66
|
+
root_views: list[View] = Field(default_factory=list, max_length=100)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def encode(value: Any) -> bytes:
|
|
70
|
+
return json.dumps(
|
|
71
|
+
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
72
|
+
).encode("utf-8", errors="backslashreplace")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sha(data: bytes) -> str:
|
|
76
|
+
return hashlib.sha256(data).hexdigest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sync_directory(path: Path) -> None:
|
|
80
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
81
|
+
try:
|
|
82
|
+
os.fsync(descriptor)
|
|
83
|
+
finally:
|
|
84
|
+
os.close(descriptor)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def canonical(path: str, directory: bool = True, private: bool = True) -> Path:
|
|
88
|
+
result = Path(path)
|
|
89
|
+
if not result.is_absolute() or str(result.resolve(strict=True)) != path:
|
|
90
|
+
raise ValueError("Maintenance paths must be existing canonical absolute paths")
|
|
91
|
+
info = result.lstat()
|
|
92
|
+
if (
|
|
93
|
+
directory
|
|
94
|
+
and not stat.S_ISDIR(info.st_mode)
|
|
95
|
+
or not directory
|
|
96
|
+
and not stat.S_ISREG(info.st_mode)
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("Maintenance inputs must be regular files or real directories")
|
|
99
|
+
if private and (info.st_uid != os.geteuid() or info.st_mode & 0o077):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
"Maintenance journals and staging must be private to the operator UID"
|
|
102
|
+
)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def fingerprint(path: Path) -> dict[str, int]:
|
|
107
|
+
info = path.lstat()
|
|
108
|
+
return {
|
|
109
|
+
"device": info.st_dev,
|
|
110
|
+
"inode": info.st_ino,
|
|
111
|
+
"uid": info.st_uid,
|
|
112
|
+
"mode": info.st_mode,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def bounded(path: Path, limit: int = MAX_RECORD_BYTES) -> bytes:
|
|
117
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
118
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
119
|
+
before = os.fstat(stream.fileno())
|
|
120
|
+
if not stat.S_ISREG(before.st_mode) or before.st_size > limit:
|
|
121
|
+
raise ValueError("Maintenance evidence exceeds its regular-file bound")
|
|
122
|
+
data = stream.read(limit + 1)
|
|
123
|
+
after = os.fstat(stream.fileno())
|
|
124
|
+
if len(data) > limit or (
|
|
125
|
+
before.st_dev,
|
|
126
|
+
before.st_ino,
|
|
127
|
+
before.st_size,
|
|
128
|
+
before.st_mtime_ns,
|
|
129
|
+
) != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns):
|
|
130
|
+
raise ValueError("Maintenance evidence changed during its bounded read")
|
|
131
|
+
return data
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def process_namespace() -> dict[str, Any]:
|
|
135
|
+
if sys.platform == "linux":
|
|
136
|
+
boot = (
|
|
137
|
+
bounded(Path("/proc/sys/kernel/random/boot_id"), 4096)
|
|
138
|
+
.decode()
|
|
139
|
+
.strip()
|
|
140
|
+
.lower()
|
|
141
|
+
)
|
|
142
|
+
namespace = Path("/proc/self/ns/pid").stat()
|
|
143
|
+
result: dict[str, Any] = {
|
|
144
|
+
"platform": "linux",
|
|
145
|
+
"pid_namespace": [namespace.st_dev, namespace.st_ino],
|
|
146
|
+
}
|
|
147
|
+
elif sys.platform == "darwin":
|
|
148
|
+
query = subprocess.run(
|
|
149
|
+
["/usr/sbin/sysctl", "-n", "kern.bootsessionuuid"],
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
timeout=2,
|
|
153
|
+
check=True,
|
|
154
|
+
)
|
|
155
|
+
boot = query.stdout.strip().lower()
|
|
156
|
+
result = {"platform": "darwin"}
|
|
157
|
+
else:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
"Operator lock recovery requires a supported process namespace"
|
|
160
|
+
)
|
|
161
|
+
if str(uuid.UUID(boot)) != boot:
|
|
162
|
+
raise ValueError("Invalid operator kernel boot identity")
|
|
163
|
+
return {**result, "boot_id": boot}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def overlaps(first: Path, second: Path) -> bool:
|
|
167
|
+
return first.is_relative_to(second) or second.is_relative_to(first)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def private_tree(path: Path) -> None:
|
|
171
|
+
missing: list[Path] = []
|
|
172
|
+
current = path
|
|
173
|
+
while not os.path.lexists(current):
|
|
174
|
+
missing.append(current)
|
|
175
|
+
current = current.parent
|
|
176
|
+
canonical(str(current), private=False)
|
|
177
|
+
for directory in reversed(missing):
|
|
178
|
+
directory.mkdir(mode=0o700)
|
|
179
|
+
sync_directory(directory)
|
|
180
|
+
sync_directory(directory.parent)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def publish(path: Path, data: bytes, replace: bool = False) -> None:
|
|
184
|
+
private_tree(path.parent)
|
|
185
|
+
canonical(str(path.parent))
|
|
186
|
+
temporary = path.parent / (".archive-" + str(uuid.uuid4()))
|
|
187
|
+
descriptor = os.open(
|
|
188
|
+
temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
192
|
+
stream.write(data)
|
|
193
|
+
stream.flush()
|
|
194
|
+
os.fsync(stream.fileno())
|
|
195
|
+
if replace:
|
|
196
|
+
os.replace(temporary, path)
|
|
197
|
+
else:
|
|
198
|
+
try:
|
|
199
|
+
os.link(temporary, path)
|
|
200
|
+
except FileExistsError:
|
|
201
|
+
if bounded(path, max(MAX_RECORD_BYTES, len(data))) != data:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"Existing archive evidence differs; explicit recovery is required"
|
|
204
|
+
)
|
|
205
|
+
sync_directory(path.parent)
|
|
206
|
+
finally:
|
|
207
|
+
with contextlib.suppress(FileNotFoundError):
|
|
208
|
+
temporary.unlink()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class JournalArchive:
|
|
212
|
+
def __init__(self, manifest_file: str) -> None:
|
|
213
|
+
self._init_manifest_system(manifest_file)
|
|
214
|
+
self._init_worker_system()
|
|
215
|
+
self._init_policy_system()
|
|
216
|
+
self._init_binding_system()
|
|
217
|
+
|
|
218
|
+
def _init_manifest_system(self, manifest_file: str) -> None:
|
|
219
|
+
self.manifest_path = canonical(manifest_file, directory=False, private=False)
|
|
220
|
+
self.manifest_bytes = bounded(self.manifest_path)
|
|
221
|
+
self.manifest = Manifest.model_validate_json(self.manifest_bytes)
|
|
222
|
+
self.control = canonical(self.manifest.state_directory)
|
|
223
|
+
self.clients = [canonical(path) for path in self.manifest.client_journals]
|
|
224
|
+
if len(set(self.clients)) != len(self.clients):
|
|
225
|
+
raise ValueError("Client journals must be unique")
|
|
226
|
+
for index, first in enumerate([self.control, *self.clients]):
|
|
227
|
+
if any(
|
|
228
|
+
overlaps(first, second)
|
|
229
|
+
for second in [self.control, *self.clients][index + 1 :]
|
|
230
|
+
):
|
|
231
|
+
raise ValueError("Host and client journal namespaces must be disjoint")
|
|
232
|
+
self.fences = [
|
|
233
|
+
self.control / "journal-maintenance.json",
|
|
234
|
+
*[Path(str(path) + ".maintenance.json") for path in self.clients],
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
def _init_worker_system(self) -> None:
|
|
238
|
+
self.worker = canonical(
|
|
239
|
+
self.manifest.worker_file, directory=False, private=False
|
|
240
|
+
)
|
|
241
|
+
packaged = canonical(
|
|
242
|
+
str(INSTALLATION / "worker.py"), directory=False, private=False
|
|
243
|
+
)
|
|
244
|
+
self.worker_bytes = bounded(self.worker, 8 * MAX_RECORD_BYTES)
|
|
245
|
+
if self.worker_bytes != bounded(packaged, 8 * MAX_RECORD_BYTES):
|
|
246
|
+
raise ValueError(
|
|
247
|
+
"Maintenance requires the matching packaged immutable worker"
|
|
248
|
+
)
|
|
249
|
+
self.worker_api = runpy.run_path(str(packaged))
|
|
250
|
+
|
|
251
|
+
def _init_policy_system(self) -> None:
|
|
252
|
+
self.policy_path = canonical(
|
|
253
|
+
self.manifest.resource_policy_file, directory=False, private=False
|
|
254
|
+
)
|
|
255
|
+
self.policy_bytes = bounded(self.policy_path)
|
|
256
|
+
self.policy = Policy.model_validate_json(self.policy_bytes)
|
|
257
|
+
if not self.policy.receipt_archive_index:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
"Enable receipt_archive_index in immutable operator policy before maintenance"
|
|
260
|
+
)
|
|
261
|
+
self.resources = {resource.id: resource for resource in self.policy.resources}
|
|
262
|
+
if len(self.resources) != len(self.policy.resources):
|
|
263
|
+
raise ValueError("Policy resource IDs must be unique")
|
|
264
|
+
configured = {
|
|
265
|
+
"/data/.ztc-delete-quarantine",
|
|
266
|
+
*self.policy.legacy_write_staging_directories,
|
|
267
|
+
}
|
|
268
|
+
configured.update(
|
|
269
|
+
resource.write_staging_directory
|
|
270
|
+
for resource in self.policy.resources
|
|
271
|
+
if resource.write_staging_directory is not None
|
|
272
|
+
)
|
|
273
|
+
self.staging = {
|
|
274
|
+
view.configured_path: canonical(view.directory)
|
|
275
|
+
for view in self.manifest.staging_views
|
|
276
|
+
}
|
|
277
|
+
if (
|
|
278
|
+
set(self.staging) != configured
|
|
279
|
+
or len(self.staging) != len(self.manifest.staging_views)
|
|
280
|
+
or len(set(self.staging.values())) != len(self.staging)
|
|
281
|
+
):
|
|
282
|
+
raise ValueError(
|
|
283
|
+
"A complete unique default/current/historical staging inventory is required"
|
|
284
|
+
)
|
|
285
|
+
views = {
|
|
286
|
+
view.configured_path: view.directory for view in self.manifest.root_views
|
|
287
|
+
}
|
|
288
|
+
if len(views) != len(self.manifest.root_views) or set(views) - {
|
|
289
|
+
resource.root for resource in self.policy.resources
|
|
290
|
+
}:
|
|
291
|
+
raise ValueError("Root views must have unique configured policy paths")
|
|
292
|
+
self.roots = {
|
|
293
|
+
resource.id: canonical(
|
|
294
|
+
views.get(resource.root, resource.root), private=False
|
|
295
|
+
)
|
|
296
|
+
for resource in self.policy.resources
|
|
297
|
+
}
|
|
298
|
+
for root in self.roots.values():
|
|
299
|
+
if any(
|
|
300
|
+
overlaps(root, protected)
|
|
301
|
+
for protected in [self.control, *self.clients, *self.staging.values()]
|
|
302
|
+
):
|
|
303
|
+
raise ValueError(
|
|
304
|
+
"Resource views overlap maintenance journals or private staging"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _init_binding_system(self) -> None:
|
|
308
|
+
paths = [
|
|
309
|
+
self.control,
|
|
310
|
+
*self.clients,
|
|
311
|
+
*self.staging.values(),
|
|
312
|
+
*self.roots.values(),
|
|
313
|
+
self.worker,
|
|
314
|
+
self.policy_path,
|
|
315
|
+
]
|
|
316
|
+
self.bindings = {str(path): fingerprint(path) for path in paths}
|
|
317
|
+
self.process_namespace = process_namespace()
|
|
318
|
+
self.records: dict[str, dict[str, Any]] = {}
|
|
319
|
+
self.source_bytes = 0
|
|
320
|
+
|
|
321
|
+
@contextlib.contextmanager
|
|
322
|
+
def locked(self, digest: str | None = None) -> Iterator[None]:
|
|
323
|
+
with contextlib.ExitStack() as handles:
|
|
324
|
+
for journal in sorted(self.clients):
|
|
325
|
+
lock = Path(str(journal) + ".admission-lock")
|
|
326
|
+
try:
|
|
327
|
+
lock.mkdir(mode=0o700)
|
|
328
|
+
except FileExistsError:
|
|
329
|
+
if digest is None:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
"Client journal is locked; inspect its owner before confirmed recovery"
|
|
332
|
+
)
|
|
333
|
+
self.recover_dead_operator_lock(lock, journal, digest)
|
|
334
|
+
lock.mkdir(mode=0o700)
|
|
335
|
+
metadata = encode(
|
|
336
|
+
{
|
|
337
|
+
"version": 1,
|
|
338
|
+
"nonce": str(uuid.uuid4()),
|
|
339
|
+
"pid": os.getpid(),
|
|
340
|
+
"operator": "ztc-archive",
|
|
341
|
+
"digest": digest,
|
|
342
|
+
"manifest_sha256": sha(self.manifest_bytes),
|
|
343
|
+
"journal_identity": fingerprint(journal),
|
|
344
|
+
"process_namespace": self.process_namespace,
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
publish(lock / "owner.json", metadata)
|
|
348
|
+
identity = fingerprint(lock)
|
|
349
|
+
handles.callback(self.release_lock, lock, identity, metadata)
|
|
350
|
+
sync_directory(lock.parent)
|
|
351
|
+
descriptor = os.open(
|
|
352
|
+
self.control / "lock",
|
|
353
|
+
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK,
|
|
354
|
+
0o600,
|
|
355
|
+
)
|
|
356
|
+
handles.callback(os.close, descriptor)
|
|
357
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
358
|
+
raise ValueError("Broker lock must be a regular file")
|
|
359
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
360
|
+
sync_directory(self.control)
|
|
361
|
+
yield
|
|
362
|
+
|
|
363
|
+
def recover_dead_operator_lock(
|
|
364
|
+
self, lock: Path, journal: Path, digest: str
|
|
365
|
+
) -> None:
|
|
366
|
+
canonical(str(lock))
|
|
367
|
+
identity = fingerprint(lock)
|
|
368
|
+
data = bounded(lock / "owner.json")
|
|
369
|
+
owner = json.loads(data)
|
|
370
|
+
if (
|
|
371
|
+
not isinstance(owner, dict)
|
|
372
|
+
or type(owner.get("version")) is not int
|
|
373
|
+
or owner["version"] != 1
|
|
374
|
+
or owner.get("operator") != "ztc-archive"
|
|
375
|
+
or owner.get("digest") != digest
|
|
376
|
+
or owner.get("manifest_sha256") != sha(self.manifest_bytes)
|
|
377
|
+
or owner.get("journal_identity") != fingerprint(journal)
|
|
378
|
+
or owner.get("process_namespace") != self.process_namespace
|
|
379
|
+
or type(owner.get("pid")) is not int
|
|
380
|
+
or owner["pid"] <= 0
|
|
381
|
+
or not isinstance(owner.get("nonce"), str)
|
|
382
|
+
or str(uuid.UUID(owner["nonce"])) != owner["nonce"]
|
|
383
|
+
):
|
|
384
|
+
raise ValueError(
|
|
385
|
+
"Client journal lock requires explicit reviewed recovery; its owner is unknown or belongs to another namespace"
|
|
386
|
+
)
|
|
387
|
+
try:
|
|
388
|
+
os.kill(owner["pid"], 0)
|
|
389
|
+
except ProcessLookupError:
|
|
390
|
+
pass
|
|
391
|
+
else:
|
|
392
|
+
raise ValueError(
|
|
393
|
+
"Operator journal lock owner is still live; confirmed resume cannot steal it"
|
|
394
|
+
)
|
|
395
|
+
retained = Path(str(lock) + ".retained-" + owner["nonce"])
|
|
396
|
+
descriptor = os.open(lock.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
397
|
+
try:
|
|
398
|
+
if fingerprint(lock) != identity or bounded(lock / "owner.json") != data:
|
|
399
|
+
raise ValueError(
|
|
400
|
+
"Operator lock changed during review; preserve its evidence"
|
|
401
|
+
)
|
|
402
|
+
self.worker_api["rename_without_replacement"](
|
|
403
|
+
descriptor, lock.name, descriptor, retained.name
|
|
404
|
+
)
|
|
405
|
+
os.fsync(descriptor)
|
|
406
|
+
if (
|
|
407
|
+
fingerprint(retained) != identity
|
|
408
|
+
or bounded(retained / "owner.json") != data
|
|
409
|
+
):
|
|
410
|
+
with contextlib.suppress(OSError):
|
|
411
|
+
self.worker_api["rename_without_replacement"](
|
|
412
|
+
descriptor, retained.name, descriptor, lock.name
|
|
413
|
+
)
|
|
414
|
+
os.fsync(descriptor)
|
|
415
|
+
raise ValueError(
|
|
416
|
+
"Operator lock recovery changed; original evidence was retained"
|
|
417
|
+
)
|
|
418
|
+
finally:
|
|
419
|
+
os.close(descriptor)
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def release_lock(path: Path, identity: dict[str, int], metadata: bytes) -> None:
|
|
423
|
+
if fingerprint(path) != identity or bounded(path / "owner.json") != metadata:
|
|
424
|
+
raise ValueError(
|
|
425
|
+
"Maintenance lock changed; explicit operator recovery is required"
|
|
426
|
+
)
|
|
427
|
+
(path / "owner.json").unlink()
|
|
428
|
+
path.rmdir()
|
|
429
|
+
sync_directory(path.parent)
|
|
430
|
+
|
|
431
|
+
def capture(self, path: Path) -> bytes:
|
|
432
|
+
data = bounded(path)
|
|
433
|
+
self.source_bytes += len(data)
|
|
434
|
+
if self.source_bytes > MAX_SOURCE_BYTES:
|
|
435
|
+
raise ValueError("Maintenance export exceeds its aggregate 256 MiB bound")
|
|
436
|
+
return data
|
|
437
|
+
|
|
438
|
+
def scan(self, directory: Path, source: str) -> list[dict[str, Any]]:
|
|
439
|
+
canonical(str(directory))
|
|
440
|
+
names = sorted(directory.iterdir())
|
|
441
|
+
if len(names) > 10000:
|
|
442
|
+
raise ValueError(
|
|
443
|
+
"Active journal exceeds its 10,000-entry bound; preserve and inspect it"
|
|
444
|
+
)
|
|
445
|
+
result: list[dict[str, Any]] = []
|
|
446
|
+
for path in names:
|
|
447
|
+
if path.suffix != ".json" or str(uuid.UUID(path.stem)) != path.stem:
|
|
448
|
+
raise ValueError("Invalid active journal record name")
|
|
449
|
+
canonical(str(path), directory=False)
|
|
450
|
+
data = self.capture(path)
|
|
451
|
+
value = json.loads(data)
|
|
452
|
+
if (
|
|
453
|
+
not isinstance(value, dict)
|
|
454
|
+
or value.get("version") != 1
|
|
455
|
+
or value.get("id") != path.stem
|
|
456
|
+
or value.get("operation") not in ["write", "delete"]
|
|
457
|
+
):
|
|
458
|
+
raise ValueError("Invalid active journal operation identity")
|
|
459
|
+
requested = value.get("requested_path")
|
|
460
|
+
if (
|
|
461
|
+
not isinstance(requested, str)
|
|
462
|
+
or not requested
|
|
463
|
+
or os.path.isabs(requested)
|
|
464
|
+
or "\x00" in requested
|
|
465
|
+
):
|
|
466
|
+
raise ValueError("Invalid original relative target path")
|
|
467
|
+
endpoint = Endpoint.model_validate(value.get("endpoint"))
|
|
468
|
+
owner = value.get("owner_id")
|
|
469
|
+
call = value.get("tool_call_id")
|
|
470
|
+
if (
|
|
471
|
+
not isinstance(owner, str)
|
|
472
|
+
or not owner
|
|
473
|
+
or len(owner) > 200
|
|
474
|
+
or call is not None
|
|
475
|
+
and (not isinstance(call, str) or not call or len(call) > 200)
|
|
476
|
+
):
|
|
477
|
+
raise ValueError("Invalid original owner or originating-call linkage")
|
|
478
|
+
target_evidence = value.get("target")
|
|
479
|
+
if source == "host":
|
|
480
|
+
if not isinstance(target_evidence, dict):
|
|
481
|
+
raise ValueError("Invalid original target verification evidence")
|
|
482
|
+
target = target_evidence.get("path")
|
|
483
|
+
else:
|
|
484
|
+
target = target_evidence
|
|
485
|
+
statuses = (
|
|
486
|
+
["pending", "resolved", "not_dispatched"]
|
|
487
|
+
if source != "host"
|
|
488
|
+
else [
|
|
489
|
+
"pending",
|
|
490
|
+
"verified",
|
|
491
|
+
"failed",
|
|
492
|
+
"unverified",
|
|
493
|
+
"dry_run",
|
|
494
|
+
"cancelled",
|
|
495
|
+
]
|
|
496
|
+
)
|
|
497
|
+
if (
|
|
498
|
+
value.get("status") not in statuses
|
|
499
|
+
or type(value.get("version")) is not int
|
|
500
|
+
):
|
|
501
|
+
raise ValueError("Invalid original journal status or schema version")
|
|
502
|
+
if not isinstance(target, str) or not os.path.isabs(target):
|
|
503
|
+
raise ValueError("Invalid original target evidence")
|
|
504
|
+
record = {
|
|
505
|
+
"source": source,
|
|
506
|
+
"id": path.stem,
|
|
507
|
+
"sha256": sha(data),
|
|
508
|
+
"size": len(data),
|
|
509
|
+
"identity": fingerprint(path),
|
|
510
|
+
"status": value.get("status"),
|
|
511
|
+
"owner_id": owner,
|
|
512
|
+
"tool_call_id": call,
|
|
513
|
+
"endpoint": endpoint.model_dump(),
|
|
514
|
+
"resource_id": value.get("resource_id"),
|
|
515
|
+
"operation": value["operation"],
|
|
516
|
+
"target": target,
|
|
517
|
+
"requested_path": value.get("requested_path"),
|
|
518
|
+
"archive": False,
|
|
519
|
+
"reason": "requires recovery",
|
|
520
|
+
}
|
|
521
|
+
self.records[source + "/" + path.stem] = {
|
|
522
|
+
"path": path,
|
|
523
|
+
"bytes": data,
|
|
524
|
+
"value": value,
|
|
525
|
+
"record": record,
|
|
526
|
+
}
|
|
527
|
+
result.append(record)
|
|
528
|
+
return result
|
|
529
|
+
|
|
530
|
+
def preview(self) -> dict[str, Any]:
|
|
531
|
+
if any(os.path.lexists(path) for path in self.fences) or os.path.lexists(
|
|
532
|
+
self.control / "cutover-fence.json"
|
|
533
|
+
):
|
|
534
|
+
raise ValueError(
|
|
535
|
+
"Maintenance or cutover is frozen; inspect and resume its confirmed transaction"
|
|
536
|
+
)
|
|
537
|
+
self.records = {}
|
|
538
|
+
self.source_bytes = 0
|
|
539
|
+
receipts = self.control / "receipts"
|
|
540
|
+
if not receipts.exists():
|
|
541
|
+
raise ValueError(
|
|
542
|
+
"Receipt journal must already exist; initialization is a separate host setup operation"
|
|
543
|
+
)
|
|
544
|
+
host = self.scan(receipts, "host")
|
|
545
|
+
clients = [
|
|
546
|
+
self.scan(path, "client-" + str(index))
|
|
547
|
+
for index, path in enumerate(self.clients)
|
|
548
|
+
]
|
|
549
|
+
if sum(len(records) for records in clients) > 10000:
|
|
550
|
+
raise ValueError(
|
|
551
|
+
"Participating client journals exceed the aggregate 10,000-entry maintenance bound"
|
|
552
|
+
)
|
|
553
|
+
staged: set[str] = set()
|
|
554
|
+
blockers: list[str] = []
|
|
555
|
+
for configured, directory in sorted(self.staging.items()):
|
|
556
|
+
entries = list(directory.iterdir())
|
|
557
|
+
if len(entries) > 10000:
|
|
558
|
+
raise ValueError("Private staging exceeds its bounded inventory")
|
|
559
|
+
for entry in entries:
|
|
560
|
+
try:
|
|
561
|
+
if str(uuid.UUID(entry.name)) != entry.name:
|
|
562
|
+
raise ValueError("Unknown staging entry")
|
|
563
|
+
except ValueError:
|
|
564
|
+
blockers.append(
|
|
565
|
+
"Unknown retained staging evidence at " + configured
|
|
566
|
+
)
|
|
567
|
+
continue
|
|
568
|
+
staged.add(entry.name)
|
|
569
|
+
by_id = {record["id"]: record for record in host}
|
|
570
|
+
if staged - set(by_id):
|
|
571
|
+
blockers.append("Unlinked private staging evidence requires recovery")
|
|
572
|
+
client_records = [record for records in clients for record in records]
|
|
573
|
+
client_linkage: dict[str, list[dict[str, Any]]] = {}
|
|
574
|
+
for record in client_records:
|
|
575
|
+
client_linkage.setdefault(record["id"], []).append(record)
|
|
576
|
+
for record in host:
|
|
577
|
+
value = self.records["host/" + record["id"]]["value"]
|
|
578
|
+
linked = client_linkage.get(record["id"], [])
|
|
579
|
+
if record["status"] not in ["verified", "failed", "dry_run", "cancelled"]:
|
|
580
|
+
record["reason"] = "pending or unverified remote outcome"
|
|
581
|
+
elif value.get("admission_resolved") is not True:
|
|
582
|
+
record["reason"] = "unresolved remote admission or recovery"
|
|
583
|
+
elif record["id"] in staged:
|
|
584
|
+
record["reason"] = "private staging still retains operation evidence"
|
|
585
|
+
elif not linked or any(other["status"] != "resolved" for other in linked):
|
|
586
|
+
record["reason"] = "missing or unresolved participating client linkage"
|
|
587
|
+
elif any(not self.same_origin(record, other) for other in linked):
|
|
588
|
+
record["reason"] = "original client/host linkage differs"
|
|
589
|
+
elif record["status"] == "verified" and (
|
|
590
|
+
not isinstance(value.get("verification"), dict)
|
|
591
|
+
or not (
|
|
592
|
+
value["verification"].get("target_matches") is True
|
|
593
|
+
or value["verification"].get("target_absent") is True
|
|
594
|
+
)
|
|
595
|
+
):
|
|
596
|
+
record["reason"] = (
|
|
597
|
+
"verified receipt lacks its original target verification"
|
|
598
|
+
)
|
|
599
|
+
elif record["status"] == "cancelled" and (
|
|
600
|
+
not isinstance(value.get("resolution"), dict)
|
|
601
|
+
or value["resolution"].get("decision") != "accept_current_state"
|
|
602
|
+
):
|
|
603
|
+
record["reason"] = (
|
|
604
|
+
"cancellation lacks its confirmed original reconciliation"
|
|
605
|
+
)
|
|
606
|
+
else:
|
|
607
|
+
record.update(
|
|
608
|
+
archive=True,
|
|
609
|
+
reason="terminal acknowledged outcome with exact client linkage",
|
|
610
|
+
)
|
|
611
|
+
for record in client_records:
|
|
612
|
+
receipt = by_id.get(record["id"])
|
|
613
|
+
if record["id"] in staged:
|
|
614
|
+
record["reason"] = "private staging still retains operation evidence"
|
|
615
|
+
elif record["status"] == "pending":
|
|
616
|
+
record["reason"] = "unresolved client admission"
|
|
617
|
+
elif record["status"] == "not_dispatched" and receipt is None:
|
|
618
|
+
record.update(
|
|
619
|
+
archive=True,
|
|
620
|
+
reason="terminal not-dispatched origin; retain host replay protection",
|
|
621
|
+
)
|
|
622
|
+
elif (
|
|
623
|
+
record["status"] == "resolved"
|
|
624
|
+
and receipt is not None
|
|
625
|
+
and receipt["archive"]
|
|
626
|
+
and self.same_origin(record, receipt)
|
|
627
|
+
):
|
|
628
|
+
record.update(
|
|
629
|
+
archive=True,
|
|
630
|
+
reason="terminal client linked to archived acknowledged remote receipt",
|
|
631
|
+
)
|
|
632
|
+
else:
|
|
633
|
+
record["reason"] = "missing, uncertain or conflicting remote linkage"
|
|
634
|
+
origins: dict[str, str] = {}
|
|
635
|
+
for record in [*host, *client_records]:
|
|
636
|
+
if record["tool_call_id"] is not None:
|
|
637
|
+
endpoint = record["endpoint"]
|
|
638
|
+
key = sha(
|
|
639
|
+
encode(
|
|
640
|
+
[
|
|
641
|
+
"call",
|
|
642
|
+
endpoint["host"],
|
|
643
|
+
endpoint["user"],
|
|
644
|
+
endpoint["port"],
|
|
645
|
+
record["owner_id"],
|
|
646
|
+
record["tool_call_id"],
|
|
647
|
+
]
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
if key in origins and origins[key] != record["id"]:
|
|
651
|
+
blockers.append(
|
|
652
|
+
"Conflicting original operation IDs for an originating call"
|
|
653
|
+
)
|
|
654
|
+
origins[key] = record["id"]
|
|
655
|
+
index_states: dict[str, str | None] = {}
|
|
656
|
+
for source, directory, archives in [
|
|
657
|
+
("host", self.control / "replay-index", self.control / "receipt-archives"),
|
|
658
|
+
*[
|
|
659
|
+
(
|
|
660
|
+
"client-" + str(i),
|
|
661
|
+
Path(str(path) + ".replay"),
|
|
662
|
+
Path(str(path) + ".archives"),
|
|
663
|
+
)
|
|
664
|
+
for i, path in enumerate(self.clients)
|
|
665
|
+
],
|
|
666
|
+
]:
|
|
667
|
+
required = os.path.lexists(archives) or (
|
|
668
|
+
source != "host"
|
|
669
|
+
and os.path.lexists(
|
|
670
|
+
Path(str(self.clients[int(source[7:])]) + ".replay-required.json")
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
header = self.worker_api["replay_manifest"](str(directory), required)
|
|
674
|
+
if header is not None:
|
|
675
|
+
for prefix, bucket in header["buckets"].items():
|
|
676
|
+
self.worker_api["replay_bucket"](str(directory), prefix, bucket)
|
|
677
|
+
index_states[source] = sha(bounded(directory / "manifest.json"))
|
|
678
|
+
else:
|
|
679
|
+
index_states[source] = None
|
|
680
|
+
leases = self.control / "leases.json"
|
|
681
|
+
lease_bytes = bounded(leases, 16 * MAX_RECORD_BYTES) if leases.exists() else b""
|
|
682
|
+
plan = {
|
|
683
|
+
"version": 1,
|
|
684
|
+
"manifest_sha256": sha(self.manifest_bytes),
|
|
685
|
+
"worker_sha256": sha(self.worker_bytes),
|
|
686
|
+
"policy_sha256": sha(self.policy_bytes),
|
|
687
|
+
"operator_sha256": sha(bounded(Path(__file__), 8 * MAX_RECORD_BYTES)),
|
|
688
|
+
"bindings": self.bindings,
|
|
689
|
+
"lease_sha256": sha(lease_bytes),
|
|
690
|
+
"journals": {
|
|
691
|
+
"host": host,
|
|
692
|
+
**{"client-" + str(i): records for i, records in enumerate(clients)},
|
|
693
|
+
},
|
|
694
|
+
"replay_before": index_states,
|
|
695
|
+
"blockers": blockers,
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
"ready": not blockers,
|
|
699
|
+
"digest": sha(encode(plan)),
|
|
700
|
+
"plan": plan,
|
|
701
|
+
"archive_count": sum(
|
|
702
|
+
record["archive"] for record in [*host, *client_records]
|
|
703
|
+
),
|
|
704
|
+
"preserved_count": sum(
|
|
705
|
+
not record["archive"] for record in [*host, *client_records]
|
|
706
|
+
),
|
|
707
|
+
"index_initialization": [
|
|
708
|
+
source for source, value in index_states.items() if value is None
|
|
709
|
+
],
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
def archive_roots(self, digest: str) -> dict[str, Path]:
|
|
713
|
+
return {
|
|
714
|
+
"host": self.control / "receipt-archives" / digest,
|
|
715
|
+
**{
|
|
716
|
+
"client-" + str(index): Path(str(path) + ".archives") / digest
|
|
717
|
+
for index, path in enumerate(self.clients)
|
|
718
|
+
},
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
def journal_roots(self) -> dict[str, Path]:
|
|
722
|
+
return {
|
|
723
|
+
"host": self.control / "receipts",
|
|
724
|
+
**{"client-" + str(index): path for index, path in enumerate(self.clients)},
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
def index_roots(self) -> dict[str, Path]:
|
|
728
|
+
return {
|
|
729
|
+
"host": self.control / "replay-index",
|
|
730
|
+
**{
|
|
731
|
+
"client-" + str(index): Path(str(path) + ".replay")
|
|
732
|
+
for index, path in enumerate(self.clients)
|
|
733
|
+
},
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
def fence_bytes(self, digest: str) -> bytes:
|
|
737
|
+
return encode(
|
|
738
|
+
{
|
|
739
|
+
"version": 1,
|
|
740
|
+
"digest": digest,
|
|
741
|
+
"manifest_sha256": sha(self.manifest_bytes),
|
|
742
|
+
"worker_sha256": sha(self.worker_bytes),
|
|
743
|
+
"policy_sha256": sha(self.policy_bytes),
|
|
744
|
+
}
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
def require_fences(self, digest: str) -> None:
|
|
748
|
+
expected = self.fence_bytes(digest)
|
|
749
|
+
for path in self.fences:
|
|
750
|
+
if not os.path.lexists(path) or bounded(path) != expected:
|
|
751
|
+
raise ValueError(
|
|
752
|
+
"Maintenance fence differs or is missing; preserve evidence for explicit recovery"
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
def record_export_path(self, root: Path, source: str, identifier: str) -> Path:
|
|
756
|
+
return (
|
|
757
|
+
root
|
|
758
|
+
/ ("receipts" if source == "host" else "records")
|
|
759
|
+
/ (identifier + ".json")
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
def export(self, plan: dict[str, Any], digest: str) -> None:
|
|
763
|
+
roots = self.archive_roots(digest)
|
|
764
|
+
payload = encode(plan)
|
|
765
|
+
if len(payload) > 16 * MAX_RECORD_BYTES:
|
|
766
|
+
raise ValueError("Concrete archive plan exceeds its 16 MiB bound")
|
|
767
|
+
for root in roots.values():
|
|
768
|
+
publish(root / "plan.json", payload)
|
|
769
|
+
for name, data in [
|
|
770
|
+
("manifest.json", self.manifest_bytes),
|
|
771
|
+
("resources.json", self.policy_bytes),
|
|
772
|
+
("worker.py", self.worker_bytes),
|
|
773
|
+
("operator.py", bounded(Path(__file__), 8 * MAX_RECORD_BYTES)),
|
|
774
|
+
]:
|
|
775
|
+
publish(roots["host"] / name, data)
|
|
776
|
+
for source, records in plan["journals"].items():
|
|
777
|
+
for record in records:
|
|
778
|
+
data = self.records[source + "/" + record["id"]]["bytes"]
|
|
779
|
+
publish(
|
|
780
|
+
self.record_export_path(roots[source], source, record["id"]), data
|
|
781
|
+
)
|
|
782
|
+
if source != "host":
|
|
783
|
+
publish(
|
|
784
|
+
roots["host"]
|
|
785
|
+
/ "clients"
|
|
786
|
+
/ source
|
|
787
|
+
/ "records"
|
|
788
|
+
/ (record["id"] + ".json"),
|
|
789
|
+
data,
|
|
790
|
+
)
|
|
791
|
+
(roots[source] / "compacted").mkdir(mode=0o700, exist_ok=True)
|
|
792
|
+
# Copy the exact old replay header and objects before any index publication.
|
|
793
|
+
# Resume builds from this immutable view even after one live header changes.
|
|
794
|
+
for source, directory in self.index_roots().items():
|
|
795
|
+
before = roots[source] / "replay-before"
|
|
796
|
+
before.mkdir(mode=0o700, exist_ok=True)
|
|
797
|
+
if plan["replay_before"][source] is None:
|
|
798
|
+
publish(
|
|
799
|
+
before / "manifest.json",
|
|
800
|
+
encode({"version": 1, "total": 0, "buckets": {}}),
|
|
801
|
+
)
|
|
802
|
+
elif not (before / "manifest.json").exists():
|
|
803
|
+
data = bounded(directory / "manifest.json")
|
|
804
|
+
if sha(data) != plan["replay_before"][source]:
|
|
805
|
+
raise ValueError("Original replay manifest changed before export")
|
|
806
|
+
header = self.worker_api["replay_manifest"](str(directory), True)
|
|
807
|
+
for bucket in header["buckets"].values():
|
|
808
|
+
publish(
|
|
809
|
+
before / "objects" / (bucket["sha256"] + ".json"),
|
|
810
|
+
bounded(
|
|
811
|
+
directory / "objects" / (bucket["sha256"] + ".json"),
|
|
812
|
+
2 * MAX_RECORD_BYTES,
|
|
813
|
+
),
|
|
814
|
+
)
|
|
815
|
+
publish(before / "manifest.json", data)
|
|
816
|
+
self.sync_export_roots(roots)
|
|
817
|
+
|
|
818
|
+
@staticmethod
|
|
819
|
+
def sync_export_roots(roots: dict[str, Path]) -> None:
|
|
820
|
+
for root in roots.values():
|
|
821
|
+
# Each file was fsynced before publication. Settle every directory
|
|
822
|
+
# binding, including archive ancestors, before active records move.
|
|
823
|
+
directories: list[Path] = []
|
|
824
|
+
for path, names, files in os.walk(root, followlinks=False):
|
|
825
|
+
directory = canonical(path)
|
|
826
|
+
directories.append(directory)
|
|
827
|
+
if len(names) + len(files) > 20000:
|
|
828
|
+
raise ValueError("Archive directory exceeds its inventory bound")
|
|
829
|
+
for directory in reversed(directories):
|
|
830
|
+
sync_directory(directory)
|
|
831
|
+
sync_directory(root.parent)
|
|
832
|
+
sync_directory(root.parent.parent)
|
|
833
|
+
|
|
834
|
+
def replay_after(
|
|
835
|
+
self, plan: dict[str, Any], digest: str
|
|
836
|
+
) -> dict[str, tuple[bytes, dict[str, bytes]]]:
|
|
837
|
+
roots = self.archive_roots(digest)
|
|
838
|
+
additions: dict[str, dict[str, dict[str, Any]]] = {
|
|
839
|
+
source: {} for source in roots
|
|
840
|
+
}
|
|
841
|
+
host_ids = {
|
|
842
|
+
record["id"]: record
|
|
843
|
+
for record in plan["journals"]["host"]
|
|
844
|
+
if record["archive"]
|
|
845
|
+
}
|
|
846
|
+
for source, records in plan["journals"].items():
|
|
847
|
+
for record in records:
|
|
848
|
+
if not record["archive"]:
|
|
849
|
+
continue
|
|
850
|
+
pointer = {
|
|
851
|
+
"id": record["id"],
|
|
852
|
+
"archive": digest,
|
|
853
|
+
"sha256": record["sha256"],
|
|
854
|
+
"kind": "receipt" if source == "host" else "client",
|
|
855
|
+
}
|
|
856
|
+
keys = [self.worker_api["replay_key"](["operation", record["id"]])]
|
|
857
|
+
endpoint = record["endpoint"]
|
|
858
|
+
if record["tool_call_id"] is not None:
|
|
859
|
+
keys.append(
|
|
860
|
+
self.worker_api["replay_key"](
|
|
861
|
+
[
|
|
862
|
+
"call",
|
|
863
|
+
endpoint["host"],
|
|
864
|
+
endpoint["user"],
|
|
865
|
+
endpoint["port"],
|
|
866
|
+
record["owner_id"],
|
|
867
|
+
record["tool_call_id"],
|
|
868
|
+
]
|
|
869
|
+
)
|
|
870
|
+
)
|
|
871
|
+
for key in keys:
|
|
872
|
+
previous = additions[source].get(key)
|
|
873
|
+
if previous is not None and previous != pointer:
|
|
874
|
+
raise ValueError(
|
|
875
|
+
"Conflicting original replay linkage; preserve both full records"
|
|
876
|
+
)
|
|
877
|
+
additions[source][key] = pointer
|
|
878
|
+
if source != "host" and record["id"] not in host_ids:
|
|
879
|
+
previous = additions["host"].get(key)
|
|
880
|
+
if previous is not None and previous != pointer:
|
|
881
|
+
raise ValueError(
|
|
882
|
+
"Ambiguous client-origin archive evidence; preserve both records"
|
|
883
|
+
)
|
|
884
|
+
additions["host"][key] = pointer
|
|
885
|
+
publish(
|
|
886
|
+
roots["host"] / "client-records" / (record["id"] + ".json"),
|
|
887
|
+
self.records[source + "/" + record["id"]]["bytes"],
|
|
888
|
+
)
|
|
889
|
+
after: dict[str, tuple[bytes, dict[str, bytes]]] = {}
|
|
890
|
+
aggregate_bytes = 0
|
|
891
|
+
for source, pending in additions.items():
|
|
892
|
+
directory = roots[source] / "replay-before"
|
|
893
|
+
header = self.worker_api["replay_manifest"](str(directory), True)
|
|
894
|
+
groups: dict[str, dict[str, Any]] = {}
|
|
895
|
+
for key, pointer in pending.items():
|
|
896
|
+
groups.setdefault(key[:3], {})[key] = pointer
|
|
897
|
+
buckets: dict[str, dict[str, Any]] = {}
|
|
898
|
+
objects: dict[str, bytes] = {}
|
|
899
|
+
total = 0
|
|
900
|
+
for prefix in sorted(set(header["buckets"]) | set(groups)):
|
|
901
|
+
entries = (
|
|
902
|
+
self.worker_api["replay_bucket"](
|
|
903
|
+
str(directory), prefix, header["buckets"][prefix]
|
|
904
|
+
)
|
|
905
|
+
if prefix in header["buckets"]
|
|
906
|
+
else {}
|
|
907
|
+
)
|
|
908
|
+
for key, pointer in groups.get(prefix, {}).items():
|
|
909
|
+
if key in entries and entries[key] != pointer:
|
|
910
|
+
raise ValueError(
|
|
911
|
+
"An active origin already appears in retired replay protection; preserve and inspect it"
|
|
912
|
+
)
|
|
913
|
+
entries[key] = pointer
|
|
914
|
+
data = encode({"version": 1, "entries": entries})
|
|
915
|
+
if len(entries) > 4096 or len(data) > 2 * MAX_RECORD_BYTES:
|
|
916
|
+
raise ValueError(
|
|
917
|
+
"Replay bucket is full; existing protection cannot be discarded"
|
|
918
|
+
)
|
|
919
|
+
total += len(entries)
|
|
920
|
+
aggregate_bytes += len(data)
|
|
921
|
+
if total > 1000000 or aggregate_bytes > 256 * MAX_RECORD_BYTES:
|
|
922
|
+
raise ValueError(
|
|
923
|
+
"Replay maintenance exceeds its bounded key or byte budget"
|
|
924
|
+
)
|
|
925
|
+
checksum = sha(data)
|
|
926
|
+
objects[checksum + ".json"] = data
|
|
927
|
+
buckets[prefix] = {"sha256": checksum, "count": len(entries)}
|
|
928
|
+
data = encode({"version": 1, "total": total, "buckets": buckets})
|
|
929
|
+
if len(data) > MAX_RECORD_BYTES:
|
|
930
|
+
raise ValueError("Replay manifest exceeds its 1 MiB bound")
|
|
931
|
+
after[source] = (data, objects)
|
|
932
|
+
for name, body in objects.items():
|
|
933
|
+
publish(roots[source] / "replay-after" / "objects" / name, body)
|
|
934
|
+
publish(roots[source] / "replay-after" / "manifest.json", data)
|
|
935
|
+
self.sync_export_roots(roots)
|
|
936
|
+
return after
|
|
937
|
+
|
|
938
|
+
def load_plan(self, digest: str) -> dict[str, Any]:
|
|
939
|
+
root = self.archive_roots(digest)["host"]
|
|
940
|
+
data = bounded(root / "plan.json", 16 * MAX_RECORD_BYTES)
|
|
941
|
+
if sha(data) != digest:
|
|
942
|
+
raise ValueError("Confirmed archive plan checksum changed")
|
|
943
|
+
plan = json.loads(data)
|
|
944
|
+
if (
|
|
945
|
+
not isinstance(plan, dict)
|
|
946
|
+
or plan.get("manifest_sha256") != sha(self.manifest_bytes)
|
|
947
|
+
or plan.get("worker_sha256") != sha(self.worker_bytes)
|
|
948
|
+
or plan.get("policy_sha256") != sha(self.policy_bytes)
|
|
949
|
+
or plan.get("operator_sha256")
|
|
950
|
+
!= sha(bounded(Path(__file__), 8 * MAX_RECORD_BYTES))
|
|
951
|
+
or plan.get("bindings") != self.bindings
|
|
952
|
+
):
|
|
953
|
+
raise ValueError(
|
|
954
|
+
"Original archive source, policy or namespace bindings changed"
|
|
955
|
+
)
|
|
956
|
+
return dict(plan)
|
|
957
|
+
|
|
958
|
+
def restore_snapshot(self, plan: dict[str, Any], digest: str) -> None:
|
|
959
|
+
self.records = {}
|
|
960
|
+
roots = self.archive_roots(digest)
|
|
961
|
+
sources = self.journal_roots()
|
|
962
|
+
for source, records in plan["journals"].items():
|
|
963
|
+
expected_names = {record["id"] + ".json" for record in records}
|
|
964
|
+
if set(os.listdir(sources[source])) - expected_names:
|
|
965
|
+
raise ValueError(
|
|
966
|
+
"Active journal acquired unreviewed records; preserve the maintenance fence"
|
|
967
|
+
)
|
|
968
|
+
for record in records:
|
|
969
|
+
exported = self.record_export_path(roots[source], source, record["id"])
|
|
970
|
+
active = sources[source] / (record["id"] + ".json")
|
|
971
|
+
captured = roots[source] / "compacted" / (record["id"] + ".json")
|
|
972
|
+
if exported.exists():
|
|
973
|
+
data = bounded(exported)
|
|
974
|
+
elif active.exists() and fingerprint(active) == record["identity"]:
|
|
975
|
+
data = bounded(active)
|
|
976
|
+
else:
|
|
977
|
+
raise ValueError(
|
|
978
|
+
"Original full archive export is missing; preserve remaining active evidence"
|
|
979
|
+
)
|
|
980
|
+
if sha(data) != record["sha256"]:
|
|
981
|
+
raise ValueError("Original archived record checksum changed")
|
|
982
|
+
if active.exists():
|
|
983
|
+
if (
|
|
984
|
+
fingerprint(active) != record["identity"]
|
|
985
|
+
or sha(bounded(active)) != record["sha256"]
|
|
986
|
+
):
|
|
987
|
+
raise ValueError(
|
|
988
|
+
"Original active record changed; preserve the maintenance fence"
|
|
989
|
+
)
|
|
990
|
+
elif (
|
|
991
|
+
not record["archive"]
|
|
992
|
+
or not captured.exists()
|
|
993
|
+
or fingerprint(captured) != record["identity"]
|
|
994
|
+
or sha(bounded(captured)) != record["sha256"]
|
|
995
|
+
):
|
|
996
|
+
raise ValueError(
|
|
997
|
+
"Original active record disappeared without its durable archive capture"
|
|
998
|
+
)
|
|
999
|
+
self.records[source + "/" + record["id"]] = {
|
|
1000
|
+
"path": active,
|
|
1001
|
+
"bytes": data,
|
|
1002
|
+
"value": json.loads(data),
|
|
1003
|
+
"record": record,
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
def compact(self, plan: dict[str, Any], digest: str) -> None:
|
|
1007
|
+
roots = self.archive_roots(digest)
|
|
1008
|
+
for source, records in plan["journals"].items():
|
|
1009
|
+
with contextlib.ExitStack() as handles:
|
|
1010
|
+
journal = os.open(
|
|
1011
|
+
self.journal_roots()[source],
|
|
1012
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
1013
|
+
)
|
|
1014
|
+
destination = os.open(
|
|
1015
|
+
roots[source] / "compacted",
|
|
1016
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
1017
|
+
)
|
|
1018
|
+
handles.callback(os.close, journal)
|
|
1019
|
+
handles.callback(os.close, destination)
|
|
1020
|
+
for record in records:
|
|
1021
|
+
if not record["archive"]:
|
|
1022
|
+
continue
|
|
1023
|
+
name = record["id"] + ".json"
|
|
1024
|
+
path = roots[source] / "compacted" / name
|
|
1025
|
+
active = self.journal_roots()[source] / name
|
|
1026
|
+
if not os.path.lexists(path):
|
|
1027
|
+
self.worker_api["rename_without_replacement"](
|
|
1028
|
+
journal, name, destination, name
|
|
1029
|
+
)
|
|
1030
|
+
os.fsync(journal)
|
|
1031
|
+
os.fsync(destination)
|
|
1032
|
+
if (
|
|
1033
|
+
fingerprint(path) != record["identity"]
|
|
1034
|
+
or sha(bounded(path)) != record["sha256"]
|
|
1035
|
+
):
|
|
1036
|
+
# Capture may have raced an external administrator. Keep
|
|
1037
|
+
# all bytes, restoring only into an unoccupied old name.
|
|
1038
|
+
with contextlib.suppress(OSError):
|
|
1039
|
+
self.worker_api["rename_without_replacement"](
|
|
1040
|
+
destination, name, journal, name
|
|
1041
|
+
)
|
|
1042
|
+
os.fsync(journal)
|
|
1043
|
+
os.fsync(destination)
|
|
1044
|
+
raise ValueError(
|
|
1045
|
+
"Archive capture changed; replacement evidence was retained for explicit recovery"
|
|
1046
|
+
)
|
|
1047
|
+
if os.path.lexists(active):
|
|
1048
|
+
raise ValueError(
|
|
1049
|
+
"An unreviewed active replacement exists; preserve both records"
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
def clear_matching_fences(self, digest: str) -> None:
|
|
1053
|
+
expected = self.fence_bytes(digest)
|
|
1054
|
+
existing = [path for path in self.fences if os.path.lexists(path)]
|
|
1055
|
+
if any(bounded(path) != expected for path in existing):
|
|
1056
|
+
raise ValueError(
|
|
1057
|
+
"A different maintenance fence is present; preserve its authority"
|
|
1058
|
+
)
|
|
1059
|
+
for path in existing:
|
|
1060
|
+
path.unlink()
|
|
1061
|
+
sync_directory(path.parent)
|
|
1062
|
+
|
|
1063
|
+
def apply(self, digest: str) -> dict[str, Any]:
|
|
1064
|
+
if len(digest) != 64 or any(
|
|
1065
|
+
character not in "0123456789abcdef" for character in digest
|
|
1066
|
+
):
|
|
1067
|
+
raise ValueError("Confirmation must be the exact archive preview digest")
|
|
1068
|
+
roots = self.archive_roots(digest)
|
|
1069
|
+
completed = roots["host"] / "complete.json"
|
|
1070
|
+
if completed.exists():
|
|
1071
|
+
plan = self.load_plan(digest)
|
|
1072
|
+
for source, records in plan["journals"].items():
|
|
1073
|
+
for record in records:
|
|
1074
|
+
if (
|
|
1075
|
+
sha(
|
|
1076
|
+
bounded(
|
|
1077
|
+
self.record_export_path(
|
|
1078
|
+
roots[source], source, record["id"]
|
|
1079
|
+
)
|
|
1080
|
+
)
|
|
1081
|
+
)
|
|
1082
|
+
!= record["sha256"]
|
|
1083
|
+
):
|
|
1084
|
+
raise ValueError("Completed archive evidence checksum changed")
|
|
1085
|
+
result = json.loads(bounded(completed))
|
|
1086
|
+
if result.get("digest") != digest or result.get("state") != "archived":
|
|
1087
|
+
raise ValueError("Completed archive receipt differs")
|
|
1088
|
+
self.clear_matching_fences(digest)
|
|
1089
|
+
return {**result, "historical": True}
|
|
1090
|
+
prepared = roots["host"] / "plan.json"
|
|
1091
|
+
if prepared.exists():
|
|
1092
|
+
plan = self.load_plan(digest)
|
|
1093
|
+
self.restore_snapshot(plan, digest)
|
|
1094
|
+
leases = self.control / "leases.json"
|
|
1095
|
+
lease_bytes = (
|
|
1096
|
+
bounded(leases, 16 * MAX_RECORD_BYTES) if leases.exists() else b""
|
|
1097
|
+
)
|
|
1098
|
+
if sha(lease_bytes) != plan["lease_sha256"]:
|
|
1099
|
+
raise ValueError(
|
|
1100
|
+
"Original lease state changed; inspect a fresh archive preview"
|
|
1101
|
+
)
|
|
1102
|
+
expected = self.fence_bytes(digest)
|
|
1103
|
+
if any(
|
|
1104
|
+
os.path.lexists(path) and bounded(path) != expected
|
|
1105
|
+
for path in self.fences
|
|
1106
|
+
):
|
|
1107
|
+
raise ValueError(
|
|
1108
|
+
"A different maintenance fence is present; preserve its authority"
|
|
1109
|
+
)
|
|
1110
|
+
for path in self.fences:
|
|
1111
|
+
publish(path, expected)
|
|
1112
|
+
else:
|
|
1113
|
+
preview = self.preview()
|
|
1114
|
+
if preview["digest"] != digest:
|
|
1115
|
+
raise ValueError(
|
|
1116
|
+
"Confirmation changed; inspect the exact current preview"
|
|
1117
|
+
)
|
|
1118
|
+
if not preview["ready"]:
|
|
1119
|
+
raise ValueError(
|
|
1120
|
+
"Maintenance is blocked by retained or conflicting evidence"
|
|
1121
|
+
)
|
|
1122
|
+
plan = preview["plan"]
|
|
1123
|
+
# The full reviewed plan is durable before the first persistent fence.
|
|
1124
|
+
for root in roots.values():
|
|
1125
|
+
publish(root / "plan.json", encode(plan))
|
|
1126
|
+
self.sync_export_roots(roots)
|
|
1127
|
+
for path in self.fences:
|
|
1128
|
+
publish(path, self.fence_bytes(digest))
|
|
1129
|
+
self.require_fences(digest)
|
|
1130
|
+
self.export(plan, digest)
|
|
1131
|
+
after = self.replay_after(plan, digest)
|
|
1132
|
+
for source, directory in self.index_roots().items():
|
|
1133
|
+
expected_before = plan["replay_before"][source]
|
|
1134
|
+
live = directory / "manifest.json"
|
|
1135
|
+
if live.exists() and sha(bounded(live)) not in [
|
|
1136
|
+
expected_before,
|
|
1137
|
+
sha(after[source][0]),
|
|
1138
|
+
]:
|
|
1139
|
+
raise ValueError(
|
|
1140
|
+
"Live replay manifest changed outside this transaction"
|
|
1141
|
+
)
|
|
1142
|
+
if not live.exists() and expected_before is not None:
|
|
1143
|
+
raise ValueError("Original live replay manifest disappeared")
|
|
1144
|
+
for name, body in after[source][1].items():
|
|
1145
|
+
publish(directory / "objects" / name, body)
|
|
1146
|
+
publish(live, after[source][0], replace=True)
|
|
1147
|
+
if source != "host":
|
|
1148
|
+
publish(
|
|
1149
|
+
Path(str(self.clients[int(source[7:])]) + ".replay-required.json"),
|
|
1150
|
+
encode({"version": 1}),
|
|
1151
|
+
replace=False,
|
|
1152
|
+
)
|
|
1153
|
+
for source, directory in self.index_roots().items():
|
|
1154
|
+
if sha(bounded(directory / "manifest.json")) != sha(after[source][0]):
|
|
1155
|
+
raise ValueError(
|
|
1156
|
+
"Replay publication is unresolved; preserve the maintenance fences"
|
|
1157
|
+
)
|
|
1158
|
+
self.restore_snapshot(plan, digest)
|
|
1159
|
+
self.compact(plan, digest)
|
|
1160
|
+
result = {
|
|
1161
|
+
"ok": True,
|
|
1162
|
+
"version": 1,
|
|
1163
|
+
"state": "archived",
|
|
1164
|
+
"digest": digest,
|
|
1165
|
+
"archived_counts": {
|
|
1166
|
+
source: sum(record["archive"] for record in records)
|
|
1167
|
+
for source, records in plan["journals"].items()
|
|
1168
|
+
},
|
|
1169
|
+
"historical": False,
|
|
1170
|
+
"target_effects": "none",
|
|
1171
|
+
"reclaimed_bytes": None,
|
|
1172
|
+
}
|
|
1173
|
+
for root in roots.values():
|
|
1174
|
+
publish(root / "complete.json", encode(result))
|
|
1175
|
+
self.sync_export_roots(roots)
|
|
1176
|
+
self.require_fences(digest)
|
|
1177
|
+
self.clear_matching_fences(digest)
|
|
1178
|
+
return result
|
|
1179
|
+
|
|
1180
|
+
@staticmethod
|
|
1181
|
+
def same_origin(first: dict[str, Any], second: dict[str, Any]) -> bool:
|
|
1182
|
+
return all(
|
|
1183
|
+
first[key] == second[key]
|
|
1184
|
+
for key in [
|
|
1185
|
+
"id",
|
|
1186
|
+
"owner_id",
|
|
1187
|
+
"tool_call_id",
|
|
1188
|
+
"endpoint",
|
|
1189
|
+
"resource_id",
|
|
1190
|
+
"operation",
|
|
1191
|
+
"target",
|
|
1192
|
+
"requested_path",
|
|
1193
|
+
]
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def main() -> None:
|
|
1198
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1199
|
+
parser.add_argument("operation", choices=["preview", "apply"])
|
|
1200
|
+
parser.add_argument("--manifest", required=True)
|
|
1201
|
+
parser.add_argument("--confirm")
|
|
1202
|
+
args = parser.parse_args()
|
|
1203
|
+
try:
|
|
1204
|
+
archive = JournalArchive(args.manifest)
|
|
1205
|
+
with archive.locked(args.confirm):
|
|
1206
|
+
if args.operation == "apply":
|
|
1207
|
+
if args.confirm is None:
|
|
1208
|
+
raise ValueError(
|
|
1209
|
+
"Inspect preview and confirm its exact digest before archival"
|
|
1210
|
+
)
|
|
1211
|
+
result = archive.apply(args.confirm)
|
|
1212
|
+
print(json.dumps(result))
|
|
1213
|
+
else:
|
|
1214
|
+
print(json.dumps({"ok": True, "preview": archive.preview()}))
|
|
1215
|
+
except (
|
|
1216
|
+
ValueError,
|
|
1217
|
+
OSError,
|
|
1218
|
+
KeyError,
|
|
1219
|
+
TypeError,
|
|
1220
|
+
subprocess.SubprocessError,
|
|
1221
|
+
) as error:
|
|
1222
|
+
print(json.dumps({"ok": False, "error": str(error)}))
|
|
1223
|
+
sys.exit(2)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
if __name__ == "__main__":
|
|
1227
|
+
main()
|