From 01544da9f523642ba1ea93528796933b77bad7bc Mon Sep 17 00:00:00 2001 From: jiayuqi7813 <1783671926@qq.com> Date: Thu, 20 Aug 2026 18:27:59 +0800 Subject: [PATCH 1/4] release: sync product snapshot to current Muteki runtime Bring the public tree onto the current Coordinator, worker roster, pentest collect-N, control plane, and Web command-deck product. Co-authored-by: Cursor --- .cursor/environment.json | 15 + .cursor/install.sh | 42 + .env.example | 19 + .github/workflows/ci.yml | 80 + .github/workflows/release-images.yml | 76 +- .gitignore | 18 +- AGENTS.md | 264 +- CHANGELOG.md | 14 +- README.md | 79 +- README_CN.md | 88 +- ROADMAP.md | 334 +- apps/tui/app.py | 2 + apps/web/account_test.py | 50 +- apps/web/auth.py | 2 +- apps/web/control_adapter.py | 567 ++ apps/web/dispatch_parse.py | 180 + apps/web/drivers.py | 1314 ++- apps/web/llm_credentials.py | 80 + apps/web/llm_test.py | 20 +- apps/web/platform_update.py | 51 + apps/web/protocol2_adapter.py | 1351 ++++ apps/web/run_manager.py | 2765 ++++++- apps/web/run_meta.py | 3 + apps/web/server.py | 664 +- apps/web/titler.py | 23 +- apps/web/ui/README.md | 3 +- apps/web/ui/app/api/[...path]/route.ts | 78 + apps/web/ui/app/globals.css | 3893 ++++++++- apps/web/ui/app/layout.tsx | 8 +- apps/web/ui/app/page.tsx | 1411 +++- apps/web/ui/app/settings/workers/page.tsx | 16 + apps/web/ui/components/ActivityStream.tsx | 304 - apps/web/ui/components/ArtifactPanel.tsx | 361 - apps/web/ui/components/Blackboard.tsx | 59 +- apps/web/ui/components/CommandPalette.tsx | 14 +- apps/web/ui/components/ContextGauge.tsx | 6 +- apps/web/ui/components/Conversation.tsx | 857 +- apps/web/ui/components/CopyText.tsx | 17 +- apps/web/ui/components/EngineBar.tsx | 172 +- apps/web/ui/components/EngineLogo.tsx | 121 + apps/web/ui/components/EvidenceChain.tsx | 12 +- apps/web/ui/components/GraphView.tsx | 110 +- apps/web/ui/components/Icon.tsx | 9 +- apps/web/ui/components/LaunchForm.tsx | 5 +- apps/web/ui/components/LoginGate.tsx | 68 +- apps/web/ui/components/NumberField.tsx | 231 + apps/web/ui/components/PlatformUpdate.tsx | 99 + apps/web/ui/components/RunInspector.tsx | 495 +- apps/web/ui/components/SchemeBoot.tsx | 20 + apps/web/ui/components/SelectionGlider.tsx | 161 + apps/web/ui/components/ThreadRail.tsx | 6 +- apps/web/ui/components/VulnReportDoc.tsx | 443 + apps/web/ui/components/WorkerLanes.tsx | 427 +- .../web/ui/components/WorkerOrchestration.tsx | 2512 ++++++ apps/web/ui/components/WorkerSettings.tsx | 1697 ---- apps/web/ui/lib/activityLedger.ts | 69 + apps/web/ui/lib/controlClient.ts | 36 + apps/web/ui/lib/events.ts | 1292 ++- apps/web/ui/lib/i18n.tsx | 358 +- apps/web/ui/lib/palette-engine.ts | 396 + apps/web/ui/lib/reportMarkdown.ts | 272 + apps/web/ui/lib/useRun.ts | 452 +- apps/web/ui/lib/workerLanePresentation.ts | 61 +- apps/web/ui/lib/workers.ts | 224 +- apps/web/ui/next.config.mjs | 9 +- apps/web/ui/package-lock.json | 4 +- apps/web/ui/package.json | 2 +- apps/web/ui/public/worker-ball-demo.html | 1207 +++ apps/web/worker_config.py | 406 +- apps/web/worker_models.manual.json | 96 + apps/web/worker_models.py | 1971 ++++- cmd/runtime-agent/agent_test.go | 289 +- cmd/runtime-agent/env.go | 18 +- cmd/runtime-agent/main.go | 72 +- cmd/runtime-agent/oom.go | 71 + cmd/runtime-agent/protocol.go | 22 +- cmd/runtime-agent/reaper.go | 54 + cmd/runtime-agent/reaper_linux.go | 69 + cmd/runtime-agent/reaper_linux_test.go | 50 + cmd/runtime-agent/reaper_other.go | 7 + cmd/runtime-agent/worker.go | 157 +- docker-compose.release.yml | 65 + docker-compose.yml | 4 + docker/ui/Dockerfile | 6 + docker/web/Dockerfile | 7 + docker/worker-slim/Dockerfile | 106 +- docker/worker-slim/build.sh | 103 +- docker/worker/AGENTS.md | 126 +- docker/worker/Dockerfile | 129 +- docker/worker/build.sh | 102 +- docs/validation/DEFERRED_DOCKER_UI.md | 20 + docs/worker-detail-roster-demo.html | 409 + docs/worker-model-catalog.md | 29 + ...45\344\275\234\345\216\237\347\220\206.md" | 30 +- examples/pentest_demo_solver.py | 434 + labs/idor_local/server.py | 108 + labs/rce_local/server.py | 104 + muteki/cli.py | 134 + muteki/control/__init__.py | 97 + muteki/control/actor.py | 699 ++ muteki/control/admission.py | 183 + muteki/control/models.py | 467 ++ muteki/control/secrets.py | 329 + muteki/control/store.py | 1332 +++ muteki/core/cost.py | 87 +- muteki/core/dotenv_boot.py | 3 +- muteki/core/event_bus.py | 71 +- muteki/core/events.py | 76 +- muteki/core/llm.py | 83 +- muteki/core/path_ids.py | 49 + muteki/core/session_store.py | 232 +- muteki/epistemic/__init__.py | 23 + muteki/epistemic/authority.py | 792 ++ muteki/epistemic/broker.py | 592 ++ muteki/epistemic/cas.py | 392 + muteki/epistemic/cognitive_events_v1.py | 1748 ++++ muteki/epistemic/context_packet_v1.py | 1249 +++ muteki/epistemic/contracts.py | 158 + muteki/epistemic/folds.py | 181 + muteki/epistemic/receipt_objects.py | 1144 +++ muteki/epistemic/sqlite_store.py | 7153 +++++++++++++++++ muteki/eval/__init__.py | 9 + muteki/eval/aggregations.py | 109 + muteki/eval/manifests.py | 224 + muteki/eval/receipts.py | 150 + muteki/frameworks/__init__.py | 13 + .../frameworks/f10_edge_cognition/__init__.py | 22 + .../frameworks/f10_edge_cognition/schema.py | 82 + muteki/frameworks/f10_edge_cognition/shell.py | 513 ++ muteki/frameworks/f10_edge_cognition/state.py | 139 + muteki/frameworks/f10_edge_cognition/swarm.py | 332 + muteki/frameworks/f11_agent_teams/__init__.py | 3 + muteki/frameworks/f11_agent_teams/schema.py | 170 + muteki/frameworks/f11_agent_teams/swarm.py | 850 ++ muteki/frameworks/f11_agent_teams/team.py | 1462 ++++ muteki/models/predicted_effects_v1.py | 145 + muteki/models/solve_graph.py | 185 +- muteki/runtime/__init__.py | 9 + muteki/runtime/admission.py | 1951 +++++ muteki/runtime/c6_transport.py | 1184 +++ muteki/runtime/canary.py | 115 + .../canonical_cognitive_continuation_v2.py | 532 ++ .../runtime/canonical_cognitive_cycle_v1.py | 721 ++ .../canonical_cognitive_selection_v1.py | 821 ++ muteki/runtime/closure.py | 1743 ++++ muteki/runtime/cognition.py | 2621 ++++++ .../runtime/cognitive_engine_registry_v1.py | 407 + .../runtime/cognitive_materialization_v1.py | 912 +++ .../runtime/cognitive_observation_label_v1.py | 711 ++ muteki/runtime/cognitive_output_capture_v1.py | 173 + .../cognitive_planning_contracts_v1.py | 344 + .../cognitive_reproduction_evidence_v1.py | 1195 +++ .../cognitive_reproduction_witness_v1.py | 660 ++ .../cognitive_runtime_observation_v1.py | 844 ++ .../cognitive_verification_authority_v1.py | 783 ++ .../cognitive_verification_certificate_v1.py | 1702 ++++ .../cognitive_verification_checker_v1.py | 710 ++ .../cognitive_verification_resolution_v1.py | 333 + .../cognitive_verification_resolver_v1.py | 1680 ++++ muteki/runtime/composition.py | 166 + muteki/runtime/contracts.py | 1073 +++ muteki/runtime/controller.py | 111 + muteki/runtime/effects.py | 78 + muteki/runtime/egress_proxy.py | 234 + muteki/runtime/executable_experiment_v1.py | 1084 +++ muteki/runtime/hypothesis.py | 1385 ++++ muteki/runtime/live_session.py | 1227 +++ muteki/runtime/network.py | 85 + muteki/runtime/permit_resolver.py | 495 ++ muteki/runtime/ports.py | 132 + muteki/runtime/progress.py | 164 + muteki/runtime/prompt_stage.py | 346 + muteki/runtime/reconciliation.py | 830 ++ muteki/runtime/release_receipts.py | 234 + muteki/runtime/run_catalog.py | 493 ++ muteki/runtime/search.py | 141 + muteki/runtime/supervisor.py | 686 ++ muteki/runtime/usage.py | 171 + .../claim_predicted_effects_persist_v1.py | 268 + muteki/solver/cli_driver.py | 2209 ++++- muteki/solver/cli_solver.py | 4151 +++++++++- muteki/solver/container_exec.py | 589 +- muteki/solver/control_client.py | 266 +- muteki/solver/control_receiver.py | 194 +- muteki/solver/credential_accounts.py | 548 +- muteki/solver/deepseek_harness_worker.py | 213 + muteki/solver/gate.py | 603 +- muteki/solver/grok_offline_agent.md | 26 + muteki/solver/identity_model.py | 74 +- muteki/solver/kimi_offline_agent.md | 10 + muteki/solver/offline_acp_bridge.py | 375 + muteki/solver/omp_offline_config.yml | 6 + muteki/solver/planning_kernel_v1.py | 704 ++ muteki/solver/predicted_effects_emit_v1.py | 125 + muteki/solver/reason.py | 946 ++- muteki/solver/swarm_chainforce.py | 274 + muteki/solver/swarm_dualrush.py | 110 + muteki/solver/swarm_hypoledger.py | 213 + muteki/solver/swarm_pex.py | 180 + muteki/solver/swarm_phased.py | 261 + muteki/solver/swarm_planning_base.py | 886 ++ muteki/solver/swarm_reapclose.py | 1096 +++ muteki/solver/swarm_roleswarm.py | 126 + muteki/solver/vuln_report.py | 825 ++ muteki/solver/worker_profiles.py | 145 +- muteki/solver/worker_skills.py | 105 + muteki/solver/workspace.py | 85 +- muteki/swarm/chain_completion_v1.py | 116 + muteki/swarm/cognitive_cluster_planner.py | 406 + muteki/swarm/context_firewall_v1.py | 170 + muteki/swarm/coordinator_dispatch.py | 769 ++ muteki/swarm/coordinator_flags.py | 3138 ++++++++ muteki/swarm/coordinator_loop.py | 2801 +++++++ muteki/swarm/coordinator_race.py | 2178 +++++ muteki/swarm/coordinator_review.py | 1222 +++ muteki/swarm/fruitless_interrupt_v1.py | 1565 ++++ muteki/swarm/graph_defs.py | 217 + muteki/swarm/graph_facts.py | 675 ++ muteki/swarm/graph_intents.py | 894 ++ muteki/swarm/graph_locks.py | 992 +++ muteki/swarm/graph_render.py | 515 ++ muteki/swarm/graph_reports.py | 193 + muteki/swarm/graph_routes.py | 605 ++ muteki/swarm/graph_schema.py | 232 + muteki/swarm/graph_views.py | 966 +++ muteki/swarm/shared_graph.py | 3933 +-------- muteki/swarm/solo_depth_verify_v1.py | 166 + muteki/swarm/state_port.py | 50 + muteki/swarm/swarm.py | 4845 +---------- muteki/swarm/swarm_support.py | 148 + muteki/updater.py | 702 ++ muteki/version.py | 58 + pyproject.toml | 6 +- run.sh | 8 +- scripts/build_release_bundle.py | 115 + scripts/install_blackboard_skill.sh | 34 +- scripts/muteki-launcher | 100 + skills/muteki-blackboard/SKILL.md | 63 + skills/muteki-blackboard/blackboard.py | 1045 ++- skills/muteki-ctf-local-playbook/SKILL.md | 44 + skills/muteki-worldmodel/taxonomy/crypto.json | 14 + .../muteki-worldmodel/taxonomy/forensics.json | 14 + skills/muteki-worldmodel/taxonomy/misc.json | 13 + skills/muteki-worldmodel/taxonomy/pwn.json | 13 + skills/muteki-worldmodel/taxonomy/rev.json | 14 + skills/muteki-worldmodel/taxonomy/web.json | 14 + tests/README.md | 65 + tests/fixtures/protocol2_live_canary.txt | 1 + tests/test_blackboard_skill.py | 69 +- tests/test_chain_completion_v1.py | 81 + tests/test_cli_executor.py | 2590 +++++- tests/test_cli_solver_shell_loop.py | 315 + tests/test_connectivity_probes.py | 124 +- tests/test_container_exec.py | 379 +- tests/test_control_client.py | 405 + tests/test_control_plane.py | 969 +++ tests/test_control_secrets.py | 260 + tests/test_cost.py | 23 + tests/test_credential_accounts.py | 372 +- tests/test_epistemic_store.py | 299 + tests/test_eval_protocol2.py | 278 + tests/test_event_bus.py | 83 + tests/test_events.py | 51 + tests/test_flag_origin_attestation.py | 177 + tests/test_framework_f11_agent_teams.py | 813 ++ tests/test_fruitless_interrupt_v1.py | 806 ++ tests/test_gate.py | 161 + tests/test_graph_lease_control.py | 204 + tests/test_health_parity.py | 268 +- tests/test_identity_migration.py | 234 - tests/test_identity_model.py | 377 +- tests/test_llm.py | 60 + tests/test_llm_base_url_wiring.py | 91 +- tests/test_llm_credentials.py | 15 + tests/test_llm_live.py | 63 - tests/test_multi_flag.py | 60 + tests/test_network_canary.py | 239 + tests/test_pentest_mode.py | 818 +- tests/test_planning_kernel_v1.py | 176 + tests/test_progress_search_kernel.py | 90 + tests/test_protocol2_authority.py | 291 + tests/test_protocol2_contracts.py | 67 + tests/test_reason.py | 507 +- tests/test_receipt_objects.py | 240 + tests/test_release_receipts.py | 126 + tests/test_run_catalog.py | 185 + tests/test_runtime_admission.py | 466 ++ tests/test_runtime_budget_settlement.py | 222 + tests/test_runtime_capture_gate.py | 338 + tests/test_runtime_closure.py | 661 ++ tests/test_runtime_cognition.py | 1867 +++++ tests/test_runtime_prompt_stage.py | 140 + tests/test_runtime_reconciliation.py | 386 + tests/test_runtime_s4e_faults.py | 505 ++ tests/test_runtime_supervisor.py | 427 + tests/test_runtime_usage.py | 91 + tests/test_session_store.py | 320 +- tests/test_shared_graph.py | 271 + tests/test_standby_hitl.py | 259 +- tests/test_stream_flag_extraction.py | 422 +- tests/test_swarm.py | 6018 ++++++++++++-- tests/test_swarm_knowledge_propagation.py | 27 +- tests/test_vuln_report.py | 240 + tests/test_web_control.py | 2041 +++++ tests/test_web_deck_ux.py | 939 ++- tests/test_web_protocol2.py | 1695 ++++ tests/test_web_server.py | 345 +- tests/test_worker_config.py | 1051 +-- tests/test_worker_models.py | 85 +- tests/test_worker_reasoning_agents.py | 154 + uv.lock | 28 +- 311 files changed, 140551 insertions(+), 17675 deletions(-) create mode 100644 .cursor/environment.json create mode 100755 .cursor/install.sh create mode 100644 .github/workflows/ci.yml create mode 100644 apps/web/control_adapter.py create mode 100644 apps/web/dispatch_parse.py create mode 100644 apps/web/llm_credentials.py create mode 100644 apps/web/platform_update.py create mode 100644 apps/web/protocol2_adapter.py create mode 100644 apps/web/ui/app/api/[...path]/route.ts create mode 100644 apps/web/ui/app/settings/workers/page.tsx delete mode 100644 apps/web/ui/components/ActivityStream.tsx delete mode 100644 apps/web/ui/components/ArtifactPanel.tsx create mode 100644 apps/web/ui/components/EngineLogo.tsx create mode 100644 apps/web/ui/components/NumberField.tsx create mode 100644 apps/web/ui/components/PlatformUpdate.tsx create mode 100644 apps/web/ui/components/SchemeBoot.tsx create mode 100644 apps/web/ui/components/SelectionGlider.tsx create mode 100644 apps/web/ui/components/VulnReportDoc.tsx create mode 100644 apps/web/ui/components/WorkerOrchestration.tsx delete mode 100644 apps/web/ui/components/WorkerSettings.tsx create mode 100644 apps/web/ui/lib/activityLedger.ts create mode 100644 apps/web/ui/lib/controlClient.ts create mode 100644 apps/web/ui/lib/palette-engine.ts create mode 100644 apps/web/ui/lib/reportMarkdown.ts create mode 100644 apps/web/ui/public/worker-ball-demo.html create mode 100644 apps/web/worker_models.manual.json create mode 100644 cmd/runtime-agent/oom.go create mode 100644 cmd/runtime-agent/reaper.go create mode 100644 cmd/runtime-agent/reaper_linux.go create mode 100644 cmd/runtime-agent/reaper_linux_test.go create mode 100644 cmd/runtime-agent/reaper_other.go create mode 100644 docker-compose.release.yml create mode 100644 docs/validation/DEFERRED_DOCKER_UI.md create mode 100644 docs/worker-detail-roster-demo.html create mode 100644 docs/worker-model-catalog.md create mode 100644 examples/pentest_demo_solver.py create mode 100644 labs/idor_local/server.py create mode 100644 labs/rce_local/server.py create mode 100644 muteki/cli.py create mode 100644 muteki/control/__init__.py create mode 100644 muteki/control/actor.py create mode 100644 muteki/control/admission.py create mode 100644 muteki/control/models.py create mode 100644 muteki/control/secrets.py create mode 100644 muteki/control/store.py create mode 100644 muteki/core/path_ids.py create mode 100644 muteki/epistemic/__init__.py create mode 100644 muteki/epistemic/authority.py create mode 100644 muteki/epistemic/broker.py create mode 100644 muteki/epistemic/cas.py create mode 100644 muteki/epistemic/cognitive_events_v1.py create mode 100644 muteki/epistemic/context_packet_v1.py create mode 100644 muteki/epistemic/contracts.py create mode 100644 muteki/epistemic/folds.py create mode 100644 muteki/epistemic/receipt_objects.py create mode 100644 muteki/epistemic/sqlite_store.py create mode 100644 muteki/eval/__init__.py create mode 100644 muteki/eval/aggregations.py create mode 100644 muteki/eval/manifests.py create mode 100644 muteki/eval/receipts.py create mode 100644 muteki/frameworks/__init__.py create mode 100644 muteki/frameworks/f10_edge_cognition/__init__.py create mode 100644 muteki/frameworks/f10_edge_cognition/schema.py create mode 100644 muteki/frameworks/f10_edge_cognition/shell.py create mode 100644 muteki/frameworks/f10_edge_cognition/state.py create mode 100644 muteki/frameworks/f10_edge_cognition/swarm.py create mode 100644 muteki/frameworks/f11_agent_teams/__init__.py create mode 100644 muteki/frameworks/f11_agent_teams/schema.py create mode 100644 muteki/frameworks/f11_agent_teams/swarm.py create mode 100644 muteki/frameworks/f11_agent_teams/team.py create mode 100644 muteki/models/predicted_effects_v1.py create mode 100644 muteki/runtime/__init__.py create mode 100644 muteki/runtime/admission.py create mode 100644 muteki/runtime/c6_transport.py create mode 100644 muteki/runtime/canary.py create mode 100644 muteki/runtime/canonical_cognitive_continuation_v2.py create mode 100644 muteki/runtime/canonical_cognitive_cycle_v1.py create mode 100644 muteki/runtime/canonical_cognitive_selection_v1.py create mode 100644 muteki/runtime/closure.py create mode 100644 muteki/runtime/cognition.py create mode 100644 muteki/runtime/cognitive_engine_registry_v1.py create mode 100644 muteki/runtime/cognitive_materialization_v1.py create mode 100644 muteki/runtime/cognitive_observation_label_v1.py create mode 100644 muteki/runtime/cognitive_output_capture_v1.py create mode 100644 muteki/runtime/cognitive_planning_contracts_v1.py create mode 100644 muteki/runtime/cognitive_reproduction_evidence_v1.py create mode 100644 muteki/runtime/cognitive_reproduction_witness_v1.py create mode 100644 muteki/runtime/cognitive_runtime_observation_v1.py create mode 100644 muteki/runtime/cognitive_verification_authority_v1.py create mode 100644 muteki/runtime/cognitive_verification_certificate_v1.py create mode 100644 muteki/runtime/cognitive_verification_checker_v1.py create mode 100644 muteki/runtime/cognitive_verification_resolution_v1.py create mode 100644 muteki/runtime/cognitive_verification_resolver_v1.py create mode 100644 muteki/runtime/composition.py create mode 100644 muteki/runtime/contracts.py create mode 100644 muteki/runtime/controller.py create mode 100644 muteki/runtime/effects.py create mode 100644 muteki/runtime/egress_proxy.py create mode 100644 muteki/runtime/executable_experiment_v1.py create mode 100644 muteki/runtime/hypothesis.py create mode 100644 muteki/runtime/live_session.py create mode 100644 muteki/runtime/network.py create mode 100644 muteki/runtime/permit_resolver.py create mode 100644 muteki/runtime/ports.py create mode 100644 muteki/runtime/progress.py create mode 100644 muteki/runtime/prompt_stage.py create mode 100644 muteki/runtime/reconciliation.py create mode 100644 muteki/runtime/release_receipts.py create mode 100644 muteki/runtime/run_catalog.py create mode 100644 muteki/runtime/search.py create mode 100644 muteki/runtime/supervisor.py create mode 100644 muteki/runtime/usage.py create mode 100644 muteki/solver/claim_predicted_effects_persist_v1.py create mode 100644 muteki/solver/deepseek_harness_worker.py create mode 100644 muteki/solver/grok_offline_agent.md create mode 100644 muteki/solver/kimi_offline_agent.md create mode 100644 muteki/solver/offline_acp_bridge.py create mode 100644 muteki/solver/omp_offline_config.yml create mode 100644 muteki/solver/planning_kernel_v1.py create mode 100644 muteki/solver/predicted_effects_emit_v1.py create mode 100644 muteki/solver/swarm_chainforce.py create mode 100644 muteki/solver/swarm_dualrush.py create mode 100644 muteki/solver/swarm_hypoledger.py create mode 100644 muteki/solver/swarm_pex.py create mode 100644 muteki/solver/swarm_phased.py create mode 100644 muteki/solver/swarm_planning_base.py create mode 100644 muteki/solver/swarm_reapclose.py create mode 100644 muteki/solver/swarm_roleswarm.py create mode 100644 muteki/solver/vuln_report.py create mode 100644 muteki/solver/worker_skills.py create mode 100644 muteki/swarm/chain_completion_v1.py create mode 100644 muteki/swarm/cognitive_cluster_planner.py create mode 100644 muteki/swarm/context_firewall_v1.py create mode 100644 muteki/swarm/coordinator_dispatch.py create mode 100644 muteki/swarm/coordinator_flags.py create mode 100644 muteki/swarm/coordinator_loop.py create mode 100644 muteki/swarm/coordinator_race.py create mode 100644 muteki/swarm/coordinator_review.py create mode 100644 muteki/swarm/fruitless_interrupt_v1.py create mode 100644 muteki/swarm/graph_defs.py create mode 100644 muteki/swarm/graph_facts.py create mode 100644 muteki/swarm/graph_intents.py create mode 100644 muteki/swarm/graph_locks.py create mode 100644 muteki/swarm/graph_render.py create mode 100644 muteki/swarm/graph_reports.py create mode 100644 muteki/swarm/graph_routes.py create mode 100644 muteki/swarm/graph_schema.py create mode 100644 muteki/swarm/graph_views.py create mode 100644 muteki/swarm/solo_depth_verify_v1.py create mode 100644 muteki/swarm/state_port.py create mode 100644 muteki/swarm/swarm_support.py create mode 100644 muteki/updater.py create mode 100644 muteki/version.py create mode 100755 scripts/build_release_bundle.py create mode 100755 scripts/muteki-launcher create mode 100644 skills/muteki-ctf-local-playbook/SKILL.md create mode 100644 skills/muteki-worldmodel/taxonomy/crypto.json create mode 100644 skills/muteki-worldmodel/taxonomy/forensics.json create mode 100644 skills/muteki-worldmodel/taxonomy/misc.json create mode 100644 skills/muteki-worldmodel/taxonomy/pwn.json create mode 100644 skills/muteki-worldmodel/taxonomy/rev.json create mode 100644 skills/muteki-worldmodel/taxonomy/web.json create mode 100644 tests/README.md create mode 100644 tests/fixtures/protocol2_live_canary.txt create mode 100644 tests/test_chain_completion_v1.py create mode 100644 tests/test_cli_solver_shell_loop.py create mode 100644 tests/test_control_plane.py create mode 100644 tests/test_control_secrets.py create mode 100644 tests/test_epistemic_store.py create mode 100644 tests/test_eval_protocol2.py create mode 100644 tests/test_flag_origin_attestation.py create mode 100644 tests/test_framework_f11_agent_teams.py create mode 100644 tests/test_fruitless_interrupt_v1.py create mode 100644 tests/test_graph_lease_control.py delete mode 100644 tests/test_identity_migration.py create mode 100644 tests/test_llm_credentials.py delete mode 100644 tests/test_llm_live.py create mode 100644 tests/test_network_canary.py create mode 100644 tests/test_planning_kernel_v1.py create mode 100644 tests/test_progress_search_kernel.py create mode 100644 tests/test_protocol2_authority.py create mode 100644 tests/test_protocol2_contracts.py create mode 100644 tests/test_receipt_objects.py create mode 100644 tests/test_release_receipts.py create mode 100644 tests/test_run_catalog.py create mode 100644 tests/test_runtime_admission.py create mode 100644 tests/test_runtime_budget_settlement.py create mode 100644 tests/test_runtime_capture_gate.py create mode 100644 tests/test_runtime_closure.py create mode 100644 tests/test_runtime_cognition.py create mode 100644 tests/test_runtime_prompt_stage.py create mode 100644 tests/test_runtime_reconciliation.py create mode 100644 tests/test_runtime_s4e_faults.py create mode 100644 tests/test_runtime_supervisor.py create mode 100644 tests/test_runtime_usage.py create mode 100644 tests/test_vuln_report.py create mode 100644 tests/test_web_control.py create mode 100644 tests/test_web_protocol2.py create mode 100644 tests/test_worker_reasoning_agents.py diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 0000000..6520206 --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,15 @@ +{ + "name": "Project Muteki", + "install": "bash .cursor/install.sh", + "terminals": [ + { + "name": "web-deck", + "command": "./run.sh web", + "description": "Muteki command deck: FastAPI backend on :8000 + production Next UI on :3001 (loopback). Open http://127.0.0.1:3001. Set MUTEKI_DEEPSEEK_API_KEY (and an engine CLI login) to run real solves." + } + ], + "ports": [ + { "name": "backend", "port": 8000 }, + { "name": "web-ui", "port": 3001 } + ] +} diff --git a/.cursor/install.sh b/.cursor/install.sh new file mode 100755 index 0000000..d55edd5 --- /dev/null +++ b/.cursor/install.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Cloud Agent install phase for Project Muteki. +# +# Idempotent dependency refresh run after the repository is checked out. It must +# terminate: no dev servers, tests, or long-running processes belong here (the +# web deck is launched from the `terminals` entry in .cursor/environment.json). +set -euo pipefail + +cd "$(dirname "$0")/.." + +# ── System dependency: libzbar (pyzbar QR decoding) ────────────────────────── +# Only a QR challenge needs it; the default test path never imports pyzbar, so a +# failure here is a warning, not a hard stop (mirrors init.sh's behaviour). +if ! ldconfig -p 2>/dev/null | grep -qi 'libzbar'; then + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update -qq \ + && sudo apt-get install -y -qq libzbar0 \ + || echo "(warn) could not install libzbar0 — QR-decode helpers will be unavailable." >&2 + else + echo "(warn) no sudo — skipping libzbar0; QR-decode helpers will be unavailable." >&2 + fi +fi + +# ── Python toolchain (uv) ──────────────────────────────────────────────────── +# uv provisions the pinned Python (>=3.13 per pyproject.toml) itself. +export PATH="$HOME/.local/bin:$PATH" +if ! command -v uv >/dev/null 2>&1; then + echo "==> installing uv from https://astral.sh/uv …" + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +# ── Python dependencies (core + dev test tools) ────────────────────────────── +uv sync --extra dev + +# ── Next.js command-deck dependencies ──────────────────────────────────────── +# Only the local web UI needs Node; skip cleanly if npm is unavailable. +if [ -f apps/web/ui/package.json ] && command -v npm >/dev/null 2>&1; then + ( cd apps/web/ui && npm install ) +fi + +echo "OK — Muteki install complete." diff --git a/.env.example b/.env.example index 6a94be7..03dd369 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,19 @@ MUTEKI_DEEPSEEK_API_KEY=sk-your-deepseek-key-here # MUTEKI_WEB_TOKEN_TTL=43200 # session-token lifetime in seconds (default 12h) # MUTEKI_WEB_AUTH_SECRET= # optional explicit HMAC signing key +# ── Optional: self-hosted releases ──────────────────────────────────────────── +# Public upgrades default to FishCodeTech/muteki and ghcr.io/fishcodetech. +# Override only if you publish your own tags and images: +# MUTEKI_RELEASE_REPOSITORY=FishCodeTech/muteki +# MUTEKI_GITHUB_TOKEN=ghp_your_release_token +# MUTEKI_IMAGE_REGISTRY=ghcr.io/fishcodetech +# MUTEKI_UPDATE_MANIFEST_URL= # optional full manifest URL, may include {version} +# Protocol 2 release receipts are normally loaded automatically from the +# worktree-bound docs/_local/protocol2_release_receipts.json. Explicit shell or +# .env values still win; a stale/mismatched receipt leaves production disabled. +# MUTEKI_PROTOCOL2_BASELINE_RECEIPT= +# MUTEKI_PROTOCOL2_FAULT_SUITE_RECEIPT= + # ── Optional: model endpoint / HTTP transport ──────────────────────────────── # Defaults to https://api.deepseek.com/v1 if unset. # MUTEKI_DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 @@ -108,6 +121,12 @@ MUTEKI_DEEPSEEK_API_KEY=sk-your-deepseek-key-here # MUTEKI_CONTAINER_DATA_ROOT, MUTEKI_SESSIONS_ROOT, MUTEKI_WEB_BIND. # Do NOT set those by hand unless you are building a custom deployment topology. +# Optional coordinator-private root for durable control.db + SecretStore files. +# It must be outside every sessions/{run}/workspace tree (worker workspaces are +# recursively chowned and bind-mounted read-write). Compose sets a safe sibling +# under MUTEKI_HOST_DATA_ROOT; local runs default to sessions/.coordinator-control. +# MUTEKI_COORDINATOR_CONTROL_ROOT=/opt/muteki/data/coordinator-control + # ── Optional: reverse-connect control plane (advanced) ─────────────────────── # Worker containers dial back to the backend receiver. Defaults work for local # Docker Desktop; compose sets the right values automatically. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3fdebe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: ci + +on: + push: + branches: + - main + - feat/web-beautiful-ui + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - name: Set up uv and Python + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.5" + python-version: "3.13" + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install locked dependencies + run: uv sync --frozen --extra dev + + - name: Run Python tests + run: uv run --frozen python -m pytest -q + + runtime-agent: + name: Runtime agent tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: cmd/runtime-agent/go.mod + cache: false + + - name: Run Go tests + run: go test -C cmd/runtime-agent ./... + + web-ui: + name: Web UI checks + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: apps/web/ui + steps: + - uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "20" + cache: npm + cache-dependency-path: apps/web/ui/package-lock.json + + - name: Install locked dependencies + run: npm ci + + - name: Check TypeScript + run: ./node_modules/.bin/tsc --noEmit + + - name: Build production UI + run: npm run build diff --git a/.github/workflows/release-images.yml b/.github/workflows/release-images.yml index 3a2ea71..4b9bb15 100644 --- a/.github/workflows/release-images.yml +++ b/.github/workflows/release-images.yml @@ -5,17 +5,17 @@ name: release-images # Images (all under ghcr.io//...): # muteki-web — FastAPI control plane (docker/web/Dockerfile, context = repo root) # muteki-ui — Next command deck (docker/ui/Dockerfile, context = apps/web/ui) -# muteki-worker-slim — ubuntu base + 3 CLIs + agent (docker/worker-slim, needs cross-compiled runtime_agent) +# muteki-worker-slim — ubuntu base + 9 engine CLIs + agent (docker/worker-slim, needs cross-compiled runtime_agent) # muteki-worker — Kali base + full pentest kit (docker/worker, heavy: ~kali + 2.6G knowledges) # # Auth: the built-in GITHUB_TOKEN with packages:write — no PAT, no Docker Hub. # Triggers: -# - release published → tag images with the release tag (e.g. v0.0.1) + latest +# - push v* tag → build every artifact, then create the GitHub Release # - workflow_dispatch → manual run for testing the pipeline inside the private repo # (optionally skip the slow Kali worker via the input) on: - release: - types: [published] + push: + tags: ["v*"] workflow_dispatch: inputs: build_kali_worker: @@ -23,13 +23,14 @@ on: type: boolean default: true tag: - description: "Image tag to push (defaults to the git ref name)" + description: "SemVer tag to build, for example v0.3.0" type: string - default: "" + required: true + default: "v0.3.0" -# Least-privilege: read the code, write packages (GHCR). +# 发布任务需要创建 Release 并上传应用包;镜像任务写入 GHCR。 permissions: - contents: read + contents: write packages: write env: @@ -37,6 +38,30 @@ env: GO_VERSION: "1.26" jobs: + bundle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Resolve release version + id: version + run: | + TAG="${{ github.event.inputs.tag }}" + [ -z "$TAG" ] && TAG="${{ github.ref_name }}" + TAG="${TAG#refs/tags/}" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + - name: Build application bundle + run: python scripts/build_release_bundle.py --version "${{ steps.version.outputs.version }}" --repository "${{ github.repository }}" + - uses: actions/upload-artifact@v4 + with: + name: muteki-release-bundle + path: | + dist/muteki-*.tar.gz + dist/release-manifest.json + if-no-files-found: error + # ── lightweight images: plain docker build-push ─────────────────────────────── web: runs-on: ubuntu-latest @@ -71,6 +96,9 @@ jobs: with: context: . file: docker/web/Dockerfile + build-args: | + IMAGE_VERSION=${{ steps.tag.outputs.tag }} + IMAGE_REVISION=${{ github.sha }} push: true tags: | ${{ env.REGISTRY }}/${{ steps.tag.outputs.owner }}/muteki-web:${{ steps.tag.outputs.tag }} @@ -114,6 +142,8 @@ jobs: # a non-compose deployment overrides NEXT/MUTEKI_BACKEND at its own build. build-args: | MUTEKI_BACKEND=http://web-api:8000 + IMAGE_VERSION=${{ steps.tag.outputs.tag }} + IMAGE_REVISION=${{ github.sha }} tags: | ${{ env.REGISTRY }}/${{ steps.tag.outputs.owner }}/muteki-ui:${{ steps.tag.outputs.tag }} ${{ env.REGISTRY }}/${{ steps.tag.outputs.owner }}/muteki-ui:latest @@ -153,10 +183,15 @@ jobs: go build -C cmd/runtime-agent -trimpath -ldflags="-s -w" \ -o "$GITHUB_WORKSPACE/$ctx/runtime_agent" . file "$ctx/runtime_agent" || true - echo ">> staging AGENTS.md + blackboard skill" + echo ">> staging AGENTS.md + blackboard skill + offline engine files" cp docker/worker/AGENTS.md "$ctx/AGENTS.md" cp skills/muteki-blackboard/SKILL.md "$ctx/blackboard.SKILL.md" cp skills/muteki-blackboard/blackboard.py "$ctx/blackboard.py" + cp muteki/solver/deepseek_harness_worker.py "$ctx/deepseek_harness_worker.py" + cp muteki/solver/offline_acp_bridge.py "$ctx/offline_acp_bridge.py" + cp muteki/solver/omp_offline_config.yml "$ctx/omp_offline_config.yml" + cp muteki/solver/kimi_offline_agent.md "$ctx/kimi_offline_agent.md" + cp muteki/solver/grok_offline_agent.md "$ctx/grok_offline_agent.md" chmod +x "$ctx/blackboard.py" - uses: docker/setup-buildx-action@v3 @@ -188,7 +223,7 @@ jobs: # The Kali image is large (kali-linux-headless + ~2.6G knowledges/pocs/templates). # Skip it on a manual run when the operator unchecks the input; always build on a # real release. - if: ${{ github.event_name == 'release' || github.event.inputs.build_kali_worker == 'true' }} + if: ${{ github.event_name == 'push' || github.event.inputs.build_kali_worker == 'true' }} steps: - uses: actions/checkout@v4 @@ -228,9 +263,14 @@ jobs: go build -C cmd/runtime-agent -trimpath -ldflags="-s -w" \ -o "$GITHUB_WORKSPACE/$ctx/runtime_agent" . file "$ctx/runtime_agent" || true - echo ">> staging blackboard skill" + echo ">> staging blackboard skill + offline engine files" cp skills/muteki-blackboard/SKILL.md "$ctx/blackboard.SKILL.md" cp skills/muteki-blackboard/blackboard.py "$ctx/blackboard.py" + cp muteki/solver/deepseek_harness_worker.py "$ctx/deepseek_harness_worker.py" + cp muteki/solver/offline_acp_bridge.py "$ctx/offline_acp_bridge.py" + cp muteki/solver/omp_offline_config.yml "$ctx/omp_offline_config.yml" + cp muteki/solver/kimi_offline_agent.md "$ctx/kimi_offline_agent.md" + cp muteki/solver/grok_offline_agent.md "$ctx/grok_offline_agent.md" chmod +x "$ctx/blackboard.py" - uses: docker/setup-buildx-action@v3 @@ -258,3 +298,17 @@ jobs: # registry-side cache image instead (does not count against the GHA cache). cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tag.outputs.owner }}/muteki-worker:buildcache cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tag.outputs.owner }}/muteki-worker:buildcache,mode=max + + publish: + needs: [bundle, web, ui, worker-slim, worker] + if: ${{ always() && startsWith(github.ref, 'refs/tags/v') && needs.bundle.result == 'success' && needs.web.result == 'success' && needs.ui.result == 'success' && needs.worker-slim.result == 'success' && needs.worker.result == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: muteki-release-bundle + path: dist + - name: Create release with upgrade assets + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${{ github.ref_name }}" dist/* --repo "${{ github.repository }}" --verify-tag --generate-notes --title "Muteki ${{ github.ref_name }}" diff --git a/.gitignore b/.gitignore index a2df883..858fe66 100644 --- a/.gitignore +++ b/.gitignore @@ -10,16 +10,24 @@ wheels/ # cmd/runtime-agent/; build.sh regenerates this into the docker build context. docker/worker/runtime_agent docker/worker/blackboard.py +docker/worker/deepseek_harness_worker.py docker/worker/blackboard.SKILL.md +docker/worker/offline_acp_bridge.py +docker/worker/omp_offline_config.yml +docker/worker/kimi_offline_agent.md +docker/worker/grok_offline_agent.md -# Slim worker image (docker/worker-slim/) build context — ALL four are regenerated by -# docker/worker-slim/build.sh (runtime_agent cross-compiled; AGENTS.md/blackboard.* -# copied from the Kali context + skills/). Unlike the Kali context's AGENTS.md (which -# is hand-maintained + tracked), the slim AGENTS.md is a build-time copy → ignore it. +# Slim worker image build context. build.sh cross-compiles runtime_agent and copies +# the shared prompt, skill, bridges and engine profiles from their maintained sources. docker/worker-slim/runtime_agent docker/worker-slim/AGENTS.md docker/worker-slim/blackboard.py +docker/worker-slim/deepseek_harness_worker.py docker/worker-slim/blackboard.SKILL.md +docker/worker-slim/offline_acp_bridge.py +docker/worker-slim/omp_offline_config.yml +docker/worker-slim/kimi_offline_agent.md +docker/worker-slim/grok_offline_agent.md # Virtual environments .venv @@ -33,6 +41,7 @@ artifacts/ sessions/ eval_runs/ knowledge/ +output/ # pytest / tooling caches .pytest_cache/ @@ -78,3 +87,4 @@ docs/_local/ docs/internal-design/ docs/wechat/ docs/assets/wechat-promo/ +.codegraph/ diff --git a/AGENTS.md b/AGENTS.md index 59eb03e..4507fc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,135 +1,153 @@ -# AGENTS.md — Project Muteki (無敵) - -Autonomous multi-model **CTF-solving agent swarm**. This file is the routing map -and the invariants — facts live in the code and in `README.md` / `README_CN.md`. - -## What this is (the 60-second model) - -- **Worker executor = a shelled full-model CLI agent** (`claude` / `codex`) running - its own agentic shell loop. Muteki orchestrates these CLIs; it does not re-implement - a model loop. Driver: `muteki/solver/cli_driver.py`; swarm-facing solver: - `muteki/solver/cli_solver.py`. -- **A flag is accepted only if it traces to real execution output.** The provenance - gate is `muteki/solver/gate.py` (`_flag_ok` + anti-laundering checks in - `cli_solver.py`). This is the project's core correctness guarantee. -- **The swarm shares one event-sourced evidence graph** (`muteki/swarm/shared_graph.py`, - append-only). An independent **Reason phase** (`muteki/solver/reason.py`) reads the - graph and proposes typed intents for workers to claim. -- **Race vs coordinator.** Default path is a heterogeneous **race** (`cli_race=True`: - claude + codex attack the same challenge, first past the gate wins). An opt-in - **coordinator** (`Swarm(coordinator=True)`) instead plans intents from the graph and - dispatches focused workers. -- **Multi-flag.** `Challenge.expected_flags` (default 1). `expected_flags=1` is - byte-identical to "first flag wins"; only `>1` engages the multi-flag paths. Until - `Swarm._flags_complete()`, a flag is not a stop signal. -- **Frontends are dumb bus subscribers** — `apps/web/` (FastAPI+SSE+Next.js) and - `apps/tui/` (Textual) render the event stream and never call the solver core directly. - -## Startup workflow (every session, before writing code) - -1. **`./init.sh`** — installs deps via `uv` and runs the fast test suite. Must be green - before you start. (Equivalent: `uv run pytest -q`.) -2. Skim `README.md` for what the project is and how to run it; `ROADMAP.md` for direction. -3. If the local working-state files exist (`session-handoff.md`, `progress.md`, - `feature_list.json` — all git-ignored, not part of the distribution), read them for - the last session's focus, the active feature, and the next step. Absence is normal on - a fresh clone; create them from the templates if you want to track multi-session work. - -## Working rules (invariants — do not violate) - -- **Provenance is sacred.** A flag is valid only if it appears in real - stdout/stderr/artifact output. Never weaken `_flag_ok` / the provenance gate to make - a test or eval pass. Zero false flags is the bar — protect it. -- **Flag acceptance stays hardcoded.** It must never become a pluggable verifier — it - stays the separate hardcoded gate (`muteki/solver/gate.py` + the anti-laundering - checks in `cli_solver.py`). -- **Black-box eval.** The solver must NOT see challenge source/solution — only the live - target + description + player-facing `files`. Exception: code-review challenges where - the source IS the intended input. Never feed a `solution.*` / reference solver. -- **Capability eval must be offline.** A full-strength online worker can web-search the - challenge writeup, which contaminates a capability measurement. Use the offline mode - (deny WebSearch/WebFetch) for any solve-rate run; online "solves" don't count toward - solve-rate. (A real competition keeps the web on — the flag is the flag.) -- **The evidence graph is append-only.** Never make `shared_graph` overwrite in place; - it is an event-sourced log. -- **Don't touch the substrate** unless asked: the event spine, provenance gate, - first-valid-flag race, cost ledger, and the shared evidence graph. -- **One feature at a time.** Finish and verify before starting the next; stay in scope — - don't expand into adjacent refactors without asking. -- **Secrets come from the environment** (e.g. `MUTEKI_DEEPSEEK_API_KEY`). Entrypoints - auto-load a repo-root `.env` via `muteki/core/dotenv_boot.py`, but `.env` is git-ignored - (only `.env.example` is tracked) and a shell-exported var always wins. Never commit a - real key or token. -- **Commit only test-backed work.** Working on `main` is fine for this repo. -- **Publishing:** if a `RELEASING.md` exists in this checkout, follow it for any public - release — do not invent your own publish path or add new remotes. (On a plain clone - there is none, and nothing special is needed.) - -## Verification & definition of done - -Primary check: **`./init.sh`** (or `uv run pytest -q`). A change is done only when: - -- [ ] `uv run pytest -q` is green — no regressions. (Unit + scripted-loop tests run - without an API key via the `ScriptedLLM` pattern; live tests skip without a key.) -- [ ] New behavior has a deterministic test. -- [ ] A solve-rate claim is backed by a real black-box trace showing the flag in actual - worker output — not a model's claim. - -> The pwn SDK tests are optional (need pwntools): `MUTEKI_RUN_PWN_TESTS=1 ./init.sh`. - -## Where things are - -| Area | Path | +# AGENTS.md — Project Muteki(無敵) + +Muteki 是一个多模型 CTF / Pentest Agent 调度系统。项目通过完整的 CLI Agent 执行任务, +由 Coordinator 负责规划、调度、审查、运行控制和结果汇总。架构与行为以当前代码为准; +README、ROADMAP 和历史会话记录只提供背景信息。 + +## 当前架构 + +- **Worker 执行器**:`muteki/solver/cli_driver.py` 和 + `muteki/solver/cli_solver.py`。支持 Claude、Codex、Cursor、Pi、OMP、Kimi、 + Grok、OpenCode、DeepSeek Harness 九类引擎。本地与容器 Worker 均支持这九类引擎。 +- **Worker Profile**:`muteki/solver/worker_profiles.py`。Profile 是实际调度单位, + 可以配置引擎、模型、凭据账户、运行环境、优先级和角色。普通角色包括 `race`、 + `bootstrap`、`explore`、`respond`,审查角色为 `review`。 +- **Coordinator**:`muteki/swarm/swarm.py` 与 `muteki/swarm/coordinator_*.py`。 + Coordinator 负责 race-scout、Intent 调度、Worker 生命周期、Review、Operator 指令、 + Flag 汇总和运行结束条件。 +- **共享图**:`muteki/swarm/shared_graph.py` 与 `muteki/swarm/graph_*.py`。 + SQLite 中保存求解事件和状态投影。新增事实、路线、分支、Review 提案、Operator 指令和 + Flag 结果通过事件记录;Intent claim、租约、摘要以及部分状态投影允许原子更新。 +- **Review**:Review Worker 写入 `review_proposal`,Coordinator 通过 + `review_proposal_decision` 接受、拒绝或应用提案。Review Worker 不直接取得全局调度权限。 +- **Operator 控制**:Operator 指令持久化为 `operator_directive`,具有最高调度优先级。 + 指令属于调度输入,执行结果仍需由 Worker 产生事实、产物或 Flag 证据。 +- **前端**:`apps/web/` 提供 FastAPI、SSE 和 Next.js UI,支持查看事件、发送指令、暂停、 + 恢复以及管理 Worker;`apps/tui/` 提供 Textual 界面。 +- **运行控制**:`muteki/control/` 管理 Worker 控制命令、运行状态和上下文交付。 + +## 执行模式 + +- Web 入口默认使用 Coordinator:`coordinator=True`、`cli_race=False`。 + 默认构造类是 `muteki.swarm.swarm.Swarm`,循环在 `coordinator_loop.py`。 + 请求体省略 `swarm_class` 时必须落到这个类。CTF 和渗透共用这一条路径。 +- Coordinator 可以先运行一轮 race-scout,再使用共享图中的 Intent 调度后续 Worker。 +- TUI 的 `--swarm` 路径显式使用直接 race。 +- 直接构造 `Swarm` 时,应明确设置所需模式,不依赖历史默认行为。 +- 单 Flag 任务在第一个有效 Flag 通过校验后完成。多 Flag 任务在收集到 + `Challenge.expected_flags` 个不同 Flag 后完成。 + +## 结果正确性 + +- Flag 只有在真实命令输出、stderr 或真实产物中出现时才可以接受。占位符、模板内容、 + 模型自行声明的结果以及经过转述但没有执行来源的内容不能作为有效 Flag。 +- Flag 接受逻辑位于 `muteki/solver/gate.py`,相关来源检查位于 + `muteki/solver/cli_solver.py`。修改求解流程时保持该校验入口独立。 +- Operator 指令、知识库结果、Review 文本和普通聊天内容都不能直接成为 Flag 来源。 +- 写入 verified fact 时提供可核查的 witness、命令输出或产物路径;尚未核查的信息写为 + candidate,并保留来源。 +- challenged fact 暂不作为已确认依据。suppressed route 只有出现新证据后再 reopen。 + 不同 branch 对应独立假设,应分别记录验证结果。 + +## 评测口径 + +- 正式求解率评测只向 Worker 提供目标、题目描述和选手可见文件。代码审计题可以把题目源码 + 作为输入,但不提供 `solution.*`、参考解或官方 Writeup。 +- 离线能力评测应关闭 WebSearch、WebFetch 和外部知识库。普通本地开发、调试和真实比赛 + 可以使用联网模式。 +- 如果显式覆盖离线限制,评测记录应注明联网条件,避免与离线结果合并统计。 +- 求解率结论应附带真实运行记录,并能定位到 Worker 实际输出中的 Flag。 + +## 开发方式 + +1. 开始前查看 `git status --short`,确认当前分支、未提交文件和其他任务留下的改动。 +2. 使用 `rg`、调用关系和当前实现定位功能。历史文档与代码不一致时,以代码为准并更新说明。 +3. 功能实现任务先完成可运行的操作路径。用户确认功能后,再添加防护、回归测试和兼容性处理; + 用户明确要求测试,或正在修复已经复现的问题时,可以同时补充对应测试。 +4. 验证范围由任务决定。文档任务检查内容和 diff;局部功能运行相关检查;发布或大范围改动再运行 + 完整测试集。 +5. 保留工作区中与当前任务无关的改动,不覆盖其他任务生成的文件。 +6. 修改共享图时同时检查事件写入、状态投影、Coordinator 消费逻辑和前端事件解析。 +7. 修改 Worker Profile、引擎或运行环境时同时检查 Web 设置、健康检查、容器支持和调度筛选。 +8. F01–F11、ChainForce、PEX、DualRush、ReapClose、RoleSwarm、HypoLedger、Phased + 都是实验 / 评测臂。未经过正式讨论并写入产品默认之前,不得把它们接到 Web / TUI / + 渗透默认启动,不得改 `_resolve_swarm_class` 的空 spec 返回值,不得在 + `coordinator_loop` 或默认 `CliSolver` 路径里无条件调用这些包。细则见 + `docs/TODO_EXPERIMENTAL_FRAMEWORKS.md`。 + +## 主要路径 + +| 内容 | 路径 | |------|------| -| Worker executor + cognitive core (shelled CLI: driver + CliSolver) | `muteki/solver/cli_driver.py`, `muteki/solver/cli_solver.py` | -| Flag gate (provenance, placeholder/laundering rejects) | `muteki/solver/gate.py` | -| Reason phase (planner + evidence audit) | `muteki/solver/reason.py` | -| Per-track / per-mode prompts | inline in `muteki/solver/cli_solver.py` (`_EXEC_PROMPT`, `_EXPLORE_PROMPT`, …) | -| Swarm + Insight Bus | `muteki/swarm/` (`swarm.py`, `insight_bus.py`, `models.py`) | -| Shared evidence graph (event-sourced) | `muteki/swarm/shared_graph.py` | -| Worker tools | the shelled CLI agent's OWN in-container toolkit (bash/python/ghidra/pwntools/…) — Muteki does not ship a capability SDK | -| Sandbox kernel | `muteki/sandbox/` | -| Event spine / cost ledger / sessions | `muteki/core/` | -| Frontends | `apps/web/` (FastAPI+SSE+Next.js), `apps/tui/` (Textual) | -| Blackboard skill (worker read/write of the shared graph) | `skills/muteki-blackboard/` | -| Roadmap | `ROADMAP.md` | - -## Running a frontend (`run.sh`) +| CLI 引擎驱动 | `muteki/solver/cli_driver.py` | +| Worker 执行循环 | `muteki/solver/cli_solver.py` | +| Worker Profile | `muteki/solver/worker_profiles.py` | +| Flag 校验 | `muteki/solver/gate.py` | +| Coordinator 主体 | `muteki/swarm/swarm.py` | +| Coordinator 循环与调度 | `muteki/swarm/coordinator_loop.py`、`coordinator_dispatch.py` | +| Race 与健康检查 | `muteki/swarm/coordinator_race.py` | +| Review | `muteki/swarm/coordinator_review.py` | +| Operator、Flag 与结束条件 | `muteki/swarm/coordinator_flags.py` | +| 共享图接口 | `muteki/swarm/shared_graph.py` | +| 共享图事件、事实、Intent、路线和锁 | `muteki/swarm/graph_*.py` | +| 黑板技能 | `skills/muteki-blackboard/` | +| 运行控制 | `muteki/control/` | +| Web 运行管理 | `apps/web/run_manager.py`、`apps/web/drivers.py` | +| Web 事件状态 | `apps/web/ui/lib/events.ts`、`apps/web/ui/lib/useRun.ts` | +| 配色方案引擎 | `apps/web/ui/lib/palette-engine.ts` | +| Worker 设置界面 | `apps/web/ui/components/WorkerOrchestration.tsx` | +| TUI | `apps/tui/` | +| Worker 容器 | `docker/worker/`、`docker/worker-slim/` | + +## 常用命令 ```bash -./run.sh tui # Textual TUI, mock stream (UI demo, no key) -./run.sh tui --swarm --key # TUI solving a real challenge (needs a key) -./run.sh web # FastAPI backend (:8000, API-only) + production Next UI (:3001) -./run.sh web --backend-only # backend only (:8000) +./run.sh tui +./run.sh tui --swarm --key +./run.sh web +./run.sh web --backend-only +uv run --extra dev python -m pytest -q <相关测试路径> ``` -The Next.js app (`apps/web/ui/`, :3001) is the deck; the FastAPI backend (:8000) is -API-only (serves the SSE / `/api` contract). The web UI's chat input *commands the -swarm* (hint / redirect / focus / pause / resume / submit) through the HITL backend — -but guidance is context, never a flag source (the provenance gate is unchanged). +`./run.sh web` 使用生产构建后的 Next.js 服务,默认前端端口为 `3001`,FastAPI 端口为 +`8000`。端口被已有进程占用时,先确认对应进程属于哪个运行任务,再决定是否停止。 -`./run.sh web` intentionally uses `next build` + a production Next server, not -`next dev`, so `/api` remains same-origin through the Next rewrite proxy. A detached -backend can still linger on :8000 (EADDRINUSE) — `lsof -ti :8000` and kill it before -reusing the port. +## 实验框架(不得默认接入) -## Blackboard skill (worker coordination) +`muteki/frameworks/f01_*` … `f11_*`,以及 `muteki/solver/swarm_chainforce.py`、 +`swarm_pex.py`、`swarm_dualrush.py`、`swarm_reapclose.py`、`swarm_roleswarm.py`、 +`swarm_hypoledger.py`、`swarm_phased.py`,都是评测和研究用的实验臂。它们不完善, +不是产品 Coordinator。 -Workers read/write/claim the shared graph through the `muteki-blackboard` skill -(`skills/muteki-blackboard/`), pointed at `$MUTEKI_BLACKBOARD_DB`. Both `claude` and -`codex` support skills; install into both skill dirs with -`scripts/install_blackboard_skill.sh`. Treat any blackboard content as a *lead*, not -ground truth — it never bypasses the flag gate. +未经过正式讨论并明确写入产品默认之前: -## Knowledge base (optional, off by default) +- Web `/start` 省略 `swarm_class` 时必须构造 `muteki.swarm.swarm.Swarm`。 +- 前端不得发送 `swarm_class`。 +- 渗透模式只改题目产品(报告、完整性、复现、价值裁定),不换调度类。 +- 评测脚本若要跑实验臂,必须在请求或命令行里写明完整 + `module.path:ClassName`,并在评测记录里注明。不得把这种指定写进产品默认。 -A worker can optionally query a knowledge-base MCP (e.g. your own CVE / writeup index) -**if the operator configures one** — no KB service is bundled, and the KB is off by -default. Opt in via `MUTEKI_KB_MCP_NAME` (the server key from your own user-scoped -`.mcp.json`). MCP results are leads/clues, not ground truth — they never become an -accepted flag without appearing in real execution output. +历史原因:PR #6 的 research 提交把 Web 默认改成了 `SwarmChainForce` +(评测基线臂)。该默认已改回 `Swarm`。剩余挂钩见 +`docs/TODO_EXPERIMENTAL_FRAMEWORKS.md`。 -## End of session +## Blackboard -If you stop mid-feature, leave a one-paragraph handoff in `session-handoff.md` -(local-only) so the next session restarts from a clean state. +Worker 通过 `skills/muteki-blackboard/blackboard.py` 访问共享图,数据库路径来自 +`$MUTEKI_BLACKBOARD_DB`。常用协议包括: + +- `read-directives`:读取 Operator 指令。 +- `read-review`、`read-deadends`、`read-facts`:读取审查结果、失败路线和事实。 +- `read-routes`、`read-branches`、`read-flags`:读取路线、假设分支和已收集 Flag。 +- `list-intents`、`claim`:查看并原子领取开放 Intent。 +- `claim-resource`、`release-resource`:管理端口、监听器、目标会话等独占资源。 + +以 `skills/muteki-blackboard/SKILL.md` 和 `blackboard.py --help` 显示的当前命令为准。 +`scripts/install_blackboard_skill.sh` 用于刷新 Claude、Cursor 和 Codex 的用户级技能副本; +源码运行和容器运行还会通过各自的启动路径提供黑板脚本。 + +## Worker 容器说明 + +`docker/worker/AGENTS.md` 是完整版和 slim Worker 共用的说明来源。修改后, +`docker/worker-slim/build.sh` 会把它复制到 slim 构建上下文。不要单独维护被忽略的 +`docker/worker-slim/AGENTS.md`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc2ef5..a9e3732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,19 @@ All notable public release changes are tracked here. -## Unreleased +## 0.3.0 - 2026-08-17 + +### Added + +- Added managed install, upgrade, and rollback through `muteki` / `./run.sh`, using a signed GitHub Release bundle and SHA-256 verification. +- Added a versioned Compose deployment (`docker-compose.release.yml`) with `muteki upgrade --compose` / `muteki rollback --compose`. +- Added **Settings → System update** in the web command deck, including compose-mode commands that stay on the host. +- Added `/api/health` for Compose liveness checks. + +### Changed + +- Package versions are `0.3.0`. Git tags and GHCR image tags keep the leading `v`, for example `v0.3.0`. +- Slim worker image documentation now matches the nine engine CLIs. ### Fixed diff --git a/README.md b/README.md index d811240..4facb79 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Issues PRs Benchmark - Engines + Engines: Claude, Codex, Cursor, Pi, OMP, Kimi, Grok, OpenCode, DeepSeek Harness

@@ -40,7 +40,7 @@ This is a **truly open-source, multi-model CTF-solving AI agent swarm.** The goal is to live up to its very name — **無敵 · Project Muteki** ("Invincible"). -At its core, the project implements a scheduling scheme for AI agents that automatically and intelligently coordinates and controls each agent's context — like a swarm, each with its own division of labor, but all working toward the final goal. It currently supports commanding and dispatching only cursor, codex, and Claude Code. More kinds of CLI agents will be supported through continuous iteration. +At its core, the project implements a scheduling scheme for AI agents that automatically and intelligently coordinates and controls each agent's context — like a swarm, each with its own division of labor, but all working toward the final goal. Current Worker engines are Claude, Codex, Cursor, Pi, OMP, Kimi, Grok, OpenCode, and DeepSeek Harness. Muteki exists to solve a specific problem: a single AI agent, when working toward a goal, very easily falls into a dead-loop at one spot — unable to pull itself out, unable to reach the final goal — and a single agent is extremely inefficient. I designed an architecture to solve this. It may not be the most perfect one, but I'll keep iterating and upgrading it. @@ -88,13 +88,13 @@ You're welcome to use it and help build and upgrade it together. If you run into ## Architecture -Muteki points a group of heterogeneous coding agents (Claude Code / Codex / cursor-agent) at the same challenge, collaborating on a single **shared blackboard**: facts one of them discovers are usable by all, dead ends one of them walks are never retried by the others, and a flag is accepted only when it **appears verbatim in real execution output**. The core isn't "swap in a smarter brain" — it's **heterogeneity + shared evidence + a provenance gate**. +Muteki points a group of heterogeneous coding agents (Claude, Codex, Cursor, Pi, OMP, Kimi, Grok, OpenCode, DeepSeek Harness) at the same challenge, collaborating on a single **shared blackboard**: facts one of them discovers are usable by all, dead ends one of them walks are never retried by the others, and a flag is accepted only when it **appears verbatim in real execution output**. The core isn't "swap in a smarter brain" — it's **heterogeneity + shared evidence + a provenance gate**. So how does a worker hand its data to the platform, and how does it see its teammates' progress? **It all relies on the `muteki-blackboard` skill built into every worker** — this is the only data channel between a worker and the blackboard. For a detailed architecture explanation, see: [docs/工作原理.md](docs/工作原理.md) -The project follows a "less is more" principle: it injects no security tools and no security knowledge, keeps the network open, and lets workers improvise freely — writing and installing their own dependencies and scripts. +The project follows a "less is more" principle: it injects no security tools and no security knowledge, keeps the network open, and lets workers improvise freely — writing and installing their own dependencies and scripts. The composer **Web tools** toggle only closes the agent's WebSearch, WebFetch, and knowledge base; the worker shell can still reach the network. ![image-20260624164618066](./assets/image-20260624164618066.png) @@ -112,11 +112,13 @@ The outer `①②③④` are the four phases of a single run; the inner `(1)~(5) | Phase | When it starts | What it does | Output | | ------------- | --------------------- | ------------------------------------- | --------------------- | | **① Prepare** | At the start of a run | Build the blackboard, stage attachments, health-check engines, install the skill, and (in container mode) start containers + reverse connection | Empty blackboard + available engines + channels wired up | -| **② Recon Race** | Cold start only (skipped when re-examining an already-solved challenge) | Multiple engines single-shot the whole challenge in parallel for breadth-first recon | A flag (→ fast path) or a batch of facts | -| **③ Coordination main loop** | When recon didn't solve it directly | `(1)~(5)` keeps looping, expanding the swarm as evidence grows | The blackboard keeps growing until there's enough for a flag | +| **② Recon Race** | Optional race-scout round (skipped when re-examining an already-solved challenge) | Multiple engines each make one main call on the whole challenge in parallel | A flag (→ fast path) or a batch of facts | +| **③ Coordination main loop** | Web default; also used after race-scout if no flag | `(1)~(5)` keeps looping, expanding the swarm as evidence grows | The blackboard keeps growing until there's enough for a flag | | **④ Wind-down** | Enough for a flag / operator stops / budget exhausted | Persist the winner, release claims, emit terminal events, clean up | RUN_FINISHED + a replayable blackboard | +Web defaults to Coordinator and may run an optional race-scout round first. The TUI `--swarm` path uses a direct race. Callers that construct `Swarm` directly must select the intended mode explicitly. + To keep Muteki from falling into a dead-loop while working a single task, we set up a review mechanism: while Muteki executes the task, it periodically runs a review that checks and verifies the facts already recorded, then corrects course promptly whenever needed. --- @@ -134,6 +136,21 @@ To keep Muteki from falling into a dead-loop while working a single task, we set The `.env` at the repo root is loaded automatically (copy it from `.env.example`); variables exported in your shell always take precedence. Configuration is done through `MUTEKI_*` environment variables. +### Built-in updates + +Application updates no longer require `git pull`. The first install creates a managed application directory and keeps the existing `.env` and `sessions` paths. Later upgrades switch versions atomically and retain one previous version for rollback. + +```bash +./run.sh upgrade --check # check the latest stable release +./run.sh install # download the latest GitHub Release into a managed install +muteki upgrade # download, verify, install, and switch +muteki upgrade v0.3.1 # install an exact release +muteki rollback # switch back to the previous installed release +muteki version # show the active version and install mode +``` + +The same controls are available under **Settings → System update** in the Web command deck. For the versioned container deployment, use `muteki upgrade --compose` with [`docker-compose.release.yml`](docker-compose.release.yml). Private GitHub Releases need `MUTEKI_RELEASE_REPOSITORY` and `MUTEKI_GITHUB_TOKEN`; private GHCR images need `MUTEKI_IMAGE_REGISTRY` and `docker login ghcr.io`. Git tags and image tags use a leading `v`, for example `v0.3.0`. + Recommended setting: ``` @@ -156,25 +173,31 @@ If you don't set it, the main impact is that the Reason planner won't autonomous - The **engine CLIs** you intend to use, available on your `PATH` (see below) - This project has so far only been tested on macOS, not on Windows — handle accordingly. -### Proprietary engine CLIs +### Worker engine CLIs -Muteki **shells out to** the three closed-source agent CLIs below; install and authenticate whichever ones you want to use. Each has its own license and sends data back to its respective vendor: +Muteki **shells out to** the Worker engine CLIs below; install and authenticate whichever ones you want to use. Vendor CLIs have their own licenses and may send data back to their vendors: | Engine | CLI | Vendor | Credential | | -------- | ------------------------------------ | --------- | ----------------------------------- | -| `claude` | `@anthropic-ai/claude-code` | Anthropic | OAuth token (`claude setup-token`) | -| `codex` | `@openai/codex` | OpenAI | `~/.codex/auth.json` (`codex login`) | -| `cursor` | `cursor-agent` (`cursor.com/install`) | Cursor | API key | +| `claude` | `claude` (`@anthropic-ai/claude-code`) | Anthropic | OAuth token (`claude setup-token`) | +| `codex` | `codex` (`@openai/codex`) | OpenAI | `~/.codex/auth.json` (`codex login`) | +| `cursor` | `cursor-agent` (`cursor.com/install`) | Cursor | API key | +| `pi` | `pi` | Pi | API key / host login | +| `omp` | `omp` | OMP | API key / host login | +| `kimi` | `kimi` | Moonshot | Kimi Code login directory | +| `grok` | `grok` | xAI | Grok login directory | +| `opencode` | `opencode` | OpenCode | API key | +| `dsh` | DeepSeek Harness worker | DeepSeek | API key | -You need at least one of them to run. Beyond these three, you can also configure a **custom OpenAI-compatible endpoint** (`base_url` + key) in a worker profile — suitable for self-hosted or third-party models. Credentials are read from the macOS Keychain / environment and injected into the worker environment; see [Credentials](#credentials) and [SECURITY.md](SECURITY.md). +You need at least one of them to run. You can also configure a **custom OpenAI-compatible endpoint** (`base_url` + key) in a worker profile — suitable for self-hosted or third-party models. Credentials are read from the macOS Keychain / environment and injected into the worker environment; see [Credentials](#credentials) and [SECURITY.md](SECURITY.md). --- ## Credentials -The three agents' credentials are configured along with the web settings. In local mode you can skip configuring them — you just need your subscription to be usable when you run the CLI yourself. +Worker credentials are configured in the web settings. In local mode you can skip configuring them — you just need your subscription to be usable when you run the CLI yourself. The remaining cases are generally for configuring remote or container environments, where container credential information is involved. @@ -200,7 +223,7 @@ After saving, you can click "Save & test" at any time. - In **`container`** mode an account is **mandatory** — the host login is not mounted into the container; credentials are mounted into the container via command injection and file mounting. - In **`local`** mode, if no account is registered, the worker inherits the host CLI's existing login — though you can also configure it manually. -The DeepSeek reasoning model (used by the coordinator, not a worker engine) is configured separately via `MUTEKI_DEEPSEEK_API_KEY` in `.env`. +The DeepSeek reasoning model (used by the coordinator) is configured separately via `MUTEKI_DEEPSEEK_API_KEY` in `.env`. DeepSeek Harness (`dsh`) is a separate Worker engine and is configured in Worker settings. ![image-20260624184600517](./assets/image-20260624184600517.png) @@ -235,8 +258,8 @@ MUTEKI_WORKER_IMAGE=ghcr.io/fishcodetech/muteki-worker-slim:latest ./run.sh web ```bash ./docker/worker/build.sh -./docker/worker/build.sh ghcr.io/fishcodetech/muteki-worker 0.2.5 -./docker/worker-slim/build.sh ghcr.io/fishcodetech/muteki-worker-slim 0.2.5 amd64 +./docker/worker/build.sh ghcr.io/fishcodetech/muteki-worker v0.3.0 +./docker/worker-slim/build.sh ghcr.io/fishcodetech/muteki-worker-slim v0.3.0 amd64 ``` The full image is intentionally large (Kali headless + Ghidra + SageMath via conda + offline knowledge). Use the slim image only when you understand that workers may need to install more tooling during a run. @@ -275,6 +298,13 @@ Topology: - **`ui`** — Next command deck; proxies `/api` → `web-api`. - **workers** are *not* a compose service — `web-api` `docker run`s one per run. +The durable operator journal and SecretStore live under the coordinator-only +`MUTEKI_COORDINATOR_CONTROL_ROOT` (compose defaults it to +`$MUTEKI_HOST_DATA_ROOT/coordinator-control`). That path is never a worker mount and +is never included in the worker-workspace ownership rewrite. A per-run sibling +bootstrap directory (`.muteki_rcp` beside the workspace, mounted at +`/run/muteki/control` in the worker) carries only the reverse-connect bootstrap token. + ```bash # 1. Have the worker image available on the host daemon. # Compose builds web-api/ui from this checkout, but it does NOT build workers. @@ -312,7 +342,7 @@ The full env contract (and which vars compose sets for you automatically — don 3. For the runtime environment, local is recommended; if you have special needs you can choose container, which will remind you to configure the relevant credentials — please configure those yourself. You can click "Test model" to check whether it works correctly; the test invokes the agent and asks the model to repeat "ok". ![image-20260624192439759](./assets/image-20260624192439759.png) 4. Next, you can configure your workers in detail; configuring them as shown in the picture is recommended. - The starting worker count is the number for the race phase; it follows your engine count and runs all three agent engines simultaneously until the flag is solved or the challenge times out. It's used for quickly grabbing first blood and quickly solving easy challenges. + The starting worker count is the number for the race-scout round when that round is enabled; it follows your enabled engine count. It is used for quickly grabbing first blood and quickly solving easy challenges. The maximum worker count is recommended to stay around 5–6, because for web challenges too many workers could cause a DDoS-like situation. ![image-20260624192517250](./assets/image-20260624192517250.png) 5. It's recommended to configure and test connectivity for the reasoning model here, for better planning and pacing of the challenge. @@ -321,7 +351,7 @@ The full env contract (and which vars compose sets for you automatically — don 7. The recommended prompting approach for solving a challenge is as follows: 1. State the challenge description, category, name, website/URL, and flag format. 2. The frontend also supports copy-paste and file upload, so you can directly upload attachment-based challenges. - 3. The "network" toggle in the picture controls whether the agent's own web-search capability is enabled; it's on by default, and turning it off is for benchmark evaluation. + 3. The "Web tools" toggle in the picture controls whether the agent's own WebSearch, WebFetch, and knowledge base are enabled; they are on by default. Turning them off is for benchmark evaluation. The worker shell can still use the network. 4. Ignore the local/container button — it's tied to the settings feature and may be removed later. Under "Advanced" you can manually specify the flag format and a few simple settings, which can be ignored. ![image-20260624193322483](./assets/image-20260624193322483.png) ![image-20260624193441654](./assets/image-20260624193441654.png) @@ -370,7 +400,7 @@ with per-challenge details in [eval_nyu/_reports/RESULTS.md](eval_nyu/_reports/R | -------------------- | --------------------------------------------------------------------------------- | | `muteki/` | Core: `swarm/` (coordinator), `solver/` (CLI driver, gate, control plane), `models/`, `platform/`, `sandbox/` | | `apps/web/` | FastAPI backend (`server.py`) + Next.js operator UI (`ui/`) | -| `apps/tui/` | Textual TUI command deck (unfinished) | +| `apps/tui/` | Textual TUI command deck (`--swarm` uses a direct race; Coordinator/Settings integration is deferred) | | `cmd/runtime-agent/` | In-container Go supervisor (reverse-connects to the control plane) | | `docker/worker/` | Worker image (Dockerfile, build scripts, tool-awareness map) | | `scripts/` | eval / backtest harness | @@ -425,7 +455,7 @@ A few key points: ## Testing ```bash -uv run pytest # Python suite (live tests auto-skip when no key is set) +uv run --extra dev python -m pytest -q # Python suite, using the project interpreter go test -C cmd/runtime-agent ./... # Go supervisor (the module lives under cmd/runtime-agent/) ( cd apps/web/ui && npx tsc --noEmit ) # UI type-check ``` @@ -434,12 +464,11 @@ go test -C cmd/runtime-agent ./... # Go supervisor (the module lives und ## Roadmap / TODO -- [ ] Add authentication logic -- [ ] Fully optimize and test the container mode +- [ ] Continue container-mode hardening - [ ] Keep iterating and improving the web UI experience -- [ ] Support more agent worker types, e.g. pi, zai, opencode, etc. -- [ ] TUI mode -- [ ] Fully automatic crawling of CTF-platform challenges, with auto-solving, auto-submission, and auto report generation +- [ ] TUI integration with the current Coordinator and Worker Settings (deferred) +- [ ] Additional worker engines such as ZAI (deferred) +- [ ] Fully automatic crawling of CTF-platform challenges, with auto-solving, auto-submission, and auto report generation (deferred) --- diff --git a/README_CN.md b/README_CN.md index 9f02603..7562c39 100644 --- a/README_CN.md +++ b/README_CN.md @@ -19,7 +19,7 @@ Issues PRs Benchmark - Engines + Engines: Claude, Codex, Cursor, Pi, OMP, Kimi, Grok, OpenCode, DeepSeek Harness

@@ -40,7 +40,7 @@ 这是一款 **真正意义上的开源的多模型 CTF 求解 AI agent 蜂群。** 目标就是成为如项目名称,**無敵 · Project Muteki** -项目核心是实现了一套ai agent的调度方案,自动、智能化协调控制每个agent的上下文,像蜂群一样,各有分工,但都是为了完成最终的目标,目前只支持cursor、codex、claude code的指挥和下发。未来将持续更新迭代支持更多种类的cli agent。 +项目核心是实现了一套ai agent的调度方案,自动、智能化协调控制每个agent的上下文,像蜂群一样,各有分工,但都是为了完成最终的目标。当前 Worker 引擎为 Claude、Codex、Cursor、Pi、OMP、Kimi、Grok、OpenCode、DeepSeek Harness。 Muteki就是为了解决单一ai agent在解决一个目标是极其容易陷入一个点死循环,无法自拔,无法完成最终的目标,并且单一agent效率极低,我设计了一套架构来解决这个问题,他可能不是最完美的,我将继续不断迭代升级。 @@ -88,13 +88,13 @@ nyuctf benchmark全题目测评成绩,可看文章结尾 ## 架构 -無敵让一群异构的编码 Agent(Claude Code / Codex / cursor-agent)扑同一道题,在一张**共享黑板**上协作:谁发现的事实大家都能用,谁走过的死路大家都不再试,而 flag 只有**逐字出现在真实执行输出里**才被接受。核心不是「换个更强的脑子」,而是 **异构 + 共享证据 + 溯源闸门**。 +無敵让一群异构的编码 Agent(Claude、Codex、Cursor、Pi、OMP、Kimi、Grok、OpenCode、DeepSeek Harness)扑同一道题,在一张**共享黑板**上协作:谁发现的事实大家都能用,谁走过的死路大家都不再试,而 flag 只有**逐字出现在真实执行输出里**才被接受。核心不是「换个更强的脑子」,而是 **异构 + 共享证据 + 溯源闸门**。 而 worker 是怎么把数据交到平台、又怎么看到队友进展的?**全靠每个 worker 内置的 `muteki-blackboard` skill**——这是 worker 与黑板之间唯一的数据通道。 详细架构说明,请参考:[docs/工作原理.md](docs/工作原理.md) -项目秉承着 less is more的原则,不注入任何安全工具、安全知识,开放网络,让worker自由发挥,自由编写和自由安装依赖脚本。 +项目秉承着 less is more的原则,不注入任何安全工具、安全知识,开放网络,让worker自由发挥,自由编写和自由安装依赖脚本。启动页的 **Web 工具** 开关只关闭 Agent 的 WebSearch、WebFetch 和知识库;Worker 的 shell 仍可访问网络。 ![image-20260624164618066](./assets/image-20260624164618066.png) @@ -112,11 +112,13 @@ nyuctf benchmark全题目测评成绩,可看文章结尾 | 阶段 | 什么时候进 | 干什么 | 产出 | | ------------- | --------------------- | ------------------------------------- | --------------------- | | **① 准备** | run 一开始 | 建黑板、暂存附件、探活引擎、装好 skill、(容器模式)起容器+反向连接 | 空黑板 + 可用引擎 + 接好通道 | -| **② 侦察 Race** | 仅冷启动(复盘已解的题跳过) | 多引擎并行单发扑整题,做广度侦察 | flag(→快路径)或一批 fact | -| **③ 协调主循环** | 侦察没直接解出时 | `(1)~(5)` 不断转圈,随证据扩张 swarm | 黑板持续长大,直到攒够 flag | +| **② 侦察 Race** | 可选的 race-scout 轮次(复盘已解的题跳过) | 多引擎各做一次主调用,并行扑整题 | flag(→快路径)或一批 fact | +| **③ 协调主循环** | Web 默认;race-scout 未解出时也走这里 | `(1)~(5)` 不断转圈,随证据扩张 swarm | 黑板持续长大,直到攒够 flag | | **④ 收尾** | 攒够 flag / 操作员停 / 预算耗尽 | 落 winner、释放认领、发终态事件、清扫 | RUN_FINISHED + 可复盘的黑板 | +Web 默认使用 Coordinator,并可先跑一轮可选的 race-scout。TUI 的 `--swarm` 路径使用直接 race。直接构造 `Swarm` 时需要显式选择模式。 + 为了防止muteki在做单一任务时进入死循环,我们设定了一个review机制,当muteki在执行任务时,会定期进行review,review机制会检查已经记录的事实并验证,然后随时及时纠正。 --- @@ -132,7 +134,22 @@ nyuctf benchmark全题目测评成绩,可看文章结尾 # 只起后端: ./run.sh web --backend-only ``` -仓库根目录的 `.env` 会被自动加载(从 `.env.example` 复制);shell 导出的变量始终优先。配置通过`MUTEKI_*` 环境变量。 +仓库根目录的 `.env` 会被自动加载(从 `.env.example` 复制);Shell 导出的变量始终优先。配置通过 `MUTEKI_*` 环境变量。 + +### 内置升级 + +应用升级不再要求执行 `git pull`。首次安装会创建托管应用目录,并继续使用现有 `.env` 和 `sessions` 路径。后续升级使用原子方式切换版本,同时保留一个上一版本用于回滚。 + +```bash +./run.sh upgrade --check # 检查最新稳定版本 +./run.sh install # 按 GitHub Release 清单下载并做成托管安装 +muteki upgrade # 下载、校验、安装并切换版本 +muteki upgrade v0.3.1 # 安装指定版本 +muteki rollback # 回滚到上一已安装版本 +muteki version # 查看当前版本和安装形态 +``` + +Web 控制台的“设置 → 系统更新”提供相同操作。版本化容器部署使用 `muteki upgrade --compose` 和 [`docker-compose.release.yml`](docker-compose.release.yml)。私有 GitHub Release 需要 `MUTEKI_RELEASE_REPOSITORY` 和 `MUTEKI_GITHUB_TOKEN`;私有 GHCR 镜像需要 `MUTEKI_IMAGE_REGISTRY` 并先执行 `docker login ghcr.io`。Git 标签和镜像标签带 `v`,例如 `v0.3.0`。 推荐设置项: @@ -156,19 +173,25 @@ MUTEKI_DEEPSEEK_API_KEY=sk-xxxx - 你打算用的**引擎 CLI**,需在 `PATH` 上(见下) - 当前项目仅在macos上进行过测试,未在windows上进行测试,请酌情处理。 -### 专有引擎 CLI +### Worker 引擎 CLI -Muteki **套壳调用**下面三个闭源 agent CLI;装好并认证你想用的那些。它们各有自己的 license, 且会向各自的厂商回传数据: +Muteki **套壳调用**下面的 Worker 引擎 CLI;装好并认证你想用的那些。厂商 CLI 各有自己的 license,并可能向各自的厂商回传数据: | 引擎 | CLI | 厂商 | 凭据 | | -------- | ------------------------------------ | --------- | ----------------------------------- | -| `claude` | `@anthropic-ai/claude-code` | Anthropic | OAuth token(`claude setup-token`) | -| `codex` | `@openai/codex` | OpenAI | `~/.codex/auth.json`(`codex login`) | -| `cursor` | `cursor-agent`(`cursor.com/install`) | Cursor | API key | - - -至少需要其中一个才能跑。除这三个外,还可在 worker profile 里配置**自定义 OpenAI 兼容端点** +| `claude` | `claude`(`@anthropic-ai/claude-code`) | Anthropic | OAuth token(`claude setup-token`) | +| `codex` | `codex`(`@openai/codex`) | OpenAI | `~/.codex/auth.json`(`codex login`) | +| `cursor` | `cursor-agent`(`cursor.com/install`) | Cursor | API key | +| `pi` | `pi` | Pi | API key / 宿主登录 | +| `omp` | `omp` | OMP | API key / 宿主登录 | +| `kimi` | `kimi` | Moonshot | Kimi Code 登录目录 | +| `grok` | `grok` | xAI | Grok 登录目录 | +| `opencode` | `opencode` | OpenCode | API key | +| `dsh` | DeepSeek Harness worker | DeepSeek | API key | + + +至少需要其中一个才能跑。还可在 worker profile 里配置**自定义 OpenAI 兼容端点** (`base_url` + key)—— 适合自托管或第三方模型。凭据从 macOS Keychain / 环境读取并注入到 worker 环境;见 [凭据](#凭据) 与 [SECURITY.md](SECURITY.md)。 @@ -176,7 +199,7 @@ Muteki **套壳调用**下面三个闭源 agent CLI;装好并认证你想用的 ## 凭据 -三个agent 凭据会跟随着网页设置中进行配置,走本地模式一下可以不需要配置,只需要保证你自己运行cli的时候,订阅可用即可。 +各引擎凭据会跟随着网页设置中进行配置,走本地模式一下可以不需要配置,只需要保证你自己运行cli的时候,订阅可用即可。 剩余情况一般用于配置远程环境、容器环境,需要涉及到容器的凭据信息。 @@ -202,7 +225,7 @@ Muteki **套壳调用**下面三个闭源 agent CLI;装好并认证你想用的 - `**container`** 模式下账户是**必须的** —— 宿主登录不会挂进容器,会通过命令注入和文件挂在的方式将凭据挂到容器里 - `**local`** 模式下,若没注册账户,worker 会继承宿主 CLI 已有的登录,当然你也可以手工配置。 -DeepSeek 推理模型(协调器用,不是 worker 引擎)单独通过 `.env` 里的 `MUTEKI_DEEPSEEK_API_KEY`配置。 +DeepSeek 推理模型(协调器用)单独通过 `.env` 里的 `MUTEKI_DEEPSEEK_API_KEY`配置。DeepSeek Harness(`dsh`)是单独的 Worker 引擎,在 Worker 设置里配置。 ![image-20260624184600517](./assets/image-20260624184600517.png) @@ -217,7 +240,7 @@ DeepSeek 推理模型(协调器用,不是 worker 引擎)单独通过 `.env` 里 | 镜像 | 用途 | | --- | --- | | `ghcr.io/fishcodetech/muteki-worker:latest` | 完整 Kali worker 镜像,用于真实 CTF 运行。体积大,但包含预期的 pwn/rev/取证工具链。 | -| `ghcr.io/fishcodetech/muteki-worker-slim:latest` | 轻量 worker,用于联调、冒烟测试和受限部署。有 supervisor 和三个引擎 CLI,但没有完整 Kali 工具链。 | +| `ghcr.io/fishcodetech/muteki-worker-slim:latest` | 轻量 worker,用于联调、冒烟测试和受限部署。有 supervisor 和引擎 CLI,但没有完整 Kali 工具链。 | | `ghcr.io/fishcodetech/muteki-web:latest` | release 流水线产出的 FastAPI 控制面镜像。 | | `ghcr.io/fishcodetech/muteki-ui:latest` | release 流水线产出的 Next 指挥台镜像。 | @@ -237,8 +260,8 @@ MUTEKI_WORKER_IMAGE=ghcr.io/fishcodetech/muteki-worker-slim:latest ./run.sh web ```bash ./docker/worker/build.sh -./docker/worker/build.sh ghcr.io/fishcodetech/muteki-worker 0.2.5 -./docker/worker-slim/build.sh ghcr.io/fishcodetech/muteki-worker-slim 0.2.5 amd64 +./docker/worker/build.sh ghcr.io/fishcodetech/muteki-worker v0.3.0 +./docker/worker-slim/build.sh ghcr.io/fishcodetech/muteki-worker-slim v0.3.0 amd64 ``` 完整镜像会比较大(Kali headless + Ghidra + 经 conda 装的 SageMath + 离线知识库)。只有在你明确知道 worker 可以在运行中自行安装缺失工具时,才建议用 slim 镜像跑真实题目。 @@ -277,6 +300,12 @@ MUTEKI_WORKER_IMAGE=ghcr.io/fishcodetech/muteki-worker-slim:latest ./run.sh web - **`ui`** —— Next 命令台,`/api` 反代到 `web-api`。 - **worker** 不是 compose 服务 —— 由 `web-api` 每次 run 时 `docker run` 拉一个。 +持久化的操作指令 journal 与 SecretStore 只放在协调器私有的 +`MUTEKI_COORDINATOR_CONTROL_ROOT`(compose 默认为 +`$MUTEKI_HOST_DATA_ROOT/coordinator-control`)。该路径不会挂进 worker,也不会 +进入 worker workspace 的属主改写。每个 run 在 workspace 旁有独立的 sibling bootstrap +目录 `.muteki_rcp`,仅挂载到 worker 内的 `/run/muteki/control`,只携带反向连接的启动 token。 + ```bash # 1. 宿主 daemon 上要先有 worker 镜像。 # compose 会从当前 checkout 构建 web-api/ui,但不负责构建 worker。 @@ -314,7 +343,7 @@ MUTEKI_WEB_PASSWORD='choose-a-strong-one' \ 3. 运行环境推荐选择本地,如有特殊需求可以选择容器,容器会提醒你配置相关的凭据,这块请自行配置,你可以通过点击测模型来测试是否正确工作,测试方式会调用agent并让模型重复 ok。 ![image-20260624192439759](./assets/image-20260624192439759.png) 4. 接下来可以详细配置你的 worker情况,推荐按照图中的方式进行配置。 - 起始worker数量表示竞速阶段的数量,数量跟随着你的引擎数,会三个agent引擎同时进行,直至flag解出或者题目超时。用于解决简单题的快速抢血和快速解答。 + 起始worker数量表示启用 race-scout 时这一轮的数量,数量跟随着你启用的引擎数。用于解决简单题的快速抢血和快速解答。 最大worker数推荐保留5-6个左右,因为对于web题目来讲,过多的worker可能会造成ddos的情况。 ![image-20260624192517250](./assets/image-20260624192517250.png) 5. 推荐配置和测联通这块推理模型,更好的规划和把控题目节奏。 @@ -323,7 +352,7 @@ MUTEKI_WEB_PASSWORD='choose-a-strong-one' \ 7. 题目解题的推荐prompt方式如下: 1. 说明题目描述,题目类型,题目名称,网站地址,flag格式 2. 同时前端页面支持复制粘贴和上传文件,可直接进行附件题目进行上传。 - 3. 图中联网代表是否开始agent自身网络搜索功能,默认开启,关闭是用于数据测评。 + 3. 图中 Web 工具开关控制 Agent 自身的 WebSearch、WebFetch 和知识库,默认开启,关闭用于数据测评。Worker 的 shell 仍可访问网络。 4. 本地容器按钮不用管,这是跟设置功能一支,后续可以删除。高级中可以手工指定flag格式,和一些简单配置,可以忽略。 ![image-20260624193322483](./assets/image-20260624193322483.png) ![image-20260624193441654](./assets/image-20260624193441654.png) @@ -372,7 +401,7 @@ Muteki 在 **NYU CTF Bench** `test` 集(CSAW 2017–2023,共 200 题)上做了 | -------------------- | --------------------------------------------------------------------------------- | | `muteki/` | 核心:`swarm/`(协调器)、`solver/`(CLI driver、gate、控制平面)、`models/`、`platform/`、`sandbox/` | | `apps/web/` | FastAPI 后端(`server.py`)+ Next.js 操作者 UI(`ui/`) | -| `apps/tui/` | Textual TUI 指挥台 (未完工) | +| `apps/tui/` | Textual TUI 指挥台(`--swarm` 走直接 race;Coordinator / Settings 接入仍暂缓) | | `cmd/runtime-agent/` | 容器内的 Go supervisor(反向连接控制器) | | `docker/worker/` | worker 镜像(Dockerfile、构建脚本、工具感知地图) | | `scripts/` | eval / 回测 harness | @@ -427,7 +456,7 @@ sessions/ ## 测试 ```bash -uv run pytest # Python 套件(无 key 时 live 测试自动跳过) +uv run --extra dev python -m pytest -q # Python 套件,固定使用项目解释器 go test -C cmd/runtime-agent ./... # Go supervisor(module 在 cmd/runtime-agent/ 下) ( cd apps/web/ui && npx tsc --noEmit ) # UI 类型检查 ``` @@ -436,12 +465,11 @@ go test -C cmd/runtime-agent ./... # Go supervisor(module 在 cmd/runtim ## 后续 TODO -- [ ] 添加鉴权逻辑 -- [ ] 完整优化测试容器模式 -- [ ] 持续迭代升级webui体验 -- [ ] 更多agent worker类型支持,如pi、zai、opencode等。 -- [ ] TUI模式 -- [ ] 全自动爬ctf平台题目,自动解题,自动提交,自动生成报告功能。 +- [ ] 继续打磨容器模式 +- [ ] 持续迭代升级 web UI 体验 +- [ ] TUI 接入当前 Coordinator 与 Worker Settings(暂缓) +- [ ] 额外 Worker 引擎,例如 ZAI(暂缓) +- [ ] 全自动爬 CTF 平台题目,自动解题,自动提交,自动生成报告(暂缓) --- diff --git a/ROADMAP.md b/ROADMAP.md index 4943221..59d3e45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,136 +1,204 @@ -# Project Muteki — Iteration Roadmap - -> **Current status override (2026-06-16 CST):** this roadmap is retained as -> historical planning context. It predates the CLI-only executor, multi-flag work, -> current web deck, and code-review v7 remediation queue. Use `progress.md`, -> `session-handoff.md`, `feature_list.json`, and `docs/CODE_REVIEW_2026-06-15.md` -> for the active next step. - -> Synthesized 2026-05-30 from a 6-dimension parallel audit (gap-vs-design, missing-tracks, -> solve-rate levers, code-quality, eval-rigor, frontend/platform) + a synthesis pass. -> Every structural claim below was re-verified against the code before adoption. +# Project Muteki — Roadmap + +> Rewritten 2026-07-05 to reflect the current architecture. The previous roadmap +> predated the CLI-only executor, the multi-flag work, pentest mode, and the +> current web deck, so it was archived. For the authoritative "what this is" and +> "how to run it", see [`README.md`](README.md) and [`AGENTS.md`](AGENTS.md). + +> **Current execution scope (2026-08-17):** the operator-approved near-term TODO, +> deferred items, and recommended order are recorded in +> [`docs/current_iteration_todo.md`](docs/current_iteration_todo.md). Cognitive +> studies, new swarm-core mechanisms, managed OAuth, strict shell-level offline +> isolation, credential-projection narrowing, TUI/platform automation, and other +> public feature expansion are currently deferred. The sections below retain the +> architecture history and long-term evidence requirements; they do not by +> themselves authorize implementation. + +## Active architecture upgrade (foundation complete; cognitive research loop default-off) + +The durable objective and selection rule for future work is +[`MUTEKI_NORTH_STAR.md`](MUTEKI_NORTH_STAR.md). The goal is measurable cognition: +better thinking, search, learning, heterogeneous collaboration, local recovery and +hard long-chain reliability under the same complete budget. + +Protocol 2 is a restricted live-local canary: single profile, local worker, agent +web tools off, no knowledge base, and a finite budget. Ordinary Web runs stay on +Protocol 1. Expanding Protocol 2 is currently deferred. The canary is implemented +through the hardened `S4-E` foundation: V1 loop containment; canonical ports and +identities; atomic append-only command/event/fold/CAS; hard-gate receipts; +per-attempt admission, budget, UNKNOWN and supervision; archive/purge/tombstone; +enforced live-local egress; and branch-scoped progress/search admission. The +worktree-bound `run-76191` release chain recorded 19 canonical events and 16 +receipts, replayed to the same checksum/projection, and set this canary path's +`production_enabled=true`. That flag applies only to the restricted canary. +Protocol 1 runs are still drained rather than hot-switched. + +The existing provenance gate remains hardcoded and its accepted set does not widen. +Above that foundation, the default-off canonical chain now joins exact assignment and +materialization with a preregistered distinct reproduction, separately admitted and +accounted deterministic checker, and receipt-only verification resolver. The resolver +rebuilds lineage from the complete prefix; checker/resolver capabilities cannot cross, +and real `host_popen` evidence remains `HELD_UNKNOWN` while containment is unproven. A +pure bridge can project resolver-owned facts into belief and recommend one next +experiment. Exact selection binding is now implemented default-off: the same +transaction resolves the complete pre-admission prefix, reruns the planner and binds +the exact `next_assignment`. The resolver-fact inventory is store-owned; candidate, +future-cost and remaining-scalar-budget inputs remain frozen caller proposals and are +labelled as such. + +The Reason/coordinator bridge remains an offline adapter over frozen ordinary intents; +the production coordinator has no research import, switch or second annotation call. +The simple V1 planner is the current research baseline. V2 remains killed. V3's old +8/16 planning-family result is no longer interpretable as eight terminal failures: +independent enumeration found outcome-correct, cheaper paths rejected only for using a +different evaluator-authored role name. This correction does not promote V3. A new +bounded terminal-completion policy then entered development against outcome-equivalent +V1 and simple ablations, with byte-identical V1 fallback and deletion on no strict gain. +V4 and its reliability-tail V5 revision both failed that rule: V4 has eight +terminal-control tail losses. V5 produced no aggregate gain and retained two losses; +independent review then disproved its claimed exact dynamic program with a valid case +where removing candidates improved the alleged optimum. V5 policy/evaluator/tests were +deleted rather than repaired. This exposed bank is closed to further policy tuning. +Profile evidence is non-steering, supplied recovery claims are not +canonical evidence, and UNKNOWN is never an automatic retry. Any canary still requires +source-disjoint tasks, complete multi-axis ledger binding, paired hard/long-chain +results and a separate release decision. ## Where we are -P0 (event bus / cost ledger / session store), P1 (code-driven solver + persistent -subprocess kernel + provenance flag gate), P2 (FastAPI SSE/WS backend + Next.js/assistant-ui -deck + Textual TUI), P4 (swarm + Insight Bus, first-valid-flag), P6 (NYU black-box eval + -self-learning distill/retrieve) are **built and verified**. Real NYU result: **1/3 web solved -black-box, provenance gate held (no false flags)**. 114 tests green. - -P3 muteki_kit (the code-driven kernel's capability SDK) was **removed** — that -kernel is retired; the CLI-agent swarm uses its own in-container tools. The -crypto/reverse build-out below (I2/I3) is obsolete and kept only as a record. -P5 (L0 coordinator / CTFBridge / auto-submit) is **user-skipped**. - -## The dominant lever (exact data) - -NYU CTF Bench `test_dataset.json` = 200 challenges: - -| Track | Count | % of bench | SDK | -|-------|------:|-----------:|-----| -| crypto | 52 | 26.0% | ❌ | -| rev | 51 | 25.5% | ❌ | -| pwn | 39 | 19.5% | ❌ | -| misc | 24 | 12.0% | ❌ | -| **web** | **19** | **9.5%** | ✅ | -| forensics | 15 | 7.5% | ❌ | - -**The agent can currently attempt only 9.5% of the benchmark.** No amount of loop tuning -moves points-per-dollar-hour on challenges it cannot start. crypto + rev alone = 51.5%. - -## Verified structural gaps (not polish — load-bearing) - -1. **Hypothesis machinery is dormant.** `SolveGraph` has `add_hypothesis/set_status/ - active_hypotheses/mark_dead_end` (solve_graph.py:85-118) but `muteki/solver/` has **zero** - call sites — the §6.1 hypothesis-driven bounded search is aspirational; the graph only - accumulates flat evidence. -2. **No triage→track routing.** Solver hardcodes `from ...prompts.web import SYSTEM_PROMPT` - (solver.py:32). `triage` is imported into the kernel but never selects prompt/SDK by - category. Even when crypto/rev SDKs exist, nothing would dispatch to them. -3. **HITL is cosmetic.** `run.hitl` queue is enqueued (run_manager.py) but **no consumer** — - the solver drains only the *insight* inbox, never HITL. Human hints go into the void. -4. **No context compaction / CONTEXT_STATE in the real solver.** Only the mock emits it; the - real loop never monitors token pressure or compacts (§6.3). -5. **Loop guard is crude** — 85%-similarity-over-3-turns on raw code; fires false-positives on - legitimate same-endpoint-different-technique retries (the exact no-pass-needed failure mode). -6. **Eval fixed at 60s** (eval.py:96) — systematically fails crypto/rev where factoring/angr - need minutes. -7. **`InsightBus.history` unbounded** (insight_bus.py:53); terminal WS replays from seq 0. -8. **Rejected flags not logged to an audit artifact** (solver.py:237) — silently noted only. - ---- - -## Iterations (ordered by points-per-dollar-hour × unblocking value) - -### I1 — Web loop sharpening + hypothesis activation *(medium)* -Close the 1/3 gap on the track that already works, before widening. -- Expand web prompt HYPOTHESIZE into an explicit **attack-class taxonomy** (SQLi subclasses, - NoSQL operators, auth bypass, SSTI, traversal) + "if a class fails, switch CLASS not payload". -- Wire `graph.add_hypothesis` / `set_status(REFUTED)` / `dead_ends` into the solver loop; emit - `SOLVE_GRAPH_DELTA` for proposals + status. -- Refactor loop guard to key on `(target, technique-class)` not raw 85% code text. -- Raise web `max_steps` 12→20; add a peek-usage hint to the web prompt. -- **Accept:** ≥2/3 on the 3-web NYU subset (no provenance regressions), OR unit tests proving - hypotheses populate, a REFUTED hypothesis lands in `dead_ends`/`to_summary()`, and the guard - passes sqli-union→sqli-blind but blocks a true triple-repeat. - -### I2 — Crypto track SDK (RSA-first) + per-track eval subset *(OBSOLETE — muteki_kit removed; CLI agents use their own crypto tools)* -Crypto = 26% of the bench, highest automation ceiling, lightweight deps (gmpy2/sympy). -- `muteki_kit/crypto/{rsa,classical,symmetric}.py`, typed Pydantic results + artifact peek. -- `RSABreaker(n,e,c).auto()`: small-e, common-modulus, Wiener, factordb, RsaCtfTool fallback. -- `requirements-crypto.txt` + `_check_deps()` probe; `prompts/crypto.py`. -- **Accept:** `test_kit_crypto.py` recovers plaintext on ≥3 classic vuln param sets (pure-math - CI); crypto NYU subset solves ≥1 real challenge black-box, provenance intact. - -### I3 — Reverse track SDK (decompile + disasm + best-effort symexec) *(OBSOLETE — muteki_kit removed; CLI agents use their own RE tools)* -Rev = 25.5% of the bench. Heavier deps (Ghidra/pyghidra/angr) → ranked after crypto. -- `muteki_kit/reverse/{decompile,disasm,symexec}.py`; **always** route pseudocode through - `save_artifact`+peek (never inline a 10KB decompilation → hallucination risk). -- `requirements-reverse.txt` + deps probe; `prompts/reverse.py` (decompile→peek-by-function). -- **Accept:** disasm a committed ELF + artifact roundtrip in CI; rev NYU subset solves ≥1 real - crackme black-box with decompilation never inlined into message history. - -### I4 — Forensics + misc SDK + triage→track routing *(large; capstone that activates dispatch)* -forensics 7.5% + misc 12%; lower frequency but high-automation easy points. The triage→track -routing glue (currently absent) belongs here and **retroactively activates I2/I3 dispatch**. -- forensics: stego/LSB, PCAP reconstruction, binwalk carving, exif. misc: QR, esolang, spectrogram. -- `TRACK_SDK_MAP` + `get_sdk_for_track(category)` + category→prompt selector in `solver.run()`. -- **Accept:** all four SDKs importable; routing unit-tested (crypto/rev/forensics triage loads - the matching prompt, not web); ≥1 stego/PCAP and ≥1 QR/encoding challenge solved black-box. - -### I5 — Eval CI gate + ablation + per-track timeout policy *(large; locks in the gains)* -By I5 all six tracks are attemptable; make it regression-proof. -- Two-tier eval: FAST mock-solver gate (every PR — proves provenance gate rejects hallucinated - flags, no flag leakage, grading tolerance) + SLOW weekly black-box subset with a stored - baseline that alerts on solve-drop/cost-spike. -- `AblationConfig` (swarm/insight/loop-guard on/off) → finally answer "is the swarm worth it?". -- `ChallengeTimeoutPolicy` per track (crypto.rsa 180s, reverse 300s, web 60s). -- Log rejected/hallucinated flags to an audit artifact. -- **Accept:** FAST gate green in CI and fails on a deliberately-broken-provenance branch; - baseline file + delta check exist; ablation produces a single-solver-vs-swarm number. - ---- - -## Quick wins (do immediately, before/within I1) -- Attack-class taxonomy prompt edit (cheapest solve-rate bump; ship before rest of I1). -- Multi-step-flag `print()` example in the web prompt discipline section. -- On kernel timeout, append directed next-step guidance to the condensed Result. -- Cap `InsightBus.history` to ~1000; bound terminal WS replay to a recent window. - -## Deferred (explicitly NOT next — and why) -- **P5 coordinator / CTFBridge / auto-submit** — pure orchestration overhead until >1 track - solves. Pull forward only when a live CTFd/rCTF contest is concretely scheduled. -- **microVM/Firecracker sandbox** — threat-model concern, not a solve-rate lever; local - subprocess is fine for the trusted NYU env. Document as a known limitation. -- **EIG/entropy hypothesis reranking** — over-engineering vs a good static+RAG taxonomy (I1). -- **Cybench second benchmark** — add only after all six NYU tracks solve (generalization check). -- **Frontend polish** (full Next build, xterm PTY, replay UI) — doesn't move solve-rate. -- **HITL consumption wiring** — genuinely half-built, but a force-multiplier on working tracks, - not a track-unlocker; fold into a later iteration once multi-track creates demand for steering. - -## Open call to revisit -The synthesizer ranked forensics (15) above pwn (39) on automation-ceiling grounds (pwn binary -exploitation is fragile to automate). By raw bench frequency pwn is 2.6× forensics — if pwn -automation proves tractable (pwntools + a ret2libc/ret2win template), it may deserve to jump -ahead of I4. Decide after I2/I3 land and we see how the typed-result pattern holds for binaries. +Muteki is a heterogeneous, multi-model **CTF-solving agent swarm**. The current +architecture is built and verified: + +- **Worker executor = a shelled full-model CLI agent.** Each worker is a real + Claude, Codex, Cursor, Pi, OMP, Kimi, Grok, OpenCode, or DeepSeek Harness + process running its own agentic shell loop; + Muteki orchestrates these CLIs rather than re-implementing a model loop + (`muteki/solver/cli_driver.py`, `muteki/solver/cli_solver.py`). +- **Shared, event-sourced evidence graph.** The swarm collaborates on one + append-only blackboard (`muteki/swarm/shared_graph.py`); workers read/write it + through the `muteki-blackboard` skill. An independent **Reason phase** + (`muteki/solver/reason.py`) plans typed intents from the graph. +- **Provenance gate.** A flag is accepted only when it appears verbatim in real + execution output (`muteki/solver/gate.py` + anti-laundering checks in + `cli_solver.py`). Zero false flags is the correctness bar. +- **Web uses the coordinator by default.** The web path enables the coordinator + and may run an initial race-scout round. The explicit TUI `--swarm` path uses a + direct race. Callers that construct `Swarm` directly must select the intended + mode explicitly. +- **Multi-flag.** `Challenge.expected_flags` (default 1). `expected_flags=1` is + byte-identical to "first flag wins"; only `>1` engages the multi-flag paths. +- **Engagement modes.** `mode="ctf"` (recover a flag; completion = the provenance + gate) and `mode="pentest"` (operator-defined goal + scope; completion is + goal-driven, findings kept honest by the same witness gate). +- **Frontends are dumb bus subscribers.** `apps/web/` (FastAPI SSE/WS backend + + Next.js command deck) and `apps/tui/` (Textual) render the event stream and + never call the solver core directly. +- **Runtime backends.** Workers run as host CLIs (`local`) or as sibling + containers driven by an in-container Go supervisor over a reverse connection + (`container` / RCP; `cmd/runtime-agent/`, `muteki/solver/container_exec.py`). + +**Evaluation.** Full NYU CTF Bench `test` set (200 challenges): 200/200 solved in +the capability evaluation. See [`eval_nyu/_reports/`](eval_nyu/_reports/) and the +Evaluation section of the README. Treat this as a capability snapshot, not a +leaderboard verdict. + +## Recorded cognitive direction (currently deferred) + +The route is sequential, capability-first, and falsifiable: + +1. **Done — canonical assignment/structural terminal:** exact cognitive assignment, + structural execution observation, composite capability, atomic store commit, + replay and UNKNOWN/no-redispatch are implemented. Structural execution is not a + verified partition and cannot update belief. +2. **Done — exact runtime materialization:** one admitted experiment travels through + the same ContextPacket, prompt CAS and host launch. The resolver reports + `HOST_LAUNCH_ONLY`; planner selection and child/provider consumption stay false. +3. **Done — independent verification mechanics:** exact source/reproduction lineage, + deterministic checker accounting and resolver-owned fact writing are implemented. + UNKNOWN/disagreement/ineligible/uncontained evidence cannot learn or redispatch. +4. **Done — belief recommendation controls initial admission:** rerun the pinned canonical + planner under the pre-admission store transaction and require full equality between + its `next_assignment` and the exact admitted assignment. Merely H5-eligible + substitution, stale prefix, omitted fact, changed cost/budget or reused selection + must roll back atomically. +5. **Done default-off — distinct-experiment UNKNOWN recovery:** preserve every historical hold, + cost and attempted-program fingerprint, but allow the recommendation layer to choose + one affordable positive-distinction typed program that is not the held program or an + ID/version alias. A versioned atomic transaction now reconstructs the canonical + prefix and admits exactly that selected continuation under a new ContextPacket. This + is a new experiment, never an automatic redispatch; the old hold is not erased. + Mixed or contradictory holds remain blocked. A later supersession receipt is + deferred until an exact independently verified successor identity actually exists. +6. **Done negative shadow policy round — reliability before mean score:** V4's exact + terminal-completion search beats all controls in aggregate on its exposed bank but + loses eight changed-action nuisance-orbit members to terminal-greedy, so V4 is not + selectable. V5's public-only tail-risk objective ties V4 at `101/160`, has an even + `8/8` changed-comparison split against V4, and still loses two changed-action orbit + members to terminal-greedy. Its one-best-child memoization also violates optimal + substructure under ancestor `max` objectives, so V5 is killed and deleted; no + holdout is opened. Exact-score ties + are now invariant to experiment/hypothesis/partition labels; indistinguishable causal + representations yield a no-assignment `TIE_REQUIRES_DIVERSITY` state. +7. **Required evidence — source-disjoint offline capability study:** freeze the + production coordinator baseline and the simplest surviving cognitive arm under a + paired complete multi-axis budget, fresh holdout, hard/long-chain subgroups, + confidence intervals, fault injection and leave-one-feature-out ablations. Reuse the + existing evaluator plane; synthetic scores cannot substitute for this study. +8. **Only after a passing study — separate canary decision:** an operator and + worktree-bound release receipt decide whether a proven recommender may influence + admission. A study reducer cannot enable production. +9. **One optional evolution candidate at a time:** memory, heterogeneous router, + diversity portfolio, dynamic DAG, workflow optimizer or protocol adapter enters + shadow independently and is deleted when its paired interval or ablation fails. + +Unrelated process-mechanics expansion, framework accumulation, and added agent count +are not on the active route. + +## Recorded product backlog and disposition + +The detailed current status is maintained in +[`docs/current_iteration_todo.md`](docs/current_iteration_todo.md). The previous +product backlog is retained here with its current disposition: + +1. **Candidate, requires product-priority confirmation — Pentest full engagement.** + `mode="pentest"` shares the swarm, + blackboard, and provenance gate with CTF, but the Explore / Review / report + paths and scope handling are still CTF-shaped. Bring them to a full + engagement flow: goal/scope-aware exploration and review, a findings report + (attack path + per-finding impact/repro/evidence/remediation + severity), a + scope authorization guard, and structured engagement artifacts derived from + the existing event graph. +2. **Done — Operator intervention and observability control plane.** Typed + operator commands, scoped delivery, pause/resume/stop, worker control and + auditable effect state are implemented. Further UI acceptance belongs to + ordinary product verification rather than a swarm-core redesign. +3. **Done — `swarm.py` and `shared_graph.py` responsibility split.** Further + module cleanup should happen only alongside an approved feature or a + reproduced problem. + +Authentication and nine-engine support are implemented. The remaining public +feature expansion, including TUI integration and generic CTF-platform automation, +is currently deferred; see the current iteration document for the exact scope. + +## Invariants (never traded away) + +- **Provenance is sacred.** A flag/finding is valid only if it appears in real + stdout/stderr/artifact output. `_flag_ok` / the provenance gate is never + weakened to make a test or eval pass. +- **Flag acceptance stays a hardcoded, separate gate** — never a pluggable + verifier. +- **The evidence graph is append-only** — event-sourced, never overwritten in + place. +- **Don't touch the substrate** unless explicitly asked: the event spine, + provenance gate, first-valid-flag race, cost ledger, and shared evidence graph. +- **Capability eval is offline** (deny WebSearch/WebFetch) so a solve-rate run + can't be contaminated by a challenge writeup; a real competition keeps the web + on. + +## Explicitly not planned + +- A pluggable / configurable flag verifier (the gate stays hardcoded). +- microVM/Firecracker-style sandboxing — a threat-model concern, not a solve-rate + lever; documented as a known limitation of the trusted-environment model. +- A second benchmark harness as a headline goal — revisit only as a + generalization check, not a near-term deliverable. diff --git a/apps/tui/app.py b/apps/tui/app.py index c8fb0b4..0b6cf04 100644 --- a/apps/tui/app.py +++ b/apps/tui/app.py @@ -36,6 +36,8 @@ def format_event(ev: Event) -> Optional[str]: return f"[b]▶ RUN[/b] {ch.get('name','?')} [{ch.get('category','?')}]" if et is EventType.REASONING_DELTA: t = p.get("text", "").strip() + if p.get("turn_end") and not t: + return None return f"[dim]{sid} ⋯ {t}[/dim]" if t else None if et is EventType.TOOL_CALL_START: return f"[cyan]{sid} ⚙ {p.get('tool','?')}[/cyan]" diff --git a/apps/web/account_test.py b/apps/web/account_test.py index da75e91..e0a96c8 100644 --- a/apps/web/account_test.py +++ b/apps/web/account_test.py @@ -25,6 +25,8 @@ import subprocess from pathlib import Path from typing import Any, Optional +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen from muteki.solver.credential_accounts import ( CONTAINER_ACCOUNTS_ROOT, @@ -36,6 +38,12 @@ "claude": "claude", "codex": "codex", "cursor": "/home/kali/.local/bin/cursor-agent", + "pi": "pi", + "omp": "/home/kali/.local/bin/omp", + "kimi": "kimi", + "grok": "/home/kali/.grok/bin/grok", + "opencode": "opencode", + "dsh": "python3", } @@ -145,27 +153,35 @@ def _probe_endpoint_account(*, account_id: str, acct: Any, root: Path) -> dict[s # build the right wire request for the target engine. if target == "claude": url = f"{base_url}/v1/messages" - headers = ["-H", f"x-api-key: {api_key}", "-H", "anthropic-version: 2023-06-01", - "-H", "Content-Type: application/json"] + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + } body = json.dumps({"model": "probe", "max_tokens": 1, "messages": [{"role": "user", "content": "ok"}]}) else: url = f"{base_url}/chat/completions" - headers = ["-H", f"Authorization: Bearer {api_key}", "-H", "Content-Type: application/json"] + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } body = json.dumps({"model": "probe", "max_tokens": 1, "messages": [{"role": "user", "content": "ok"}]}) - # -w writes the HTTP status on its own line so we can classify auth vs other. - argv = ["curl", "-sS", "-m", "20", "-o", "/dev/null", "-w", "%{http_code}", - "-X", "POST", *headers, "--data", body, url] + request = Request( + url, data=body.encode("utf-8"), headers=headers, method="POST") try: - r = subprocess.run(argv, capture_output=True, text=True, - encoding="utf-8", errors="replace", timeout=25) - except FileNotFoundError: - return _result(False, "curl 不可用", layer="auth") - except subprocess.TimeoutExpired: + with urlopen(request, timeout=20) as response: # noqa: S310 + code = str(int(response.getcode() or 0)) + response.read(4096) + except HTTPError as exc: + code = str(int(exc.code or 0)) + except (TimeoutError, URLError) as exc: + if isinstance(exc, URLError) and not isinstance( + getattr(exc, "reason", None), TimeoutError): + return _result(False, f"端点探测失败: {str(exc.reason)[:80]}", layer="auth") return _result(False, "端点探测超时(>20s)", layer="auth") - code = (r.stdout or "").strip()[-3:] # 200 → key authenticates. 400/422 → endpoint reached + key OK, just our dummy # "probe" model/body was rejected — that still PROVES auth+reachability, which is # all an account test asserts. 401/403 → bad key. 404 → wrong endpoint path. @@ -175,9 +191,7 @@ def _probe_endpoint_account(*, account_id: str, acct: Any, root: Path) -> dict[s return _result(False, f"端点拒绝凭据(HTTP {code},key 可能无效)", layer="auth") if code == "404": return _result(False, f"端点路径不存在(HTTP 404,base_url 或目标引擎不匹配)", layer="auth") - tail = (r.stderr or "").strip().splitlines() - detail = f"端点探测失败(HTTP {code or '?'})" + (f": {tail[-1][:80]}" if tail else "") - return _result(False, detail, layer="auth") + return _result(False, f"端点探测失败(HTTP {code or '?'})", layer="auth") def _docker(*args: str, timeout: float = 30.0) -> subprocess.CompletedProcess: @@ -240,6 +254,10 @@ def _probe_container(*, engine: str, account_id: str, root: Path) -> dict[str, A return _result(False, f"凭据投影失败: {str(exc)[:120]}", layer="mount") bin_path = _CONTAINER_BIN.get(engine, engine) + version_cmd = ( + "python3 /opt/muteki/deepseek_harness_worker.py --version" + if engine == "dsh" else f"{bin_path} --version" + ) # in-container probe: the credential file must be READABLE at the mount # path (catches #15 uid-mismatch) AND the engine binary must launch # (--version is the cheap liveness check; a full authed turn would spend @@ -247,7 +265,7 @@ def _probe_container(*, engine: str, account_id: str, root: Path) -> dict[str, A cred_path = f"{CONTAINER_ACCOUNTS_ROOT}/{account_id}" script = ( f"test -r {cred_path} || {{ echo MUTEKI_MOUNT_UNREADABLE; exit 71; }}; " - f"{bin_path} --version >/dev/null 2>&1 || {{ echo MUTEKI_CLI_FAIL; exit 72; }}; " + f"{version_cmd} >/dev/null 2>&1 || {{ echo MUTEKI_CLI_FAIL; exit 72; }}; " "echo MUTEKI_OK" ) run_cmd = [ diff --git a/apps/web/auth.py b/apps/web/auth.py index fcd76c3..9a4caf3 100644 --- a/apps/web/auth.py +++ b/apps/web/auth.py @@ -59,7 +59,7 @@ # Paths under the gate that must stay reachable WITHOUT a token, otherwise the # operator could never obtain one. Everything else under /api requires auth. # /api/auth/login — exchange password for a token (you have no token yet) -# /api/health — liveness probe (does not exist today, harmless to allow) +# /api/health — liveness probe used by compose and load balancers # NOTE: /api/auth/ticket and /api/auth/me are deliberately NOT public — minting a # ticket or reading identity both require an already-valid token. PUBLIC_API_PATHS = frozenset({"/api/auth/login", "/api/health"}) diff --git a/apps/web/control_adapter.py b/apps/web/control_adapter.py new file mode 100644 index 0000000..ddbbc25 --- /dev/null +++ b/apps/web/control_adapter.py @@ -0,0 +1,567 @@ +"""Web boundary for the durable operator control plane. + +The FastAPI/RunManager layer owns admission and audit, while the existing swarm +continues to consume plain dictionaries from ``run.hitl``. ``QueueControlPort`` +is the deliberately small bridge between those worlds. A queue write is only +routing; the port reports an observed effect only after the coordinator resolves +the per-command acknowledgement future. +""" + +from __future__ import annotations + +import asyncio +import inspect +import re +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import Any, Optional + +from muteki.control import ( + ApplyResult, + ControlAction, + ControlCommand, + ControlScope, + EffectReceipt, + EffectState, + IdempotencyConflict, + RunControlState, + WorkerRef, +) +from muteki.control.secrets import SecretStore, SecretStoreError +from muteki.core.events import control_command_payload + + +_RESERVED_BODY_KEYS = { + "action", "target", "scope", "payload", "command_id", + "expected_generation", "deadline_at", +} +_SENSITIVE_KEY = re.compile( + r"(?:password|passwd|token|secret|credential|api[_-]?key|private[_-]?key|" + r"密码|凭证|令牌|密钥)", + re.IGNORECASE, +) +_SENSITIVE_TEXT = re.compile( + r"(?:\b(?:password|passwd|token|secret|credential|api[ _-]?key|private[ _-]?key)" + r"\b|密码|凭证|令牌|密钥)\s*(?::|=|is\s+|是\s*)?\S+", + re.IGNORECASE, +) +_URL_USERINFO = re.compile(r"^[a-z][a-z0-9+.-]*://[^/@\s]+:[^/@\s]+@", re.IGNORECASE) + + +class ControlPayloadError(ValueError): + """A clean client error while compiling a wire request.""" + + +class _RetrySecretStore: + """Reuse refs by canonical payload path, independent of JSON key order.""" + + def __init__(self, base: SecretStore, command: ControlCommand) -> None: + self.base = base + self.command_id = command.command_id + self.refs_by_path: dict[tuple[str, ...], str] = {} + + def _walk(value: Any, path: tuple[str, ...] = ()) -> None: + if isinstance(value, Mapping): + for key, child in value.items(): + skey = str(key) + if not path and skey in {"secret_refs", "redacted"}: + continue + _walk(child, (*path, skey)) + elif isinstance(value, list): + for index, child in enumerate(value): + _walk(child, (*path, str(index))) + elif isinstance(value, str) and value.startswith("secret://"): + self.refs_by_path[path] = value + + _walk(command.payload) + + def put(self, value: str) -> str: + # Compatibility for callers without a path. A retry with multiple secret + # fields is intentionally rejected rather than positionally guessing. + if len(self.refs_by_path) != 1: + raise IdempotencyConflict( + f"command_id {self.command_id!r} was reused with different secret fields") + return self._reuse(value, next(iter(self.refs_by_path.values()))) + + def put_for_path(self, value: str, path: tuple[str, ...]) -> str: + reference = self.refs_by_path.get(tuple(path)) + if not reference: + raise IdempotencyConflict( + f"command_id {self.command_id!r} was reused with different secret fields") + return self._reuse(value, reference) + + def _reuse(self, value: str, reference: str) -> str: + try: + prior = self.base.resolve(reference) + except Exception as exc: + raise IdempotencyConflict( + f"command_id {self.command_id!r} references unavailable secret material") from exc + if prior != value: + raise IdempotencyConflict( + f"command_id {self.command_id!r} was reused with different content") + return reference + + def get(self, reference: str) -> Any: + return self.base.get(reference) + + +class _StagedSecretStore: + """Roll back newly-created secret files if command validation fails. + + SecretStore publication is intentionally atomic, but compiling a command is a + larger transaction: Pydantic/CAS fields are validated after payload traversal. + Without this small staging owner, an invalid command could leave unreachable + secret files behind even though no journal command existed. + """ + + def __init__(self, base: SecretStore) -> None: + self.base = base + self.created: list[str] = [] + + def put(self, value: str) -> str: + reference = self.base.put(value) + self.created.append(reference) + return reference + + def put_for_path(self, value: str, _path: tuple[str, ...]) -> str: + return self.put(value) + + def get(self, reference: str) -> Any: + return self.base.get(reference) + + def rollback(self) -> None: + for reference in reversed(self.created): + try: + self.base.delete(reference) + except SecretStoreError: + pass + self.created.clear() + + +def _looks_sensitive_text(value: str) -> bool: + return bool(_SENSITIVE_TEXT.search(value)) + + +def _put_secret(secrets: Any, value: str, path: tuple[str, ...]) -> str: + by_path = getattr(secrets, "put_for_path", None) + if callable(by_path): + return str(by_path(value, path)) + return str(secrets.put(value)) + + +def _validate_secret_reference(secrets: Any, reference: str) -> str: + getter = getattr(secrets, "get", None) + if not callable(getter): + base = getattr(secrets, "base", None) + getter = getattr(base, "get", None) + if not callable(getter): + raise ControlPayloadError("secret reference store is unavailable") + try: + metadata = getter(reference) + except SecretStoreError as exc: + raise ControlPayloadError("unknown or invalid secret reference") from exc + canonical = str(getattr(metadata, "reference", reference) or reference) + if canonical != reference: + raise ControlPayloadError("non-canonical secret reference") + return canonical + + +def _redact_value(value: Any, *, key: str, secrets: SecretStore, + references: list[str], path: tuple[str, ...] = ()) -> Any: + if isinstance(value, Mapping): + return { + str(k): _redact_value(v, key=str(k), secrets=secrets, + references=references, + path=(*path, str(k))) + for k, v in value.items() + } + if isinstance(value, list): + return [ + _redact_value(v, key=key, secrets=secrets, references=references, + path=(*path, str(index))) + for index, v in enumerate(value) + ] + if not isinstance(value, str): + return value + if value.startswith("secret://"): + reference = _validate_secret_reference(secrets, value) + references.append(reference) + return reference + sensitive = ( + bool(_SENSITIVE_KEY.search(key)) + or _looks_sensitive_text(value) + or bool(_URL_USERINFO.search(value)) + ) + if not sensitive: + return value + reference = _put_secret(secrets, value, path) + references.append(reference) + return reference + + +def secure_payload(payload: Mapping[str, Any], *, secrets: SecretStore, + force_text_secret: bool = False) -> dict[str, Any]: + """Replace operator secrets with run-local opaque references before journaling. + + The original value exists only inside ``SecretStore``. The returned mapping is + safe to place in SQLite and event payloads. + """ + references: list[str] = [] + secured: dict[str, Any] = {} + for key, value in payload.items(): + skey = str(key) + if skey == "context" and isinstance(value, Mapping): + context = dict(value) + content = context.get("content") + if (str(context.get("kind") or "").lower() == "secret_ref" + and isinstance(content, str) + and content and not content.startswith("secret://")): + reference = _put_secret(secrets, content, (skey, "content")) + references.append(reference) + context["content"] = reference + secured[skey] = _redact_value( + context, key=skey, secrets=secrets, references=references, + path=(skey,)) + continue + if (force_text_secret and skey in {"text", "hint", "answer"} + and isinstance(value, str) and value + and not value.startswith("secret://")): + reference = _put_secret(secrets, value, (skey,)) + references.append(reference) + secured[skey] = reference + else: + secured[skey] = _redact_value( + value, key=skey, secrets=secrets, references=references, + path=(skey,)) + if references: + # Semantic payload hashes must not depend on object insertion order. + secured["secret_refs"] = sorted(set(references)) + secured["redacted"] = True + return secured + + +def compile_control_command( + run_id: str, + body: Mapping[str, Any], + *, + secrets: SecretStore, + existing_command: Optional[ControlCommand] = None, +) -> ControlCommand: + """Compile the typed endpoint and legacy flat HITL shape into one command.""" + raw_payload = body.get("payload") or {} + if not isinstance(raw_payload, Mapping): + raise ControlPayloadError("payload must be a JSON object") + payload = dict(raw_payload) + # Legacy /hitl callers put text/url/request_id/standing/etc. at the top level. + for key, value in body.items(): + if key not in _RESERVED_BODY_KEYS: + payload.setdefault(str(key), value) + + raw_action = str(body.get("action") or "hint").strip().lower() + request_id = str(payload.get("request_id") or "").strip() + if raw_action in {"answer", "submit"} and request_id: + raw_action = ControlAction.ANSWER_DECISION.value + elif raw_action == "reject": + raw_action = ControlAction.DISMISS.value + try: + action = ControlAction(raw_action) + except ValueError as exc: + raise ControlPayloadError(f"unsupported control action: {raw_action}") from exc + + try: + scope = ControlScope.parse(body.get("scope", body.get("target", "global"))) + except (TypeError, ValueError) as exc: + raise ControlPayloadError(str(exc)) from exc + + staged: Optional[_StagedSecretStore] = None + secret_writer: Any + if existing_command is not None: + secret_writer = _RetrySecretStore(secrets, existing_command) + else: + staged = _StagedSecretStore(secrets) + secret_writer = staged + try: + secured = secure_payload( + payload, + secrets=secret_writer, + force_text_secret=action is ControlAction.ANSWER_DECISION, + ) + values: dict[str, Any] = { + "run_id": run_id, + "action": action, + "scope": scope, + "payload": secured, + } + if body.get("command_id") is not None: + values["command_id"] = body.get("command_id") + if body.get("expected_generation") is not None: + values["expected_generation"] = body.get("expected_generation") + if body.get("deadline_at") is not None: + values["deadline_at"] = body.get("deadline_at") + return ControlCommand.model_validate(values) + except Exception: + if staged is not None: + staged.rollback() + raise + + +def safe_hitl_echo(command: ControlCommand, *, status: str) -> dict[str, Any]: + """Small, non-secret operator echo for the conversation event stream.""" + payload = command.payload + result: dict[str, Any] = { + "target": command.scope.as_legacy_target(), + "action": command.action.value, + "command_id": command.command_id, + "status": status, + } + request_id = payload.get("request_id") + if request_id: + result["request_id"] = str(request_id) + if payload.get("redacted"): + result["text"] = "[redacted operator secret]" + refs = payload.get("secret_refs") or [] + if refs: + result["secret_ref"] = str(refs[0]) + else: + text = payload.get("text") or payload.get("hint") + if text: + result["text"] = str(text)[:2000] + url = payload.get("url") or payload.get("target_url") + if url: + result["url"] = str(url)[:2000] + return result + + +def _effect_kind(command: ControlCommand, receipt: EffectReceipt) -> str: + runtime_effect = str(receipt.metadata.get("effect") or "").lower() + aliases = { + "graceful_drain": "run_quiesced", + "termination_requested": "run_terminated", + "standby_cancelled": "run_terminated", + } + runtime_effect = aliases.get(runtime_effect, runtime_effect) + authoritative = { + "run_quiesced", "run_resumed", "run_frozen", "run_thawed", + "workers_frozen", "workers_thawed", "run_terminated", + } + if runtime_effect in authoritative: + return runtime_effect + run_wide = command.scope.kind.value in {"global", "run", "challenge"} + return { + ControlAction.PAUSE: "run_quiesced", + ControlAction.FREEZE: "run_frozen" if run_wide else "workers_frozen", + ControlAction.RESUME: "run_resumed", + ControlAction.THAW: "run_thawed" if run_wide else "workers_thawed", + ControlAction.GRACEFUL_DRAIN: "run_quiesced", + ControlAction.STOP: "run_terminated", + ControlAction.COMPLETE: "run_terminated", + }.get(command.action, "command_applied") + + +def effect_event_payload(command: ControlCommand, + receipt: EffectReceipt) -> dict[str, Any]: + effect: Optional[dict[str, Any]] = None + if receipt.state is EffectState.EFFECT_OBSERVED: + effect = { + "kind": _effect_kind(command, receipt), + "targets": list(receipt.target_ids), + } + request_id = (receipt.metadata.get("request_id") + or command.payload.get("request_id")) + detail = str(receipt.detail or "") + if command.payload.get("redacted") or _looks_sensitive_text(detail): + detail = "[redacted control detail]" + return control_command_payload( + command.command_id, + command.action.value, + target=command.scope.as_legacy_target(), + status=receipt.state.value, + request_id=str(request_id) if request_id else None, + effect=effect, + detail=detail, + generation=receipt.observed_generation, + target_ids=list(receipt.target_ids), + code=str(receipt.metadata.get("code") or ""), + receipt_id=receipt.receipt_id, + decision_closed=bool(receipt.metadata.get("decision_closed", False)), + decision_status=str(receipt.metadata.get("decision_status") or ""), + ) + + +def safe_receipt_detail(command: ControlCommand, detail: Any) -> str: + value = str(detail or "") + if command.payload.get("redacted") or _looks_sensitive_text(value): + return "[redacted control detail]" + return value[:32768] + + +def materialize_runtime_secrets(value: Any, *, secrets: SecretStore) -> Any: + """Resolve opaque references for an ephemeral runtime envelope only.""" + if isinstance(value, Mapping): + return {str(k): materialize_runtime_secrets(v, secrets=secrets) + for k, v in value.items()} + if isinstance(value, list): + return [materialize_runtime_secrets(v, secrets=secrets) for v in value] + if isinstance(value, str) and value.startswith("secret://"): + return secrets.resolve(value) + return value + + +def control_paths(coordinator_root: str | Path) -> tuple[Path, Path]: + """Return journal/SecretStore paths below a coordinator-private run root. + + The caller owns the trust boundary: this root must not be the worker workspace + (or any of its descendants). ``RunManager.coordinator_control_dir`` enforces + that invariant before this pure path helper is used. + """ + root = Path(coordinator_root) + return root / "control.db", root / "secrets" + + +def _coerce_apply_result(value: Any, *, targets: Sequence[WorkerRef]) -> ApplyResult: + target_ids = [target.worker_id for target in targets] + if isinstance(value, ApplyResult): + return value + if isinstance(value, Mapping): + data = dict(value) + data.setdefault("target_ids", target_ids) + return ApplyResult.model_validate(data) + if value is True: + return ApplyResult( + state=EffectState.EFFECT_OBSERVED, + detail="coordinator acknowledged command effect", + target_ids=target_ids, + ) + return ApplyResult( + state=EffectState.UNKNOWN, + detail="coordinator acknowledgement did not prove an effect", + target_ids=target_ids, + ) + + +class QueueControlPort: + """Deliver a command to the existing coordinator queue with a real ACK fence.""" + + def __init__( + self, + *, + inbox: "asyncio.Queue[dict[str, Any]]", + is_live: Callable[[], bool], + ack_timeout: float = 2.0, + claim_timeout: Optional[float] = None, + standby_actions: Sequence[str] = (), + on_standby: Optional[Callable[[dict[str, Any]], Any]] = None, + ) -> None: + self.inbox = inbox + self.is_live = is_live + self.ack_timeout = max(0.01, float(ack_timeout)) + self.claim_timeout = max( + self.ack_timeout, + float(claim_timeout) if claim_timeout is not None + else max(30.0, self.ack_timeout * 5.0), + ) + self.standby_actions = frozenset(standby_actions) + self.on_standby = on_standby + + @staticmethod + def wire_command(command: ControlCommand) -> dict[str, Any]: + return { + "target": command.scope.as_legacy_target(), + "action": command.action.value, + **dict(command.payload), + "command_id": command.command_id, + } + + async def apply( + self, + command: ControlCommand, + targets: Sequence[WorkerRef], + desired: RunControlState, + ) -> ApplyResult: + del desired # the queue consumer applies the desired transition + wire = self.wire_command(command) + if not self.is_live(): + if self.on_standby is not None: + standby_result = self.on_standby(wire) + if inspect.isawaitable(standby_result): + standby_result = await standby_result + if standby_result is not None: + return _coerce_apply_result(standby_result, targets=targets) + return ApplyResult( + state=EffectState.UNKNOWN, + detail="no live coordinator accepted the command", + target_ids=[], + ) + + loop = asyncio.get_running_loop() + acknowledgement: "asyncio.Future[Any]" = loop.create_future() + wire["_control_ack"] = acknowledgement + wire["_control_deadline"] = loop.time() + self.ack_timeout + wire["_control_started"] = False + await self.inbox.put(wire) + try: + value = await asyncio.wait_for( + asyncio.shield(acknowledgement), timeout=self.ack_timeout) + except asyncio.TimeoutError: + # Request cancellation before declaring UNKNOWN. If the envelope is + # still queued, remove it and balance Queue.join bookkeeping. Once the + # coordinator has dequeued/claimed it we must wait for its terminal ACK: + # returning UNKNOWN on a wall-clock timeout would let the real effect + # execute later and permanently fork journal state from runtime state. + wire["_control_cancel_requested"] = True + removed = False + try: + pending = getattr(self.inbox, "_queue") + pending.remove(wire) + self.inbox.task_done() + removed = True + except (AttributeError, ValueError): + pass + if not removed: + try: + value = await asyncio.wait_for( + asyncio.shield(acknowledgement), timeout=self.claim_timeout) + return _coerce_apply_result(value, targets=targets) + except asyncio.TimeoutError: + # The official consumer publishes its per-envelope task. Cancel + # that task and advance the consumer generation. The generation + # fence matters because user/runtime callbacks can suppress + # ``CancelledError``: waiting for that stale child to exit would + # otherwise strand every later control command behind it. + consumer = wire.get("_control_consumer_task") + cancel_sent = isinstance(consumer, asyncio.Task) + if cancel_sent: + consumer.cancel() + restart_event = wire.get("_control_restart_event") + restart_requested = isinstance(restart_event, asyncio.Event) + if restart_requested: + restart_event.set() + try: + # Cooperative consumers resolve the ACK from their ``finally`` + # immediately. Keep this grace period bounded by the configured + # ACK policy so a cancellation-suppressing consumer cannot hold + # the actor for an additional hard-coded two seconds. + value = await asyncio.wait_for( + asyncio.shield(acknowledgement), + timeout=min(2.0, max(0.05, self.ack_timeout)), + ) + return _coerce_apply_result(value, targets=targets) + except asyncio.TimeoutError: + return ApplyResult( + state=EffectState.UNKNOWN, + detail="claimed control consumer did not acknowledge cancellation", + target_ids=[target.worker_id for target in targets], + metadata={ + "code": "claim_timeout", + "consumer_cancel_sent": cancel_sent, + "consumer_restart_requested": restart_requested, + }, + ) + return ApplyResult( + state=EffectState.UNKNOWN, + detail=("command cancelled before coordinator routing" if removed + else "coordinator cancellation acknowledgement timed out"), + target_ids=[target.worker_id for target in targets], + metadata={"code": "ack_timeout", "cancelled_before_route": removed}, + ) + return _coerce_apply_result(value, targets=targets) diff --git a/apps/web/dispatch_parse.py b/apps/web/dispatch_parse.py new file mode 100644 index 0000000..c02aed9 --- /dev/null +++ b/apps/web/dispatch_parse.py @@ -0,0 +1,180 @@ +"""LLM structured parse of a conversational dispatch prompt. + +Regex heuristics in ``drivers._infer_challenge`` / ``parse_engagement_goal`` +remain the fallback. This module never raises: any failure returns ``{}`` and +the caller fills each missing field from regex or explicit form values. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Optional + +from muteki.core.llm import LLMClient +from muteki.models.solve_graph import Category, QuantityKind + +_CATEGORIES: frozenset[str] = frozenset( + ("web", "pwn", "reverse", "crypto", "forensics", "misc"), +) +_QUANTITIES: frozenset[str] = frozenset(("first", "collect", "recon")) + +_SYSTEM = ( + "You extract structured fields from an operator's dispatch message for a " + "CTF or pentest run. Reply with a single JSON object and nothing else. " + "Do not visit URLs or browse the web. Use null for any field you cannot " + "determine from the text. Fields:\n" + " name: short human title in the prompt's language (3-12 words / 8-24 CJK chars)\n" + " category: one of web, pwn, reverse, crypto, forensics, misc\n" + " target: the primary URL or host:port to attack, or null\n" + " scope: in-scope assets (URL/host/path), or null\n" + " finding_class: vulnerability class if stated (sqli, xss, rce, idor, ssrf, " + "or a short free-text label); generic if unspecified\n" + " quantity: first (stop at first valid finding), collect (gather N findings), " + "or recon (map only, no exploit quota)\n" + " expected_findings: integer >= 1 when the operator asked for a count. " + "Parse Chinese numerals (一份/两份/三份/四份/五份/十份) as 1-5/10. " + "null when no count is given.\n" + " collect_until_coverage: true only for open-ended collect/recon with NO " + "numeric quota; false when a count is given.\n" + "Pentest of a web app is category web. A count such as '三份报告' means " + "quantity=collect, expected_findings=3, collect_until_coverage=false." +) + + +def _extract_json(raw: str) -> dict[str, Any]: + text = (raw or "").strip() + if not text: + return {} + fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S) + if fenced: + text = fenced.group(1) + else: + start = text.find("{") + end = text.rfind("}") + if start < 0 or end <= start: + return {} + text = text[start:end + 1] + try: + data = json.loads(text) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def _clean_str(value: Any, *, max_len: int = 240) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + if not text or text.lower() in {"null", "none", "n/a"}: + return None + return text[:max_len] + + +def _clean_category(value: Any) -> Optional[Category]: + text = _clean_str(value, max_len=32) + if text is None: + return None + key = text.lower() + if key in _CATEGORIES: + return key # type: ignore[return-value] + return None + + +def _clean_quantity(value: Any) -> Optional[QuantityKind]: + text = _clean_str(value, max_len=16) + if text is None: + return None + key = text.lower() + if key in _QUANTITIES: + return key # type: ignore[return-value] + return None + + +def _clean_expected(value: Any) -> Optional[int]: + if value is None or value is False: + return None + if isinstance(value, bool): + return None + try: + n = int(value) + except (TypeError, ValueError): + return None + if n < 1: + return None + return n + + +def _clean_bool(value: Any) -> Optional[bool]: + if isinstance(value, bool): + return value + if value is None: + return None + text = str(value).strip().lower() + if text in {"true", "1", "yes"}: + return True + if text in {"false", "0", "no"}: + return False + return None + + +def validate_dispatch_fields(data: dict[str, Any]) -> dict[str, Any]: + """Keep only legal fields. Illegal values become absent (caller falls back).""" + out: dict[str, Any] = {} + name = _clean_str(data.get("name"), max_len=80) + if name: + out["name"] = name + category = _clean_category(data.get("category")) + if category: + out["category"] = category + target = _clean_str(data.get("target"), max_len=500) + if target: + out["target"] = target + scope = _clean_str(data.get("scope"), max_len=500) + if scope: + out["scope"] = scope + finding_class = _clean_str(data.get("finding_class"), max_len=64) + if finding_class: + out["finding_class"] = finding_class + quantity = _clean_quantity(data.get("quantity")) + if quantity: + out["quantity"] = quantity + expected = _clean_expected(data.get("expected_findings")) + if expected is not None: + out["expected_findings"] = expected + until = _clean_bool(data.get("collect_until_coverage")) + if until is not None: + out["collect_until_coverage"] = until + return out + + +async def parse_dispatch( + prompt: str, + goal: str, + mode: str, + *, + llm: Optional[LLMClient] = None, + model: Optional[str] = None, +) -> dict[str, Any]: + """Return a validated field dict. Empty dict on any failure.""" + if llm is None: + return {} + user = ( + f"mode: {mode or 'ctf'}\n" + f"goal: {(goal or '').strip() or '(none)'}\n" + f"prompt:\n{(prompt or '')[:4000]}" + ) + try: + resp = await llm.chat( + model=model or "deepseek-v4-pro", + messages=[ + {"role": "system", "content": _SYSTEM}, + {"role": "user", "content": user}, + ], + temperature=0.0, + max_tokens=2000, + stream=False, + ) + return validate_dispatch_fields(_extract_json(resp.content)) + except Exception: + return {} diff --git a/apps/web/drivers.py b/apps/web/drivers.py index 7606a7b..3729c71 100644 --- a/apps/web/drivers.py +++ b/apps/web/drivers.py @@ -12,25 +12,34 @@ from __future__ import annotations +import asyncio import copy +import hashlib +import json import re import os +import sys from pathlib import Path from typing import TYPE_CHECKING, Any, Awaitable, Callable +from apps.web.dispatch_parse import parse_dispatch +from apps.web.llm_credentials import LlmCredentialStore from apps.web.run_manager import Run, RunManager from apps.web.worker_config import ( - DEFAULT_WORKER_BACKEND, backend_for_profile, resolve_worker_backend, ) from muteki.solver.credential_accounts import account_store_root from muteki.core.runtime_env import is_web_container from muteki.solver.worker_profiles import ( + apply_worker_identity_env, base_engine_for_profile, normalize_profile_roster, profile_uses_endpoint, + worker_identity_fields, ) +from muteki.solver.cli_driver import driver_for +from muteki.core.llm import LLMClient, llm_temperature_kwargs if TYPE_CHECKING: from muteki.solver.profile_health import ProfileHealth @@ -58,7 +67,7 @@ def _format_missing(p: dict, h: "ProfileHealth") -> str: def _missing_profile_accounts( *, worker_profiles: list[dict], - runtime_profiles: list[dict], + worker_backend: str, sessions_root: Path, ) -> list[str]: """Dispatch precheck — now a thin wrapper over the profile_health kernel so it @@ -78,9 +87,7 @@ def _missing_profile_accounts( def _ev(p: dict) -> "tuple[dict, ProfileHealth]": backend = backend_for_profile( - p, - runtime_profiles=runtime_profiles, - worker_backend=DEFAULT_WORKER_BACKEND, + worker_backend=worker_backend, in_web_container=is_web_container(), ) return p, evaluate_profile_health( @@ -101,14 +108,209 @@ def _selected_profiles(engines: list[str], worker_profiles: list[dict]) -> list[ return [by_name[n] for n in names if n in by_name] -def _drop_cursor_profiles(engines: list[str], worker_profiles: list[dict]) -> list[str]: - if not worker_profiles: - return [e for e in engines if e != "cursor"] - by_name = {str(p.get("name") or p.get("id")): p for p in worker_profiles if isinstance(p, dict)} - return [ - name for name in normalize_profile_roster(engines, worker_profiles) - if base_engine_for_profile(by_name.get(name) or name) != "cursor" +def _startup_profiles( + *, + engines: list[str], + race_engines: list[str] | None, + worker_profiles: list[dict], + stage_policy: dict[str, Any], + coordinator: bool, +) -> tuple[list[dict], list[str]]: + """Return the task roster that Swarm can actually dispatch. + + ``Swarm.engines`` is built from the main roster. Race and review settings can + only select a subset of that roster; references outside it are filtered by + the scheduler and therefore must not spend a readiness request or block the + task. Keeping those parameters explicit documents that this matches the + current call chain rather than the shape of the settings document. + """ + del race_engines, stage_policy, coordinator + refs = list(engines) + selected = _selected_profiles(refs, worker_profiles) + unknown_refs = [] if not worker_profiles else [ + str(ref) + for ref in refs + if str(ref).strip() + and not normalize_profile_roster([str(ref)], worker_profiles) ] + out: list[dict] = [] + seen: set[str] = set() + for profile in selected: + profile_id = str( + profile.get("id") or profile.get("name") or profile.get("engine") or "" + ) + if profile_id and profile_id not in seen and profile.get("enabled", True): + seen.add(profile_id) + out.append(profile) + return out, list(dict.fromkeys(unknown_refs)) + + +def _safe_preflight_detail(value: Any, *, layer: str) -> str: + """Preserve the actionable probe error after the probe redacts credentials.""" + text = str(value or "预检失败").replace("\x00", "").strip() + text = "".join(ch for ch in text if ch in "\n\t" or ord(ch) >= 32) + return text[:2000] or f"{layer or 'model'} preflight failed" + + +def _preflight_error_id(*parts: Any) -> str: + material = "\x1f".join(str(part or "") for part in parts) + digest = hashlib.sha256(material.encode("utf-8", "replace")).hexdigest() + return f"PF-{digest[:10].upper()}" + + +def _profile_readiness_key( + profile: dict, + *, + runtime: dict, + backend: str, +) -> str: + """Identify the exact runnable profile configuration for task-level reuse.""" + material = { + "profile_id": str( + profile.get("id") or profile.get("name") or profile.get("engine") or "" + ), + "engine": base_engine_for_profile(profile), + "model": str(profile.get("model") or ""), + "reasoning_effort": str(profile.get("reasoning_effort") or "default"), + "credential_account": str(profile.get("credential_account") or ""), + "backend": backend, + "runtime": runtime, + } + encoded = json.dumps( + material, ensure_ascii=False, sort_keys=True, separators=(",", ":"), + default=str, + ) + return hashlib.sha256(encoded.encode("utf-8", "replace")).hexdigest() + + +async def _startup_readiness( + *, + profiles: list[dict], + worker_network: str, + worker_backend: str, + sessions_root: Path, + cached_results: dict[str, tuple[bool, dict[str, Any] | None]], +) -> tuple[dict[str, bool], list[dict[str, Any]]]: + """Send one real minimal model request for every participating profile.""" + from apps.web.worker_models import ProbeProcessOwner, probe_worker_model + from muteki.solver.profile_health import evaluate_profile_health + + runtime = {"network": worker_network} + owner = ProbeProcessOwner() + + async def _probe(profile: dict) -> tuple[str, bool, dict[str, Any] | None]: + profile_id = str( + profile.get("id") or profile.get("name") or profile.get("engine") + ) + backend = backend_for_profile( + worker_backend=worker_backend, + in_web_container=is_web_container(), + ) + cache_key = _profile_readiness_key( + profile, runtime=runtime, backend=backend) + cached = cached_results.get(cache_key) + if cached is not None: + cached_ok, cached_failure = cached + return profile_id, cached_ok, copy.deepcopy(cached_failure) + binding = await asyncio.to_thread( + evaluate_profile_health, + profile, + backend=backend, + sessions_root=sessions_root, + depth="binding", + ) + if not binding.ok: + result: dict[str, Any] = { + "ok": False, + "layer": binding.layer or "binding", + "detail": binding.detail or binding.blocker or "凭据未配置", + } + else: + result = await asyncio.to_thread( + probe_worker_model, + profile=profile, + model=str(profile.get("model") or ""), + reasoning_effort=str( + profile.get("reasoning_effort") or "default"), + sessions_root=sessions_root, + backend=backend, + runtime=runtime, + owner=owner, + ) + if result.get("ok"): + cached_results[cache_key] = (True, None) + return profile_id, True, None + layer = str(result.get("layer") or "model") + detail = _safe_preflight_detail(result.get("detail"), layer=layer) + code = f"preflight_{layer}_failed" + failure = { + "error_id": _preflight_error_id( + profile_id, base_engine_for_profile(profile), layer, code, detail), + "profile_id": profile_id, + "engine": base_engine_for_profile(profile), + "model": str(profile.get("model") or ""), + "backend": backend, + "network": worker_network if backend == "container" else "", + "stage": "preflight", + "layer": layer, + "code": code, + "detail": detail, + } + cached_results[cache_key] = (False, copy.deepcopy(failure)) + return profile_id, False, failure + + tasks = [asyncio.create_task(_probe(profile)) for profile in profiles] + try: + rows = await asyncio.gather(*tasks) + except asyncio.CancelledError: + await asyncio.to_thread(owner.cancel) + await asyncio.to_thread(owner.wait, 15.0) + await asyncio.gather(*tasks, return_exceptions=True) + raise + snapshot = {profile_id: ok for profile_id, ok, _failure in rows} + failures = [failure for _profile_id, _ok, failure in rows if failure is not None] + return snapshot, failures + + +# Allowlisted module prefixes for the optional `swarm_class` /start knob. The +# endpoint loads a class by dotted path, so without this list it would be an +# arbitrary-import (RCE) surface. +_SWARM_CLASS_PREFIXES: tuple[str, ...] = ( + "muteki.swarm.", + "muteki.solver.", + "muteki.frameworks.", +) + + +def _resolve_swarm_class(spec: Any) -> type: + """Resolve the optional `swarm_class` body knob (``module.path:ClassName``). + + Product default is ``muteki.swarm.swarm.Swarm`` (coordinator_loop). + Empty / omitted spec must stay on that class. Experimental arms + (ChainForce, PEX, DualRush, ReapClose, F01–F11) load only when a + caller writes an explicit allowlisted spec. Web UI does not send + this field. See docs/TODO_EXPERIMENTAL_FRAMEWORKS.md. + """ + from muteki.swarm.swarm import Swarm + + text = str(spec or "").strip() + if not text: + return Swarm + module_name, sep, class_name = text.partition(":") + if not sep or not module_name or not class_name: + raise RuntimeError( + f"swarm_class must be 'module.path:ClassName', got {text!r}") + if not module_name.startswith(_SWARM_CLASS_PREFIXES): + raise RuntimeError( + f"swarm_class module {module_name!r} not in allowlist " + f"{_SWARM_CLASS_PREFIXES}") + import importlib + + cls = getattr(importlib.import_module(module_name), class_name, None) + if not (isinstance(cls, type) and issubclass(cls, Swarm)): + raise RuntimeError( + f"swarm_class {text!r} is not a muteki.swarm.swarm.Swarm subclass") + return cls def build_driver(body: dict[str, Any], mgr: RunManager | None = None) -> Driver: @@ -117,6 +319,8 @@ def build_driver(body: dict[str, Any], mgr: RunManager | None = None) -> Driver: kind = (body or {}).get("kind", "swarm") if kind == "mock": return _mock_driver(body) + if kind == "pentest_demo": + return _pentest_demo_driver(body, mgr=mgr) if kind == "idle": return _idle_driver(body) return _swarm_driver(_infer_challenge(body), mgr=mgr) @@ -139,6 +343,27 @@ def build_driver(body: dict[str, Any], mgr: RunManager | None = None) -> Driver: _DEFAULT_BRACE_FLAG_FORMAT = r"[A-Za-z0-9_]{0,15}\{[^}]{1,200}\}" +# Stop inferred URLs at fullwidth / CJK wrappers. ASCII rstrip alone leaves +# `http://127.0.0.1:4280)做黑盒渗透。账号` which later blows up urllib port parse. +_INFERRED_URL_CUT = re.compile(r"[)】」』,。;、(【「『<>\"']") +_INFERRED_URL_KEEP = re.compile( + r"(https?://(?:\[[0-9A-Fa-f:]+\]|[^/:?#\s]+)" + r"(?::\d{1,5})?(?:[/?#][^\s]*)?)" +) + + +def _inferred_http_target(prompt: str) -> str: + m = re.search(r"https?://[^\s\"'<>]+", prompt or "") + if not m: + return "" + raw = m.group(0) + cut = _INFERRED_URL_CUT.search(raw) + if cut: + raw = raw[:cut.start()] + raw = raw.rstrip(".,;)/]\\") + kept = _INFERRED_URL_KEEP.match(raw) + return (kept.group(1) if kept else raw).rstrip(".,;)/]\\") + def _clean_flag_wrapper(raw: Any) -> str: wrapper = str(raw or "").strip() @@ -187,22 +412,30 @@ def _infer_challenge(body: dict[str, Any]) -> dict[str, Any]: return body low = prompt.lower() + inferred: list[str] = [] if not ch.get("description"): ch["description"] = prompt + inferred.append("description") if not ch.get("category"): ch["category"] = next( (cat for cat, kws in _CATEGORY_HINTS if any(k in low for k in kws)), "misc", ) + inferred.append("category") if not ch.get("target"): - m = re.search(r"https?://[^\s\"'<>]+", prompt) - if m: - ch["target"] = m.group(0).rstrip(".,;)") + target = _inferred_http_target(prompt) + if target: + ch["target"] = target + inferred.append("target") + if (body.get("mode") or ch.get("mode")) == "pentest": + ch["mode"] = "pentest" if not ch.get("name"): # first few words, slugified — a readable thread-rail label words = re.findall(r"[A-Za-z0-9]+", prompt)[:4] ch["name"] = "-".join(w.lower() for w in words) or "challenge" + inferred.append("name") body["challenge"] = ch + body["_inferred_fields"] = inferred return body @@ -234,6 +467,63 @@ async def drive(run: Run) -> None: return drive +def _pentest_demo_driver(body: dict[str, Any], mgr: RunManager | None = None) -> Driver: + """Scripts a full pentest run whose sole purpose is to populate every + runtime-asset / review panel on the deck (findings, vuln reports, PoCs, + routes/branches, directives, credentials). UI/demo ONLY — kind:"pentest_demo". + + Credentials read the run's persisted shared_graph.db, so when a RunManager is + available we point the demo at ``sessions/{id}/workspace/graph`` and it writes + the verified credential facts there (matching what /api/runs/{id}/credentials + reads). Without a manager it degrades to counting-only (no db).""" + async def drive(run: Run) -> None: + from examples.pentest_demo_solver import run_pentest_demo_solve + + # pace the canned stream so the panels visibly fill in the browser. + tick = float(body.get("tick", 0.5)) + graph_dir = None + if mgr is not None: + graph_dir = mgr.workspace_dir(run.run_id) / "graph" + await run_pentest_demo_solve( + run.bus, run.cost, run_id=run.run_id, tick=tick, graph_dir=graph_dir) + + return drive + + +async def _open_planner_llm( + *, + llm_profiles: dict[str, Any], + run: Run, + mgr: RunManager | None, +) -> tuple[Any, Any]: + """Return (context manager, client). Never raises; (None, None) on failure.""" + try: + planner_profile = llm_profiles.get("planner") or {} + planner_base = str(planner_profile.get("base_url") or "").strip() + llm_kwargs: dict[str, Any] = { + "cost": run.cost, + "bus": run.bus, + **llm_temperature_kwargs(planner_profile), + } + if planner_base: + llm_kwargs["base_url"] = planner_base + if mgr is not None: + planner_key = LlmCredentialStore(mgr.sessions_root).resolve("planner") + if planner_key: + llm_kwargs["api_key"] = planner_key + llm_cm = LLMClient(**llm_kwargs) + llm = await llm_cm.__aenter__() + return llm_cm, llm + except Exception: + return None, None + + +def _llm_may_override(field: str, inferred: set[str], current: Any) -> bool: + if field in inferred: + return True + return not str(current or "").strip() + + def _swarm_driver(body: dict[str, Any], mgr: RunManager | None = None) -> Driver: """The REAL solver: a shelled-CLI swarm (claude + codex race) against the challenge. No DeepSeek key — CliSolver runs the subscription CLIs directly and @@ -256,20 +546,35 @@ def _swarm_driver(body: dict[str, Any], mgr: RunManager | None = None) -> Driver engines: list[str] (default [cursor,claude,codex]) — engine roster; offline drops cursor (can't go offline cleanly) start_workers: int (default len(engines)) — bootstrap workers (one per engine) + swarm_class: "module.path:ClassName" (default muteki.swarm.swarm:Swarm) + — product Coordinator. Omit this + field for CTF and pentest. + Experimental arms are eval-only + and require an explicit spec. """ + try: + declared_protocol = int(body.get("protocol", 1) or 1) + except (TypeError, ValueError) as exc: + raise RuntimeError("protocol must be 1 or 2") from exc + async def drive(run: Run) -> None: import os import tempfile from pathlib import Path - from muteki.models.solve_graph import Challenge + from muteki.models.solve_graph import ( + Challenge, apply_expected_findings, parse_engagement_goal, + ) from muteki.sandbox.manager import SandboxManager from muteki.solver.result import ArtifactStore from muteki.solver.types import SolverConfig from muteki.swarm.models import default_lineup - from muteki.swarm.swarm import Swarm + + # Empty spec → production Swarm. Explicit spec is eval-only. + swarm_cls = _resolve_swarm_class(body.get("swarm_class")) ch = body.get("challenge", {}) + inferred_fields = set(body.get("_inferred_fields") or []) # attachments: local file paths for FILE-based tracks (crypto/rev/forensics # /misc). The worker stages them into its cwd. Keep only paths that exist so # a stray entry can't crash the run. @@ -280,11 +585,85 @@ async def drive(run: Run) -> None: mode = (ch.get("mode") or body.get("mode") or "ctf") if mode not in ("ctf", "pentest"): mode = "ctf" - # multi-flag: thread expected_flags + multi_flag so a ladder/collection - # challenge SAVES every flag without finishing on the first (run-10070's - # 22-level ladder otherwise registered as single-flag). multi_flag is the - # mode bit; expected_flags is the optional count (<=1 in multi-flag mode → - # collect until operator STOP / no-progress pause). body.* wins over ch.*. + prompt_text = (body.get("prompt") or ch.get("description") or "").strip() + goal_text = (ch.get("goal") or body.get("goal") or "") + if mode == "pentest" and not str(goal_text).strip(): + goal_text = prompt_text + scope_text = (ch.get("scope") or body.get("scope") or "") + if mode == "pentest" and not scope_text and ch.get("target"): + scope_text = str(ch.get("target") or "") + coordinator = bool(body.get("coordinator", True)) + llm_profiles = dict(body.get("llm_profiles") or {}) + if not llm_profiles and mgr is not None: + try: + llm_profiles = dict( + mgr.worker_config.get().get("llm_profiles") or {}) + except Exception: + llm_profiles = {} + llm_cm = None + llm = None + parse_audit: dict[str, Any] = {"source": "regex", "model": ""} + planner_model = str( + (llm_profiles.get("planner") or {}).get("model") or "deepseek-v4-pro") + parsed: dict[str, Any] = {} + if coordinator: + llm_cm, llm = await _open_planner_llm( + llm_profiles=llm_profiles, run=run, mgr=mgr) + if llm is not None: + try: + parsed = await asyncio.wait_for( + parse_dispatch( + prompt_text, goal_text, mode, + llm=llm, model=planner_model), + timeout=15.0, + ) + except Exception: + parsed = {} + if parsed: + parse_audit = {"source": "llm", "model": planner_model} + if (_llm_may_override("category", inferred_fields, ch.get("category")) + and parsed.get("category")): + ch["category"] = parsed["category"] + inferred_fields.discard("category") + if (_llm_may_override("target", inferred_fields, ch.get("target")) + and parsed.get("target")): + ch["target"] = parsed["target"] + inferred_fields.discard("target") + if (_llm_may_override("name", inferred_fields, ch.get("name")) + and parsed.get("name")): + ch["name"] = parsed["name"] + inferred_fields.discard("name") + if not str(scope_text or "").strip() and parsed.get("scope"): + scope_text = parsed["scope"] + name_autogen = "name" in inferred_fields + engagement = parse_engagement_goal(goal_text) if mode == "pentest" else None + if engagement is not None and parsed: + updates: dict[str, Any] = {} + if parsed.get("finding_class"): + updates["finding_class"] = parsed["finding_class"] + if parsed.get("quantity"): + updates["quantity"] = parsed["quantity"] + if parsed.get("expected_findings") is not None: + updates["expected_findings"] = parsed["expected_findings"] + if parsed.get("collect_until_coverage") is not None: + updates["collect_until_coverage"] = parsed["collect_until_coverage"] + if parsed.get("expected_findings") and parsed.get("quantity") != "recon": + updates.setdefault("quantity", "collect") + updates["collect_until_coverage"] = False + if updates: + engagement = engagement.model_copy(update=updates) + expected_findings_raw = ( + body.get("expected_findings") + if body.get("expected_findings") is not None + else ch.get("expected_findings") + ) + if engagement is not None and expected_findings_raw not in (None, ""): + try: + want = int(expected_findings_raw) + except (TypeError, ValueError): + want = None + if want is not None: + engagement = apply_expected_findings(engagement, want) expected_flags = int(body.get("expected_flags") or ch.get("expected_flags") or 1) multi_flag = bool(body.get("multi_flag") @@ -308,12 +687,27 @@ async def drive(run: Run) -> None: if body.get("verifier_rate_limited") is not None else ch.get("verifier_rate_limited", False)), mode=mode, - goal=(ch.get("goal") or body.get("goal") or ""), - scope=(ch.get("scope") or body.get("scope") or ""), + goal=goal_text, + scope=scope_text, + engagement=engagement, + pentest_flag_required=bool(body.get("pentest_flag_required") + if body.get("pentest_flag_required") is not None + else ch.get("pentest_flag_required", False)), ) executor = body.get("executor", "cli") + try: + protocol_version = int(body.get("protocol", 1) or 1) + except (TypeError, ValueError) as exc: + raise RuntimeError("protocol must be 1 or 2") from exc + if protocol_version not in {1, 2}: + raise RuntimeError("protocol must be 1 or 2") cli_race = bool(body.get("cli_race", False)) cli_engine = body.get("cli_engine", "claude") + # Default-off cognitive cluster planner (intent ranking + engine match). + # Env MUTEKI_COGNITIVE_CLUSTER_PLANNER=1 also enables (Swarm ctor). + cognitive_cluster_planner = bool( + body.get("cognitive_cluster_planner", False) + ) offline = bool(body.get("offline", False)) web_access = not offline # offline implies NO KB (a clean black-box eval denies every external @@ -325,35 +719,31 @@ async def drive(run: Run) -> None: # engine roster: three-engine race by default (cursor + claude + codex). # Resolution order: explicit body.engines > the operator's per-category # worker-config default (apps/web/worker_config.py) > the hardcoded roster. - # OFFLINE drops cursor — Cursor's headless CLI has no --disallowed-tools to - # deny web tools and doesn't inherit the optional KB MCP, so it can't run a - # clean offline bench eval (protects the AGENTS.md offline rule). + # Offline capability is checked per selected profile below. Cursor uses its + # ACP permission channel in this mode; the other engines use their native + # deny flags or local-only tool allowlists. wc = mgr.worker_config.resolve(challenge.category) if mgr is not None else {} - engines = body.get("engines") or wc.get("engines") or ["cursor", "claude", "codex"] - runtime_profiles = body.get("runtime_profiles") or wc.get("runtime_profiles") or [] + engines = body.get("engines") or wc.get("engines") or ["cursor", "claude", "codex", "pi", "omp"] worker_profiles = body.get("worker_profiles") or wc.get("worker_profiles") or [] - # OFFLINE normally drops cursor (no --disallowed-tools to deny web tools → - # can't guarantee a clean black-box). `allow_cursor_offline:true` overrides - # that — the operator accepts that cursor MIGHT web-search a writeup, and the - # anti-cheat audit (scripts/audit_retest.py) is relied on to flag any case - # where a flag comes from a fetched writeup rather than a real exploit. - allow_cursor_offline = bool(body.get("allow_cursor_offline", False)) - if offline and not allow_cursor_offline: - engines = _drop_cursor_profiles(engines, worker_profiles) or ["claude", "codex"] + worker_network = str( + body.get("worker_network") or wc.get("worker_network") or "bridge" + ).strip() + if worker_network not in {"bridge", "host", "none"}: + raise RuntimeError("worker_network must be bridge, host, or none") if offline: - runtime_profiles = [ - {**r, "network": "none"} if isinstance(r, dict) - and str(r.get("backend") or "") == "container" else r - for r in runtime_profiles - ] - endpoint_profiles = [ + worker_network = "none" + incompatible_profiles = [ p for p in _selected_profiles(engines, worker_profiles) - if profile_uses_endpoint(p) + if not bool(getattr( + driver_for(p), "offline_web_isolation", False)) ] - if endpoint_profiles: - names = ", ".join(str(p.get("name") or p.get("id")) for p in endpoint_profiles) + if incompatible_profiles: + names = ", ".join( + str(p.get("name") or p.get("id")) + for p in incompatible_profiles + ) raise RuntimeError( - "profile_incompatible offline eval cannot use custom endpoint profile(s): " + "profile_incompatible offline eval cannot isolate web tools for profile(s): " + names ) # bootstrap worker count: explicit body wins, else the config default, else @@ -369,7 +759,30 @@ async def drive(run: Run) -> None: wall_clock_budget = float(_wcb) if (_wcb and float(_wcb) > 0) else float("inf") max_total_workers = int(body.get("max_total_workers", wc.get("max_total_workers", 0)) or 0) or None cost_budget_usd = float(body.get("cost_budget_usd", wc.get("cost_budget_usd", 0.0)) or 0.0) or None + token_budget = int(body.get("token_budget", 0) or 0) + tool_call_budget = int(body.get("tool_call_budget", 0) or 0) + max_barren_attempts = int(body.get("max_barren_attempts", 1) or 1) llm_profiles = body.get("llm_profiles") or wc.get("llm_profiles") or {} + if protocol_version == 2: + if mgr is None or mgr.protocol2 is None: + raise RuntimeError( + "Protocol2Unavailable: " + + (mgr.protocol2_error if mgr is not None else "no Web composition root")) + selected = _selected_profiles(engines, worker_profiles) + if not offline or kb: + raise RuntimeError( + "Protocol2CanaryRejected: live canary requires offline=true and kb=false") + if (coordinator or bool(body.get("race_scout", True)) + or len(selected) != 1): + raise RuntimeError( + "Protocol2CanaryRejected: use one profile, coordinator=false, race_scout=false") + if start_workers != 1 or max_workers != 1 or max_total_workers != 1: + raise RuntimeError( + "Protocol2CanaryRejected: minimal canary requires exactly one worker/attempt") + if (wall_clock_budget == float("inf") or not cost_budget_usd + or token_budget <= 0 or tool_call_budget <= 0): + raise RuntimeError( + "Protocol2CanaryRejected: finite wall/cost/token/tool budgets are required") if "stage_policy" in body: stage_policy = copy.deepcopy(body.get("stage_policy") or {}) elif wc.get("stage_policy"): @@ -377,7 +790,8 @@ async def drive(run: Run) -> None: else: stage_policy = { "race": { - "enabled": bool(body["race_scout"]) if "race_scout" in body else bool(wc.get("race_scout", True)), + "enabled": bool(body["race_scout"]) if "race_scout" in body else ( + False if mode == "pentest" else bool(wc.get("race_scout", True))), "timeout": int(body.get("race_timeout", wc.get("race_timeout", 720))), "engines": body.get("race_engines") or wc.get("race_engines") or [], }, @@ -401,15 +815,19 @@ async def drive(run: Run) -> None: if "cost_budget_usd" in body: stage_policy.setdefault("budgets", {})["cost_budget_usd"] = float( body["cost_budget_usd"] or 0.0) + stage_policy.setdefault("coordinator", {}) + stage_policy["coordinator"]["token_budget"] = token_budget + stage_policy["coordinator"]["tool_call_budget"] = tool_call_budget # race-scout layer (DESIGN_race_scout_layer.md): one parallel single-shot # round in front of the main coordinator loop. Operator-configurable from the request: # race_scout (bool, default on) — whole-layer toggle # race_engines (list, default = engines) — which engines race (worker switch) # race_timeout (int, default 720s) — short per-worker recon timeout - race_scout = bool(body["race_scout"]) if "race_scout" in body else bool(wc.get("race_scout", True)) + race_scout = ( + bool(body["race_scout"]) if "race_scout" in body + else (False if mode == "pentest" else bool(wc.get("race_scout", True))) + ) race_engines = body.get("race_engines") or wc.get("race_engines") or None # None → defaults to the roster - if offline and not allow_cursor_offline and race_engines: - race_engines = _drop_cursor_profiles(race_engines, worker_profiles) or None race_timeout = int(body.get("race_timeout", wc.get("race_timeout", 720))) # cold_start (run-75379 BUG④): "继续做题"/standby relaunch sets this False so the # coordinator skips the race-scout warmup and continues on the existing graph. @@ -428,26 +846,122 @@ async def drive(run: Run) -> None: env_backend=os.environ.get("MUTEKI_WORKER_BACKEND"), in_web_container=is_web_container(), ) - if mgr is not None and worker_profiles: - # The precheck runs a real per-profile health probe (a synchronous - # `subprocess.run` that shells the CLI for a one-turn hello) for any - # profile that needs an account/endpoint. That can take seconds per - # engine, so it MUST run off the event loop — otherwise a relaunch - # (`/resolve`) freezes the whole single-threaded uvicorn loop while it - # probes (the "resolve → backend hangs" symptom). to_thread it. - import asyncio - precheck_profiles = _selected_profiles(engines, worker_profiles) or worker_profiles - missing_accounts = await asyncio.to_thread( - _missing_profile_accounts, - worker_profiles=precheck_profiles, - runtime_profiles=runtime_profiles, - sessions_root=mgr.sessions_root, + if protocol_version == 2 and worker_backend != "local": + raise RuntimeError( + "Protocol2CanaryRejected: current live-local egress enforcer " + "requires worker_backend=local") + startup_health_snapshot: dict[str, bool] | None = None + if mgr is not None and protocol_version != 2: + from muteki.core.events import Event, EventType + + precheck_profiles, unknown_profile_refs = _startup_profiles( + engines=list(engines), + race_engines=list(race_engines) if race_engines else None, + worker_profiles=worker_profiles, + stage_policy=stage_policy, + coordinator=coordinator, ) - if missing_accounts: - raise RuntimeError( - "profile_unhealthy missing credential account(s): " - + ", ".join(missing_accounts) + if not precheck_profiles and not worker_profiles: + precheck_profiles = [ + { + "id": str(engine), + "name": str(engine), + "engine": str(engine), + "model": "", + "credential_account": "", + "enabled": True, + } + for engine in engines + ] + await run.bus.emit(Event( + event_type=EventType.RUN_PREPARING, + run_id=run.run_id, + challenge_id=challenge.id, + payload={ + "phase": "preflight", + "challenge": challenge.model_dump(mode="json"), + "parse": parse_audit, + "name_autogen": bool(name_autogen), + "profiles": [ + { + "profile_id": str( + profile.get("id") or profile.get("name") + or profile.get("engine") or ""), + "engine": base_engine_for_profile(profile), + "model": str(profile.get("model") or ""), + "reused": _profile_readiness_key( + profile, + runtime={"network": worker_network}, + backend=backend_for_profile( + worker_backend=worker_backend, + in_web_container=is_web_container(), + ), + ) in run.profile_readiness, + } + for profile in precheck_profiles + ], + }, + )) + if unknown_profile_refs: + startup_health_snapshot = {} + preflight_failures = [ + { + "profile_id": ref, + "engine": "", + "model": "", + "backend": worker_backend, + "network": worker_network if worker_backend == "container" else "", + "stage": "preflight", + "layer": "binding", + "code": "unknown_profile_ref", + "detail": "任务引用了不存在的 Worker Profile", + "error_id": _preflight_error_id( + ref, "binding", "unknown_profile_ref"), + } + for ref in unknown_profile_refs + ] + else: + startup_health_snapshot, preflight_failures = await _startup_readiness( + profiles=precheck_profiles, + worker_network=worker_network, + worker_backend=worker_backend, + sessions_root=mgr.sessions_root, + cached_results=run.profile_readiness, ) + mgr.persist_profile_readiness(run) + if preflight_failures: + await run.bus.emit(Event( + event_type=EventType.RUN_FINISHED, + run_id=run.run_id, + challenge_id=challenge.id, + payload={ + "flag": None, + "flags": [], + "expected_flags": challenge.expected_flags, + "multi_flag": challenge.multi_flag, + "solved": False, + "reason": "preflight_failed", + "failure_code": "profile_unhealthy", + "failure_phase": "preflight", + "error_id": _preflight_error_id( + run.run_id, + *(failure.get("error_id", "") + for failure in preflight_failures), + ), + "detail": ( + f"Worker 预检失败({len(preflight_failures)} 个 Profile)" + ), + "profile_failures": preflight_failures, + }, + )) + if llm_cm is not None: + try: + await llm_cm.__aexit__(None, None, None) + except Exception: + pass + llm_cm = None + llm = None + return if mgr is not None: root = mgr.workspace_dir(run.run_id) @@ -458,6 +972,22 @@ async def drive(run: Run) -> None: # they persist (the shared_graph.db is the run's queryable fact graph). sandbox = SandboxManager(bus=run.bus, root=root / "sbx") arts = ArtifactStore(root=root / "arts") + protocol2_session = None + if protocol_version == 2: + protocol2_session = mgr.protocol2.prepare_live_session( + run_id=run.run_id, + challenge_id=challenge.id, + attachments=attachments, + profiles=_selected_profiles(engines, worker_profiles), + artifacts=arts, + max_attempts=int(max_total_workers or 0), + max_barren_attempts=max_barren_attempts, + wall_ms=int(wall_clock_budget * 1000), + token_budget=token_budget, + cost_micro_usd=int(float(cost_budget_usd or 0.0) * 1_000_000), + tool_call_budget=tool_call_budget, + expected_goal_units=max(1, expected_flags), + ) graph_dir = root / "graph" # worker_root is a SIBLING of sbx (NOT under it) so each CLI worker's cwd — # staged attachments, agent-extracted files, PoCs — lives under the run's @@ -465,40 +995,60 @@ async def drive(run: Run) -> None: # sbx. It's cleaned up with the run (RunManager.delete drops sessions/{id}). worker_root = root / "workers" - # LLMClient: the coordinator needs it for the Reason planner. A plain CLI - # race needs none. - llm_cm = None - llm = None - if coordinator: - from muteki.core.llm import LLMClient - try: - # planner endpoint override (DESIGN §2.2): base_url from the - # planner llm_profile (empty = default DeepSeek). Key stays in env. - planner_base = str((llm_profiles.get("planner") or {}).get("base_url") or "").strip() - llm_kwargs: dict[str, Any] = {"cost": run.cost, "bus": run.bus} - if planner_base: - llm_kwargs["base_url"] = planner_base - llm_cm = LLMClient(**llm_kwargs) - llm = await llm_cm.__aenter__() - except Exception: - # no key / client unavailable → coordinator Reason will no-op, - # bootstrap workers still run. Never block the run on this. - llm_cm = None - llm = None + # Planner LLMClient was opened before Challenge construction (dispatch + # parse). A missing key leaves llm=None and Reason no-ops. # §16 flywheel store (optional; recall prior + distill on solve) from muteki.learning.distill import TemplateStore knowledge = TemplateStore(root=os.environ.get("MUTEKI_KNOWLEDGE_DIR", "knowledge")) - swarm = Swarm( + # Initialise the run-local control boundary before workers are built so + # every spawn registers against the same registry and secret:// values can + # be materialised only at the final in-memory worker injection boundary. + secret_resolver = None + context_provider = None + context_binder = None + context_reserver = None + context_committer = None + context_releaser = None + context_delivery_unknown_marker = None + context_status_provider = None + context_expirer = None + standing_clear_provider = None + control_state_provider = None + worker_registry = getattr(run, "worker_registry", None) + if mgr is not None and protocol_version != 2: + try: + _actor, control_journal, secret_store = mgr._ensure_control(run) + secret_resolver = secret_store.resolve + context_provider = control_journal.context_resources + context_binder = control_journal.bind_context + context_reserver = control_journal.reserve_context + context_committer = control_journal.commit_context_binding + context_releaser = control_journal.release_context_reservation + context_delivery_unknown_marker = ( + control_journal.mark_context_delivery_unknown) + context_status_provider = control_journal.context_delivery_status + context_expirer = control_journal.expire_context + standing_clear_provider = ( + control_journal.standing_clear_operations) + control_state_provider = control_journal.current_state + except Exception: + # Control is additive: a journal/storage failure must not prevent + # an otherwise valid solve from starting. + secret_resolver = None + + swarm = swarm_cls( challenge, default_lineup(n), llm=llm, sandbox=sandbox, bus=run.bus, cost=run.cost, artifacts=arts, config=SolverConfig(), run_id=run.run_id, knowledge=knowledge, - hitl_inbox=run.hitl, # HITL: human commands reach the solvers - worker_cmds=run.worker_cmds, # operator spawn/kill of specific engines + execution_generation=int(getattr(run, "execution_generation", 1) or 1), + hitl_inbox=(None if protocol_version == 2 else run.hitl), + worker_cmds=(None if protocol_version == 2 else run.worker_cmds), executor=executor, cli_engine=cli_engine, cli_race=cli_race, engines=engines, start_workers=start_workers, max_workers=max_workers, web_access=web_access, kb=kb, coordinator=coordinator, + cognitive_cluster_planner=cognitive_cluster_planner, graph_dir=graph_dir, worker_root=worker_root, wall_clock_budget=wall_clock_budget, race_scout=race_scout, race_engines=race_engines, @@ -509,20 +1059,124 @@ async def drive(run: Run) -> None: llm_profiles=llm_profiles, reason_model=(llm_profiles.get("planner") or {}).get("model", "deepseek-v4-pro"), worker_backend=worker_backend, - runtime_profiles=runtime_profiles, + worker_network=worker_network, worker_profiles=worker_profiles, + startup_health_snapshot=startup_health_snapshot, credential_accounts_root=( account_store_root(mgr.sessions_root) if mgr is not None else None ), + worker_registry=worker_registry, + secret_resolver=secret_resolver, + context_provider=context_provider, + context_binder=context_binder, + context_reserver=context_reserver, + context_committer=context_committer, + context_releaser=context_releaser, + context_delivery_unknown_marker=context_delivery_unknown_marker, + context_status_provider=context_status_provider, + context_expirer=context_expirer, + standing_clear_provider=standing_clear_provider, + control_state_provider=control_state_provider, + protocol2_session=protocol2_session, ) + deferred_cleanup = False try: out = await swarm.run() - run.flag = out.flag + if protocol2_session is not None: + await mgr.protocol2.complete_live_session( + run_id=run.run_id, session=protocol2_session, + solved=bool(out.solved)) + else: + # Protocol 1 keeps its direct outcome projection. Protocol 2's + # private outcome is only a canonical-finalization handoff; accepted + # values become public later through typed flag.accepted recovery. + run.flag = out.flag + except BaseException as exc: + from muteki.swarm.swarm_support import ControlShutdownIncomplete + if not isinstance(exc, ControlShutdownIncomplete): + if protocol2_session is not None: + try: + await mgr.protocol2.abort_live_session( + run_id=run.run_id, session=protocol2_session) + except BaseException as cleanup_exc: + # Never let best-effort cleanup erase the original owner + # failure/cancellation. Protocol2WebAdapter retains its live + # owner and store until canonical finalization itself lands. + if not isinstance(exc, asyncio.CancelledError): + exc.add_note( + "Protocol 2 abort cleanup failed without replacing " + "the original exception; " + f"cleanup_error_class={type(cleanup_exc).__name__}" + ) + raise + deferred_cleanup = True + run.runtime_incomplete = True + run.runtime_owner = swarm + run.runtime_error = ( + f"control shutdown incomplete ({type(exc).__name__})") + cleanup_state = {"sandbox": False, "llm": False} + + async def _settle_incomplete_runtime() -> None: + try: + await swarm.settle_control_shutdown() + if protocol2_session is not None: + await mgr.protocol2.complete_live_session( + run_id=run.run_id, session=protocol2_session, + solved=bool(run.solved)) + if not cleanup_state["sandbox"]: + await sandbox.shutdown_all() + cleanup_state["sandbox"] = True + if llm_cm is not None and not cleanup_state["llm"]: + await llm_cm.__aexit__(None, None, None) + cleanup_state["llm"] = True + # settle_control_shutdown emits the delayed truthful terminal + # event only after the orphan owner has left and graph/container + # teardown is safe. + run.finished = True + await run.bus.close() + except BaseException as cleanup_exc: + run.runtime_error = ( + "runtime cleanup failed " + f"({type(cleanup_exc).__name__})") + raise + else: + run.runtime_incomplete = False + run.runtime_owner = None + run.runtime_error = "" + run.runtime_settle = None + + run.runtime_settle = _settle_incomplete_runtime + run.runtime_cleanup_task = asyncio.create_task( + _settle_incomplete_runtime(), + name=f"runtime-owner-settle-{run.run_id}", + ) + raise finally: - await sandbox.shutdown_all() - if llm_cm is not None: - await llm_cm.__aexit__(None, None, None) - + if not deferred_cleanup: + original = sys.exception() + cleanup_failures: list[BaseException] = [] + try: + await sandbox.shutdown_all() + except BaseException as cleanup_exc: + cleanup_failures.append(cleanup_exc) + if llm_cm is not None: + try: + await llm_cm.__aexit__(None, None, None) + except BaseException as cleanup_exc: + cleanup_failures.append(cleanup_exc) + if cleanup_failures: + if original is None: + raise cleanup_failures[0] + if not isinstance(original, asyncio.CancelledError): + classes = ", ".join( + type(failure).__name__ for failure in cleanup_failures + ) + original.add_note( + "Driver final cleanup failed without replacing the " + f"original exception; cleanup_error_classes={classes}" + ) + + drive.protocol_version = declared_protocol # type: ignore[attr-defined] return drive @@ -554,19 +1208,6 @@ def _standby_profile_for(engine: str, worker_profiles: list[dict[str, Any]]) -> return None -def _runtime_for_profile( - profile: dict[str, Any] | None, - runtime_profiles: list[dict[str, Any]], -) -> dict[str, Any]: - if not profile: - return {} - rid = str(profile.get("runtime") or "") - for rt in runtime_profiles: - if isinstance(rt, dict) and str(rt.get("id") or "") == rid: - return rt - return {} - - def _standby_worker_env( *, root: Path, @@ -578,20 +1219,34 @@ def _standby_worker_env( ) -> dict[str, str]: from muteki.solver.credential_accounts import runtime_env_for_engine + agent_state_dir: Path | None = None + agent_state_container_path: str | None = None + if engine in {"pi", "omp", "opencode", "dsh"}: + agent_state_dir = root / ".muteki-agent-state" / label + agent_state_dir.mkdir(parents=True, exist_ok=True) + if container is not None: + mapper = getattr(container, "to_container_path", None) + if callable(mapper): + agent_state_container_path = mapper(str(agent_state_dir)) + env = runtime_env_for_engine( engine, account_root=account_root, account_id=(profile.get("credential_account") if profile else None), container=container is not None, + agent_state_dir=agent_state_dir, + agent_state_container_path=agent_state_container_path, + model=str((profile or {}).get("model") or ""), ).env if profile: - env["MUTEKI_WORKER_PROFILE_ID"] = str(profile.get("id") or "") - env["MUTEKI_CREDENTIAL_ACCOUNT_ID"] = str(profile.get("credential_account") or "") - if profile.get("model"): - env["MUTEKI_WORKER_MODEL"] = str(profile["model"]) + apply_worker_identity_env(env, profile) + env["MUTEKI_WORKER_REASONING_EFFORT"] = str( + profile.get("reasoning_effort") or "default") if container is not None: from muteki.swarm.swarm import _ensure_blackboard_skill_links from muteki.solver.container_exec import _chown_tree_to_worker + if agent_state_dir is not None: + _chown_tree_to_worker(str(agent_state_dir)) home_host = root / "homes" / label home_host.mkdir(parents=True, exist_ok=True) _ensure_blackboard_skill_links(home_host) @@ -639,10 +1294,11 @@ def build_standby_driver(cmd: dict[str, Any], mgr: "RunManager | None" = None) - """A driver that serves ONE post-solve HITL command via a resumed worker.""" async def drive(run: Run) -> None: import asyncio + import inspect import json from pathlib import Path - from muteki.models.solve_graph import Challenge + from muteki.models.solve_graph import Challenge, EngagementGoal, parse_engagement_goal from muteki.solver.cli_driver import driver_for from muteki.solver.cli_solver import CliSolver from muteki.solver.credential_accounts import account_store_root @@ -650,7 +1306,60 @@ async def drive(run: Run) -> None: from muteki.solver.types import SolverConfig from muteki.swarm.shared_graph import SQLiteSharedGraph - action = (cmd.get("action") or "ask").lower() + mark_false_already_applied = bool( + cmd.get("_control_mark_false_applied", False)) + safe_cmd = { + key: value for key, value in dict(cmd).items() + if not str(key).startswith("_control_") + and key != "_standby_delivery_ack" + } + context_reservations = list( + cmd.get("_control_context_reservations") or []) + context_owner = str(cmd.get("_control_context_owner") or "") + if mgr is not None: + _actor, control_journal, control_secrets = mgr._ensure_control(run) + else: + control_journal = None + control_secrets = None + runtime_cmd = dict(safe_cmd) + materialized_secret_values: list[str] = [] + if context_reservations: + if control_journal is None or control_secrets is None: + raise RuntimeError("standby context journal is unavailable") + context_id = str(context_reservations[0][0]) + resource = next( + (row for row in control_journal.context_resources(active_only=False) + if str(getattr(row, "context_id", "")) == context_id), + None, + ) + if resource is None: + raise RuntimeError("standby context resource is unavailable") + content = str(getattr(resource, "content", "") or "") + if content.startswith("secret://"): + try: + content = str(control_secrets.resolve(content) or "") + except Exception: + raise RuntimeError("standby secret material is unavailable") from None + if not content or content.startswith("secret://"): + raise RuntimeError("standby secret material is unavailable") + materialized_secret_values.append(content) + # One reserved resource authorises exactly one prompt value. Never + # recursively decrypt the rest of the envelope/metadata. + runtime_cmd = { + key: safe_cmd[key] + for key in ( + "action", "target", "command_id", "request_id", + "standing", "preempt_policy", "preemption", "flag", + ) + if key in safe_cmd + } + kind = getattr(resource, "kind", "") + kind_value = str(getattr(kind, "value", kind) or "") + if kind_value == "endpoint": + runtime_cmd["url"] = content + else: + runtime_cmd["text"] = content + action = (runtime_cmd.get("action") or "ask").lower() if mgr is not None: root = mgr.workspace_dir(run.run_id) @@ -678,12 +1387,27 @@ async def drive(run: Run) -> None: try: from muteki.core.events import EventType async for ev in run.store.replay(run.run_id): - if ev.event_type == EventType.RUN_STARTED: + if ev.event_type in { + EventType.RUN_PREPARING, EventType.RUN_STARTED, + }: ch = (ev.payload or {}).get("challenge") or {} if ch: break except Exception: ch = {} + mode = ch.get("mode") or "ctf" + if mode not in ("ctf", "pentest"): + mode = "ctf" + engagement = None + if mode == "pentest": + raw_eg = ch.get("engagement") + if isinstance(raw_eg, dict): + try: + engagement = EngagementGoal.model_validate(raw_eg) + except Exception: + engagement = parse_engagement_goal(ch.get("goal") or "") + else: + engagement = parse_engagement_goal(ch.get("goal") or "") challenge = Challenge( id=run.run_id, name=ch.get("name", run.name or run.run_id), @@ -701,11 +1425,16 @@ async def drive(run: Run) -> None: expected_flags=int(ch.get("expected_flags") or 1), multi_flag=bool(ch.get("multi_flag", False)), verifier_rate_limited=bool(ch.get("verifier_rate_limited", False)), + mode=mode, + goal=ch.get("goal") or "", + scope=ch.get("scope") or "", + engagement=engagement, + pentest_flag_required=bool(ch.get("pentest_flag_required", False)), ) wc = mgr.worker_config.resolve(challenge.category) if mgr is not None else {} - runtime_profiles = wc.get("runtime_profiles") or [] worker_profiles = wc.get("worker_profiles") or [] + worker_network = str(wc.get("worker_network") or "bridge") winner_engine = str(winner.get("engine") or "claude") profile = _standby_profile_for(winner_engine, worker_profiles) transport = base_engine_for_profile(profile or winner_engine) @@ -717,28 +1446,124 @@ async def drive(run: Run) -> None: ) backend = ( backend_for_profile( - profile, - runtime_profiles=runtime_profiles, worker_backend=worker_backend, in_web_container=is_web_container(), ) if profile else worker_backend ) - runtime = _runtime_for_profile(profile, runtime_profiles) container = None + setup_cancel_boundary = None + setup_exit_query = None account_root = account_store_root(mgr.sessions_root) if mgr is not None else None if backend == "container": from muteki.solver.container_exec import ensure_container - container = await asyncio.to_thread( - ensure_container, - run.run_id, - str(root), - network=str(runtime.get("network") or "bridge"), - memory=str(runtime.get("memory") or "") or None, - cpus=str(runtime.get("cpus") or "") or None, - pids_limit=int(runtime.get("pids_limit") or 0) or None, - account_root=(str(account_root) if account_root is not None else None), - ) + setup_container_active = True + + async def _cancel_setup_container() -> None: + nonlocal setup_container_active + if not setup_container_active: + return + from muteki.solver.container_exec import teardown_container + removed = await asyncio.to_thread( + teardown_container, run.run_id, remove=True) + if removed is not True: + raise RuntimeError("container teardown could not be proven") + setup_container_active = False + + def _setup_runtime_exited() -> bool: + return not setup_container_active + + async def _wait_setup_exit(_timeout=None) -> bool: + return not setup_container_active + + setup_cancel_boundary = _cancel_setup_container + setup_exit_query = _setup_runtime_exited + + def _clear_setup_owner(setup_task: asyncio.Task[Any]) -> None: + if run.standby_cancel is _cancel_setup_container: + run.standby_cancel = None + if run.standby_runtime_exited is _setup_runtime_exited: + run.standby_runtime_exited = None + if run.standby_wait_runtime_exit is _wait_setup_exit: + run.standby_wait_runtime_exit = None + if run.standby_setup_task is setup_task: + run.standby_setup_task = None + try: + current = asyncio.current_task() + except RuntimeError: + current = None + if run.standby_runtime_cleanup_task is current: + run.standby_runtime_cleanup_task = None + + async def _reap_failed_setup(setup_task: asyncio.Task[Any]) -> None: + try: + while setup_container_active: + try: + await _cancel_setup_container() + except asyncio.CancelledError: + raise + except Exception: + await asyncio.sleep(0.1) + finally: + if not setup_container_active: + _clear_setup_owner(setup_task) + + def _retain_failed_setup(setup_task: asyncio.Task[Any]) -> None: + run.standby_cancel = _cancel_setup_container + run.standby_runtime_exited = _setup_runtime_exited + run.standby_wait_runtime_exit = _wait_setup_exit + cleanup = run.standby_runtime_cleanup_task + if cleanup is None or cleanup.done(): + run.standby_runtime_cleanup_task = asyncio.create_task( + _reap_failed_setup(setup_task), + name=f"standby-setup-reap:{run.run_id}", + ) + + setup_task = asyncio.create_task(asyncio.to_thread( + ensure_container, + run.run_id, + str(root), + network=worker_network, + account_root=(str(account_root) if account_root is not None else None), + ), name=f"standby-runtime-setup:{run.run_id}") + run.standby_setup_task = setup_task + try: + container = await asyncio.shield(setup_task) + except asyncio.CancelledError: + # to_thread acquisition is not cancellable. Retain ownership until + # it lands, then tear down the possibly-created container before the + # wrapper is allowed to finish cancellation. + try: + container = await asyncio.shield(setup_task) + except Exception: + container = None + _retain_failed_setup(setup_task) + try: + await _cancel_setup_container() + except Exception: + # The autonomous reaper retains the only cleanup owner. + pass + if not setup_container_active: + _clear_setup_owner(setup_task) + raise + except Exception: + # ensure_container can create the container successfully and fail + # later while awaiting its supervisor. Treat every ordinary setup + # exception as a potential acquired owner and prove rollback. + _retain_failed_setup(setup_task) + try: + await _cancel_setup_container() + except Exception: + pass + if not setup_container_active: + _clear_setup_owner(setup_task) + raise + else: + if run.standby_setup_task is setup_task: + run.standby_setup_task = None + run.standby_cancel = _cancel_setup_container + run.standby_runtime_exited = _setup_runtime_exited + run.standby_wait_runtime_exit = _wait_setup_exit # re-open the persisted shared graph (verified facts / dead-ends / flag). shared_graph = None @@ -750,13 +1575,31 @@ async def drive(run: Run) -> None: except Exception: shared_graph = None - stored_flag = winner.get("flag") or run.flag or "" + canonical_graph_flags: list[str] = [] + graph_flags_authoritative = False + if shared_graph is not None: + try: + canonical_graph_flags = list(shared_graph.snapshot().flags) + graph_flags_authoritative = any( + row.get("kind") in {"flag_found", "flag_invalidated"} + for row in shared_graph.events() + ) + except Exception: + canonical_graph_flags = [] + graph_flags_authoritative = False + stored_flag = ( + run.flag + or (canonical_graph_flags[0] + if graph_flags_authoritative and canonical_graph_flags else "") + or winner.get("flag") + or "" + ) def _flag_from_operator_cmd() -> str: - explicit = str(cmd.get("flag") or "").strip() + explicit = str(runtime_cmd.get("flag") or "").strip() if explicit: return explicit - raw = str(cmd.get("text") or "").strip() + raw = str(runtime_cmd.get("text") or "").strip() if not raw: return "" m = re.search(r"[A-Za-z0-9_]{0,15}\{[^}]{1,200}\}", raw) @@ -769,7 +1612,14 @@ def _flag_from_operator_cmd() -> str: # multi-flag: the flags already collected (from winner.json), minus the one # the operator is marking false — so a mark_false re-solve worker is seeded # with the SURVIVING flags and re-finds only the missing one, not the rest. - prior_flags = list(winner.get("flags") or run.flags or ([stored_flag] if stored_flag else [])) + prior_flags = list( + canonical_graph_flags + if graph_flags_authoritative + else run.flags + if (run.flags or run.invalidated_flags) + else winner.get("flags") + or ([stored_flag] if stored_flag else []) + ) if action == "mark_false": prior_flags = [f for f in prior_flags if f != flag] @@ -784,7 +1634,8 @@ async def _emit_bb(kind: str, **fields: Any) -> None: # mark_false: re-open the solve BEFORE the worker runs, so the board shows a # dead-end + reopened intents (fact-graph + blackboard grow the dead-end # node), and the rail flips back to running (RUN_REOPENED). - if action == "mark_false" and shared_graph is not None and flag: + if (action == "mark_false" and not mark_false_already_applied + and shared_graph is not None and flag): try: info = shared_graph.reopen_after_false_positive( actor="operator", flag=flag) @@ -828,12 +1679,139 @@ async def _emit_bb(kind: str, **fields: Any) -> None: web_access=True, kb=False, mode="respond", resume_session=winner.get("session") or None, - hitl_cmd={**cmd, "flag": flag}, + hitl_cmd={**runtime_cmd, "flag": flag}, found_flags=prior_flags, solver_label=solver_label, container=container, worker_env=worker_env, + identity=worker_identity_fields(profile), ) + worker._control_secret_values = list(materialized_secret_values) + if context_reservations and control_journal is not None: + worker._pending_control_context_reservations = context_reservations + worker._context_committer = control_journal.commit_context_binding + worker._context_releaser = control_journal.release_context_reservation + worker._context_delivery_unknown_marker = ( + control_journal.mark_context_delivery_unknown) + worker._context_binding_worker_id = context_owner + delivery_ack = cmd.get("_standby_delivery_ack") + delivery_loop = asyncio.get_running_loop() + + def _confirm_delivery(ok: bool) -> None: + if not isinstance(delivery_ack, asyncio.Future): + return + + def _set() -> None: + if not delivery_ack.done(): + delivery_ack.set_result(bool(ok)) + + delivery_loop.call_soon_threadsafe(_set) + + worker._context_delivery_callback = _confirm_delivery + # Publish the REAL worker cancellation boundary before awaiting it. A + # RunManager STOP can then kill the shelled CLI process tree first instead + # of merely cancelling this Python coroutine and leaking the child. + worker_cancel = getattr(worker, "cancel", None) + worker_runtime_exited = getattr(worker, "runtime_exit_confirmed", None) + worker_wait_runtime_exit = getattr(worker, "wait_runtime_exit", None) + + async def _cancel_runtime_owner() -> None: + if callable(worker_cancel): + result = worker_cancel() + if inspect.isawaitable(result): + await result + if callable(setup_cancel_boundary): + result = setup_cancel_boundary() + if inspect.isawaitable(result): + await result + + def _runtime_owner_exited() -> bool: + worker_done = True + if callable(worker_runtime_exited): + try: + worker_done = bool(worker_runtime_exited()) + except Exception: + worker_done = False + container_done = True + if callable(setup_exit_query): + try: + container_done = bool(setup_exit_query()) + except Exception: + container_done = False + return worker_done and container_done + + async def _wait_runtime_owner_exit(timeout=None) -> bool: + loop = asyncio.get_running_loop() + deadline = None if timeout is None else ( + loop.time() + max(0.0, float(timeout))) + if callable(worker_wait_runtime_exit): + remaining = None if deadline is None else max( + 0.0, deadline - loop.time()) + result = worker_wait_runtime_exit(remaining) + worker_done = bool( + await result if inspect.isawaitable(result) else result) + if not worker_done: + return False + if callable(setup_cancel_boundary) and not bool(setup_exit_query()): + try: + result = setup_cancel_boundary() + if inspect.isawaitable(result): + await result + except Exception: + return False + return _runtime_owner_exited() + + run.standby_cancel = _cancel_runtime_owner + run.standby_runtime_exited = _runtime_owner_exited + run.standby_wait_runtime_exit = _wait_runtime_owner_exit + + def _clear_runtime_registration() -> None: + if shared_graph is not None: + try: + shared_graph.close() + except Exception: + pass + if run.standby_cancel is _cancel_runtime_owner: + run.standby_cancel = None + if run.standby_runtime_exited is _runtime_owner_exited: + run.standby_runtime_exited = None + if run.standby_wait_runtime_exit is _wait_runtime_owner_exit: + run.standby_wait_runtime_exit = None + try: + current = asyncio.current_task() + except RuntimeError: + current = None + if run.standby_runtime_cleanup_task is current: + run.standby_runtime_cleanup_task = None + + async def _reap_runtime_until_exit() -> None: + """Keep the real kill boundary alive after the wrapper task exits. + + A PARTIAL control receipt is an audit fact, not permission to orphan the + child. Re-signal and poll until CliSolver proves every runner/process is + gone; later STOP/FORCE_CANCEL commands can use the same retained callbacks. + """ + confirmed = False + try: + while not confirmed: + try: + await _cancel_runtime_owner() + confirmed = await _wait_runtime_owner_exit(0.5) + if not confirmed: + await asyncio.sleep(0.1) + except asyncio.CancelledError: + raise + except Exception: + # A transient signal/poll failure must not abandon the only + # remaining process handle. Keep this watcher and retry. + await asyncio.sleep(0.1) + except asyncio.CancelledError: + # Preserve the callbacks when server shutdown interrupts the watcher; + # clearing them would turn an unproved runtime into a fake clean exit. + raise + finally: + if confirmed: + _clear_runtime_registration() try: out = await worker.run() # writeup: persist the body to sessions/{id}/writeup.md (and it already @@ -852,7 +1830,8 @@ async def _emit_bb(kind: str, **fields: Any) -> None: run.merge_flags(refound) try: (root / "winner.json").write_text(json.dumps({ - "engine": out.engine, "session": out.session, + "engine": out.engine, "worker_id": solver_label, + "session": out.session, "workdir": out.workdir, "flag": run.flag, "flags": list(run.flags), "challenge": challenge.model_dump(), @@ -860,16 +1839,51 @@ async def _emit_bb(kind: str, **fields: Any) -> None: except Exception: pass finally: - if shared_graph is not None: - try: - shared_graph.close() - except Exception: - pass - if container is not None: - try: - from muteki.solver.container_exec import teardown_container - await asyncio.to_thread(teardown_container, run.run_id, remove=True) - except Exception: - pass + # A secure transport can reject before any process boundary (for + # example Cursor exact-secret delivery is intentionally unsupported). + # Return those reservations immediately so the same context remains + # retryable; only a crossed/uncertain start may consume or strand them. + if (context_reservations and control_journal is not None + and not bool(getattr(worker, "_runtime_process_started", False)) + and not bool(getattr( + worker, "_control_context_delivery_committed", False)) + and not bool(getattr( + worker, "_control_context_delivery_unknown", False))): + released: set[tuple[str, str]] = set() + for context_id, reservation_id in context_reservations: + try: + if control_journal.release_context_reservation( + str(context_id), worker_id=context_owner, + reservation_id=str(reservation_id), + ): + released.add((str(context_id), str(reservation_id))) + except Exception: + pass + lock = getattr(worker, "_context_delivery_lock", None) + if lock is not None: + with lock: + worker._pending_control_context_reservations = [ + item for item in + worker._pending_control_context_reservations + if (str(item[0]), str(item[1])) not in released + ] + worker._notify_context_delivery(False) + # Always cross the worker boundary, including normal completion: a + # cancelled asyncio.to_thread await may leave its runner thread alive + # briefly, and CliSolver retains the process handles specifically so a + # final idempotent cancel can still reap them. + try: + await _cancel_runtime_owner() + except Exception: + pass + runtime_confirmed = _runtime_owner_exited() + if runtime_confirmed: + _clear_runtime_registration() + else: + cleanup_task = asyncio.create_task( + _reap_runtime_until_exit(), + name=f"standby-runtime-reap:{run.run_id}", + ) + run.standby_runtime_cleanup_task = cleanup_task return drive diff --git a/apps/web/llm_credentials.py b/apps/web/llm_credentials.py new file mode 100644 index 0000000..714bb4b --- /dev/null +++ b/apps/web/llm_credentials.py @@ -0,0 +1,80 @@ +"""Private API-key storage for the coordinator planner and conversation titler.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + + +LLM_PROFILE_NAMES = {"planner", "titler"} + + +class LlmCredentialStore: + """Store per-profile keys outside the normal worker configuration JSON.""" + + def __init__(self, sessions_root: str | Path) -> None: + self.root = Path(sessions_root) / "_secrets" / "llm_profiles" + self.root.mkdir(parents=True, exist_ok=True) + try: + self.root.chmod(0o700) + except OSError: + pass + + @staticmethod + def _profile(which: str) -> str: + profile = (which or "").strip().lower() + if profile not in LLM_PROFILE_NAMES: + raise ValueError("which must be planner or titler") + return profile + + def _path(self, which: str) -> Path: + return self.root / self._profile(which) / "API_KEY" + + def saved_key(self, which: str) -> str: + path = self._path(which) + try: + return path.read_text(encoding="utf-8").strip() + except OSError: + return "" + + def resolve(self, which: str) -> str: + return self.saved_key(which) or os.environ.get("MUTEKI_DEEPSEEK_API_KEY", "").strip() + + def source(self, which: str) -> str: + if self.saved_key(which): + return "saved" + if os.environ.get("MUTEKI_DEEPSEEK_API_KEY", "").strip(): + return "environment" + return "missing" + + def save(self, which: str, api_key: str) -> None: + value = str(api_key or "").strip() + if not value: + raise ValueError("API Key 不能为空") + path = self._path(which) + path.parent.mkdir(parents=True, exist_ok=True) + try: + path.parent.chmod(0o700) + except OSError: + pass + fd, tmp_name = tempfile.mkstemp(prefix=".API_KEY.", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(value + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(tmp_name, 0o600) + os.replace(tmp_name, path) + finally: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass + + def clear(self, which: str) -> None: + path = self._path(which) + try: + path.unlink() + except FileNotFoundError: + pass diff --git a/apps/web/llm_test.py b/apps/web/llm_test.py index b3d8613..9c548e8 100644 --- a/apps/web/llm_test.py +++ b/apps/web/llm_test.py @@ -9,8 +9,9 @@ on a perfectly healthy endpoint. We use the client's default cap and treat "chat returned without raising" as success. -The API key is NOT taken from the request — it stays in .env -(MUTEKI_DEEPSEEK_API_KEY). base_url empty → default DeepSeek endpoint. +The caller resolves the selected profile's saved key, with +MUTEKI_DEEPSEEK_API_KEY retained as the fallback. base_url empty means the +default DeepSeek endpoint. """ from __future__ import annotations @@ -23,9 +24,12 @@ async def test_llm_endpoint( which: str, base_url: Optional[str] = None, model: Optional[str] = None, + api_key: Optional[str] = None, + temperature_mode: Optional[str] = None, + temperature: Any = None, ) -> dict[str, Any]: """Make one minimal chat against the (edited) endpoint. Never raises.""" - from muteki.core.llm import LLMClient + from muteki.core.llm import LLMClient, llm_temperature_kwargs which = (which or "").strip() or "planner" base_url = (base_url or "").strip() @@ -33,7 +37,15 @@ async def test_llm_endpoint( if not model: return {"ok": False, "detail": "model 不能为空", "model": ""} - client = LLMClient(base_url=base_url) if base_url else LLMClient() + client_kwargs: dict[str, Any] = llm_temperature_kwargs({ + "temperature_mode": temperature_mode, + "temperature": temperature, + }) + if base_url: + client_kwargs["base_url"] = base_url + if (api_key or "").strip(): + client_kwargs["api_key"] = str(api_key).strip() + client = LLMClient(**client_kwargs) try: # default cap (generous) so a reasoning model's content isn't starved. resp = await client.chat( diff --git a/apps/web/platform_update.py b/apps/web/platform_update.py new file mode 100644 index 0000000..2315533 --- /dev/null +++ b/apps/web/platform_update.py @@ -0,0 +1,51 @@ +"""Web 设置页使用的平台升级控制器。""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +from muteki.updater import UpdateManager + + +class PlatformUpdateController: + def __init__(self, install_root: Path | None = None) -> None: + self.manager = UpdateManager(install_root) + self._task: asyncio.Task[None] | None = None + + def status(self) -> dict[str, Any]: + payload = self.manager.status() + payload["running"] = bool(self._task and not self._task.done()) + return payload + + async def check(self, target: str | None = None) -> dict[str, Any]: + if self._task and not self._task.done(): + return self.status() + await asyncio.to_thread(self.manager.check, target) + return self.status() + + async def start(self, target: str | None = None, *, force: bool = False) -> dict[str, Any]: + if self._task and not self._task.done(): + return self.status() + if self.manager.status().get("deployment") == "compose": + raise RuntimeError("容器部署请在宿主机执行 muteki upgrade --compose") + + async def run() -> None: + try: + await asyncio.to_thread(self.manager.upgrade, target, force=force) + except Exception: + # UpdateManager 已把可展示错误写入 update-state.json。 + return + + self._task = asyncio.create_task(run()) + await asyncio.sleep(0) + return self.status() + + async def rollback(self) -> dict[str, Any]: + if self._task and not self._task.done(): + return self.status() + if self.manager.status().get("deployment") == "compose": + raise RuntimeError("容器部署请在宿主机执行 muteki rollback --compose") + await asyncio.to_thread(self.manager.rollback) + return self.status() diff --git a/apps/web/protocol2_adapter.py b/apps/web/protocol2_adapter.py new file mode 100644 index 0000000..2b8eeb4 --- /dev/null +++ b/apps/web/protocol2_adapter.py @@ -0,0 +1,1351 @@ +"""Web composition root for Protocol 2 live-local canaries. + +Protocol 1 remains available for existing runs. A request must explicitly select +``protocol: 2`` and satisfy the finite-budget/single-worker canary contract; there +is no hot switch and no permissive fallback. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import time +from pathlib import Path +from typing import Any, Mapping, Sequence +from urllib.parse import urlparse + +from muteki.epistemic.authority import ( + AcceptedFlagPublicationV1, + GateInputRejected, + resolve_accepted_flag_publication, +) +from muteki.epistemic.cas import CASIntegrityError, ReceiptCAS +from muteki.epistemic.contracts import canonical_digest +from muteki.epistemic.sqlite_store import ( + CommandEvent, + EpistemicSQLiteStore, + FlagAcceptedOutboxV1, + IntegrityError, + ProjectionMutation, +) +from muteki.eval.manifests import ( + EVAL_CONTRACT_VERSION, + TrialAssignment, + TrialIdentity, +) +from muteki.runtime.canary import ( + CanaryEvidence, + CanaryLevel, + admit_canary, + missing_s4e_receipts, +) +from muteki.runtime.composition import HostRunFactory +from muteki.runtime.closure import ClosureResolutionError, resolve_s4e_closure +from muteki.runtime.cognition import CognitiveFeatureGateV1 +from muteki.runtime.controller import BootRecoveryCapability +from muteki.runtime.contracts import AttemptIdentity, ExecutionScope, LeaseIdentity +from muteki.runtime.egress_proxy import LoopbackAllowlistProxy +from muteki.runtime.live_session import Protocol2RunSession +from muteki.runtime.network import NetworkPolicyAuthority +from muteki.runtime.run_catalog import RunCatalog +from muteki.solver.cli_driver import driver_for + + +class Protocol2Unavailable(RuntimeError): + pass + + +class _CliToolPolicyAdapter: + """Read back the actual CLI web-tool isolation contract. + + Local full-model workers still need egress to their configured model provider. + The black-box boundary required by this project is that the CLI cannot expose its + native WebSearch/WebFetch surface. We verify the concrete argv, not a manifest + boolean. Cursor therefore fails this adapter closed. + """ + + def __init__(self, profiles: Sequence[Mapping[str, Any]]) -> None: + self._profiles = [dict(profile) for profile in profiles] + self._policy: dict[str, Any] = {} + + def _verify(self) -> None: + if sys.platform != "darwin" or not Path("/usr/bin/sandbox-exec").is_file(): + raise Protocol2Unavailable( + "live-local host egress enforcement is unavailable" + ) + for profile in self._profiles: + driver = driver_for(profile) + if not bool(getattr(driver, "offline_web_isolation", False)): + raise Protocol2Unavailable( + f"profile {profile.get('id')} cannot isolate native web tools" + ) + argv = driver.build_execute( + "Reply exactly READY", + driver.new_session(), + web_access=False, + kb_access=False, + ) + if driver.name == "claude": + try: + start = argv.index("--disallowed-tools") + end = argv.index("--") + except ValueError as exc: + raise Protocol2Unavailable( + "Claude offline deny flags are absent" + ) from exc + denied = set(argv[start + 1 : end]) + if not {"WebSearch", "WebFetch"}.issubset(denied): + raise Protocol2Unavailable( + "Claude native web tools are not both denied" + ) + elif driver.name == "codex" and "--search" in argv: + raise Protocol2Unavailable("Codex native search remained enabled") + elif driver.name == "cursor": + raise Protocol2Unavailable("Cursor has no enforceable offline mode") + + def apply(self, policy: Mapping) -> Mapping: + self._verify() + self._policy = { + "allowlist": tuple(policy.get("allowlist") or ()), + "mode": str(policy.get("mode") or ""), + } + return dict(self._policy) + + def readback(self) -> Mapping: + self._verify() + return dict(self._policy) + + +class Protocol2WebAdapter: + def __init__(self, *, control_root: Path) -> None: + self.root = Path(control_root) / "protocol2" + self.catalog = RunCatalog.open_or_create(root=self.root) + self._live: dict[str, Protocol2RunSession] = {} + + def has_run(self, run_id: str) -> bool: + return self.catalog.has_run(run_id) + + def list_run_ids(self) -> tuple[str, ...]: + return self.catalog.list_run_ids() + + def recover_flag_publications( + self, run_id: str + ) -> tuple[AcceptedFlagPublicationV1, ...]: + """Resolve accepted flag handoffs without inferring run success.""" + + view = self.catalog.run_view(run_id) + root = Path(view["target_root"]) + target = root / "epistemic-v2.db" + if not target.is_file(): + raise Protocol2Unavailable("canonical run store is unavailable") + store = EpistemicSQLiteStore.open(target) + cas = ReceiptCAS(root / "receipt-cas") + publications: list[AcceptedFlagPublicationV1] = [] + try: + with store.stable_read_snapshot(): + try: + store.verify() + rows = store._conn.execute( + "SELECT payload_json FROM immutable_outbox " + "WHERE topic='flag.accepted' ORDER BY outbox_id" + ).fetchall() + for row in rows: + payload = json.loads(row[0]) + accepted = FlagAcceptedOutboxV1.from_payload(payload) + flag_bytes = cas.read_verified( + accepted.flag_object_digest + ) + flag = flag_bytes.decode( + accepted.flag_encoding, errors="strict" + ) + publications.append(resolve_accepted_flag_publication( + store=store, + cas=cas, + attempt_digest=accepted.attempt_digest, + flag=flag, + )) + except ( + CASIntegrityError, + GateInputRejected, + IntegrityError, + json.JSONDecodeError, + OSError, + TypeError, + UnicodeError, + ValueError, + ) as exc: + raise Protocol2Unavailable( + "accepted flag publication did not resolve" + ) from exc + finally: + store.close() + return tuple(publications) + + def run_view(self, run_id: str) -> dict[str, Any]: + return self.catalog.run_view(run_id) + + def _catalog_policy(self, run_id: str) -> dict[str, Any]: + run = self.catalog._store.catalog_run(run_id) + provision = self.catalog._store.provision_status(run["operation_id"]) + drafts = [ + row for row in self.catalog._store.event_rows(kind="DRAFT_CREATED") + if row["payload"].get("draft_id") == provision["draft_id"] + ] + if len(drafts) != 1 or type(drafts[0]["payload"].get("policy")) is not dict: + raise Protocol2Unavailable("catalog policy does not resolve uniquely") + return dict(drafts[0]["payload"]["policy"]) + + def _catalog_policy_digest(self, run_id: str) -> str: + return canonical_digest(self._catalog_policy(run_id)) + + @staticmethod + def _exact_event_receipt( + *, + store: EpistemicSQLiteStore, + receipt_digest: str, + command_id: str, + event_id: str, + kind: str, + ) -> dict[str, Any]: + """Resolve one named receipt through its exact canonical command/event.""" + try: + receipt = store.resolve_receipt(receipt_digest) + rows = [ + row + for row in store.event_rows(kind=kind) + if row["event_id"] == event_id + and store.receipt_digest_for_event(row["event_digest"]) + == receipt_digest + ] + except (IntegrityError, KeyError, TypeError, ValueError) as exc: + raise Protocol2Unavailable( + f"canonical {kind} receipt did not resolve" + ) from exc + if receipt.command_id != command_id or len(rows) != 1: + raise Protocol2Unavailable( + f"canonical {kind} command/event identity diverged" + ) + return rows[0] + + @staticmethod + def _single_event_receipt( + *, + store: EpistemicSQLiteStore, + command_id: str, + event_id: str, + kind: str, + ) -> str: + rows = [ + row + for row in store.event_rows(kind=kind) + if row["event_id"] == event_id + ] + if len(rows) != 1: + raise Protocol2Unavailable( + f"canonical {kind} event does not resolve uniquely" + ) + try: + receipt = store.resolve_receipt_for_event(rows[0]["event_digest"]) + except (IntegrityError, KeyError, TypeError, ValueError) as exc: + raise Protocol2Unavailable( + f"canonical {kind} receipt did not resolve" + ) from exc + if receipt.command_id != command_id: + raise Protocol2Unavailable( + f"canonical {kind} command identity diverged" + ) + return receipt.digest + + def _resolve_live_closure( + self, *, run_id: str, receipt_chain: Mapping[str, str] + ): + view = self.catalog.run_view(run_id) + root = Path(view["target_root"]) + target = root / "epistemic-v2.db" + if not target.is_file(): + raise Protocol2Unavailable("canonical run store is unavailable") + store = EpistemicSQLiteStore.open(target) + try: + resolved = resolve_s4e_closure( + store=store, + cas=ReceiptCAS(root / "receipt-cas"), + receipt_chain=receipt_chain, + ) + except (CASIntegrityError, ClosureResolutionError) as exc: + raise Protocol2Unavailable( + "S4-E semantic closure did not resolve" + ) from exc + finally: + store.close() + if resolved.policy_digest != self._catalog_policy_digest(run_id): + raise Protocol2Unavailable( + "S4-E policy digest is not bound to the catalog assignment" + ) + return resolved + + def _resolve_live_status_evidence( + self, + *, + run_id: str, + receipt_chain: Mapping[str, str], + canary_seq: int, + ) -> None: + """Bind every non-release canary name to canonical run/catalog history.""" + catalog_store = self.catalog._store + catalog_run = catalog_store.catalog_run(run_id) + operation_id = catalog_run["operation_id"] + provision = catalog_store.provision_status(operation_id) + policy = self._catalog_policy(run_id) + if ( + provision["run_id"] != run_id + or provision["state"] != "sealed" + or policy.get("run_id") != run_id + or policy.get("protocol") != 2 + or policy.get("offline") is not True + ): + raise Protocol2Unavailable("catalog run/policy identity diverged") + limits = policy.get("budget") + if type(limits) is not dict or type(limits.get("wall_ms")) is not int: + raise Protocol2Unavailable("catalog canary budget is malformed") + + schema = self._exact_event_receipt( + store=catalog_store, + receipt_digest=receipt_chain["schema"], + command_id=f"provision:sealed:{operation_id}", + event_id=f"event:provision:sealed:{operation_id}", + kind="RUN_SEALED", + ) + expected_schema = { + "anchor_digest": catalog_run["anchor_digest"], + "operation_id": operation_id, + "owner_epoch": provision["owner_epoch"], + "run_id": run_id, + } + if schema["payload"] != expected_schema: + raise Protocol2Unavailable("catalog schema/run binding diverged") + + platform_operation = f"platform:egress-proxy:{run_id}" + platform_admission = self._exact_event_receipt( + store=catalog_store, + receipt_digest=receipt_chain["platform_admission"], + command_id=f"{platform_operation}:admitted", + event_id=f"event:{platform_operation}:admitted", + kind="PLATFORM_OPERATION_ADMITTED", + ) + admission_payload = platform_admission["payload"] + if set(admission_payload) != { + "conflict_key", + "destination", + "operation_id", + "owner_epoch", + "run_id", + "wall_ms", + }: + raise Protocol2Unavailable("platform admission payload is malformed") + destination = admission_payload["destination"] + if ( + type(destination) is not str + or not destination + or admission_payload + != { + "conflict_key": f"egress-proxy:{run_id}", + "destination": destination, + "operation_id": platform_operation, + "owner_epoch": 1, + "run_id": run_id, + "wall_ms": limits["wall_ms"], + } + ): + raise Protocol2Unavailable("platform admission identity diverged") + + target_root = Path(provision["target_root"]) + target = target_root / "epistemic-v2.db" + if not target.is_file(): + raise Protocol2Unavailable("canonical run store is unavailable") + store = EpistemicSQLiteStore.open(target) + try: + policy_digest = canonical_digest(policy) + attachments = catalog_store.draft_attachments(provision["draft_id"]) + attachment_digests = [item["digest"] for item in attachments] + cas = ReceiptCAS(target_root / "receipt-cas") + for item in attachments: + if len(cas.read_verified(item["digest"])) != item["byte_count"]: + raise Protocol2Unavailable("canonical CAS byte count diverged") + cas_event = self._exact_event_receipt( + store=store, + receipt_digest=receipt_chain["cas"], + command_id="CREATE_RUN", + event_id="event:CREATE_RUN", + kind="RUN_CREATED", + ) + expected_create = { + "attachment_digests": attachment_digests, + "manifest_digest": provision["manifest_digest"], + } + if cas_event["payload"] != expected_create: + raise Protocol2Unavailable("canonical CAS/run binding diverged") + create_receipt = store.resolve_receipt(receipt_chain["cas"]) + expected_anchor = canonical_digest( + { + "attachment_digests": attachment_digests, + "create_receipt_digest": receipt_chain["cas"], + "manifest_digest": provision["manifest_digest"], + "run_id": run_id, + "state_checksum": create_receipt.payload["state_checksum"], + } + ) + if catalog_run["anchor_digest"] != expected_anchor: + raise Protocol2Unavailable("catalog/run anchor binding diverged") + + boot_verifying_digest = self._single_event_receipt( + store=store, + command_id="BOOT_VERIFYING:1", + event_id="event:BOOT_VERIFYING:1", + kind="BOOT_VERIFYING", + ) + boot_verifying = self._exact_event_receipt( + store=store, + receipt_digest=boot_verifying_digest, + command_id="BOOT_VERIFYING:1", + event_id="event:BOOT_VERIFYING:1", + kind="BOOT_VERIFYING", + ) + if boot_verifying["payload"] != { + "boot_epoch": 1, + "writer_epoch": 1, + }: + raise Protocol2Unavailable("kernel boot identity diverged") + verifying_receipt = store.resolve_receipt(boot_verifying_digest) + expected_attestation = canonical_digest( + { + "anchor": store.run_anchor(), + "boot_epoch": 1, + "state_checksum": verifying_receipt.payload["state_checksum"], + "writer_epoch": 1, + } + ) + kernel = self._exact_event_receipt( + store=store, + receipt_digest=receipt_chain["kernel"], + command_id="BOOT_READY:1", + event_id="event:BOOT_READY:1", + kind="BOOT_READY", + ) + if kernel["payload"] != { + "attestation_digest": expected_attestation + }: + raise Protocol2Unavailable("kernel readiness binding diverged") + + network_operation = f"network:{run_id}" + expected_network_policy = { + "allowlist": [destination], + "mode": "allowlist", + } + network_policy_digest = canonical_digest(expected_network_policy) + network = self._exact_event_receipt( + store=store, + receipt_digest=receipt_chain["network_policy"], + command_id=f"network-policy:{network_operation}", + event_id=f"event:network-policy:{network_operation}", + kind="NETWORK_POLICY_ENFORCED", + ) + if network["payload"] != { + "operation_id": network_operation, + "policy_digest": network_policy_digest, + "readback_digest": network_policy_digest, + }: + raise Protocol2Unavailable("network policy binding diverged") + + identity = TrialIdentity( + f"live-canary:{run_id}", + f"trial:{run_id}", + f"intent:{run_id}", + EVAL_CONTRACT_VERSION, + ) + assignment = TrialAssignment( + identity, + str(policy.get("challenge_id") or ""), + "candidate", + f"pair:{run_id}", + tuple(sorted((str(key), int(value)) for key, value in limits.items())), + policy_digest, + ) + assignment_payload = { + "assignment": assignment.as_dict(), + "identity": { + "intention_id": identity.intention_id, + "protocol_version": identity.protocol_version, + "study_id": identity.study_id, + "trial_id": identity.trial_id, + }, + "run_id": run_id, + } + eval_assignment = self._exact_event_receipt( + store=store, + receipt_digest=receipt_chain["eval_assignment"], + command_id=f"eval:assignment:{run_id}", + event_id=f"event:eval:assignment:{run_id}", + kind="EVAL_ASSIGNMENT_BOUND", + ) + if eval_assignment["payload"] != assignment_payload: + raise Protocol2Unavailable("eval assignment binding diverged") + + state = store.state() + scope = ExecutionScope( + run_id, state.run_fence_epoch, state.execution_generation + ) + + def canonical_admission(receipt_digest: str) -> tuple[dict, LeaseIdentity]: + try: + receipt = store.resolve_receipt(receipt_digest) + rows = [ + row + for row in store.event_rows(kind="ATTEMPT_ADMITTED") + if store.receipt_digest_for_event(row["event_digest"]) + == receipt_digest + ] + except (IntegrityError, KeyError, TypeError, ValueError) as exc: + raise Protocol2Unavailable( + "canonical attempt admission did not resolve" + ) from exc + if len(rows) != 1: + raise Protocol2Unavailable( + "canonical attempt admission is not unique" + ) + payload = rows[0]["payload"] + try: + attempt = AttemptIdentity( + scope, + payload["branch_id"], + payload["attempt_id"], + payload["launch_ordinal"], + ) + lease = LeaseIdentity( + attempt, + payload["lease_id"], + payload["lease_epoch"], + payload["worker_generation"], + ) + except (KeyError, TypeError, ValueError) as exc: + raise Protocol2Unavailable( + "attempt admission identity is malformed" + ) from exc + if ( + receipt.command_id != f"attempt:admit:{attempt.attempt_id}" + or rows[0]["event_id"] + != f"event:attempt:admit:{attempt.attempt_id}" + or payload.get("scope_digest") != scope.digest + or payload.get("attempt_digest") != attempt.digest + or payload.get("lease_digest") != lease.digest + or payload.get("policy_digest") != policy_digest + ): + raise Protocol2Unavailable( + "attempt admission run/scope/lease binding diverged" + ) + return rows[0], lease + + admission, admission_lease = canonical_admission( + receipt_chain["admission"] + ) + destination_digest = canonical_digest(destination) + egress_id = f"provider:{destination_digest}" + egress = self._exact_event_receipt( + store=store, + receipt_digest=receipt_chain["egress"], + command_id=f"egress:{egress_id}", + event_id=f"event:egress:{egress_id}", + kind="EGRESS_RECEIPT", + ) + if egress["payload"] != { + "allowed": True, + "destination": destination, + "lease_digest": admission_lease.digest, + "observed": False, + "observation_digest": "", + "policy_digest": network_policy_digest, + }: + raise Protocol2Unavailable("egress permit binding diverged") + + observation_id = f"provider-observed:{destination_digest}" + egress_observation = self._exact_event_receipt( + store=store, + receipt_digest=receipt_chain["egress_observation"], + command_id=f"egress:{observation_id}", + event_id=f"event:egress:{observation_id}", + kind="EGRESS_RECEIPT", + ) + observation_payload = egress_observation["payload"] + observation_digest = observation_payload.get("observation_digest") + if ( + type(observation_digest) is not str + or len(observation_digest) != 64 + or any(char not in "0123456789abcdef" for char in observation_digest) + or observation_payload + != { + "allowed": True, + "destination": destination, + "lease_digest": observation_payload.get("lease_digest"), + "observed": True, + "observation_digest": observation_digest, + "policy_digest": network_policy_digest, + } + ): + raise Protocol2Unavailable("egress observation binding diverged") + observation_admissions = [ + row + for row in store.event_rows(kind="ATTEMPT_ADMITTED") + if row["payload"].get("lease_digest") + == observation_payload["lease_digest"] + ] + if len(observation_admissions) != 1: + raise Protocol2Unavailable( + "egress observation has no unique canonical attempt" + ) + observation_receipt = store.receipt_digest_for_event( + observation_admissions[0]["event_digest"] + ) + observation_admission, _ = canonical_admission(observation_receipt) + if observation_admission != observation_admissions[0]: + raise Protocol2Unavailable( + "egress observation attempt identity diverged" + ) + if not ( + cas_event["seq"] + < boot_verifying["seq"] + < kernel["seq"] + < network["seq"] + < eval_assignment["seq"] + < admission["seq"] + < egress["seq"] + < egress_observation["seq"] + ): + raise Protocol2Unavailable("run evidence order diverged") + except ( + CASIntegrityError, + IntegrityError, + KeyError, + OSError, + TypeError, + ValueError, + ) as exc: + raise Protocol2Unavailable( + "canonical run evidence did not resolve" + ) from exc + finally: + store.close() + + platform_supervisor = self._exact_event_receipt( + store=catalog_store, + receipt_digest=receipt_chain["platform_supervisor"], + command_id=f"{platform_operation}:started", + event_id=f"event:{platform_operation}:started", + kind="PLATFORM_OPERATION_OBSERVED", + ) + supervisor_payload = platform_supervisor["payload"] + endpoint_digest = supervisor_payload.get("proxy_endpoint_digest") + if ( + type(endpoint_digest) is not str + or len(endpoint_digest) != 64 + or any(char not in "0123456789abcdef" for char in endpoint_digest) + or supervisor_payload + != { + "operation_id": platform_operation, + "owner_epoch": 1, + "policy_digest": network_policy_digest, + "proxy_endpoint_digest": endpoint_digest, + "run_id": run_id, + } + ): + raise Protocol2Unavailable("platform supervisor identity diverged") + platform_cleanup = self._exact_event_receipt( + store=catalog_store, + receipt_digest=receipt_chain["platform_cleanup"], + command_id=f"{platform_operation}:settled", + event_id=f"event:{platform_operation}:settled", + kind="PLATFORM_OPERATION_SETTLED", + ) + if platform_cleanup["payload"] != { + "operation_id": platform_operation, + "owner_epoch": 1, + "run_id": run_id, + "terminal": "observed_closed", + }: + raise Protocol2Unavailable("platform cleanup identity diverged") + if not ( + schema["seq"] + < platform_admission["seq"] + < platform_supervisor["seq"] + < platform_cleanup["seq"] + < canary_seq + ): + raise Protocol2Unavailable("catalog evidence order diverged") + + def archive( + self, *, run_id: str, operation_id: str, occurred_at_ns: int + ) -> dict[str, Any]: + if run_id in self._live: + raise Protocol2Unavailable( + "Protocol 2 live owner must drain before archive" + ) + return self.catalog.request_archive( + operation_id=operation_id, + run_id=run_id, + owner_epoch=1, + occurred_at_ns=occurred_at_ns, + ) + + def begin_purge( + self, + *, + run_id: str, + operation_id: str, + items: tuple[Mapping, ...], + occurred_at_ns: int, + ) -> dict[str, Any]: + if run_id in self._live: + raise Protocol2Unavailable("Protocol 2 live owner must drain before purge") + return self.catalog.begin_purge( + operation_id=operation_id, + run_id=run_id, + owner_epoch=1, + items=items, + occurred_at_ns=occurred_at_ns, + ) + + def purge_item_absent(self, **kwargs: Any) -> dict[str, Any]: + return self.catalog.record_purge_item_absent(**kwargs) + + def purge_item_unknown(self, **kwargs: Any) -> dict[str, Any]: + return self.catalog.record_purge_item_unknown(**kwargs) + + def complete_purge( + self, *, operation_id: str, occurred_at_ns: int + ) -> dict[str, Any]: + return self.catalog.complete_purge( + operation_id=operation_id, occurred_at_ns=occurred_at_ns + ) + + def archive_status(self, operation_id: str) -> dict[str, Any]: + return self.catalog._store.archive_status(operation_id) + + def purge_status(self, operation_id: str) -> dict[str, Any]: + return self.catalog._store.purge_status(operation_id) + + @staticmethod + def _release_receipts() -> dict[str, str]: + values = { + "baseline": os.environ.get("MUTEKI_PROTOCOL2_BASELINE_RECEIPT", "").strip(), + "fault_suite": os.environ.get( + "MUTEKI_PROTOCOL2_FAULT_SUITE_RECEIPT", "" + ).strip(), + } + return { + key: value + for key, value in values.items() + if len(value) == 64 and all(char in "0123456789abcdef" for char in value) + } + + @staticmethod + def _provider_destination(profiles: Sequence[Mapping[str, Any]]) -> str: + destinations: set[str] = set() + for profile in profiles: + base = str(profile.get("base_url") or "").strip() + if not base: + raise Protocol2Unavailable( + "live-local canary requires an explicit provider endpoint" + ) + parsed = urlparse(base) + if parsed.scheme != "https" or not parsed.hostname: + raise Protocol2Unavailable( + "provider endpoint must be an explicit HTTPS URL" + ) + port = parsed.port or 443 + destinations.add(f"{parsed.hostname}:{port}") + if len(destinations) != 1: + raise Protocol2Unavailable( + "minimal canary permits exactly one provider destination" + ) + return next(iter(destinations)) + + @staticmethod + def _provider_base_url(profiles: Sequence[Mapping[str, Any]]) -> str: + values = { + str(profile.get("base_url") or "").rstrip("/") for profile in profiles + } + if len(values) != 1 or not next(iter(values)): + raise Protocol2Unavailable( + "minimal canary requires one explicit provider base URL" + ) + return next(iter(values)) + + def prepare_live_session( + self, + *, + run_id: str, + challenge_id: str, + attachments: Sequence[str], + profiles: Sequence[Mapping[str, Any]], + artifacts: Any, + max_attempts: int, + max_barren_attempts: int, + wall_ms: int, + token_budget: int, + cost_micro_usd: int, + tool_call_budget: int, + expected_goal_units: int, + cognitive_feature_gate: CognitiveFeatureGateV1 | None = None, + ) -> Protocol2RunSession: + if run_id in self._live: + raise Protocol2Unavailable("Protocol 2 run is already live") + release = self._release_receipts() + if len(release) != 2: + missing = sorted({"baseline", "fault_suite"} - set(release)) + raise Protocol2Unavailable( + "Protocol 2 live canary is release-gated; missing receipt(s): " + + ", ".join(missing) + ) + limits = { + "attempts": int(max_attempts), + "cost_micro_usd": int(cost_micro_usd), + "tokens": int(token_budget), + "tool_calls": int(tool_call_budget), + "wall_ms": int(wall_ms), + "worker_ms": int(wall_ms), + } + if any(value <= 0 for value in limits.values()): + raise Protocol2Unavailable( + "Protocol 2 live canary requires finite positive budgets" + ) + if len(profiles) != 1: + raise Protocol2Unavailable( + "minimal live canary requires exactly one worker profile" + ) + if type(expected_goal_units) is not int or expected_goal_units != 1: + raise Protocol2Unavailable( + "S4-E v1 live canary supports exactly one expected goal unit" + ) + if cognitive_feature_gate is not None and type( + cognitive_feature_gate + ) is not CognitiveFeatureGateV1: + raise TypeError( + "cognitive_feature_gate must be CognitiveFeatureGateV1 or None" + ) + provider_destination = self._provider_destination(profiles) + provider_base_url = self._provider_base_url(profiles) + policy = { + "budget": limits, + "challenge_id": challenge_id, + "offline": True, + "profile_ids": tuple(str(p.get("id") or "") for p in profiles), + "protocol": 2, + "run_id": run_id, + } + if cognitive_feature_gate is not None: + # A C6 shape is never selected by an HTTP/UI string. The immutable + # policy carries its exact canonical body before provision, while the + # default web canary remains on the trusted S4 baseline. + policy["cognitive_feature_gate"] = cognitive_feature_gate.canonical_body() + policy_digest = canonical_digest(policy) + draft_id = f"draft:{run_id}" + operation_id = f"provision:{run_id}" + now = time.time_ns() + self.catalog.create_draft(draft_id=draft_id, policy=policy, occurred_at_ns=now) + for ordinal, raw_path in enumerate(attachments, start=1): + path = Path(raw_path) + self.catalog.add_attachment( + draft_id=draft_id, + attachment_id=f"attachment:{run_id}:{ordinal}:{path.name}", + data=path.read_bytes(), + occurred_at_ns=now + ordinal, + ) + manifest_digest = canonical_digest( + { + "attachment_count": len(attachments), + "policy_digest": policy_digest, + } + ) + target_root = self.root / "runs" / run_id + self.catalog.begin_provision( + operation_id=operation_id, + draft_id=draft_id, + run_id=run_id, + target_root=target_root, + manifest_digest=manifest_digest, + owner_epoch=1, + occurred_at_ns=now + len(attachments) + 1, + ) + self.catalog.materialize( + operation_id=operation_id, occurred_at_ns=now + len(attachments) + 2 + ) + factory = HostRunFactory(catalog=self.catalog, artifacts=artifacts) + context, ports = factory.open( + run_id=run_id, + boot_capability=BootRecoveryCapability( + 1, 1, canonical_digest({"run_id": run_id, "owner": "web"}) + ), + occurred_at_ns=now + len(attachments) + 3, + ) + scope, supervisor = factory.start_execution( + ports=ports, + idempotency_key=f"start:{run_id}", + occurred_at_ns=now + len(attachments) + 4, + ) + ports.admission.create_branch( + branch_id="root", + max_attempts=max_attempts, + occurred_at_ns=now + len(attachments) + 5, + ) + ports.admission.create_budget_account( + account_id="run", limits=limits, occurred_at_ns=now + len(attachments) + 6 + ) + + network = NetworkPolicyAuthority( + store=ports.store, adapter=_CliToolPolicyAdapter(profiles) + ) + enforced = network.apply_and_readback( + operation_id=f"network:{run_id}", + mode="allowlist", + allowlist=[provider_destination], + occurred_at_ns=now + len(attachments) + 7, + ) + assignment = TrialAssignment( + TrialIdentity( + f"live-canary:{run_id}", + f"trial:{run_id}", + f"intent:{run_id}", + EVAL_CONTRACT_VERSION, + ), + challenge_id, + "candidate", + f"pair:{run_id}", + tuple(sorted(limits.items())), + policy_digest, + ) + assignment_payload = { + "assignment": assignment.as_dict(), + "identity": { + "intention_id": assignment.identity.intention_id, + "protocol_version": assignment.identity.protocol_version, + "study_id": assignment.identity.study_id, + "trial_id": assignment.identity.trial_id, + }, + "run_id": run_id, + } + assignment_result = ports.store.commit_command( + command_id=f"eval:assignment:{run_id}", + idempotency_key=f"eval:assignment:{run_id}", + command_payload=assignment_payload, + events=[ + CommandEvent( + f"event:eval:assignment:{run_id}", + "EVAL_ASSIGNMENT_BOUND", + "protocol2-web-adapter", + now + len(attachments) + 8, + assignment_payload, + ) + ], + committed_at_ns=now + len(attachments) + 8, + ) + external = { + "cas": self._single_event_receipt( + store=ports.store, + command_id="CREATE_RUN", + event_id="event:CREATE_RUN", + kind="RUN_CREATED", + ), + "eval_assignment": assignment_result.receipt_digest, + "kernel": self._single_event_receipt( + store=ports.store, + command_id="BOOT_READY:1", + event_id="event:BOOT_READY:1", + kind="BOOT_READY", + ), + "network_policy": enforced.enforcement_receipt_digest, + "schema": self._single_event_receipt( + store=self.catalog._store, + command_id=f"provision:sealed:{operation_id}", + event_id=f"event:provision:sealed:{operation_id}", + kind="RUN_SEALED", + ), + **release, + } + proxy_operation = f"platform:egress-proxy:{run_id}" + proxy_admission_payload = { + "conflict_key": f"egress-proxy:{run_id}", + "destination": provider_destination, + "operation_id": proxy_operation, + "owner_epoch": 1, + "run_id": run_id, + "wall_ms": limits["wall_ms"], + } + proxy_admission = self.catalog._store.commit_command( + command_id=f"{proxy_operation}:admitted", + idempotency_key=f"{proxy_operation}:admitted", + command_payload=proxy_admission_payload, + events=[ + CommandEvent( + f"event:{proxy_operation}:admitted", + "PLATFORM_OPERATION_ADMITTED", + "protocol2-web-adapter", + time.time_ns(), + proxy_admission_payload, + ) + ], + committed_at_ns=time.time_ns(), + ) + proxy = LoopbackAllowlistProxy(provider_destination) + try: + proxy.start() + except Exception: + proxy.close() + raise + proxy_started_payload = { + "operation_id": proxy_operation, + "owner_epoch": 1, + "policy_digest": enforced.policy_digest, + "proxy_endpoint_digest": canonical_digest( + {"host": "localhost", "port": proxy.port} + ), + "run_id": run_id, + } + try: + proxy_started = self.catalog._store.commit_command( + command_id=f"{proxy_operation}:started", + idempotency_key=f"{proxy_operation}:started", + command_payload=proxy_started_payload, + events=[ + CommandEvent( + f"event:{proxy_operation}:started", + "PLATFORM_OPERATION_OBSERVED", + "protocol2-web-adapter", + time.time_ns(), + proxy_started_payload, + ) + ], + committed_at_ns=time.time_ns(), + ) + except Exception: + proxy.close() + raise + external["platform_admission"] = proxy_admission.receipt_digest + external["platform_supervisor"] = proxy_started.receipt_digest + per_attempt = { + key: max(1, value // max_attempts) for key, value in limits.items() + } + per_attempt["attempts"] = 1 + + def admit_completion( + live_session: Protocol2RunSession, + receipts: Mapping[str, str], + solved: bool, + ) -> Mapping[str, str]: + settled_payload = { + "operation_id": proxy_operation, + "owner_epoch": 1, + "run_id": run_id, + "terminal": "observed_closed", + } + settled = self.catalog._store.commit_command( + command_id=f"{proxy_operation}:settled", + idempotency_key=f"{proxy_operation}:settled", + command_payload=settled_payload, + events=[ + CommandEvent( + f"event:{proxy_operation}:settled", + "PLATFORM_OPERATION_SETTLED", + "protocol2-web-adapter", + time.time_ns(), + settled_payload, + ) + ], + committed_at_ns=time.time_ns(), + ) + closure = {**dict(receipts), "platform_cleanup": settled.receipt_digest} + if not solved: + return {"platform_cleanup": settled.receipt_digest} + resolved = resolve_s4e_closure( + store=live_session.ports.store, + cas=live_session.ports.cas, + receipt_chain=closure, + ) + if ( + resolved.policy_digest != policy_digest + or resolved.accepted_goal_units != 1 + ): + raise Protocol2Unavailable( + "live canary closure is not bound to its frozen policy" + ) + canary_digest = admit_canary( + CanaryEvidence( + CanaryLevel.LIVE_LOCAL, + closure, + fault_suite_green=True, + gate_equivalent=live_session.gate_equivalent, + projection_rebuild_equivalent=True, + ) + ) + payload = { + "canary_digest": canary_digest, + "level": CanaryLevel.LIVE_LOCAL.value, + "receipt_chain": { + str(key): str(value) for key, value in sorted(closure.items()) + }, + "run_id": run_id, + } + self.catalog._store.commit_command( + command_id=f"canary:{run_id}", + idempotency_key=f"canary:{run_id}", + command_payload=payload, + events=[ + CommandEvent( + f"event:canary:{run_id}", + "CANARY_ADMITTED", + "protocol2-web-adapter", + time.time_ns(), + payload, + ) + ], + projection_mutations=[ProjectionMutation( + "canary_commit_guard", payload + )], + authority_capability=( + self.catalog._store._canary_commit_capability + ), + committed_at_ns=time.time_ns(), + ) + return {"canary": canary_digest, "platform_cleanup": settled.receipt_digest} + + try: + session = Protocol2RunSession( + ports=ports, + scope=scope, + supervisor=supervisor, + policy_digest=policy_digest, + budget_account_id="run", + per_attempt_budget=per_attempt, + max_barren_attempts=max_barren_attempts, + expected_goal_units=expected_goal_units, + external_receipts=external, + network_authority=network, + network_policy=enforced, + provider_destination=provider_destination, + provider_base_url=provider_base_url, + egress_proxy=proxy, + completion_callback=admit_completion, + cognitive_feature_gate=cognitive_feature_gate, + ) + except Exception: + proxy.close() + ports.store.close() + raise + self._live[run_id] = session + return session + + async def _finalize_live_session( + self, + *, + run_id: str, + session: Protocol2RunSession, + solved: bool, + ) -> dict[str, Any]: + finalize = asyncio.create_task(session.finalize(solved=solved)) + caller_cancelled: asyncio.CancelledError | None = None + try: + receipts = dict(await asyncio.shield(finalize)) + except asyncio.CancelledError as exc: + caller_cancelled = exc + while not finalize.done(): + try: + await asyncio.shield(finalize) + except asyncio.CancelledError: + continue + except BaseException: + # Inspect the completed task below. Calling exception() there + # consumes the failure and prevents an unhandled-task warning. + break + if finalize.cancelled(): + finalize_failure: BaseException | None = asyncio.CancelledError() + else: + finalize_failure = finalize.exception() + if finalize_failure is not None: + error_class = type(finalize_failure).__name__ + diagnostic = Protocol2Unavailable( + "Protocol 2 finalize failed after caller cancellation " + f"({error_class}); live owner retained" + ) + caller_cancelled.add_note( + "Protocol 2 finalize failed after caller cancellation; " + f"finalizer_error_class={error_class}; live owner retained" + ) + # The raw finalizer exception may contain provider output or other + # secrets. Preserve only its class in a local diagnostic while the + # original caller cancellation remains authoritative. + raise caller_cancelled from diagnostic + receipts = dict(finalize.result()) + # Relinquish the only in-process owner and close its canonical store only + # after successful local finalization. A failed finalization keeps both + # retained so drain/recovery can be retried instead of presenting an + # ownerless, partially finalized run. + self._live.pop(run_id, None) + session.ports.store.close() + if caller_cancelled is not None: + raise caller_cancelled + return {"canary_digest": receipts.get("canary", ""), "receipts": receipts} + + async def complete_live_session( + self, + *, + run_id: str, + session: Protocol2RunSession, + solved: bool, + ) -> dict[str, Any]: + if self._live.get(run_id) is not session: + raise Protocol2Unavailable("Protocol 2 live owner mismatch") + return await self._finalize_live_session( + run_id=run_id, session=session, solved=solved + ) + + async def abort_live_session( + self, + *, + run_id: str, + session: Protocol2RunSession, + ) -> None: + """Best-effort canonical pause/close for a failed pre-terminal canary.""" + if self._live.get(run_id) is not session: + raise Protocol2Unavailable("Protocol 2 live owner mismatch") + await self._finalize_live_session( + run_id=run_id, session=session, solved=False + ) + + def status(self) -> dict: + state = self.catalog._store.verify() + admitted = self.catalog._store.event_rows(kind="CANARY_ADMITTED") + latest = admitted[-1]["payload"] if admitted else {} + release = self._release_receipts() + chain = ( + latest.get("receipt_chain") + if isinstance(latest.get("receipt_chain"), dict) + else {} + ) + valid_canary = False + semantic_closure = False + semantic_status_evidence = False + if admitted: + try: + recomputed = admit_canary( + CanaryEvidence( + CanaryLevel.LIVE_LOCAL, + chain, + fault_suite_green=True, + gate_equivalent=True, + projection_rebuild_equivalent=True, + ) + ) + valid_canary = recomputed == latest.get("canary_digest") + except Exception: + valid_canary = False + if valid_canary and type(latest.get("run_id")) is str: + try: + self._resolve_live_closure( + run_id=latest["run_id"], receipt_chain=chain + ) + semantic_closure = True + except (Protocol2Unavailable, KeyError, OSError): + semantic_closure = False + if semantic_closure: + try: + self._resolve_live_status_evidence( + run_id=latest["run_id"], + receipt_chain=chain, + canary_seq=admitted[-1]["seq"], + ) + semantic_status_evidence = True + except (Protocol2Unavailable, KeyError, OSError): + semantic_status_evidence = False + release_matches = bool( + len(release) == 2 + and all(chain.get(key) == value for key, value in release.items()) + ) + s4e_missing = missing_s4e_receipts(chain) + production_enabled = bool( + admitted + and valid_canary + and semantic_closure + and semantic_status_evidence + and release_matches + and not s4e_missing + ) + if production_enabled: + reason = ( + "live-local semantic canary and operator-attested release policy passed" + ) + else: + missing = sorted({"baseline", "fault_suite"} - set(release)) + if missing: + detail = f"missing release receipt(s): {', '.join(missing)}" + elif not admitted or not valid_canary: + detail = "no valid admitted live-local canary" + elif s4e_missing: + detail = "missing S4-E receipt(s): " + ", ".join(s4e_missing) + elif not semantic_closure: + detail = "no semantically resolved S4-E closure" + elif not semantic_status_evidence: + detail = "live-local canary evidence is not canonically bound" + else: + detail = "live-local canary is not release-bound" + reason = ( + "Protocol 2 kernel is healthy; production remains fail-closed; " + + detail + ) + return { + "protocol_version": 2, + "available": True, + "production_enabled": production_enabled, + "reason": reason, + "catalog_head": state.head_seq, + "catalog_checksum": state.checksum, + "latest_canary": latest, + "latest_receipt_chain": chain, + "live_run_count": len(self._live), + } + + def canonical_run_status(self, run_id: str) -> dict[str, Any]: + """Read lifecycle and canary closure only from canonical V2 stores.""" + view = self.catalog.run_view(run_id) + canaries = [ + row + for row in self.catalog._store.event_rows(kind="CANARY_ADMITTED") + if row["payload"].get("run_id") == run_id + ] + latest = canaries[-1] if canaries else None + result: dict[str, Any] = { + "run": {key: value for key, value in view.items() if key != "target_root"}, + "catalog_head": self.catalog._store.state().head_seq, + "catalog_checksum": self.catalog._store.verify().checksum, + "canary": latest["payload"] if latest else {}, + "receipt_chain": ( + dict(latest["payload"].get("receipt_chain") or {}) if latest else {} + ), + } + target = Path(view["target_root"]) / "epistemic-v2.db" + if target.is_file(): + from muteki.epistemic.sqlite_store import EpistemicSQLiteStore + + store = EpistemicSQLiteStore.open(target) + try: + verified = store.verify() + result["run_store"] = { + "head": verified.head_seq, + "checksum": verified.checksum, + "execution": verified.run_execution.value, + "search_mode": verified.search_mode.value, + "runtime_projection_digest": store.runtime_projection_digest(), + } + finally: + store.close() + else: + result["run_store"] = {"available": False} + return result diff --git a/apps/web/run_manager.py b/apps/web/run_manager.py index a627247..e4c179e 100644 --- a/apps/web/run_manager.py +++ b/apps/web/run_manager.py @@ -1,9 +1,9 @@ """RunManager — the web/TUI-facing handle to live solve runs. The frontends are dumb subscribers (§3): they never call the solver core -directly. They ask the RunManager to start a run, then subscribe to that run's -EventBus and POST HITL commands which land in the run's HITL queue. This keeps -the event schema as the only contract between core and UI. +directly. They ask the RunManager to start a run, subscribe to that run's +EventBus, and submit durable control commands. A narrow queue adapter preserves +the existing coordinator inbox without treating queueing as proof of effect. A "run" here is one challenge being solved (solo or by a swarm). Each gets its own EventBus + SessionStore (durable replay) + an asyncio.Queue for inbound @@ -13,25 +13,76 @@ from __future__ import annotations import asyncio +import hashlib +import inspect +import json import logging import os import re import shutil +import stat import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Awaitable, Callable, Optional +from apps.web.control_adapter import ( + ControlPayloadError, + QueueControlPort, + compile_control_command, + control_paths, + effect_event_payload, + safe_receipt_detail, + safe_hitl_echo, +) from apps.web.run_meta import FolderStore, RunMetaStore from apps.web.worker_config import WorkerConfigStore +from muteki.control import ( + ApplyResult, + ControlAction, + ControlActor, + ControlAdmission, + ControlScope, + DecisionKind, + DecisionRequest, + DecisionStatus, + EffectState, + InMemoryWorkerRegistry, + RunControlMode, + SQLiteControlJournal, + StateConflict, + WorkerRef, +) +from muteki.control.secrets import SecretStore from muteki.core.cost import CostController from muteki.core.event_bus import EventBus from muteki.core.events import Event, EventType, hitl_response_payload +from muteki.core.path_ids import encode_run_id from muteki.core.session_store import SessionStore LOG = logging.getLogger(__name__) +def _safe_exception_detail(prefix: str, exc: BaseException) -> str: + """Keep the concrete boundary error available for operator diagnosis.""" + message = str(exc).replace("\x00", "").strip() + message = "".join(ch for ch in message if ch in "\n\t" or ord(ch) >= 32) + message = re.sub( + r"(?i)\b(password|secret|token|api[_-]?key)\s*[:=]\s*" + r"(?:\"[^\"]*\"|'[^']*'|\S+)", + lambda match: f"{match.group(1)}=", + message, + ) + suffix = f": {message[:2000]}" if message else "" + return f"{prefix} ({type(exc).__name__}){suffix}" + + +def _runtime_error_id(run_id: str, generation: int, detail: str) -> str: + material = f"{run_id}\x1f{generation}\x1f{detail}" + digest = hashlib.sha256(material.encode("utf-8", "replace")).hexdigest() + return f"RT-{digest[:10].upper()}" + + @dataclass class Run: run_id: str @@ -46,12 +97,46 @@ class Run: # the main run is no longer live (finished, or the server restarted). Serialized # — one at a time per run. standby_task: Optional[asyncio.Task] = None + # The asyncio task is only the Python wrapper. The actual standby worker owns + # a shelled CLI process tree, so STOP must cross that runtime boundary before + # cancelling/awaiting the wrapper task. The driver installs this callback as + # soon as CliSolver exists and removes it only after its final cleanup. + standby_cancel: Optional[Callable[[], Any]] = None + standby_runtime_exited: Optional[Callable[[], bool]] = None + standby_wait_runtime_exit: Optional[Callable[[Optional[float]], Awaitable[bool]]] = None + # If the wrapper exits before its runner thread/process, the driver keeps an + # autonomous kill/reap watcher here. This prevents a PARTIAL receipt from + # orphaning the runtime merely because later STOP admission is unavailable. + standby_runtime_cleanup_task: Optional[asyncio.Task] = None + # Pre-start standby context release is a separate durable owner. A journal + # failure must not strand a one-shot reservation after the wrapper exits. + standby_context_cleanup_task: Optional[asyncio.Task] = None + standby_context_cleanup_owner: str = "" + standby_context_cleanup_reservations: list[tuple[str, str]] = field( + default_factory=list) + # Container/runtime acquisition can itself be a non-cancellable to_thread call. + # Track it independently so cancel/delete/resolve never mistake "callbacks not + # registered yet" for proof that no runtime resource exists. + standby_setup_task: Optional[asyncio.Task] = None + # A control callback that ignored shutdown cancellation still owns an in-flight + # mutation even after the main wrapper task returned. Keep that owner and its + # autonomous settle task first-class so resolve/delete/shutdown cannot tear down + # or replace the runtime underneath it. + runtime_incomplete: bool = False + runtime_owner: Optional[Any] = None + runtime_cleanup_task: Optional[asyncio.Task] = None + runtime_settle: Optional[Callable[[], Awaitable[None]]] = None + runtime_error: str = "" finished: bool = False flag: Optional[str] = None # multi-flag: every distinct flag the run collected (dedup, discovery order). # `flag` stays the first for back-compat. expected_flags drives the rail/UI # "collected N/total" + the solved-vs-collecting distinction. flags: list[str] = field(default_factory=list) + # flags the operator explicitly marked false. A reopened run replays the + # shared graph, so old flag_found events can appear again; never let those + # values re-enter the rail summary once invalidated. + invalidated_flags: set[str] = field(default_factory=set) expected_flags: int = 1 # multi-flag MODE bit (collect vs single). Relayed on the synthetic RUN_FINISHED # so a reconnecting deck knows a collect run shouldn't read "solved" on flag #1. @@ -71,6 +156,7 @@ class Run: # — independent of `paused` (the swarm may keep running with one hand up). awaiting_help: bool = False help_text: str = "" + pending_help: dict[str, str] = field(default_factory=dict) created_seq: int = 0 updated_seq: int = 0 # bumped on every event — exposed as activity metadata updated_at: float = 0.0 # epoch seconds of the latest event, for rail "x ago" @@ -85,6 +171,38 @@ class Run: # M2: signature of the last HITL command (target, action, text, url) — an # identical back-to-back resend is dropped instead of re-queued/re-emitted. _last_hitl_sig: Optional[tuple] = None + # Lazily-created per-run control plane. Its journal and SecretStore live under + # RunManager.control_root, which is coordinator-private and deliberately outside + # every bind-mounted worker workspace. The actor is the sole async writer/router. + control_actor: Optional[ControlActor] = None + control_journal: Optional[SQLiteControlJournal] = None + control_secrets: Optional[SecretStore] = None + worker_registry: InMemoryWorkerRegistry = field( + default_factory=InMemoryWorkerRegistry) + # Monotonic in-memory ownership token for the main execution wrapper. A stale + # generation may finish late, but it may never synthesize terminal state or + # close the bus owned by a newer generation. + execution_generation: int = 0 + control_generation: int = 0 + # Lifecycle admission state. Old-generation events are dropped before they + # reach the durable log, and each generation may publish RUN_FINISHED once. + terminal_generations: set[int] = field(default_factory=set) + termination_reasons: dict[int, str] = field(default_factory=dict) + # One task owns one readiness result for each exact participating profile + # configuration. A continuation generation reuses the result; changing the + # profile/model/account/runtime produces a different key and therefore a new + # real probe. This cache deliberately lives on Run rather than Swarm because + # every continuation constructs a fresh Swarm instance. + profile_readiness: dict[str, tuple[bool, Optional[dict[str, Any]]]] = field( + default_factory=dict) + # The title request belongs to the generation that started it and is cancelled + # before a replacement generation or shutdown. + title_task: Optional[asyncio.Task] = None + # Protocol is fixed at fresh-start admission. Protocol 2 intentionally disables + # legacy standby/resolve/control/delete paths until their canonical adapters + # exist; callers can inspect this field before any side effect. + protocol_version: int = 1 + protocol_ownership_confirmed: bool = True def merge_flags(self, flags: Any) -> None: """Accumulate flags from an event payload (dedup, keep order); keep the @@ -92,11 +210,34 @@ def merge_flags(self, flags: Any) -> None: if isinstance(flags, str): flags = [flags] for f in (flags or []): + if f in self.invalidated_flags: + continue if f and f not in self.flags: self.flags.append(f) if self.flags and not self.flag: self.flag = self.flags[0] + def valid_incoming_flags(self, flags: Any) -> list[str]: + if isinstance(flags, str): + flags = [flags] + return [ + f for f in (flags or []) + if f and f not in self.invalidated_flags + ] + + def invalidate_flag(self, flag: Any = None) -> None: + """Drop a false-positive flag and remember it across graph replay.""" + bad = str(flag or "").strip() + if bad: + self.invalidated_flags.add(bad) + self.flags = [f for f in self.flags if f != bad] + else: + self.invalidated_flags.update(self.flags) + self.flags = [] + self.flag = self.flags[0] if self.flags else None + if not self.flags: + self.solved = False + def status(self) -> str: """Single derived lifecycle status the rail renders an icon for. @@ -115,6 +256,7 @@ def summary(self) -> dict[str, Any]: """The shape the deck's thread rail consumes (one row per run).""" return { "run_id": self.run_id, + "protocol_version": self.protocol_version, # custom_name (operator rename) wins; else the auto/challenge name. # Empty when neither is set — the rail renders its own placeholder, we # do NOT leak the bare run id as a display name. @@ -126,6 +268,8 @@ def summary(self) -> dict[str, Any]: "paused": self.paused, "awaiting_help": self.awaiting_help, "help_text": self.help_text, + "runtime_incomplete": self.runtime_incomplete, + "runtime_error": self.runtime_error, "status": self.status(), "flag": self.flag, "flags": list(self.flags), @@ -162,14 +306,56 @@ def _apply_blackboard_meta(run: "Run", ev: Event) -> None: kind = (ev.payload or {}).get("kind") if kind == "flag_found": run.merge_flags((ev.payload or {}).get("flag")) + elif kind == "flag_invalidated": + run.invalidate_flag((ev.payload or {}).get("flag")) elif kind in ("awaiting_operator", "collect_idle"): run.paused = True elif kind in ("operator_resumed", "operator_stopped"): run.paused = False +def _apply_operator_meta(run: "Run", ev: Event) -> bool: + """Fold HITL/control events into rail metadata without guessing effects. + + Returns True when the event was fully handled. A submitted command is merely + an echo; only a ``control.command/effect_observed`` event can change pause. + """ + payload = ev.payload or {} + if ev.event_type is EventType.HITL_REQUEST: + need = str(payload.get("need") or payload.get("text") or "")[:300] + request_id = str(payload.get("request_id") or payload.get("id") or + f"legacy:{payload.get('worker', '')}:{need}") + run.pending_help[request_id] = need + run.awaiting_help = True + run.help_text = next(iter(run.pending_help.values()), "") + return True + if ev.event_type is EventType.HITL_RESPONSE: + # This is only the immutable operator echo (normally PERSISTED). The + # durable DecisionAnswer companion closes the card via CONTROL_COMMAND. + return True + if ev.event_type is EventType.CONTROL_COMMAND: + if (bool(payload.get("decision_closed")) + and payload.get("status") == "effect_observed"): + request_id = str(payload.get("request_id") or "").strip() + if request_id: + run.pending_help.pop(request_id, None) + run.awaiting_help = bool(run.pending_help) + run.help_text = next(iter(run.pending_help.values()), "") + if payload.get("status") != "effect_observed": + return True + effect = payload.get("effect") if isinstance(payload.get("effect"), dict) else {} + effect_kind = str(effect.get("kind") or payload.get("effect_kind") or "").lower() + if effect_kind in {"run_quiesced", "run_frozen"}: + run.paused = True + elif effect_kind in {"run_resumed", "run_thawed"}: + run.paused = bool(run.pending_help) + return True + return False + + class RunManager: - def __init__(self, *, sessions_root: "str | Path | None" = None) -> None: + def __init__(self, *, sessions_root: "str | Path | None" = None, + control_root: "str | Path | None" = None) -> None: # P2-v3: in the compose layout the sessions/ tree must live UNDER the # mirrored data root (MUTEKI_HOST_DATA_ROOT bind-mounted into the web # container), so worker sibling containers — launched by the host daemon — @@ -180,7 +366,56 @@ def __init__(self, *, sessions_root: "str | Path | None" = None) -> None: sessions_root = os.environ.get("MUTEKI_SESSIONS_ROOT") or "sessions" self.sessions_root = Path(sessions_root) self.sessions_root.mkdir(parents=True, exist_ok=True) + # Operator control state is more privileged than worker evidence. Never put + # control.db or materialised secret files below sessions/{run}/workspace: + # that whole workspace is recursively chowned to the worker uid and mounted + # read-write into its container. The default sibling root is visible only to + # the coordinator process. Deployments may place it on a dedicated volume. + if control_root is None: + control_root = ( + os.environ.get("MUTEKI_COORDINATOR_CONTROL_ROOT") + or self.sessions_root / ".coordinator-control" + ) + self.control_root = Path(control_root) + try: + rel_to_sessions = self.control_root.resolve().relative_to( + self.sessions_root.resolve()) + except ValueError: + rel_to_sessions = None + if (rel_to_sessions is not None and len(rel_to_sessions.parts) >= 2 + and rel_to_sessions.parts[1] == "workspace"): + raise ValueError( + "coordinator control root cannot be inside a worker workspace") + self.control_root.mkdir(mode=0o700, parents=True, exist_ok=True) + root_info = self.control_root.lstat() + if not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode): + raise ValueError("coordinator control root must be a real directory") + os.chmod(self.control_root, 0o700) + # Protocol 2 has an independent host-only catalog/composition root. It is + # wired into the real Web process now, but production dispatch stays + # fail-closed until the canary/black-box receipts enable it explicitly. + self.protocol2 = None + self.protocol2_error = "" + try: + from apps.web.protocol2_adapter import Protocol2WebAdapter + self.protocol2 = Protocol2WebAdapter(control_root=self.control_root) + except Exception as exc: + self.protocol2_error = type(exc).__name__ self.runs: dict[str, Run] = {} + # Main-task, standby, resolve, delete, and shutdown admission share one + # lifecycle boundary. Long cleanup waits publish an explicit per-run fence + # rather than holding this lock, so conflicting operations fail closed + # instead of deadlocking behind an adversarial cancellation handler. + self._lifecycle_lock = asyncio.Lock() + self._closing_runs: set[str] = set() + self._launching_runs: set[str] = set() + self._shutting_down = False + # Admission is single-writer all the way through secret extraction. The + # actor serialises journal mutation, but compilation happens before the + # actor and may create opaque SecretStore refs. Without this boundary, + # two concurrent retries carrying the same command_id can each mint a + # different ref before either command is visible in SQLite. + self._control_submit_locks: dict[str, asyncio.Lock] = {} self._seq = 0 self.meta = RunMetaStore(root=self.sessions_root) # operator-created rail folders (id → name); runs reference one via meta. @@ -188,7 +423,129 @@ def __init__(self, *, sessions_root: "str | Path | None" = None) -> None: # default worker-roster config (which engines launch per challenge); the # dispatch path falls back to this when a request doesn't say otherwise. self.worker_config = WorkerConfigStore(root=self.sessions_root) - self._rehydrate() + protocol2_run_ids = self._reconcile_protocol2_flags() + self._rehydrate(protocol2_run_ids=protocol2_run_ids) + + def _execution_owned( + self, run: Run, generation: int, + task: "Optional[asyncio.Task[Any]]" = None, + ) -> bool: + return bool( + self.runs.get(run.run_id) is run + and run.execution_generation == generation + and (task is None or run.task is task) + ) + + def _generation_filter_for(self, run: Run): + async def _generation_filter(ev: Event) -> bool: + payload = ev.payload + supplied = payload.get("execution_generation") + if supplied is None: + generation = run.execution_generation + payload["execution_generation"] = generation + else: + try: + generation = int(supplied) + except (TypeError, ValueError): + return False + if generation < run.execution_generation: + return False + payload.setdefault("control_generation", run.control_generation) + if (generation in run.terminal_generations + and ev.event_type is not EventType.CONTROL_COMMAND): + # The terminal event closes this execution generation. A worker + # subprocess may still flush a buffered frame while cancellation is + # propagating, but that frame belongs to a closed runtime and must not + # mutate the durable/UI projection. The terminal control receipt is + # still admitted so operators can audit the completed STOP/COMPLETE. + return False + if ev.event_type is EventType.RUN_FINISHED: + run.terminal_generations.add(generation) + return True + return _generation_filter + + @staticmethod + def _safe_run_id(run_id: str) -> str: + return encode_run_id(run_id) + + @staticmethod + def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + def coordinator_control_dir(self, run_id: str) -> Path: + """Return the run's coordinator-only control directory. + + Worker containers receive the exact path returned by :meth:`workspace_dir` + as a read-write bind mount. This directory is rooted separately and the + resolved-path check fails closed if configuration or a symlink would place + it below that worker-visible tree. + """ + safe = self._safe_run_id(run_id) + directory = self.control_root / safe + workspace = self.workspace_dir(run_id) + resolved_directory = directory.resolve() + worker_workspaces = {workspace, *self.sessions_root.glob("*/workspace")} + for worker_workspace in worker_workspaces: + if self._is_within(resolved_directory, worker_workspace.resolve()): + raise RuntimeError( + "coordinator control directory cannot be inside worker workspace") + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + info = directory.lstat() + if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise RuntimeError("run control directory must be a real directory") + os.chmod(directory, 0o700) + return directory + + def _profile_readiness_path(self, run_id: str) -> Path: + return self.control_root / self._safe_run_id(run_id) / "profile-readiness.json" + + def _load_profile_readiness( + self, run_id: str, + ) -> dict[str, tuple[bool, Optional[dict[str, Any]]]]: + """Load the task-owned probe results used by continuation generations.""" + path = self._profile_readiness_path(run_id) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, ValueError, TypeError): + return {} + rows = payload.get("profiles") if isinstance(payload, dict) else None + if not isinstance(rows, dict): + return {} + loaded: dict[str, tuple[bool, Optional[dict[str, Any]]]] = {} + for key, value in rows.items(): + if not isinstance(key, str) or not isinstance(value, dict): + continue + ok = value.get("ok") + failure = value.get("failure") + if not isinstance(ok, bool): + continue + if failure is not None and not isinstance(failure, dict): + continue + loaded[key] = (ok, failure) + return loaded + + def persist_profile_readiness(self, run: Run) -> None: + """Persist real profile probes outside every Worker-visible workspace.""" + directory = self.coordinator_control_dir(run.run_id) + path = directory / "profile-readiness.json" + temporary = directory / f".profile-readiness-{os.getpid()}-{time.time_ns()}.tmp" + payload = { + "version": 1, + "profiles": { + key: {"ok": ok, "failure": failure} + for key, (ok, failure) in run.profile_readiness.items() + }, + } + temporary.write_text( + json.dumps(payload, ensure_ascii=False, sort_keys=True), + encoding="utf-8", + ) + os.chmod(temporary, 0o600) + os.replace(temporary, path) def _apply_meta(self, run: "Run") -> None: """Overlay persisted operator metadata (pin/archive/rename) onto a run.""" @@ -200,7 +557,87 @@ def _apply_meta(self, run: "Run") -> None: run.folder_id = m["folder_id"] run.sort_order = m["order"] - def _rehydrate(self) -> None: + def _reconcile_protocol2_flags(self) -> Optional[frozenset[str]]: + """Project accepted handoffs and return one authoritative owner snapshot. + + ``None`` means the catalog inventory could not be established. Callers must + not reinterpret that uncertainty as proof that a persisted run is Protocol 1. + """ + + if self.protocol2 is None: + return None + store = SessionStore(root=self.sessions_root) + try: + catalog_run_ids = set(self.protocol2.list_run_ids()) + except Exception as exc: + # No run id can be attributed safely when the canonical inventory itself + # is unavailable, so retain a process diagnostic without persisting any + # exception-controlled material. + LOG.error( + "Protocol 2 projection inventory is incomplete (%s)", + type(exc).__name__, + ) + return None + # The union prevents display-only/session history from disappearing from the + # startup scan, while catalog membership remains the sole Protocol 2 owner bit. + for run_id in sorted(catalog_run_ids | set(store.list_runs())): + if run_id not in catalog_run_ids: + continue + try: + publications = self.protocol2.recover_flag_publications(run_id) + for publication in publications: + event = Event( + event_type=EventType.FLAG_ACCEPTED, + seq=store.last_stream_seq(run_id) + 1, + run_id=run_id, + solver_id="protocol2-authority-projector", + payload={ + "schema_id": "muteki.flag-accepted-projection.v1", + "publication_id": publication.publication_id, + "evaluation_id": publication.evaluation_id, + "flag": publication.flag, + "flag_digest": publication.flag_digest, + "gate_receipt_digest": publication.gate_receipt_digest, + }, + ) + store.append_if_absent_sync( + event, + identity_field="publication_id", + identity=publication.publication_id, + ) + except Exception as exc: + # One corrupt canonical run must not suppress recovery for the rest. + # This typed event is deliberately non-terminal and carries only a + # local diagnostic identity plus a bounded exception class name. + diagnostic_id = "protocol2-projection-incomplete:" + encode_run_id(run_id) + diagnostic = Event( + event_type=EventType.PROJECTION_INCOMPLETE, + seq=store.last_stream_seq(run_id) + 1, + run_id=run_id, + solver_id="protocol2-authority-projector", + payload={ + "schema_id": "muteki.projection-incomplete.v1", + "diagnostic_id": diagnostic_id, + "projection": "flag.accepted", + "error_class": type(exc).__name__[:100], + }, + ) + try: + store.append_if_absent_sync( + diagnostic, + identity_field="diagnostic_id", + identity=diagnostic_id, + ) + except Exception: + LOG.error( + "Protocol 2 projection diagnostic could not persist for %s", + run_id, + ) + return frozenset(catalog_run_ids) + + def _rehydrate( + self, *, protocol2_run_ids: Optional[frozenset[str]] = None + ) -> None: """Re-populate the rail from durable JSONL on startup. Without this, a server restart drops every past conversation: self.runs @@ -230,18 +667,44 @@ def _rehydrate(self) -> None: max_seq = max(max_seq, int(m0.group(1))) continue run = self.create(rid) + run.execution_generation = int( + s.get("execution_generation") or 0) + run.terminal_generations = { + int(generation) + for generation in (s.get("terminal_generations") or []) + if int(generation) > 0 + } + # Reuse the inventory already verified by startup reconciliation. If that + # inventory failed, ownership is unknown rather than Protocol 1. + protocol2_owned: Optional[bool] = ( + rid in protocol2_run_ids + if protocol2_run_ids is not None + else self.protocol2_ownership(rid, run=run) + ) + if protocol2_owned is True: + run.protocol_version = 2 + elif protocol2_owned is None: + run.protocol_ownership_confirmed = False # `summary()` falls back name→run_id; treat that as "no real title" so # the rail renders its placeholder instead of leaking the bare id. run.name = "" if s.get("name") in (None, "", rid) else s["name"] run.category = s.get("category", "") or "" run.started = bool(s.get("started")) - # a rehydrated run has NO live task (the swarm coroutine died with the - # previous server). So a started run is necessarily finished — even if - # its on-disk summary says finished=False because it was killed mid-run - # before emitting RUN_FINISHED (a "ghost run": the rail would otherwise - # spin forever with no terminal event to settle it). Force-settle here. - run.finished = bool(s.get("finished")) or run.started - run.solved = bool(s.get("solved")) + # Protocol 1 keeps the historical ghost-run compatibility contract: a + # dead started run is force-settled. Protocol 2 lifecycle is canonical + # elsewhere, so missing public closure is unresolved, not permission to + # fabricate terminal state. Accepted-only history likewise stays open. + protocol1_owned = protocol2_owned is False + run.finished = ( + bool(s.get("finished")) or run.started + if protocol1_owned + else bool(s.get("finished")) + ) + run.solved = ( + bool(s.get("solved")) + if protocol1_owned + else bool(s.get("solved")) and run.finished + ) run.flag = s.get("flag") run.flags = list(s.get("flags") or ([run.flag] if run.flag else [])) run.expected_flags = int(s.get("expected_flags") or 1) @@ -266,6 +729,43 @@ def _rehydrate(self) -> None: def get(self, run_id: str) -> Optional[Run]: return self.runs.get(run_id) + def protocol2_ownership( + self, run_id: str, *, run: Optional[Run] = None + ) -> Optional[bool]: + """Return catalog ownership as ``True`` / ``False`` / unknown ``None``. + + An in-memory Protocol 2 bit is a positive durable hint. Absence is not a + Protocol 1 proof: if the adapter or catalog lookup is unavailable, legacy + synthesis and mutation must remain disabled rather than claiming ownership. + """ + candidate = run if run is not None else self.runs.get(run_id) + if candidate is not None and candidate.protocol_version == 2: + return True + if (candidate is not None + and candidate.protocol_version == 1 + and candidate.protocol_ownership_confirmed): + return False + adapter = self.protocol2 + if adapter is None: + return None + try: + return bool(adapter.has_run(run_id)) + except Exception as exc: + LOG.error( + "Protocol 2 ownership lookup is incomplete for %s (%s)", + run_id, + type(exc).__name__, + ) + return None + + def is_protocol2_run(self, run_id: str, *, run: Optional[Run] = None) -> bool: + """Compatibility facade: true only for positively owned Protocol 2 runs.""" + return self.protocol2_ownership(run_id, run=run) is True + + def is_protocol1_run(self, run_id: str, *, run: Optional[Run] = None) -> bool: + """Return true only when the catalog positively excludes Protocol 2.""" + return self.protocol2_ownership(run_id, run=run) is False + def list_runs(self, *, include_archived: bool = False) -> list[dict[str, Any]]: """Run summaries for the thread rail, newest first. @@ -307,6 +807,9 @@ def set_archived(self, run_id: str, archived: bool, *, run = self.runs.get(run_id) if run is None: return False + if not self.is_protocol1_run(run_id, run=run): + # V2 or unknown ownership is never permission for legacy rail archive. + return False m = self.meta.set_archived(run_id, archived, now=now if now is not None else time.time()) run.archived, run.pinned, run.pinned_at = m["archived"], m["pinned"], m["pinned_at"] @@ -354,11 +857,31 @@ def delete_folder(self, fid: str) -> bool: return self.folders.delete(fid) async def delete(self, run_id: str) -> bool: - """Hard-delete a run: cancel its task(s), drop the handle + JSONL + meta.""" - run = self.runs.pop(run_id, None) - if run is None: - # still scrub any orphaned on-disk artifacts / meta - self._delete_artifacts(run_id) + """Hard-delete under a published per-run lifecycle fence.""" + async with self._lifecycle_lock: + if run_id in self._closing_runs or run_id in self._launching_runs: + return False + run = self.runs.get(run_id) + if not self.is_protocol1_run(run_id, run=run): + return False + # No authority means no mutation. In particular, an unknown id must + # never become an orphan-artifact scrub that bypasses the V2 catalog. + if run is None: + return False + self._closing_runs.add(run_id) + try: + return await self._delete_owned_run(run_id, run) + finally: + async with self._lifecycle_lock: + self._closing_runs.discard(run_id) + + async def _delete_owned_run(self, run_id: str, run: Run) -> bool: + """Settle and remove the exact Run captured by :meth:`delete`.""" + if run.runtime_incomplete and not await self._settle_incomplete_runtime( + run, timeout=self._standby_cancel_timeout()): + LOG.error( + "refusing to delete %s: main runtime owner is still unsettled", + run_id) return False # Cancel BOTH the swarm task and any live standby worker, then AWAIT them to # actually unwind before we close the bus / delete artifacts. Cancelling @@ -366,19 +889,96 @@ async def delete(self, run_id: str) -> bool: # still be writing to the bus or reading an upload while we closed/removed # them. A cancelled task re-raises CancelledError on await — return_exceptions # swallows it (and any other shutdown error) so delete never self-destructs. - pending = [t for t in (run.task, run.standby_task) + if not await self._settle_standby_runtime( + run, timeout=self._standby_cancel_timeout()): + # Keep the run, callbacks, cleanup watcher and artifacts intact. A + # caller can retry STOP/FORCE_CANCEL/delete; dropping ownership here + # would turn a PARTIAL kill into an orphan. + LOG.error( + "refusing to delete %s: standby runtime exit is unconfirmed", run_id) + return False + pending = [t for t in (run.task, run.standby_task, run.title_task) if t is not None and not t.done()] for t in pending: t.cancel() if pending: - await asyncio.gather(*pending, return_exceptions=True) + done, still_live = await asyncio.wait( + tuple(pending), timeout=self._standby_cancel_timeout()) + if done: + await asyncio.gather(*done, return_exceptions=True) + if still_live: + # A wrapper that suppresses CancelledError still owns the Run and + # its files. Publish the same retained-owner fence used by driver + # runtime cleanup, then return boundedly; start/resolve/control will + # fail closed until the autonomous waiter proves task exit. + owned = tuple(still_live) + owner_token = object() + run.runtime_incomplete = True + run.runtime_owner = owner_token + + async def _settle_wrapper_owner() -> None: + await asyncio.gather( + *(asyncio.shield(task) for task in owned), + return_exceptions=True, + ) + # The late wrapper unwind may discover and publish a stronger + # subprocess/container owner. Clear only our exact token/task; + # never erase ownership transferred after the delete timeout. + current = asyncio.current_task() + if (all(task.done() for task in owned) + and run.runtime_owner is owner_token + and run.runtime_cleanup_task is current): + run.runtime_incomplete = False + run.runtime_owner = None + run.runtime_error = "" + run.runtime_settle = None + + run.runtime_settle = _settle_wrapper_owner + run.runtime_error = "task cancellation exit unconfirmed" + run.runtime_cleanup_task = asyncio.create_task( + _settle_wrapper_owner(), + name=f"delete-runtime-owner-settle-{run_id}", + ) + LOG.error( + "refusing to delete %s: task cancellation exit is unconfirmed", + run_id, + ) + return False + # Cancelling the asyncio wrapper can be the operation that discovers an + # independently-live worker runtime. The driver then transfers ownership + # to ``runtime_cleanup_task`` and marks ``runtime_incomplete`` while the + # gather above unwinds. Re-check after cancellation before dropping the + # Run, journal, callbacks, or artifacts. + if run.runtime_incomplete and not await self._settle_incomplete_runtime( + run, timeout=self._standby_cancel_timeout()): + LOG.error( + "refusing to delete %s: cancellation exposed an unsettled " + "main runtime owner", run_id) + return False + # Remove the exact handle only after every execution boundary is settled. + # start/resolve/standby all reject the published closing fence, but retain + # the identity check as the final destructive commit guard. + async with self._lifecycle_lock: + if self.runs.get(run_id) is not run: + return False + self.runs.pop(run_id, None) + if run.control_actor is not None: + try: + await run.control_actor.close() + except Exception: + LOG.exception("failed to close control actor for %s", run_id) + if run.control_journal is not None: + try: + run.control_journal.close() + except Exception: + LOG.exception("failed to close control journal for %s", run_id) await run.bus.close() self._delete_artifacts(run_id) return True def _delete_artifacts(self, run_id: str) -> None: self.meta.forget(run_id) - safe = run_id.replace("/", "_").replace("..", "_") + safe = self._safe_run_id(run_id) jsonl = self.sessions_root / f"{safe}.jsonl" try: jsonl.unlink(missing_ok=True) @@ -387,6 +987,168 @@ def _delete_artifacts(self, run_id: str) -> None: # also drop the per-run upload dir (sessions/{safe}/) so deleting a # conversation doesn't orphan its uploaded challenge files on disk. shutil.rmtree(self.sessions_root / safe, ignore_errors=True) + # Control state may live on a dedicated coordinator volume outside the + # sessions tree, so deleting the worker workspace is intentionally not + # relied on to scrub it. + shutil.rmtree(self.control_root / safe, ignore_errors=True) + + def _protocol2_owner_settled(self, run: Optional[Run]) -> bool: + if run is None: + return True + return bool( + not run.runtime_incomplete + and (run.task is None or run.task.done()) + and not self._standby_busy(run) + and (run.runtime_cleanup_task is None or run.runtime_cleanup_task.done()) + and (run.standby_runtime_cleanup_task is None + or run.standby_runtime_cleanup_task.done()) + ) + + async def archive_protocol2(self, run_id: str) -> dict[str, Any]: + """Run the catalog-owned archive saga; legacy metadata is display-only.""" + adapter = self.protocol2 + if adapter is None or not self.is_protocol2_run(run_id): + raise StateConflict("Protocol 2 run is unavailable") + async with self._lifecycle_lock: + if (self._shutting_down or run_id in self._closing_runs + or run_id in self._launching_runs): + raise StateConflict("run lifecycle transition is in progress") + run = self.runs.get(run_id) + if not self._protocol2_owner_settled(run): + raise StateConflict("Protocol 2 runtime owner is not settled") + self._closing_runs.add(run_id) + try: + status = adapter.archive( + run_id=run_id, operation_id=f"archive:{run_id}", + occurred_at_ns=time.time_ns()) + if status.get("state") == "archived" and run is not None: + # This is a non-authoritative rail projection applied only after + # the catalog and per-run archive receipts are durable. + meta = self.meta.set_archived(run_id, True, now=time.time()) + run.archived = bool(meta["archived"]) + run.pinned = bool(meta["pinned"]) + run.pinned_at = meta["pinned_at"] + return status + finally: + async with self._lifecycle_lock: + self._closing_runs.discard(run_id) + + def _protocol2_purge_plan(self, run_id: str) -> tuple[dict[str, str], ...]: + # Logical locators are sealed in the external plan. Host paths remain an + # adapter detail and never enter catalog events or API responses. + return ( + {"locator": "session-jsonl", "adapter": "file"}, + {"locator": "session-tree", "adapter": "tree"}, + {"locator": "legacy-control-tree", "adapter": "tree"}, + {"locator": "rail-meta", "adapter": "metadata"}, + # The canonical run DB/CAS is deleted last. The catalog/tombstone is + # outside this tree and can therefore resume after any crash window. + {"locator": "protocol2-run-tree", "adapter": "tree"}, + ) + + def _protocol2_purge_target(self, run_id: str, locator: str) -> Path | None: + safe = self._safe_run_id(run_id) + if locator == "session-jsonl": + return self.sessions_root / f"{safe}.jsonl" + if locator == "session-tree": + return self.sessions_root / safe + if locator == "legacy-control-tree": + return self.control_root / safe + if locator == "rail-meta": + return None + if locator == "protocol2-run-tree": + view = self.protocol2.run_view(run_id) + target = Path(view["target_root"]) + allowed = (Path(self.protocol2.root) / "runs").resolve() + if not self._is_within(target.resolve(), allowed): + raise RuntimeError("Protocol 2 target escaped the catalog run root") + return target + raise RuntimeError("unknown sealed purge locator") + + @staticmethod + def _path_present(path: Path) -> bool: + return path.exists() or path.is_symlink() + + def _execute_protocol2_purge_item( + self, *, run_id: str, locator: str, adapter: str, + ) -> bool: + """Delete one sealed logical item and return whether it was pre-absent.""" + if adapter == "metadata": + if locator != "rail-meta": + raise RuntimeError("metadata adapter locator mismatch") + already_absent = not self.meta.contains(run_id) + self.meta.forget(run_id) + if self.meta.contains(run_id): + raise OSError("metadata absence readback failed") + return already_absent + path = self._protocol2_purge_target(run_id, locator) + if path is None: + raise RuntimeError("filesystem adapter has no target") + already_absent = not self._path_present(path) + if not already_absent: + if adapter == "file": + path.unlink() + elif adapter == "tree": + if path.is_symlink(): + path.unlink() + else: + shutil.rmtree(path) + else: + raise RuntimeError("unsupported purge adapter") + if self._path_present(path): + raise OSError("filesystem absence readback failed") + return already_absent + + async def purge_protocol2(self, run_id: str) -> dict[str, Any]: + """Execute a sealed, itemized, idempotent purge and retain tombstone.""" + adapter = self.protocol2 + if adapter is None or not self.is_protocol2_run(run_id): + raise StateConflict("Protocol 2 run is unavailable") + async with self._lifecycle_lock: + if (self._shutting_down or run_id in self._closing_runs + or run_id in self._launching_runs): + raise StateConflict("run lifecycle transition is in progress") + run = self.runs.get(run_id) + if not self._protocol2_owner_settled(run): + raise StateConflict("Protocol 2 runtime owner is not settled") + self._closing_runs.add(run_id) + operation_id = f"purge:{run_id}" + try: + status = adapter.begin_purge( + run_id=run_id, operation_id=operation_id, + items=self._protocol2_purge_plan(run_id), + occurred_at_ns=time.time_ns()) + for item in status["items"]: + if item["state"] == "absent": + continue + try: + already_absent = self._execute_protocol2_purge_item( + run_id=run_id, locator=item["locator"], + adapter=item["adapter"]) + except Exception as exc: + if item["state"] == "pending": + status = adapter.purge_item_unknown( + operation_id=operation_id, ordinal=item["ordinal"], + locator=item["locator"], adapter=item["adapter"], + error_class=type(exc).__name__, + occurred_at_ns=time.time_ns()) + return status + status = adapter.purge_item_absent( + operation_id=operation_id, ordinal=item["ordinal"], + locator=item["locator"], adapter=item["adapter"], + already_absent=already_absent, + occurred_at_ns=time.time_ns()) + status = adapter.complete_purge( + operation_id=operation_id, occurred_at_ns=time.time_ns()) + if status.get("state") == "purged" and run is not None: + async with self._lifecycle_lock: + if self.runs.get(run_id) is run: + self.runs.pop(run_id, None) + await run.bus.close() + return status + finally: + async with self._lifecycle_lock: + self._closing_runs.discard(run_id) # ---- retention sweep: auto-archive idle runs, then delete stale ones ----- @@ -414,12 +1176,32 @@ async def retention_sweep(self, *, now: float, archive_after_s: float, continue # can't date it → leave it alone idle = now - ts meta = self.meta.get(run.run_id) + ownership = self.protocol2_ownership(run.run_id, run=run) + if ownership is None: + continue + if ownership: + try: + view = self.protocol2.run_view(run.run_id) + if view["state"] == "archived" and idle > delete_after_s: + status = await self.purge_protocol2(run.run_id) + if status.get("state") == "purged": + deleted.append(run.run_id) + elif view["state"] == "sealed" and idle > archive_after_s: + status = await self.archive_protocol2(run.run_id) + if status.get("state") == "archived": + archived.append(run.run_id) + except Exception: + LOG.exception( + "retention: Protocol 2 lifecycle failed closed for %s", + run.run_id) + continue if meta["archived"]: if idle > delete_after_s: - await self.delete(run.run_id) - deleted.append(run.run_id) - LOG.info("retention: deleted stale archived run %s (idle %.0fs)", - run.run_id, idle) + if await self.delete(run.run_id): + deleted.append(run.run_id) + LOG.info( + "retention: deleted stale archived run %s (idle %.0fs)", + run.run_id, idle) elif idle > archive_after_s: self.set_archived(run.run_id, True, now=now) archived.append(run.run_id) @@ -448,7 +1230,7 @@ def workspace_dir(self, run_id: str) -> Path: Replaces the old tempfile.mkdtemp root so sandbox, artifacts, and shared_graph.db survive process restarts. Same id-sanitization as uploads_dir / _delete_artifacts.""" - safe = run_id.replace("/", "_").replace("..", "_") + safe = self._safe_run_id(run_id) d = self.sessions_root / safe / "workspace" d.mkdir(parents=True, exist_ok=True) return d @@ -457,6 +1239,8 @@ def open_workspace(self, run_id: str) -> bool: """Open the run's workspace dir in the host file manager (operator-local — the deck runs in a browser, so a backend opener is the only way to truly reveal Finder/Explorer). Best-effort; False if it can't open.""" + if not self.is_protocol1_run(run_id): + return False import subprocess import sys @@ -484,7 +1268,7 @@ def uploads_dir(self, run_id: str) -> Path: is a sibling of the run's {id}.jsonl log — SessionStore only globs *.jsonl, so a directory of the same stem never collides with rehydration. """ - safe = run_id.replace("/", "_").replace("..", "_") + safe = self._safe_run_id(run_id) d = self.sessions_root / safe / "uploads" d.mkdir(parents=True, exist_ok=True) return d @@ -495,12 +1279,24 @@ def create(self, run_id: str) -> Run: bus = EventBus() store = SessionStore(root=self.sessions_root) self._sync_bus_seq(bus, store=store, run_id=run_id) - bus.add_sink(store.sink) self._seq += 1 run = Run( run_id=run_id, bus=bus, cost=CostController(bus=bus), store=store, created_seq=self._seq, ) + run.profile_readiness = self._load_profile_readiness(run_id) + # A run may already have a canonical Protocol 2 owner before its Web + # projection is opened. Resolve that once at handle creation. A confirmed + # Protocol 1 handle remains stable afterwards even if a later catalog query + # is temporarily unavailable. + if self.protocol2 is not None: + try: + if self.protocol2.has_run(run_id): + run.protocol_version = 2 + except Exception: + run.protocol_ownership_confirmed = False + bus.add_filter(self._generation_filter_for(run)) + bus.add_sink(store.sink) # sniff run.started / run.finished off the bus to keep rail metadata fresh # without making the run anything but a dumb event source. async def _meta_sink(ev: Event) -> None: @@ -509,7 +1305,11 @@ async def _meta_sink(ev: Event) -> None: self._seq += 1 run.updated_seq = self._seq run.updated_at = ev.ts - if ev.event_type is EventType.RUN_STARTED: + if ev.event_type is EventType.HITL_REQUEST: + self._record_decision_request(run, ev) + if _apply_operator_meta(run, ev): + return + if ev.event_type in {EventType.RUN_PREPARING, EventType.RUN_STARTED}: ch = ev.payload.get("challenge", {}) or {} run.started = True # Keep name EMPTY when the operator gave none — the rail renders a @@ -528,20 +1328,6 @@ async def _meta_sink(ev: Event) -> None: title = ev.payload.get("title") or "" if title and not run.name: run.name = title - elif ev.event_type is EventType.HITL_RESPONSE: - # reflect pause/resume into the rail status icon. The driver still - # owns the real halt; this is just the displayed state. - action = ev.payload.get("action") - if action == "pause": - run.paused = True - elif action == "resume": - run.paused = False - # ANY operator response lowers a raised hand. This MUST live in the - # same branch: an if/elif chain only matches ONE HITL_RESPONSE arm, - # so a separate `elif ev.event_type is HITL_RESPONSE` below was dead - # code and the rail showed "需要输入" forever after a hint/answer. - run.awaiting_help = False - run.help_text = "" elif ev.event_type is EventType.RUN_REOPENED: # The run is solving again. Resolve/continue keeps all prior flags # visible; false-positive payloads carry the one invalid flag to @@ -551,29 +1337,23 @@ async def _meta_sink(ev: Event) -> None: run.paused = False if ev.payload.get("reason") == "resolve": return - bad = ev.payload.get("flag") - if bad and run.flags: - run.flags = [f for f in run.flags if f != bad] - run.flag = run.flags[0] if run.flags else None - else: - run.flag = None - run.flags = [] - elif ev.event_type is EventType.HITL_REQUEST: - # a worker raised its hand (NEED_INPUT / env_down: target crashed, - # instance expired, missing credential…). Surface it on the summary so - # an operator (or a 1-min poll of /api/runs) sees it WITHOUT scanning - # JSONL — this does NOT require the run to be "paused" (the swarm may - # still be hurling workers at the wall while one hand is up). - run.awaiting_help = True - run.help_text = str((ev.payload or {}).get("need") - or (ev.payload or {}).get("text") or "")[:300] + run.invalidate_flag(ev.payload.get("flag")) + elif ev.event_type is EventType.FLAG_ACCEPTED: + # Protocol 2 public visibility is intentionally independent from + # lifecycle closure. Never synthesize solved/finished/progress here. + run.merge_flags(ev.payload.get("flag")) elif ev.event_type is EventType.RUN_FINISHED: run.finished = True run.paused = False # a finished run is never "paused" run.awaiting_help = False # finished → no outstanding ask run.help_text = "" - run.solved = bool(ev.payload.get("solved")) or run.solved - run.merge_flags(ev.payload.get("flags") or ev.payload.get("flag")) + run.pending_help.clear() + incoming_flags = ev.payload.get("flags") or ev.payload.get("flag") + had_flag_payload = bool(incoming_flags) + valid_incoming = run.valid_incoming_flags(incoming_flags) + run.merge_flags(incoming_flags) + if bool(ev.payload.get("solved")): + run.solved = bool(valid_incoming) if had_flag_payload else True if ev.payload.get("expected_flags"): run.expected_flags = int(ev.payload["expected_flags"]) if "multi_flag" in ev.payload: @@ -609,98 +1389,1411 @@ def create_new(self) -> Run: run_id = f"run-{self._seq:04d}" return self.create(run_id) - async def start(self, run_id: str, driver: Driver) -> Run: - """Create the run and launch `driver` as a background task on its bus.""" - run = self.create(run_id) + def _retire_worker_command_epoch(self, run: Run) -> None: + """Clear execution-local selectors and close queued command receipts.""" + run.worker_registry.clear() + while True: + try: + stale_worker_cmd = run.worker_cmds.get_nowait() + except asyncio.QueueEmpty: + break + if isinstance(stale_worker_cmd, dict): + stale_ack = stale_worker_cmd.get("_control_ack") + if isinstance(stale_ack, asyncio.Future) and not stale_ack.done(): + stale_ack.set_result({ + "state": "unknown", + "detail": "stale worker command retired at execution epoch", + "target_ids": [], + "metadata": {"code": "stale_execution_epoch"}, + }) + run.worker_cmds.task_done() + + def _retire_hitl_epoch(self, run: Run, *, terminal: bool) -> None: + """Remove commands that no longer belong to a live execution generation.""" + while True: + try: + stale = run.hitl.get_nowait() + except asyncio.QueueEmpty: + break + if isinstance(stale, dict): + acknowledgement = stale.get("_control_ack") + action = str(stale.get("action") or "") + if (isinstance(acknowledgement, asyncio.Future) + and not acknowledgement.done()): + observed_stop = terminal and action in { + "stop", "complete", "force_cancel"} + acknowledgement.set_result({ + "state": ( + "effect_observed" if observed_stop else "unknown"), + "detail": ( + "run generation terminated" if observed_stop + else "stale command retired at execution generation"), + "target_ids": [], + "metadata": { + "effect": ( + "run_terminated" if observed_stop + else "stale_execution_generation"), + }, + }) + run.hitl.task_done() + + def _launch_generation(self, run: Run, driver: Driver) -> asyncio.Task[Any]: + """Create one owner-token-fenced execution wrapper. + + Caller holds ``_lifecycle_lock`` and has completed admission. The wrapper + is shared by fresh start and resolve so both synthesize a terminal event on + crash/cancel and neither stale generation can close a replacement bus. + """ + previous_generation = run.execution_generation + generation = previous_generation + 1 + if run.title_task is not None and not run.title_task.done(): + run.title_task.cancel() + run.execution_generation = generation + run.finished = False async def _go() -> None: failure_detail = "" try: await driver(run) except Exception as exc: - failure_detail = str(exc)[:500] + LOG.exception( + "run driver failed before terminal receipt: run_id=%s generation=%s", + run.run_id, generation, + ) + failure_detail = _safe_exception_detail("driver failed", exc) finally: - # If the driver exited WITHOUT emitting RUN_FINISHED (cancelled - # mid-run, or it crashed before its own terminal event), the deck - # never gets a terminal signal and the rail spins forever (a "ghost - # run"). _meta_sink flips run.finished=True on a real RUN_FINISHED, - # so a still-False flag here means none was emitted — synthesize one - # before closing the bus so every run reaches a settled state. - if not run.finished: - try: - await run.bus.emit(Event( - event_type=EventType.RUN_FINISHED, run_id=run_id, - payload={"flag": run.flag, "flags": list(run.flags), - "expected_flags": run.expected_flags, - "multi_flag": run.multi_flag, - "solved": run.solved, - "reason": "runtime_failure", - "detail": failure_detail})) - except Exception: - pass - run.finished = True - await run.bus.close() + current = asyncio.current_task() + # A driver may transfer cleanup to a separately fenced runtime + # owner. A stale wrapper or transferred owner performs no generation + # finalization here. Avoid returning from ``finally`` so cancellation + # and unexpected base exceptions retain their normal semantics. + if (self._execution_owned(run, generation, current) + and not run.runtime_incomplete): + # If the driver exited without a terminal receipt, synthesize the + # generation's sole terminal event before closing its bus. + if not run.finished: + try: + reason = run.termination_reasons.pop( + generation, "runtime_failure") + detail = failure_detail + if reason == "operator_stop" and not detail: + detail = "Operator requested run termination" + error_id = _runtime_error_id( + run.run_id, generation, detail or reason) + if run.protocol_version == 2: + payload = { + "expected_flags": run.expected_flags, + "multi_flag": run.multi_flag, + "solved": run.solved, + "reason": reason, + "failure_code": ( + "operator_stop" + if reason == "operator_stop" + else "runtime_driver_failed"), + "failure_phase": "runtime", + "error_id": error_id, + "detail": detail, + } + else: + payload = { + "flag": run.flag, + "flags": list(run.flags), + "expected_flags": run.expected_flags, + "multi_flag": run.multi_flag, + "solved": run.solved, + "reason": reason, + "failure_code": ( + "operator_stop" + if reason == "operator_stop" + else "runtime_driver_failed"), + "failure_phase": "runtime", + "error_id": error_id, + "detail": detail, + } + await run.bus.emit(Event( + event_type=EventType.RUN_FINISHED, + run_id=run.run_id, + payload=payload, + )) + except Exception: + pass + # Event sinks may await. Re-check ownership before final close. + if self._execution_owned(run, generation, current): + self._retire_hitl_epoch(run, terminal=True) + self._retire_worker_command_epoch(run) + title_task = run.title_task + if (title_task is not None and title_task is not current + and not title_task.done()): + title_task.cancel() + await asyncio.gather( + title_task, return_exceptions=True) + run.finished = True + await run.bus.close() + + coroutine = _go() + try: + task = asyncio.create_task( + coroutine, name=f"run-{run.run_id}-generation-{generation}") + except BaseException: + coroutine.close() + run.execution_generation = previous_generation + raise + run.task = task + return task - run.task = asyncio.create_task(_go()) - return run + @staticmethod + def _control_epoch_drain_timeout() -> float: + try: + return max(0.05, float(os.environ.get( + "MUTEKI_CONTROL_EPOCH_DRAIN_TIMEOUT", "35"))) + except (TypeError, ValueError): + return 35.0 + + async def _drain_control_before_launch(self, run_id: str, run: Run) -> bool: + """Fence and drain every pre-launch control command to a terminal receipt. + + ``_launching_runs`` is published before this method is called, so new + submissions fail closed. Acquiring the compile/submit lock waits for a + request that crossed admission but has not yet reached the actor; actor + ``join`` then drains commands already queued or executing. The wait is + bounded—failure leaves the old epoch intact and launches nothing. + """ + submit_lock = self._control_submit_locks.setdefault( + run_id, asyncio.Lock()) + async with submit_lock: + actor = run.control_actor + if actor is None: + return True + try: + await asyncio.wait_for( + actor.join(), timeout=self._control_epoch_drain_timeout()) + except asyncio.TimeoutError: + LOG.error( + "refusing to launch %s: prior control epoch did not drain", + run_id, + ) + return False + return True + + async def start(self, run_id: str, driver: Driver) -> Run: + """Admit and launch one fresh execution generation. + + Duplicate live starts are conflicts; they never overwrite the only task + handle. Finished generations may be explicitly restarted on the same run + id, with a fresh bus/control epoch and cleared terminal projection. + """ + async with self._lifecycle_lock: + if self._shutting_down: + raise StateConflict("run manager is shutting down") + if run_id in self._closing_runs or run_id in self._launching_runs: + raise StateConflict(f"run {run_id} lifecycle transition is in progress") + run = self.create(run_id) + if not self.is_protocol1_run(run_id, run=run): + # A catalog-owned V2 run is immutable and never re-enters the + # generic start path after restart/archive/purge. Continuation + # needs a future explicit execution-generation command. + raise StateConflict( + f"run {run_id} is Protocol 2; legacy/restart start is unavailable") + if run.runtime_incomplete: + raise StateConflict( + f"run {run_id} still has an unsettled runtime owner") + if run.task is not None and not run.task.done(): + raise StateConflict(f"run {run_id} is already running") + if self._standby_busy(run): + raise StateConflict(f"run {run_id} standby runtime is still active") + self._launching_runs.add(run_id) + try: + if not await self._drain_control_before_launch(run_id, run): + raise StateConflict( + f"run {run_id} prior control epoch is still draining") + async with self._lifecycle_lock: + if (self._shutting_down or self.runs.get(run_id) is not run + or run_id in self._closing_runs + or run.runtime_incomplete + or (run.task is not None and not run.task.done()) + or self._standby_busy(run)): + raise StateConflict( + f"run {run_id} lifecycle changed before launch") + self._fresh_bus(run) + self._retire_hitl_epoch(run, terminal=False) + self._retire_worker_command_epoch(run) + run.protocol_version = int( + getattr(driver, "protocol_version", 1) or 1) + if run.started: + _actor, journal, _secrets = self._ensure_control(run) + state = journal.reopen_state( + reason="explicit start generation") + run.control_generation = state.generation + self._launch_generation(run, driver) + run.finished = False + run.solved = False + run.flag = None + run.flags = [] + run.paused = False + run.started = True + return run + finally: + async with self._lifecycle_lock: + self._launching_runs.discard(run_id) # actions a standby (post-solve) worker can serve. pause/resume/submit only # make sense against a LIVE run, so they never trigger a standby. _STANDBY_ACTIONS = {"ask", "hint", "mark_false", "writeup", "redirect", "focus"} + _OFFLINE_CONTROL_ACTIONS = {"clear_standing", "reset_guidance"} - async def post_hitl(self, run_id: str, target: str, action: str, **fields: Any) -> bool: - """Route a human command into the run + echo it on the event stream. + @staticmethod + def _standby_cancel_timeout() -> float: + try: + return max(0.01, float(os.environ.get( + "MUTEKI_STANDBY_CANCEL_TIMEOUT", "2"))) + except (TypeError, ValueError): + return 2.0 - While the run is LIVE, the command flows to the running swarm via run.hitl - (pause/resume act on the subprocess; hints reach workers). Once the run has - FINISHED — or the server restarted and there's no live task — a follow-up - would otherwise vanish: nothing drains run.hitl. So we COLD-START a standby - worker (resume the winner's session) to actually respond.""" - run = self.runs.get(run_id) - if run is None: + @staticmethod + def _standby_runtime_status(run: Run) -> Optional[bool]: + query = run.standby_runtime_exited + if not callable(query): + return None + try: + return bool(query()) + except Exception: + # A broken proof boundary is never proof of exit. return False - # stop: gracefully END a run. A LIVE run: cancel run.task → the swarm's - # finally-block _cancel_solver + killpg every worker, the driver's finally - # closes the bus → the run reaches `finished` (RUN_FINISHED), JSONL + board - # PRESERVED (unlike DELETE). A GHOST run (no live task but the deck still - # shows "running" because its event stream ended mid-flight without a - # terminating RUN_FINISHED — e.g. a relaunch killed when the server died, - # run-4305): we FORCE it finished here + broadcast RUN_FINISHED so the deck - # settles and shows the finished controls. Stop must never leave a run stuck. - if action == "stop": - await run.bus.emit(Event( - event_type=EventType.HITL_RESPONSE, run_id=run_id, - payload=hitl_response_payload(target, action, **fields))) - if run.task is not None and not run.task.done(): - # ⑤ Route stop THROUGH the hitl queue first so the swarm's _drain_hitl - # sets _operator_stop=True and the coordinator finalizes as - # "operator_stop" — NOT "runtime_failure". A bare task.cancel() (the old - # path) skipped that flag, so finalize mislabeled an operator stop as a - # crash and parked every in-flight intent as resume noise (run-75377: 53 - # stranded intents). Give the coordinator a brief window to drain + exit - # cleanly on its own; cancel only as a backstop if it doesn't. + + async def _settle_incomplete_runtime( + self, run: Run, *, timeout: float, + ) -> bool: + """Boundedly wait/retry the retained main-runtime owner cleanup.""" + if not run.runtime_incomplete: + return True + task = run.runtime_cleanup_task + if task is None or task.done(): + settle = run.runtime_settle + if callable(settle): + task = asyncio.create_task( + settle(), name=f"runtime-owner-settle-{run.run_id}") + run.runtime_cleanup_task = task + if task is None: + return False + try: + await asyncio.wait_for( + asyncio.shield(task), timeout=max(0.01, float(timeout))) + except asyncio.TimeoutError: + return False + except asyncio.CancelledError: + raise + except Exception: + return False + return not run.runtime_incomplete and run.runtime_owner is None + + def _standby_scope_matches_winner(self, run: Run, target: str) -> bool: + """Prove a finished-run selector includes the one resumable winner.""" + try: + scope = ControlScope.parse(target or "global") + except Exception: + return False + if scope.kind.value == "global": + return True + if scope.kind.value in {"run", "challenge"}: + return scope.value == run.run_id + try: + import json + winner_path = self.workspace_dir(run.run_id) / "winner.json" + winner = json.loads(winner_path.read_text()) + except Exception: + return False + if scope.kind.value == "worker": + persisted_worker = str(winner.get("worker_id") or "") + return bool(persisted_worker and persisted_worker == scope.value) + if scope.kind.value == "engine": + return str(winner.get("engine") or "") == scope.value + # Intent/lane identity is not persisted in winner.json; never widen it to + # the winner merely because that is the only standby session available. + return False + + def _register_standby_winner(self, run: Run) -> None: + """Project the persisted winner as the only valid finished-run mailbox.""" + try: + import json + winner = json.loads( + (self.workspace_dir(run.run_id) / "winner.json").read_text()) + worker_id = str(winner.get("worker_id") or "").strip() + if not worker_id: + return + run.worker_registry.register(WorkerRef( + worker_id=worker_id, + engine=str(winner.get("engine") or ""), + challenge_id=run.run_id, + status="standby", + metadata={"persisted_winner": True}, + )) + except Exception: + return + + def _ensure_standby_context_cleanup( + self, run: Run, *, owner: str, + reservations: list[tuple[str, str]], + ) -> Optional[asyncio.Task]: + """Retain/retry standby reservation release until SQLite proves terminal.""" + if not owner or not reservations or run.control_journal is None: + return None + run.standby_context_cleanup_owner = str(owner) + run.standby_context_cleanup_reservations = list(dict.fromkeys([ + *run.standby_context_cleanup_reservations, + *((str(a), str(b)) for a, b in reservations), + ])) + current = run.standby_context_cleanup_task + if current is not None and not current.done(): + return current + + async def _cleanup() -> None: + journal = run.control_journal + assert journal is not None + pending = list(run.standby_context_cleanup_reservations) + while pending: + remaining: list[tuple[str, str]] = [] + for context_id, reservation_id in pending: + released = False + try: + released = bool(journal.release_context_reservation( + str(context_id), worker_id=str(owner), + reservation_id=str(reservation_id))) + except Exception: + released = False + if not released: + try: + # bound/unknown/already-active are terminal postconditions; + # only an actually reserved row still needs retry. + released = ( + journal.context_delivery_status(str(context_id)) + != "reserved") + except Exception: + released = False + if not released: + remaining.append((str(context_id), str(reservation_id))) + pending = remaining + run.standby_context_cleanup_reservations = list(remaining) + if pending: + await asyncio.sleep(0.05) + run.standby_context_cleanup_owner = "" + + task = asyncio.create_task( + _cleanup(), name=f"standby-context-release-{run.run_id}") + run.standby_context_cleanup_task = task + + def _done(done: asyncio.Task) -> None: + try: + done.result() + except BaseException: + pass + if run.standby_context_cleanup_task is done: + run.standby_context_cleanup_task = None + + task.add_done_callback(_done) + return task + + async def _settle_standby_runtime(self, run: Run, *, timeout: float) -> bool: + """Boundedly drive standby teardown while retaining its kill owner. + + Returns only proof: no live wrapper and no independently-live runtime. A + timeout never cancels the autonomous reaper; callers must keep the Run and + its callbacks so cleanup remains retryable. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + max(0.01, float(timeout)) + if (run.standby_context_cleanup_reservations + and (run.standby_context_cleanup_task is None + or run.standby_context_cleanup_task.done())): + self._ensure_standby_context_cleanup( + run, owner=run.standby_context_cleanup_owner, + reservations=list(run.standby_context_cleanup_reservations)) + if self._standby_busy(run): + await self._cancel_standby( + run, timeout=max(0.01, deadline - loop.time())) + cleanup = run.standby_runtime_cleanup_task + if cleanup is not None and not cleanup.done(): + remaining = deadline - loop.time() + if remaining > 0: + try: + await asyncio.wait_for( + asyncio.shield(cleanup), timeout=remaining) + except asyncio.TimeoutError: + return False + except asyncio.CancelledError: + raise + except Exception: + return False + setup = run.standby_setup_task + if setup is not None and not setup.done(): + remaining = deadline - loop.time() + if remaining > 0: try: - run.hitl.put_nowait({"action": "stop", "target": target}) + await asyncio.wait_for( + asyncio.shield(setup), timeout=remaining) + except asyncio.TimeoutError: + return False + except asyncio.CancelledError: + raise except Exception: + # A failed acquisition is settled only after its owning wrapper + # has run the teardown/finally path below. pass - for _ in range(40): # ~4s: _drain_hitl runs each coordinator tick - await asyncio.sleep(0.1) - if run.task.done(): - break - if not run.task.done(): - run.task.cancel() + context_cleanup = run.standby_context_cleanup_task + if context_cleanup is not None and not context_cleanup.done(): + remaining = deadline - loop.time() + if remaining > 0: + try: + await asyncio.wait_for( + asyncio.shield(context_cleanup), timeout=remaining) + except asyncio.TimeoutError: + return False + except asyncio.CancelledError: + raise + except Exception: + return False + task_live = run.standby_task is not None and not run.standby_task.done() + runtime_live = self._standby_runtime_status(run) is False + cleanup_live = ( + run.standby_runtime_cleanup_task is not None + and not run.standby_runtime_cleanup_task.done() + ) + setup_live = ( + run.standby_setup_task is not None + and not run.standby_setup_task.done() + ) + context_cleanup_live = ( + run.standby_context_cleanup_task is not None + and not run.standby_context_cleanup_task.done() + ) or bool(run.standby_context_cleanup_reservations) + return (not task_live and not runtime_live and not cleanup_live + and not setup_live and not context_cleanup_live) + + @classmethod + def _standby_busy(cls, run: Run) -> bool: + task_live = run.standby_task is not None and not run.standby_task.done() + setup_live = ( + run.standby_setup_task is not None + and not run.standby_setup_task.done() + ) + runtime_status = cls._standby_runtime_status(run) + context_cleanup_live = ( + run.standby_context_cleanup_task is not None + and not run.standby_context_cleanup_task.done() + ) or bool(run.standby_context_cleanup_reservations) + return (task_live or setup_live or context_cleanup_live + or runtime_status is False) + + async def _cancel_standby(self, run: Run, *, timeout: float) -> dict[str, Any]: + """Cancel a standby at both the runtime and asyncio boundaries. + + Calling ``Task.cancel`` alone only interrupts the coroutine waiting on + ``asyncio.to_thread``; it does not stop the thread or the shelled CLI. + Therefore an observed effect requires ALL THREE fences: successful delivery + to the live worker cancel callback, wrapper task unwind, and CliSolver proof + that every runner thread and process handle exited. A deadline can prove only + a partial/unknown effect, never success. + """ + task = run.standby_task + initial_runtime_status = self._standby_runtime_status(run) + task_live = task is not None and not task.done() + if not task_live and initial_runtime_status is not False: + return { + "state": "unknown", + "detail": "no live standby worker was available to cancel", + "target_ids": [], + "metadata": { + "code": "no_live_standby", + "worker_cancel_delivered": False, + "task_done": bool(task is not None and task.done()), + "runtime_exit_confirmed": initial_runtime_status is True, + }, + } + + loop = asyncio.get_running_loop() + deadline = loop.time() + max(0.01, float(timeout)) + cancel_callback = run.standby_cancel + runtime_query = run.standby_runtime_exited + runtime_wait = run.standby_wait_runtime_exit + cancel_delivered = False + cancel_error = "" + timed_out = False + + # Order is intentional: signal the real process tree first. Cancelling the + # wrapper first can run driver.finally and lose the only live process handle. + if callable(cancel_callback): + try: + callback_result = cancel_callback() + if inspect.isawaitable(callback_result): + remaining = max(0.001, deadline - loop.time()) + callback_result = await asyncio.wait_for( + callback_result, timeout=remaining) + # A callback may explicitly return False when its runtime boundary + # could not accept the signal. Legacy ``worker.cancel`` returns + # None, which means the call itself completed successfully. + cancel_delivered = callback_result is not False + except asyncio.TimeoutError: + timed_out = True + cancel_error = "worker cancellation callback timed out" + except Exception as exc: # noqa: BLE001 - recorded as effect evidence + cancel_error = _safe_exception_detail( + "worker cancellation callback failed", exc) + + if task is not None and not task.done(): + task.cancel() + + if task is not None and not task.done() and not timed_out: + remaining = max(0.001, deadline - loop.time()) + try: + await asyncio.wait_for(asyncio.shield(task), timeout=remaining) + except asyncio.TimeoutError: + timed_out = True + except asyncio.CancelledError: + # Expected when the INNER standby task acknowledged cancellation. + # If it is not done, this CancelledError belongs to our own actor and + # must propagate rather than being forged into a terminal receipt. + if not task.done(): + raise + except Exception: + # A failed-but-done worker is still an observed termination once the + # real cancel callback was delivered. + pass + + task_done = bool(task is not None and task.done()) + runtime_exit_confirmed = False + if callable(runtime_query): + try: + runtime_exit_confirmed = bool(runtime_query()) + except Exception: + runtime_exit_confirmed = False + + # The wrapper may already be done while asyncio.to_thread continues. Spend + # the remainder of the standby deadline waiting on the independent runtime + # fence. The waiter itself never cancels the tracked runner tasks. + if (task_done and not runtime_exit_confirmed and callable(runtime_wait) + and not timed_out): + remaining = deadline - loop.time() + if remaining > 0: + try: + wait_result = runtime_wait(remaining) + if inspect.isawaitable(wait_result): + wait_result = await asyncio.wait_for( + wait_result, timeout=remaining) + runtime_exit_confirmed = bool(wait_result) + except asyncio.TimeoutError: + timed_out = True + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - effect evidence + cancel_error = (cancel_error + "; " if cancel_error else "") + ( + _safe_exception_detail("runtime exit fence failed", exc)) else: - # ghost / already-dead task → settle the state ourselves. - run.finished = True + timed_out = True + + # Re-query after the await to close a boundary race where the waiter reached + # its deadline just as the final process changed poll state. + if callable(runtime_query): + try: + runtime_exit_confirmed = bool(runtime_query()) + except Exception: + pass + if not (task_done and runtime_exit_confirmed) and loop.time() >= deadline: + timed_out = True + + metadata = { + "worker_cancel_registered": callable(cancel_callback), + "worker_cancel_delivered": cancel_delivered, + "task_done": task_done, + "runtime_exit_registered": ( + callable(runtime_query) and callable(runtime_wait)), + "runtime_exit_confirmed": runtime_exit_confirmed, + "timed_out": timed_out, + } + if cancel_error: + metadata["cancel_error"] = cancel_error[:500] + + if cancel_delivered and task_done and runtime_exit_confirmed: + return { + "state": "effect_observed", + "detail": ( + "standby worker cancellation, task unwind, and runtime exit confirmed"), + "target_ids": [], + "metadata": {**metadata, "effect": "standby_cancelled"}, + } + if cancel_delivered or task_done or runtime_exit_confirmed: + detail = (cancel_error or + "standby cancellation was requested but runtime exit was not fully confirmed") + return { + "state": "partial", + "detail": detail, + "target_ids": [], + "metadata": {**metadata, "code": "standby_cancel_unconfirmed"}, + } + return { + "state": "unknown", + "detail": (cancel_error or + "standby cancellation could not be confirmed before the deadline"), + "target_ids": [], + "metadata": {**metadata, "code": "standby_cancel_unknown"}, + } + + def _ensure_control(self, run: Run) -> tuple[ControlActor, SQLiteControlJournal, + SecretStore]: + """Create the one actor/journal/secret boundary owned by this run.""" + if (run.control_actor is not None and run.control_journal is not None + and run.control_secrets is not None): + return run.control_actor, run.control_journal, run.control_secrets + + db_path, secrets_path = control_paths( + self.coordinator_control_dir(run.run_id)) + journal = SQLiteControlJournal.open(db_path=db_path, run_id=run.run_id) + # SessionStore and the control journal are separate durable sinks. A crash + # can persist HITL_REQUEST to JSONL before the live metadata sink appends + # its DecisionRequest. Rebuild that idempotent edge before validating any + # answer so a replayed card can never become permanently unanswerable. + self._reconcile_decision_requests(run, journal) + # This is the sole owner boundary for a fresh web runtime generation. Any + # pre-Popen reservation left by the prior process has an unknowable delivery + # outcome and must be terminalised append-only, never silently replayed. + journal.recover_context_reservations(actor="web-runtime-recovery") + secrets = SecretStore(secrets_path) + + def _live() -> bool: + return run.task is not None and not run.task.done() + + try: + ack_timeout = float(os.environ.get("MUTEKI_CONTROL_ACK_TIMEOUT", "2")) + except (TypeError, ValueError): + ack_timeout = 2.0 + try: + claim_timeout = float(os.environ.get( + "MUTEKI_CONTROL_CLAIM_TIMEOUT", "30")) + except (TypeError, ValueError): + claim_timeout = 30.0 + async def _standby_control(wire: dict[str, Any]) -> Any: + action = str(wire.get("action") or "").lower() + busy = self._standby_busy(run) + target = str(wire.get("target") or "global") + exact_text = str(wire.get("text") or wire.get("hint") or "").strip() + + if action in self._OFFLINE_CONTROL_ACTIONS: + # The actor already expired typed ContextResources. Atomically + # expire the evidence-graph projection too so a restart cannot + # resurrect guidance that an offline command claimed to clear. + graph_db = ( + self.workspace_dir(run.run_id) / "graph" / "shared_graph.db") + expired_directives: list[str] = [] + companion = (wire.get("_control_companion") + if isinstance(wire.get("_control_companion"), dict) + else {}) + expired_context_count = int( + companion.get("expired_context_count") or 0) + graph = None + if graph_db.exists(): + try: + from muteki.models.solve_graph import Challenge + from muteki.swarm.shared_graph import SQLiteSharedGraph + graph = SQLiteSharedGraph.open( + db_path=graph_db, + challenge=Challenge( + id=run.run_id, + name=run.name or run.run_id, + category=run.category or "web", + ), + ) + source_command_id = str( + wire.get("command_id") or "").strip() + matched_source_ids = sorted({ + str(value or "").strip() + for value in ( + companion.get("matched_source_command_ids") or []) + if str(value or "").strip() + }) + if source_command_id: + clear_result = graph.apply_standing_clear( + command_id=source_command_id, + actor="operator", + text=("" if exact_text.startswith("secret://") + else exact_text), + eligible_command_ids=( + matched_source_ids if exact_text else None), + match_by_source_ids=exact_text.startswith( + "secret://"), + ) + expired_directives = list( + clear_result.get("expired_directives") or []) + else: + expired_directives = graph.expire_standing_directives( + actor="operator", text=exact_text) + remaining = [ + row for row in graph.operator_directives(active_only=True) + if row.get("standing") + and (not exact_text or row.get("text") == exact_text) + ] + if remaining: + raise RuntimeError("standing directive remained active") + except Exception: + return { + "state": ("partial" if expired_context_count else "failed"), + "detail": "offline standing guidance expiration failed", + "target_ids": [], + "metadata": { + "code": "guidance_graph_expire_failed", + "expired_context_count": expired_context_count, + }, + } + finally: + if graph is not None: + graph.close() + remaining_context = [ + resource for resource in journal.context_resources(active_only=True) + if resource.standing + and (not exact_text or resource.content == exact_text) + ] + if remaining_context: + return { + "state": ("partial" if ( + expired_directives or expired_context_count) else "failed"), + "detail": "offline standing context expiration was not confirmed", + "target_ids": [], + "metadata": { + "code": "guidance_context_expire_failed", + "expired_context_count": expired_context_count, + }, + } + return { + "state": "effect_observed", + "detail": "offline standing guidance durably expired", + "target_ids": [], + "metadata": { + "effect": "guidance_cleared", + "expired_directives": expired_directives, + "expired_context_count": expired_context_count, + }, + } + + if not self._standby_scope_matches_winner(run, target): + return { + "state": "unknown", + "detail": "standby winner identity does not match control scope", + "target_ids": [], + "metadata": {"code": "standby_scope_unresolved"}, + } + if action == "mark_false": + flag = str(wire.get("flag") or "").strip() + if not flag and exact_text: + match = re.search( + r"[A-Za-z0-9_]{0,15}\{[^}]{1,200}\}", exact_text) + flag = match.group(0) if match else exact_text + if not flag: + flag = str(run.flag or "") + if not flag: + return { + "state": "unknown", + "detail": "no flag was available to invalidate", + "target_ids": [], + "metadata": {"code": "flag_unavailable"}, + } + graph_db = ( + self.workspace_dir(run.run_id) / "graph" / "shared_graph.db") + if not graph_db.exists(): + return { + "state": "failed", + "detail": "offline flag graph is unavailable", + "target_ids": [], + "metadata": {"code": "flag_graph_unavailable"}, + } + graph = None + try: + from muteki.models.solve_graph import Challenge + from muteki.swarm.shared_graph import SQLiteSharedGraph + graph = SQLiteSharedGraph.open( + db_path=graph_db, + challenge=Challenge( + id=run.run_id, + name=run.name or run.run_id, + category=run.category or "web", + ), + ) + info = graph.reopen_after_false_positive( + actor="operator", flag=flag) + except Exception: + return { + "state": "failed", + "detail": "offline flag invalidation was not committed", + "target_ids": [], + "metadata": {"code": "flag_invalidation_failed"}, + } + finally: + if graph is not None: + graph.close() + + run.invalidate_flag(flag) + # Keep the post-solve snapshot aligned immediately. A failed + # re-solve must not let a later writeup/ask seed workers from a + # winner.json that still contains the invalidated flag. + try: + import json + winner_path = self.workspace_dir(run.run_id) / "winner.json" + winner = (json.loads(winner_path.read_text()) + if winner_path.exists() else {}) + surviving = [ + value for value in list(winner.get("flags") or run.flags) + if value and value != flag + ] + winner["flags"] = surviving + winner["flag"] = surviving[0] if surviving else "" + temp = winner_path.with_suffix(".json.tmp") + temp.write_text(json.dumps( + winner, ensure_ascii=False, indent=2)) + os.replace(temp, winner_path) + except Exception: + # The graph/run projection remains authoritative; standby also + # reads graph flags first. Snapshot repair is retried naturally + # on the next successful solve. + pass + runtime_wire = dict(wire) + runtime_wire["_control_mark_false_applied"] = True + try: + from muteki.core.events import blackboard_delta_payload + await run.bus.emit(Event( + event_type=EventType.BLACKBOARD_DELTA, + run_id=run.run_id, + payload=blackboard_delta_payload( + "flag_invalidated", actor="operator", flag=flag), + )) + await run.bus.emit(Event( + event_type=EventType.RUN_REOPENED, + run_id=run.run_id, + payload={"flag": flag}, + )) + except Exception: + pass + if busy: + return { + "state": "partial", + "detail": "flag invalidated; standby re-solve is already busy", + "target_ids": [], + "metadata": { + "effect": "flag_invalidated", + "code": "standby_busy", + "reopened": list(info.get("reopened") or []), + }, + } + accepted = self._ensure_standby(run.run_id, runtime_wire) + return { + "state": "effect_observed" if accepted else "partial", + "detail": ( + "flag invalidated and standby re-solve scheduled" if accepted + else "flag invalidated but standby re-solve could not start"), + "target_ids": [], + "metadata": { + "effect": "flag_invalidated", + "resolve_scheduled": bool(accepted), + "reopened": list(info.get("reopened") or []), + }, + } + if action in {"stop", "complete", "force_cancel"} and busy: + return await self._cancel_standby( + run, timeout=self._standby_cancel_timeout()) + if action not in self._STANDBY_ACTIONS: + return None + if busy: + return { + "state": "unknown", + "detail": "standby worker is already serving another command", + "target_ids": [], + "metadata": {"code": "standby_busy"}, + } + runtime_wire = dict(wire) # remains opaque; driver resolves after reserve + command_id = str(wire.get("command_id") or "") + reservation: Optional[tuple[str, str]] = None + reservation_owner = f"standby:{command_id}" if command_id else "" + context_status = "missing" + if command_id: + try: + from muteki.control import context_resource_id_for_command + context_id = context_resource_id_for_command(command_id) + context_status = journal.context_delivery_status(context_id) + if context_status == "active": + reservation_id = journal.reserve_context( + context_id, worker_id=reservation_owner) + if not reservation_id: + raise RuntimeError("reservation unavailable") + reservation = (context_id, str(reservation_id)) + elif context_status != "missing": + raise RuntimeError( + f"context state {context_status} is not deliverable") + except Exception: + return { + "state": "unknown", + "detail": "standby context is not deliverable", + "target_ids": [], + "metadata": {"code": "standby_context_unavailable"}, + } + + def _has_secret_ref(value: Any) -> bool: + if isinstance(value, dict): + return any(_has_secret_ref(child) for child in value.values()) + if isinstance(value, (list, tuple)): + return any(_has_secret_ref(child) for child in value) + return isinstance(value, str) and value.startswith("secret://") + + carries_prompt_context = ( + action in {"ask", "hint", "focus", "redirect"} + and any(wire.get(key) for key in ( + "text", "hint", "url", "target_url", "context")) + ) + if reservation is None and ( + _has_secret_ref(wire) or carries_prompt_context): + return { + "state": "unknown", + "detail": "standby prompt context has no active reservation", + "target_ids": [], + "metadata": {"code": "standby_context_unavailable"}, + } + loop = asyncio.get_running_loop() + delivery_ack: "asyncio.Future[bool]" = loop.create_future() + runtime_wire["_standby_delivery_ack"] = delivery_ack + runtime_wire["_control_context_reservations"] = ( + [reservation] if reservation is not None else []) + runtime_wire["_control_context_owner"] = reservation_owner + accepted = self._ensure_standby(run.run_id, runtime_wire) + if not accepted: + if reservation is not None: + released = False + try: + released = bool(journal.release_context_reservation( + reservation[0], worker_id=reservation_owner, + reservation_id=reservation[1])) + except Exception: + pass + if not released: + self._ensure_standby_context_cleanup( + run, owner=reservation_owner, + reservations=[reservation]) + return { + "state": "unknown", + "detail": "standby worker could not be started", + "target_ids": [], + "metadata": {"code": "standby_start_failed"}, + } + try: + delivered = await asyncio.wait_for( + asyncio.shield(delivery_ack), timeout=max(0.05, claim_timeout)) + except asyncio.TimeoutError: + await self._cancel_standby( + run, timeout=self._standby_cancel_timeout()) + return { + "state": "unknown", + "detail": "standby prompt delivery was not confirmed", + "target_ids": [], + "metadata": {"code": "standby_delivery_timeout"}, + } + return { + "state": "effect_observed" if delivered else "unknown", + "detail": ("standby prompt delivery confirmed" if delivered + else ("standby prompt delivery was not confirmed and " + "may have crossed the process/stdin boundary")), + "target_ids": [], + "metadata": { + "effect": ("standby_prompt_started" if delivered + else "delivery_unknown"), + "context_reserved": reservation is not None, + "process_start_unknown": not delivered, + }, + } + + port = QueueControlPort( + inbox=run.hitl, + is_live=_live, + ack_timeout=ack_timeout, + claim_timeout=claim_timeout, + standby_actions=tuple( + self._STANDBY_ACTIONS | self._OFFLINE_CONTROL_ACTIONS), + on_standby=_standby_control, + ) + + async def _cancel_main_runtime(command, targets, desired) -> ApplyResult: + run_wide = command.scope.kind.value in { + "global", "run", "challenge", + } + allows_run_termination_fallback = ( + command.action in {ControlAction.STOP, ControlAction.COMPLETE} + or ( + command.action is ControlAction.FORCE_CANCEL + and desired.mode is RunControlMode.TERMINATED + ) + ) + if allows_run_termination_fallback and run_wide: + run.termination_reasons[run.execution_generation] = "operator_stop" + result = await port.apply(command, targets, desired) + if not allows_run_termination_fallback or not run_wide: + return result + # QueueControlPort already owns the stronger standby process/runtime + # fence. Never launder its PARTIAL/UNKNOWN into a clean main-task exit + # merely because run.task is absent. + if (run.standby_task is not None + or callable(run.standby_runtime_exited) + or callable(run.standby_wait_runtime_exit)): + return result + + task = run.task + cancel_requested = bool(task is not None and not task.done()) + if cancel_requested: + run.termination_reasons[run.execution_generation] = "operator_stop" + task.cancel() + try: + await asyncio.wait_for( + asyncio.shield(task), + timeout=self._standby_cancel_timeout(), + ) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + except Exception: + # The wrapper's failure is reflected by task.done/runtime owner + # below; exception text is never needed for the cancellation proof. + pass + task_done = task is None or task.done() + exit_confirmed = task_done and not run.runtime_incomplete + metadata = { + "effect": "run_terminated" if exit_confirmed else "run_cancel_requested", + "coordinator_effect": result.state.value, + "cancel_requested": cancel_requested, + "task_done": task_done, + "runtime_exit_confirmed": exit_confirmed, + } + if exit_confirmed: run.paused = False - await run.bus.emit(Event( - event_type=EventType.RUN_FINISHED, run_id=run_id, - payload={"flag": run.flag, "flags": list(run.flags), - "expected_flags": run.expected_flags, - "multi_flag": run.multi_flag, - "solved": run.solved, - "reason": "operator_stop"})) - return True + if not run.finished: + detail = "Operator requested run termination" + await run.bus.emit(Event( + event_type=EventType.RUN_FINISHED, + run_id=run.run_id, + payload={ + "solved": bool(run.solved), + "flag": run.flag, + "flags": list(run.flags), + "reason": "operator_stop", + "failure_code": "operator_stop", + "failure_phase": "runtime", + "error_id": _runtime_error_id( + run.run_id, run.execution_generation, detail), + "detail": detail, + }, + )) + return ApplyResult( + state=EffectState.EFFECT_OBSERVED, + detail="run task cancellation and runtime exit confirmed", + target_ids=[], metadata=metadata, + ) + return ApplyResult( + state=EffectState.PARTIAL if cancel_requested else EffectState.UNKNOWN, + detail="run cancellation requested but runtime exit is unconfirmed", + target_ids=[], metadata=metadata, + ) + + class _RuntimeFencedPort: + async def apply(self, command, targets, desired): + return await _cancel_main_runtime(command, targets, desired) + + async def _effect_sink(receipt) -> None: + command = journal.get_command(receipt.command_id) + if command is None: + return + run.control_generation = journal.current_state().generation + await run.bus.emit(Event( + event_type=EventType.CONTROL_COMMAND, + run_id=run.run_id, + payload=effect_event_payload(command, receipt), + )) + + actor = ControlActor( + run_id=run.run_id, + journal=journal, + port=_RuntimeFencedPort(), + registry=run.worker_registry, + admission=ControlAdmission(challenge_id=run.run_id), + effect_sink=_effect_sink, + secret_resolver=secrets.resolve, + ) + run.control_actor = actor + run.control_journal = journal + run.control_secrets = secrets + run.control_generation = journal.current_state().generation + return actor, journal, secrets + + def _decision_request_from_event( + self, run: Run, ev: Event, + ) -> Optional[DecisionRequest]: + payload = ev.payload or {} + request_id = str( + payload.get("request_id") or payload.get("id") or "").strip() + prompt = str( + payload.get("need") or payload.get("prompt") or "").strip() + if not request_id or not prompt: + return None + worker = str(payload.get("worker") or ev.solver_id or "").strip() + raw_scope = payload.get("blocking_scope") + try: + scope = ControlScope.parse( + raw_scope if raw_scope is not None + else f"worker:{worker}" if worker else "global") + except (TypeError, ValueError): + scope = ControlScope.parse( + f"worker:{worker}" if worker else "global") + worker_ref = next( + (ref for ref in run.worker_registry.snapshot() + if ref.worker_id == worker), None) + intent_id = str( + payload.get("intent_id") + or getattr(worker_ref, "intent_id", "") or "") + lane = str( + payload.get("lane") + or getattr(worker_ref, "lane", "") or "") + delivery_scope = str(payload.get("delivery_scope") or "") + if not delivery_scope: + if intent_id: + delivery_scope = f"intent:{intent_id}" + elif lane: + delivery_scope = f"lane:{lane}" + need_kind = str(payload.get("need_kind") or "external_blocker") + kind = (DecisionKind.EXTERNAL_INPUT + if need_kind == "external_blocker" + else DecisionKind.UNCERTAINTY) + return DecisionRequest( + request_id=request_id, + run_id=run.run_id, + worker_id=worker, + prompt=prompt, + kind=kind, + blocking_scope=scope, + choices=[str(v) for v in (payload.get("options") or [])][:32], + default_action=str(payload.get("default_action") or ""), + execution_id=str(payload.get("execution_id") or ""), + execution_occurrence=str( + payload.get("execution_occurrence") or ""), + resolve_epoch=str(payload.get("resolve_epoch") or ""), + deadline_at=(float(payload["deadline_at"]) + if payload.get("deadline_at") is not None else None), + created_at=float(ev.ts), + metadata={ + "delivery_scope": delivery_scope, + "engine": str( + payload.get("engine") + or getattr(worker_ref, "engine", "") or ""), + "intent_id": intent_id, + "lane": lane, + "reconciled_from_session": True, + }, + ) + + def _reconcile_decision_requests( + self, run: Run, journal: SQLiteControlJournal, + ) -> int: + appended = 0 + for raw in run.store.load_all(run.run_id): + if str(raw.get("event_type") or "") != EventType.HITL_REQUEST.value: + continue + try: + request = self._decision_request_from_event( + run, Event.model_validate(raw)) + if request is None: + continue + if journal.get_decision_request(request.request_id) is None: + journal.append_decision_request(request) + appended += 1 + except Exception: + # Preserve other valid cards if one historical row is malformed or + # reuses an id inconsistently. That row remains fail-closed/unknown. + LOG.error( + "failed to reconcile durable decision request for run %s", + run.run_id, + ) + return appended + + def _record_decision_request(self, run: Run, ev: Event) -> None: + request = self._decision_request_from_event(run, ev) + if request is None: + return + try: + _actor, journal, _secrets = self._ensure_control(run) + journal.append_decision_request(request) + except Exception: + LOG.exception( + "failed to journal decision request %s", request.request_id) + + async def post_control(self, run_id: str, body: dict[str, Any]) -> dict[str, Any]: + """Durably accept one command; never confuse HTTP acceptance with effect.""" + run = self.runs.get(run_id) + if run is None: + return {"ok": False, "status": "unknown_run"} + if not self.is_protocol1_run(run_id, run=run): + return { + "ok": False, + "status": "unavailable", + "detail": "Protocol 2 control/standby is not enabled for live canary", + "code": "PROTOCOL2_CONTROL_UNAVAILABLE", + } + if (self._shutting_down or run_id in self._closing_runs + or run_id in self._launching_runs): + return { + "ok": False, + "status": "unknown", + "detail": "run lifecycle transition is in progress", + "code": "run_lifecycle_unavailable", + } + lock = self._control_submit_locks.setdefault(run_id, asyncio.Lock()) + async with lock: + if (self.runs.get(run_id) is not run or self._shutting_down + or run_id in self._closing_runs + or run_id in self._launching_runs): + return { + "ok": False, + "status": "unknown", + "detail": "run lifecycle transition is in progress", + "code": "run_lifecycle_unavailable", + } + return await self._post_control_serialized(run_id, run, body) + + async def _post_control_serialized( + self, run_id: str, run: Run, body: dict[str, Any], + ) -> dict[str, Any]: + """Compile and submit under the per-run idempotency/secret boundary.""" + if run.runtime_incomplete: + return { + "ok": False, + "status": "unknown", + "detail": "runtime shutdown is incomplete", + "code": "runtime_shutdown_incomplete", + } + actor, journal, secrets = self._ensure_control(run) + requested_id = str(body.get("command_id") or "").strip() + existing = journal.get_command(requested_id) if requested_id else None + + def _decision_status_with_reconcile( + request_id: str) -> Optional[DecisionStatus]: + status = journal.decision_status(request_id) + if status is None and request_id: + # The JSONL sink may have committed while the live metadata sink + # transiently failed. Repair in the same process as well as after + # restart, then re-read before rejecting the operator's answer. + self._reconcile_decision_requests(run, journal) + status = journal.decision_status(request_id) + return status + + # Validate decision correlation before any plaintext is staged into the + # SecretStore. This closes the larger boundary around compile validation: + # an unknown/already-answered request must not leave an unreachable file. + raw_payload = body.get("payload") + decision_payload = raw_payload if isinstance(raw_payload, dict) else {} + raw_request_id = str( + decision_payload.get("request_id") + or body.get("request_id") + or "" + ).strip() + raw_action = str(body.get("action") or "hint").strip().lower() + is_decision_answer = ( + raw_action == ControlAction.ANSWER_DECISION.value + or (raw_action in {"answer", "submit"} and bool(raw_request_id)) + ) + if is_decision_answer: + decision_status = _decision_status_with_reconcile(raw_request_id) + if decision_status is None: + raise ControlPayloadError( + f"unknown decision request: {raw_request_id}") + if decision_status is not DecisionStatus.OPEN and existing is None: + raise StateConflict( + f"decision {raw_request_id!r} is already {decision_status.value}") + + command = compile_control_command( + run_id, body, secrets=secrets, existing_command=existing) + if existing is None: + existing = journal.get_command(command.command_id) + + request_id = str(command.payload.get("request_id") or "").strip() + if command.action is ControlAction.ANSWER_DECISION: + decision_status = _decision_status_with_reconcile(request_id) + if decision_status is None: + raise ControlPayloadError(f"unknown decision request: {request_id}") + if decision_status is not DecisionStatus.OPEN and existing is None: + raise StateConflict( + f"decision {request_id!r} is already {decision_status.value}") + + live = run.task is not None and not run.task.done() + schedule_standby = not live and command.action.value in self._STANDBY_ACTIONS + schedule_offline = ( + not live + and command.action.value in self._OFFLINE_CONTROL_ACTIONS) + if schedule_standby: + self._register_standby_winner(run) + if ((schedule_standby or schedule_offline) + and bool(getattr(run.bus, "_closed", False))): + # Finished buses are closed; make receipt events visible before the actor + # emits them and before the standby worker starts. + self._fresh_bus(run) + + receipt = await actor.submit(command) + if existing is None: + await run.bus.emit(Event( + event_type=EventType.HITL_RESPONSE, + run_id=run_id, + payload=safe_hitl_echo(command, status=receipt.state.value), + )) + ok = receipt.state not in {EffectState.REJECTED, EffectState.FAILED} + result = { + "ok": ok, + "command_id": command.command_id, + "status": receipt.state.value, + "generation": receipt.observed_generation, + "detail": safe_receipt_detail(command, receipt.detail), + "code": receipt.metadata.get("code", ""), + } + if schedule_standby and existing is None: + # Give the actor one scheduling turn so legacy callers can immediately + # observe standby_task without claiming the terminal effect in HTTP. + await asyncio.sleep(0) + return result + + def control_receipt(self, run_id: str, command_id: str) -> Optional[dict[str, Any]]: + """Return a safe durable receipt projection for crash/event reconciliation.""" + run = self.runs.get(run_id) + if run is None: + return None + if not self.is_protocol1_run(run_id, run=run): + return None + _actor, journal, _secrets = self._ensure_control(run) + command = journal.get_command(str(command_id or "")) + if command is None: + return None + receipt = journal.latest_effect(command.command_id) + if receipt is None: + return None + return { + "command_id": command.command_id, + "receipt_id": receipt.receipt_id, + "action": command.action.value, + "target": command.scope.as_legacy_target(), + "status": receipt.state.value, + "generation": receipt.observed_generation, + "target_ids": list(receipt.target_ids), + "detail": safe_receipt_detail(command, receipt.detail), + "code": str(receipt.metadata.get("code") or ""), + "terminal": receipt.state.terminal, + } + + async def post_hitl(self, run_id: str, target: str, action: str, **fields: Any) -> bool: + """Legacy bool facade compiled onto the same durable ControlCommand path.""" + run = self.runs.get(run_id) + if run is None: + return False + if not self.is_protocol1_run(run_id, run=run): + return False + fields = dict(fields) + # Old clients have no decision picker correlation. Preserve compatibility + # only when there is exactly one unambiguous pending request; with zero or + # multiple requests we refuse to guess and leave request_id absent. + if not fields.get("request_id") and len(run.pending_help) == 1: + fields["request_id"] = next(iter(run.pending_help)) # M2: drop an identical back-to-back resend (same target/action/text/url). # The UI has no client throttle, and an operator hammering the SAME hint at a # busy single-shot worker (run-0011: 11×) otherwise queues 11 items + 11 @@ -708,7 +2801,10 @@ async def post_hitl(self, run_id: str, target: str, action: str, **fields: Any) # text, or a different action) still goes through. sig = (target, action, str(fields.get("text") or fields.get("hint") or ""), str(fields.get("url") or fields.get("target_url") or ""), - str(fields.get("flag") or "")) + str(fields.get("flag") or ""), + str(fields.get("request_id") or ""), + bool(fields.get("standing", False)), + str(fields.get("preempt_policy") or fields.get("preemption") or "")) # `writeup` is an idempotent-looking no-arg command from the UI, but each # click is a real request to run a fresh post-solve standby turn. If we # dedupe it here, the second "生成复盘" click only echoes a duplicate @@ -716,56 +2812,90 @@ async def post_hitl(self, run_id: str, target: str, action: str, **fields: Any) if action != "writeup" and getattr(run, "_last_hitl_sig", None) == sig: await run.bus.emit(Event( event_type=EventType.HITL_RESPONSE, run_id=run_id, - payload=hitl_response_payload(target, action, delivery="duplicate", - **fields))) + payload=hitl_response_payload( + target, action, status="duplicate", text="[duplicate omitted]"))) return True - run._last_hitl_sig = sig - - live = run.task is not None and not run.task.done() - # M4: tell the operator WHERE the command went, so they stop re-sending a hint - # that already landed. A non-standing hint can't steer a live single-shot - # worker mid-turn (it's folded into the NEXT spawn), and a finished run routes - # to a cold-start standby — both look identical without this status. - if live: - delivery = "queued_for_next_worker" if action in ("hint", "focus") else "applied_live" - elif action in self._STANDBY_ACTIONS: - delivery = "standby" - else: - delivery = "no_live_workers" - - await run.hitl.put({"target": target, "action": action, **fields}) - await run.bus.emit( - Event( - event_type=EventType.HITL_RESPONSE, - run_id=run_id, - payload=hitl_response_payload(target, action, delivery=delivery, **fields), + result = await self.post_control( + run_id, {"target": target, "action": action, **fields}) + terminal_ok = False + terminal_observed = False + if result.get("command_id") and run.control_actor is not None: + await run.control_actor.join() + terminal = run.control_journal.latest_effect(result["command_id"]) + terminal_ok = bool( + terminal is not None + and terminal.state in { + EffectState.EFFECT_OBSERVED, + EffectState.PARTIAL, + } ) - ) - if not live and action in self._STANDBY_ACTIONS: - self._ensure_standby(run_id, {"target": target, "action": action, **fields}) - return True + terminal_observed = bool( + terminal is not None + and terminal.state is EffectState.EFFECT_OBSERVED) + if terminal_observed: + run._last_hitl_sig = sig + return terminal_ok async def post_worker_cmd(self, run_id: str, action: str, *, engine: Optional[str] = None, solver_id: Optional[str] = None) -> bool: - """Queue an operator worker command (spawn/kill) for the LIVE coordinator - to drain. Only meaningful while the run is running; a finished/ghost run - has no coordinator loop to act on it, so we reject it.""" + """Compile worker spawn/kill through the durable typed control plane. + + Only a terminal runtime ACK returns true. A finished/ghost run has no + coordinator capable of proving the effect and is rejected. + """ run = self.runs.get(run_id) if run is None: return False + if not self.is_protocol1_run(run_id, run=run): + return False live = run.task is not None and not run.task.done() if not live: return False - cmd: dict[str, Any] = {"action": action} - if engine: - cmd["engine"] = engine - if solver_id: - cmd["solver_id"] = solver_id - await run.worker_cmds.put(cmd) - return True + if action == "spawn": + result = await self.post_control(run_id, { + "action": "spawn_worker", + "target": "global", + "payload": {"engine": str(engine or "")}, + }) + elif action == "kill" and solver_id: + result = await self.post_control(run_id, { + "action": "cancel_worker", + "target": f"worker:{solver_id}", + "payload": {"worker_id": solver_id}, + }) + else: + return False + if run.control_actor is None: + return False + await run.control_actor.join() + receipt = run.control_journal.latest_effect( + str(result.get("command_id") or "")) + return bool(receipt is not None + and receipt.state is EffectState.EFFECT_OBSERVED) async def resolve(self, run_id: str, body: dict[str, Any] | None = None) -> bool: + """Fence and launch an explicit continuation generation.""" + async with self._lifecycle_lock: + run = self.runs.get(run_id) + if (run is None or self._shutting_down + or run_id in self._closing_runs + or run_id in self._launching_runs): + return False + if not self.is_protocol1_run(run_id, run=run): + return False + if run.task is not None and not run.task.done(): + return False + self._launching_runs.add(run_id) + try: + return await self._resolve_launching(run_id, run, body) + finally: + async with self._lifecycle_lock: + self._launching_runs.discard(run_id) + + async def _resolve_launching( + self, run_id: str, run: Run, body: dict[str, Any] | None = None, + ) -> bool: """"继续做题" — relaunch the FULL coordinator swarm on a finished run. Unlike a standby (one cold-started worker resuming the winner's session to @@ -777,11 +2907,27 @@ async def resolve(self, run_id: str, body: dict[str, Any] | None = None) -> bool The challenge is reconstructed from winner.json (the durable run snapshot), falling back to the run's rail metadata. Caller-supplied `body` fields win (e.g. an operator hint folded into the description, a new target).""" - run = self.runs.get(run_id) - if run is None: + if run.runtime_incomplete and not await self._settle_incomplete_runtime( + run, timeout=self._standby_cancel_timeout()): + LOG.error( + "refusing to resolve %s: main runtime owner is still unsettled", + run_id) return False if run.task is not None and not run.task.done(): return False # already live — nothing to relaunch (use HITL instead) + if self._standby_busy(run): + # A resumed winner and a fresh coordinator may share session/workspace/ + # container state. Resolve is allowed only after the real standby runtime + # (not merely its asyncio wrapper) crosses the exit fence. + if not await self._settle_standby_runtime( + run, timeout=self._standby_cancel_timeout()): + LOG.error( + "refusing to resolve %s: standby runtime exit is unconfirmed", + run_id) + return False + + if not await self._drain_control_before_launch(run_id, run): + return False # rebuild the challenge body from the durable winner.json snapshot. ch: dict[str, Any] = {} @@ -795,7 +2941,9 @@ async def resolve(self, run_id: str, body: dict[str, Any] | None = None) -> bool if not ch: try: async for ev in run.store.replay(run_id): - if ev.event_type is EventType.RUN_STARTED: + if ev.event_type in { + EventType.RUN_PREPARING, EventType.RUN_STARTED, + }: ch = (ev.payload or {}).get("challenge") or {} break except Exception: @@ -816,28 +2964,45 @@ async def resolve(self, run_id: str, body: dict[str, Any] | None = None) -> bool merged.setdefault("race_scout", False) merged.setdefault("cold_start", False) - # revive the closed bus so the relaunched swarm's events reach SSE, and - # reopen the run state (rail flips back to running). - self._fresh_bus(run) - run.finished = False - run.solved = False - run.paused = False - await run.bus.emit(Event( - event_type=EventType.RUN_REOPENED, run_id=run_id, - payload={"reason": "resolve"})) - from apps.web.drivers import build_driver - driver = build_driver(merged, mgr=self) + try: + driver = build_driver(merged, mgr=self) + except Exception: + LOG.exception("failed to build resolve driver for %s", run_id) + return False - async def _go() -> None: + # Destructive/visible commit: revalidate the exact Run and shutdown fence, + # then reopen control state, bus, and execution owner as one admission + # transaction. Holding the lifecycle lock across the replayable bus emit is + # acceptable; it is a short local sink operation and prevents shutdown from + # observing a half-reopened generation. + async with self._lifecycle_lock: + if (self._shutting_down or self.runs.get(run_id) is not run + or run_id in self._closing_runs + or (run.task is not None and not run.task.done())): + return False try: - await driver(run) - finally: - run.finished = True - await run.bus.close() - - run.task = asyncio.create_task(_go()) - return True + _actor, control_journal, _secrets = self._ensure_control(run) + state = control_journal.reopen_state(reason="operator resolve") + run.control_generation = state.generation + except Exception: + LOG.exception("failed to reopen control epoch for %s", run_id) + return False + self._fresh_bus(run) + self._retire_hitl_epoch(run, terminal=False) + self._retire_worker_command_epoch(run) + run.finished = False + run.solved = False + run.paused = False + await run.bus.emit(Event( + event_type=EventType.RUN_REOPENED, run_id=run_id, + payload={ + "reason": "resolve", + "execution_generation": run.execution_generation + 1, + "control_generation": run.control_generation, + })) + self._launch_generation(run, driver) + return True def _fresh_bus(self, run: Run) -> None: """Replace a run's CLOSED bus with a live one (same sinks) so a standby @@ -851,6 +3016,7 @@ def _fresh_bus(self, run: Run) -> None: if not getattr(run.bus, "_closed", False): return # still open (live run) — keep it new_bus = EventBus() + new_bus.add_filter(self._generation_filter_for(run)) new_bus.add_sink(run.store.sink) new_bus.add_sink(self._meta_sink_for(run)) # carry the seq forward so SSE Last-Event-ID continuity holds across runs @@ -858,15 +3024,28 @@ def _fresh_bus(self, run: Run) -> None: run.bus = new_bus run.cost.bus = new_bus # cost updates emit onto the live bus too - def _ensure_standby(self, run_id: str, cmd: dict[str, Any]) -> None: + def _ensure_standby(self, run_id: str, cmd: dict[str, Any]) -> bool: """Spin up a standby worker to serve `cmd`, unless one is already running (serialized — one standby per run). Fire-and-forget; events stream live.""" run = self.runs.get(run_id) if run is None: - return - if run.standby_task is not None and not run.standby_task.done(): - return # a standby is already serving this run — don't pile on - self._fresh_bus(run) + return False + if (self._shutting_down or run_id in self._closing_runs + or run_id in self._launching_runs): + return False + if self._standby_busy(run): + return False # a standby is already serving this run — don't pile on + # A prior driver clears these only after the runtime-exit fence. Clear stale + # registrations defensively before publishing the next worker instance. + run.standby_cancel = None + run.standby_runtime_exited = None + run.standby_wait_runtime_exit = None + cleanup_task = run.standby_runtime_cleanup_task + if cleanup_task is not None and not cleanup_task.done(): + cleanup_task.cancel() + run.standby_runtime_cleanup_task = None + if bool(getattr(run.bus, "_closed", False)): + self._fresh_bus(run) from apps.web.drivers import build_standby_driver driver = build_standby_driver(cmd, mgr=self) @@ -880,9 +3059,11 @@ async def _go() -> None: except asyncio.CancelledError: raise except Exception as exc: - detail = str(exc)[:500] - LOG.exception("standby worker failed for %s action=%s", - run_id, cmd.get("action")) + detail = _safe_exception_detail("standby worker failed", exc) + # Do not log the traceback here: exception messages from worker + # boundaries may contain materialised operator secrets. + LOG.error("standby worker failed for %s action=%s error_type=%s", + run_id, cmd.get("action"), type(exc).__name__) try: await run.bus.emit(Event( event_type=EventType.HITL_REQUEST, @@ -891,17 +3072,51 @@ async def _go() -> None: "target": cmd.get("target") or "global", "source": "standby", "action": cmd.get("action"), - "need": f"standby worker failed: {detail}", - "text": f"standby worker failed: {detail}", + "need": detail, + "text": detail, }, )) except Exception: pass finally: - # do NOT close the bus — keep the run reachable for more follow-ups. - run.standby_task = None + # Do not close the bus; retain the completed task as an observable + # receipt. `_ensure_standby` checks `.done()` and replaces it on the + # next command, so this does not block subsequent follow-ups. + cancel_boundary = run.standby_cancel + if callable(cancel_boundary): + try: + cancel_result = cancel_boundary() + if inspect.isawaitable(cancel_result): + await cancel_result + except Exception as exc: + # Runtime callbacks may embed materialised prompt/credential + # values in exception messages. Log only the local type. + LOG.error( + "standby final cancel boundary failed for %s " + "error_type=%s", + run_id, type(exc).__name__, + ) + delivery_ack = cmd.get("_standby_delivery_ack") + if isinstance(delivery_ack, asyncio.Future) and not delivery_ack.done(): + # The CLI process-start hook can run on a worker thread and + # publishes its positive ACK with call_soon_threadsafe(). If a + # very short-lived worker returns in the same tick, give that + # already-queued callback one chance to land before recording a + # negative pre-start outcome here. + await asyncio.sleep(0) + if isinstance(delivery_ack, asyncio.Future) and not delivery_ack.done(): + delivery_ack.set_result(False) + owner = str(cmd.get("_control_context_owner") or "") + reservations = [ + (str(context_id), str(reservation_id)) + for context_id, reservation_id in list( + cmd.get("_control_context_reservations") or []) + ] + self._ensure_standby_context_cleanup( + run, owner=owner, reservations=reservations) run.standby_task = asyncio.create_task(_go()) + return True def _meta_sink_for(self, run: Run): """The rail-metadata sink bound to a specific Run (used when rebuilding a @@ -910,41 +3125,39 @@ async def _meta_sink(ev: Event) -> None: self._seq += 1 run.updated_seq = self._seq run.updated_at = ev.ts - if ev.event_type is EventType.RUN_REOPENED: + if ev.event_type is EventType.HITL_REQUEST: + self._record_decision_request(run, ev) + if _apply_operator_meta(run, ev): + return + if ev.event_type in {EventType.RUN_PREPARING, EventType.RUN_STARTED}: + ch = ev.payload.get("challenge", {}) or {} + run.started = True + if ch.get("name"): + run.name = ch["name"] + run.category = ch.get("category", run.category) or run.category + if ch.get("expected_flags"): + run.expected_flags = int(ch["expected_flags"]) + if "multi_flag" in ch: + run.multi_flag = bool(ch["multi_flag"]) + elif ev.event_type is EventType.RUN_REOPENED: run.finished = False run.solved = False run.paused = False if ev.payload.get("reason") == "resolve": return - bad = ev.payload.get("flag") - if bad and run.flags: - run.flags = [f for f in run.flags if f != bad] - run.flag = run.flags[0] if run.flags else None - else: - run.flag = None - run.flags = [] - elif ev.event_type is EventType.HITL_REQUEST: - # a (standby) worker raised its hand — surface it on the summary, same - # as the inline _meta_sink in create(). - run.awaiting_help = True - run.help_text = str((ev.payload or {}).get("need") - or (ev.payload or {}).get("text") or "")[:300] - elif ev.event_type is EventType.HITL_RESPONSE: - # mirror the primary sink: reflect pause/resume AND lower the hand. - action = ev.payload.get("action") - if action == "pause": - run.paused = True - elif action == "resume": - run.paused = False - run.awaiting_help = False - run.help_text = "" + run.invalidate_flag(ev.payload.get("flag")) elif ev.event_type is EventType.RUN_FINISHED: run.finished = True run.paused = False run.awaiting_help = False run.help_text = "" - run.solved = bool(ev.payload.get("solved")) or run.solved - run.merge_flags(ev.payload.get("flags") or ev.payload.get("flag")) + run.pending_help.clear() + incoming_flags = ev.payload.get("flags") or ev.payload.get("flag") + had_flag_payload = bool(incoming_flags) + valid_incoming = run.valid_incoming_flags(incoming_flags) + run.merge_flags(incoming_flags) + if bool(ev.payload.get("solved")): + run.solved = bool(valid_incoming) if had_flag_payload else True if ev.payload.get("expected_flags"): run.expected_flags = int(ev.payload["expected_flags"]) if "multi_flag" in ev.payload: @@ -960,11 +3173,97 @@ async def shutdown(self) -> None: worker spun up to answer a post-solve follow-up kept running). The titler is a detached create_task with no stored handle, so it can't be cancelled here; it is short-lived and self-terminates.""" - pending: list[asyncio.Task] = [] + # Publish the admission fence before the first snapshot/await. It remains + # latched even when bounded cleanup reports incomplete; callers may retry + # shutdown, but no new main or standby generation can race into the gap. + async with self._lifecycle_lock: + self._shutting_down = True + pending: dict[asyncio.Task, str] = {} + live_standbys = [ + run for run in list(self.runs.values()) + if self._standby_busy(run) + ] + main_unsettled: set[str] = set() + standby_unsettled: set[str] = set() + task_unsettled: set[str] = set() + live_main_owners = [ + run for run in list(self.runs.values()) if run.runtime_incomplete + ] + if live_main_owners: + main_results = await asyncio.gather(*( + self._settle_incomplete_runtime( + run, timeout=self._standby_cancel_timeout()) + for run in live_main_owners + ), return_exceptions=True) + main_unsettled.update( + run.run_id for run, result in zip( + live_main_owners, main_results) + if result is not True + ) + if live_standbys: + results = await asyncio.gather(*( + self._settle_standby_runtime( + run, timeout=self._standby_cancel_timeout()) + for run in live_standbys + ), return_exceptions=True) + standby_unsettled.update({ + run.run_id for run, result in zip(live_standbys, results) + if result is not True + }) for run in list(self.runs.values()): - for t in (run.task, run.standby_task): + if run.run_id in main_unsettled or run.run_id in standby_unsettled: + # The bounded runtime settler/reaper already owns cancellation. + # A second raw cancel+gather can hang forever when the wrapper + # suppresses CancelledError and would discard the retained owner. + continue + for t in (run.task, run.standby_task, run.title_task): if t is not None and not t.done(): t.cancel() - pending.append(t) + pending[t] = run.run_id if pending: - await asyncio.gather(*pending, return_exceptions=True) + done, still_live = await asyncio.wait( + tuple(pending), timeout=self._standby_cancel_timeout()) + if done: + await asyncio.gather(*done, return_exceptions=True) + task_unsettled.update(pending[task] for task in still_live) + # A driver may only learn that its subprocess/container survived while its + # cancelled wrapper is unwinding. Such ownership transfer happens after + # the pre-cancel snapshot above, so settle/rescan before closing control + # state. Discard a prior timeout when the autonomous reaper has since + # proved exit; preserve every still-unsettled owner. + post_cancel_main = [ + run for run in list(self.runs.values()) + if run.runtime_incomplete or run.run_id in main_unsettled + ] + if post_cancel_main: + post_results = await asyncio.gather(*( + self._settle_incomplete_runtime( + run, timeout=self._standby_cancel_timeout()) + for run in post_cancel_main + ), return_exceptions=True) + for run, result in zip(post_cancel_main, post_results): + if result is True: + main_unsettled.discard(run.run_id) + else: + main_unsettled.add(run.run_id) + unsettled = main_unsettled | standby_unsettled | task_unsettled + for run in list(self.runs.values()): + if run.run_id in unsettled: + # Keep its journal/actor/cleanup callbacks owned and retryable. The + # method will fail loudly below instead of pretending shutdown was + # clean while its kill boundary remains live. + continue + if run.control_actor is not None: + try: + await run.control_actor.close() + except Exception: + LOG.exception("failed to close control actor for %s", run.run_id) + if run.control_journal is not None: + try: + run.control_journal.close() + except Exception: + LOG.exception("failed to close control journal for %s", run.run_id) + if unsettled: + joined = ", ".join(sorted(unsettled)) + raise RuntimeError( + f"runtime owner exit unconfirmed; shutdown incomplete for: {joined}") diff --git a/apps/web/run_meta.py b/apps/web/run_meta.py index a28b9be..bb98305 100644 --- a/apps/web/run_meta.py +++ b/apps/web/run_meta.py @@ -58,6 +58,9 @@ def get(self, run_id: str) -> dict[str, Any]: def all(self) -> dict[str, dict[str, Any]]: return {rid: self.get(rid) for rid in self._data} + def contains(self, run_id: str) -> bool: + return run_id in self._data + def _mutate(self, run_id: str, **changes: Any) -> dict[str, Any]: m = dict(self._data.get(run_id, {})) m.update(changes) diff --git a/apps/web/server.py b/apps/web/server.py index 9707cc0..6ba21a4 100644 --- a/apps/web/server.py +++ b/apps/web/server.py @@ -7,7 +7,8 @@ GET /api/runs/{run_id}/events SSE: the typed event stream (Last-Event-ID resume via the standard header) WS /api/runs/{run_id}/terminal sandbox terminal: TERMINAL_OUTPUT bytes - POST /api/runs/{run_id}/hitl human command into the run (hint/pause/etc.) + POST /api/runs/{run_id}/control durable operator command admission + POST /api/runs/{run_id}/hitl legacy adapter onto /control GET / the single-page UI (static) The server holds NO solving logic — it only brokers the event bus + HITL. Event @@ -17,6 +18,7 @@ from __future__ import annotations import asyncio +import copy import json import os import re @@ -37,8 +39,10 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from pydantic import ValidationError as PydanticValidationError from sse_starlette.sse import EventSourceResponse, ServerSentEvent +from apps.web.control_adapter import ControlPayloadError from apps.web.auth import ( PUBLIC_API_PATHS, AuthConfig, @@ -49,14 +53,19 @@ verify_token, ) from apps.web.run_manager import Run, RunManager +from apps.web.platform_update import PlatformUpdateController +from muteki.control import IdempotencyConflict, StateConflict from muteki.core.dotenv_boot import load_env from muteki.core.events import Event, EventType +from muteki.runtime.release_receipts import load_verified_release_receipts +from muteki.version import get_version from muteki.solver.credential_accounts import ( CredentialAccountStore, account_store_root, ) load_env() # local convenience: pick up repo-root .env (shell env still wins) +load_verified_release_receipts(root=Path(__file__).resolve().parents[2]) UI_DIR = Path(__file__).parent / "ui" @@ -149,6 +158,24 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Project Muteki — Command Deck", lifespan=lifespan) app.state.manager = mgr + app.state.platform_updates = PlatformUpdateController() + + @app.get("/api/health") + async def health() -> Any: + return {"status": "ok", "version": get_version()} + + def llm_settings_payload(config: dict[str, Any]) -> dict[str, Any]: + """Expose credential presence/source without returning any secret value.""" + from apps.web.llm_credentials import LlmCredentialStore + + payload = copy.deepcopy(config) + store = LlmCredentialStore(app.state.manager.sessions_root) + profiles = payload.get("llm_profiles") or {} + for which in ("planner", "titler"): + row = profiles.get(which) + if isinstance(row, dict): + row["credential_source"] = store.source(which) + return payload # Auth (P3): a single-password gate in front of /api. fail_fast_check refuses # to start if bound to a non-loopback host with no password — see auth.py and @@ -309,13 +336,70 @@ async def delete_folder(folder_id: str) -> Any: async def delete_run(run_id: str) -> Any: # Hard-delete: cancels the task, drops the in-memory handle, the JSONL # log, and the meta row. Irreversible — the UI confirms before calling. + if app.state.manager.is_protocol2_run(run_id): + raise HTTPException( + status_code=409, detail="PROTOCOL2_PURGE_UNAVAILABLE") ok = await app.state.manager.delete(run_id) return {"ok": ok} + @app.post("/api/runs/{run_id}/archive") + async def archive_protocol2_run(run_id: str) -> Any: + if not app.state.manager.is_protocol2_run(run_id): + raise HTTPException(status_code=404, detail="unknown Protocol 2 run") + try: + status = await app.state.manager.archive_protocol2(run_id) + except (StateConflict, RuntimeError) as exc: + raise HTTPException( + status_code=409, detail=type(exc).__name__ + ) from exc + return {"operation_id": status["operation_id"], + "run_id": status["run_id"], "state": status["state"].upper(), + "archive_receipt_digest": status["archive_receipt_digest"]} + + @app.get("/api/archive-operations/{operation_id}") + async def archive_operation_status(operation_id: str) -> Any: + adapter = app.state.manager.protocol2 + if adapter is None: + raise HTTPException(status_code=503, detail="Protocol 2 unavailable") + try: + status = adapter.archive_status(operation_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="unknown archive operation") from exc + return {**status, "state": status["state"].upper()} + + @app.post("/api/runs/{run_id}/purge") + async def purge_protocol2_run(run_id: str) -> Any: + if not app.state.manager.is_protocol2_run(run_id): + raise HTTPException(status_code=404, detail="unknown Protocol 2 run") + try: + status = await app.state.manager.purge_protocol2(run_id) + except (StateConflict, RuntimeError) as exc: + raise HTTPException( + status_code=409, detail=type(exc).__name__ + ) from exc + return {"operation_id": status["operation_id"], + "run_id": status["run_id"], "state": status["state"].upper(), + "plan_receipt_digest": status["plan_receipt_digest"], + "absence_receipt_digest": status["absence_receipt_digest"]} + + @app.get("/api/purge-operations/{operation_id}") + async def purge_operation_status(operation_id: str) -> Any: + adapter = app.state.manager.protocol2 + if adapter is None: + raise HTTPException(status_code=503, detail="Protocol 2 unavailable") + try: + status = adapter.purge_status(operation_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="unknown purge operation") from exc + return {**status, "state": status["state"].upper()} + @app.post("/api/runs/{run_id}/open") async def open_run_workspace(run_id: str) -> Any: # Reveal the run's workspace dir in the host file manager. Only meaningful # when the operator runs the backend locally; a no-op (ok:false) otherwise. + if app.state.manager.is_protocol2_run(run_id): + raise HTTPException( + status_code=409, detail="PROTOCOL2_WORKSPACE_REVEAL_UNAVAILABLE") ok = app.state.manager.open_workspace(run_id) return {"ok": ok} @@ -326,6 +410,11 @@ async def run_credentials(run_id: str) -> Any: mgr: RunManager = app.state.manager run = mgr.get(run_id) + if run is None: + raise HTTPException(status_code=404, detail="unknown run") + if not mgr.is_protocol1_run(run_id, run=run): + raise HTTPException( + status_code=409, detail="PROTOCOL2_CREDENTIALS_UNAVAILABLE") graph_db = mgr.workspace_dir(run_id) / "graph" / "shared_graph.db" if not graph_db.exists(): return {"credentials": []} @@ -350,10 +439,7 @@ async def run_credentials(run_id: str) -> Any: # superseding /btw request. The process exits after the turn. @app.post("/api/runs/{run_id}/btw") async def btw(run_id: str, request: Request) -> Any: - from apps.web.drivers import ( - _runtime_for_profile, - _standby_profile_for, - ) + from apps.web.drivers import _standby_profile_for, _standby_worker_env from apps.web.worker_config import backend_for_profile, resolve_worker_backend from muteki.core.runtime_env import is_web_container from muteki.solver.btw import ( @@ -365,10 +451,7 @@ async def btw(run_id: str, request: Request) -> Any: stream_btw_worker_deltas, ) from muteki.solver.cli_driver import driver_for - from muteki.solver.credential_accounts import ( - account_store_root, - runtime_env_for_engine, - ) + from muteki.solver.credential_accounts import account_store_root from muteki.solver.worker_profiles import base_engine_for_profile body = await _require_dict_body(request) @@ -383,10 +466,13 @@ async def btw(run_id: str, request: Request) -> Any: if run is None: # Unknown run → 404. Do NOT create a workspace for it. return JSONResponse({"error": "unknown run"}, status_code=404) + if not mgr.is_protocol1_run(run_id, run=run): + return JSONResponse( + {"error": "PROTOCOL2_BTW_UNAVAILABLE"}, status_code=409) # The worker needs a cwd, so /btw creates only a per-turn scratch dir under # the run workspace. It never opens the graph read-write or joins the swarm. - safe = run_id.replace("/", "_").replace("..", "_") + safe = mgr._safe_run_id(run_id) root = mgr.workspace_dir(run_id).resolve() graph_db = root / "graph" / "shared_graph.db" jsonl_path = (mgr.sessions_root / f"{safe}.jsonl").resolve() @@ -420,7 +506,7 @@ async def btw(run_id: str, request: Request) -> Any: winner = {} wc = mgr.worker_config.resolve(challenge_category) worker_profiles = wc.get("worker_profiles") or [] - runtime_profiles = wc.get("runtime_profiles") or [] + worker_network = str(wc.get("worker_network") or "bridge") def _pick_profile() -> tuple[dict[str, Any] | None, str]: requested = str( @@ -446,7 +532,10 @@ def _pick_profile() -> tuple[dict[str, Any] | None, str]: if profile is not None: return profile, cand base = base_engine_for_profile(cand) - if base in ("claude", "codex", "cursor"): + if base in ( + "claude", "codex", "cursor", "pi", "omp", "kimi", "grok", + "opencode", "dsh", + ): return None, base return None, "claude" @@ -465,15 +554,16 @@ async def stream(): ) backend = ( backend_for_profile( - profile, - runtime_profiles=runtime_profiles, worker_backend=worker_backend, in_web_container=is_web_container(), ) if profile else worker_backend ) - runtime = _runtime_for_profile(profile, runtime_profiles) container = None + # A BTW turn on a finished run cold-starts a run container that has no + # swarm owner to tear it down. Remember that ownership here; live runs + # keep sharing their existing container with the active swarm. + owns_finished_run_container = bool(run.finished) account_root = account_store_root(mgr.sessions_root) worker_root = root / "workers" / "_btw" worker_root.mkdir(parents=True, exist_ok=True) @@ -490,10 +580,7 @@ async def stream(): ensure_container, run_id, str(root), - network=str(runtime.get("network") or "bridge"), - memory=str(runtime.get("memory") or "") or None, - cpus=str(runtime.get("cpus") or "") or None, - pids_limit=int(runtime.get("pids_limit") or 0) or None, + network=worker_network, account_root=str(account_root), ) await asyncio.to_thread(_chown_tree_to_worker, str(workdir)) @@ -523,30 +610,16 @@ def _worker_path(p: Path) -> str: context_hint=context_hint, transcript=transcript, ) - worker_env = runtime_env_for_engine( - transport, + worker_env = _standby_worker_env( + root=root, + label=f"btw-{transport}", + engine=transport, + profile=profile, account_root=account_root, - account_id=(profile.get("credential_account") if profile else None), - container=container is not None, - ).env + container=container, + ) worker_env["MUTEKI_BTW_WORKER"] = "1" worker_env["MUTEKI_BLACKBOARD_DB"] = "" - if profile: - worker_env["MUTEKI_WORKER_PROFILE_ID"] = str(profile.get("id") or "") - worker_env["MUTEKI_CREDENTIAL_ACCOUNT_ID"] = str( - profile.get("credential_account") or "" - ) - if profile.get("model"): - worker_env["MUTEKI_WORKER_MODEL"] = str(profile["model"]) - if container is not None: - home_host = root / "homes" / f"btw-{transport}" - home_host.mkdir(parents=True, exist_ok=True) - from muteki.solver.container_exec import _chown_tree_to_worker - await asyncio.to_thread(_chown_tree_to_worker, str(home_host)) - mapper = getattr(container, "to_container_path", None) - worker_env["HOME"] = ( - mapper(str(home_host)) if callable(mapper) else str(home_host) - ) async for chunk in stream_btw_worker_deltas( driver=driver_for(profile or transport), prompt=prompt, @@ -566,6 +639,22 @@ def _worker_path(p: Path) -> str: except Exception as e: # noqa: BLE001 yield {"data": json.dumps({"error": str(e)[:300]}, ensure_ascii=False)} finally: + if (container is not None and owns_finished_run_container + and run.finished): + from muteki.solver.container_exec import teardown_container + try: + removed = await asyncio.to_thread( + teardown_container, run_id, remove=True) + if removed is not True: + import logging + logging.getLogger(__name__).warning( + "BTW container teardown could not be proven for %s", + run_id, + ) + except Exception: + import logging + logging.getLogger(__name__).exception( + "BTW container teardown failed for %s", run_id) this_task = asyncio.current_task() if this_task is not None: limiter.release(run_id, this_task) @@ -581,6 +670,13 @@ def _worker_path(p: Path) -> str: _engine_cache_ttl_s = 300.0 _engine_refresh_lock = asyncio.Lock() + def _invalidate_engine_cache() -> None: + # The header polls this cache slowly. A user who changes the enabled + # Worker roster must see the saved engine on the very next run instead of + # the previous roster for up to five minutes. + _engine_cache["ts"] = 0.0 + _engine_cache["data"] = None + @app.get("/api/engines") async def engines() -> Any: from muteki.solver.cli_driver import engine_status @@ -642,17 +738,59 @@ async def engines_health(request: Request) -> Any: async def get_worker_settings() -> Any: # the default worker roster (engines + bootstrap count + per-category # overrides) the dispatch path falls back to when a request is silent. - return {"config": app.state.manager.worker_config.get()} + return {"config": llm_settings_payload(app.state.manager.worker_config.get())} + + @app.get("/api/settings/system-update") + async def get_system_update() -> Any: + return {"update": app.state.platform_updates.status()} + + @app.post("/api/settings/system-update/check") + async def check_system_update(request: Request) -> Any: + body = await _require_dict_body(request, allow_empty=True) + target = str(body.get("target") or "").strip() or None + try: + update = await app.state.platform_updates.check(target) + except Exception: + update = app.state.platform_updates.status() + return {"update": update} + + @app.post("/api/settings/system-update/install") + async def install_system_update(request: Request) -> Any: + body = await _require_dict_body(request, allow_empty=True) + target = str(body.get("target") or "").strip() or None + try: + update = await app.state.platform_updates.start(target, force=bool(body.get("force", False))) + except Exception as exc: + raise HTTPException(status_code=409, detail=str(exc)) + return {"update": update} + + @app.post("/api/settings/system-update/rollback") + async def rollback_system_update() -> Any: + try: + update = await app.state.platform_updates.rollback() + except Exception as exc: + raise HTTPException(status_code=409, detail=str(exc)) + return {"update": update} @app.put("/api/settings/workers") async def put_worker_settings(request: Request) -> Any: body = await _require_dict_body(request) + raw_llm_profiles = body.get("llm_profiles") + llm_profiles = copy.deepcopy(raw_llm_profiles) + if isinstance(llm_profiles, dict): + for which in ("planner", "titler"): + row = llm_profiles.get(which) + if isinstance(row, dict): + row.pop("api_key", None) + row.pop("clear_api_key", None) + row.pop("credential_source", None) try: cfg = app.state.manager.worker_config.set( engines=body.get("engines"), start_workers=body.get("start_workers"), max_workers=body.get("max_workers"), worker_backend=body.get("worker_backend"), + worker_network=body.get("worker_network"), race_scout=body.get("race_scout"), race_timeout=body.get("race_timeout"), wall_clock_budget=body.get("wall_clock_budget"), @@ -660,31 +798,45 @@ async def put_worker_settings(request: Request) -> Any: max_total_workers=body.get("max_total_workers"), cost_budget_usd=body.get("cost_budget_usd"), stage_policy=body.get("stage_policy"), - llm_profiles=body.get("llm_profiles"), - runtime_profiles=body.get("runtime_profiles"), + llm_profiles=llm_profiles, worker_profiles=body.get("worker_profiles"), overrides=body.get("overrides"), ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return {"ok": True, "config": cfg} + if isinstance(raw_llm_profiles, dict): + from apps.web.llm_credentials import LlmCredentialStore + + store = LlmCredentialStore(app.state.manager.sessions_root) + try: + for which in ("planner", "titler"): + row = raw_llm_profiles.get(which) + if not isinstance(row, dict): + continue + if bool(row.get("clear_api_key")): + store.clear(which) + elif isinstance(row.get("api_key"), str) and row["api_key"].strip(): + store.save(which, row["api_key"]) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + _invalidate_engine_cache() + return {"ok": True, "config": llm_settings_payload(cfg)} @app.put("/api/settings/identity") async def put_identity_model(request: Request) -> Any: - # Save the NEW Credential/Seat/Environment model. Additive to the legacy + # Save the Credential/Seat model. Additive to the legacy # PUT /workers above (which still accepts worker_profiles/engines). The # store validates the container×system_inherit legality gate and rejects an - # illegal combo with 400. GET the model back via GET /workers (config.seats - # / config.credentials / config.environments are attached there). + # illegal combo with 400. GET the model back via GET /workers. body = await _require_dict_body(request) try: cfg = app.state.manager.worker_config.set_identity_model( seats=body.get("seats"), credentials=body.get("credentials"), - environments=body.get("environments"), ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) + _invalidate_engine_cache() return {"ok": True, "config": cfg} @app.get("/api/settings/profiles/health") @@ -693,8 +845,8 @@ async def get_profiles_health() -> Any: # CHEAP binding layer (zero network / zero docker) so opening the modal # never fires a wall of CLI hellos — the deep auth probe is the explicit # "测连通" button (POST below). Backend is resolved from SERVER context - # via backend_for_profile (the same per-profile runtime→backend mapping - # dispatch uses), NEVER trusted from the client, so the verdict predicts + # via backend_for_profile (the same global backend mapping dispatch uses), + # NEVER trusted from the client, so the verdict predicts # what a real run would use. from dataclasses import asdict @@ -704,7 +856,6 @@ async def get_profiles_health() -> Any: cfg = app.state.manager.worker_config.get() profiles = [p for p in (cfg.get("worker_profiles") or []) if isinstance(p, dict)] - runtime_profiles = cfg.get("runtime_profiles") or [] worker_backend = str(cfg.get("worker_backend") or "") in_web = is_web_container() sessions_root = app.state.manager.sessions_root @@ -713,7 +864,6 @@ def _eval_all() -> list[dict]: out: list[dict] = [] for p in profiles: backend = backend_for_profile( - p, runtime_profiles=runtime_profiles, worker_backend=worker_backend, in_web_container=in_web, ) h = evaluate_profile_health( @@ -758,7 +908,6 @@ async def test_profile_health(profile_id: str) -> Any: if match is None: raise HTTPException(status_code=404, detail=f"unknown profile: {profile_id}") backend = backend_for_profile( - match, runtime_profiles=cfg.get("runtime_profiles") or [], worker_backend=str(cfg.get("worker_backend") or ""), in_web_container=is_web_container(), ) @@ -773,22 +922,101 @@ async def test_profile_health(profile_id: str) -> Any: async def get_worker_models() -> Any: from apps.web.worker_models import worker_model_options_payload - return worker_model_options_payload() + return worker_model_options_payload(app.state.manager.sessions_root) + + @app.post("/api/settings/worker-models/discover") + async def discover_worker_models_now(request: Request) -> Any: + from muteki.core.runtime_env import is_web_container + from apps.web.worker_config import backend_for_profile + from apps.web.worker_models import ( + WorkerModelDiscoveryStore, + discover_worker_models, + worker_model_options_payload, + ) + + cfg = app.state.manager.worker_config.get() + profiles = [ + profile + for profile in (cfg.get("worker_profiles") or []) + if isinstance(profile, dict) + ] + body = await _require_dict_body(request, allow_empty=True) + profile_id = str(body.get("profile_id") or "").strip() + if profile_id: + profiles = [ + profile for profile in profiles + if profile_id in { + str(profile.get("id") or "").strip(), + str(profile.get("name") or "").strip(), + } + ] + results: list[dict[str, Any]] = [] + for profile in profiles: + backend = backend_for_profile( + worker_backend=str(cfg.get("worker_backend") or ""), + in_web_container=is_web_container(), + ) + results.append( + await asyncio.to_thread( + discover_worker_models, + profile=profile, + sessions_root=app.state.manager.sessions_root, + backend=backend, + ) + ) + + WorkerModelDiscoveryStore(app.state.manager.sessions_root).save_results(results) + payload = worker_model_options_payload(app.state.manager.sessions_root) + payload["discovery_results"] = results + payload["discovery_ok"] = any(bool(result.get("ok")) for result in results) + return payload @app.post("/api/settings/worker-model/test") async def test_worker_model(request: Request) -> Any: body = await _require_dict_body(request) + from apps.web.worker_config import backend_for_profile from apps.web.worker_models import probe_worker_model + from muteki.core.runtime_env import is_web_container profile = body.get("profile") if not isinstance(profile, dict): raise HTTPException(status_code=400, detail="profile must be an object") + cfg = app.state.manager.worker_config.get() + backend = backend_for_profile( + worker_backend=str(cfg.get("worker_backend") or ""), + in_web_container=is_web_container(), + ) return await asyncio.to_thread( probe_worker_model, profile=profile, model=str(body.get("model") or ""), + reasoning_effort=str(body.get("reasoning_effort") or "default"), + sessions_root=app.state.manager.sessions_root, + backend=backend, + runtime={"network": str(cfg.get("worker_network") or "bridge")}, + ) + + @app.post("/api/settings/worker-model/test-batch") + async def test_worker_models_batch(request: Request) -> Any: + body = await _require_dict_body(request) + from apps.web.worker_config import backend_for_profile + from apps.web.worker_models import probe_worker_models_batch + from muteki.core.runtime_env import is_web_container + + items = body.get("items") + if not isinstance(items, list): + raise HTTPException(status_code=400, detail="items must be an array") + cfg = app.state.manager.worker_config.get() + backend = backend_for_profile( + worker_backend=str(cfg.get("worker_backend") or ""), + in_web_container=is_web_container(), + ) + return await asyncio.to_thread( + probe_worker_models_batch, + items=[item for item in items if isinstance(item, dict)], sessions_root=app.state.manager.sessions_root, - backend=str(body.get("backend") or "local"), + backend=backend, + runtime={"network": str(cfg.get("worker_network") or "bridge")}, ) @app.get("/api/settings/worker-image") @@ -814,19 +1042,46 @@ async def list_credential_accounts() -> Any: async def put_credential_account(account_id: str, request: Request) -> Any: body = await _require_dict_body(request) store = CredentialAccountStore(account_store_root(app.state.manager.sessions_root)) + requested_engine = str( + body.get("worker_engine") or body.get("target_engine") or body.get("engine") or "" + ).strip().lower() + connection = str(body.get("connection") or "").strip().lower() + if not connection: + legacy_engine = str(body.get("engine") or "").strip().lower() + legacy_base_url = str(body.get("base_url") or "").strip() + connection = ( + "custom_endpoint" + if legacy_engine == "api" or legacy_base_url + else "official" + ) + if connection not in {"official", "custom_endpoint"}: + raise HTTPException(status_code=400, detail="connection must be official or custom_endpoint") + if requested_engine not in { + "claude", "codex", "cursor", "pi", "omp", "kimi", "grok", + "opencode", "dsh" + }: + raise HTTPException( + status_code=400, + detail="worker_engine must be claude, codex, cursor, pi, omp, kimi, " + "grok, opencode, or dsh", + ) + base_url = str(body.get("base_url") or "").strip() + if connection == "custom_endpoint" and not base_url: + raise HTTPException(status_code=400, detail="自定义端点必须填写 Base URL") + storage_engine = "api" if connection == "custom_endpoint" else requested_engine try: account = store.upsert_secret( account_id=account_id, - engine=str(body.get("engine") or ""), + engine=storage_engine, secret=(body.get("secret") if body.get("secret") is not None else None), codex_auth_json=( body.get("codex_auth_json") if body.get("codex_auth_json") is not None else None ), - base_url=(body.get("base_url") if body.get("base_url") is not None else None), - target_engine=( - body.get("target_engine") if body.get("target_engine") is not None else None - ), + base_url=base_url, + target_engine=requested_engine if connection == "custom_endpoint" else None, + provider=(body.get("provider") if body.get("provider") is not None else None), + clear_base_url=connection == "official", ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) @@ -834,6 +1089,22 @@ async def put_credential_account(account_id: str, request: Request) -> Any: @app.delete("/api/settings/credential-accounts/{account_id}") async def delete_credential_account(account_id: str) -> Any: + cfg = app.state.manager.worker_config.get() + credential_ids = { + str(item.get("id")) + for item in (cfg.get("credentials") or []) + if isinstance(item, dict) and str(item.get("secret_ref") or "") == account_id + } + used_by = [ + str(item.get("label") or item.get("id") or "Worker") + for item in (cfg.get("seats") or []) + if isinstance(item, dict) and str(item.get("credential_id") or "") in credential_ids + ] + if used_by: + raise HTTPException( + status_code=409, + detail=f"账号仍被 {len(used_by)} 个 Worker 使用:{'、'.join(used_by[:4])}", + ) store = CredentialAccountStore(account_store_root(app.state.manager.sessions_root)) return {"ok": store.delete(account_id)} @@ -859,6 +1130,31 @@ async def import_host_codex(account_id: str) -> Any: raise HTTPException(status_code=400, detail=str(exc)) return {"ok": True, "account": account} + @app.post("/api/settings/credential-accounts/{account_id}/import-host-login") + async def import_host_login(account_id: str, request: Request) -> Any: + """Copy a minimal Claude, Kimi Code, or Grok host login into an account. + + Container workers receive the account projection, so they can authenticate + without mounting the operator's complete home directory. + """ + from muteki.core.runtime_env import is_web_container + + if is_web_container(): + raise HTTPException( + status_code=409, + detail="import-from-host is unavailable when the web control plane runs in a container", + ) + body = await _require_dict_body(request) + engine = str(body.get("engine") or "").strip().lower() + if engine not in {"claude", "kimi", "grok"}: + raise HTTPException(status_code=400, detail="engine must be claude, kimi, or grok") + store = CredentialAccountStore(account_store_root(app.state.manager.sessions_root)) + try: + account = await asyncio.to_thread(store.import_host_login, account_id, engine) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"ok": True, "account": account} + @app.post("/api/settings/credential-accounts/{account_id}/test") async def test_credential_account(account_id: str, request: Request) -> Any: # Test the REGISTERED account (DESIGN §2.4 補強C-2). local → host probe with @@ -887,38 +1183,39 @@ async def get_system_login() -> Any: from muteki.solver.credential_accounts import detect_system_login logins = await asyncio.to_thread( - lambda: {e: detect_system_login(e) for e in ("claude", "codex", "cursor")} + lambda: { + e: detect_system_login(e) + for e in ( + "claude", "codex", "cursor", "pi", "omp", "kimi", "grok", + "opencode", "dsh", + ) + } ) return {"logins": logins} @app.post("/api/settings/llm/test") async def test_llm_endpoint_route(request: Request) -> Any: - # Test the planner/titler endpoint the operator is EDITING (DESIGN §2.4 - # 補強C-1): base_url + model from the request body, key from .env. ok by - # API success, not content non-empty (reasoning models). + # Test the planner/titler endpoint the operator is editing. A freshly + # entered key takes precedence over the saved profile key and env fallback. body = await _require_dict_body(request) + from apps.web.llm_credentials import LlmCredentialStore from apps.web.llm_test import test_llm_endpoint + which = str(body.get("which") or "planner") + entered_key = str(body.get("api_key") or "").strip() + try: + api_key = entered_key or LlmCredentialStore(app.state.manager.sessions_root).resolve(which) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) return await test_llm_endpoint( - which=str(body.get("which") or "planner"), + which=which, base_url=(body.get("base_url") if body.get("base_url") is not None else None), model=(body.get("model") if body.get("model") is not None else None), + api_key=api_key, + temperature_mode=body.get("temperature_mode"), + temperature=body.get("temperature"), ) - @app.put("/api/settings/runtime-environment") - async def put_runtime_environment(request: Request) -> Any: - # Unify backend + runtime across all enabled profiles (DESIGN §5) so the - # displayed run environment is what actually runs. - body = await _require_dict_body(request) - try: - cfg = app.state.manager.worker_config.set_runtime_environment( - backend=str(body.get("backend") or ""), - runtime_id=str(body.get("runtime_id") or ""), - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - return {"ok": True, "config": cfg} - @app.post("/api/runs") async def new_run(request: Request) -> Any: # Mint a fresh run id for a new conversation ("+ New solve"). The deck @@ -926,32 +1223,54 @@ async def new_run(request: Request) -> Any: run = app.state.manager.create_new() return {"run_id": run.run_id} + @app.get("/api/protocol2/status") + async def protocol2_status() -> Any: + adapter = app.state.manager.protocol2 + if adapter is None: + return { + "protocol_version": 2, + "available": False, + "production_enabled": False, + "reason": app.state.manager.protocol2_error or "unavailable", + } + return adapter.status() + + @app.get("/api/protocol2/runs/{run_id}/status") + async def protocol2_run_status(run_id: str) -> Any: + adapter = app.state.manager.protocol2 + if adapter is None: + raise HTTPException(status_code=503, detail="Protocol 2 unavailable") + try: + return adapter.canonical_run_status(run_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="unknown Protocol 2 run") from exc + @app.post("/api/runs/{run_id}/start") async def start_run(run_id: str, request: Request) -> Any: body = await _require_dict_body(request) from apps.web.drivers import build_driver driver = build_driver(body, mgr=app.state.manager) - # seed rail metadata up front so the row appears the instant we dispatch - # (before run.started lands) — conversational dispatch infers the rest. - run = app.state.manager.get(run_id) or app.state.manager.create(run_id) + from muteki.core.path_ids import RunIdPathError + try: + run = app.state.manager.get(run_id) or app.state.manager.create(run_id) + except RunIdPathError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + try: + run = await app.state.manager.start(run_id, driver) + except RunIdPathError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except StateConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + # Mutate rail metadata only after lifecycle admission succeeds. A duplicate + # live start must be a pure 409 and may not erase the active generation's + # solved/flag/name projection before the manager rejects it. ch = (body.get("challenge") or {}) if ch.get("name"): run.name = ch["name"] if ch.get("category"): run.category = ch["category"] - # Re-starting an existing run_id (e.g. a re-test redo of the same challenge): - # the run object still carries the PRIOR run's terminal state (finished/solved/ - # flag). Reset it synchronously here so the rail doesn't show a freshly- - # dispatched run as "已解出" until the new run.started bus event is sinked - # (it would otherwise display the stale solved flag the whole time it runs). - run.finished = False - run.solved = False - run.flag = None - run.flags = [] - run.paused = False - run.started = True - await app.state.manager.start(run_id, driver) # ChatGPT-style auto-title: if the operator gave no explicit name, kick off # a background summarizer that names the conversation from the prompt and @@ -965,13 +1284,86 @@ async def start_run(run_id: str, request: Request) -> Any: titler_profile = llm_profiles.get("titler") or {} title_model = titler_profile.get("model") title_base_url = titler_profile.get("base_url") or None - asyncio.create_task( - generate_title(prompt, bus=run.bus, run_id=run_id, - model=title_model, base_url=title_base_url) - ) + from apps.web.llm_credentials import LlmCredentialStore + title_api_key = LlmCredentialStore( + app.state.manager.sessions_root).resolve("titler") + title_generation = run.execution_generation + title_bus = run.bus + + async def _generate_owned_title() -> None: + current = asyncio.current_task() + try: + title = await generate_title( + prompt, bus=None, run_id=None, + model=title_model, base_url=title_base_url, + api_key=title_api_key, + temperature_mode=titler_profile.get("temperature_mode"), + temperature=titler_profile.get("temperature"), + ) + if (run.execution_generation == title_generation + and run.bus is title_bus and not run.finished + and title): + await title_bus.emit(Event( + event_type=EventType.RUN_TITLED, + run_id=run_id, + payload={ + "title": title, + "execution_generation": title_generation, + }, + )) + finally: + if run.title_task is current: + run.title_task = None + + run.title_task = asyncio.create_task(_generate_owned_title()) return {"run_id": run_id, "started": True, "kind": body.get("kind", "swarm")} + @app.post("/api/dispatch/parse") + async def dispatch_parse_preflight(request: Request) -> Any: + """Preflight LLM parse of a dispatch prompt, so the deck can warn BEFORE + launch when neither the count field nor the prompt carries a collect + quota (open-ended collect may run until the operator stops it). + Never raises: ``parsed`` is {} when the planner LLM is unavailable or + cannot decide — the caller falls back to its own heuristics.""" + body = await _require_dict_body(request) + prompt = str(body.get("prompt") or "")[:4000] + goal = str(body.get("goal") or "") + mode = str(body.get("mode") or "ctf") + if mode not in ("ctf", "pentest"): + mode = "ctf" + mgr = app.state.manager + try: + llm_profiles = dict(mgr.worker_config.get().get("llm_profiles") or {}) + except Exception: + llm_profiles = {} + planner_profile = llm_profiles.get("planner") or {} + planner_model = str(planner_profile.get("model") or "deepseek-v4-pro") + from apps.web.llm_credentials import LlmCredentialStore + from muteki.core.llm import LLMClient, llm_temperature_kwargs + + llm_kwargs: dict[str, Any] = dict( + llm_temperature_kwargs(planner_profile)) + planner_base = str(planner_profile.get("base_url") or "").strip() + if planner_base: + llm_kwargs["base_url"] = planner_base + planner_key = LlmCredentialStore(mgr.sessions_root).resolve("planner") + if planner_key: + llm_kwargs["api_key"] = planner_key + parsed: dict[str, Any] = {} + try: + from apps.web.dispatch_parse import parse_dispatch + + async with LLMClient(**llm_kwargs) as llm: + parsed = await asyncio.wait_for( + parse_dispatch( + prompt, goal, mode, llm=llm, model=planner_model), + timeout=15.0, + ) + except Exception: + parsed = {} + return {"parsed": parsed or {}} + @app.post("/api/runs/{run_id}/uploads") async def upload_files( run_id: str, files: list[UploadFile] = File(...) @@ -986,7 +1378,11 @@ async def upload_files( # ensure a run handle exists so an upload BEFORE dispatch still works # (the deck promotes a draft to a real run id before uploading, but be # robust — mirror the get-or-create the events/start endpoints use). - mgr.get(run_id) or mgr.create(run_id) + from muteki.core.path_ids import RunIdPathError + try: + mgr.get(run_id) or mgr.create(run_id) + except RunIdPathError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc if len(files) > MAX_UPLOAD_FILES: raise HTTPException(status_code=413, detail="too many files") @@ -1068,7 +1464,8 @@ async def gen(): async for ev in run.store.replay_monotonic(run_id, after_seq=last_id): replayed_seq = ev.seq replayed_count += 1 - if ev.event_type in (EventType.RUN_STARTED, + if ev.event_type in (EventType.RUN_PREPARING, + EventType.RUN_STARTED, EventType.RUN_FINISHED, EventType.RUN_REOPENED): last_lifecycle = ev.event_type.value @@ -1089,7 +1486,10 @@ async def gen(): # lifecycle from the skipped prefix and should simply wait on the bus. task = getattr(run, "task", None) live = task is not None and not task.done() - if fresh and not live and last_lifecycle in ("run.started", "run.reopened"): + if (fresh and not live + and manager.is_protocol1_run(run_id, run=run) + and last_lifecycle in ( + "run.preparing", "run.started", "run.reopened")): replayed_seq = max(replayed_seq, run.store.last_stream_seq(run_id)) + 1 synth = Event( event_type=EventType.RUN_FINISHED, run_id=run_id, @@ -1111,7 +1511,12 @@ async def gen(): # keep the SSE open (ping handles liveness) and hop to a fresh bus if # resolve/standby reopens the run. manager._sync_bus_seq(run.bus, store=run.store, run_id=run_id) - tail_from = max(last_id, replayed_seq, run.store.last_stream_seq(run_id)) + # Do not advance the cursor from a second store lookup here. An event + # can commit after replay reached EOF but before this line; adopting its + # sequence without yielding it would skip that event permanently. Live + # in-process writes are present in the EventBus ring and subscribe() + # delivers everything after the last sequence actually replayed. + tail_from = max(last_id, replayed_seq) while True: bus = run.bus async for ev in bus.subscribe(last_event_id=tail_from): @@ -1126,6 +1531,13 @@ async def gen(): while run.bus is bus: if await request.is_disconnected(): return + # STOP/COMPLETE confirms runtime exit only after the generation + # task has emitted RUN_FINISHED and closed its bus. The final + # control receipt is therefore a valid late publication on that + # closed bus. Re-enter subscribe() when its sequence advances so + # the existing SSE connection receives the durable receipt. + if bus.current_seq > tail_from: + break await asyncio.sleep(1) return EventSourceResponse( @@ -1191,15 +1603,51 @@ async def kill_worker(run_id: str, request: Request) -> Any: run_id, "kill", solver_id=body.get("solver_id")) return {"ok": ok} + @app.post("/api/runs/{run_id}/control") + async def control(run_id: str, request: Request) -> Any: + """Persist an idempotent command; effects arrive later over SSE.""" + body = await _require_dict_body(request) + if app.state.manager.get(run_id) is None: + raise HTTPException(status_code=404, detail="unknown run") + try: + result = await app.state.manager.post_control(run_id, body) + except (ControlPayloadError, PydanticValidationError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except (IdempotencyConflict, StateConflict) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + if result.get("status") == "rejected": + code = str(result.get("code") or "") + status = 409 if code == "generation_conflict" else 422 + return JSONResponse(result, status_code=status) + return result + + @app.get("/api/runs/{run_id}/control/{command_id}") + async def control_receipt(run_id: str, command_id: str) -> Any: + """Reconcile a command whose SSE terminal projection was interrupted.""" + if app.state.manager.get(run_id) is None: + raise HTTPException(status_code=404, detail="unknown run") + if not app.state.manager.is_protocol1_run(run_id): + raise HTTPException( + status_code=409, detail="PROTOCOL2_CONTROL_UNAVAILABLE") + receipt = app.state.manager.control_receipt(run_id, command_id) + if receipt is None: + raise HTTPException(status_code=404, detail="unknown control command") + return receipt + @app.post("/api/runs/{run_id}/hitl") async def hitl(run_id: str, request: Request) -> Any: body = await _require_dict_body(request) - ok = await app.state.manager.post_hitl( - run_id, - body.get("target", "global"), - body.get("action", "hint"), - **{k: v for k, v in body.items() if k not in ("target", "action")}, - ) + try: + ok = await app.state.manager.post_hitl( + run_id, + body.get("target", "global"), + body.get("action", "hint"), + **{k: v for k, v in body.items() if k not in ("target", "action")}, + ) + except (ControlPayloadError, PydanticValidationError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except (IdempotencyConflict, StateConflict) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc return {"ok": ok} # static UI: the deck is the Next.js app (run `./run.sh web` → :3001, which diff --git a/apps/web/titler.py b/apps/web/titler.py index 74adcc6..b9480e7 100644 --- a/apps/web/titler.py +++ b/apps/web/titler.py @@ -14,11 +14,11 @@ from __future__ import annotations import re -from typing import Optional +from typing import Any, Optional from muteki.core.events import Event, EventType from muteki.core.event_bus import EventBus -from muteki.core.llm import LLMClient +from muteki.core.llm import LLMClient, llm_temperature_kwargs TITLE_MODEL = "deepseek-v4-flash" @@ -72,6 +72,9 @@ async def generate_title( run_id: Optional[str] = None, model: Optional[str] = None, base_url: Optional[str] = None, + api_key: Optional[str] = None, + temperature_mode: Optional[str] = None, + temperature: Any = None, ) -> str: """Return a short title for `prompt`; emit RUN_TITLED on `bus` if given. @@ -80,15 +83,21 @@ async def generate_title( from surfacing as an unhandled-task warning. `base_url` overrides the titler endpoint (DESIGN §2.2 補强A) — empty/None = - default DeepSeek. The API key is NOT passed here; it stays in .env. Only used - when `llm` is not injected (we own the client lifecycle). + default DeepSeek. `api_key` can override the environment fallback when this + function owns the client lifecycle. """ title = fallback_title(prompt) owns_llm = llm is None try: - client = llm or ( - LLMClient(base_url=base_url) if (base_url or "").strip() else LLMClient() - ) + client_kwargs = llm_temperature_kwargs({ + "temperature_mode": temperature_mode, + "temperature": temperature, + }) + if (base_url or "").strip(): + client_kwargs["base_url"] = str(base_url).strip() + if (api_key or "").strip(): + client_kwargs["api_key"] = str(api_key).strip() + client = llm or LLMClient(**client_kwargs) try: resp = await client.chat( model=model or TITLE_MODEL, diff --git a/apps/web/ui/README.md b/apps/web/ui/README.md index 9418e50..aa934dd 100644 --- a/apps/web/ui/README.md +++ b/apps/web/ui/README.md @@ -62,7 +62,8 @@ NEXT_PUBLIC_MUTEKI_API=http://127.0.0.1:8000 npm run dev # http://local backend infers `challenge.{category,target,name}` when structured fields are absent (caller-provided fields always win). - `GET /api/runs/{id}/events` — the typed SSE event stream (Last-Event-ID resume). -- `POST /api/runs/{id}/hitl` — a human command into the live run. +- `POST /api/runs/{id}/control` — durable, idempotent operator command admission. +- `POST /api/runs/{id}/hitl` — legacy compatibility adapter onto `/control`. ## Files diff --git a/apps/web/ui/app/api/[...path]/route.ts b/apps/web/ui/app/api/[...path]/route.ts new file mode 100644 index 0000000..a59ab3d --- /dev/null +++ b/apps/web/ui/app/api/[...path]/route.ts @@ -0,0 +1,78 @@ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 900; + +const BACKEND = process.env.MUTEKI_BACKEND || "http://127.0.0.1:8000"; + +const hopByHopHeaders = [ + "host", + "connection", + "content-length", + "transfer-encoding", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "upgrade", +]; + +type RouteContext = { + params: { + path?: string[]; + }; +}; + +function apiUrl(req: Request, path: string[] | undefined): string { + const requestUrl = new URL(req.url); + const encodedPath = (path || []).map((part) => encodeURIComponent(part)).join("/"); + const upstreamUrl = new URL("/api/" + encodedPath, BACKEND); + upstreamUrl.search = requestUrl.search; + return upstreamUrl.toString(); +} + +function requestHeaders(req: Request): Headers { + const headers = new Headers(req.headers); + for (const name of hopByHopHeaders) headers.delete(name); + return headers; +} + +function responseHeaders(upstream: Response): Headers { + const headers = new Headers(upstream.headers); + for (const name of hopByHopHeaders) headers.delete(name); + return headers; +} + +async function proxy(req: Request, ctx: RouteContext) { + const init: RequestInit & { duplex?: "half" } = { + method: req.method, + headers: requestHeaders(req), + cache: "no-store", + }; + if (req.method !== "GET" && req.method !== "HEAD" && req.body !== null) { + init.body = req.body; + init.duplex = "half"; + } + + try { + const upstream = await fetch(apiUrl(req, ctx.params.path), init); + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders(upstream), + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return Response.json( + { ok: false, detail: `api proxy failed: ${detail}` }, + { status: 502 }, + ); + } +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; +export const OPTIONS = proxy; diff --git a/apps/web/ui/app/globals.css b/apps/web/ui/app/globals.css index eadcc96..1c4a53a 100644 --- a/apps/web/ui/app/globals.css +++ b/apps/web/ui/app/globals.css @@ -7,47 +7,52 @@ they read on white). Body stays monospace (terminal/hacker energy). Existing var NAMES are kept as aliases so every old rule still resolves; only the VALUES flip from dark → light. */ - --bg: #f4f6fa; /* page ground — cool near-white */ - --rail: #eceff5; /* left rail / menus — a touch cooler */ - --bg2: #eceff5; /* alias → rail (menus / term chrome / canvas) */ - --panel: #ffffff; /* cards / bubbles — pure white */ - --panel2: #f6f8fc; /* recessed surfaces */ - --panel3: #eef1f7; - --term-bg: #f1f3f8; /* code/term surfaces — faint ink-grey */ - --line: #d6dbe6; /* hairline — slate */ - --line2: #b4bccd; /* stronger hairline (hard borders) */ - --border: #d6dbe6; /* alias → line */ - --border2: #b4bccd; /* alias → line2 */ - --text: #1d2530; /* ink body */ - --bright: #080c14; /* near-black headings */ - --muted: #5a6478; /* secondary ink */ - --dim: #5a6478; /* alias → muted */ - - --blue: #2563eb; - --accent: #2563eb; /* alias → blue */ - --green: #15a34a; - --amber: #b45309; - --yellow: #b45309; /* alias → amber */ - --cyan: #0891b2; - --pink: #db2777; - --violet: #7c3aed; - --magenta: #9333ea; - --red: #dc2626; - --gold: #b8860b; - - /* engine accents (mirror lib/workers.ts) */ + --bg: #f7f8fa; + --rail: #f2f4f6; + --bg2: #f2f4f6; + --panel: #ffffff; + --panel2: #f5f7f9; + --panel3: #edf0f3; + --term-bg: #f2f2f3; + --line: #e5e8ec; + --line2: #d7dce2; + --border: #e5e8ec; + --border2: #d7dce2; + --text: #242930; + --bright: #171b20; + --muted: #68717d; + --dim: #929aa5; + + --blue: #56779f; + --accent: #56779f; + --green: #527d60; + --amber: #94764d; + --yellow: #94764d; + --cyan: #667f9d; + --pink: #7b7f8d; + --violet: #777f91; + --magenta: #777f91; + --red: #a45f65; + --gold: #8d744f; + + /* text on top of --accent fills (palette engine overrides per scheme/mode) */ + --on-accent: #ffffff; + + /* engine accents — fallback aliases only; palette-engine regenerates the + real --eng-* tokens (fixed per-engine OKLCH hues, mode-correct) on load */ --eng-claude: var(--cyan); --eng-codex: var(--pink); --eng-cursor: var(--violet); --eng-reason: var(--amber); - --human-bg: #dbeafe; /* light blue chat ground */ - --human-border: #93b4f0; + --human-bg: #f0f4f8; + --human-border: #d7e0ea; /* soft ink shadows (replace the dark theme's rgba(0,0,0,.4) drops) */ - --shadow-sm: 0 1px 2px rgba(20, 30, 50, .06), 0 1px 1px rgba(20, 30, 50, .04); - --shadow-md: 0 6px 18px rgba(20, 30, 50, .08), 0 2px 6px rgba(20, 30, 50, .05); - --shadow-pop: 0 12px 32px rgba(20, 30, 50, .14), 0 4px 10px rgba(20, 30, 50, .08); + --shadow-sm: 0 0 0 1px #e0e2e5, 0 1px 2px #1018280d; + --shadow-md: 0 0 0 1px #ecedef, 0 1px 2px #1018280a, 0 2px 6px #10182808; + --shadow-pop: 0 0 0 1px #ecedef, 0 8px 28px #0001; + --shadow-lg: var(--shadow-pop); /* faint ink overlay (replaces white-on-dark rgba(255,255,255,.03) insets) */ --ink-wash: rgba(20, 30, 50, .035); @@ -78,48 +83,226 @@ --ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1); } +/* Runtime workspace: trace-first navigation, overview, event ledger, and worker roster. */ +.artifact { --runtime-accent: var(--blue); container: runtimepanel / inline-size; overflow:hidden; } +.artifact.workspace-mode { flex:1 1 auto; width:100%; max-width:none; min-width:0; border-left:0; overflow:hidden; transition:none; } +.artifact.workspace-mode.motion-artifact { animation:motion-rise .28s var(--ease-out-soft) both; } +.artifact.workspace-mode .runtime-head { padding-inline:clamp(16px,2vw,28px); } +.artifact.workspace-mode .runtime-navigation { padding-inline:clamp(12px,1.6vw,22px); } +.artifact-head.runtime-head { min-height:72px; padding:13px 16px; gap:11px; border-bottom:0; background:color-mix(in srgb,var(--panel2) 94%,transparent); } +.runtime-titlemark { width:38px; height:38px; flex:none; display:grid; place-items:center; color:var(--blue); border:1px solid color-mix(in srgb,var(--blue) 34%,var(--line)); border-radius:12px; background:color-mix(in srgb,var(--blue) 10%,var(--panel)); box-shadow:inset 0 1px 0 color-mix(in srgb,var(--bright) 7%,transparent); } +.runtime-heading { min-width:0; display:grid; gap:2px; } +.runtime-eyebrow { color:var(--muted); font-size:9.5px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; } +.runtime-titleline { min-width:0; display:flex; align-items:center; gap:8px; } +.runtime-titleline strong { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--bright); font-size:15px; } +.runtime-context { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-family:var(--font-mono); font-size:10.5px; } +.runtime-state { flex:none; display:inline-flex; align-items:center; gap:5px; color:var(--muted); border:1px solid var(--line); border-radius:var(--r-pill); padding:2px 7px; background:var(--ink-wash); font-size:9.5px; font-weight:750; } +.runtime-state-dot { width:6px; height:6px; border-radius:50%; background:currentColor; } +.runtime-state.live { color:var(--green); border-color:color-mix(in srgb,var(--green) 30%,var(--line)); } +.runtime-state.live .runtime-state-dot { box-shadow:0 0 8px color-mix(in srgb,var(--green) 80%,transparent); } +.runtime-state.complete { color:var(--blue); } +.runtime-back { flex:none; min-height:34px; display:inline-flex; align-items:center; gap:7px; padding:6px 11px 6px 9px; border:1px solid var(--line2); border-radius:9px; color:var(--text); background:var(--panel); font:inherit; font-size:11px; font-weight:750; cursor:pointer; box-shadow:var(--shadow-sm); } +.runtime-back svg { transform:rotate(180deg); color:var(--blue); } +.runtime-back:hover { color:var(--bright); border-color:color-mix(in srgb,var(--blue) 50%,var(--line2)); background:color-mix(in srgb,var(--blue) 7%,var(--panel)); } +.runtime-back:focus-visible { outline:2px solid color-mix(in srgb,var(--blue) 45%,transparent); outline-offset:2px; } + +.runtime-navigation { flex:none; min-width:0; min-height:52px; display:flex; align-items:center; gap:10px; padding:8px 16px; overflow-x:auto; border-bottom:1px dashed var(--line2); background:var(--bg); scrollbar-width:none; } +.runtime-navigation::-webkit-scrollbar { display:none; } +.runtime-primary-nav { flex:none; height:34px; display:inline-flex; align-items:center; gap:2px; padding:3px; border:1px solid var(--line); border-radius:9px; background:var(--panel2); } +.runtime-primary-nav button { min-width:82px; height:28px; display:inline-flex; align-items:center; justify-content:center; gap:7px; padding:0 11px; color:var(--muted); border:0; border-radius:6px; background:transparent; font:inherit; font-size:11px; font-weight:570; cursor:pointer; } +.runtime-primary-nav button:hover { color:var(--text); background:color-mix(in srgb,var(--bright) 4%,transparent); } +.runtime-primary-nav button.on { color:var(--bright); background:var(--panel); box-shadow:var(--shadow-sm); } +.runtime-primary-nav button.on svg { color:var(--accent); } +.runtime-nav-divider { flex:none; width:1px; height:24px; background:var(--line); } +.runtime-view-nav { flex:1 0 auto; min-width:max-content; display:inline-flex; align-items:center; gap:3px; overflow-x:visible; scrollbar-width:none; } +.runtime-view-nav::-webkit-scrollbar { display:none; } +.runtime-view-nav button { flex:none; height:32px; display:inline-flex; align-items:center; gap:6px; padding:0 10px; color:var(--muted); border:1px solid transparent; border-radius:7px; background:transparent; font:inherit; font-size:10.5px; font-weight:540; cursor:pointer; } +.runtime-view-nav button:hover { color:var(--text); background:var(--panel2); } +.runtime-view-nav button.on { color:var(--bright); border-color:var(--line); background:var(--panel2); } +.runtime-view-nav button.on svg { color:var(--accent); } +.runtime-view-nav button b { color:var(--dim); font:600 9.5px/1 var(--font-mono); font-variant-numeric:tabular-nums; } +.runtime-view-nav button.on b { color:var(--accent); } + +.runtime-trace { --lane-control:var(--blue); --lane-worker:var(--green); --lane-tool:var(--amber); --lane-evidence:var(--violet); flex:none; border-bottom:1px solid var(--line); background:var(--panel); } +.runtime-trace-head { min-width:0; min-height:43px; display:flex; align-items:center; gap:10px; padding:6px 9px 6px 12px; border-bottom:1px solid var(--line); } +.runtime-trace.collapsed .runtime-trace-head { border-bottom-color:transparent; } +.runtime-trace-collapse { flex:none; width:24px; height:24px; display:grid; place-items:center; padding:0; border:1px solid var(--line); border-radius:6px; color:var(--muted); background:var(--panel2); cursor:pointer; } +.runtime-trace-collapse:hover { color:var(--bright); border-color:var(--line2); background:var(--panel3); } +.runtime-trace-fold { display:grid; grid-template-rows:1fr; transition:grid-template-rows .22s ease; } +.runtime-trace-fold>.runtime-trace-stage { overflow:hidden; min-height:0; } +.runtime-trace.collapsed .runtime-trace-fold { grid-template-rows:0fr; } +.runtime-trace-title { flex:none; display:inline-flex; align-items:center; gap:6px; color:var(--text); font-size:10.5px; font-weight:750; } +.runtime-trace-title svg { color:var(--blue); } +.runtime-trace-live { min-height:18px; display:inline-flex; align-items:center; padding:0 5px; border:1px solid color-mix(in srgb,var(--green) 28%,var(--line)); border-radius:5px; color:var(--green); background:color-mix(in srgb,var(--green) 8%,transparent); font-size:8.5px; letter-spacing:.06em; text-transform:uppercase; } +.runtime-trace-hint { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--dim); font-size:9.5px; } +.runtime-trace-controls { margin-left:auto; flex:none; display:flex; align-items:center; gap:4px; } +.runtime-trace-controls>button { height:27px; display:inline-flex; align-items:center; gap:5px; padding:0 8px; border:1px solid var(--line); border-radius:6px; color:var(--muted); background:var(--panel2); font:550 9.5px/1 var(--font-sans); cursor:pointer; } +.runtime-trace-controls>button:hover,.runtime-trace-controls>button.on { color:var(--bright); border-color:var(--line2); background:var(--panel3); } +.runtime-trace-controls>button.on svg { color:var(--blue); } +.runtime-trace-controls>time { min-width:44px; color:var(--dim); font:9px/1 var(--font-mono); text-align:right; } +.runtime-trace-zoom { min-width:42px; height:27px; display:inline-flex; align-items:center; justify-content:center; padding:0 8px; border:1px solid var(--line); border-radius:6px; color:var(--muted); background:var(--panel2); font:650 9px/1 var(--font-mono); pointer-events:none; } +.runtime-trace-stage { min-width:0; display:grid; grid-template-columns:82px minmax(0,1fr); min-height:184px; background:var(--panel2); } +.runtime-trace-labels { position:relative; min-width:0; height:184px; border-right:1px solid var(--line); background:color-mix(in srgb,var(--panel) 76%,var(--panel2)); color:var(--muted); } +.runtime-trace-labels span { position:absolute; left:9px; right:7px; height:22px; display:grid; grid-template-columns:6px minmax(0,1fr) auto; align-items:center; gap:5px; color:var(--muted); font-size:9.5px; } +.runtime-trace-labels span:nth-child(1){top:7px}.runtime-trace-labels span:nth-child(2){top:39px}.runtime-trace-labels span:nth-child(3){top:71px}.runtime-trace-labels span:nth-child(4){top:103px} +.runtime-trace-labels i { width:5px; height:5px; border-radius:50%; background:currentColor; box-shadow:0 0 7px color-mix(in srgb,currentColor 45%,transparent); }.runtime-trace-labels em{overflow:hidden;text-overflow:ellipsis;font-style:normal;white-space:nowrap}.runtime-trace-labels b{color:var(--dim);font:600 8px/1 var(--font-mono)} +.runtime-trace .lane-control{color:var(--lane-control)}.runtime-trace .lane-worker{color:var(--lane-worker)}.runtime-trace .lane-tool{color:var(--lane-tool)}.runtime-trace .lane-evidence{color:var(--lane-evidence)} +.runtime-trace-plot { min-width:0; height:184px; display:grid; grid-template-rows:132px 20px 32px; overflow:hidden; background:var(--panel2); } +.runtime-trace-canvas { position:relative; min-width:0; overflow:hidden; overscroll-behavior:contain; touch-action:none; user-select:none; cursor:grab; contain:layout paint; background-image:linear-gradient(to right,color-mix(in srgb,var(--line) 72%,transparent) 1px,transparent 1px),linear-gradient(to bottom,transparent 31px,color-mix(in srgb,var(--line) 62%,transparent) 32px); background-size:12.5% 100%,100% 32px; } +.runtime-trace-plot.dragging .runtime-trace-canvas{cursor:grabbing}.runtime-trace-plot.dragging .runtime-trace-mark{pointer-events:none} +.runtime-trace-relations { position:absolute; z-index:1; inset:0; width:100%; height:132px; overflow:visible; pointer-events:none; opacity:0; transition:opacity .16s ease; } +.runtime-trace-relations.visible { opacity:1; }.runtime-trace-relations path{fill:none;stroke:color-mix(in srgb,var(--blue) 24%,var(--line));stroke-width:1;vector-effect:non-scaling-stroke}.runtime-trace-relations path.flow{stroke:color-mix(in srgb,var(--violet) 28%,var(--line));stroke-dasharray:3 4}.runtime-trace-relations path.selected{stroke:var(--bright);stroke-width:1.5;opacity:.9;stroke-dasharray:none} +.runtime-trace-track { position:absolute; z-index:2; left:0; right:0; height:14px; } +.runtime-trace-track.lane-control{top:11px}.runtime-trace-track.lane-worker{top:43px}.runtime-trace-track.lane-tool{top:75px}.runtime-trace-track.lane-evidence{top:107px} +.runtime-trace-mark { position:absolute; top:2px; left:var(--rt-left); width:max(3px,var(--rt-width)); min-width:3px; height:10px; padding:0; border:0; border-radius:3px; opacity:.92; background:var(--lane-control); cursor:pointer; contain:strict; transition:opacity .12s ease,transform .14s ease,box-shadow .14s ease; } +.runtime-trace-track.lane-worker .runtime-trace-mark{background:var(--rt-color,var(--lane-worker))}.runtime-trace-track.lane-tool .runtime-trace-mark{background:var(--lane-tool)}.runtime-trace-track.lane-evidence .runtime-trace-mark{background:var(--lane-evidence)} +.runtime-trace-mark.kind-flag { background:var(--green)!important; box-shadow:0 0 8px color-mix(in srgb,var(--green) 45%,transparent); } +.runtime-trace-mark:hover { z-index:4; opacity:1; transform:scaleY(1.35); box-shadow:0 0 0 1px var(--bright); }.runtime-trace-mark:focus-visible{z-index:4;outline:2px solid var(--bright);outline-offset:2px} +.runtime-trace-mark.selected { z-index:5; opacity:1; transform:scaleY(1.5); box-shadow:0 0 0 2px var(--panel2),0 0 0 3px var(--bright),0 0 12px currentColor; } +.runtime-trace-now { position:absolute; z-index:3; top:0; right:1px; bottom:0; width:1px; background:var(--green); box-shadow:0 0 7px var(--green); } +.runtime-trace-scale { height:20px; display:flex; align-items:center; justify-content:space-between; padding:0 8px; color:var(--dim); border-top:1px solid color-mix(in srgb,var(--line) 70%,transparent); font:8.5px/1 var(--font-mono); }.runtime-trace-scale span:nth-child(2){font-family:var(--font-sans);letter-spacing:.04em} +.runtime-trace-navigator { position:relative; height:22px; margin:4px 8px 6px; overflow:hidden; touch-action:none; border:1px solid var(--line); border-radius:5px; background:var(--panel3); cursor:pointer; } +.runtime-trace-density { position:absolute; inset:0; overflow:hidden; pointer-events:none; }.runtime-trace-density i{position:absolute;top:4px;bottom:4px;width:1px;background:currentColor;opacity:.6} +.runtime-trace-window { position:absolute; top:1px; bottom:1px; min-width:8px; padding:0; border:1px solid color-mix(in srgb,var(--blue) 75%,var(--bright)); border-radius:4px; background:color-mix(in srgb,var(--blue) 12%,transparent); box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--blue) 16%,transparent); cursor:grab; }.runtime-trace-window:focus-visible{outline:2px solid var(--blue);outline-offset:-2px} + +.artifact-body,.artifact-view { min-width:0; overflow:hidden; background:var(--panel2); } +.artifact-list { padding:16px; } +.artifact-row { position:relative; border-radius:12px; border-color:var(--line); background:var(--panel); } + +.wlane-overview { display:grid; grid-template-columns:minmax(160px,1fr) minmax(260px,1.4fr); align-items:stretch; gap:12px; margin-bottom:14px; } +.wlane-overview:has(.wlane-spawn) { grid-template-columns:minmax(160px,1fr) minmax(260px,1.4fr) auto; } +.wlane-overview-copy { min-width:0; display:flex; flex-direction:column; justify-content:center; padding:14px 16px; border:1px solid var(--line); border-radius:14px; background:var(--panel); } +.wlane-overview-kicker { color:var(--blue); font-size:9.5px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; } +.wlane-overview-copy strong { margin-top:3px; color:var(--bright); font-size:15px; } +.wlane-overview-phase { margin-top:6px; display:inline-flex; align-items:center; gap:5px; align-self:flex-start; padding:3px 9px; border-radius:var(--r-pill); background:color-mix(in srgb,var(--blue) 12%,transparent); color:var(--blue); font-size:11px; font-weight:700; } +.wlane-overview-stats { min-width:0; display:grid; grid-template-columns:repeat(auto-fit,minmax(84px,1fr)); border:1px solid var(--line); border-radius:14px; overflow:hidden; background:var(--line); gap:1px; } +.wlane-overview-stats>div { min-width:0; padding:11px 12px; display:flex; flex-direction:column; justify-content:center; gap:3px; background:var(--panel); } +.wlane-overview-stats span { color:var(--muted); font-size:9px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; } +.wlane-overview-stats b { color:var(--bright); font-family:var(--font-mono); font-size:16px; }.wlane-overview-stats b.ok{color:var(--green)}.wlane-overview-stats b.info{color:var(--blue)} + +.activity-panel { width:100%; max-width:100%; min-width:0; overflow:hidden; background:var(--panel2); } +.activity-toolbar { flex:none; min-height:46px; display:flex; align-items:center; gap:8px; padding:7px 12px; border-bottom:1px solid var(--line); background:var(--panel); } +.activity-toolbar-title { min-width:115px; display:grid; gap:1px; }.activity-toolbar-title strong{color:var(--bright);font-size:11.5px}.activity-toolbar-title span{color:var(--muted);font-size:9.5px} +.activity-search { flex:1; min-width:100px; height:30px; display:flex; align-items:center; gap:6px; padding:0 9px; border:1px solid var(--line2); border-radius:9px; color:var(--muted); background:var(--panel3); } +.activity-search:focus-within { color:var(--blue); border-color:color-mix(in srgb,var(--blue) 55%,var(--line2)); box-shadow:0 0 0 3px color-mix(in srgb,var(--blue) 9%,transparent); } +.activity-search input { min-width:0; width:100%; border:0; outline:0; color:var(--text); background:transparent; font:inherit; font-size:10.5px; }.activity-search input::placeholder{color:var(--dim)} +.activity-filterbar.chip-filterbar.inline { background:var(--panel2); }.activity-panel>.panel-scroll{padding:0}.trace-ledger{gap:0} +.runtime-speaker-filter { flex:none; display:flex; align-items:center; gap:5px; padding:6px 10px; overflow-x:auto; border-bottom:1px solid var(--line); background:var(--panel2); scrollbar-width:none; } +.runtime-speaker-filter::-webkit-scrollbar { display:none; } +.runtime-speaker-filter button { flex:none; min-height:23px; padding:3px 8px; border:1px solid var(--line); border-radius:var(--r-pill); color:var(--dim); background:transparent; font:inherit; font-size:9.5px; cursor:pointer; } +.runtime-speaker-filter button.on { color:var(--text); border-color:color-mix(in srgb,var(--wc,var(--line2)) 55%,var(--line2)); background:var(--panel); } +.runtime-speaker-filter button .spk-dot { width:6px; height:6px; margin-right:5px; border-radius:50%; background:var(--wc,var(--dim)); display:inline-block; vertical-align:middle; } +.runtime-ledger-scroll { width:100%; min-width:0; padding:0; } +.activity-jump-latest { position:absolute; right:18px; bottom:14px; z-index:6; display:inline-flex; align-items:center; gap:5px; height:28px; padding:0 11px; border:1px solid var(--line2); border-radius:999px; color:var(--text); background:color-mix(in srgb,var(--panel3) 88%,transparent); backdrop-filter:blur(10px); box-shadow:var(--shadow-md); font:600 10px/1 var(--font-sans); cursor:pointer; } +.activity-jump-latest:hover { color:var(--bright); border-color:var(--blue); } +.activity-jump-latest svg { color:var(--blue); } +.activity-jump-latest b { min-width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; padding:0 4px; border-radius:999px; color:var(--bg); background:var(--blue); font:700 8.5px/1 var(--font-mono); } +.trace-ledger.virtualized { position:relative; width:100%; min-height:100%; } +.trace-ledger.virtualized .act-msg { position:absolute; left:0; right:0; min-height:0; overflow:hidden; } +.trace-ledger.virtualized .act-msg.expanded { overflow:hidden; } +.runtime-worker-host { padding:0; overflow:auto; } +.runtime-worker-host>.panel-scroll { flex:none; overflow:visible; padding:0; } +.trace-ledger-head { position:sticky; top:0; z-index:3; min-height:28px; display:grid; grid-template-columns:34px 88px minmax(0,1fr) 68px; align-items:center; padding:0 28px 0 8px; border-bottom:1px solid var(--line); color:var(--muted); background:color-mix(in srgb,var(--panel2) 94%,transparent); backdrop-filter:blur(12px); font-size:9px; text-transform:uppercase; letter-spacing:.08em; } +.trace-ledger .act-msg { --wc:var(--blue); position:relative; width:100%; min-height:34px; display:grid; grid-template-columns:34px 88px minmax(0,1fr) 68px 16px; align-items:start; gap:0; padding:0 8px; border:0; border-bottom:1px solid var(--line); border-radius:0; color:inherit; background:var(--panel); text-align:left; cursor:pointer; content-visibility:auto; contain-intrinsic-block-size:34px; } +.trace-ledger .act-msg:hover{background:color-mix(in srgb,var(--blue) 5%,var(--panel))}.trace-ledger .act-msg:focus-visible{outline:1px solid var(--blue);outline-offset:-1px} +.trace-ledger .act-msg.selected { z-index:1; background:color-mix(in srgb,var(--blue) 9%,var(--panel)); box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--blue) 36%,transparent); } +.trace-ledger .act-index { align-self:start; margin-top:11px; color:var(--dim); font:9.5px/1 var(--font-mono); font-variant-numeric:tabular-nums; white-space:nowrap; } +.trace-ledger .act-kind { align-self:start; width:fit-content; max-width:82px; min-width:0; margin-top:7px; display:inline-flex; align-items:center; gap:5px; padding:3px 7px; overflow:hidden; border-radius:6px; color:var(--wc); background:color-mix(in srgb,var(--wc) 10%,var(--panel)); font-size:9.5px; font-weight:750; line-height:1.25; white-space:nowrap; text-overflow:ellipsis; } +.trace-ledger .act-kind svg { flex:none; } +.trace-ledger .act-kind.kind-tool{color:var(--amber);background:color-mix(in srgb,var(--amber) 10%,var(--panel))}.trace-ledger .act-kind.kind-flag{color:var(--green);background:color-mix(in srgb,var(--green) 10%,var(--panel))} +.trace-ledger .act-main { align-self:start; min-width:0; max-width:100%; max-height:100%; padding:7px 10px 7px 4px; overflow:hidden; }.trace-ledger .act-who{min-width:0;margin:0 0 2px;gap:5px;color:var(--muted);font-size:9px}.trace-ledger .act-actor{color:var(--wc);font-weight:750} +.trace-ledger .act-body { display:block; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); font-size:11px; line-height:1.35; } +.trace-ledger .act-msg.expanded{background:color-mix(in srgb,var(--blue) 4%,var(--panel))}.trace-ledger .act-msg.expanded .act-body{overflow-x:hidden;overflow-y:auto;text-overflow:clip;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;max-height:132px;overscroll-behavior:contain} +.trace-ledger .act-time { align-self:start; margin-top:11px; color:var(--dim); font:9.5px/1 var(--font-mono); font-variant-numeric:tabular-nums; white-space:nowrap; }.trace-ledger .act-expand{align-self:start;margin-top:10px;color:var(--muted);transition:transform .14s ease}.trace-ledger .act-msg.expanded .act-expand{transform:rotate(180deg);color:var(--blue)} +.trace-ledger .act-msg.grouped{padding-top:0}.trace-ledger .act-msg.grouped .act-who{display:flex} +.activity-feed.trace-ledger.compact .act-msg,.trace-ledger.compact .act-msg{min-height:28px;grid-template-columns:30px 76px minmax(0,1fr) 62px 14px;padding-inline:6px}.trace-ledger.compact .act-index{margin-top:9px}.trace-ledger.compact .act-kind{margin-top:6px;padding:2px 5px;font-size:9px}.trace-ledger.compact .act-time{margin-top:9px}.trace-ledger.compact .act-expand{margin-top:8px}.trace-ledger.compact .act-who{display:none}.trace-ledger.compact .act-main{padding-block:4px}.trace-ledger.compact .act-body{font-size:10.5px;line-height:1.25} +.trace-ledger .act-msg.tool-group { display:flex; flex-direction:column; align-items:stretch; gap:0; padding:0; overflow:hidden; } +.trace-ledger .act-group-head { display:grid; grid-template-columns:34px 88px minmax(0,1fr) 68px 16px; align-items:start; width:100%; min-height:34px; padding:0 8px; border:0; color:inherit; background:transparent; text-align:left; cursor:pointer; font:inherit; } +.trace-ledger.compact .act-group-head { min-height:28px; grid-template-columns:30px 76px minmax(0,1fr) 62px 14px; padding-inline:6px; } +.trace-ledger .act-msg.tool-group.expanded .act-expand { transform:rotate(180deg); color:var(--blue); } +.trace-ledger .act-msg.tool-failed .act-kind { color:var(--amber); background:color-mix(in srgb,var(--amber) 14%,var(--panel)); } +.trace-ledger .act-tool-list { display:flex; flex-direction:column; gap:2px; padding:0 10px 8px 122px; min-width:0; } +.trace-ledger .act-tool-child { display:flex; flex-direction:column; gap:2px; width:100%; padding:4px 8px; border:0; border-radius:6px; color:inherit; background:color-mix(in srgb,var(--amber) 7%,var(--panel)); text-align:left; cursor:pointer; font:inherit; } +.trace-ledger .act-tool-child:hover { background:color-mix(in srgb,var(--amber) 12%,var(--panel)); } +.trace-ledger .act-tool-child.selected, .trace-ledger .act-tool-child.expanded { background:color-mix(in srgb,var(--blue) 9%,var(--panel)); } +.trace-ledger .act-tool-child.tool-failed { background:color-mix(in srgb,var(--amber) 16%,var(--panel)); } +.trace-ledger .act-tool-cmd { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); font:11px/1.35 var(--font-mono); } +.trace-ledger .act-tool-out { max-height:88px; overflow:auto; color:var(--dim); font:10.5px/1.4 var(--font-mono); white-space:pre-wrap; overflow-wrap:anywhere; } + +@container runtimepanel (max-width:520px) { + .runtime-navigation{gap:7px;padding-inline:8px}.runtime-primary-nav button{min-width:70px;padding-inline:8px}.runtime-trace-hint{display:none}.runtime-trace-controls>button span{display:none}.runtime-trace-controls>button{width:27px;justify-content:center;padding:0}.runtime-trace-stage{grid-template-columns:68px minmax(0,1fr)} + .wlane-overview{grid-template-columns:1fr}.wlane-overview-stats{grid-template-columns:repeat(4,minmax(0,1fr))}.wlane-overview .wlane-spawn{justify-content:flex-start} + .activity-toolbar-title{display:none} .trace-ledger-head{grid-template-columns:28px 70px minmax(0,1fr);padding-right:8px}.trace-ledger-head span:last-child{display:none} + .trace-ledger .act-msg{grid-template-columns:28px 70px minmax(0,1fr) 14px;padding-inline:6px}.trace-ledger .act-time{display:none}.trace-ledger .act-kind{max-width:66px;padding-inline:5px} + .trace-ledger .act-group-head{grid-template-columns:28px 70px minmax(0,1fr) 14px;padding-inline:6px}.trace-ledger .act-tool-list{padding-left:98px} +} +@container runtimepanel (max-width:400px) { + .runtime-titlemark{width:34px;height:34px}.runtime-context{max-width:185px}.runtime-primary-nav button{min-width:62px;gap:5px;padding-inline:7px;font-size:10.5px}.runtime-view-nav button{padding-inline:7px} + .runtime-trace-head{padding-inline:8px}.runtime-trace-title span:not(.runtime-trace-live){display:none}.runtime-trace-controls>time{display:none}.runtime-trace-zoom{min-width:36px;padding-inline:6px}.runtime-trace-stage{grid-template-columns:61px minmax(0,1fr)}.runtime-trace-labels span{left:6px;right:5px;gap:4px}.wlane-overview-stats>div{padding-inline:8px}.wlane-overview-stats span{font-size:8.5px}.wlane-overview-stats b{font-size:14px} + .activity-toolbar{padding-inline:8px}.density-toggle{width:30px;height:30px;justify-content:center;padding:0}.density-toggle span{display:none} +} + :root[data-theme="dark"] { - --bg: #0f1724; - --rail: #131c2b; - --bg2: #131c2b; - --panel: #172235; - --panel2: #111a29; - --panel3: #1d2a40; - --term-bg: #0b1220; - --line: #27364d; - --line2: #3a4b68; - --border: #27364d; - --border2: #3a4b68; - --text: #d8e2f1; - --bright: #f7fbff; - --muted: #97a7bd; - --dim: #97a7bd; - - --blue: #60a5fa; - --accent: #60a5fa; - --green: #34d399; - --amber: #f59e0b; - --yellow: #f59e0b; - --cyan: #22d3ee; - --pink: #f472b6; - --violet: #a78bfa; - --magenta: #c084fc; - --red: #fb7185; - --gold: #fbbf24; - - --human-bg: #1e3a5f; - --human-border: #3b82f6; - --shadow-sm: 0 1px 2px rgba(0, 0, 0, .28); - --shadow-md: 0 8px 24px rgba(0, 0, 0, .34); - --shadow-pop: 0 16px 44px rgba(0, 0, 0, .46); - --ink-wash: rgba(255, 255, 255, .045); + --bg: #15181c; + --rail: #121519; + --bg2: #121519; + --panel: #1b1f24; + --panel2: #181c20; + --panel3: #22272d; + --term-bg: #101317; + --line: #292e34; + --line2: #353b43; + --border: #292e34; + --border2: #353b43; + --text: #dfe3e8; + --bright: #f0f2f5; + --muted: #9aa2ac; + --dim: #68717c; + + --blue: #7896bc; + --accent: #7896bc; + --green: #79a286; + --amber: #b49a70; + --yellow: #b49a70; + --cyan: #8297b0; + --pink: #8b909d; + --violet: #898f9e; + --magenta: #898f9e; + --red: #bd787d; + --gold: #b39a70; + + /* text on top of --accent fills (palette engine overrides per scheme/mode) */ + --on-accent: #ffffff; + + --human-bg: #1d252e; + --human-border: #354453; + --shadow-sm: 0 0 0 1px #30363d, 0 1px 2px #00000038; + --shadow-md: 0 0 0 1px #292e34, 0 1px 2px #0000002b; + --shadow-pop: 0 0 0 1px #353b43, 0 8px 24px #00000045; + --shadow-lg: var(--shadow-pop); + --ink-wash: rgba(255, 255, 255, .035); } * { box-sizing: border-box; } html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); - font: var(--fs-body, 13.5px)/1.6 var(--font-mono); + font: 14px/1.5 var(--font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +.login-gate { position: fixed; inset: 0; z-index: 9999; display: grid; place-items: center; background: var(--bg); color: var(--text); } +.login-gate-checking { color: var(--muted); font-size: 14px; } +.login-gate-card { display: flex; flex-direction: column; gap: 14px; width: 320px; padding: 28px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow-pop); } +.login-gate-copy { text-align: center; } +.login-gate-title { color: var(--bright); font-size: 17px; font-weight: 700; } +.login-gate-sub { margin-top: 4px; color: var(--muted); font-size: 13px; } +.login-gate-input { padding: 10px 12px; border: 1px solid var(--line2); border-radius: 9px; outline: none; color: var(--bright); background: var(--bg); font: inherit; font-size: 14px; caret-color: var(--accent); } +.login-gate-input::placeholder { color: var(--muted); opacity: 1; } +.login-gate-input:focus { border-color: color-mix(in srgb, var(--accent) 55%, var(--line2)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); } +.login-gate-input.error { border-color: var(--red); } +.login-gate-error { font-size: 12.5px; color: var(--red); } +.login-gate-submit { padding: 10px 12px; border: 0; border-radius: 9px; background: var(--accent); color: var(--on-accent, #fff); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; } +.login-gate-submit:disabled { opacity: .7; cursor: default; } +.login-gate-submit:not(:disabled):hover { filter: brightness(1.06); } + /* ── accessibility helpers (additive — no visual change in normal flow) ────── .sr-only hides content visually while keeping it in the accessibility tree (used for live-region status mirrors). .skip-link is visually hidden until it @@ -169,7 +352,7 @@ input, select, textarea { font: inherit; } .rail-resizer:focus-visible { border-radius: 6px; } body.rail-resizing, body.rail-resizing * { cursor: col-resize !important; user-select: none !important; } .newsolve { margin: 8px 12px 10px; display: flex; align-items: center; justify-content: center; gap: 8px; - background: var(--accent); color: #fff; border: 0; border-radius: 8px; padding: 9px; font-weight: 700; } + background: var(--accent); color: var(--on-accent, #fff); border: 0; border-radius: 8px; padding: 9px; font-weight: 700; } .newsolve:hover { filter: brightness(1.1); } .newsolve:disabled { opacity: .4; cursor: not-allowed; } /* rail run-search — narrows the loaded run list (name/category/status/id) client-side */ @@ -345,8 +528,31 @@ body.rail-resizing, body.rail-resizing * { cursor: col-resize !important; user-s .convo-top .rid-open { background: transparent; border: 0; color: var(--dim); cursor: pointer; font-size: 11px; line-height: 1; padding: 0; } .convo-top .rid-open:hover { color: var(--accent); } -/* global engine availability pills (FE-quota-display) */ -.engine-bar { display: inline-flex; align-items: center; gap: 6px; } +/* global engine availability — compact chip + hover/click list */ +.engine-bar { position: relative; display: inline-flex; flex: none; align-items: center; } +.engine-chip { + display: inline-flex; align-items: center; gap: 7px; min-height: 26px; + padding: 2px 8px 2px 7px; border: 1px solid var(--line2); border-radius: var(--r-pill); + background: var(--panel2); color: var(--muted); font: inherit; font-size: 10.5px; + font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; cursor: pointer; +} +.engine-chip:hover, .engine-bar.open .engine-chip { + color: var(--text); border-color: var(--line); background: var(--panel3); +} +.engine-bar.all-up .engine-chip { color: var(--text); } +.engine-bar.mixed .engine-chip { + color: var(--amber); border-color: color-mix(in srgb, var(--amber) 36%, var(--line2)); +} +.engine-bar.all-down .engine-chip { + color: var(--red); border-color: color-mix(in srgb, var(--red) 36%, var(--line2)); +} +.engine-cluster { display: inline-flex; align-items: center; gap: 3px; } +.engine-dot { width: 6px; height: 6px; border-radius: 99px; background: var(--dim); flex: none; } +.engine-dot.up { background: var(--green); } +.engine-dot.down { background: var(--red); } +.engine-dot.degraded { background: var(--amber); } +.engine-count { font-weight: 700; } +.engine-exception { max-width: 88px; overflow: hidden; text-overflow: ellipsis; font-weight: 650; } .engine-pill { display: inline-flex; align-items: center; gap: 4px; font-size: 10.5px; color: var(--dim); border: 1px solid var(--border2); border-radius: 999px; padding: 1px 7px 1px 5px; } .engine-pill .engine-dot { width: 6px; height: 6px; border-radius: 99px; background: var(--dim); } @@ -362,6 +568,18 @@ body.rail-resizing, body.rail-resizing * { cursor: col-resize !important; user-s .convo-top .runstate.paused { color: var(--amber); } .convo-top .runstate.done { color: var(--accent); } .convo-top .runstate.idle { color: var(--dim); } +.convo-top .control-receipt { max-width: 230px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; padding: 2px 7px; border: 1px solid var(--line2); + border-radius: var(--r-pill); color: var(--muted); background: var(--panel2); + font-size: 10.5px; font-variant-numeric: tabular-nums; } +.convo-top .control-receipt.status-effect_observed { color: var(--green); + border-color: color-mix(in srgb, var(--green) 36%, var(--line2)); } +.convo-top .control-receipt.status-failed, +.convo-top .control-receipt.status-rejected { color: var(--red); + border-color: color-mix(in srgb, var(--red) 36%, var(--line2)); } +.convo-top .control-receipt.status-unknown, +.convo-top .control-receipt.status-partial { color: var(--amber); + border-color: color-mix(in srgb, var(--amber) 36%, var(--line2)); } .convo-top .cost { color: var(--yellow); font-variant-numeric: tabular-nums; font-size: 12px; } .convo-top .dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; } .convo-top .dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); } @@ -370,10 +588,19 @@ body.rail-resizing, body.rail-resizing * { cursor: col-resize !important; user-s .convo-top .lang-btn { background: transparent; border: 1px solid var(--border2); color: var(--dim); border-radius: 6px; padding: 3px 9px; font-size: 11px; min-width: 30px; } .convo-top .lang-btn:hover { border-color: var(--accent); color: var(--bright); } +.convo-view-switch { flex:none; display:inline-flex; align-items:center; gap:3px; padding:3px; border:1px solid var(--line); border-radius:9px; background:var(--ink-wash); } +.convo-view-switch button { min-height:26px; display:inline-flex; align-items:center; gap:6px; padding:4px 9px; border:0; border-radius:6px; color:var(--muted); background:transparent; font:inherit; font-size:10.5px; font-weight:700; cursor:pointer; } +.convo-view-switch button:hover { color:var(--bright); background:color-mix(in srgb,var(--bright) 4%,transparent); } +.convo-view-switch button.on { color:var(--bright); background:var(--panel); box-shadow:var(--shadow-sm); } +.convo-view-switch button:focus-visible { outline:2px solid color-mix(in srgb,var(--accent) 42%,transparent); outline-offset:1px; } .convo-body { flex: 1; min-height: 0; min-width: 0; display: flex; align-items: stretch; position: relative; overflow: hidden; } .convo-mainpane { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; } .convo.has-inspector .convo-mainpane { max-width: calc(100% - var(--inspector-width, 360px)); } +.convo-runtime-peer { display:none; flex:1 1 auto; min-width:0; min-height:0; overflow:hidden; } +.convo.runtime-peer-open .convo-mainpane { display:none; } +.convo.runtime-peer-open .convo-runtime-peer { display:flex; } +.convo-runtime-peer>.artifact.workspace-mode { height:100%; } .convo-scroll { flex: 1; overflow: auto; min-height: 0; } .convo-scroll.has-workbench { overflow: hidden; } .thread-wrap { max-width: 720px; margin: 0 auto; padding: 22px 20px 8px; @@ -400,14 +627,13 @@ body.rail-resizing, body.rail-resizing * { cursor: col-resize !important; user-s .welcome .wm em { color: var(--accent); font-style: normal; font-weight: 600; font-size: 19px; margin-left: 2px; } .welcome .sub { color: var(--dim); font-size: 13px; line-height: 1.75; max-width: 540px; text-wrap: pretty; } -.welcome .sub code { color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, var(--bg2)); - padding: 1px 5px; border-radius: 4px; font-size: 12px; } .suggest-label { font-size: 10px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); font-weight: 700; } .suggest { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; margin-top: -8px; } .suggest button, .suggest-card { background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: 13px; padding: 14px 15px 13px; text-align: left; display: flex; flex-direction: column; gap: 5px; width: 204px; box-shadow: var(--shadow-sm); + font: inherit; cursor: pointer; transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease; } .suggest button:hover, .suggest-card:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--line2)); transform: translateY(-3px); box-shadow: var(--shadow-md); } @@ -515,7 +741,7 @@ body.rail-resizing, body.rail-resizing * { cursor: col-resize !important; user-s background: var(--bg); border: 1px solid var(--border2); border-radius: 7px; padding: 2px 6px; outline: none; } .advanced-select:focus { border-color: var(--accent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); } -.composer2 .send { background: var(--accent); color: #fff; border: 0; border-radius: 9px; +.composer2 .send { background: var(--accent); color: var(--on-accent, #fff); border: 0; border-radius: 9px; width: 34px; height: 34px; font-weight: 700; font-size: 15px; } .composer2 .send:disabled { opacity: .4; cursor: not-allowed; } /* file attachments (file-based tracks): attach button + chip row */ @@ -546,8 +772,8 @@ body.rail-resizing, body.rail-resizing * { cursor: col-resize !important; user-s border-radius: 14px; padding: 3px 12px; font-size: 12px; } .composer2 .quick button:hover { color: var(--bright); border-color: var(--accent); } /* 继续做题 — the primary post-finish action (relaunch the full swarm) */ -.composer2 .quick button.primary { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 500; } -.composer2 .quick button.primary:hover { filter: brightness(1.1); color: #fff; } +.composer2 .quick button.primary { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); font-weight: 500; } +.composer2 .quick button.primary:hover { filter: brightness(1.1); color: var(--on-accent, #fff); } /* 停止 / 标记误报 — destructive / state-changing actions, set apart */ .composer2 .quick button.danger { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--border2)); } .composer2 .quick button.danger:hover { color: var(--red); border-color: var(--red); background: color-mix(in srgb, var(--red) 8%, transparent); } @@ -567,7 +793,7 @@ body.rail-resizing, body.rail-resizing * { cursor: col-resize !important; user-s .composer2 .mode-seg button { background: transparent; color: var(--dim); border: 0; padding: 4px 12px; font-size: 12px; cursor: pointer; line-height: 1.4; } .composer2 .mode-seg button:hover { color: var(--bright); } -.composer2 .mode-seg button.on { background: var(--accent); color: #fff; font-weight: 600; } +.composer2 .mode-seg button.on { background: var(--accent); color: var(--on-accent, #fff); font-weight: 600; } .composer2 .pentest-fields { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } .composer2 .pf-input { width: 100%; background: var(--bg); border: 1px solid var(--border2); border-radius: 8px; padding: 6px 10px; color: var(--text); outline: none; font: inherit; font-size: 13px; } @@ -589,7 +815,7 @@ body.rail-resizing, body.rail-resizing * { cursor: col-resize !important; user-s .rc-state { margin-left: auto; font-size: 12px; display: inline-flex; align-items: center; gap: 5px; } .rc-state.live { color: var(--green); } .rc-state.done { color: var(--accent); } -.rc-flag { color: #fff; background: var(--gold); font-weight: 700; border-radius: 5px; padding: 2px 8px; font-size: 12px; } +.rc-flag { color: var(--on-accent, #fff); background: var(--gold); font-weight: 700; border-radius: 5px; padding: 2px 8px; font-size: 12px; } .rc-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1px; background: var(--border); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } .rc-stat { background: var(--panel2); padding: 8px 12px; } @@ -955,15 +1181,15 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important .btw-user .btw-msg-role { color: var(--accent); } .btw-assistant { background: color-mix(in srgb, var(--panel2) 50%, var(--panel)); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; } -.btw-error { color: #e4575a; font-size: 12px; line-height: 1.5; padding: 8px 10px; border-radius: 7px; - background: color-mix(in srgb, #e4575a 10%, var(--panel)); border: 1px solid color-mix(in srgb, #e4575a 30%, var(--border)); } +.btw-error { color: var(--red); font-size: 12px; line-height: 1.5; padding: 8px 10px; border-radius: 7px; + background: color-mix(in srgb, var(--red) 10%, var(--panel)); border: 1px solid color-mix(in srgb, var(--red) 30%, var(--border)); } .btw-input-row { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); background: var(--panel); } .btw-input { flex: 1; resize: none; background: var(--bg); border: 1px solid var(--border2); border-radius: 8px; color: var(--bright); font-size: 13px; line-height: 1.5; padding: 8px 10px; font-family: inherit; outline: none; min-height: 44px; max-height: 120px; } .btw-input:focus { border-color: var(--accent); } .btw-input:disabled { opacity: .55; } -.btw-send { background: var(--accent); color: #fff; border: 0; border-radius: 8px; padding: 0 16px; +.btw-send { background: var(--accent); color: var(--on-accent, #fff); border: 0; border-radius: 8px; padding: 0 16px; font-size: 12.5px; font-weight: 700; cursor: pointer; transition: opacity .12s ease; align-self: stretch; } .btw-send:disabled { opacity: .4; cursor: not-allowed; } .worker-settings { width: min(1040px, calc(100vw - 48px)); max-width: none; max-height: min(88vh, 820px); padding: 0; @@ -989,14 +1215,14 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important /* P2-v3 worker-image health panel */ .ws-image { display: flex; align-items: center; gap: 12px; } .ws-image-dot { flex: 0 0 auto; width: 10px; height: 10px; border-radius: 50%; background: var(--dim); } -.ws-image-dot.green { background: #2faf5b; box-shadow: 0 0 0 3px color-mix(in srgb, #2faf5b 22%, transparent); } -.ws-image-dot.yellow { background: #d6a32a; box-shadow: 0 0 0 3px color-mix(in srgb, #d6a32a 22%, transparent); } -.ws-image-dot.red { background: #d24b4b; box-shadow: 0 0 0 3px color-mix(in srgb, #d24b4b 22%, transparent); } +.ws-image-dot.green { background: var(--green); box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 22%, transparent); } +.ws-image-dot.yellow { background: var(--amber); box-shadow: 0 0 0 3px color-mix(in srgb, var(--amber) 22%, transparent); } +.ws-image-dot.red { background: var(--red); box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 22%, transparent); } .ws-image-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 4px; } .ws-image-name { font-size: 12px; color: var(--bright); word-break: break-all; } .ws-image-checks { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11.5px; } -.ws-image-checks .ok { color: #2faf5b; } -.ws-image-checks .bad { color: #d24b4b; } +.ws-image-checks .ok { color: var(--green); } +.ws-image-checks .bad { color: var(--red); } .ws-image-checks .muted { color: var(--dim); } .ws-image-actions { flex: 0 0 auto; display: flex; gap: 8px; } .ws-btn { padding: 5px 11px; border-radius: 6px; border: 1px solid var(--border2); background: var(--bg); @@ -1017,7 +1243,7 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important .ws-engines, .ws-engine-list { display: flex; flex-wrap: wrap; gap: 8px; } .ws-engine { flex: 0 1 auto; min-width: 118px; background: var(--bg); border: 1px solid var(--border2); color: var(--dim); border-radius: 8px; padding: 6px 10px; font-size: 13px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ws-engine.on { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; } +.ws-engine.on { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); font-weight: 600; } /* ── worker composer (P1 §10: drag out N same-engine instances) ───────────── */ .ws-add-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; } @@ -1040,8 +1266,8 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important .ws-card-engine { flex: 0 0 auto; min-width: 64px; text-align: center; padding: 3px 8px; border-radius: 6px; font-size: 11.5px; font-weight: 600; background: color-mix(in srgb, var(--accent) 14%, var(--panel)); color: var(--bright); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); } -.ws-card-engine.eng-codex { background: color-mix(in srgb, #10a37f 16%, var(--panel)); border-color: color-mix(in srgb, #10a37f 34%, transparent); } -.ws-card-engine.eng-cursor { background: color-mix(in srgb, #7c5cff 16%, var(--panel)); border-color: color-mix(in srgb, #7c5cff 34%, transparent); } +.ws-card-engine.eng-codex { background: color-mix(in srgb, var(--eng-codex) 16%, var(--panel)); border-color: color-mix(in srgb, var(--eng-codex) 34%, transparent); } +.ws-card-engine.eng-cursor { background: color-mix(in srgb, var(--eng-cursor) 16%, var(--panel)); border-color: color-mix(in srgb, var(--eng-cursor) 34%, transparent); } .ws-card-name { flex: 0 1 150px; min-width: 90px; background: var(--bg); border: 1px solid var(--border2); color: var(--text); border-radius: 6px; padding: 5px 7px; font-size: 12px; font-family: var(--mono); } .ws-card-model { flex: 1 1 200px; display: flex; gap: 6px; min-width: 150px; } @@ -1050,7 +1276,7 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important color: var(--text); border-radius: 6px; padding: 5px 7px; font-size: 12px; font-family: var(--mono); } .ws-card-race { flex: 0 0 auto; background: var(--bg); border: 1px solid var(--border2); color: var(--dim); border-radius: 6px; padding: 4px 9px; font-size: 11.5px; cursor: pointer; } -.ws-card-race.on { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; } +.ws-card-race.on { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); font-weight: 600; } .ws-card-cap { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 2px; color: var(--dim); font-size: 12px; } .ws-card-cap input { width: 42px; background: var(--bg); border: 1px solid var(--border2); color: var(--text); border-radius: 6px; padding: 4px 5px; font-size: 12px; font-variant-numeric: tabular-nums; } @@ -1118,7 +1344,7 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important .ws-status { font-size: 12px; color: var(--dim); } .ws-status.saved { color: var(--green); } .ws-status.error { color: var(--red); } -.ws-save { background: var(--accent); color: #fff; border: 0; border-radius: 9px; padding: 6px 16px; font-weight: 700; +.ws-save { background: var(--accent); color: var(--on-accent, #fff); border: 0; border-radius: 9px; padding: 6px 16px; font-weight: 700; cursor: pointer; } .ws-save:disabled { opacity: .5; cursor: not-allowed; } /* self-check (FE-healthcheck-page) */ @@ -1142,7 +1368,7 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important background: var(--bg, transparent); padding: 1px 5px; border-radius: 4px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ws-sc-pin { font-size: 10px; opacity: .8; flex: none; } -.ws-sc-binwarn { font-size: 10.5px; color: var(--amber, #c08400); font-weight: 600; cursor: help; } +.ws-sc-binwarn { font-size: 10.5px; color: var(--amber); font-weight: 600; cursor: help; } .ws-sc-dot { width: 8px; height: 8px; border-radius: 99px; flex: none; } .ws-sc-row.ok .ws-sc-dot { background: var(--green); box-shadow: 0 0 6px var(--green); } .ws-sc-row.bad .ws-sc-dot { background: var(--red); } @@ -1282,6 +1508,10 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important border-radius: 7px; padding: 5px 8px; font: inherit; font-size: 12px; color: var(--text); } .ws2-card .ws-card-model-custom { flex: 1 1 100px; min-width: 80px; background: var(--bg); border: 1px solid var(--border2); border-radius: 7px; padding: 5px 8px; font: inherit; font-size: 12px; color: var(--text); } +.ws2-card .ws-card-effort { flex: 0 1 132px; min-width: 105px; } +.ws2-card .ws-card-effort select { width: 100%; min-width: 0; background: var(--bg); border: 1px solid var(--border2); + border-radius: 7px; padding: 5px 8px; font: inherit; font-size: 12px; color: var(--text); } +.ws2-card .ws-card-effort select:disabled { color: var(--dim); cursor: not-allowed; } .ws2-card input:focus, .ws2-card select:focus { outline: none; border-color: var(--accent); } .ws2-ready { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; white-space: nowrap; flex: 0 0 auto; } .ws2-ready.g { color: var(--green); } @@ -1333,8 +1563,8 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important .ws2-steps li:last-child { border-bottom: 0; } .ws2-stepn { flex: 0 0 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; border: 1px solid var(--border2); color: var(--dim); } -.ws2-stepn.done { background: var(--green); color: #fff; border-color: var(--green); } -.ws2-stepn.now { background: var(--accent); color: #fff; border-color: var(--accent); } +.ws2-stepn.done { background: var(--green); color: var(--on-accent, #fff); border-color: var(--green); } +.ws2-stepn.now { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); } /* accounts tab */ .ws2-acct-list { display: flex; flex-direction: column; gap: 9px; margin-bottom: 14px; } @@ -1467,7 +1697,7 @@ body.artifact-resizing, body.artifact-resizing * { cursor: col-resize !important /* ---------- UX polish: consistent hover/focus transitions (FE-ux-polish) ---- */ .mode-seg button, .ws-engine, .ws-sc-btn, .ws-save, .worker-spawn-btn, .worker-kill, -.engine-pill, .rail-newfolder, .rail-archtoggle, .icon-btn, .websearch-toggle, +.engine-pill, .engine-chip, .rail-newfolder, .rail-archtoggle, .icon-btn, .websearch-toggle, .rail-folder-toggle, .pf-input, .bb-search, .graph-search, .bb-tool-btn { transition: color .12s ease, background-color .12s ease, border-color .12s ease, opacity .12s ease; } @@ -1528,10 +1758,12 @@ kbd { display: inline-flex; align-items: center; min-width: 18px; justify-conten .convo-top .rid { display: none; } } @media (max-width: 560px) { - /* below this the category tag + run-state label crowd the engine pills; + /* below this the category tag + run-state label crowd the engine chip; drop the secondary labels, keep menu/title/engines/conn/settings/lang */ .convo-top .cat, - .convo-top .runstate { display: none; } + .convo-top .runstate, + .convo-top .control-receipt { display: none; } + .engine-exception { display: none; } .convo-top { gap: 8px; } } @@ -1609,6 +1841,11 @@ kbd { display: inline-flex; align-items: center; min-width: 18px; justify-conten border-radius: 10px; padding: 11px 12px; box-shadow: 0 1px 3px rgba(0,0,0,.06); } .hitl-card.first { animation: hitlPulse 1.4s ease-out 2; } .hitl-card.sending { opacity: .7; } +.hitl-card.answered-readonly { border-color: var(--border2); border-left-color: var(--amber); } +.hitl-card.delivery-failed, +.hitl-card.delivery-unknown { border-left-color: var(--red); } +.hitl-card.delivery-partial, +.hitl-card.delivery-rejected { border-left-color: var(--amber); } /* F: an auto-resolving (non-blocking) card is informational — muted, no pulse */ .hitl-card.auto { border-color: var(--border2); border-left-color: var(--dim); } .hitl-card.auto.first { animation: none; } @@ -1620,15 +1857,30 @@ kbd { display: inline-flex; align-items: center; min-width: 18px; justify-conten .hitl-head .hitl-auto { font-size: 11px; color: var(--dim); font-weight: 600; } .hitl-card .body { margin: 6px 0 9px; color: var(--bright); } .hitl-opts { display: flex; flex-wrap: wrap; gap: 6px; align-items: stretch; } -.hitl-opts button { background: var(--accent); color: #fff; border: 0; border-radius: 6px; padding: 5px 11px; font-weight: 700; cursor: pointer; } +.hitl-opts button { background: var(--accent); color: var(--on-accent, #fff); border: 0; border-radius: 6px; padding: 5px 11px; font-weight: 700; cursor: pointer; } .hitl-opts button:hover:not(:disabled) { filter: brightness(1.06); } .hitl-opts button:disabled { opacity: .5; cursor: default; } .hitl-opts .hitl-free { flex: 1; min-width: 140px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 9px; } .hitl-opts .hitl-free:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 30%, transparent); } -.hitl-opts .hitl-send { background: var(--amber); color: #fff; font-weight: 800; } +.hitl-opts .hitl-send { background: var(--amber); color: var(--on-accent, #fff); font-weight: 800; } .hitl-opts .hitl-dismiss { background: transparent; color: var(--muted); border: 1px solid var(--border); font-weight: 600; border-radius: 6px; padding: 5px 11px; cursor: pointer; } .hitl-opts .hitl-dismiss:hover:not(:disabled) { color: var(--text); border-color: var(--muted); background: color-mix(in srgb, var(--muted) 8%, transparent); } .hitl-opts .hitl-dismiss:disabled { opacity: .5; cursor: default; } +.hitl-delivery-state { display: flex; align-items: flex-start; gap: 7px; margin-top: 8px; + padding: 8px 9px; border: 1px solid var(--border2); border-radius: 7px; + background: color-mix(in srgb, var(--panel2) 72%, transparent); color: var(--muted); + font-size: 12px; line-height: 1.45; } +.hitl-delivery-state svg { flex: none; margin-top: 1px; } +.hitl-delivery-copy { flex: 1; min-width: 0; } +.hitl-delivery-retry { flex: none; align-self: center; border: 1px solid currentColor; + border-radius: 6px; padding: 4px 8px; background: transparent; color: inherit; + font: inherit; font-weight: 700; cursor: pointer; } +.hitl-delivery-retry:hover:not(:disabled) { background: color-mix(in srgb, currentColor 9%, transparent); } +.hitl-delivery-retry:disabled { opacity: .5; cursor: default; } +.hitl-delivery-state.partial, +.hitl-delivery-state.rejected { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 42%, var(--border)); } +.hitl-delivery-state.failed, +.hitl-delivery-state.unknown { color: var(--red); border-color: color-mix(in srgb, var(--red) 42%, var(--border)); } .hitl-raw { margin: 2px 0 6px; font-size: 12px; color: var(--muted); } .hitl-raw summary { cursor: pointer; user-select: none; } .hitl-raw summary:hover { color: var(--text); } @@ -1690,36 +1942,31 @@ kbd { display: inline-flex; align-items: center; min-width: 18px; justify-conten .bb-worker-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bb-flagchip { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px; max-width: min(520px, 70vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: #fff; background: var(--gold); font-weight: 700; border-radius: 5px; padding: 2px 9px; font-size: 12px; } + color: var(--on-accent, #fff); background: var(--gold); font-weight: 700; border-radius: 5px; padding: 2px 9px; font-size: 12px; } .bb-avatar { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid; display: inline-flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; flex: none; background: var(--bg); line-height: 1; } /* the group card IS the intent now: a header (id/class/count) + a clamped goal, - then the child dots in the body below. Left accent bar = worker engine colour. */ + then the child dots in the body below. */ .bb-group-node { width: 100%; height: 100%; border-radius: 10px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--gc) 38%, var(--border)); background: var(--panel); - box-shadow: inset 4px 0 0 var(--gc), var(--shadow-sm); } + box-shadow: var(--shadow-sm); } .bb-group-node.done { opacity: .82; } .bb-group-node.resume { opacity: .6; } .bb-group-node.retired { opacity: .45; } -.bb-group-head { display: flex; align-items: center; gap: 6px; padding: 7px 10px 4px 14px; +.bb-group-head { display: flex; align-items: center; gap: 6px; padding: 7px 10px 4px; flex-wrap: wrap; } .bb-group-head .bb-iid { font-size: 11px; font-weight: 800; color: var(--bright); } -.bb-group-goal { padding: 0 12px 8px 14px; font-size: 11px; color: var(--muted); line-height: 1.4; +.bb-group-goal { padding: 0 12px 8px; font-size: 11px; color: var(--muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; } .bb-group-node.retired .bb-group-goal { text-decoration: line-through; } /* a sticky note (React Flow custom node) */ .bb-node { width: 184px; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; - padding: 8px 10px; font-size: 11.5px; color: var(--text); box-shadow: var(--shadow-sm); - border-top: 3px solid var(--nc, var(--border2)); } -/* worker identity bar: a 3px left stripe in the producing worker's engine colour - (--wc, set inline). Drawn as an inset shadow so it doesn't shift layout, and - pairs with the type's top border (--nc). Mirrors the fact-graph's worker colour - so "who produced this" reads the same across both views. */ -.bb-node[style*="--wc"] { box-shadow: inset 3px 0 0 var(--wc), var(--shadow-sm); } + padding: 8px 10px; font-size: 11.5px; color: var(--text); box-shadow: var(--shadow-sm); } +/* Worker colour stays on the avatar / glyph, not a left stripe. */ /* verified fact = light green fill (solid/confident); candidate = paper fill + dashed top border (hollow/unconfirmed) so the two read apart at a glance. */ .bb-node.fact.v { background: color-mix(in srgb, var(--green) 5%, var(--panel)); } @@ -1888,9 +2135,11 @@ kbd { display: inline-flex; align-items: center; min-width: 18px; justify-conten /* run-status hero band — always-visible phase + one key detail, above the metric strip. --sh-c is the per-phase accent (set by the .phase-* modifiers below). */ +.coord-col { container: coordcolumn / inline-size; } .status-hero-shell { position: relative; flex: none; z-index: 3; } -.status-hero { --sh-c: var(--muted); flex: none; display: flex; align-items: center; gap: 9px; - padding: 9px 16px; min-width: 0; font-size: 12.5px; +.status-hero { --sh-c: var(--muted); flex: none; min-width: 0; min-height: 64px; + display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; + padding: 10px 16px; font-size: 12.5px; border-bottom: 1px solid var(--line); background: linear-gradient(90deg, color-mix(in srgb, var(--sh-c) 9%, var(--panel)) 0%, @@ -1903,12 +2152,18 @@ kbd { display: inline-flex; align-items: center; min-width: 18px; justify-conten color-mix(in srgb, var(--sh-c) 5%, var(--panel)) 62%, var(--panel) 100%); box-shadow: inset 3px 0 0 var(--sh-c), inset 0 -1px 0 color-mix(in srgb, var(--sh-c) 25%, var(--line)); } .status-hero:focus-visible { outline: 2px solid color-mix(in srgb, var(--sh-c) 55%, transparent); outline-offset: -3px; } +.status-hero .sh-status { min-width: 0; display: inline-flex; align-items: center; gap: 7px; padding-right: 14px; + border-right: 1px solid color-mix(in srgb, var(--sh-c) 22%, var(--line)); } +.status-hero .sh-main { min-width: 0; display: grid; gap: 3px; } +.status-hero .sh-label { color: var(--muted); font-size: 9px; line-height: 1; font-weight: 800; + letter-spacing: .12em; text-transform: uppercase; } +.status-hero .sh-meta { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; } .status-hero .sh-ico { color: var(--sh-c); display: inline-flex; flex: none; } .status-hero .sh-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--sh-c); box-shadow: 0 0 0 0 color-mix(in srgb, var(--sh-c) 55%, transparent); } .status-hero .sh-phase { color: var(--sh-c); font-weight: 800; letter-spacing: .01em; flex: none; text-transform: uppercase; font-size: 11px; } -.status-hero .sh-detail { color: var(--text); min-width: 0; flex: 1 1 auto; +.status-hero .sh-detail { color: var(--text); min-width: 0; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; /* the detail line carries live counts ("3/5 online", "2/4 flags") — tabular digits keep it from reflowing a pixel each time a number ticks. */ @@ -1920,6 +2175,19 @@ kbd { display: inline-flex; align-items: center; min-width: 18px; justify-conten .status-hero .sh-flow { flex: none; display: inline-flex; align-items: center; gap: 4px; color: var(--wc, var(--sh-c)); border: 1px solid color-mix(in srgb, var(--sh-c) 32%, var(--line2)); border-radius: var(--r-pill); padding: 2px 8px; font-size: 11px; font-weight: 800; background: color-mix(in srgb, var(--sh-c) 7%, transparent); } +@container coordcolumn (max-width: 680px) { + .status-hero { grid-template-columns: auto minmax(0,1fr); gap: 8px 12px; padding-block: 11px; } + .status-hero .sh-status { grid-column: 1; grid-row: 1; } + .status-hero .sh-main { grid-column: 2; grid-row: 1; } + .status-hero .sh-meta { grid-column: 2; grid-row: 2; justify-content: flex-start; } +} +@container coordcolumn (max-width: 430px) { + .status-hero { grid-template-columns: minmax(0,1fr); } + .status-hero .sh-status { grid-column: 1; grid-row: auto; padding-right: 0; border-right: 0; } + .status-hero .sh-main { grid-column: 1; grid-row: auto; } + .status-hero .sh-meta { grid-column: 1; grid-row: auto; } + .status-hero .sh-workers { display:none; } +} .flow-popover { position: absolute; top: calc(100% + 8px); left: 14px; width: min(620px, calc(100vw - 42px)); z-index: 60; background: var(--panel); border: 1px solid var(--border2); border-radius: 10px; box-shadow: var(--shadow-lg); padding: 12px; color: var(--text); } @@ -1967,6 +2235,475 @@ kbd { display: inline-flex; align-items: center; min-width: 18px; justify-conten .status-hero.live .sh-dot { animation: none; } } +/* ========================================================================== */ +/* Worker orchestration settings — dedicated route */ +/* */ +/* Styled to the deck's own design language (rail / convo-top / suggest): */ +/* type 10 uppercase labels & latin meta · 10.5 tiny hints · */ +/* 11 secondary · 12.5 controls · 13.5 titles · 15 page title */ +/* radius 7 controls · 9 cards · 12 panels */ +/* active nav = soft panel bg + 3px left accent bar (thread-item pattern) */ +/* ========================================================================== */ +.wsettings-page { + --ws-fs-micro: 10px; + --ws-fs-hint: 10.5px; + --ws-fs-small: 11px; + --ws-fs-body: 12.5px; + --ws-fs-title: 13.5px; + --ws-fs-head: 15px; + --ws-r-sm: 7px; + --ws-r-md: 9px; + --ws-r-lg: 12px; + --ws-surface: color-mix(in srgb, var(--panel) 82%, var(--bg)); + --ws-raised: color-mix(in srgb, var(--panel2) 76%, var(--panel)); + --wsettings-content-max: 1040px; + --wsettings-content-pad: clamp(24px, 3vw, 44px); + width: 100vw; height: 100vh; min-width: 0; display: grid; + grid-template-columns: 232px minmax(0, 1fr); overflow: hidden; + color: var(--text); background: var(--bg); font-family: var(--font-sans); +} +.wsettings-page button, .wsettings-page input, .wsettings-page select, +.wsettings-page textarea { font-family: var(--font-sans); } + +/* ── sidebar (mirrors deck rail) ─────────────────────────────────────────── */ +.wsettings-nav { + min-width: 0; display: flex; flex-direction: column; padding: 14px 10px 12px; + border-right: 1px solid var(--line); background: var(--rail); +} +.wsettings-brand { + display: flex; align-items: baseline; gap: 6px; padding: 2px 8px 14px; + color: var(--bright); text-decoration: none; +} +.wsettings-brand strong { font-size: 14px; font-weight: 800; letter-spacing: .02em; } +.wsettings-brand span { color: var(--accent); font-size: var(--ws-fs-small); } +.wsettings-nav-title { display: grid; gap: 2px; padding: 12px 10px 6px; border-top: 1px solid var(--line); } +.wsettings-nav-title span { color: var(--dim); font-size: var(--ws-fs-micro); font-weight: 700; letter-spacing: var(--ls-label); text-transform: uppercase; } +.wsettings-nav-title strong { color: var(--bright); font-size: 13px; font-weight: 650; } +.wsettings-nav nav { display: grid; gap: 2px; padding-top: 4px; } +.wsettings-nav nav button { + position: relative; width: 100%; min-height: 46px; display: grid; + grid-template-columns: 22px minmax(0, 1fr) 12px; align-items: center; gap: 9px; + padding: 6px 10px; border: 1px solid transparent; border-radius: 8px; + color: var(--muted); background: transparent; text-align: left; + transition: color .15s ease, background-color .16s ease; +} +.wsettings-nav nav button:hover { color: var(--text); background: var(--panel); } +.wsettings-nav nav button.on { color: var(--bright); background: var(--panel); } +.wsettings-nav nav button.on::before { + content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; + border-radius: 0 3px 3px 0; background: var(--accent); +} +.wsettings-nav nav button.on > svg:first-child { color: var(--accent); } +.wsettings-nav nav button > span { min-width: 0; display: grid; gap: 2px; } +.wsettings-nav nav button strong { overflow: hidden; text-overflow: ellipsis; font-size: var(--ws-fs-body); font-weight: 600; white-space: nowrap; } +.wsettings-nav nav button small { overflow: hidden; text-overflow: ellipsis; color: var(--dim); font-size: var(--ws-fs-micro); white-space: nowrap; } +.wsettings-nav nav button > svg:last-child { color: var(--dim); transition: transform .18s var(--ease-out-soft), color .15s ease; } +.wsettings-nav nav button:hover > svg:last-child, .wsettings-nav nav button.on > svg:last-child { color: var(--text); transform: translateX(2px); } +.wsettings-nav-foot { margin-top: auto; display: grid; gap: 8px; padding: 10px 8px 0; border-top: 1px solid var(--line); } +.wsettings-nav-foot span, .wsettings-nav-foot a { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: var(--ws-fs-small); } +.wsettings-nav-foot span i { width: 6px; height: 6px; border-radius: 50%; background: var(--green); } +.wsettings-nav-foot a { color: var(--text); text-decoration: none; transition: color .15s ease; } +.wsettings-nav-foot a:hover { color: var(--bright); } +.wsettings-nav-foot a svg { transform: rotate(180deg); } + +/* ── topbar (mirrors deck convo-top) ─────────────────────────────────────── */ +.wsettings-main { min-width: 0; min-height: 0; position: relative; display: flex; flex-direction: column; overflow: hidden; } +.wsettings-topbar { + flex: none; min-height: 50px; display: flex; align-items: center; gap: 12px; + padding: 8px 16px 8px 20px; border-bottom: 1px solid var(--line); background: var(--bg); +} +.wsettings-topbar > div:first-child { min-width: 0; display: flex; align-items: baseline; gap: 8px; } +.wsettings-topbar > div:first-child > span { color: var(--dim); font-size: var(--ws-fs-micro); white-space: nowrap; } +.wsettings-topbar h1 { margin: 0; color: var(--bright); font-size: var(--ws-fs-head); font-weight: 650; line-height: 1.2; letter-spacing: -.01em; white-space: nowrap; } +.wsettings-draft { + min-height: 21px; display: inline-flex; align-items: center; padding: 0 8px; + border: 1px solid var(--line); border-radius: 6px; color: var(--muted); background: var(--panel2); + font-size: var(--ws-fs-micro); white-space: nowrap; +} +.wsettings-draft.dirty { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 35%, var(--line)); } +.wsettings-top-actions { margin-left: auto; display: flex; align-items: center; gap: 7px; } +.wsettings-top-actions button, .wflow-toolbar button, .wflow-account-state button, +.wsettings-editor-actions button, .wsettings-subhead button, .wsettings-test-button { + min-height: 31px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 0 11px; border: 1px solid var(--line2); border-radius: var(--ws-r-sm); color: var(--text); + background: var(--panel); box-shadow: var(--shadow-sm); font-size: var(--ws-fs-body); font-weight: 600; + transition: color .15s ease, border-color .15s ease, background-color .15s ease, + box-shadow .18s var(--ease-out-soft), transform .14s var(--ease-out-soft); +} +.wsettings-top-actions button:hover, .wflow-toolbar button:hover, .wflow-account-state button:hover, +.wsettings-editor-actions button:hover, .wsettings-subhead button:hover, .wsettings-test-button:hover { + color: var(--bright); border-color: color-mix(in srgb, var(--accent) 45%, var(--line2)); + background: color-mix(in srgb, var(--accent) 6%, var(--panel)); +} +.wsettings-top-actions button:active, .wflow-toolbar button:active, .wflow-account-state button:active, +.wsettings-editor-actions button:active, .wsettings-subhead button:active, .wsettings-test-button:active { transform: scale(.98); } +.wsettings-top-actions button.primary, .wsettings-editor-actions button.primary { + color: var(--bg); border-color: var(--bright); background: var(--bright); box-shadow: none; +} +.wsettings-top-actions button.primary:hover, .wsettings-editor-actions button.primary:hover { + color: var(--bg); border-color: var(--bright); background: color-mix(in srgb, var(--bright) 88%, var(--muted)); +} +.wsettings-top-actions button:disabled, .wsettings-editor-actions button:disabled { opacity: .42; cursor: default; transform: none; } +.wsettings-content { flex: 1; min-height: 0; min-width: 0; overflow: hidden; background: var(--panel2); } +.wsystem-page { width: min(940px, 100%); height: 100%; margin: 0 auto; padding: 26px var(--wsettings-content-pad) 40px; overflow-y: auto; } +.wsystem-page .wsettings-section-head { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; } +.wsystem-page .wsettings-section-copy { min-width: 0; flex: 1; } +.wsystem-check, .wsystem-actions button { + min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; + padding: 0 13px; border: 1px solid var(--line2); border-radius: var(--ws-r-sm); color: var(--text); + background: var(--panel); box-shadow: var(--shadow-sm); font-size: var(--ws-fs-body); font-weight: 600; +} +.wsystem-check:hover, .wsystem-actions button:hover { color: var(--bright); border-color: color-mix(in srgb, var(--accent) 45%, var(--line2)); } +.wsystem-check:disabled, .wsystem-actions button:disabled { opacity: .42; cursor: default; } +.wsystem-overview { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.wsystem-version-card, .wsystem-state, .wsystem-progress, .wsystem-notice, .wsystem-actions, .wsystem-command { + border: 1px solid var(--line); border-radius: var(--ws-r-lg); background: var(--panel); +} +.wsystem-version-card { min-height: 126px; display: grid; align-content: center; gap: 6px; padding: 18px 20px; } +.wsystem-version-card > span { color: var(--muted); font-size: var(--ws-fs-small); } +.wsystem-version-card strong { color: var(--bright); font: 700 28px/1.1 var(--mono); letter-spacing: -.035em; } +.wsystem-version-card small { color: var(--dim); font-size: var(--ws-fs-small); } +.wsystem-version-card.available { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); background: color-mix(in srgb, var(--accent) 5%, var(--panel)); } +.wsystem-version-card.available strong { color: var(--accent); } +.wsystem-state { grid-column: 1 / -1; min-height: 64px; display: flex; align-items: center; gap: 12px; padding: 12px 16px; } +.wsystem-state > i { width: 9px; height: 9px; flex: none; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 14%, transparent); } +.wsystem-state.attention > i { background: var(--amber); box-shadow: 0 0 0 4px color-mix(in srgb, var(--amber) 14%, transparent); } +.wsystem-state.bad > i { background: var(--red); box-shadow: 0 0 0 4px color-mix(in srgb, var(--red) 14%, transparent); } +.wsystem-state span, .wsystem-notice span { display: grid; gap: 3px; min-width: 0; } +.wsystem-state strong, .wsystem-notice strong { color: var(--bright); font-size: var(--ws-fs-body); } +.wsystem-state small, .wsystem-notice small { color: var(--muted); font-size: var(--ws-fs-small); overflow-wrap: anywhere; } +.wsystem-progress { display: grid; gap: 9px; margin-top: 10px; padding: 13px 16px; } +.wsystem-progress > div { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: var(--ws-fs-small); } +.wsystem-progress progress { width: 100%; height: 6px; overflow: hidden; border: 0; border-radius: 99px; background: var(--line); accent-color: var(--accent); } +.wsystem-notice { display: flex; align-items: center; gap: 11px; margin-top: 10px; padding: 12px 15px; border-color: color-mix(in srgb, var(--amber) 35%, var(--line)); background: color-mix(in srgb, var(--amber) 6%, var(--panel)); color: var(--amber); } +.wsystem-notice.error { border-color: color-mix(in srgb, var(--red) 35%, var(--line)); background: color-mix(in srgb, var(--red) 6%, var(--panel)); color: var(--red); } +.wsystem-actions { min-height: 86px; display: flex; align-items: center; gap: 20px; margin-top: 14px; padding: 15px 17px; } +.wsystem-actions > div { min-width: 0; flex: 1; display: grid; gap: 5px; } +.wsystem-actions h3 { margin: 0; color: var(--bright); font-size: var(--ws-fs-title); } +.wsystem-actions p { margin: 0; color: var(--muted); font-size: var(--ws-fs-small); line-height: 1.5; overflow-wrap: anywhere; } +.wsystem-actions button.primary { color: var(--bg); border-color: var(--bright); background: var(--bright); } +.wsystem-actions.secondary { margin-top: 8px; } +.wsystem-command { display: flex; align-items: center; gap: 9px; margin-top: 14px; padding: 12px 15px; color: var(--muted); font-size: var(--ws-fs-small); } +.wsystem-command code { padding: 4px 7px; border: 1px solid var(--line); border-radius: 5px; color: var(--bright); background: var(--panel2); font-family: var(--mono); } +.wsettings-page[data-theme="light"] .wsystem-actions button.primary { color: var(--on-accent, #fff); } +@media (max-width: 760px) { + .wsystem-overview { grid-template-columns: 1fr; } + .wsystem-state { grid-column: auto; } + .wsystem-actions { align-items: stretch; flex-direction: column; } + .wsystem-actions button { width: 100%; } + .wsystem-command { flex-wrap: wrap; } +} +.wsettings-orchestration { width: 100%; height: 100%; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 380px; } + +.wflow-canvas-shell { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--panel2); } +.wflow-canvas-head { + flex: none; min-height: 58px; display: flex; align-items: center; gap: 12px; + padding: 8px 13px 8px 18px; border-bottom: 1px solid var(--line); background: var(--panel); +} +.wflow-canvas-head > div:first-child { display: grid; gap: 1px; } +.wflow-canvas-head strong { color: var(--bright); font-size: 14px; } +.wflow-canvas-head span { color: var(--muted); font-size: 10.5px; } +.wflow-toolbar { margin-left: auto; display: flex; gap: 6px; } +.wflow-toolbar button { min-height: 31px; padding: 0 10px; font-size: 10.5px; box-shadow: none; } +.wflow-canvas-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: 140px minmax(0, 1fr); } +.wflow-library { z-index: 4; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--panel); } +.wflow-library > strong { min-height: 38px; display: flex; align-items: center; padding: 0 13px; color: var(--muted); border-bottom: 1px solid var(--line); font-size: 10.5px; letter-spacing: .05em; } +.wflow-library button { + min-height: 53px; display: grid; grid-template-columns: 23px minmax(0, 1fr) 12px; + align-items: center; gap: 7px; padding: 0 12px; border: 0; border-bottom: 1px solid var(--line); + color: var(--muted); background: transparent; text-align: left; font-size: 11.5px; + transition: color .16s ease, background-color .18s ease, padding-left .2s var(--ease-out-soft); +} +.wflow-library button:hover { color: var(--bright); background: var(--panel2); padding-left: 15px; } +.wflow-library button svg:last-child { color: var(--dim); } +.wflow-stage { min-width: 0; min-height: 0; position: relative; background: var(--panel2); } +.wflow-stage .react-flow { background: var(--panel2); } +.wflow-stage .react-flow__background { color: color-mix(in srgb, var(--line2) 70%, transparent); } +.wflow-stage .react-flow__edge-path { stroke: color-mix(in srgb, var(--muted) 58%, var(--line)); } +.wflow-stage .react-flow__edge.selected .react-flow__edge-path { stroke: var(--accent); } +.wflow-stage .react-flow__minimap { + width: 130px; height: 78px; margin: 13px; overflow: hidden; border: 1px solid var(--line2); + border-radius: 8px; background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: var(--shadow-md); +} +.wflow-stage .react-flow__minimap-mask { fill: color-mix(in srgb, var(--accent) 12%, transparent); } +.wflow-stage .react-flow__minimap-node { fill: var(--dim); } +.wflow-stage .react-flow__node { border: 0; background: transparent; } +.wflow-stage .react-flow__node:focus-visible { outline: none; } +.wflow-stage .react-flow__attribution { display: none; } +.wflow-gesture-hint { + position: absolute; z-index: 6; left: 50%; bottom: 14px; transform: translateX(-50%); + padding: 6px 10px; border: 1px solid var(--line); border-radius: 7px; + color: var(--dim); background: color-mix(in srgb, var(--panel) 90%, transparent); + box-shadow: var(--shadow-sm); backdrop-filter: blur(12px); font-size: 9px; white-space: nowrap; +} + +.wflow-node { + width: 222px; overflow: hidden; border: 1px solid var(--line2); border-radius: 10px; + color: var(--text); background: var(--panel); box-shadow: 0 5px 20px #00000016, inset 0 1px 0 var(--ink-wash); + transition: border-color .2s var(--ease-out-soft), box-shadow .22s var(--ease-out-soft), + transform .2s var(--ease-out-soft), opacity .2s ease; +} +.wflow-node:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--accent) 42%, var(--line2)); box-shadow: 0 10px 28px #00000022; } +.wflow-node.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 12px 32px #00000030; } +.wflow-node.disabled { opacity: .55; filter: saturate(.45); } +.wflow-node-head { height: 38px; display: grid; grid-template-columns: 17px minmax(0, 1fr) 25px; align-items: center; gap: 6px; padding: 0 8px 0 10px; border-bottom: 1px solid var(--line); } +.wflow-node-head strong { overflow: hidden; text-overflow: ellipsis; color: var(--bright); font-size: 12.5px; white-space: nowrap; } +.wflow-drag { color: var(--dim); cursor: grab; } +.wflow-icon-btn { width: 24px; height: 24px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px; color: var(--muted); background: transparent; } +.wflow-icon-btn:hover { color: var(--bright); background: var(--panel3); } +.wflow-role { display: block; padding: 8px 11px 5px; color: var(--muted); font-size: 10.5px; } +.wflow-node-meta { display: grid; gap: 4px; margin: 0; padding: 3px 11px 9px; } +.wflow-node-meta div { min-width: 0; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 7px; align-items: center; } +.wflow-node-meta dt { color: var(--dim); font-size: 10px; } +.wflow-node-meta dd { margin: 0; overflow: hidden; text-overflow: ellipsis; color: var(--text); font: 10.5px/1.35 var(--font-mono); white-space: nowrap; } +.wflow-node-meta dd.account { width: fit-content; max-width: 100%; padding: 2px 5px; border-radius: 4px; color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } +.wflow-node-foot { height: 31px; display: flex; align-items: center; justify-content: space-between; padding: 0 11px; border-top: 1px solid var(--line); } +.wflow-node-foot span { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; } +.wflow-node-foot span i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.wflow-node-foot span.ok { color: var(--green); } +.wflow-node-foot span.off { color: var(--red); } +.wflow-node-foot code { color: var(--dim); font: 9px/1 var(--font-mono); } +.wflow-handle { width: 9px !important; height: 9px !important; border: 2px solid var(--panel) !important; background: var(--muted) !important; } +.wflow-node.selected .wflow-handle { background: var(--accent) !important; box-shadow: 0 0 0 1px var(--accent); } +.wflow-boundary { + min-width: 78px; min-height: 78px; display: grid; place-content: center; justify-items: center; gap: 6px; + padding: 10px; border: 1px solid var(--line2); border-radius: 10px; color: var(--muted); background: var(--panel); + box-shadow: var(--shadow-sm); +} +.wflow-boundary svg { color: var(--accent); } +.wflow-boundary strong { font-size: 10.5px; } + +.wflow-inspector, .wsettings-editor { + min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: auto; + border-left: 1px solid var(--line); background: var(--bg); +} +.wflow-inspector.empty { align-items: center; justify-content: center; gap: 7px; padding: 24px; color: var(--dim); text-align: center; } +.wflow-inspector.empty strong { color: var(--text); font-size: 12px; } +.wflow-inspector.empty span { max-width: 220px; font-size: 10px; } +.wflow-inspector-head { flex: none; display: grid; gap: 4px; padding: 15px 16px 13px; border-bottom: 1px solid var(--line); } +.wflow-inspector-head > span { color: var(--dim); font-size: 10px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } +.wflow-inspector-head > strong { overflow: hidden; text-overflow: ellipsis; color: var(--bright); font-size: 16px; white-space: nowrap; } +.wflow-inspector-head > p { margin: 2px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; } +.wflow-binding-line { min-width: 0; display: flex; align-items: center; gap: 8px; } +.wflow-binding-line code { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font: 10.5px/1.4 var(--font-mono); white-space: nowrap; } +.wflow-binding-line em { flex: none; display: inline-flex; align-items: center; gap: 4px; color: var(--amber); font-size: 9px; font-style: normal; } +.wflow-binding-line em i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.wflow-binding-line em.ok { color: var(--green); } +.wflow-form-section { display: grid; gap: 9px; padding: 13px 16px; border-bottom: 1px solid var(--line); } +.wflow-form-section h3 { margin: 0 0 2px; color: var(--text); font-size: 11.5px; } +.wflow-form-section label { min-width: 0; display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 8px; } +.wflow-form-section label > span, .wflow-role-field > span { color: var(--muted); font-size: 10.5px; } +.wflow-form-section input, .wflow-form-section select, .wflow-form-section textarea { + min-width: 0; width: 100%; min-height: 33px; padding: 6px 9px; border: 1px solid var(--line2); + border-radius: 7px; outline: 0; color: var(--text); background: var(--panel); + font-size: 11.5px; transition: border-color .16s ease, box-shadow .16s ease, background-color .16s ease; +} +.wflow-form-section textarea { resize: vertical; font-family: var(--font-mono); line-height: 1.45; } +.wflow-form-section input:focus, .wflow-form-section select:focus, .wflow-form-section textarea:focus { + border-color: color-mix(in srgb, var(--accent) 65%, var(--line2)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent); +} +.wflow-role-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: start; gap: 8px; } +.wflow-role-field > span { padding-top: 5px; } +.wflow-role-field > div { display: flex; flex-wrap: wrap; gap: 5px; } +.wflow-role-field button { min-height: 26px; padding: 0 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); background: transparent; font-size: 10px; } +.wflow-role-field button:hover { color: var(--text); border-color: var(--line2); } +.wflow-role-field button.on { color: var(--bright); border-color: color-mix(in srgb, var(--accent) 40%, var(--line2)); background: color-mix(in srgb, var(--accent) 9%, var(--panel)); } +.wflow-account-state { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-left: 94px; } +.wflow-account-state > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--dim); font-size: 10px; white-space: nowrap; } +.wflow-account-state button { min-height: 27px; padding-inline: 8px; box-shadow: none; font-size: 9px; } +.wflow-switch-row > span { display: grid; gap: 1px; } +.wflow-switch-row > span b { color: var(--text); font-size: 10px; } +.wflow-switch-row > span small { color: var(--dim); font-size: 8.5px; } +.wflow-switch { justify-self: end; width: 34px; height: 19px; position: relative; padding: 0; border: 1px solid var(--line2); border-radius: 999px; background: var(--panel3); } +.wflow-switch i { position: absolute; top: 3px; left: 3px; width: 11px; height: 11px; border-radius: 50%; background: var(--muted); transition: transform .2s var(--ease-out-soft), background-color .2s ease; } +.wflow-switch.on { border-color: color-mix(in srgb, var(--accent) 50%, var(--line2)); background: color-mix(in srgb, var(--accent) 22%, var(--panel3)); } +.wflow-switch.on i { transform: translateX(15px); background: var(--accent); } +.wflow-inspector-actions { margin-top: auto; flex: none; display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; padding: 12px; border-top: 1px solid var(--line); background: var(--panel2); } +.wflow-inspector-actions button { + min-width: 0; min-height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; + padding: 0 7px; border: 1px solid var(--line2); border-radius: 7px; color: var(--muted); background: var(--panel); font-size: 9px; +} +.wflow-inspector-actions button:hover { color: var(--bright); background: var(--panel3); } +.wflow-inspector-actions button.danger { color: var(--red); } + +.wsettings-subworkspace { width: 100%; height: 100%; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 400px; } +.wsettings-resource-list, .wsettings-runtime-stage { min-width: 0; overflow: auto; padding: 20px clamp(16px, 3vw, 32px); background: var(--panel2); } +.wsettings-subhead { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; } +.wsettings-subhead > div { display: grid; gap: 2px; } +.wsettings-subhead strong { color: var(--bright); font-size: var(--ws-fs-title); font-weight: 650; } +.wsettings-subhead span { color: var(--muted); font-size: var(--ws-fs-small); } +.wsettings-subhead button { min-height: 30px; font-size: var(--ws-fs-small); } +.wsettings-account-list { display: grid; gap: 7px; max-width: 820px; } +.wsettings-account-list > button { + width: 100%; min-height: 58px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto 14px; + align-items: center; gap: 9px; padding: 8px 11px; border: 1px solid var(--line); border-radius: var(--ws-r-md); + color: var(--muted); background: var(--panel); text-align: left; + transition: border-color .16s ease, background-color .16s ease; +} +.wsettings-account-list > button:hover { border-color: var(--line2); background: var(--panel3); } +.wsettings-account-list > button.on { border-color: color-mix(in srgb, var(--accent) 60%, var(--line2)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent); } +.wsettings-account-icon { width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); } +.wsettings-account-icon .engine-logo { color: var(--text); } +.wsettings-account-list > button > span:nth-child(2) { min-width: 0; display: grid; gap: 2px; } +.wsettings-account-list strong { overflow: hidden; text-overflow: ellipsis; color: var(--text); font-size: var(--ws-fs-body); white-space: nowrap; } +.wsettings-account-list small { overflow: hidden; text-overflow: ellipsis; color: var(--dim); font-size: var(--ws-fs-micro); white-space: nowrap; } +.wsettings-account-list em { display: inline-flex; align-items: center; gap: 5px; color: var(--amber); font-size: var(--ws-fs-micro); font-style: normal; } +.wsettings-account-list em i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.wsettings-account-list em.ok { color: var(--green); } +.wsettings-editor .wflow-form-section { padding-top: 17px; } +.wsettings-result { margin: 10px 14px 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--ws-r-sm); color: var(--muted); background: var(--panel2); font-size: var(--ws-fs-small); } +.wsettings-result.ok { color: var(--green); border-color: color-mix(in srgb, var(--green) 30%, var(--line)); } +.wsettings-result.bad { color: var(--red); border-color: color-mix(in srgb, var(--red) 30%, var(--line)); } +.wsettings-editor-actions { margin-top: auto; display: flex; justify-content: flex-end; gap: 7px; padding: 10px 14px; border-top: 1px solid var(--line); background: var(--panel2); } +.wsettings-editor-actions > span { flex: 1; } +.wsettings-editor-actions button.danger { margin-right: auto; color: var(--red); } + +.wsettings-feedback { position: absolute; z-index: 30; left: 50%; bottom: 20px; transform: translateX(-50%); display: inline-flex; align-items: center; gap: 7px; padding: 8px 13px; border: 1px solid var(--line2); border-radius: var(--ws-r-md); color: var(--text); background: color-mix(in srgb, var(--panel) 94%, transparent); box-shadow: var(--shadow-pop); backdrop-filter: blur(16px); font-size: var(--ws-fs-small); animation: motion-rise .24s var(--ease-out-soft) both; } +.wsettings-feedback svg { color: var(--green); } +.wsettings-loading { width: 100vw; height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--muted); background: var(--bg); } +.wsettings-loading strong { font-size: var(--ws-fs-body); } + +/* ============================================================================ + BEAUTIFUL UI ALIGNMENT + Unified shell primitives for the run list, conversation, composer, overview, + and runtime detail workspaces. Interaction ownership remains in React; this + layer replaces the former mixed navy/terminal presentation with the same flat + surfaces, compact controls, hairline shadows, and restrained accent use as the + selected AI-interface reference. + ============================================================================ */ + +html, body { letter-spacing: -.006em; } +code, pre, kbd, .rid, .artifact-code, .runtime-context, .runtime-trace-labels, +.runtime-trace-scale, .trace-ledger, .act-msg, +.insp-lock-key { font-family: var(--font-mono); } + +.shell { background: var(--bg); } +.rail { background: var(--rail); border-right: 1px dashed var(--line2); } +.rail-top { padding: 18px 16px 10px; } +.rail .brand { font-size: 14px; font-weight: 650; letter-spacing: -.02em; } +.rail .brand em { color: var(--muted); font-size: 11px; font-weight: 500; letter-spacing: .03em; } +.newsolve { margin: 8px 12px 10px; min-height: 38px; padding: 8px 11px; justify-content: flex-start; + color: var(--text); background: var(--panel); border: 0; border-radius: 8px; + box-shadow: var(--shadow-sm); font-size: 12.5px; font-weight: 550; } +.newsolve:hover { filter: none; background: var(--panel3); } +.rail-search-input { min-height: 36px; background: var(--panel2); border-color: transparent; + border-radius: 8px; box-shadow: inset 0 1px 2px color-mix(in srgb, #000 18%, transparent); } +.rail-search-input:focus { border-color: var(--line2); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent); } +.rail-sec { padding: 13px 16px 5px; color: var(--dim); font-size: 9.5px; font-weight: 650; letter-spacing: .08em; } +.thread { gap: 3px; } +.thread-item { min-height: 44px; padding: 7px 8px; border: 0; border-radius: 7px; } +.thread-item:hover { background: var(--panel2); } +.thread-item.active { background: var(--panel3); box-shadow: inset 0 0 0 1px var(--line); } +.thread-item.active::before { display: none; } +.thread-item .nm { font-size: 12.5px; font-weight: 520; } +.thread-item .sub .ct, .thread-item .sub .st, .thread-item .sub .when { border-color: transparent; background: transparent; } +.rail-folder-toggle { border-radius: 7px; padding: 5px 4px; } +.rail-folder-toggle:hover { background: var(--panel2); } +.rail-foot { padding: 12px; border-top: 1px dashed var(--line2); background: transparent; } +.rail-settings-btn { border-color: transparent; background: transparent; } + +.main, .convo, .artifact { background: var(--bg); } +.convo-top { min-height: 58px; padding: 10px 18px; gap: 10px; border-bottom: 1px dashed var(--line2); background: var(--bg); } +.convo-top .title { font-size: 13.5px; font-weight: 620; } +.convo-top .cat, .convo-top .rid { color: var(--dim); border-color: var(--line); border-radius: 6px; background: var(--panel2); } +.icon-btn, .convo-top .lang-btn { border-color: transparent; background: transparent; box-shadow: none; } +.icon-btn:hover, .convo-top .lang-btn:hover { color: var(--text); border-color: transparent; background: var(--panel3); } +.engine-pill { border: 0; padding-inline: 3px; background: transparent; } +.engine-pill .engine-dot, .convo-top .dot { box-shadow: none !important; } +.convo-view-switch { padding: 3px; border-color: var(--line); border-radius: 8px; background: var(--panel2); } +.convo-view-switch button { min-height: 28px; padding: 4px 10px; border-radius: 6px; font-size: 11.5px; font-weight: 550; } +.convo-view-switch button.on { background: var(--panel); box-shadow: var(--shadow-sm); } + +.coord-col { background: var(--bg); } +.status-hero-shell { margin: 16px 20px 10px; } +.status-hero { min-height: 68px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; + background: var(--panel); box-shadow: var(--shadow-md); } +.status-hero:hover, .status-hero.open { background: var(--panel3); box-shadow: var(--shadow-md); } +.status-hero .sh-status { border-right-color: var(--line); } +.status-hero .sh-label { color: var(--dim); font-size: 9px; font-weight: 650; letter-spacing: .09em; } +.status-hero .sh-phase { font-size: 11.5px; font-weight: 620; text-transform: none; } +.status-hero .sh-detail { font-size: 13px; } +.status-hero .sh-flow { color: var(--muted); border-color: var(--line2); border-radius: 7px; + padding: 4px 8px; background: var(--panel2); font-weight: 550; } +.status-hero .sh-flow:hover { color: var(--text); background: var(--panel3); } +.status-hero .sh-dot { width: 7px; height: 7px; box-shadow: none; } +.status-hero.live .sh-dot { animation: none; } +.flow-popover { border-color: var(--line2); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-pop); } +.flow-step { border-radius: 8px; background: var(--panel2); } + +.quiet-meta { margin: 0 20px 10px; gap: 0; overflow: hidden; border: 1px solid var(--line); + border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-md); } +.qm { min-height: 58px; padding: 9px 12px; justify-content: center; gap: 4px; background: transparent; border-right: 1px solid var(--line); } +.qm:last-child { border-right: 0; } +.qm .qk { color: var(--dim); font-size: 9px; font-weight: 620; letter-spacing: .07em; } +.qm .qv { font-family: var(--font-mono); font-size: 14px; font-weight: 620; } + +.coord-thread { padding: 12px 20px 16px; background: var(--bg); } +.coord-wrap { max-width: 920px; gap: 12px; } +.coord-bubble { max-width: min(88%, 760px); padding: 11px 13px; border-radius: 10px; line-height: 1.55; box-shadow: var(--shadow-md); } +.coord-bubble.you { background: color-mix(in srgb, var(--accent) 11%, var(--panel)); border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); } +.coord-bubble.coordinator { background: var(--panel); border: 1px solid var(--line); } +.coord-bubble.system { max-width: min(88%, 760px); border-color: var(--line2); border-radius: 8px; box-shadow: none; background: var(--panel2); } +.coord-bubble.system.guidance, .coord-bubble.system.insight, .coord-bubble.system.status { border-color: var(--line2); color: var(--muted); } +.coord-bubble.digest, .coord-bubble.answer { background: var(--panel); border: 1px solid var(--line); } +.coord-bubble.answer { box-shadow: var(--shadow-md), inset 3px 0 0 var(--green); } +.coord-bubble.digest { box-shadow: var(--shadow-md), inset 3px 0 0 var(--accent); } +.coord-bubble .who { color: var(--dim); font-size: 10.5px; } + +.composer2 { padding: 12px 20px 18px; background: linear-gradient(to top, var(--bg) 76%, transparent); } +.composer2 .wrap { max-width: 920px; padding: 12px; border: 0; border-radius: 12px; + background: var(--panel); box-shadow: var(--shadow-pop); } +.composer2 .wrap:focus-within { border-color: transparent; box-shadow: var(--shadow-pop), 0 0 0 2px color-mix(in srgb, var(--accent) 24%, transparent); } +.composer2 textarea { min-height: 48px; font-family: var(--font-sans); font-size: 13.5px; line-height: 1.55; } +.composer2 .mode-seg { padding: 3px; gap: 2px; border-color: var(--line); background: var(--panel2); } +.composer2 .mode-seg button { min-height: 26px; padding: 4px 10px; border-radius: 6px; font-size: 11px; } +.composer2 .mode-seg button.on { color: var(--text); background: var(--panel); box-shadow: var(--shadow-sm); } +.composer2 .attach-btn, .composer2 .quick button, .websearch-toggle, .advanced-toggle { border-color: var(--line); border-radius: 7px; background: var(--panel2); } +.composer2 .attach-btn:hover, .composer2 .quick button:hover, .websearch-toggle:hover, .advanced-toggle:hover { color: var(--text); border-color: var(--line2); background: var(--panel3); } +.composer2 .send { width: 34px; height: 34px; border-radius: 9px; background: var(--accent); box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 45%, transparent); } +.composer2 .hintline { color: var(--dim); } + +.insp-tabs { padding: 3px; border-color: var(--line); border-radius: 8px; background: var(--panel2); } +.insp-tab { border-radius: 6px; color: var(--muted); font-weight: 560; } +.insp-tab.on { color: var(--text); background: var(--panel); box-shadow: var(--shadow-sm); } +.insp-sec { padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-md); } +.insp-sec-h { color: var(--dim); font-size: 10px; font-weight: 620; letter-spacing: .07em; } +.insp-run-flag { border-color: var(--line); border-radius: 8px; background: var(--panel2); } +.insp-chip { border-color: var(--line); border-radius: 6px; background: var(--panel2); } +.insp-panel-btn { min-height: 44px; border-color: var(--line); border-radius: 8px; background: var(--panel2); box-shadow: none; } +.insp-panel-btn:hover, .insp-panel-btn.on { color: var(--text); border-color: var(--line2); background: var(--panel3); } +.iwk { border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); } +.iwk-clickable:hover { border-color: var(--line2); background: var(--panel3); } + +.artifact.workspace-mode { background: var(--bg); } +.artifact-head.runtime-head { min-height: 64px; padding-block: 10px; border-bottom: 1px dashed var(--line2); background: var(--bg); } +.runtime-titlemark { width: 34px; height: 34px; border-color: var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow-sm); } +.runtime-eyebrow { color: var(--dim); font-size: 9px; font-weight: 620; letter-spacing: .09em; } +.runtime-titleline strong { font-size: 14px; font-weight: 620; } +.runtime-state { border-color: var(--line); border-radius: 6px; background: var(--panel2); font-weight: 550; } +.runtime-back { min-height: 32px; border: 0; border-radius: 8px; background: var(--panel); box-shadow: var(--shadow-sm); font-weight: 560; } +.runtime-console { flex:1; min-height:0; display:flex; flex-direction:column; } +.runtime-console .artifact-body { height:auto; } +.runtime-trace { overflow: hidden; background: var(--panel); } +.runtime-trace-plot { background: var(--panel2); } +.artifact-body, .artifact-view { background: var(--bg); } +.artifact-list, .panel-scroll { padding: 16px; } +.artifact-row { border-color: var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow-md); } +.artifact-row::before { display: none; } +.artifact-badge, .artifact-chip { border-color: var(--line); border-radius: 6px; background: var(--panel2); } +.status-hero .sh-detail.copytext, +.coord-bubble .ans-flag.copytext { padding: 0; border: 0; background: transparent; box-shadow: none; color: inherit; } + +@media (max-width: 760px) { + .status-hero-shell { margin: 10px 10px 8px; } + .quiet-meta { margin: 0 10px 8px; overflow-x: auto; } + .qm { min-width: 88px; } + .coord-thread { padding-inline: 10px; } + .composer2 { padding: 10px; } +} + /* SSE-drop banner: a slim inline strip surfaced ONLY when a STARTED, still-RUNNING run loses its live event stream (connected === false). A finished run that stops streaming is normal — it never shows this. Calm amber, single line, no dismiss: @@ -2102,8 +2839,8 @@ kbd { display: inline-flex; align-items: center; min-width: 18px; justify-conten .status-hero .sh-detail-flag.copytext { color: var(--gold); font-weight: 700; } /* blackboard flag chip: gold solid chip — keep the check readable on copy */ .bb-flagchip.copytext { align-items: center; } -.bb-flagchip.copytext .copytext-aff { color: color-mix(in srgb, #fff 80%, transparent); } -.bb-flagchip.copytext.copied .copytext-aff { color: #fff; } +.bb-flagchip.copytext .copytext-aff { color: color-mix(in srgb, var(--on-accent, #fff) 80%, transparent); } +.bb-flagchip.copytext.copied .copytext-aff { color: var(--on-accent, #fff); } @media (prefers-reduced-motion: reduce) { .copytext .copytext-aff { transition: none; } @@ -2130,15 +2867,23 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa font-size: 11px; line-height: 1.35; } .insp-runtime-degraded b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: color-mix(in srgb, var(--text) 85%, var(--red)); font-weight: 600; } +.insp-preflight { flex: none; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--line2)); + border-radius: 8px; padding: 9px; font-size: 11px; line-height: 1.4; + background: color-mix(in srgb, var(--accent) 9%, var(--panel)); } +.insp-preflight.preparing { display: flex; align-items: center; gap: 7px; color: var(--accent); } +.insp-preflight.failed { border-color: color-mix(in srgb, var(--red) 38%, var(--line2)); + background: color-mix(in srgb, var(--red) 9%, var(--panel)); } +.insp-preflight-title { display: flex; align-items: center; gap: 7px; color: var(--red); font-weight: 700; } +.insp-preflight-list { display: grid; gap: 7px; margin-top: 8px; } +.insp-preflight-row { display: grid; gap: 2px; padding-top: 7px; border-top: 1px solid var(--line); } +.insp-preflight-row b { color: var(--text); font-weight: 650; overflow-wrap: anywhere; } +.insp-preflight-row span { color: var(--muted); overflow-wrap: anywhere; } /* engine degrade is amber (reduced capacity, not a hard error) — distinct from the * red runtime-layer failure above it. */ .insp-engine-degraded { color: var(--amber); background: color-mix(in srgb, var(--amber) 10%, var(--panel)); border-color: color-mix(in srgb, var(--amber) 38%, var(--line2)); } .insp-engine-degraded b { color: color-mix(in srgb, var(--text) 85%, var(--amber)); } -/* ── inspector tab bar (结果 / 运行时) ──────────────────────────────────────── - A flush 2-up segmented switcher pinned at the top of the column. The active - tab reads as a raised card (panel bg + accent ink + a 2px underline drawn as an - inset shadow so there's no border-box growth → no layout shift on switch). */ +/* ── inspector tab bar (结果 / 运行时) ──────────────────────────────────────── */ .insp-tabs { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 2px; padding: 3px; background: var(--panel2); border: 1px solid var(--line); border-radius: var(--r-input); } @@ -2149,8 +2894,7 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa transition: transform .1s ease, color .12s ease, background-color .12s ease, box-shadow .12s ease; } .insp-tab:hover { color: var(--text); } .insp-tab:active { transform: scale(0.97); } -.insp-tab.on { color: var(--accent); background: var(--panel); - box-shadow: var(--shadow-sm), inset 0 -2px 0 var(--accent); } +.insp-tab.on { color: var(--text); background: var(--panel); box-shadow: var(--shadow-sm); } .insp-tab:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: -2px; } .insp-tab svg { flex: none; opacity: .85; } .insp-tab.on svg { opacity: 1; } @@ -2206,6 +2950,22 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .insp-flag-false:hover { border-color: color-mix(in srgb, var(--red) 58%, var(--line2)); background: color-mix(in srgb, var(--red) 13%, var(--panel)); } .insp-flag-false:focus-visible { outline: 2px solid color-mix(in srgb, var(--red) 55%, transparent); outline-offset: 1px; } +.insp-report-dir { display: flex; flex-direction: column; gap: 6px; min-height: 0; max-height: 240px; overflow-y: auto; } +.insp-report-row { width: 100%; display: flex; flex-direction: column; gap: 4px; align-items: flex-start; + padding: 8px 10px; text-align: left; color: inherit; cursor: pointer; font: inherit; + border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); } +.insp-report-row:hover { border-color: var(--line2); background: var(--panel3); } +.insp-report-row:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 1px; } +.insp-report-row-top { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.insp-report-type { font-size: 12.5px; font-weight: 650; color: var(--text); } +.insp-report-path { font-size: 11px; color: var(--muted); word-break: break-all; } +.insp-report-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 2px 2px 0; } +.insp-report-hint { font-size: 10.5px; color: var(--dim); line-height: 1.4; } +.insp-report-export { display: inline-flex; align-items: center; gap: 5px; min-height: 26px; padding: 3px 8px; + border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--muted); font-size: 11px; } +.insp-report-export:hover { color: var(--text); border-color: var(--line2); } +.insp-report-export.copied { color: var(--green); border-color: color-mix(in srgb, var(--green) 35%, var(--line)); } +.report-row.report-focused { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } .insp-flag-count, .ans-flag-count { font-weight: 700; color: var(--gold); font-size: 11px; font-variant-numeric: tabular-nums; background: color-mix(in srgb, var(--gold) 14%, transparent); border-radius: 999px; padding: 1px 8px; @@ -2226,15 +2986,40 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa background: color-mix(in srgb, var(--amber) 10%, var(--panel)); border-radius: var(--r-pill); padding: 1px 6px; font-size: 9.5px; line-height: 14px; font-weight: 800; letter-spacing: 0; text-transform: uppercase; } +.worker-role-chip.verifier { + color: var(--cyan); + border-color: color-mix(in srgb, var(--cyan) 42%, var(--line2)); + background: color-mix(in srgb, var(--cyan) 10%, var(--panel)); +} + +/* Execution-generation badge (continued runs): marks a worker minted by a + resolve generation, e.g. cli-pi-g2 → "g2". Quiet blue, smaller than role chips. */ +.wlane-gen, .iwk-gen { + flex: none; display: inline-flex; align-items: center; + color: var(--blue); border: 1px solid color-mix(in srgb, var(--blue) 40%, var(--line2)); + background: color-mix(in srgb, var(--blue) 9%, var(--panel)); + border-radius: var(--r-pill); padding: 0 5px; font-size: 9px; line-height: 13px; + font-weight: 800; text-transform: uppercase; +} + +.sh-phase-pills { display: inline-flex; align-items: center; gap: 5px; margin-left: 6px; flex-wrap: wrap; } +.sh-phase-pill { + display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--r-pill); + border: 1px solid var(--line2); background: var(--panel2); color: var(--muted); + font-size: 9.5px; font-weight: 700; letter-spacing: .02em; white-space: nowrap; +} +.sh-phase-pill.race { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--line2)); } +.sh-phase-pill.verifying { color: var(--cyan); border-color: color-mix(in srgb, var(--cyan) 35%, var(--line2)); } + +.insp-flag-count small { margin-left: 6px; color: var(--muted); font-size: 10px; font-weight: 600; } /* worker mini rows */ .iwk-list { flex: 1 1 auto; min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 7px; padding-right: 3px; } .iwk { --wc: var(--blue); display: flex; align-items: center; gap: 9px; padding: 8px 9px; - border: 1px solid var(--line); border-left: 3px solid var(--wc); border-radius: 9px; - background: color-mix(in srgb, var(--wc) 5%, var(--panel)); flex: none; } -.iwk.review-worker { border-left-color: var(--amber); - background: color-mix(in srgb, var(--amber) 7%, color-mix(in srgb, var(--wc) 3%, var(--panel))); } + border: 1px solid var(--line); border-radius: 9px; + background: var(--panel); flex: none; } +.iwk.review-worker { background: color-mix(in srgb, var(--amber) 7%, var(--panel)); } .iwk.review-worker .iwk-avatar { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 50%, var(--line2)); background: color-mix(in srgb, var(--amber) 13%, var(--bg)); } @@ -2257,6 +3042,7 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa text-overflow: ellipsis; white-space: nowrap; } .iwk-sub { display: flex; align-items: center; gap: 6px; font-size: 10px; color: var(--muted); } .iwk-sub .iwk-eng { color: var(--wc); } +.iwk-sub .iwk-conn { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .iwk-runtime { flex: none; max-width: 116px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: color-mix(in srgb, var(--blue) 78%, var(--muted)); border: 1px solid color-mix(in srgb, var(--blue) 28%, var(--line2)); border-radius: 6px; padding: 1px 4px; font-size: 9.5px; font-weight: 700; } @@ -2364,7 +3150,40 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .artifact-badge.ok { color: var(--green); border-color: color-mix(in srgb, var(--green) 35%, var(--border)); } .artifact-badge.bad, .artifact-badge.sev-high, .artifact-badge.sev-critical { color: var(--red); border-color: color-mix(in srgb, var(--red) 35%, var(--border)); } .artifact-badge.sev-warn, .artifact-badge.sev-medium { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 42%, var(--border)); } +.artifact-badge.sev-low { color: var(--blue); border-color: color-mix(in srgb, var(--blue) 35%, var(--border)); } .artifact-code { display: block; min-width: 0; padding: 6px 7px; border-radius: 6px; background: var(--panel2); border: 1px solid var(--line); color: var(--bright); font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; } +.report-panel { background: var(--bg); } +.report-panel .evi-toolbar-title { align-items: center; } +.report-panel .evi-summary { justify-content: flex-start; } +.report-panel .evi-filter-btn { + appearance: none; -webkit-appearance: none; gap: 5px; cursor: pointer; +} +.report-panel .evi-filter-btn.copied { color: var(--green); border-color: color-mix(in srgb, var(--green) 35%, var(--line2)); } +.report-row .artifact-row-top { align-items: flex-start; } +.report-row .artifact-row-title { flex: 1; white-space: normal; overflow: visible; text-overflow: unset; line-height: 1.4; } +.report-row .evi-copy { margin: 0; align-self: flex-start; } +.report-row:hover .evi-copy, +.report-row:focus-within .evi-copy { opacity: .7; } +.report-toggle { + appearance: none; -webkit-appearance: none; background: transparent; border: 0; + color: inherit; display: flex; align-items: flex-start; gap: 7px; flex: 1; + min-width: 0; text-align: left; cursor: pointer; padding: 0; font: inherit; +} +.report-toggle .artifact-row-title { flex: 1; } +.report-toggle svg { flex: none; color: var(--muted); margin-top: 2px; transition: transform .14s ease; } +.report-row.expanded .report-toggle svg { transform: rotate(180deg); color: var(--blue); } +.report-summary { display: grid; gap: 4px; min-width: 0; } +.report-steps { margin: 0; padding: 0 0 0 18px; color: var(--text); font-size: 12px; line-height: 1.5; } +.report-steps li { overflow-wrap: anywhere; } +.report-history { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; } +.report-history li { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; color: var(--muted); font-size: 11px; } +.report-history-reason { color: var(--text); } +.report-links { display: flex; flex-wrap: wrap; gap: 6px; } +.report-link-btn { + appearance: none; -webkit-appearance: none; border: 1px solid var(--line2); background: var(--panel2); + color: var(--text); border-radius: 999px; font: inherit; font-size: 11px; padding: 2px 8px; cursor: pointer; +} +.report-link-btn:hover { border-color: var(--line); color: var(--bright); } /* wrapper holds the scroll panel + a floating "↓ new messages" pill. It takes the flex role so the inner scroll panel fills it; the pill is absolutely positioned so it doesn't scroll with the content. */ @@ -2411,7 +3230,7 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa position: absolute; right: 16px; bottom: 14px; z-index: 5; display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; border-radius: 999px; - background: var(--accent, #f0a020); color: #fff; + background: var(--accent); color: var(--on-accent, #fff); border: none; font-size: 12px; font-weight: 700; cursor: pointer; box-shadow: var(--shadow-md); animation: jump-newest-in 140ms ease-out; @@ -2433,23 +3252,48 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .panel-empty-hint { color: var(--muted); font-size: 11.5px; line-height: 1.6; max-width: 320px; text-wrap: pretty; } /* worker lanes (rich) */ -.wlane { --wc: var(--blue); border: 1px solid var(--line); border-left: 4px solid var(--wc); - border-radius: 10px; background: color-mix(in srgb, var(--wc) 4%, var(--panel)); margin-bottom: 14px; +.wlane { --wc: var(--blue); border: 1px solid var(--line); + border-radius: 10px; background: var(--panel); margin-bottom: 8px; overflow: hidden; } -.wlane.review-worker { border-left-color: var(--amber); - background: color-mix(in srgb, var(--amber) 6%, color-mix(in srgb, var(--wc) 3%, var(--panel))); } +.wlane.review-worker { background: color-mix(in srgb, var(--amber) 6%, var(--panel)); } .wlane.review-worker .wlane-avatar { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 50%, var(--line2)); background: color-mix(in srgb, var(--amber) 12%, var(--bg)); } -.wlane.solved { border-left-color: var(--green); } .wlane.offline { opacity: .72; } -/* I: stalled / paused lane highlight — spot a stuck or held worker at a glance */ -.wlane.stalled { border-left-color: var(--amber); - background: color-mix(in srgb, var(--amber) 5%, transparent); } -.wlane.paused { border-left-color: var(--dim); } -.wlane-filterbar { margin: 0 0 14px; border: 1px solid var(--line); border-radius: var(--r-input); padding: 4px 8px; } +.wlane.paused { box-shadow: inset 3px 0 0 var(--dim); background: color-mix(in srgb, var(--dim) 7%, var(--panel)); } +.wlane.stalled, .wlane.error { box-shadow: inset 3px 0 0 var(--red); background: color-mix(in srgb, var(--red) 7%, var(--panel)); } +.wlane-tools { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 12px; } +.wlane-filterbar { flex: 1; min-width: 0; margin: 0; border: 1px solid var(--line); border-radius: var(--r-input); padding: 4px 8px; } +.wlane-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); } +.wlane-bar-l { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; color: var(--muted); font-size: 12px; } +.wlane-bar-l b { color: var(--bright); font-weight: 750; } +.wlane-bar-r { display: flex; align-items: center; gap: 6px; } +.wlane-dot { width: 6px; height: 6px; border-radius: 99px; background: var(--dim); } +.wlane-dot.live { background: var(--green); box-shadow: 0 0 7px var(--green); } +.wlane-anomaly { flex: none; min-height: 28px; padding: 4px 10px; border: 1px solid var(--line); border-radius: var(--r-pill); color: var(--muted); background: var(--panel); font: inherit; font-size: 11.5px; font-weight: 650; cursor: pointer; } +.wlane-anomaly:hover { color: var(--text); border-color: var(--line2); } +.wlane-anomaly.on { color: var(--red); border-color: color-mix(in srgb, var(--red) 45%, var(--line)); background: color-mix(in srgb, var(--red) 8%, var(--panel)); } +.wlane-bar .wlane-spawn { margin: 0; } +.wlane-roster { padding: 4px 0 10px; } +.wlane-grp-h { width: 100%; display: flex; align-items: center; gap: 8px; padding: 7px 14px; border: 0; background: transparent; color: var(--muted); text-align: left; font: inherit; } +button.wlane-grp-h { cursor: pointer; } +button.wlane-grp-h:hover { color: var(--text); background: color-mix(in srgb, var(--blue) 5%, transparent); } +.wlane-grp-h b { color: var(--bright); font-weight: 750; } +.wlane-grp-n { margin-left: auto; color: var(--dim); font: 700 11px/1 var(--font-mono); } +.wlane-row { width: 100%; display: grid; grid-template-columns: minmax(0,1fr) 24px; align-items: center; gap: 6px; padding: 0 10px 0 0; } +.wlane-row-main { min-width: 0; overflow: hidden; display: grid; grid-template-columns: 22px minmax(96px,.9fr) 52px minmax(0,1.4fr) auto; align-items: center; gap: 8px; padding: 5px 8px 5px 14px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; font: inherit; } +.wlane-row:hover, .wlane-row-main:hover { background: color-mix(in srgb, var(--blue) 6%, transparent); } +.wlane-row.focused { background: color-mix(in srgb, var(--blue) 9%, transparent); } +.wlane-row.is-stalled, .wlane-row.is-error { box-shadow: inset 3px 0 0 var(--red); background: color-mix(in srgb, var(--red) 6%, transparent); } +.wlane-row.is-paused { box-shadow: inset 3px 0 0 var(--dim); } +.wlane-row .wlane-avatar { width: 22px; height: 22px; border-radius: 6px; font-size: 9px; } +.wlane-row .wlane-id { min-width: 0; display: flex; align-items: center; gap: 6px; } +.wlane-row .wlane-name { min-width: 0; flex: 1; font-size: 12.5px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wlane-row .wlane-latest { margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 11.5px; } +.wlane-row .wlane-status { grid-column: 5; justify-self: end; display: inline-flex; align-items: center; gap: 6px; } +.wlane-row .wlane-kill { align-self: center; } .wlane-head { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto 24px auto; - align-items: start; gap: 10px; padding: 11px 12px; cursor: pointer; + align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; transition: background .12s ease; } .wlane-head:hover { background: color-mix(in srgb, var(--wc) 5%, transparent); } .wlane-head:focus-visible { outline: 2px solid color-mix(in srgb, var(--wc) 55%, transparent); outline-offset: -3px; } @@ -2461,22 +3305,33 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .wlane-idrow { min-width: 0; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .wlane-name { color: var(--bright); font-weight: 800; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wlane-sub { min-width: 0; color: var(--muted); font-size: 10.5px; line-height: 1.35; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wlane-eng { color: var(--wc); font-size: 10.5px; font-weight: 800; border: 1px solid color-mix(in srgb, var(--wc) 35%, var(--line2)); border-radius: var(--r-pill); padding: 1px 7px; background: color-mix(in srgb, var(--wc) 9%, transparent); text-transform: capitalize; } .wlane-latest { min-width: 0; display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 11px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wlane-latest span { flex: none; color: var(--wc); font-size: 9px; font-weight: 800; letter-spacing: var(--ls-label); text-transform: uppercase; } -.wlane-status { align-self: start; max-width: 126px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +.wlane-status { align-self: center; max-width: 126px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: 700; color: var(--muted); border: 1px solid var(--line2); border-radius: var(--r-pill); padding: 2px 9px; background: color-mix(in srgb, var(--panel2) 70%, transparent); } -.wlane-status.solved { color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, var(--line2)); +.wlane-status.is-solved { color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, var(--line2)); background: color-mix(in srgb, var(--green) 8%, transparent); } +.wlane-status.is-running-tool { color: var(--blue); border-color: color-mix(in srgb, var(--blue) 45%, var(--line2)); + background: color-mix(in srgb, var(--blue) 10%, transparent); } +.wlane-status.is-thinking { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 45%, var(--line2)); + background: color-mix(in srgb, var(--amber) 10%, transparent); } +.wlane-status.is-paused { color: var(--dim); border-style: dashed; border-color: color-mix(in srgb, var(--dim) 55%, var(--line2)); + background: color-mix(in srgb, var(--dim) 10%, transparent); } +.wlane-status.is-stalled, .wlane-status.is-error { color: var(--red); border-color: color-mix(in srgb, var(--red) 45%, var(--line2)); + background: color-mix(in srgb, var(--red) 10%, transparent); } +.wlane-status.is-offline { color: var(--muted); } .wlane-toggle { align-self: start; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); border: 1px solid var(--line2); border-radius: 6px; background: color-mix(in srgb, var(--bg2) 70%, transparent); transition: color .12s ease, border-color .12s ease, transform .12s ease; } .wlane-head:hover .wlane-toggle { color: var(--wc); border-color: color-mix(in srgb, var(--wc) 40%, var(--line2)); } .wlane-toggle.expanded { color: var(--wc); transform: rotate(180deg); } -.wlane-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; background: var(--line); +.wlane-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 1px; background: var(--line); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } .wlane-tile { background: var(--panel2); padding: 7px 11px; } .wlane-tile .tk { font-size: var(--fs-label); font-weight: 700; text-transform: uppercase; letter-spacing: var(--ls-label); color: var(--muted); } @@ -2486,28 +3341,25 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .wlane-online.off { color: var(--muted); } .wlane-online-dot { width: 7px; height: 7px; border-radius: 99px; background: currentColor; } .wlane-online.on .wlane-online-dot { box-shadow: 0 0 7px var(--green); } +.wlane-actions { display: flex; padding: 9px 12px; } +.wlane-view-stream { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border: 1px solid var(--line); border-radius: var(--r-pill); background: var(--panel2); color: var(--muted); font-size: 11.5px; font-weight: 600; cursor: pointer; transition: color .12s var(--ease-out-soft), border-color .12s var(--ease-out-soft), background .12s var(--ease-out-soft); } +.wlane-view-stream:hover { color: var(--blue); border-color: color-mix(in srgb, var(--blue) 40%, var(--line)); background: color-mix(in srgb, var(--blue) 8%, var(--panel2)); } .wlane-runtime { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: color-mix(in srgb, var(--blue) 82%, var(--bright)); font-size: 12px !important; } -.wlane-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; } -.wlane-block-h { font-size: var(--fs-label); text-transform: uppercase; letter-spacing: var(--ls-label); color: var(--muted); margin-bottom: 4px; } -.wlane-reason { color: var(--text); font-size: 12px; line-height: 1.55; white-space: pre-wrap; - word-break: break-word; max-height: 150px; overflow: auto; background: var(--bg2); - border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; } -.wlane-reason.idle { color: var(--muted); font-style: italic; } -.wlane-tools { display: flex; flex-direction: column; gap: 5px; } -.wlane-toolline { color: var(--text); font-size: 12px; line-height: 1.5; white-space: pre-wrap; - word-break: break-word; background: var(--term-bg); border: 1px solid var(--line); border-radius: 7px; - padding: 6px 9px; max-height: 130px; overflow: auto; } .wlane-sess { display: inline-flex; align-items: center; gap: 3px; cursor: copy; color: var(--muted); font-size: 10.5px; border: 1px solid var(--line2); border-radius: var(--r-pill); padding: 1px 7px; background: color-mix(in srgb, var(--bg2) 75%, transparent); transition: color .12s ease, border-color .12s ease; } .wlane-sess:hover { color: var(--bright); border-color: color-mix(in srgb, var(--wc) 40%, var(--line2)); } .wlane-sess.copied { color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, var(--line2)); } -.wlane-kill { align-self: start; flex: none; width: 24px; height: 24px; display: inline-flex; align-items: center; +.wlane-kill { align-self: center; flex: none; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); border: 1px solid var(--line2); border-radius: 6px; - background: transparent; } + background: transparent; opacity: .45; } +.wlane-head:hover .wlane-kill, .wlane-kill:focus-visible { opacity: 1; } .wlane-kill:hover { color: var(--red); border-color: var(--red); } -.wlane-spawn { display: flex; gap: 7px; margin-bottom: 14px; } +.wlane-detail-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 12px 0; } +.wlane-detail-meta .wlane-sub { margin: 0; } +.wlane-spawn { display: flex; align-items: center; gap: 7px; margin: 0; } +.wlane-overview .wlane-spawn { align-self: center; } .wlane-spawn select { background: var(--bg); border: 1px solid var(--line2); color: var(--text); border-radius: 7px; padding: 4px 8px; font: inherit; font-size: 12px; outline: none; } .wlane-spawn-btn { background: var(--bg); color: var(--muted); border: 1px solid var(--line2); @@ -2526,14 +3378,13 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa background: color-mix(in srgb, var(--blue) 10%, transparent); } .activity-feed { display: flex; flex-direction: column; gap: 10px; } .act-msg { --wc: var(--blue); display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; - padding: 10px 12px; border: 1px solid var(--line); border-left: 3px solid var(--wc); border-radius: 9px; + padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); } .act-msg.coordinator { --wc: var(--eng-reason); } .act-msg.human { --wc: var(--blue); background: color-mix(in srgb, var(--human-bg) 40%, var(--panel)); } .act-msg.system { --wc: var(--muted); } -.act-msg.review-worker { border-left-color: var(--amber); - background: color-mix(in srgb, var(--amber) 5%, var(--panel)); } -.filter-chip.review-speaker { box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--amber) 65%, transparent); } +.act-msg.review-worker { background: color-mix(in srgb, var(--amber) 5%, var(--panel)); } +.filter-chip.review-speaker { color: var(--amber); } .act-ico { width: 30px; height: 30px; border-radius: 7px; flex: none; display: inline-flex; align-items: center; justify-content: center; font-weight: 800; color: var(--wc); background: color-mix(in srgb, var(--wc) 16%, var(--bg)); } @@ -2544,6 +3395,13 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .act-who .ts { margin-left: auto; color: var(--muted); font-weight: 500; font-variant-numeric: tabular-nums; } .act-body { color: var(--text); font-size: 13px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; } .act-msg.tool .act-body { font-family: var(--font-mono); font-size: 12px; max-height: 180px; overflow: auto; } +.act-tool-group-toggle { display:block; width:100%; padding:0; border:0; background:transparent; color:inherit; text-align:left; cursor:pointer; font:inherit; } +.activity-feed .act-tool-list { display:flex; flex-direction:column; gap:4px; margin-top:8px; } +.activity-feed .act-tool-child { display:flex; flex-direction:column; gap:2px; width:100%; padding:6px 8px; border:0; border-radius:6px; color:inherit; background:color-mix(in srgb, var(--amber) 8%, var(--panel2)); text-align:left; cursor:pointer; font:inherit; } +.activity-feed .act-tool-child:hover { background:color-mix(in srgb, var(--amber) 12%, var(--panel2)); } +.activity-feed .act-tool-child.expanded, .activity-feed .act-tool-child.tool-failed { background:color-mix(in srgb, var(--amber) 14%, var(--panel2)); } +.activity-feed .act-tool-cmd { font:12px/1.4 var(--font-mono); color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.activity-feed .act-tool-out { margin-top:4px; max-height:140px; overflow:auto; color:var(--dim); font:11.5px/1.45 var(--font-mono); white-space:pre-wrap; } /* grouped: a consecutive row from the same speaker within the group window — hide the repeated avatar + who header (Slack/iMessage style) for a denser log. */ @@ -2551,16 +3409,16 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .act-msg.grouped .act-ico { visibility: hidden; } .act-msg.grouped .act-who { display: none; } -/* compact density: tighter rows, smaller text, no avatar column. driven by the - header toggle (persisted to localStorage). comfortable is the default above. */ -.activity-feed.compact { gap: 4px; } -.activity-feed.compact .act-msg { grid-template-columns: minmax(0, 1fr); gap: 0; +/* Old ActivityStream card compact. The live ledger also has class activity-feed; + leave these 1-column rules unscoped and they override the 5-column ledger grid. */ +.activity-feed.compact:not(.trace-ledger) { gap: 4px; } +.activity-feed.compact:not(.trace-ledger) .act-msg { grid-template-columns: minmax(0, 1fr); gap: 0; padding: 5px 10px; border-radius: 7px; } -.activity-feed.compact .act-ico { display: none; } -.activity-feed.compact .act-who { margin-bottom: 1px; font-size: 10.5px; } -.activity-feed.compact .act-body { font-size: 12px; line-height: 1.4; } -.activity-feed.compact .act-msg.grouped { padding-top: 1px; } -.activity-feed.compact .act-msg.tool .act-body { max-height: 130px; } +.activity-feed.compact:not(.trace-ledger) .act-ico { display: none; } +.activity-feed.compact:not(.trace-ledger) .act-who { margin-bottom: 1px; font-size: 10.5px; } +.activity-feed.compact:not(.trace-ledger) .act-body { font-size: 12px; line-height: 1.4; } +.activity-feed.compact:not(.trace-ledger) .act-msg.grouped { padding-top: 1px; } +.activity-feed.compact:not(.trace-ledger) .act-msg.tool .act-body { max-height: 130px; } /* evidence chain */ .evidence-panel { background: var(--bg); } @@ -2595,10 +3453,7 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .evi-group.candidates .evi-group-h { color: var(--amber); } .evi-group.dead .evi-group-h { color: var(--red); } .evi-item { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); padding: 0; - margin-bottom: 6px; border-left: 3px solid var(--line2); overflow: hidden; } -.evi-item.v { border-left-color: var(--green); } -.evi-item.c { border-left-color: var(--amber); } -.evi-item.d { border-left-color: var(--red); } + margin-bottom: 6px; overflow: hidden; } .evi-item.expanded { background: color-mix(in srgb, var(--panel) 82%, var(--bg2)); } .evi-fact { color: var(--text); font-size: 12.5px; line-height: 1.45; word-break: break-word; } .evi-item.d .evi-fact { color: var(--red); text-decoration: line-through; } @@ -2623,7 +3478,7 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .evi-sort-btn { background: transparent; color: var(--dim); border: 0; padding: 4px 11px; font: inherit; font-size: 12px; cursor: pointer; line-height: 1.4; } .evi-sort-btn:hover { color: var(--bright); } -.evi-sort-btn.on { background: var(--accent); color: #fff; font-weight: 600; } +.evi-sort-btn.on { background: var(--accent); color: var(--on-accent, #fff); font-weight: 600; } .evi-sort-btn:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: -2px; } @@ -2664,28 +3519,45 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa /* health-check degrade — an engine that can't complete a turn (auth/binary). */ .engine-pill.degraded { opacity: 1; color: var(--text); border-color: color-mix(in srgb, var(--amber) 55%, var(--border2)); } -.engine-pill.degraded .engine-dot { background: var(--amber); box-shadow: 0 0 7px var(--amber); - animation: engine-degrade-pulse 1.8s ease-in-out infinite; } +.engine-pill.degraded .engine-dot, +.engine-dot.degraded { background: var(--amber); } +.engine-dot.degraded { animation: engine-degrade-pulse 1.8s ease-in-out infinite; } .engine-pill.degraded .engine-pct.degraded-tag { color: var(--amber); text-transform: uppercase; letter-spacing: .04em; } @keyframes engine-degrade-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .45; } } +@media (prefers-reduced-motion: reduce) { .engine-dot.degraded { animation: none; } } .engine-pop-degraded { color: var(--amber); } -/* hover card — instant structured quota popover (replaces the native title) */ -.engine-pill { position: relative; } +/* hover / pinned list — one card for the whole chip */ .engine-pop { position: absolute; top: calc(100% + 7px); right: 0; z-index: 50; - display: none; flex-direction: column; gap: 6px; min-width: 220px; + display: none; flex-direction: column; gap: 8px; min-width: 248px; padding: 9px 11px; background: var(--bg2); border: 1px solid var(--border2); border-radius: 10px; box-shadow: var(--shadow-pop); cursor: default; text-align: left; } -.engine-pill:hover .engine-pop, .engine-pill:focus-within .engine-pop { display: flex; } -/* a small bridge so the gap between pill and card doesn't drop the hover */ +.engine-bar:hover:not(.dismissed) .engine-pop, .engine-bar.open .engine-pop { display: flex; } +/* a small bridge so the gap between chip and card doesn't drop the hover */ .engine-pop::before { content: ""; position: absolute; top: -7px; left: 0; right: 0; height: 7px; } -.engine-pop-head { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text); } +.engine-pop-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 11px; color: var(--text); } .engine-pop-head b { font-weight: 700; } +.engine-pop-head .engine-pop-sub { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--muted); } .engine-pop-note { font-size: 10px; color: var(--muted); } +.engine-pop-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; } +.engine-pop-row { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; column-gap: 8px; align-items: center; } +.engine-pop-label { + display: flex; align-items: baseline; gap: 6px; min-width: 0; + font-size: 11px; color: var(--text); overflow: hidden; white-space: nowrap; +} +.engine-pop-label strong { font-weight: 650; flex: none; } +.engine-pop-label em { + font-style: normal; color: var(--muted); overflow: hidden; + text-overflow: ellipsis; min-width: 0; +} +.engine-pop-status { font-size: 10px; color: var(--muted); white-space: nowrap; } +.engine-pop-row.down .engine-pop-status, +.engine-pop-row.degraded .engine-pop-status { color: var(--amber); } +.engine-pop-row .engine-pop-note { grid-column: 2 / -1; } /* ---- cost / token per-agent hover card (reuses .engine-pop visual language) ---- */ .qm.has-pop { position: relative; cursor: default; } @@ -2715,6 +3587,8 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa @media (max-width: 720px) { .coord-thread { padding: 14px; } .insp-panels { grid-template-columns: 1fr; } + .convo-view-switch button { padding-inline:7px; } + .convo-view-switch button span { display:none; } } /* scrollbars — light + thin, terminal chrome */ @@ -2723,14 +3597,7 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 6px; } ::-webkit-scrollbar-thumb:hover { background: var(--muted); } -/* ============================================================================ - "NO-CSS-FEEL" GHOST CONTROLS — the mysterious, under-styled interaction - layer. Secondary buttons drop their fills + chunky borders for a typographic - ghost: transparent ground, ink-on-hover, an accent underline when active. - Inputs become underline fields (a single hairline that lights up on focus). - Source order keeps these AFTER the component rules so they win same-specificity - ties; the primary CTAs (newsolve / send / hitl / save) keep their accent fill. - ============================================================================ */ +/* Secondary controls share one quiet surface. Primary actions keep their fill. */ .icon-btn, .graph-toolbar button, .bb-tool-btn, @@ -2760,8 +3627,8 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .bb-worker-chip:hover { color: var(--bright); background: var(--ink-wash); border-color: transparent; } -/* active / selected ghost: accent ink + a 2px underline drawn as an inset shadow - (no border-box growth → no layout shift). */ +/* Active secondary controls use the same neutral surface as the rest of the + command deck. State remains visible through the label, icon and ARIA value. */ .graph-toolbar button.on, .bb-tool-btn.on, .rc-actions button.on, @@ -2769,8 +3636,8 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .graph-solver-chip.on, .websearch-toggle.on, .advanced-toggle.on { - color: var(--accent); background: transparent; border-color: transparent; - box-shadow: inset 0 -2px 0 var(--accent); + color: var(--text); background: var(--ink-wash); border-color: transparent; + box-shadow: inset 0 0 0 1px var(--line2); } :where(.graph-toolbar button.on, .bb-tool-btn.on, .rc-actions button.on, .artifact-head .tabs button.on):hover { background: var(--ink-wash); @@ -2800,6 +3667,70 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa } .composer2 .collect-count { width: 70px; font-size: 12px; font-variant-numeric: tabular-nums; } .composer2 .collect-count::placeholder { color: var(--dim); } + +/* Beautiful UI Fine-tune number chip — no native spinner */ +input[type="number"] { -moz-appearance: textfield; appearance: textfield; } +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { appearance: none; margin: 0; } + +.num-field { + display: inline-flex; align-items: center; gap: 4px; min-width: 0; width: 100%; + min-height: 33px; padding: 4px 6px 4px 4px; border: 0; border-radius: 6px; + background: var(--panel3); color: var(--text); box-shadow: none; + transition: background-color .16s ease, box-shadow .16s ease; +} +.num-field:focus-within { background: color-mix(in srgb, var(--accent) 8%, var(--panel3)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent); } +.num-field.disabled { opacity: .55; pointer-events: none; } +.num-field-scrub { + flex: none; display: inline-flex; align-items: center; height: 18px; + padding: 0 2px; border-radius: 4px; color: var(--dim); font-size: 12px; + line-height: 1; user-select: none; cursor: ew-resize; touch-action: none; +} +.num-field-scrub:hover { color: var(--muted); } +.num-field-scrub:focus-visible { color: var(--accent); outline: none; } +.num-field-input { + min-width: 0; flex: 1 1 auto; width: 100%; height: 18px; padding: 0; border: 0; + background: transparent; color: var(--text); font: inherit; font-size: 12px; + font-variant-numeric: tabular-nums; outline: none; box-shadow: none; +} +.num-field-input::placeholder { color: var(--dim); } +.num-field-suffix { flex: none; padding-right: 2px; color: var(--dim); font-size: 11.5px; line-height: 1; } +.num-field-stepper { + flex: none; display: flex; flex-direction: column; width: 16px; height: 22px; + border-radius: 4px; overflow: hidden; + background: color-mix(in srgb, var(--bright) 5%, transparent); +} +.num-field-step { + flex: 1 1 50%; display: grid; place-items: center; min-width: 0; min-height: 0; + padding: 0; border: 0; border-radius: 0; background: transparent; color: var(--dim); + font: inherit; box-shadow: none; cursor: pointer; line-height: 0; +} +.num-field-step:hover { color: var(--text); background: color-mix(in srgb, var(--bright) 7%, transparent); } +.num-field-step:active { color: var(--bright); } +.num-field-step:disabled { opacity: .32; cursor: default; background: transparent; } +.num-field-step:focus-visible { outline: none; color: var(--accent); } + +.composer2 .num-field.collect-count, +.composer2 .num-field { + width: auto; min-width: 108px; min-height: 26px; padding: 3px 3px 3px 2px; +} +.composer2 .pentest-fields .num-field { + align-self: flex-start; width: auto; max-width: 168px; +} +.composer2 .num-field-stepper { width: 15px; height: 20px; } +.composer2 .advanced-field .num-field, +.composer2 .advanced-metric-field .num-field { width: 100%; min-width: 0; } +.wset-form-section .num-field input, +.wsettings-setting-group .num-field input, +.composer2 .num-field input, +.launch .num-field input { + min-height: 0; width: 100%; padding: 0; border: 0; border-radius: 0; + background: transparent; box-shadow: none; +} +.wset-form-section .num-field input:focus, +.wsettings-setting-group .num-field input:focus, +.composer2 .num-field input:focus, +.launch .num-field input:focus { border: 0; box-shadow: none; } .composer2 .flag-wrapper-input { font-size: 12px; } .composer2 .flag-wrapper-input::placeholder { color: var(--dim); } /* rail row/folder ⋯ + new-folder: ghost icon buttons — NO border/box (the folder @@ -3023,6 +3954,9 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .motion-feedback, .motion-artifact, .motion-inspector, + .runtime-trace-relations, + .runtime-trace-mark, + .runtime-trace-fold, .coord-bubble, .welcome, .welcome-hero, @@ -3068,3 +4002,2576 @@ body.inspector-resizing, body.inspector-resizing * { cursor: col-resize !importa .density-toggle:active, .menu .mi:active:not(:disabled) { transform: none; opacity: 1; } } + +/* Final cascade for primitives whose legacy definitions live below the main + Beautiful UI alignment section. */ +.quiet-meta { margin: 0 20px 10px; gap: 0; overflow: hidden; border: 1px solid var(--line); + border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-md); } +.qm { min-height: 58px; padding: 9px 12px; justify-content: center; gap: 4px; + background: transparent; border-right: 1px solid var(--line); } +.qm:last-child { border-right: 0; } +.qm .qk { color: var(--dim); font-size: 9px; font-weight: 620; letter-spacing: .07em; } +.qm .qv { font-family: var(--font-mono); font-size: 14px; font-weight: 620; } + +.coord-thread { padding: 12px 20px 16px; background: var(--bg); } +.coord-wrap { max-width: 920px; gap: 12px; } +.coord-bubble { max-width: min(88%, 760px); padding: 11px 13px; border-radius: 10px; + line-height: 1.55; box-shadow: var(--shadow-md); } +.coord-bubble.you { background: color-mix(in srgb, var(--accent) 11%, var(--panel)); + border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); } +.coord-bubble.coordinator { background: var(--panel); border: 1px solid var(--line); } +.coord-bubble.system { max-width: min(88%, 760px); border-color: var(--line2); + border-radius: 8px; box-shadow: none; background: var(--panel2); } +.coord-bubble.system.guidance, .coord-bubble.system.insight, +.coord-bubble.system.status { border-color: var(--line2); color: var(--muted); } +.coord-bubble.digest, .coord-bubble.answer { background: var(--panel); border: 1px solid var(--line); } +.coord-bubble.answer { box-shadow: var(--shadow-md), inset 3px 0 0 var(--green); } +.coord-bubble.digest { box-shadow: var(--shadow-md), inset 3px 0 0 var(--accent); } +.coord-bubble .who { color: var(--dim); font-size: 10.5px; } + +.insp-tabs { padding: 3px; border-color: var(--line); border-radius: 8px; background: var(--panel2); } +.insp-tab { border-radius: 6px; color: var(--muted); font-weight: 560; } +.insp-tab.on { color: var(--text); background: var(--panel); box-shadow: var(--shadow-sm); } +.insp-sec { padding: 14px; border: 1px solid var(--line); border-radius: 10px; + background: var(--panel); box-shadow: var(--shadow-md); } +.insp-sec-h { color: var(--dim); font-size: 10px; font-weight: 620; letter-spacing: .07em; } +.insp-run-flag { border-color: var(--line); border-radius: 8px; background: var(--panel2); } +.insp-chip { border-color: var(--line); border-radius: 6px; background: var(--panel2); } +.insp-panel-btn { min-height: 44px; border-color: var(--line); border-radius: 8px; + background: var(--panel2); box-shadow: none; } +.insp-panel-btn:hover, .insp-panel-btn.on { color: var(--text); border-color: var(--line2); background: var(--panel3); } +.iwk { border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); } +.iwk-clickable:hover { border-color: var(--line2); background: var(--panel3); } + +.artifact-body, .artifact-view { background: var(--bg); } +.artifact-list, .panel-scroll { padding: 16px; } +.artifact-row { border-color: var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow-md); } +.artifact-row::before { display: none; } +.artifact-badge, .artifact-chip { border-color: var(--line); border-radius: 6px; background: var(--panel2); } + +@media (max-width: 760px) { + .quiet-meta { margin: 0 10px 8px; overflow-x: auto; } + .qm { min-width: 88px; } + .coord-thread { padding-inline: 10px; } +} + +/* Conversation timeline: one continuous execution history rather than a stack + of unrelated chat cards. The rail carries chronology; typography and node + colour carry role hierarchy. Only summaries and final answers keep a panel. */ +.coord-thread { padding: 8px 20px 14px; } +.coord-wrap { position: relative; max-width: 940px; gap: 0; padding-left: 38px; } +.coord-wrap::before { content: ""; position: absolute; top: 16px; bottom: 16px; left: 10px; width: 1px; background: var(--line2); } +.coord-bubble { position: relative; align-self: stretch; max-width: none; min-height: 54px; margin: 0; padding: 11px 10px 13px 14px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; box-shadow: none; line-height: 1.55; } +.coord-bubble:hover { transform: none; border-color: var(--line2); background: color-mix(in srgb, var(--bright) 2.5%, transparent); box-shadow: none; } +.coord-bubble .coord-node { position: absolute; z-index: 1; top: 12px; left: -38px; width: 21px; height: 21px; display: grid; place-items: center; color: var(--muted); border: 1px solid var(--line2); border-radius: 7px; background: var(--bg); box-shadow: 0 0 0 4px var(--bg); } +.coord-bubble .who { min-height: 18px; margin: 0 0 3px; color: var(--muted); font-size: 10px; font-weight: 560; letter-spacing: .01em; } +.coord-bubble .who .k { color: var(--dim); } +.coord-bubble .body { color: var(--text); font-size: 12.5px; line-height: 1.6; } +.coord-bubble.you, .coord-bubble.coordinator, .coord-bubble.system { align-self: stretch; max-width: none; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; box-shadow: none; } +.coord-bubble.you .coord-node { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); background: color-mix(in srgb, var(--accent) 9%, var(--bg)); } +.coord-bubble.you .who { color: var(--accent); } +.coord-bubble.coordinator .coord-node { color: var(--cyan); border-color: color-mix(in srgb, var(--cyan) 38%, var(--line)); } +.coord-bubble.system { min-height: 43px; padding-block: 9px 10px; color: var(--muted); font-size: 12px; } +.coord-bubble.system .coord-node { top: 9px; width: 18px; height: 18px; left: -36px; border-radius: 50%; } +.coord-bubble.system.insight .coord-node { color: var(--violet); border-color: color-mix(in srgb, var(--violet) 42%, var(--line)); } +.coord-bubble.system.guidance .coord-node { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 42%, var(--line)); } +.coord-bubble.system.status .coord-node { color: var(--blue); border-color: color-mix(in srgb, var(--blue) 42%, var(--line)); } +.coord-bubble.digest, .coord-bubble.answer { align-self: stretch; max-width: none; min-height: 0; margin: 12px 0 8px; padding: 13px 15px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-md); } +.coord-bubble.digest { box-shadow: var(--shadow-md), inset 3px 0 0 var(--accent); } +.coord-bubble.answer { box-shadow: var(--shadow-md), inset 3px 0 0 var(--green); } +.coord-bubble.digest .coord-node { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } +.coord-bubble.answer .coord-node { color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, var(--line)); } + +/* Compact run summary: the inspector owns the detailed counters and cost data, + so the conversation only keeps phase, current result, elapsed time and flow. */ +.status-hero-shell { margin: 8px 20px 6px; } +.status-hero { min-height: 46px; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; padding: 6px 7px 6px 10px; border-radius: 9px; box-shadow: none; cursor: default; } +.status-hero:hover { background: var(--panel); box-shadow: none; } +.status-hero.open { background: var(--panel3); box-shadow: none; } +.status-hero .sh-status { gap: 6px; padding-right: 10px; } +.status-hero .sh-ico { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--sh-c) 30%, var(--line)); border-radius: 7px; background: color-mix(in srgb, var(--sh-c) 8%, var(--panel2)); } +.status-hero .sh-phase { font-size: 11px; } +.status-hero .sh-main { display: block; } +.status-hero .sh-detail { font-size: 12px; line-height: 1.35; } +.status-hero .sh-meta { gap: 5px; } +.status-hero .sh-workers, .status-hero .sh-elapsed { min-height: 26px; padding: 0 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel2); font-size: 10.5px; font-weight: 550; } +.status-hero .sh-flow { min-height: 28px; padding: 4px 7px; cursor: pointer; } + +/* The page-level 对话 / 运行时 switch owns navigation. The inspector is one + continuous, scrollable information column with results and launchers first. */ +.run-inspector { overflow-x: hidden; overflow-y: auto; } +.run-inspector .insp-sec-outcome { order: 1; flex: none; min-height: 0; overflow: visible; } +.run-inspector .insp-sec-panels { order: 2; flex: none; } +.run-inspector .insp-sec-workers { order: 3; flex: none; min-height: 0; overflow: visible; } +.run-inspector .insp-sec-locks { order: 4; flex: none; min-height: 0; overflow: visible; } + +/* In-run composer: one command surface with an integrated action rail. */ +.composer2.command-composer { padding: 10px 20px 14px; background: linear-gradient(to top, var(--bg) 82%, transparent); } +.command-composer .command-wrap { max-width: 940px; padding: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); box-shadow: var(--shadow-pop); } +.command-composer .command-wrap:focus-within { border-color: color-mix(in srgb, var(--accent) 48%, var(--line)); box-shadow: var(--shadow-pop), 0 0 0 2px color-mix(in srgb, var(--accent) 16%, transparent); } +.command-row { min-height: 50px; display: flex; align-items: center; gap: 10px; padding: 7px 9px 7px 12px; } +.command-target { flex: none; min-width: 0; display: inline-flex; align-items: center; gap: 6px; padding-right: 11px; color: var(--dim); border-right: 1px solid var(--line); font-size: 10.5px; white-space: nowrap; } +.command-target select { max-width: 142px; appearance: none; color: var(--text); border: 0; outline: 0; background: transparent; font: 560 11.5px/1.2 var(--font-sans); cursor: pointer; } +.command-target svg { color: var(--dim); pointer-events: none; } +.command-input { flex: 1 1 auto; min-width: 0; height: 34px; padding: 0 2px; color: var(--text); border: 0; outline: 0; background: transparent; font: 13px/1.45 var(--font-sans); } +.command-input::placeholder { color: var(--dim); } +.command-composer .send { flex: none; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 8px; } +.command-actionbar { min-height: 39px; display: flex; align-items: center; gap: 12px; padding: 5px 9px; border-top: 1px solid var(--line); background: var(--panel2); } +.command-actionbar .quick { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 3px; margin: 0; max-width: none; } +.command-actionbar .quick button { min-height: 28px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 0; border-radius: 6px; color: var(--muted); background: transparent; font-size: 10.5px; font-weight: 550; } +.command-actionbar .quick button:hover { color: var(--bright); background: var(--panel3); } +.command-actionbar .quick button.primary { color: var(--on-accent, #fff); background: var(--accent); } +.command-actionbar .quick button.primary:hover { color: var(--on-accent, #fff); background: color-mix(in srgb, var(--accent) 88%, #fff); } +.command-actionbar .quick button.danger { color: var(--red); background: transparent; } +.command-actionbar .quick button.danger:hover { color: var(--red); background: color-mix(in srgb, var(--red) 8%, transparent); } +.command-actionbar .quick .quick-sep { height: 20px; align-self: center; margin: 0 3px; background: var(--line); } +.command-hint { flex: 0 1 310px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dim); font-size: 9.5px; text-align: right; } +.command-composer .markfalse-picker { max-width: none; margin: 0; padding: 7px 9px; border-top: 1px solid var(--line); background: var(--panel2); } + +/* The inspector leaves the conversation column intentionally compact on common + laptop widths. Keep the status and command controls on one calm line there. */ +@container coordcolumn (min-width: 431px) and (max-width: 680px) { + .status-hero { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: auto; gap: 8px; padding-block: 6px; } + .status-hero .sh-status, .status-hero .sh-main, .status-hero .sh-meta { grid-column: auto; grid-row: 1; } + .status-hero .sh-meta { justify-content: flex-end; } + .status-hero .sh-flow { padding-inline: 6px; } +} + +@container coordcolumn (max-width: 430px) { + .status-hero { grid-template-columns: auto minmax(0, 1fr); gap: 5px 8px; } + .status-hero .sh-status { grid-column: 1; grid-row: 1; } + .status-hero .sh-main { grid-column: 2; grid-row: 1; } + .status-hero .sh-meta { grid-column: 2; grid-row: 2; justify-content: flex-start; } + .status-hero .sh-results-toggle { grid-template-columns: minmax(0, 1fr) max-content 16px; gap: 5px; } + .status-hero .sh-results-preview { display: none; } +} + +@media (max-width: 1320px) { + .command-hint { display: none; } +} + +@media (max-width: 760px) { + .coord-thread { padding-inline: 10px; } + .coord-wrap { padding-left: 32px; } + .coord-wrap::before { left: 8px; } + .coord-bubble .coord-node { left: -32px; } + .coord-bubble.system .coord-node { left: -30px; } + .composer2.command-composer { padding: 8px 10px 10px; } + .command-target { max-width: 120px; } + .command-target select { max-width: 86px; } + .command-hint { display: none; } +} + +/* ============================================================================ + QUIET COMMAND DECK + One neutral surface system, one selection colour, and semantic colour only + at the icon/text level. This final cascade intentionally removes the coloured + edge strips and glow-heavy states accumulated by older component variants. + ============================================================================ */ + +.shell button { + font-family: var(--font-sans); + box-shadow: none; + transition: color .12s ease, background-color .12s ease, border-color .12s ease, opacity .12s ease; +} + +.rail { + border-right-style: solid; + border-right-color: var(--line); +} +.rail-top { padding-top: 16px; } +.newsolve { + border: 1px solid var(--line); + background: var(--panel2); + box-shadow: none; +} +.newsolve:hover { border-color: var(--line2); background: var(--panel3); } +.rail-search-input { + border: 1px solid transparent; + background: var(--panel2); + box-shadow: none; +} +.rail-search-input:focus { + border-color: color-mix(in srgb, var(--accent) 52%, var(--line)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 13%, transparent); +} +.rail-sec { text-transform: none; letter-spacing: .04em; } +.rail-folder, .thread { gap: 1px; } +.thread-item { min-height: 46px; border: 1px solid transparent; } +.thread-item:hover { border-color: var(--line); background: var(--panel2); } +.thread-item.active { + border-color: color-mix(in srgb, var(--accent) 28%, var(--line)); + background: color-mix(in srgb, var(--accent) 7%, var(--panel2)); + box-shadow: none; +} +.thread-item::before, .thread-item.active::before { display: none !important; } +.thread-item .tk { color: var(--muted) !important; } +.thread-item .sub .st { color: var(--muted); } +.rail-foot { border-top-style: solid; } +.rail-foot .dot { width: 6px; height: 6px; box-shadow: none; } + +.convo-top { + min-height: 56px; + border-bottom-style: solid; + border-bottom-color: var(--line); +} +.convo-top .runstate.done { color: var(--muted); } +.convo-top .cat, .convo-top .rid { background: transparent; } +.convo-view-switch { border-color: var(--line); background: transparent; } +.convo-view-switch button { color: var(--muted); } +.convo-view-switch button.on { + color: var(--bright); + border: 1px solid color-mix(in srgb, var(--accent) 44%, var(--line)); + background: color-mix(in srgb, var(--accent) 9%, var(--panel)); + box-shadow: none; +} +.icon-btn:hover, .convo-top .lang-btn:hover, .btw-btn:hover { + color: var(--text); + border-color: var(--line); + background: var(--panel2); +} + +.status-hero { + --sh-c: var(--muted) !important; + border-color: var(--line); + background: var(--panel); + box-shadow: none !important; +} +.status-hero:hover, .status-hero.open { background: var(--panel2); box-shadow: none; } +.status-hero .sh-ico { + color: var(--muted); + border-color: var(--line); + background: var(--panel2); +} +.status-hero .sh-phase { color: var(--text); } +.status-hero .sh-flow, +.status-hero .sh-workers, +.status-hero .sh-elapsed { + color: var(--muted); + border-color: var(--line); + background: transparent; +} +.status-hero .sh-dot { background: var(--green); } +.status-hero .sh-main { overflow: hidden; } +.status-hero .sh-results-toggle { + width: 100%; min-width: 0; min-height: 28px; display: grid; + grid-template-columns: max-content minmax(0, 1fr) max-content 16px; + align-items: center; gap: 8px; padding: 3px 5px; border: 0; border-radius: 6px; + color: var(--text); background: transparent; text-align: left; +} +.status-hero .sh-results-toggle:hover { background: var(--panel3); } +.status-hero .sh-results-toggle:focus-visible { outline: 1px solid var(--line2); outline-offset: 1px; } +.status-hero .sh-results-count { font-size: 10.5px; font-weight: 650; white-space: nowrap; } +.status-hero .sh-results-preview { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--muted); font: 10.5px/1.3 var(--font-mono); +} +.status-hero .sh-results-more { + min-width: 24px; height: 20px; display: inline-flex; align-items: center; justify-content: center; + padding: 0 5px; border: 1px solid var(--line); border-radius: 5px; + color: var(--muted); background: var(--panel2); font: 650 9px/1 var(--font-mono); +} +.status-hero .sh-results-toggle svg { color: var(--dim); transition: transform .14s ease; } +.status-hero-shell.results-open .sh-results-toggle svg { transform: rotate(180deg); } +.sh-results-panel { + margin-top: 4px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; + background: var(--panel); box-shadow: var(--shadow-md); +} +.sh-results-head { + min-height: 34px; display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 6px 10px; border-bottom: 1px solid var(--line); color: var(--muted); + background: var(--panel2); font-size: 10px; font-weight: 600; +} +.sh-results-head span { display: inline-flex; align-items: center; gap: 6px; } +.sh-results-list { max-height: 184px; overflow: auto; } +.sh-result-row { + min-width: 0; display: grid; grid-template-columns: 30px minmax(0, 1fr); align-items: stretch; + border-bottom: 1px solid var(--line); +} +.sh-result-row:last-child { border-bottom: 0; } +.sh-result-index { + display: grid; place-items: center; color: var(--dim); border-right: 1px solid var(--line); + background: var(--panel2); font: 9px/1 var(--font-mono); +} +.sh-result-value.copytext { + width: 100%; min-height: 38px; justify-content: space-between; align-items: center; + padding: 7px 10px; border: 0; color: var(--text); background: transparent; + font: 11px/1.4 var(--font-mono); text-align: left; +} +.sh-result-value.copytext:hover { background: var(--panel2); } +.sh-result-value.copytext .copytext-val { overflow-wrap: anywhere; } +.sh-result-value.copytext .copytext-aff { opacity: .65; } + +.coord-wrap::before { background: var(--line); } +.coord-bubble, +.coord-bubble.you, +.coord-bubble.coordinator, +.coord-bubble.system { + border-left: 0 !important; + box-shadow: none !important; +} +.coord-bubble .coord-node, +.coord-bubble.you .coord-node, +.coord-bubble.coordinator .coord-node, +.coord-bubble.system .coord-node, +.coord-bubble.system.insight .coord-node, +.coord-bubble.system.guidance .coord-node, +.coord-bubble.system.status .coord-node { + color: var(--muted); + border-color: var(--line2); + background: var(--bg); + box-shadow: 0 0 0 4px var(--bg); +} +.coord-bubble.you .coord-node { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 38%, var(--line)); } +.coord-bubble.you .who { color: var(--muted); } +.coord-bubble.digest, +.coord-bubble.answer, +.coord-bubble.answer.none { + border: 1px solid var(--line); + background: var(--panel); + box-shadow: none !important; +} +.coord-bubble.digest .coord-node { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 38%, var(--line)); } +.coord-bubble.answer .coord-node { color: var(--green); border-color: color-mix(in srgb, var(--green) 38%, var(--line)); } +.coord-bubble.answer .coord-digest-title, +.coord-bubble.digest .coord-digest-title { color: var(--muted); } +.coord-bubble.answer .ans-flag, +.coord-bubble.answer .ans-flag.copytext, +.coord-bubble.answer .ans-flag.goal.copytext { + color: var(--text); + font-size: 13px; + font-weight: 560; +} + +.composer2, +.composer2.command-composer { background: var(--bg); } +.command-composer .command-wrap, +.composer2 .wrap { + border: 1px solid var(--line); + background: var(--panel); + box-shadow: none; +} +.command-composer .command-wrap:focus-within, +.composer2 .wrap:focus-within { + border-color: color-mix(in srgb, var(--accent) 48%, var(--line)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent); +} +.command-actionbar { background: var(--panel2); } +.command-actionbar .quick button, +.composer2 .quick button { color: var(--muted); background: transparent; border-color: transparent; } +.command-actionbar .quick button:hover, +.composer2 .quick button:hover { color: var(--text); border-color: transparent; background: var(--panel3); } +.command-actionbar .quick button.primary, +.composer2 .quick button.primary, +.composer2 .send { + color: var(--on-accent, #fff); + border-color: var(--accent); + background: var(--accent); + box-shadow: none; +} +.command-actionbar .quick button.danger, +.composer2 .quick button.danger { color: var(--muted); } +.command-actionbar .quick button.danger:hover, +.composer2 .quick button.danger:hover { color: var(--red); background: var(--panel3); } + +.run-inspector { background: var(--bg); } +.insp-sec { + border-color: var(--line); + border-radius: 8px; + background: transparent; + box-shadow: none; +} +.insp-run-flag, +.insp-run-flag.copytext, +.insp-run-flag.goal, +.insp-run-flag.pending { + color: var(--text); + border-color: var(--line); + background: var(--panel2); + font-size: 12.5px; + font-weight: 560; +} +.insp-run-flag.copytext:hover, +.insp-run-flag.goal.copytext:hover { border-color: var(--line2); } +.insp-flag-false { + color: var(--red); + border-color: var(--line); + background: transparent; +} +.insp-flag-false:hover { border-color: color-mix(in srgb, var(--red) 42%, var(--line)); background: var(--panel2); } +.insp-flag-count, .ans-flag-count { color: var(--muted); } +.insp-chip, +.insp-chip.verified, +.insp-chip.candidates, +.insp-chip.intents, +.insp-chip.dead, +.insp-chip.cost { + color: var(--muted); + border-color: var(--line); + background: transparent; +} +.insp-chip.verified svg { color: var(--green); } +.insp-chip.candidates svg { color: var(--amber); } +.insp-chip.intents svg { color: var(--accent); } +.insp-chip.dead svg { color: var(--red); } +.insp-signals { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} +.insp-signal { + min-width: 0; + min-height: 50px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 5px 2px; + border: 0; + border-right: 1px solid var(--line); + border-radius: 0; + color: var(--muted); + background: transparent; + font: inherit; + cursor: pointer; + transition: color .12s ease, background-color .12s ease, box-shadow .12s ease; +} +.insp-signal:last-child { border-right: 0; } +.insp-signal > span { + max-width: 100%; + display: inline-flex; + align-items: center; + gap: 3px; + overflow: hidden; + color: var(--dim); + font-size: 9px; + line-height: 1; + white-space: nowrap; + text-overflow: ellipsis; +} +.insp-signal > span svg { width: 10px; height: 10px; } +.insp-signal > b { + max-width: 100%; + overflow: hidden; + color: var(--text); + font: 650 11px/1.1 var(--font-mono); + font-variant-numeric: tabular-nums; + white-space: nowrap; + text-overflow: ellipsis; +} +.insp-signal.cost > b { font-size: 9.5px; } +.insp-signal:hover { color: var(--text); background: var(--panel2); } +.insp-signal.on { + color: var(--text); + background: var(--panel3); + box-shadow: inset 0 0 0 1px var(--line2); +} +.insp-signal.on > span { color: var(--muted); } +.insp-signal:focus-visible { position: relative; z-index: 1; outline: 2px solid var(--accent); outline-offset: -2px; } +.insp-signal-detail { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel2); + animation: panel-fade-in .16s var(--ease-out-soft) both; +} +.insp-signal-detail-head { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px 6px 10px; + border-bottom: 1px solid var(--line); +} +.insp-signal-detail-head > span { min-width: 0; display: flex; align-items: center; gap: 7px; } +.insp-signal-detail-head strong { overflow: hidden; color: var(--text); font-size: 11px; font-weight: 650; white-space: nowrap; text-overflow: ellipsis; } +.insp-signal-detail-head b { + min-width: 17px; + height: 17px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 4px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--muted); + background: var(--panel); + font: 600 9px/1 var(--font-mono); +} +.insp-signal-detail-head button { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 5px; + color: var(--dim); + background: transparent; +} +.insp-signal-detail-head button:hover { color: var(--text); background: var(--panel3); } +.insp-signal-list { max-height: 176px; overflow: auto; margin: 0; padding: 0; list-style: none; } +.insp-signal-list li { + min-width: 0; + display: grid; + grid-template-columns: 25px minmax(0, 1fr); + border-bottom: 1px solid var(--line); + counter-increment: signal-row; +} +.insp-signal-list { counter-reset: signal-row; } +.insp-signal-list li::before { + content: counter(signal-row, decimal-leading-zero); + display: grid; + place-items: center; + color: var(--dim); + border-right: 1px solid var(--line); + background: var(--panel); + font: 8.5px/1 var(--font-mono); +} +.insp-signal-list li:last-child { border-bottom: 0; } +.insp-signal-list li > span { + min-width: 0; + display: -webkit-box; + overflow: hidden; + padding: 8px 9px; + color: var(--muted); + font-size: 10.5px; + line-height: 1.42; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.insp-signal-empty { padding: 16px 10px; color: var(--dim); font-size: 10.5px; text-align: center; } +.insp-cost-total { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 10px; + padding: 9px 10px; + border-bottom: 1px solid var(--line); +} +.insp-cost-total > span { color: var(--dim); font-size: 9.5px; } +.insp-cost-total > b { color: var(--text); font: 650 12px/1.2 var(--font-mono); } +.insp-cost-total > small { grid-column: 1 / -1; color: var(--muted); font: 9.5px/1.3 var(--font-mono); } +.insp-cost-list { max-height: 168px; overflow: auto; } +.insp-cost-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 10px; + border-bottom: 1px solid var(--line); +} +.insp-cost-row:last-child { border-bottom: 0; } +.insp-cost-agent, .insp-cost-value { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.insp-cost-agent { align-items: flex-start; } +.insp-cost-value { align-items: flex-end; text-align: right; } +.insp-cost-row b { max-width: 122px; overflow: hidden; color: var(--text); font-size: 10px; font-weight: 600; white-space: nowrap; text-overflow: ellipsis; } +.insp-cost-row small { color: var(--dim); font: 8.5px/1.2 var(--font-mono); } +.insp-signal-detail-foot { + min-height: 33px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 7px 5px 10px; + border-top: 1px solid var(--line); + background: var(--panel); +} +.insp-signal-detail-foot > span { color: var(--dim); font-size: 9px; } +.insp-signal-detail-foot button { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 3px; + padding: 5px 6px; + border: 0; + border-radius: 5px; + color: var(--muted); + background: transparent; + font: inherit; + font-size: 9.5px; + font-weight: 600; +} +.insp-signal-detail-foot button:hover { color: var(--text); background: var(--panel3); } +@media (prefers-reduced-motion: reduce) { + .insp-signal, .insp-signal-detail { transition: none; animation: none; } +} +.insp-panel-btn { + min-height: 40px; + color: var(--muted); + border-color: var(--line); + border-radius: 7px; + background: transparent; + box-shadow: none; +} +.insp-panel-btn:hover { color: var(--text); border-color: var(--line2); background: var(--panel2); } +.insp-panel-btn.on { + color: var(--text); + border-color: color-mix(in srgb, var(--accent) 42%, var(--line)); + background: color-mix(in srgb, var(--accent) 8%, var(--panel2)); +} +.insp-panel-btn.writeup { color: var(--muted); } +.insp-panel-btn:hover .insp-panel-kbd, +.insp-panel-btn.on .insp-panel-kbd { color: var(--muted); border-color: var(--line2); } +.iwk, .iwk-clickable { border-color: var(--line); background: transparent; } +.iwk-clickable:hover { border-color: var(--line2); background: var(--panel2); } +.iwk-avatar { color: var(--muted) !important; border-color: var(--line2) !important; background: var(--panel2) !important; } + +/* Remove colour-strip component variants throughout conversation, runtime and + secondary workspaces. Status remains available through labels and icons. */ +.toast, +.brief-card.outcome, +.workbench .msg, +.worker-card, +.hitl-card, +.report-row, +.evi-item { + border-left-width: 1px !important; + border-left-color: var(--line) !important; +} +.toast { padding-left: 12px; } +.bb-group, +.bb-group-node, +.bb-node, +.bb-node[style*="--wc"], +.graph-node, +.artifact-row { box-shadow: none !important; } +.artifact-row::before, +.trace-ledger .act-msg::before, +.activity-feed .act-msg::before { display: none !important; } + +.conn-banner { + color: var(--amber); + border-color: var(--line); + background: var(--panel2); + box-shadow: none; +} + +.runtime-navigation { border-bottom-style: solid; } +.artifact-head.runtime-head { border-bottom-style: solid; } +.runtime-titlemark { color: var(--accent); border-color: var(--line); background: var(--panel); box-shadow: none; } +.runtime-primary-nav button.on, +.runtime-view-nav button.on { + color: var(--bright); + border-color: color-mix(in srgb, var(--accent) 36%, var(--line)); + background: color-mix(in srgb, var(--accent) 8%, var(--panel)); + box-shadow: none; +} +.runtime-trace { + --lane-control: var(--accent); + --lane-worker: var(--green); + --lane-tool: var(--amber); + --lane-evidence: var(--violet); + box-shadow: none; +} +.runtime-trace-labels i, +.runtime-trace-mark.kind-flag, +.runtime-trace-now, +.runtime-trace-mark.selected { box-shadow: none; } +.runtime-trace-mark.selected { outline: 1px solid var(--bright); outline-offset: 1px; } + +@media (prefers-reduced-motion: no-preference) { + .motion-panel-btn:hover:not(:disabled):not(:active), + .thread-item.motion-rail-item:hover:not(:active) { transform: none; } +} + +/* ---------- Worker settings / task-based information architecture --------- + Global settings keep one draft/save scope. Credential actions persist from + their own page, which is stated independently in the page and footer. */ +.worker-settings.ws2 { + width: min(1110px, calc(100vw - 42px)); + height: min(88vh, 790px); + max-height: min(88vh, 790px); + border-radius: 12px; + background: var(--panel2); + box-shadow: 0 24px 70px rgba(0, 0, 0, .42); +} +.worker-settings.ws2 .modal-head { + min-height: 72px; + padding: 17px 22px 15px; + background: var(--panel2); + border-bottom-color: var(--line); +} +.worker-settings.ws2 .modal-head span { font-size: 17px; letter-spacing: -.015em; } +.worker-settings.ws2 .modal-head p { margin-top: 4px; font-size: 11.5px; } +.worker-settings.ws2 .modal-x { background: transparent; border-color: transparent; } + +.worker-settings.ws2 .ws2-body { background: var(--panel2); } +.worker-settings.ws2 .ws2-rail { + flex-basis: 194px; + padding: 14px 10px; + gap: 3px; + background: color-mix(in srgb, var(--rail) 88%, var(--panel2)); + border-right-color: var(--line); +} +.worker-settings.ws2 .ws2-tab { + min-height: 42px; + padding: 9px 12px; + border-radius: 7px; + color: var(--muted); + border-color: transparent; + box-shadow: none; +} +.worker-settings.ws2 .ws2-tab:hover { background: var(--ink-wash); color: var(--text); } +.worker-settings.ws2 .ws2-tab.on { + color: var(--bright); + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); + border-color: color-mix(in srgb, var(--accent) 28%, var(--line)); + box-shadow: inset 2px 0 0 var(--accent); +} +.worker-settings.ws2 .ws2-content { + padding: 0 26px 24px; + background: var(--panel2); +} +.ws2-page { min-height: 100%; } +.ws2-page-head { + min-height: 88px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 22px; + border-bottom: 1px solid var(--line); +} +.ws2-page-head h2 { margin: 0; color: var(--bright); font-size: 15px; line-height: 1.3; letter-spacing: -.012em; } +.ws2-page-head p { max-width: 680px; margin: 6px 0 0; color: var(--muted); font-size: 11.5px; line-height: 1.55; } +.ws2-page-context, +.ws2-context-state { + flex: none; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} +.ws2-context-state.clean { color: var(--green); } +.ws2-context-state.dirty { color: var(--amber); } +.ws-model-discovery-actions { + flex: none; + display: flex; + align-items: center; + gap: 10px; +} +.ws-model-discover { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + padding: 0 9px; + border: 1px solid var(--border2); + border-radius: 6px; + background: var(--panel); + color: var(--text); + font-size: 11px; + cursor: pointer; +} +.ws-model-discover:hover:not(:disabled) { border-color: var(--accent); color: var(--bright); } +.ws-model-discover:disabled { opacity: .55; cursor: default; } +.ws-model-discovery-detail { + margin: 10px 0 0; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--muted); + font-size: 11px; + line-height: 1.5; +} + +.ws2-overview-band { + display: grid; + grid-template-columns: 165px minmax(0, 1fr); + align-items: center; + min-height: 76px; + border-bottom: 1px solid var(--line); +} +.ws2-overview-band > h3, +.ws2-overview-section h3, +.ws2-schedule-summary h3, +.ws2-subhead h3 { + margin: 0; + color: var(--bright); + font-size: 12.5px; + font-weight: 650; +} +.ws2-overview-value { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + color: var(--text); + background: transparent; + border: 0; + text-align: left; + cursor: pointer; +} +.ws2-overview-value > span { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 3px; } +.ws2-overview-value strong { color: var(--bright); font-size: 12.5px; } +.ws2-overview-value small { color: var(--muted); font-size: 11px; } +.ws2-overview-value > svg:last-child { color: var(--dim); } +.ws2-status-band { grid-template-columns: 165px repeat(3, minmax(0, 1fr)); gap: 8px; } +.ws2-status-band button { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 6px; + color: var(--text); + background: transparent; + border: 0; + font: inherit; + font-size: 11.5px; + cursor: pointer; +} +.ws2-status-band button:hover span:last-child { color: var(--bright); } +.ws2-next-band { grid-template-columns: 165px minmax(0, 1fr) auto; gap: 12px; } +.ws2-next-band > span { color: var(--text); font-size: 12px; } +.ws2-next-band .ws-btn { display: inline-flex; align-items: center; gap: 5px; } + +.ws2-overview-section { padding: 19px 0 10px; border-bottom: 1px solid var(--line); } +.ws2-overview-section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; } +.ws2-overview-section-head button { + display: inline-flex; align-items: center; gap: 4px; color: var(--muted); + background: transparent; border: 0; font: inherit; font-size: 11px; cursor: pointer; +} +.ws2-overview-section-head button:hover { color: var(--bright); } +.ws2-overview-table { width: 100%; } +.ws2-overview-tr { + width: 100%; + display: grid; + grid-template-columns: minmax(150px, 1.25fr) 90px minmax(140px, 1.2fr) 82px minmax(120px, 1fr); + align-items: center; + gap: 12px; + min-height: 48px; + padding: 0 8px; + color: var(--text); + background: transparent; + border: 0; + border-bottom: 1px solid var(--line); + text-align: left; + font: inherit; + font-size: 11.5px; +} +button.ws2-overview-tr { cursor: pointer; } +button.ws2-overview-tr:hover { background: var(--ink-wash); } +.ws2-overview-tr:last-child { border-bottom: 0; } +.ws2-overview-th { min-height: 31px; color: var(--dim); font-size: 10px; letter-spacing: .04em; } +.ws2-worker-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.ws2-worker-id b { color: var(--bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws2-worker-id small { color: var(--dim); font-size: 10px; } +.ws2-overview-tr code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws2-summary-state { display: inline-flex; align-items: center; gap: 7px; min-width: 0; } +.ws2-overview-empty { margin: 14px 0; color: var(--dim); font-size: 11.5px; } +.ws2-schedule-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0; + min-height: 66px; + padding-top: 7px; +} +.ws2-schedule-summary h3 { min-width: 165px; } +.ws2-schedule-summary > span { padding: 0 17px; border-left: 1px solid var(--line); color: var(--muted); font-size: 11.5px; } +.ws2-schedule-summary b { margin-left: 4px; color: var(--bright); font-weight: 650; } + +.ws2-subhead { + min-height: 42px; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 18px; + margin: 0; +} +.ws2-subhead > span { color: var(--dim); font-size: 10.5px; line-height: 1.45; text-align: right; } +.ws2-readiness-head { border-top: 1px solid var(--line); } +.ws2-settings-block { padding: 19px 0 21px; border-bottom: 1px solid var(--line); } +.worker-settings.ws2 .ws-grid { gap: 18px 22px; } +.worker-settings.ws2 .ws-field { gap: 7px; } +.worker-settings.ws2 .ws-field label { color: var(--muted); text-transform: none; letter-spacing: 0; font-size: 11px; } +.worker-settings.ws2 .ws-field select, +.worker-settings.ws2 .ws-field textarea { + width: 100%; min-height: 34px; border-radius: 6px; background: var(--panel); border-color: var(--line); color: var(--text); +} +.worker-settings.ws2 .ws-field input { min-height: 34px; color: var(--text); } + +/* Roster is a compact editable ledger. Expanded controls stay attached to the + row and drag/drop continues to use the existing handlers. */ +.worker-settings.ws2 .ws2-roster { gap: 0; border-top: 1px solid var(--line); } +.worker-settings.ws2 .ws2-empty { + margin: 0 0 14px; + padding: 13px 18px; + border: 1px solid var(--line); + border-radius: 7px; + background: transparent; +} +.worker-settings.ws2 .ws2-steps li { border-bottom-color: var(--line); } +.worker-settings.ws2 .ws2-stepn { background: var(--panel); } +.worker-settings.ws2 .ws2-card { + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + background: transparent; +} +.worker-settings.ws2 .ws2-card.blocked { box-shadow: inset 2px 0 0 var(--red); } +.worker-settings.ws2 .ws2-card.disabled { background: color-mix(in srgb, var(--panel) 44%, transparent); opacity: .72; } +.worker-settings.ws2 .ws2-card-row { min-height: 55px; padding: 8px 5px; } +.worker-settings.ws2 .ws2-card .ws-card-name, +.worker-settings.ws2 .ws2-card .ws-card-model select, +.worker-settings.ws2 .ws2-card .ws-card-model-custom, +.worker-settings.ws2 .ws2-card .ws-card-effort select { border-radius: 5px; background: transparent; border-color: transparent; } +.worker-settings.ws2 .ws2-card .ws-card-name:hover, +.worker-settings.ws2 .ws2-card .ws-card-model select:hover, +.worker-settings.ws2 .ws2-card .ws-card-model-custom:hover, +.worker-settings.ws2 .ws2-card .ws-card-effort select:hover:not(:disabled) { border-color: var(--line); background: var(--panel); } +.worker-settings.ws2 .ws2-expand { background: var(--panel); border-top: 1px solid var(--line); } +.worker-settings.ws2 .ws2-blocker { border-top-color: var(--line); background: transparent; } +.worker-settings.ws2 .ws-add-row { margin: 14px 0 0; } +.worker-settings.ws2 .ws-add-btn { border-radius: 6px; background: transparent; border-color: var(--line); } + +/* Account readiness spans the page. Saved accounts and the local immediate-save + form share a two-column workspace below it. */ +.worker-settings.ws2 .ws2-acct-list { gap: 0; margin: 0 0 20px; border-top: 1px solid var(--line); } +.worker-settings.ws2 .ws2-acct { + min-height: 48px; + padding: 7px 5px; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + background: transparent; +} +.worker-settings.ws2 .ws2-acct.miss { background: transparent; box-shadow: inset 2px 0 0 var(--red); } +.ws2-account-columns { + display: grid; + grid-template-columns: minmax(310px, 1.05fr) minmax(320px, .95fr); + min-height: 340px; + border-top: 1px solid var(--line); +} +.ws2-account-saved { min-width: 0; padding: 18px 22px 10px 0; border-right: 1px solid var(--line); } +.worker-settings.ws2 .ws-account-list { gap: 0; margin: 0; } +.worker-settings.ws2 .ws-account-row { + min-height: 45px; + grid-template-columns: minmax(108px, 1.25fr) 62px minmax(80px, .9fr) auto auto 28px; + gap: 7px; + padding: 5px 4px; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + background: transparent; +} +.worker-settings.ws2 .ws-account-row:hover { background: var(--ink-wash); } +.worker-settings.ws2 .ws2-form { + min-width: 0; + margin: 0; + padding: 18px 0 10px 24px; + border: 0; + border-radius: 0; + background: transparent; +} +.worker-settings.ws2 .ws2-form .ws-section-head { min-height: 42px; margin: 0; } +.worker-settings.ws2 .ws2-form .ws-grid { grid-template-columns: 1fr; gap: 14px; } +.worker-settings.ws2 .ws2-form .ws-foot { flex-wrap: wrap; gap: 8px; } + +.worker-settings.ws2 .ws2-review { margin-top: 0; border: 0; border-radius: 0; background: transparent; } +.worker-settings.ws2 .ws2-pill { border-radius: 4px; } +.worker-settings.ws2 .ws-image { min-height: 52px; padding: 9px 0; border-bottom: 1px solid var(--line); } +.worker-settings.ws2 .ws-note { border-radius: 6px; background: color-mix(in srgb, var(--accent) 5%, transparent); } + +.worker-settings.ws2 .ws2-savebar { + min-height: 64px; + padding: 10px 22px; + border-top-color: var(--line); + background: var(--panel2); +} +.worker-settings.ws2 .ws2-foot-note { color: var(--muted); } +.worker-settings.ws2 .ws-btn, +.worker-settings.ws2 .ws-mini-btn, +.worker-settings.ws2 .ws-save { border-radius: 6px; } +.worker-settings.ws2 .ws-save:disabled { opacity: .38; cursor: default; } + +@media (max-width: 900px) { + .worker-settings.ws2 { width: calc(100vw - 20px); height: calc(100vh - 20px); max-height: calc(100vh - 20px); } + .worker-settings.ws2 .ws2-rail { flex-basis: 166px; } + .worker-settings.ws2 .ws2-content { padding-left: 18px; padding-right: 18px; } + .ws2-status-band { grid-template-columns: 130px 1fr; } + .ws2-status-band h3 { grid-row: 1 / 4; } + .ws2-overview-band, .ws2-next-band { grid-template-columns: 130px minmax(0, 1fr); } + .ws2-next-band .ws-btn { grid-column: 2; justify-self: start; } + .ws2-overview-tr { grid-template-columns: minmax(130px, 1.2fr) 78px minmax(120px, 1fr) 70px; } + .ws2-overview-tr > :last-child { display: none; } + .ws2-schedule-summary h3 { min-width: 130px; } + .ws2-account-columns { grid-template-columns: 1fr; } + .ws2-account-saved { padding-right: 0; border-right: 0; border-bottom: 1px solid var(--line); } + .worker-settings.ws2 .ws2-form { padding-left: 0; } +} + +@media (max-width: 700px) { + .worker-settings.ws2 .ws2-body { flex-direction: column; } + .worker-settings.ws2 .ws2-rail { + flex: none; flex-direction: row; flex-wrap: nowrap; overflow-x: auto; + padding: 7px 9px; border-right: 0; border-bottom: 1px solid var(--line); + } + .worker-settings.ws2 .ws2-tab { flex: none; min-height: 34px; padding: 6px 9px; } + .worker-settings.ws2 .ws2-tab.on { box-shadow: none; } + .worker-settings.ws2 .ws2-content { padding: 0 14px 18px; } + .ws2-page-head { min-height: 76px; align-items: flex-start; padding: 12px 0; } + .ws2-page-context, .ws2-context-state { display: none; } + .ws2-overview-band, .ws2-status-band, .ws2-next-band { grid-template-columns: 1fr; gap: 3px; padding: 10px 0; } + .ws2-status-band h3 { grid-row: auto; } + .ws2-next-band .ws-btn { grid-column: 1; } + .ws2-overview-th { display: none; } + .ws2-overview-tr { grid-template-columns: minmax(120px, 1fr) 72px minmax(110px, 1fr); } + .ws2-overview-tr > :nth-child(4), .ws2-overview-tr > :last-child { display: none; } + .ws2-schedule-summary h3 { width: 100%; margin-bottom: 10px; } + .ws2-schedule-summary > span { padding: 4px 10px; } + .worker-settings.ws2 .ws2-savebar { padding: 9px 14px; } + .worker-settings.ws2 .ws2-foot-note { max-width: 50%; } +} + +/* Run-result interaction and lifecycle semantics. + A local pointer-follow light is reserved for copyable results so it remains + a single, recognizable interaction rather than a page-wide decoration. */ +.copytext[data-flag-glow="true"] { + --copy-glow-x: 50%; + --copy-glow-y: 50%; + position: relative; + isolation: isolate; + overflow: hidden; + border-radius: 6px; + transition: + color .18s cubic-bezier(.22, 1, .36, 1), + border-color .18s cubic-bezier(.22, 1, .36, 1), + background-color .18s cubic-bezier(.22, 1, .36, 1), + box-shadow .18s cubic-bezier(.22, 1, .36, 1), + transform .18s cubic-bezier(.22, 1, .36, 1); +} +.copytext[data-flag-glow="true"]::before { + content: ""; + position: absolute; + inset: -1px; + z-index: 0; + pointer-events: none; + opacity: 0; + background: radial-gradient( + circle 92px at var(--copy-glow-x) var(--copy-glow-y), + color-mix(in srgb, var(--gold) 28%, transparent) 0, + color-mix(in srgb, var(--gold) 12%, transparent) 42%, + transparent 74% + ); + transition: opacity .18s cubic-bezier(.22, 1, .36, 1); +} +.copytext[data-flag-glow="true"] > * { position: relative; z-index: 1; } +.copytext[data-flag-glow="true"]:hover, +.copytext[data-flag-glow="true"]:focus-visible { + color: color-mix(in srgb, var(--gold) 84%, var(--text)) !important; + border-color: color-mix(in srgb, var(--gold) 36%, var(--line)) !important; + background-color: color-mix(in srgb, var(--gold) 6%, transparent) !important; + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--gold) 9%, transparent), + 0 8px 24px color-mix(in srgb, var(--gold) 9%, transparent); + transform: translateY(-1px); +} +.copytext[data-flag-glow="true"]:hover::before, +.copytext[data-flag-glow="true"]:focus-visible::before { opacity: 1; } +.copytext[data-flag-glow="true"].copied { + color: color-mix(in srgb, var(--green) 82%, var(--text)) !important; + border-color: color-mix(in srgb, var(--green) 34%, var(--line)) !important; + background-color: color-mix(in srgb, var(--green) 7%, transparent) !important; +} +.coord-bubble.answer .ans-flag.copytext[data-flag-glow="true"] { + margin: -3px -5px; + padding: 3px 5px; + border: 1px solid transparent; +} + +/* Rail terminal states use different symbols, labels and colour families. + This remains readable without colour because solved uses a flag while a + normally ended run uses a closed stop mark. */ +.thread-item .tk { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 5px; + color: var(--muted) !important; +} +.thread-item .tk.st-solved { + color: color-mix(in srgb, var(--gold) 88%, var(--text)) !important; + border-color: color-mix(in srgb, var(--gold) 38%, transparent); + background: color-mix(in srgb, var(--gold) 13%, transparent); +} +.thread-item .tk.st-finished { + color: var(--muted) !important; + border-color: var(--line); + background: var(--panel2); +} +.thread-item .tk.st-failed { + color: var(--red) !important; + border-color: color-mix(in srgb, var(--red) 25%, transparent); + background: color-mix(in srgb, var(--red) 7%, transparent); +} +.thread-item:has(.tk.st-solved) .sub .st { + color: color-mix(in srgb, var(--gold) 82%, var(--text)); + border-color: color-mix(in srgb, var(--gold) 30%, var(--line)); + background: color-mix(in srgb, var(--gold) 10%, transparent); +} +.thread-item:has(.tk.st-finished) .sub .st { + color: var(--muted); + border-color: var(--line); + background: transparent; +} + +@media (prefers-reduced-motion: reduce) { + .copytext[data-flag-glow="true"], + .copytext[data-flag-glow="true"]::before { transition: none; } + .copytext[data-flag-glow="true"]:hover, + .copytext[data-flag-glow="true"]:focus-visible { transform: none; } +} + +/* Shared list selection ---------------------------------------------------- + Single-choice lists use one measured surface that moves between items. The + selected row keeps text/icon emphasis, while its background no longer pops + independently. Selection motion is transform-only. */ +.selection-glide-host { + position: relative; + isolation: isolate; +} +.selection-glider { + position: absolute; + top: 0; + left: 0; + z-index: 0; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; + transform-origin: top left; + will-change: transform; +} +.selection-glider-surface { + position: absolute; + inset: 0; + border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--line)); + border-radius: 7px; + background: color-mix(in srgb, var(--accent) 7%, var(--panel2)); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bright) 3%, transparent); +} +.selection-glider-rail .selection-glider-surface { + border-color: color-mix(in srgb, var(--accent) 25%, var(--line)); + background: color-mix(in srgb, var(--accent) 7%, var(--panel2)); +} +.selection-glider-palette .selection-glider-surface { + border-color: var(--line2); + background: var(--panel2); + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--accent) 64%, transparent); +} +.selection-glider-settings .selection-glider-surface { + border-color: color-mix(in srgb, var(--accent) 25%, var(--line)); + background: color-mix(in srgb, var(--accent) 8%, var(--panel)); +} +.selection-glider-grid .selection-glider-surface { + border-color: color-mix(in srgb, var(--accent) 22%, var(--line)); + background: color-mix(in srgb, var(--accent) 6%, var(--panel2)); +} +.selection-glide-host > :not(.selection-glider), +.selection-glide-host .thread-item, +.selection-glide-host .cmdk-item { + position: relative; + z-index: 1; +} + +/* The shared layer owns selected backgrounds; items continue to own content, + focus rings and semantic colours. */ +.rail-scroll.selection-glide-host .thread-item.active, +.rail-scroll.selection-glide-host .thread-item.active:hover, +.cmdk-list.selection-glide-host .cmdk-item.sel, +.worker-settings.ws2 .ws2-rail.selection-glide-host .ws2-tab.on, +.worker-settings.ws2 .ws2-rail.selection-glide-host .ws2-tab.on:hover, +.convo-view-switch.selection-glide-host button.on, +.convo-view-switch.selection-glide-host button.on:hover, +.runtime-primary-nav.selection-glide-host button.on, +.runtime-primary-nav.selection-glide-host button.on:hover, +.runtime-view-nav.selection-glide-host button.on, +.runtime-view-nav.selection-glide-host button.on:hover, +.artifact-head .tabs.selection-glide-host button.on, +.artifact-head .tabs.selection-glide-host button.on:hover, +.insp-panels.selection-glide-host .insp-panel-btn.on, +.insp-panels.selection-glide-host .insp-panel-btn.on:hover { + border-color: transparent; + background: transparent; + box-shadow: none; +} + +@media (prefers-reduced-motion: reduce) { + .selection-glider { will-change: auto; } +} + +/* ========================================================================== */ +/* Worker settings v2 — ordered ordinary pool with a separate Review channel */ +/* Deck-scale values (rail / convo-top / suggest cards) via .wsettings-page */ +/* tokens; nothing here invents its own rhythm. */ +/* ========================================================================== */ + +/* ── roster workspace (出战配置) ──────────────────────────────────────────── */ +.wroster-workspace { + min-width: 0; + min-height: 0; + overflow: auto; + padding: 24px var(--wsettings-content-pad) 36px; + background: var(--panel2); +} +.wsettings-section-head { + width: 100%; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} +.wsettings-section-copy { min-width: 0; display: grid; gap: 4px; } +.wsettings-section-head h2 { margin: 0; color: var(--bright); font-size: 14px; font-weight: 700; line-height: 1.3; } +.wsettings-section-head p { margin: 0; color: var(--muted); font-size: var(--ws-fs-small); line-height: 1.5; } +.wroster-head { + max-width: var(--wsettings-content-max); + margin: 0 auto 16px; +} +.wroster-head-main { margin-bottom: 10px; } +.wreview-slot h3 { margin: 0; color: var(--bright); font-size: 14px; font-weight: 700; line-height: 1.3; } +.wreview-slot p { margin: 3px 0 0; color: var(--muted); font-size: var(--ws-fs-small); line-height: 1.5; } +.wroster-check-all { + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line2)); + border-radius: var(--ws-r-sm); + color: var(--bright); + background: color-mix(in srgb, var(--accent) 9%, var(--panel)); + font-size: var(--ws-fs-small); + font-weight: 600; + white-space: nowrap; + transition: border-color .15s ease, background-color .15s ease, transform .16s var(--ease-out-soft); +} +.wroster-check-all:hover:not(:disabled) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--panel)); transform: translateY(-1px); } +.wroster-check-all:disabled { opacity: .56; cursor: not-allowed; transform: none; } +.wroster-check-all.running svg { animation: railspin .75s linear infinite; } +.wroster-addbar { display: flex; align-items: center; justify-content: flex-end; gap: 6px; white-space: nowrap; } +.wroster-addbar > span { margin-right: 2px; color: var(--dim); font-size: var(--ws-fs-micro); } +.wroster-primary-engines { display: flex; align-items: center; gap: 5px; } +.wroster-primary-engines button { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: var(--ws-r-sm); + color: var(--muted); + background: var(--panel); + font-size: var(--ws-fs-small); + transition: color .13s ease, border-color .13s ease, background-color .13s ease; +} +.wroster-primary-engines button:hover:not(:disabled) { color: var(--bright); border-color: var(--line2); background: var(--panel3); } +.wroster-primary-engines button:disabled { opacity: .42; cursor: not-allowed; } +.wroster-add { position: relative; flex: none; } +.wroster-add-trigger { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 1px solid var(--line2); + border-radius: var(--ws-r-sm); + color: var(--text); + background: var(--panel); + font-size: var(--ws-fs-small); + font-weight: 600; + white-space: nowrap; + transition: color .15s ease, border-color .15s ease, background-color .15s ease; +} +.wroster-add-trigger:hover, +.wroster-add-trigger[aria-expanded="true"] { color: var(--bright); border-color: color-mix(in srgb, var(--accent) 48%, var(--line2)); background: var(--panel3); } +.wroster-add-trigger svg:last-child { transition: transform .16s var(--ease-out-soft); } +.wroster-add-trigger[aria-expanded="true"] svg:last-child { transform: rotate(180deg); } +.wroster-add-menu { + width: min(390px, calc(100vw - 32px)); + position: absolute; + z-index: 45; + top: calc(100% + 8px); + right: 0; + overflow: hidden; + padding: 8px; + border: 1px solid color-mix(in srgb, var(--bright) 14%, var(--line2)); + border-radius: var(--ws-r-lg); + background: color-mix(in srgb, var(--panel) 96%, transparent); + box-shadow: 0 18px 48px rgba(0, 0, 0, .34), 0 2px 8px rgba(0, 0, 0, .2); + backdrop-filter: blur(18px); + animation: wroster-context-in .15s var(--ease-out-soft) both; +} +.wroster-add-menu > header { display: grid; gap: 3px; padding: 4px 5px 9px; } +.wroster-add-menu > header strong { color: var(--bright); font-size: var(--ws-fs-body); } +.wroster-add-menu > header small { color: var(--dim); font-size: var(--ws-fs-hint); } +.wroster-add-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; } +.wroster-add-options button { + min-width: 0; + min-height: 52px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 12px; + align-items: center; + gap: 7px; + padding: 7px 8px; + border: 1px solid transparent; + border-radius: var(--ws-r-md); + color: var(--muted); + background: transparent; + text-align: left; + transition: color .13s ease, border-color .13s ease, background-color .13s ease; +} +.wroster-add-options button:hover:not(:disabled), +.wroster-add-options button:focus-visible { color: var(--bright); border-color: var(--line); background: var(--panel3); } +.wroster-add-options button:disabled { opacity: .42; cursor: not-allowed; } +.wroster-add-logo { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: var(--ws-r-sm); background: var(--panel2); } +.wroster-add-options button > span:nth-child(2) { min-width: 0; display: grid; gap: 2px; } +.wroster-add-options strong, +.wroster-add-options small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wroster-add-options strong { color: currentColor; font-size: var(--ws-fs-small); font-weight: 650; } +.wroster-add-options small { color: var(--dim); font-size: var(--ws-fs-micro); } +.wroster-add-options button > svg { color: var(--dim); } +.wroster-list { + max-width: var(--wsettings-content-max); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 0 auto; +} +.wroster-card { + min-width: 0; + position: relative; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--ws-r-lg); + color: var(--text); + background: var(--panel); + box-shadow: var(--shadow-sm); + cursor: pointer; + transition: border-color .18s ease, background-color .18s ease, + box-shadow .2s ease, transform .2s var(--ease-out-soft), opacity .16s ease; +} +.wroster-card:hover { + border-color: var(--line2); + background: color-mix(in srgb, var(--panel3) 70%, var(--panel)); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} +.wroster-card.selected { + border-color: color-mix(in srgb, var(--accent) 52%, var(--line2)); + background: color-mix(in srgb, var(--accent) 5%, var(--panel)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent), var(--shadow-md); +} +.wroster-card.checking { border-color: color-mix(in srgb, var(--accent) 46%, var(--line2)); } +.wroster-card.checking::after { + content: ""; + width: 38%; + height: 2px; + position: absolute; + top: 0; + left: -38%; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + animation: wroster-card-check 1.15s ease-in-out infinite; +} +@keyframes wroster-card-check { + from { transform: translateX(0); } + to { transform: translateX(365%); } +} +.wroster-card.disabled { opacity: .5; } +.wroster-card.dragging { opacity: .35; transform: scale(.985); } +.wroster-card.drop-target { + border-color: color-mix(in srgb, var(--accent) 62%, var(--line2)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 24%, transparent), var(--shadow-md); +} +.wroster-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.wroster-card-head { + display: grid; + grid-template-columns: 20px 32px minmax(0, 1fr) 33px 16px; + align-items: center; + gap: 8px; + padding: 12px 12px 9px; +} +.wroster-order { color: var(--dim); font: 10px/1 var(--font-mono); } +.wroster-engine, +.wreview-engine { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--text); + background: var(--panel2); +} +.engine-logo { display: block; flex: none; color: inherit; overflow: visible; } +.engine-logo[data-engine="pi"] { padding: 1px; } +.engine-logo[data-engine="claude"] { padding: .5px; } +.engine-logo[data-engine="omp"] { width: 20px; height: 17px; } +.wroster-add .engine-logo { opacity: .82; transition: opacity .15s ease, transform .16s var(--ease-out-soft); } +.wroster-add button:hover .engine-logo { opacity: 1; transform: scale(1.06); } +.wroster-title { min-width: 0; display: grid; gap: 2px; } +.wroster-title strong { overflow: hidden; text-overflow: ellipsis; color: var(--bright); font-size: var(--ws-fs-body); font-weight: 700; white-space: nowrap; } +.wroster-title small { color: var(--dim); font-size: var(--ws-fs-micro); } +.wroster-card-quick-toggle { display: grid; place-items: center; } +.wroster-card-quick-toggle .wset-toggle { justify-self: auto; } +.wroster-grip { color: var(--dim); cursor: grab; } +.wroster-meta { display: grid; gap: 5px; margin: 0; padding: 0 12px 10px; } +.wroster-meta div { min-width: 0; display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 7px; align-items: baseline; } +.wroster-meta dt { color: var(--dim); font-size: var(--ws-fs-micro); } +.wroster-meta dd { margin: 0; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font: 11px/1.4 var(--font-mono); white-space: nowrap; } +.wroster-card-foot { + min-height: 38px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 0 12px; + border-top: 1px solid var(--line); + color: var(--dim); + font-size: var(--ws-fs-micro); +} +.wroster-card-foot strong { color: var(--text); font: 11px/1 var(--font-mono); } +.wroster-self-check { min-width: 0; display: grid; grid-template-columns: 11px minmax(0, 1fr); align-items: center; gap: 6px; color: var(--muted); } +.wroster-self-check > i { width: 8px; height: 8px; border: 2px solid currentColor; border-radius: 50%; } +.wroster-self-check > span { min-width: 0; display: grid; gap: 1px; } +.wroster-self-check strong, +.wroster-self-check small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wroster-self-check strong { color: currentColor; font: 600 11px/1.2 var(--font-sans); } +.wroster-self-check small { color: var(--dim); font-size: var(--ws-fs-micro); line-height: 1.3; } +.wroster-self-check.checking { color: var(--accent); } +.wroster-self-check.checking > i { border-right-color: transparent; animation: railspin .7s linear infinite; } +.wroster-self-check.ok { color: var(--green); } +.wroster-self-check.bad { color: var(--red); } +.wroster-self-check.off { color: var(--dim); } +.wroster-self-check.compact { display: inline-flex; grid-template-columns: none; white-space: nowrap; } +.wroster-health { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--ws-fs-small); font-weight: 600; } +.wroster-health i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.wroster-health.ok { color: var(--green); } +.wroster-health.bad, .wroster-health.off { color: var(--red); } +.wroster-health.pending { color: var(--muted); } +.wroster-empty { + grid-column: 1 / -1; + min-height: 170px; + display: grid; + place-content: center; + justify-items: center; + gap: 7px; + border: 1px dashed var(--line2); + border-radius: var(--ws-r-lg); + color: var(--dim); + text-align: center; +} +.wroster-empty strong { color: var(--text); font-size: var(--ws-fs-body); } +.wroster-empty span { font-size: var(--ws-fs-small); } + +/* ── review slot ─────────────────────────────────────────────────────────── */ +.wreview-slot { + max-width: var(--wsettings-content-max); + display: grid; + grid-template-columns: minmax(170px, .8fr) minmax(260px, 1.2fr) auto; + align-items: center; + gap: 14px; + margin: 14px auto 0; + padding: 12px 14px; + border: 1px solid var(--line2); + border-radius: var(--ws-r-lg); + background: var(--ws-surface); +} +.wreview-slot.disabled { opacity: .58; } +.wreview-slot-copy { min-width: 0; display: flex; align-items: center; gap: 10px; } +.wreview-symbol { width: 34px; height: 34px; flex: none; display: grid; place-items: center; border: 1px solid var(--line); border-radius: var(--ws-r-md); color: var(--text); background: var(--panel); } +.wreview-slot h3 { font-size: var(--ws-fs-body); font-weight: 700; } +.wreview-slot p { font-size: var(--ws-fs-hint); } +.wreview-selection { + min-width: 0; + min-height: 52px; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto 14px; + align-items: center; + gap: 9px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: var(--ws-r-md); + color: var(--muted); + background: var(--panel); + text-align: left; + transition: border-color .15s ease, background-color .15s ease; +} +.wreview-selection:hover { border-color: var(--line2); background: var(--panel3); } +.wreview-selection > span:nth-child(2) { min-width: 0; display: grid; gap: 2px; } +.wreview-selection strong, .wreview-selection small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wreview-selection strong { color: var(--bright); font-size: var(--ws-fs-body); font-weight: 600; } +.wreview-selection small { color: var(--dim); font-size: var(--ws-fs-micro); } +.wreview-selection em { padding: 2px 7px; border: 1px solid var(--line); border-radius: var(--r-pill); color: var(--muted); font-size: var(--ws-fs-micro); font-style: normal; white-space: nowrap; } +.wreview-slot-state { display: grid; justify-items: end; gap: 5px; color: var(--muted); font-size: var(--ws-fs-micro); white-space: nowrap; } +.wreview-slot-state span { display: inline-flex; align-items: center; gap: 5px; } +.wreview-slot-state > span:not(.wroster-self-check) i { width: 6px; height: 6px; border-radius: 50%; background: var(--green); } +.wreview-slot-state strong { color: var(--text); font: 10px/1 var(--font-mono); } +.wroster-note { max-width: var(--wsettings-content-max); display: flex; align-items: center; gap: 7px; margin: 12px auto 0; color: var(--dim); font-size: var(--ws-fs-small); line-height: 1.5; } +.wroster-note svg { flex: none; } + +/* ── roster context menu ─────────────────────────────────────────────────── */ +.wroster-context-layer { + position: fixed; + inset: 0; + z-index: 160; +} +.wroster-context-menu { + width: 232px; + position: fixed; + overflow: hidden; + padding: 5px; + border: 1px solid color-mix(in srgb, var(--bright) 14%, var(--line2)); + border-radius: 10px; + color: var(--text); + background: color-mix(in srgb, var(--panel) 96%, transparent); + box-shadow: 0 18px 48px rgba(0, 0, 0, .36), 0 2px 8px rgba(0, 0, 0, .24); + backdrop-filter: blur(18px); + animation: wroster-context-in .15s var(--ease-out-soft) both; +} +@keyframes wroster-context-in { + from { opacity: 0; transform: translateY(-4px) scale(.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.wroster-context-menu > header { + min-width: 0; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 5px 7px 8px; +} +.wroster-context-logo { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 1px solid var(--line2); + border-radius: 7px; + color: var(--text); + background: var(--panel2); +} +.wroster-context-menu > header > span:last-child { min-width: 0; display: grid; gap: 2px; } +.wroster-context-menu > header strong, +.wroster-context-menu > header small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wroster-context-menu > header strong { color: var(--bright); font-size: var(--ws-fs-body); font-weight: 650; } +.wroster-context-menu > header small { color: var(--dim); font-size: var(--ws-fs-micro); } +.wroster-context-group { display: grid; gap: 1px; } +.wroster-context-group button { + min-height: 30px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 0 8px; + border: 0; + border-radius: 7px; + color: var(--muted); + background: transparent; + font-size: var(--ws-fs-small); + text-align: left; + transition: color .12s ease, background-color .12s ease; +} +.wroster-context-group button:hover, +.wroster-context-group button:focus-visible { color: var(--bright); background: var(--panel3); outline: none; } +.wroster-context-group button > svg { color: var(--dim); } +.wroster-context-group button:hover > svg, +.wroster-context-group button:focus-visible > svg { color: var(--text); } +.wroster-context-group button > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wroster-context-group button kbd { color: var(--dim); font: 10px/1 var(--font-mono); } +.wroster-context-group button.danger { color: color-mix(in srgb, var(--red) 80%, var(--text)); } +.wroster-context-group button.danger:hover, +.wroster-context-group button.danger:focus-visible { color: var(--red); background: color-mix(in srgb, var(--red) 8%, transparent); } +.wroster-context-group button.danger > svg { color: currentColor; } +.wroster-context-separator { height: 1px; margin: 4px 3px; background: var(--line); } + +@media (prefers-reduced-motion: reduce) { + .wroster-context-menu, + .wroster-card.checking::after, + .wroster-self-check.checking > i, + .wroster-check-all.running svg { animation: none; } +} + +/* ── inspector (right rail) ──────────────────────────────────────────────── */ +.wset-inspector, +.wsettings-editor { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border-left: 1px solid var(--line); + background: var(--bg); +} +.wset-inspector { overflow: hidden; } +.wsettings-editor { overflow: auto; } +.wset-inspector.empty { align-items: center; justify-content: center; gap: 7px; padding: 24px; color: var(--dim); text-align: center; } +.wset-inspector.empty strong { color: var(--text); font-size: 13px; } +.wset-inspector.empty span { max-width: 230px; font-size: var(--ws-fs-small); line-height: 1.5; } +.wset-inspector-head { + flex: none; + display: grid; + gap: 3px; + padding: 14px 16px 12px; + border-bottom: 1px solid var(--line); +} +.wset-inspector-head > span { color: var(--dim); font-size: var(--ws-fs-micro); font-weight: 700; letter-spacing: var(--ls-label); text-transform: uppercase; } +.wset-inspector-head > strong { overflow: hidden; text-overflow: ellipsis; color: var(--bright); font-size: 15px; font-weight: 650; white-space: nowrap; } +.wset-inspector-head > p { margin: 2px 0 0; color: var(--muted); font-size: var(--ws-fs-small); line-height: 1.5; } +.wset-inspector-scroll { + min-height: 0; + flex: 1; + overflow-x: hidden; + overflow-y: auto; + scrollbar-gutter: stable; + overscroll-behavior: contain; +} +.wset-form-section { display: grid; gap: 10px; padding: 13px 16px; border-bottom: 1px solid var(--line); } +.wset-form-section h3 { margin: 0 0 1px; color: var(--bright); font-size: var(--ws-fs-body); font-weight: 650; } +.wset-form-section label { min-width: 0; display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 8px; } +.wset-form-section label > span { color: var(--muted); font-size: var(--ws-fs-small); } +.wset-form-section input, +.wset-form-section select, +.wset-form-section textarea { + min-width: 0; + width: 100%; + min-height: 33px; + padding: 6px 9px; + border: 1px solid var(--line2); + border-radius: var(--ws-r-sm); + outline: 0; + color: var(--text); + background: var(--panel); + font-size: var(--ws-fs-body); + transition: border-color .15s ease, box-shadow .15s ease; +} +.wset-form-section textarea { resize: vertical; font-family: var(--font-mono); font-size: 11px; line-height: 1.5; } +.wset-form-section input:focus, +.wset-form-section select:focus, +.wset-form-section textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); } +.wset-binding-help { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: -3px; color: var(--dim); font-size: var(--ws-fs-micro); } +.wset-binding-help button { padding: 0; border: 0; color: var(--text); background: transparent; font-size: var(--ws-fs-small); font-weight: 600; } +.wset-binding-help button:hover { color: var(--bright); text-decoration: underline; } +.wset-effort-control { min-width: 0; display: grid; gap: 5px; } +.wset-effort-control > label { min-width: 0; display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 8px; } +.wset-effort-control > label > span { color: var(--muted); font-size: var(--ws-fs-small); } +.wset-effort-control > label > select { + min-width: 0; width: 100%; min-height: 33px; padding: 6px 9px; + border: 1px solid var(--line2); border-radius: var(--ws-r-sm); outline: 0; + color: var(--text); background: var(--panel); font-size: var(--ws-fs-body); + transition: border-color .15s ease, box-shadow .15s ease; +} +.wset-effort-control > label > select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); } +.wset-effort-control > small { padding-left: 96px; color: var(--dim); font-size: var(--ws-fs-micro); line-height: 1.45; } + +/* ── credential binding ──────────────────────────────────────────────────── */ +.wbinding-editor { min-width: 0; display: grid; gap: 9px; } +.wbinding-editor > label { margin: 0; } +.wbinding-local-note { display: flex; align-items: center; gap: 6px; margin: -2px 0 0 96px; color: var(--dim); font-size: var(--ws-fs-micro); line-height: 1.45; } +.wbinding-local-note svg { flex: none; } +.wbinding-summary { + display: grid; + grid-template-columns: minmax(0, .72fr) minmax(0, 1.2fr) minmax(0, .9fr); + gap: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--ws-r-md); + background: var(--panel2); +} +.wbinding-summary > div { min-width: 0; display: grid; gap: 3px; padding: 9px 10px; border-right: 1px solid var(--line); align-content: start; } +.wbinding-summary > div span { color: var(--dim); font-size: var(--ws-fs-micro); letter-spacing: .04em; } +.wbinding-summary > div strong { overflow: hidden; text-overflow: ellipsis; color: var(--text); font-size: var(--ws-fs-small); font-weight: 650; white-space: nowrap; } +.wbinding-summary > div small { overflow: hidden; text-overflow: ellipsis; color: var(--muted); font: 10px/1.35 var(--font-mono); white-space: nowrap; } +.wbinding-summary > button { + grid-column: 1 / -1; + min-height: 29px; + border: 0; + border-top: 1px solid var(--line); + color: var(--muted); + background: transparent; + font-size: var(--ws-fs-small); + font-weight: 600; + transition: color .13s ease, background-color .13s ease; +} +.wbinding-summary > button:hover { color: var(--bright); background: var(--panel3); } +.wbinding-form { display: grid; gap: 9px; padding: 11px; border: 1px solid var(--line2); border-radius: var(--ws-r-md); background: var(--panel2); } +.wbinding-form > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding-bottom: 9px; border-bottom: 1px solid var(--line); } +.wbinding-form > header > div { display: grid; gap: 2px; } +.wbinding-form > header strong { color: var(--bright); font-size: var(--ws-fs-body); font-weight: 650; } +.wbinding-form > header span { color: var(--dim); font-size: var(--ws-fs-micro); } +.wbinding-form > header button { width: 24px; height: 24px; flex: none; display: grid; place-items: center; padding: 0; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); background: var(--panel); transition: color .13s ease, background-color .13s ease; } +.wbinding-form > header button:hover { color: var(--bright); background: var(--panel3); } +.wbinding-form-group-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 1px 0 0; +} +.wbinding-form-group-title.model { + margin-top: 1px; + padding-top: 10px; + border-top: 1px solid var(--line); +} +.wbinding-form-group-title > span { color: var(--text); font-size: var(--ws-fs-small); font-weight: 650; } +.wbinding-form-group-title > small { color: var(--dim); font-size: var(--ws-fs-micro); } +.wbinding-model-tools { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 7px; } +.wbinding-model-tools > small { overflow: hidden; color: var(--dim); font-size: var(--ws-fs-micro); text-overflow: ellipsis; white-space: nowrap; } +.wbinding-model-tools > button { + flex: none; + min-height: 25px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 7px; + border: 1px solid var(--line2); + border-radius: 6px; + color: var(--muted); + background: var(--panel); + font-size: var(--ws-fs-micro); + transition: color .13s ease, border-color .13s ease, background-color .13s ease; +} +.wbinding-model-tools > button:hover:not(:disabled) { color: var(--bright); border-color: color-mix(in srgb, var(--accent) 45%, var(--line2)); background: var(--panel3); } +.wbinding-model-tools > button:disabled { opacity: .6; } +.wbinding-model-tools > button.loading { cursor: wait; } +.wbinding-model-tools > button.unsupported { cursor: not-allowed; } +.wbinding-model-tools > button.loading svg { animation: railspin .7s linear infinite; } +.wbinding-form label { display: grid; grid-template-columns: 80px minmax(0, 1fr); align-items: center; gap: 8px; } +.wbinding-form label > span { color: var(--muted); font-size: var(--ws-fs-small); } +.wbinding-form label.secret { align-items: start; } +.wbinding-form label.secret > span { padding-top: 7px; } +.wbinding-form input, +.wbinding-form select, +.wbinding-form textarea { + min-width: 0; width: 100%; min-height: 33px; padding: 6px 9px; + border: 1px solid var(--line2); border-radius: var(--ws-r-sm); outline: 0; + color: var(--text); background: var(--panel); font-size: var(--ws-fs-body); + transition: border-color .15s ease, box-shadow .15s ease; +} +.wbinding-form textarea { resize: vertical; font-family: var(--font-mono); font-size: 11px; line-height: 1.5; } +.wbinding-host-import { display: grid; grid-template-columns: 80px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--ws-r-sm); background: var(--panel); } +.wbinding-host-import > span { color: var(--muted); font-size: var(--ws-fs-micro); } +.wbinding-host-import > p { margin: 0; color: var(--dim); font-size: var(--ws-fs-micro); line-height: 1.4; } +.wbinding-host-import > button { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; padding: 0 9px; border: 1px solid var(--line2); border-radius: 6px; color: var(--text); background: var(--panel2); font-size: var(--ws-fs-micro); white-space: nowrap; } +.wbinding-host-import > button:hover:not(:disabled) { color: var(--bright); border-color: var(--muted); } +.wbinding-host-import > button:disabled { opacity: .55; cursor: not-allowed; } +.wbinding-form input:focus, +.wbinding-form select:focus, +.wbinding-form textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); } +.wbinding-form input:disabled { opacity: .55; cursor: not-allowed; } +.wbinding-model-field { + min-width: 0; + display: grid; + grid-template-columns: 80px minmax(0, 1fr); + align-items: start; + gap: 8px; +} +.wbinding-model-field > span { padding-top: 10px; color: var(--muted); font-size: var(--ws-fs-small); } +.wbinding-model-input { width: 100%; min-width: 0; font-family: var(--font-mono); } +.waccount-protocol { display: grid; grid-template-columns: 80px minmax(0, 1fr); gap: 3px 8px; padding: 9px 10px; border: 1px solid var(--line); border-radius: var(--ws-r-sm); background: var(--panel); } +.waccount-protocol > span { color: var(--muted); font-size: var(--ws-fs-micro); } +.waccount-protocol > strong { color: var(--text); font-size: var(--ws-fs-small); font-weight: 650; } +.waccount-protocol > p { grid-column: 2; margin: 0; color: var(--dim); font-size: var(--ws-fs-micro); line-height: 1.45; } +.wbinding-discovery-result { display: flex; align-items: flex-start; gap: 5px; margin: -2px 0 0 88px; font-size: var(--ws-fs-micro); line-height: 1.45; } +.wbinding-discovery-result svg { flex: none; margin-top: 1px; } +.wbinding-discovery-result.ok { color: var(--green); } +.wbinding-discovery-result.failed { color: var(--red); } +.wbinding-error { margin: 0; color: var(--red); font-size: var(--ws-fs-small); line-height: 1.4; } +.wbinding-form > footer { display: flex; justify-content: flex-end; gap: 6px; padding-top: 3px; } +.wbinding-form > footer button { + min-height: 29px; display: inline-flex; align-items: center; gap: 5px; + padding: 0 10px; border: 1px solid var(--line2); border-radius: var(--ws-r-sm); + color: var(--muted); background: var(--panel); font-size: var(--ws-fs-small); font-weight: 600; + transition: color .13s ease, background-color .13s ease; +} +.wbinding-form > footer button:hover:not(:disabled) { color: var(--bright); background: var(--panel3); } +.wbinding-form > footer button:disabled { opacity: .55; cursor: not-allowed; } +.wbinding-form > footer button.primary { color: var(--bg); border-color: var(--bright); background: var(--bright); } +.wbinding-form > footer button.primary:hover:not(:disabled) { color: var(--bg); background: color-mix(in srgb, var(--bright) 88%, var(--muted)); } + +/* ── model picker ────────────────────────────────────────────────────────── */ +.wmodel-picker { min-width: 0; position: relative; z-index: 8; } +.wmodel-picker-trigger { + width: 100%; + min-height: 40px; + display: grid; + grid-template-columns: minmax(0, 1fr) 15px; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid var(--line2); + border-radius: var(--ws-r-sm); + color: var(--text); + background: var(--panel); + text-align: left; + transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease; +} +.wmodel-picker-trigger:hover { border-color: color-mix(in srgb, var(--text) 28%, var(--line2)); background: var(--panel3); } +.wmodel-picker.open .wmodel-picker-trigger { border-color: color-mix(in srgb, var(--accent) 62%, var(--line2)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent); } +.wmodel-picker-trigger > span { min-width: 0; display: grid; gap: 1px; } +.wmodel-picker-trigger strong, +.wmodel-picker-trigger small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wmodel-picker-trigger strong { color: var(--bright); font: 550 11.5px/1.35 var(--font-mono); } +.wmodel-picker-trigger small { color: var(--dim); font-size: var(--ws-fs-micro); } +.wmodel-picker-trigger > svg { color: var(--dim); transition: transform .16s var(--ease-out-soft), color .15s ease; } +.wmodel-picker.open .wmodel-picker-trigger > svg { transform: rotate(180deg); color: var(--text); } +.wmodel-picker-popover { + position: fixed; + top: auto; + left: auto; + right: auto; + z-index: 1200; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--text) 19%, var(--line2)); + border-radius: 10px; + background: color-mix(in srgb, var(--panel) 96%, var(--term-bg)); + box-shadow: 0 18px 48px rgba(0,0,0,.48), 0 1px 0 rgba(255,255,255,.035) inset; + transform-origin: top center; + animation: wmodel-picker-in .16s var(--ease-out-soft) both; +} +:root[data-theme="light"] .wmodel-picker-popover { background: color-mix(in srgb, var(--panel) 97%, white); box-shadow: var(--shadow-pop); } +.wmodel-picker-popover.placement-top { transform-origin: bottom center; animation-name: wmodel-picker-in-top; } +@keyframes wmodel-picker-in { + from { opacity: 0; transform: translateY(-5px) scale(.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes wmodel-picker-in-top { + from { opacity: 0; transform: translateY(5px) scale(.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.wmodel-picker-search { + min-height: 34px; + display: grid; + grid-template-columns: 14px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + margin: 7px; + padding: 0 9px; + border: 1px solid var(--line2); + border-radius: var(--ws-r-sm); + color: var(--dim); + background: var(--panel2); +} +.wmodel-picker-search:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--line2)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 8%, transparent); } +.wmodel-picker-search input { + min-width: 0; + width: 100%; + min-height: 32px; + padding: 0; + border: 0; + outline: 0; + color: var(--bright); + background: transparent; + font: 11px/1.3 var(--font-mono); + box-shadow: none; +} +.wmodel-picker-search input:focus { border: 0; box-shadow: none; } +.wmodel-picker-search button { width: 22px; height: 22px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px; color: var(--dim); background: transparent; } +.wmodel-picker-search button:hover { color: var(--text); background: var(--panel3); } +.wmodel-picker-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 2px 12px 6px; color: var(--dim); font-size: var(--ws-fs-micro); font-weight: 650; letter-spacing: var(--ls-label); text-transform: uppercase; } +.wmodel-picker-meta em { font-style: normal; font-family: var(--font-mono); text-transform: none; } +.wmodel-picker-list { max-height: min(238px, 32vh); overflow-x: hidden; overflow-y: auto; padding: 0 5px 5px; scrollbar-width: thin; scrollbar-color: var(--line2) transparent; } +.wmodel-picker-list > button { + width: 100%; + min-height: 42px; + display: grid; + grid-template-columns: 23px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: var(--ws-r-sm); + color: var(--muted); + background: transparent; + text-align: left; + transition: color .12s ease, background-color .12s ease, border-color .12s ease; +} +.wmodel-picker-list > button.active { color: var(--text); border-color: var(--line); background: var(--panel3); } +.wmodel-picker-list > button.selected { color: var(--bright); background: color-mix(in srgb, var(--accent) 8%, var(--panel2)); } +.wmodel-picker-list > button.selected.active { border-color: color-mix(in srgb, var(--accent) 24%, var(--line)); } +.wmodel-picker-mark { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 6px; color: var(--accent); background: var(--panel2); } +.wmodel-picker-mark i { width: 5px; height: 5px; border-radius: 50%; background: var(--dim); opacity: .55; } +.wmodel-picker-list > button > span:nth-child(2) { min-width: 0; display: grid; gap: 1px; } +.wmodel-picker-list strong, +.wmodel-picker-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wmodel-picker-list strong { color: inherit; font: 580 11px/1.3 var(--font-mono); } +.wmodel-picker-list small { color: var(--dim); font-size: var(--ws-fs-micro); } +.wmodel-picker-list > button > em { padding: 2px 5px; border: 1px solid var(--line); border-radius: 5px; color: var(--muted); font-size: var(--ws-fs-micro); font-style: normal; } +.wmodel-picker-empty { min-height: 68px; display: grid; place-items: center; align-content: center; gap: 6px; color: var(--dim); font-size: var(--ws-fs-small); } +.wmodel-picker-popover > footer { min-height: 28px; display: flex; align-items: center; gap: 10px; padding: 0 10px; border-top: 1px solid var(--line); color: var(--dim); background: var(--panel2); font-size: var(--ws-fs-micro); } +.wmodel-picker-popover > footer span { display: inline-flex; align-items: center; gap: 4px; } +.wmodel-picker-popover kbd { min-width: 18px; padding: 2px 4px; border: 1px solid var(--line2); border-radius: 4px; color: var(--muted); background: var(--panel); font: 9.5px/1 var(--font-mono); text-align: center; } +@media (prefers-reduced-motion: reduce) { + .wmodel-picker-popover { animation: none; } + .wmodel-picker-trigger > svg { transition: none; } +} + +/* ── switches, toggles, number suffix ────────────────────────────────────── */ +.wset-switch-row { min-height: 33px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; } +.wset-switch-row > span { display: grid; gap: 2px; } +.wset-switch-row b { color: var(--text); font-size: var(--ws-fs-body); font-weight: 600; } +.wset-switch-row small { color: var(--dim); font-size: var(--ws-fs-hint); line-height: 1.4; } +.wset-toggle { justify-self: end; width: 33px; height: 19px; position: relative; padding: 0; border: 1px solid var(--line2); border-radius: 999px; background: var(--panel3); transition: border-color .16s ease, background-color .16s ease; } +.wset-toggle i { position: absolute; top: 3px; left: 3px; width: 11px; height: 11px; border-radius: 50%; background: var(--muted); transition: transform .18s var(--ease-out-soft), background-color .15s ease; } +.wset-toggle.on { border-color: color-mix(in srgb, var(--accent) 52%, var(--line2)); background: color-mix(in srgb, var(--accent) 18%, var(--panel3)); } +.wset-toggle.on i { transform: translateX(14px); background: var(--accent); } +.wset-number-suffix { position: relative; min-width: 0; } +.wset-number-suffix input { padding-right: 46px; } +.wset-number-suffix > span, +.wset-number-suffix > em { position: absolute; top: 50%; right: 9px; transform: translateY(-50%); color: var(--dim); font-size: var(--ws-fs-small); font-style: normal; pointer-events: none; } +.wset-health-block { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: var(--ws-r-sm); background: var(--panel2); } +.wset-health-block > span:first-child { display: grid; gap: 2px; } +.wset-health-block strong { color: var(--text); font-size: var(--ws-fs-small); font-weight: 650; } +.wset-health-block small { color: var(--dim); font-size: var(--ws-fs-micro); } +.wset-health-block button, .wreview-independent { + min-height: 29px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 9px; + border: 1px solid var(--line2); + border-radius: var(--ws-r-sm); + color: var(--text); + background: var(--panel); + font-size: var(--ws-fs-small); + font-weight: 600; +} +.wset-review-triggers { border-bottom: 1px solid var(--line); } +.wset-review-triggers > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 16px; + cursor: pointer; + list-style: none; + color: var(--bright); + font-size: var(--ws-fs-body); + font-weight: 650; + transition: background-color .13s ease; +} +.wset-review-triggers > summary::-webkit-details-marker { display: none; } +.wset-review-triggers > summary:hover { background: color-mix(in srgb, var(--panel2) 60%, transparent); } +.wset-review-triggers > summary > span { display: inline-flex; align-items: center; gap: 7px; } +.wset-review-triggers > summary svg { color: var(--dim); } +.wset-review-triggers > summary > svg:last-child { transition: transform .16s var(--ease-out-soft); } +.wset-review-triggers[open] > summary > svg:last-child { transform: rotate(180deg); } +.wset-review-triggers > div { display: grid; gap: 2px; padding: 0 16px 13px; } +.wset-review-triggers .wset-switch-row { min-height: 32px; padding: 5px 0; border-bottom: 1px solid var(--line); } +.wset-review-triggers .wset-switch-row:last-child { border-bottom: 0; } +.wset-review-triggers label { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 104px; align-items: center; gap: 8px; padding: 5px 0; } +.wset-review-triggers label > span { color: var(--muted); font-size: var(--ws-fs-small); } +.wset-review-triggers input { + min-width: 0; width: 100%; min-height: 31px; padding: 5px 9px; + border: 1px solid var(--line2); border-radius: var(--ws-r-sm); outline: 0; + color: var(--text); background: var(--panel); font-size: var(--ws-fs-body); + transition: border-color .15s ease, box-shadow .15s ease; +} +.wset-review-triggers input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); } +.wreview-independent { width: 100%; min-height: 32px; margin-top: 2px; } +.wreview-reuse-note p { margin: 0; color: var(--muted); font-size: var(--ws-fs-small); line-height: 1.5; } +.wreview-reuse-note > button { width: fit-content; min-height: 29px; display: inline-flex; align-items: center; gap: 5px; padding: 0 9px; border: 1px solid var(--line2); border-radius: var(--ws-r-sm); color: var(--text); background: var(--panel); font-size: var(--ws-fs-small); font-weight: 600; transition: color .13s ease, background-color .13s ease; } +.wreview-reuse-note > button:hover { color: var(--bright); background: var(--panel3); } +.wset-test-summary { display: grid; gap: 10px; padding: 12px 16px 16px; } +.wset-test-summary > div:first-child { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 9px; } +.wset-test-summary > div:first-child p { min-width: 0; margin: 0; color: var(--dim); font-size: var(--ws-fs-hint); line-height: 1.45; } +.wset-test-summary > p { margin: 0; color: var(--dim); font-size: var(--ws-fs-hint); line-height: 1.45; } + +/* ── model test terminal ─────────────────────────────────────────────────── */ +/* Always-dark terminal chrome: the global semantic tokens are RE-TUNED for + this dark surface at component scope (children keep using var(--green) + etc.), and its fixed dark surfaces live in --wt-* component vars — one + place to control the whole component, no scattered literals. */ +.wmodel-terminal { + --green: #82bc98; + --red: #d79999; + --amber: #d3b568; + --wt-bg: #0b0e12; + --wt-head: #11151b; + --wt-foot: #0f1318; + --wt-idle: #758091; + --wt-text: #9ba5b2; + --wt-dim: #667383; + --wt-faint: #485260; + --wt-bright: #c8cfda; + --wt-hover: #d1d7df; + --wt-title: #aeb7c4; + --wt-meta: #737e8c; + --wt-footnote: #596574; + --wt-stdout: #a9c3b2; + min-width: 0; + overflow: hidden; + border: 1px solid var(--line2); + border-radius: var(--ws-r-md); + background: var(--wt-bg); + box-shadow: inset 0 1px 0 rgba(255,255,255,.025); +} +.wmodel-terminal > header { + min-height: 31px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px; + border-bottom: 1px solid rgba(255,255,255,.07); + color: var(--wt-title); + background: var(--wt-head); + font-size: var(--ws-fs-hint); +} +.wmodel-terminal > header span { display: inline-flex; align-items: center; gap: 7px; font-weight: 650; } +.wmodel-terminal > header i { width: 6px; height: 6px; border-radius: 50%; background: var(--wt-idle); } +.wmodel-terminal > header i.running { background: var(--amber); animation: pulse 1s ease-in-out infinite; } +.wmodel-terminal > header i.ok { background: var(--green); } +.wmodel-terminal > header i.bad { background: var(--red); } +.wmodel-terminal > header > div { display: inline-flex; align-items: center; gap: 7px; } +.wmodel-terminal > header em { color: var(--wt-meta); font-size: var(--ws-fs-micro); font-style: normal; } +.wmodel-terminal > header button { width: 22px; height: 22px; display: grid; place-items: center; padding: 0; border: 1px solid rgba(255,255,255,.07); border-radius: 5px; color: var(--wt-meta); background: transparent; } +.wmodel-terminal > header button:hover { color: var(--wt-hover); background: rgba(255,255,255,.045); } +.wmodel-terminal-body { max-height: 250px; overflow: auto; padding: 7px 0; font: 10px/1.55 var(--font-mono); } +.wmodel-terminal-body > div { min-width: 0; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: start; gap: 7px; padding: 4px 9px; color: var(--wt-text); } +.wmodel-terminal-body b { color: var(--wt-faint); font-weight: 500; } +.wmodel-terminal-body pre { min-width: 0; margin: 0; overflow-wrap: anywhere; color: inherit; font: inherit; white-space: pre-wrap; } +.wmodel-terminal-body time { color: var(--wt-faint); font: inherit; } +.wmodel-terminal-body .command { color: var(--wt-bright); } +.wmodel-terminal-body .command pre::before { content: "$ "; color: var(--wt-dim); } +.wmodel-terminal-body .stdout { color: var(--wt-stdout); } +.wmodel-terminal-body .stderr, .wmodel-terminal-body .error { color: var(--red); } +.wmodel-terminal-body .success { color: var(--green); } +.wmodel-terminal > footer { display: grid; gap: 3px; padding: 8px 10px; border-top: 1px solid rgba(255,255,255,.07); background: var(--wt-foot); } +.wmodel-terminal > footer strong { color: var(--wt-bright); font-size: var(--ws-fs-hint); font-weight: 550; line-height: 1.4; } +.wmodel-terminal > footer span { color: var(--wt-footnote); font: 10px/1.4 var(--font-mono); } +.wmodel-terminal.compact { margin-top: 2px; } +.wmodel-terminal.compact .wmodel-terminal-body { max-height: 180px; } +.wset-live-terminal { flex: none; padding: 8px 10px 0; background: var(--panel2); } +.wset-live-terminal .wmodel-terminal-body { max-height: min(230px, 28vh); } +.wmodel-terminal-collapsed { + --green: #82bc98; + --red: #d79999; + --amber: #d3b568; + --wt-idle: #758091; + --wt-bg: #10141a; + width: 100%; + min-height: 32px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto 15px; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--line2); + border-radius: var(--ws-r-sm); + color: var(--muted); + background: var(--wt-bg); + text-align: left; + font-size: var(--ws-fs-hint); +} +.wmodel-terminal-collapsed:hover { color: var(--bright); border-color: color-mix(in srgb, var(--bright) 22%, var(--line2)); } +.wmodel-terminal-collapsed > span { min-width: 0; display: inline-flex; align-items: center; gap: 7px; font-weight: 650; } +.wmodel-terminal-collapsed i { width: 6px; height: 6px; flex: none; border-radius: 50%; background: var(--wt-idle); } +.wmodel-terminal-collapsed i.running { background: var(--amber); animation: pulse 1s ease-in-out infinite; } +.wmodel-terminal-collapsed i.ok { background: var(--green); } +.wmodel-terminal-collapsed i.bad { background: var(--red); } +.wmodel-terminal-collapsed em { color: var(--dim); font-size: var(--ws-fs-micro); font-style: normal; white-space: nowrap; } +.wmodel-terminal-collapsed > svg { transform: rotate(180deg); } + +/* ── inspector dock (bottom actions) ─────────────────────────────────────── */ +.wset-inspector-dock { + flex: none; + display: grid; + gap: 7px; + padding: 10px 12px 12px; + border-top: 1px solid var(--line); + background: var(--panel2); + box-shadow: 0 -12px 24px rgba(0,0,0,.12); +} +.wset-inspector-dock > .primary { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid color-mix(in srgb, var(--bright) 35%, var(--line2)); + border-radius: var(--ws-r-sm); + color: var(--bg); + background: var(--bright); + font-size: var(--ws-fs-body); + font-weight: 650; + transition: background-color .15s ease, transform .13s var(--ease-out-soft); +} +.wset-inspector-dock > .primary:hover:not(:disabled) { background: color-mix(in srgb, var(--bright) 88%, var(--muted)); } +.wset-inspector-dock > .primary:active:not(:disabled) { transform: scale(.99); } +.wset-inspector-dock > .primary:disabled { opacity: .55; cursor: not-allowed; } +.wset-inspector-dock > div { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } +.wset-inspector-dock > div button { + min-width: 0; + min-height: 29px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 7px; + border: 1px solid var(--line2); + border-radius: var(--ws-r-sm); + color: var(--muted); + background: var(--panel); + font-size: var(--ws-fs-small); + font-weight: 600; + transition: color .13s ease, background-color .13s ease; +} +.wset-inspector-dock > div button:hover { color: var(--bright); background: var(--panel3); } +.wset-inspector-dock > div button.danger { color: var(--red); } +.wset-inspector-dock.review { grid-template-columns: 1fr; } + +/* ── runtime environment workspace (运行环境) ────────────────────────────── */ +.wruntime-workspace { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 700px) minmax(280px, 320px); + align-items: start; + justify-content: center; + gap: clamp(16px, 2vw, 24px); + overflow: auto; + padding: 24px var(--wsettings-content-pad) 36px; + background: var(--panel2); +} +.wruntime-stage { min-width: 0; width: 100%; } +.wruntime-intro { margin-bottom: 16px; } +.wruntime-intro h2, +.wruntime-intro p, +.wruntime-network h3, +.wruntime-image h3, +.wruntime-image p, +.wruntime-summary h2, +.wruntime-summary p { margin: 0; } +.wruntime-modes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.wruntime-mode { + min-width: 0; + position: relative; + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + align-items: center; + gap: 11px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--ws-r-lg); + color: var(--muted); + background: var(--panel); + text-align: left; + transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease, transform .16s var(--ease-out-soft); +} +.wruntime-mode:hover { transform: translateY(-1px); border-color: var(--line2); background: var(--panel3); } +.wruntime-mode:focus-visible, +.wruntime-network-option:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 72%, white); outline-offset: 2px; } +.wruntime-mode.on { + border-color: color-mix(in srgb, var(--accent) 82%, var(--line2)); + background: color-mix(in srgb, var(--accent) 5%, var(--panel)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent); +} +.wruntime-mode-radio { + position: absolute; + top: 12px; + right: 12px; + width: 14px; + height: 14px; + display: grid; + place-items: center; + border: 1px solid var(--muted); + border-radius: 50%; + transition: border-color .15s ease, background-color .15s ease; +} +.wruntime-mode-radio i { width: 6px; height: 6px; border-radius: 50%; background: transparent; } +.wruntime-mode.on .wruntime-mode-radio { border-color: var(--accent); background: var(--accent); } +.wruntime-mode.on .wruntime-mode-radio i { background: var(--bright); } +.wruntime-mode-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: var(--ws-r-md); + color: var(--muted); + background: var(--panel2); + transition: color .15s ease, border-color .15s ease; +} +.wruntime-mode.on .wruntime-mode-icon { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); } +.wruntime-mode-copy { min-width: 0; display: grid; gap: 3px; padding-right: 14px; } +.wruntime-mode-copy strong { color: var(--bright); font-size: 13px; font-weight: 700; } +.wruntime-mode-copy small { color: var(--muted); font-size: var(--ws-fs-small); line-height: 1.45; } +.wruntime-network { margin-top: 18px; } +.wruntime-network h3, +.wruntime-image h3 { margin-bottom: 9px; color: var(--bright); font-size: var(--ws-fs-body); font-weight: 650; } +.wruntime-network-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.wruntime-network-option { + width: 100%; + min-width: 0; + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: var(--ws-r-md); + color: var(--muted); + background: var(--panel); + text-align: left; + transition: border-color .15s ease, background-color .15s ease, transform .16s var(--ease-out-soft); +} +.wruntime-network-option:hover { transform: translateY(-1px); border-color: var(--line2); background: var(--panel3); } +.wruntime-network-option.on { border-color: color-mix(in srgb, var(--accent) 78%, var(--line2)); background: color-mix(in srgb, var(--accent) 4%, var(--panel)); } +.wruntime-network-option > span { min-width: 0; display: grid; gap: 4px; } +.wruntime-network-option strong { color: var(--bright); font: 650 11px/1.3 var(--font-mono); text-transform: lowercase; } +.wruntime-network-option small { color: var(--muted); font-size: var(--ws-fs-hint); line-height: 1.45; } +.wruntime-network-option > svg { flex: none; color: var(--dim); } +.wruntime-network-option.on > svg { color: var(--accent); } +.wruntime-image { margin-top: 18px; overflow: hidden; border: 1px solid var(--line); border-radius: var(--ws-r-lg); background: var(--panel); } +.wruntime-image > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 13px 15px; border-bottom: 1px solid var(--line); } +.wruntime-image > header h3 { margin-bottom: 3px; } +.wruntime-image > header p { color: var(--muted); font-size: var(--ws-fs-hint); line-height: 1.45; } +.wruntime-image > header code { max-width: 50%; overflow: hidden; color: var(--muted); font: 10px/1.4 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.wruntime-image-checks { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.wruntime-image-checks > span { min-height: 58px; display: grid; grid-template-columns: 8px minmax(0, 1fr); align-content: center; gap: 2px 7px; padding: 10px 15px; border-right: 1px solid var(--line); color: var(--muted); font-size: var(--ws-fs-hint); } +.wruntime-image-checks > span:last-child { border-right: 0; } +.wruntime-image-checks i { grid-row: 1 / span 2; align-self: center; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); } +.wruntime-image-checks strong { color: var(--text); font-size: var(--ws-fs-small); font-weight: 650; } +.wruntime-image-checks .ok i { background: var(--green); } +.wruntime-image-checks .bad i { background: var(--red); } +.wruntime-image-checks .unknown i { background: var(--amber); } +.wruntime-image-warning { display: flex; align-items: center; gap: 6px; padding: 8px 15px; border-top: 1px solid var(--line); color: var(--amber); font-size: var(--ws-fs-hint); } +.wruntime-image > footer { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 8px 15px; border-top: 1px solid var(--line); } +.wruntime-image > footer > span { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--ws-fs-hint); } +.wruntime-image > footer code { color: var(--text); font: 10px/1.3 var(--font-mono); } +.wruntime-image > footer > div { display: flex; gap: 7px; } +.wruntime-image > footer button { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; padding: 0 10px; border: 1px solid var(--line2); border-radius: var(--ws-r-sm); color: var(--text); background: var(--panel2); font-size: var(--ws-fs-small); } +.wruntime-image > footer button.primary { color: var(--bg); border-color: var(--bright); background: var(--bright); } +.wruntime-image > footer button:disabled { cursor: not-allowed; opacity: .5; } +.wruntime-local-note { min-height: 76px; display: flex; align-items: center; gap: 11px; margin-top: 18px; padding: 13px 15px; border: 1px solid var(--line); border-radius: var(--ws-r-lg); color: var(--muted); background: var(--panel); } +.wruntime-local-note > span { display: grid; gap: 3px; } +.wruntime-local-note strong { color: var(--bright); font-size: var(--ws-fs-body); } +.wruntime-local-note small { color: var(--muted); font-size: var(--ws-fs-small); } +.wruntime-summary { + min-width: 0; + position: sticky; + top: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--ws-r-lg); + background: var(--panel); +} +.wruntime-summary > header { padding: 13px 16px; border-bottom: 1px solid var(--line); } +.wruntime-summary h2 { color: var(--bright); font-size: 13px; font-weight: 700; } +.wruntime-summary dl { margin: 0; padding: 6px 16px; } +.wruntime-summary dl > div { min-height: 44px; display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--line); } +.wruntime-summary dl > div:last-child { border-bottom: 0; } +.wruntime-summary dt { color: var(--muted); font-size: var(--ws-fs-small); } +.wruntime-summary dd { margin: 0; color: var(--text); font-size: var(--ws-fs-body); font-weight: 550; text-align: right; } +.wruntime-summary dd code { color: inherit; font: 11px/1.3 var(--font-mono); } +.wruntime-summary > p { display: flex; align-items: flex-start; gap: 7px; margin: 0; padding: 11px 16px 13px; border-top: 1px solid var(--line); color: var(--muted); font-size: var(--ws-fs-hint); line-height: 1.5; } +.wruntime-summary > p svg { flex: none; margin-top: 1px; } +.wruntime-mode:active, +.wruntime-network-option:active { transform: translateY(0); } +.wruntime-mode:disabled, +.wruntime-network-option:disabled { cursor: not-allowed; opacity: .48; transform: none; } + +/* ── simple pages (调度与预算 / 推理模型) ────────────────────────────────── */ +.wsettings-simple-page { + height: 100%; + min-height: 0; + overflow: auto; + padding: 24px var(--wsettings-content-pad) 36px; + background: var(--panel2); +} +.wsettings-simple-page > header { max-width: var(--wsettings-content-max); margin: 0 auto 16px; } +.wsettings-simple-grid { max-width: var(--wsettings-content-max); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0 auto; } + +/* ── appearance / palette-engine console ────────────────────────────────── + Cards follow .wsettings-setting-group visual language; the preview block is + themed by INLINE vars from buildPalette* (not the page theme) so it shows + the candidate palette even before the operator commits to it. */ +.wappearance-page { display: block; } +.wappearance-card { max-width: var(--wsettings-content-max); margin: 0 auto 10px; display: grid; gap: 12px; + padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--ws-r-lg); background: var(--panel); } +.wappearance-card > header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.wappearance-card > header h3 { margin: 0; color: var(--bright); font-size: 13px; font-weight: 700; } +.wappearance-card > header span { color: var(--dim); font-size: var(--ws-fs-small); font-family: var(--font-mono); } +.wappearance-modes { display: inline-flex; gap: 3px; padding: 3px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel2); justify-self: start; } +.wappearance-modes button { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 14px; + border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 11.5px; font-weight: 600; cursor: pointer; } +.wappearance-modes button.on { color: var(--bright); background: var(--panel); box-shadow: var(--shadow-sm); } +.wappearance-modes button.on svg { color: var(--accent); } +.wappearance-presets { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } +.wappearance-preset { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; align-items: center; + column-gap: 9px; row-gap: 1px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 10px; + background: var(--panel2); cursor: pointer; text-align: left; transition: border-color .14s ease, background .14s ease; } +.wappearance-preset:hover { border-color: var(--line2); background: var(--panel3); } +.wappearance-preset.on { border-color: color-mix(in srgb, var(--accent) 55%, var(--line2)); + background: color-mix(in srgb, var(--accent) 7%, var(--panel)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); } +.wappearance-preset i { grid-row: 1 / 3; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--line2); } +.wappearance-preset strong { color: var(--bright); font-size: 12px; } +.wappearance-preset code { color: var(--dim); font-size: 9.5px; font-family: var(--font-mono); } +.wappearance-hue { -webkit-appearance: none; appearance: none; width: 100%; height: 14px; margin: 2px 0; border-radius: var(--r-pill); + border: 1px solid var(--line2); outline: 0; cursor: pointer; + background: linear-gradient(90deg, hsl(0,75%,55%), hsl(45,80%,52%), hsl(90,65%,45%), hsl(150,60%,42%), hsl(200,75%,50%), hsl(250,70%,60%), hsl(300,65%,55%), hsl(340,75%,55%), hsl(359,75%,55%)); } +.wappearance-hue::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; + background: var(--panel); border: 3px solid var(--bright); box-shadow: var(--shadow-sm); } +.wappearance-hue::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--panel); border: 3px solid var(--bright); } +.wappearance-family { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 6px; } +.wappearance-swatch { display: grid; gap: 3px; justify-items: center; } +.wappearance-swatch i { width: 100%; height: 26px; border-radius: 7px; border: 1px solid var(--line2); } +.wappearance-swatch em { color: var(--muted); font-size: 9px; font-style: normal; } +.wappearance-swatch code { color: var(--dim); font-size: 8px; font-family: var(--font-mono); } +.wappearance-preview { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel2); } +.wp-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.wp-chips span { font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 6px; color: var(--c); + background: color-mix(in srgb, var(--c) 10%, var(--panel)); border: 1px solid color-mix(in srgb, var(--c) 30%, var(--line)); } +.wp-ledger { display: grid; gap: 2px; font-family: var(--font-mono); font-size: 11px; } +.wp-ledger > div { display: flex; gap: 10px; padding: 5px 8px; border-radius: 6px; background: var(--panel); border: 1px solid var(--line); color: var(--text); } +.wp-ledger time { color: var(--dim); } +.wp-foot { display: flex; align-items: center; gap: 14px; } +.wp-primary { background: var(--accent); color: var(--on-accent); border: 0; border-radius: 8px; padding: 7px 14px; font-weight: 700; font-size: 12px; cursor: default; } +.wp-live { display: inline-flex; align-items: center; gap: 6px; color: var(--green); font-size: 11px; font-weight: 650; } +.wp-live i { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px color-mix(in srgb, var(--green) 60%, transparent); } + +.wsettings-setting-group { display: grid; gap: 11px; align-content: start; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--ws-r-lg); background: var(--panel); } +.wsettings-setting-group h3 { margin: 0 0 1px; color: var(--bright); font-size: 13px; font-weight: 700; } +.wsettings-setting-group label { min-width: 0; display: grid; grid-template-columns: minmax(104px, 1fr) minmax(132px, 1fr); align-items: center; gap: 10px; } +.wsettings-setting-group label > span { color: var(--muted); font-size: var(--ws-fs-small); } +.wsettings-setting-group input, +.wsettings-setting-group select { + min-width: 0; width: 100%; min-height: 33px; padding: 6px 9px; + border: 1px solid var(--line2); border-radius: var(--ws-r-sm); outline: 0; + color: var(--text); background: var(--panel2); font-size: var(--ws-fs-body); + transition: border-color .15s ease, box-shadow .15s ease; +} +.wsettings-setting-group input:focus, +.wsettings-setting-group select:focus { border-color: color-mix(in srgb, var(--accent) 70%, var(--line2)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); } +.wsettings-derived { display: grid; gap: 4px; padding: 11px 13px; border: 1px solid var(--line); border-radius: var(--ws-r-sm); background: var(--panel2); } +.wsettings-derived span { color: var(--dim); font-size: var(--ws-fs-micro); } +.wsettings-derived strong { color: var(--bright); font: 600 17px/1.2 var(--font-mono); } +.wsettings-derived small { color: var(--dim); font-size: var(--ws-fs-micro); line-height: 1.4; } +.wsettings-inline-note { max-width: var(--wsettings-content-max); display: flex; align-items: flex-start; gap: 7px; margin: 12px auto 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--ws-r-md); color: var(--muted); background: var(--panel); font-size: var(--ws-fs-small); line-height: 1.5; } +.wsettings-inline-note svg { flex: none; margin-top: 1px; } + +/* ── llm profile cards (推理模型) ────────────────────────────────────────── */ +.wmodels-page > header p { max-width: 700px; } +.wmodel-settings-card { gap: 11px; } +.wmodel-settings-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding-bottom: 11px; border-bottom: 1px solid var(--line); } +.wmodel-settings-card > header > div { min-width: 0; display: grid; gap: 3px; } +.wmodel-settings-card > header h3 { margin: 0; } +.wmodel-settings-card > header p { margin: 0; color: var(--dim); font-size: var(--ws-fs-hint); line-height: 1.4; } +.wmodel-settings-card > header > span { + flex: none; display: inline-flex; align-items: center; min-height: 21px; padding: 0 8px; + border: 1px solid var(--line); border-radius: var(--r-pill); + color: var(--muted); background: var(--panel2); font-size: var(--ws-fs-micro); white-space: nowrap; +} +.wmodel-settings-card > header > span.missing { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 35%, var(--line)); } +.wmodel-default-endpoint { display: grid; grid-template-columns: minmax(104px, 1fr) minmax(132px, 1fr); align-items: center; gap: 3px 10px; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--ws-r-sm); background: var(--panel2); } +.wmodel-default-endpoint > span { color: var(--muted); font-size: var(--ws-fs-small); } +.wmodel-default-endpoint strong { min-width: 0; overflow: hidden; color: var(--text); font: 500 11px/1.35 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.wmodel-default-endpoint small { grid-column: 2; color: var(--dim); font-size: var(--ws-fs-micro); line-height: 1.4; } +.wsettings-setting-group .wmodel-key-field { align-items: start; } +.wmodel-key-field > span { padding-top: 9px; } +.wmodel-key-field > div { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 33px auto; gap: 5px; } +.wmodel-key-field > div > button { min-width: 33px; min-height: 33px; display: inline-flex; align-items: center; justify-content: center; padding: 0 8px; border: 1px solid var(--line2); border-radius: var(--ws-r-sm); color: var(--muted); background: var(--panel2); font-size: var(--ws-fs-small); transition: color .13s ease, border-color .13s ease; } +.wmodel-key-field > div > button:hover { color: var(--bright); border-color: var(--muted); } +.wmodel-key-field > div > button.clear { color: var(--red); } +.wmodel-credential-note, +.wmodel-test-result { display: flex; align-items: flex-start; gap: 6px; margin: -3px 0 0 calc(50% + 5px); color: var(--dim); font-size: var(--ws-fs-hint); line-height: 1.45; } +.wmodel-credential-note svg, +.wmodel-test-result svg { flex: none; margin-top: 1px; } +.wmodel-credential-note.missing, +.wmodel-test-result.bad { color: var(--red); } +.wmodel-test-result.ok { color: var(--green); } +.wmodel-settings-card > .wsettings-test-button { width: 100%; min-height: 33px; margin-top: 1px; } +.wmodel-temperature-switch { padding: 2px 0 1px; } +.wmodel-temperature-note { margin: -4px 0 0 calc(50% + 5px); color: var(--dim); font-size: var(--ws-fs-hint); line-height: 1.45; } + +/* ── responsive ──────────────────────────────────────────────────────────── */ +@media (max-width: 1180px) { + .wsettings-page { grid-template-columns: 200px minmax(0, 1fr); } + .wsettings-orchestration { grid-template-columns: minmax(0, 1fr) 340px; } + .wflow-canvas-body { grid-template-columns: 112px minmax(0, 1fr); } + .wflow-library button { grid-template-columns: 20px minmax(0, 1fr); padding-inline: 10px; } + .wflow-library button svg:last-child { display: none; } + .wsettings-subworkspace { grid-template-columns: minmax(0, 1fr) 350px; } + .wroster-list { grid-template-columns: 1fr; } + .wreview-slot { grid-template-columns: 1fr; } + .wreview-slot-state { grid-auto-flow: column; justify-content: space-between; justify-items: start; } + .wruntime-workspace { grid-template-columns: minmax(0, 1fr) minmax(260px, 300px); justify-content: stretch; } +} +@media (max-width: 900px) { + .wsettings-page { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); overflow: auto; } + .wsettings-nav { min-height: auto; flex-direction: row; align-items: center; padding: 8px 10px; border-right: 0; border-bottom: 1px solid var(--line); } + .wsettings-brand { padding: 4px 8px; } + .wsettings-nav-title, .wsettings-nav-foot { display: none; } + .wsettings-nav nav { margin-left: auto; display: flex; overflow-x: auto; padding: 0; } + .wsettings-nav nav button { width: auto; min-height: 38px; grid-template-columns: 16px auto; padding: 6px 9px; } + .wsettings-nav nav button.on::before { top: auto; bottom: 0; left: 8px; right: 8px; width: auto; height: 2px; border-radius: 3px 3px 0 0; } + .wsettings-nav nav button small, .wsettings-nav nav button > svg:last-child { display: none; } + .wsettings-main { min-height: 700px; } + .wsettings-orchestration, .wsettings-subworkspace { grid-template-columns: 1fr; overflow: auto; } + .wroster-workspace { min-height: 500px; overflow: visible; } + .wflow-canvas-shell { min-height: 570px; } + .wflow-inspector, .wset-inspector, .wsettings-editor { min-height: 520px; border-left: 0; border-top: 1px solid var(--line); } + .wsettings-topbar { padding-inline: 12px; } + .wsettings-draft { display: none; } + .wsettings-simple-grid { grid-template-columns: 1fr; } + .wruntime-workspace { height: auto; min-height: 100%; grid-template-columns: 1fr; } + .wruntime-stage { max-width: none; } + .wruntime-summary { position: static; } +} +@media (max-width: 620px) { + .wsettings-brand span, .wsettings-nav nav button > span { display: none; } + .wsettings-nav nav button { grid-template-columns: 16px; } + .wsettings-topbar h1 { font-size: 14px; } + .wsettings-top-actions button { padding-inline: 9px; } + .wflow-canvas-head { align-items: flex-start; flex-direction: column; padding: 9px 10px; } + .wflow-toolbar { width: 100%; margin: 0; overflow-x: auto; } + .wflow-canvas-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } + .wflow-library { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); } + .wflow-library > strong { display: none; } + .wflow-library button { min-width: 96px; min-height: 40px; border-right: 1px solid var(--line); border-bottom: 0; } + .wsettings-section-head { flex-direction: column; } + .wroster-head-main { gap: 10px; } + .wroster-check-all { align-self: flex-end; } + .wroster-addbar > span, + .wroster-primary-engines button > span { display: none; } + .wroster-primary-engines button { width: 30px; justify-content: center; padding: 0; } + .wroster-card-head { grid-template-columns: 18px 30px minmax(0, 1fr) 33px 14px; gap: 7px; } + .wroster-engine, .wreview-engine { width: 30px; height: 30px; } + .wreview-selection { grid-template-columns: 30px minmax(0, 1fr) 13px; } + .wreview-selection em { display: none; } + .wset-form-section label { grid-template-columns: 1fr; gap: 5px; } + .wset-effort-control > label { grid-template-columns: 1fr; gap: 5px; } + .wset-effort-control > small, .wbinding-local-note { padding-left: 0; margin-left: 0; } + .wsettings-setting-group label { grid-template-columns: 1fr; gap: 5px; } + .wmodel-default-endpoint { grid-template-columns: 1fr; gap: 3px; } + .wmodel-default-endpoint small { grid-column: 1; } + .wmodel-credential-note, + .wmodel-test-result { margin-left: 0; } + .wruntime-workspace { padding: 16px 14px 26px; } + .wruntime-modes { grid-template-columns: 1fr; } + .wruntime-network-list, .wruntime-image-checks { grid-template-columns: 1fr; } + .wruntime-image-checks > span { border-right: 0; border-bottom: 1px solid var(--line); } + .wruntime-image-checks > span:last-child { border-bottom: 0; } + .wruntime-image > header, .wruntime-image > footer { align-items: stretch; flex-direction: column; } + .wruntime-image > header code { max-width: 100%; } + .wruntime-image > footer > div { justify-content: flex-end; } + .wruntime-intro { margin-bottom: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + .wruntime-mode, + .wruntime-network-option { transition: none; } +} + +/* ========================================================================== */ +/* Worker settings — motion & detail polish */ +/* Entrance choreography, popover spring, and state-change pops, informed by */ +/* beautifului.dev's component motion: fade-up 8px / pop-in scale(.96) .16s / */ +/* stream-in blur(4px)→0 on a strong ease-out. Entrance animations use */ +/* fill-mode backwards so hover/press transforms keep working afterwards. */ +/* ========================================================================== */ +@keyframes wset-fade-up { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +@keyframes wset-pop { 0% { transform: scale(.4); } 60% { transform: scale(1.18); } 100% { transform: scale(1); } } +@keyframes wset-blur-in { from { opacity: 0; filter: blur(4px); transform: translate(-50%, 6px); } to { opacity: 1; filter: blur(0); transform: translate(-50%, 0); } } +@keyframes wset-bar-in { from { transform: scaleY(.35); opacity: .4; } to { transform: scaleY(1); opacity: 1; } } + +/* section entrances — staggered card fade-up, one calm curve */ +.wroster-head, .wreview-slot, +.wsettings-simple-page > header, .wsettings-inline-note, +.wruntime-intro, .wruntime-network, .wruntime-image, .wruntime-local-note, .wruntime-summary, +.wset-inspector-head { animation: wset-fade-up .3s var(--ease-out-soft) backwards; } +.wreview-slot { animation-delay: .1s; } +.wruntime-summary { animation-delay: .06s; } +.wroster-list > *, .wsettings-simple-grid > *, .wruntime-modes > *, .wruntime-network-list > * { + animation: wset-fade-up .32s var(--ease-out-soft) backwards; +} +.wroster-list > *:nth-child(2), .wsettings-simple-grid > *:nth-child(2), .wruntime-modes > *:nth-child(2), .wruntime-network-list > *:nth-child(2) { animation-delay: .04s; } +.wroster-list > *:nth-child(3), .wsettings-simple-grid > *:nth-child(3), .wruntime-network-list > *:nth-child(3) { animation-delay: .08s; } +.wroster-list > *:nth-child(4), .wsettings-simple-grid > *:nth-child(4) { animation-delay: .12s; } +.wroster-list > *:nth-child(5) { animation-delay: .16s; } +.wroster-list > *:nth-child(6) { animation-delay: .2s; } +.wroster-list > *:nth-child(n+7) { animation-delay: .24s; } + +/* popovers — slightly deeper scale, snappier .16s (later keyframes win) */ +@keyframes wroster-context-in { from { opacity: 0; transform: translateY(-3px) scale(.96); } to { opacity: 1; transform: translateY(0) scale(1); } } +@keyframes wmodel-picker-in { from { opacity: 0; transform: translateY(-4px) scale(.96); } to { opacity: 1; transform: translateY(0) scale(1); } } +@keyframes wmodel-picker-in-top { from { opacity: 0; transform: translateY(4px) scale(.96); } to { opacity: 1; transform: translateY(0) scale(1); } } + +/* toast — blur-in rise (stream-in flavour) */ +.wsettings-feedback { animation: wset-blur-in .22s var(--ease-out-soft) backwards; } + +/* nav active bar draws itself in */ +.wsettings-nav nav button.on::before { transform-origin: center; animation: wset-bar-in .18s var(--ease-out-soft) backwards; } + +/* state-change pops: radio dot and self-check ok */ +.wruntime-mode.on .wruntime-mode-radio i, +.wroster-self-check.ok > i { animation: wset-pop .22s var(--ease-out-soft) backwards; } + +/* toggle knob gets a soft spring */ +.wset-toggle i { transition: transform .24s cubic-bezier(.34, 1.35, .44, 1), background-color .15s ease; } + +/* consistent keyboard focus ring across settings controls */ +.wsettings-page button:focus-visible, .wsettings-page a:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 65%, var(--line2)); outline-offset: 2px; +} + +/* loading skeleton (replaces bare spinner) — deck shimmer blocks */ +.wsettings-skeleton { width: 100vw; height: 100vh; display: grid; grid-template-columns: 232px minmax(0, 1fr); overflow: hidden; background: var(--bg); } +.wsettings-skel-rail { display: flex; flex-direction: column; gap: 8px; padding: 16px 12px; border-right: 1px solid var(--line); background: var(--rail); } +.wsettings-skeleton .sk-brand { width: 96px; height: 18px; margin: 2px 6px 16px; } +.wsettings-skeleton .sk-label { width: 64px; height: 11px; margin: 0 6px 6px; } +.wsettings-skeleton .sk-row { height: 46px; border-radius: 8px; } +.wsettings-skel-main { min-width: 0; display: flex; flex-direction: column; } +.wsettings-skel-top { flex: none; min-height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 8px 16px 8px 20px; border-bottom: 1px solid var(--line); } +.wsettings-skeleton .sk-title { width: 150px; height: 16px; } +.wsettings-skeleton .sk-btn { width: 96px; height: 31px; border-radius: 7px; } +.wsettings-skel-grid { width: 100%; max-width: 980px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0 auto; padding: 20px clamp(16px, 2.5vw, 32px); } +.wsettings-skeleton .sk-card { height: 116px; border-radius: 12px; } +.wsettings-skel-note { margin: 2px auto 0; color: var(--dim); font-size: 11px; } +@media (max-width: 900px) { + .wsettings-skeleton { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } + .wsettings-skel-rail { flex-direction: row; align-items: center; gap: 6px; padding: 10px 12px; border-right: 0; border-bottom: 1px solid var(--line); } + .wsettings-skeleton .sk-brand { margin: 0; } + .wsettings-skeleton .sk-label, .wsettings-skeleton .sk-row { display: none; } + .wsettings-skel-grid { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + .wroster-head, .wreview-slot, .wroster-list > *, + .wsettings-simple-page > header, .wsettings-simple-grid > *, .wsettings-inline-note, + .wruntime-intro, .wruntime-network, .wruntime-image, .wruntime-local-note, .wruntime-summary, .wruntime-modes > *, .wruntime-network-list > *, + .wset-inspector-head, .wsettings-feedback, + .wsettings-nav nav button.on::before, + .wruntime-mode.on .wruntime-mode-radio i, + .wroster-self-check.ok > i { animation: none; } + .wset-toggle i { transition: none; } +} + +/* Open-ended collect confirm (dispatch without a count may never stop). */ +.collect-warn { width: 440px; padding: 18px 20px 16px; } +.collect-warn-title { font-weight: 700; color: var(--bright); font-size: 15px; margin-bottom: 8px; } +.collect-warn-body { color: var(--red); font-size: 13px; line-height: 1.6; font-weight: 600; } +.collect-warn-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } +.collect-warn-actions button { border: 1px solid var(--border2); background: var(--bg); color: var(--bright); + border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; } +.collect-warn-actions button:hover { border-color: var(--bright); } +.collect-warn-actions button.danger { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--border2)); } +.collect-warn-actions button.danger:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 8%, transparent); } diff --git a/apps/web/ui/app/layout.tsx b/apps/web/ui/app/layout.tsx index ebb3af4..86e8854 100644 --- a/apps/web/ui/app/layout.tsx +++ b/apps/web/ui/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import SchemeBoot from "../components/SchemeBoot"; export const metadata: Metadata = { title: "Project Muteki — Command Deck", @@ -8,8 +9,11 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - {children} + + + + {children} + ); } diff --git a/apps/web/ui/app/page.tsx b/apps/web/ui/app/page.tsx index b51652d..fc4eabb 100644 --- a/apps/web/ui/app/page.tsx +++ b/apps/web/ui/app/page.tsx @@ -1,22 +1,41 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useRun, useRunList, useFolders, newRun, patchRun, deleteRun, uploadFiles, spawnWorker, killWorker, openWorkspace, createFolder, renameFolder, deleteFolder, SavedFile } from "@/lib/useRun"; -import { useT } from "@/lib/i18n"; -import { GraphNode, isRunActive } from "@/lib/events"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties, ReactNode, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; +import { useRun, useRunList, useFolders, newRun, patchRun, deleteRun, uploadFiles, spawnWorker, killWorker, openWorkspace, createFolder, renameFolder, deleteFolder, SavedFile, apiFetch } from "@/lib/useRun"; +import { useT, useLang } from "@/lib/i18n"; +import { DeckState, GraphNode, ChatMessage, isRunActive, isWorkerLane, workerIds, swarmDigest } from "@/lib/events"; import { I18nProvider } from "@/lib/i18n"; import { ThreadRail } from "@/components/ThreadRail"; import { Conversation } from "@/components/Conversation"; -import type { DispatchOpts } from "@/components/Conversation"; -import { ArtifactPanel } from "@/components/ArtifactPanel"; +import type { ControlCommandOpts, DispatchOpts } from "@/components/Conversation"; import { LoginGate } from "@/components/LoginGate"; -import { WorkerSettings } from "@/components/WorkerSettings"; import { CommandPalette } from "@/components/CommandPalette"; import { BtwPanel } from "@/components/BtwPanel"; import { ToastLane, useToasts } from "@/components/Toast"; -import type { ArtifactView } from "@/components/ArtifactPanel"; +import type { ArtifactView, SwarmDigest } from "@/lib/events"; import { clampRailWidth, RAIL_WIDTH_DEFAULT, RAIL_WIDTH_STORAGE_KEY } from "@/lib/railSizing"; import { useDeckMotion } from "@/lib/useDeckMotion"; +import { Icon, type IconName } from "@/components/Icon"; +import { SelectionGlider } from "@/components/SelectionGlider"; +import { GraphView } from "@/components/GraphView"; +import { NodeInspector } from "@/components/NodeInspector"; +import { Blackboard } from "@/components/Blackboard"; +import { WorkerLanes } from "@/components/WorkerLanes"; +import { EvidenceChain } from "@/components/EvidenceChain"; +import { PanelSkeleton } from "@/components/Skeleton"; +import { PanelEmpty } from "@/components/PanelEmpty"; +import { VulnReportsList } from "@/components/VulnReportDoc"; +import { actorDisplayTitle, toWorkerIdentity, workerColor, workerDisplayName, workerEngine } from "@/lib/workers"; +import { applySelection, readSavedSelection } from "@/lib/palette-engine"; +import { + ledgerItemContainsId, + ledgerItemHeight, + projectActivityLedger, + toolCommandLabel, + toolGroupFailedCommand, + toolGroupLatestCommand, +} from "@/lib/activityLedger"; /** * Muteki Command Deck — conversation-first shell. @@ -26,7 +45,7 @@ import { useDeckMotion } from "@/lib/useDeckMotion"; * spatial views: fact graph and blackboard. The run summary lives in the home * workspace instead of a separate statistics page. * - * ThreadRail (run list) │ Conversation (spine) │ ArtifactPanel (graph/blackboard) + * ThreadRail (run list) │ Conversation (spine) │ RuntimeArtifactPanel (graph/blackboard) * * The deck stays a dumb subscriber (§3): dispatch POSTs /start with the prose * prompt (the swarm infers category/target/solvers), commands POST /hitl, and @@ -67,6 +86,1171 @@ const runIdFromPath = (): string => { const urlForRun = (id: string): string => id && !isDraft(id) ? `/run/${encodeURIComponent(id)}` : "/"; +type RuntimeGroup = "observe" | "investigate" | "assets"; +type RuntimeTab = { view: ArtifactView; key: string; group: RuntimeGroup; icon: IconName }; + +const RUNTIME_GROUPS: { id: RuntimeGroup; key: string; descKey: string; icon: IconName }[] = [ + { id: "observe", key: "runtime.group.observe", descKey: "runtime.group.observeDesc", icon: "radio" }, + { id: "investigate", key: "runtime.group.investigate", descKey: "runtime.group.investigateDesc", icon: "crosshair" }, + { id: "assets", key: "runtime.group.assets", descKey: "runtime.group.assetsDesc", icon: "layers" }, +]; + +const RUNTIME_TABS: RuntimeTab[] = [ + { view: "timeline", key: "panelbtn.timeline", group: "observe", icon: "rows" }, + { view: "workers", key: "panelbtn.workers", group: "observe", icon: "cpu" }, + { view: "graph", key: "rc.factGraph", group: "observe", icon: "network" }, + { view: "evidence", key: "panelbtn.evidence", group: "investigate", icon: "layers" }, + { view: "blackboard", key: "rc.blackboard", group: "investigate", icon: "board" }, + { view: "findings", key: "panelbtn.findings", group: "investigate", icon: "alert" }, + { view: "reports", key: "panelbtn.reports", group: "assets", icon: "list" }, + { view: "credentials", key: "panelbtn.credentials", group: "assets", icon: "lock" }, + { view: "pocs", key: "panelbtn.pocs", group: "assets", icon: "terminal" }, + { view: "routes", key: "panelbtn.routes", group: "assets", icon: "network" }, + { view: "directives", key: "panelbtn.directives", group: "assets", icon: "send" }, +]; + +const RUNTIME_COPY: Record = { + "runtime.title": { zh: "运行时", en: "Runtime" }, + "runtime.untitled": { zh: "未命名任务", en: "Untitled run" }, + "runtime.group.observe": { zh: "轨迹", en: "Trace" }, + "runtime.group.observeDesc": { zh: "事件、Worker 与执行关系", en: "Events, workers, and execution relationships" }, + "runtime.group.investigate": { zh: "调查", en: "Investigation" }, + "runtime.group.investigateDesc": { zh: "证据、知识与审查结果", en: "Evidence, knowledge, and review results" }, + "runtime.group.assets": { zh: "资产", en: "Assets" }, + "runtime.group.assetsDesc": { zh: "漏洞报告、凭据、PoC、路线与指令", en: "Reports, credentials, PoCs, routes, and directives" }, + "runtime.status.live": { zh: "实时", en: "Live" }, + "runtime.status.complete": { zh: "已结束", en: "Complete" }, + "runtime.status.standby": { zh: "待命", en: "Standby" }, + "runtime.backToConversation": { zh: "返回对话", en: "Back to conversation" }, + "runtime.trace.title": { zh: "运行图谱", en: "Runtime graph" }, + "runtime.trace.coordinator": { zh: "调度", en: "Control" }, + "runtime.trace.workers": { zh: "Worker", en: "Worker" }, + "runtime.trace.tools": { zh: "工具", en: "Tools" }, + "runtime.trace.evidence": { zh: "证据", en: "Evidence" }, + "runtime.trace.relations": { zh: "关系线", en: "Relations" }, + "runtime.trace.fit": { zh: "适应窗口", en: "Fit view" }, + "runtime.trace.zoomLevel": { zh: "当前缩放", en: "Current zoom" }, + "runtime.trace.window": { zh: "当前窗口", en: "Visible window" }, + "runtime.trace.dragHint": { zh: "滚轮或触控板缩放 · 横向手势或拖动平移 · 双击复位", en: "Wheel or pinch to zoom · swipe or drag to pan · double-click to reset" }, + "runtime.trace.selectHint": { zh: "点击事件区块查看内容,并定位到下方事件记录。", en: "Select an event to inspect it and locate it in the ledger." }, + "runtime.trace.actor": { zh: "来源", en: "Actor" }, + "runtime.trace.type": { zh: "类型", en: "Type" }, + "runtime.trace.time": { zh: "时间", en: "Time" }, + "runtime.trace.closeDetail": { zh: "关闭事件详情", en: "Close event details" }, + "runtime.trace.events": { zh: "{n} 事件", en: "{n} events" }, + "runtime.trace.actors": { zh: "{n} 来源", en: "{n} actors" }, + "runtime.trace.calls": { zh: "{n} 调用", en: "{n} calls" }, + "runtime.trace.workerTotal": { zh: "{n} 个 Worker", en: "{n} workers" }, + "runtime.trace.noWorkers": { zh: "暂无 Worker", en: "No workers" }, + "runtime.ledger.title": { zh: "事件记录", en: "Event ledger" }, + "runtime.ledger.visible": { zh: "显示 {visible}/{total}", en: "Showing {visible}/{total}" }, + "runtime.ledger.search": { zh: "搜索事件、输出或 Worker", en: "Search events, output, or workers" }, + "runtime.ledger.event": { zh: "事件", en: "Event" }, + "runtime.ledger.content": { zh: "内容", en: "Content" }, + "runtime.ledger.time": { zh: "时间", en: "Time" }, + "runtime.ledger.jumpLatest": { zh: "回到最新", en: "Jump to latest" }, + "runtime.ledger.toolGroup": { zh: "调用了 {n} 次工具", en: "Called {n} tools" }, + "runtime.ledger.toolGroupLatest": { zh: "调用了 {n} 次工具 · 最近 {cmd}", en: "Called {n} tools · latest {cmd}" }, + "runtime.ledger.toolFailed": { zh: "失败 {cmd}", en: "Failed {cmd}" }, + "runtime.ledger.toolPending": { zh: "进行中", en: "Running" }, + "runtime.event.tools": { zh: "工具组", en: "Tools" }, + "runtime.trace.collapse": { zh: "收起运行图谱", en: "Collapse runtime graph" }, + "runtime.trace.expand": { zh: "展开运行图谱", en: "Expand runtime graph" }, + "runtime.event.input": { zh: "输入", en: "Input" }, + "runtime.event.system": { zh: "系统", en: "System" }, + "runtime.event.tool": { zh: "工具", en: "Tool" }, + "runtime.event.reasoning": { zh: "推理", en: "Reasoning" }, + "runtime.event.insight": { zh: "洞察", en: "Insight" }, + "runtime.event.guidance": { zh: "引导", en: "Guidance" }, + "runtime.event.result": { zh: "结果", en: "Result" }, + "runtime.event.worker": { zh: "Worker", en: "Worker" }, + "runtime.event.agent": { zh: "协调器", en: "Agent" }, + "runtime.findings.accepted": { zh: "证据门槛通过", en: "Evidence accepted" }, + "runtime.findings.review": { zh: "审查记录", en: "Review record" }, + "runtime.findings.resource": { zh: "资源", en: "Resource" }, + "runtime.findings.identities": { zh: "身份", en: "Identities" }, + "runtime.reports.accepted": { zh: "已入库", en: "Accepted" }, + "runtime.reports.submitted": { zh: "待复现", en: "Submitted" }, + "runtime.reports.reproduced": { zh: "已复现", en: "Reproduced" }, + "runtime.reports.rejected": { zh: "已拒绝", en: "Rejected" }, + "runtime.reports.reproFailed": { zh: "复现失败", en: "Reproduction failed" }, + "runtime.reports.severity.critical": { zh: "严重", en: "Critical" }, + "runtime.reports.severity.high": { zh: "高危", en: "High" }, + "runtime.reports.severity.medium": { zh: "中危", en: "Medium" }, + "runtime.reports.severity.low": { zh: "低危", en: "Low" }, + "runtime.reports.cvss": { zh: "参考向量", en: "Reference vector" }, + "runtime.reports.expand": { zh: "展开完整内容", en: "Expand report" }, + "runtime.reports.collapse": { zh: "收起", en: "Collapse report" }, + "runtime.reports.class": { zh: "类型", en: "Type" }, + "runtime.reports.resource": { zh: "位置", en: "Location" }, + "runtime.reports.impact": { zh: "影响", en: "Impact" }, + "runtime.reports.witness": { zh: "证明输出", en: "Proof of concept output" }, + "runtime.reports.copyMarkdown": { zh: "复制 Markdown", en: "Copy Markdown" }, + "runtime.reports.copyMarkdownAria": { zh: "复制报告 Markdown:{text}", en: "Copy report Markdown: {text}" }, + "runtime.reports.copyCollection": { zh: "复制合集", en: "Copy collection" }, + "runtime.reports.copyCollectionAria": { zh: "复制漏洞报告集 Markdown", en: "Copy vulnerability report collection Markdown" }, + "runtime.reports.summary": { zh: "漏洞概要", en: "Summary" }, + "runtime.reports.preconditions": { zh: "先决条件", en: "Prerequisites" }, + "runtime.reports.role": { zh: "影响对象", en: "Affected party" }, + "runtime.reports.steps": { zh: "复现步骤", en: "Steps to reproduce" }, + "runtime.reports.replay": { zh: "PoC", en: "PoC" }, + "runtime.reports.narrative": { zh: "漏洞概要", en: "Summary" }, + "runtime.reports.markdownSource": { zh: "Markdown 原文", en: "Markdown source" }, + "runtime.reports.missing": { zh: "(未填写)", en: "(not provided)" }, + "runtime.workers.overview": { zh: "执行阵列", en: "Execution roster" }, + "runtime.workers.online": { zh: "在线", en: "Online" }, + "runtime.workers.active": { zh: "活动", en: "Active" }, + "runtime.workers.solved": { zh: "完成", en: "Solved" }, + "runtime.workers.calls": { zh: "调用", en: "Calls" }, +}; + +function useRuntimeT() { + const base = useT(); + const { lang } = useLang(); + return useCallback((key: string, vars?: Record) => { + const copy = RUNTIME_COPY[key]?.[lang]; + if (!copy) return base(key, vars); + return Object.entries(vars ?? {}).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), copy); + }, [base, lang]); +} + +const runtimeTsMs = (ts?: number) => !ts ? 0 : ts < 1e12 ? ts * 1000 : ts; +const runtimeClock = (ts?: number) => { + const ms = runtimeTsMs(ts); + if (!ms) return "--:--:--"; + const d = new Date(ms); + const p = (n: number) => String(n).padStart(2, "0"); + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +}; +const runtimeDuration = (ms: number) => { + const total = Math.max(0, Math.round(ms / 1000)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + return h ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`; +}; + +// Live-ticking elapsed for the worker roster bar: ticks every 1s while the run is +// open, freezes at finishedAt once ended. Returns runtimeDuration's M:SS format. +function useRuntimeElapsed(startedAt?: number, finishedAt?: number): string { + const live = startedAt != null && finishedAt == null; + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!live) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [live]); + if (startedAt == null) return ""; + const end = finishedAt != null ? runtimeTsMs(finishedAt) : now; + return runtimeDuration(end - runtimeTsMs(startedAt)); +} + +function runtimeTabCount(view: ArtifactView, deck: DeckState): number | null { + switch (view) { + case "timeline": return deck.chat.length; + case "workers": return Object.keys(deck.lanes).length; + case "graph": return deck.model.nodes.length; + case "evidence": return deck.blackboard.facts.length + deck.blackboard.deadEnds.length; + case "blackboard": return deck.blackboard.facts.length + deck.blackboard.intents.length + deck.blackboard.pocs.length; + case "findings": return deck.blackboard.reviewFindings?.length ?? 0; + case "reports": return deck.blackboard.vulnReports?.length ?? 0; + case "pocs": return deck.blackboard.pocs.length; + case "routes": return (deck.blackboard.suppressedRoutes?.length ?? 0) + (deck.blackboard.branches?.length ?? 0); + case "directives": return (deck.blackboard.directives?.length ?? 0) + deck.operatorDirectives.length; + case "credentials": return null; + default: { + const _exhaustive: never = view; + return _exhaustive; + } + } +} + +type RuntimeTraceLane = "control" | "worker" | "tool" | "evidence"; +type RuntimeTraceRecord = { + message: ChatMessage; + index: number; + time: number; + lane: RuntimeTraceLane; + actor: string; +}; + +const RUNTIME_TRACE_LANE_Y: Record = { control: 18, worker: 50, tool: 82, evidence: 114 }; + +function runtimeTraceLane(message: ChatMessage): RuntimeTraceLane { + if (message.kind === "flag" || message.kind === "insight") return "evidence"; + if (message.kind === "tool") return "tool"; + if (message.solverId && isWorkerLane(message.solverId)) return "worker"; + return "control"; +} + +function RuntimeTraceOverview({ deck, running, selectedId, onSelect }: { + deck: DeckState; + running: boolean; + selectedId: string | null; + onSelect: (id: string | null) => void; +}) { + const t = useRuntimeT(); + const [zoom, setZoom] = useState(1); + const [pan, setPan] = useState(0); + const [showRelations, setShowRelations] = useState(true); + // Collapse state persists per browser; the graph body folds away but the + // head row stays as a compact summary strip. + const [collapsed, setCollapsed] = useState(() => { + if (typeof window === "undefined") return false; + try { return window.localStorage.getItem("muteki.traceCollapsed") === "1"; } catch { return false; } + }); + const toggleCollapsed = useCallback(() => { + setCollapsed((value) => { + const next = !value; + try { window.localStorage.setItem("muteki.traceCollapsed", next ? "1" : "0"); } catch { /* storage unavailable */ } + return next; + }); + }, []); + const [dragging, setDragging] = useState(false); + const plotRef = useRef(null); + const navigatorRef = useRef(null); + const dragRef = useRef<{ pointerId: number; startX: number; startPan: number } | null>(null); + const navDragRef = useRef(null); + const zoomRef = useRef(zoom); + const panRef = useRef(pan); + const wheelFrameRef = useRef(null); + const wheelIntentRef = useRef<{ mode: "pan" | "zoom"; delta: number; focus: number } | null>(null); + const dragFrameRef = useRef(null); + const pendingDragPanRef = useRef(null); + const records = useMemo(() => deck.chat + .map((message, index) => ({ + message, + index, + time: runtimeTsMs(message.ts), + lane: runtimeTraceLane(message), + actor: message.solverId || message.role, + })) + .filter((record) => record.time > 0), [deck.chat]); + const workerSiblings = useMemo( + () => workerIds(deck).map((id) => toWorkerIdentity(id, deck.lanes[id])), + [deck], + ); + const start = runtimeTsMs(deck.startedAt) || records[0]?.time || Date.now(); + const last = records[records.length - 1]?.time || start; + const runtimeNow = useMemo(() => Date.now(), [records.length, running]); + const end = running ? Math.max(runtimeNow, last, start + 1) : Math.max(runtimeTsMs(deck.finishedAt), last, start + 1); + const domain = Math.max(1, end - start); + const visibleDuration = domain / zoom; + const maxOffset = Math.max(0, domain - visibleDuration); + const viewStart = start + pan * maxOffset; + const viewEnd = viewStart + visibleDuration; + const selectedRecord = useMemo(() => records.find((record) => record.message.id === selectedId) ?? null, [records, selectedId]); + const lanes = [ + { id: "control", label: t("runtime.trace.coordinator") }, + { id: "worker", label: t("runtime.trace.workers") }, + { id: "tool", label: t("runtime.trace.tools") }, + { id: "evidence", label: t("runtime.trace.evidence") }, + ] as { id: RuntimeTraceLane; label: string }[]; + const laneCounts = useMemo(() => records.reduce>((counts, record) => { + counts[record.lane] += 1; + return counts; + }, { control: 0, worker: 0, tool: 0, evidence: 0 }), [records]); + const visibleRecords = useMemo(() => records.filter((record) => record.time >= viewStart && record.time <= viewEnd), [records, viewStart, viewEnd]); + const renderedRecords = useMemo(() => { + // Keep the graph responsive on long runs by grouping dense events into + // lane-aware time buckets. Evidence and the selected event always remain + // individually addressable. + const bucketCount = 76; + const buckets = new Map(); + const priority = new Map(); + visibleRecords.forEach((record) => { + if (record.message.id === selectedId || record.lane === "evidence") { + priority.set(record.message.id, record); + return; + } + const ratio = Math.max(0, Math.min(.9999, (record.time - viewStart) / visibleDuration)); + const key = `${record.lane}-${Math.floor(ratio * bucketCount)}`; + const current = buckets.get(key); + if (!current || record.message.kind === "flag" || record.index > current.index) buckets.set(key, record); + }); + return [...buckets.values(), ...priority.values()].sort((a, b) => a.time - b.time || a.index - b.index); + }, [visibleRecords, selectedId, viewStart, visibleDuration]); + const positionFor = useCallback((time: number) => Math.max(0, Math.min(99.35, ((time - viewStart) / visibleDuration) * 100)), [viewStart, visibleDuration]); + const connections = useMemo(() => { + const links: { id: string; from: RuntimeTraceRecord; to: RuntimeTraceRecord; kind: "actor" | "flow" }[] = []; + const lastByActor = new Map(); + let previous: RuntimeTraceRecord | null = null; + renderedRecords.forEach((record) => { + const actorPrevious = lastByActor.get(record.actor); + if (actorPrevious && actorPrevious.message.id !== record.message.id) links.push({ id: `actor-${actorPrevious.message.id}-${record.message.id}`, from: actorPrevious, to: record, kind: "actor" }); + if (previous && previous.actor !== record.actor && previous.lane !== record.lane) links.push({ id: `flow-${previous.message.id}-${record.message.id}`, from: previous, to: record, kind: "flow" }); + lastByActor.set(record.actor, record); + previous = record; + }); + if (links.length <= 144) return links; + const selectedLinks = links.filter((link) => link.from.message.id === selectedId || link.to.message.id === selectedId); + const stride = Math.ceil(links.length / Math.max(1, 144 - selectedLinks.length)); + const sampled = links.filter((_, index) => index % stride === 0); + return [...new Map([...sampled, ...selectedLinks].map((link) => [link.id, link])).values()].slice(-144); + }, [renderedRecords, selectedId]); + + const navigatorRecords = useMemo(() => { + const stride = Math.max(1, Math.ceil(records.length / 220)); + return records.filter((_, index) => index % stride === 0); + }, [records]); + + useEffect(() => { zoomRef.current = zoom; }, [zoom]); + useEffect(() => { panRef.current = pan; }, [pan]); + + // Recenter on the selected event exactly ONCE per selection change. This + // must NOT depend on the viewport (viewStart/viewEnd/zoom/…): otherwise any + // operator pan that moves the selection out of the window gets yanked back + // by this effect, fighting the user's own scroll and producing visible + // jitter. After the initial centering, the operator owns the viewport. + const selectedMessageId = selectedRecord?.message.id ?? null; + useEffect(() => { + if (!selectedRecord || zoom <= 1 || (selectedRecord.time >= viewStart && selectedRecord.time <= viewEnd)) return; + const nextStart = Math.max(start, Math.min(end - visibleDuration, selectedRecord.time - visibleDuration / 2)); + setPan(maxOffset ? (nextStart - start) / maxOffset : 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedMessageId]); + + const commitViewport = useCallback((nextZoom: number, nextPan: number) => { + const safeZoom = Math.max(1, Math.min(12, nextZoom)); + const safePan = safeZoom <= 1.001 ? 0 : Math.max(0, Math.min(1, nextPan)); + zoomRef.current = safeZoom; + panRef.current = safePan; + setZoom(safeZoom); + setPan(safePan); + }, []); + const fitView = useCallback(() => commitViewport(1, 0), [commitViewport]); + + useEffect(() => { + const canvas = plotRef.current; + if (!canvas) return; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + if (!rect.width) return; + const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? rect.width : 1; + const horizontal = event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY) * .8; + const pinchScale = event.ctrlKey ? 5 : 1; + const delta = horizontal ? (Math.abs(event.deltaX) > 0 ? event.deltaX : event.deltaY) * unit : event.deltaY * unit * pinchScale; + const focus = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const previous = wheelIntentRef.current; + wheelIntentRef.current = previous?.mode === (horizontal ? "pan" : "zoom") + ? { mode: previous.mode, delta: previous.delta + delta, focus } + : { mode: horizontal ? "pan" : "zoom", delta, focus }; + if (wheelFrameRef.current !== null) return; + wheelFrameRef.current = window.requestAnimationFrame(() => { + wheelFrameRef.current = null; + const intent = wheelIntentRef.current; + wheelIntentRef.current = null; + if (!intent) return; + const currentZoom = zoomRef.current; + const currentPan = panRef.current; + if (intent.mode === "pan") { + if (currentZoom <= 1) return; + commitViewport(currentZoom, currentPan + intent.delta / (rect.width * Math.max(1, currentZoom - 1))); + return; + } + const nextZoom = Math.max(1, Math.min(12, currentZoom * Math.exp(-intent.delta * .0018))); + const currentDuration = domain / currentZoom; + const currentOffset = currentPan * Math.max(0, domain - currentDuration); + const focusTime = start + currentOffset + intent.focus * currentDuration; + const nextDuration = domain / nextZoom; + const nextMaxOffset = Math.max(0, domain - nextDuration); + const nextStart = Math.max(start, Math.min(end - nextDuration, focusTime - intent.focus * nextDuration)); + commitViewport(nextZoom, nextMaxOffset ? (nextStart - start) / nextMaxOffset : 0); + }); + }; + canvas.addEventListener("wheel", onWheel, { passive: false }); + return () => { + canvas.removeEventListener("wheel", onWheel); + if (wheelFrameRef.current !== null) window.cancelAnimationFrame(wheelFrameRef.current); + if (dragFrameRef.current !== null) window.cancelAnimationFrame(dragFrameRef.current); + wheelFrameRef.current = null; + wheelIntentRef.current = null; + dragFrameRef.current = null; + pendingDragPanRef.current = null; + }; + }, [commitViewport, domain, end, start]); + const moveNavigator = (clientX: number) => { + const rect = navigatorRef.current?.getBoundingClientRect(); + if (!rect || zoom <= 1) return; + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const windowRatio = 1 / zoom; + const startRatio = Math.max(0, Math.min(1 - windowRatio, ratio - windowRatio / 2)); + commitViewport(zoomRef.current, startRatio / (1 - windowRatio)); + }; + const startPlotDrag = (event: ReactPointerEvent) => { + if (zoom <= 1 || (event.target as HTMLElement).closest("button")) return; + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startPan: pan }; + setDragging(true); + }; + const movePlotDrag = (event: ReactPointerEvent) => { + const origin = dragRef.current; + const width = plotRef.current?.clientWidth || 1; + if (!origin || origin.pointerId !== event.pointerId || zoom <= 1) return; + pendingDragPanRef.current = Math.max(0, Math.min(1, origin.startPan - (event.clientX - origin.startX) / (width * (zoomRef.current - 1)))); + if (dragFrameRef.current !== null) return; + dragFrameRef.current = window.requestAnimationFrame(() => { + dragFrameRef.current = null; + if (pendingDragPanRef.current === null) return; + commitViewport(zoomRef.current, pendingDragPanRef.current); + pendingDragPanRef.current = null; + }); + }; + const stopPlotDrag = (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + setDragging(false); + }; + return ( +

+
+ +
{t("runtime.trace.title")}{running && {t("runtime.status.live")}}
+ {collapsed + ? {t("runtime.trace.events", { n: records.length })} · {runtimeDuration(end - start)} + : {t("runtime.trace.dragHint")}} + {!collapsed &&
+ + + {zoom.toFixed(zoom % 1 ? 1 : 0)}× + +
} +
+
+
+ +
+
+ + {lanes.map((lane) =>
+ {renderedRecords.map((record, index) => { + if (record.lane !== lane.id) return null; + const nextTime = renderedRecords[index + 1]?.time ?? record.time + visibleDuration * .006; + const width = Math.max(.42, Math.min(3.2, ((Math.max(record.time, nextTime) - record.time) / visibleDuration) * 100)); + const style = { + "--rt-left": `${positionFor(record.time)}%`, + "--rt-width": `${width}%`, + ...(lane.id === "worker" && record.message.solverId ? { "--rt-color": workerColor(record.message.solverId) } : {}), + } as CSSProperties; + return
)} + {running && viewEnd >= end - 1000 &&
+ +
{ event.currentTarget.setPointerCapture(event.pointerId); navDragRef.current = event.pointerId; moveNavigator(event.clientX); }} onPointerMove={(event) => { if (navDragRef.current === event.pointerId) moveNavigator(event.clientX); }} onPointerUp={(event) => { if (navDragRef.current === event.pointerId) navDragRef.current = null; }} onPointerCancel={() => { navDragRef.current = null; }}> + +
+
+
+
+
+ ); +} + +function RuntimeActivityStream({ deck, selectedEventId, onSelectEvent, focusSpeaker }: { deck: DeckState; selectedEventId: string | null; onSelectEvent: (id: string | null) => void; focusSpeaker?: { id: string; nonce: number } | null }) { + const t = useRuntimeT(); + const { lang } = useLang(); + const [query, setQuery] = useState(""); + const [compact, setCompact] = useState(() => { + if (typeof window === "undefined") return false; + try { return window.localStorage.getItem("muteki.activity.compact") === "1"; } catch { return false; } + }); + const [expandedId, setExpandedId] = useState(null); + const [expandedGroupId, setExpandedGroupId] = useState(null); + const [shown, setShown] = useState>(new Set()); + // Reverse focus-seed: when the operator clicks "在活动流中查看" in Worker 详情, + // seed the speaker filter to that worker (nonce-gated so re-clicks re-seed). + const lastSpeakerNonce = useRef(null); + useEffect(() => { + if (!focusSpeaker || focusSpeaker.nonce === lastSpeakerNonce.current) return; + lastSpeakerNonce.current = focusSpeaker.nonce; + setShown(new Set([focusSpeaker.id])); + }, [focusSpeaker]); + const [viewport, setViewport] = useState({ top: 0, height: 360 }); + const scrollerRef = useRef(null); + const scrollFrameRef = useRef(null); + const pendingViewportRef = useRef<{ top: number; height: number } | null>(null); + // Follow-latest semantics (chat-app style): the ledger pins itself to the + // newest event until the operator scrolls away; while unpinned, incoming + // events only increment the "jump to latest" badge instead of yanking the + // scroll position. `pinned` mirrors pinnedRef into state for the button. + const [pinned, setPinned] = useState(true); + const [unread, setUnread] = useState(0); + const pinnedRef = useRef(true); + const jumpingRef = useRef(false); + const prevLenRef = useRef(0); + const speakerKey = (message: ChatMessage) => message.solverId || message.role; + const workerSiblings = useMemo( + () => workerIds(deck).map((id) => toWorkerIdentity(id, deck.lanes[id])), + [deck], + ); + const speakers = useMemo(() => { + const seen = new Map(); + deck.chat.forEach((message) => seen.set(speakerKey(message), message.solverId || message.role)); + return [...seen.entries()]; + }, [deck.chat]); + const visible = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + return deck.chat.filter((message) => { + if (shown.size && !shown.has(speakerKey(message))) return false; + if (!needle) return true; + const text = message.i18nKey ? t(message.i18nKey, message.i18nVars) : message.content; + const actorId = speakerKey(message); + const actor = actorDisplayTitle(actorId, t, toWorkerIdentity(actorId, deck.lanes[actorId]), workerSiblings); + return `${text} ${message.toolOutput || ""} ${message.solverId || ""} ${actor} ${message.role} ${message.kind}`.toLocaleLowerCase().includes(needle); + }); + }, [deck.chat, deck.lanes, query, shown, t, workerSiblings]); + const items = useMemo(() => projectActivityLedger(visible), [visible]); + // Drill-down edge case: operator arrived from Worker 详情 for a worker that + // hasn't emitted any chat event yet (its speaker chip won't even render). Show + // a targeted hint instead of the generic "adjust your filter" empty state. + const silentFocusedWorker = useMemo(() => { + if (shown.size !== 1 || query.trim()) return null; + const only = [...shown][0]; + const hasEvents = deck.chat.some((message) => speakerKey(message) === only); + return hasEvents ? null : only; + }, [shown, query, deck.chat]); + const labelFor = (message: ChatMessage) => { + if (message.role === "human") return t("runtime.event.input"); + if (message.role === "system") return t("runtime.event.system"); + switch (message.kind) { + case "tool": return t("runtime.event.tool"); + case "reasoning": return t("runtime.event.reasoning"); + case "insight": return t("runtime.event.insight"); + case "guidance": return t("runtime.event.guidance"); + case "flag": return t("runtime.event.result"); + case "text": + case "status": + return message.solverId && isWorkerLane(message.solverId) ? t("runtime.event.worker") : t("runtime.event.agent"); + default: { + const exhaustive: never = message.kind; + return exhaustive; + } + } + }; + const rowHeight = compact ? 32 : 46; + const expandedHeight = compact ? 142 : 176; + const headerHeight = 28; + const activeExpandedId = selectedEventId || expandedId; + const groupOpen = (itemId: string) => expandedGroupId === itemId || items.some((item) => item.id === itemId && ledgerItemContainsId(item, activeExpandedId)); + const heights = useMemo(() => items.map((item) => ledgerItemHeight(item, { + row: rowHeight, + expanded: expandedHeight, + groupOpen: item.type === "tools" && groupOpen(item.id), + expandedMessageId: activeExpandedId, + })), [activeExpandedId, expandedGroupId, expandedHeight, items, rowHeight]); + const offsets = useMemo(() => { + const next = [0]; + for (const height of heights) next.push(next[next.length - 1] + height); + return next; + }, [heights]); + const totalRowsHeight = offsets[offsets.length - 1] || 0; + const indexAtOffset = useCallback((offset: number) => { + let lo = 0; + let hi = items.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (offsets[mid + 1] <= offset) lo = mid + 1; + else hi = mid; + } + return Math.min(items.length, Math.max(0, lo)); + }, [items.length, offsets]); + const viewportStart = Math.max(0, viewport.top - headerHeight); + const startIndex = Math.max(0, indexAtOffset(viewportStart) - 4); + const endIndex = Math.min(items.length, indexAtOffset(viewportStart + viewport.height) + 6); + const virtualRows = items.slice(startIndex, endIndex); + + const updateViewport = useCallback((node: HTMLDivElement) => { + pendingViewportRef.current = { top: node.scrollTop, height: node.clientHeight }; + if (scrollFrameRef.current !== null) return; + scrollFrameRef.current = window.requestAnimationFrame(() => { + scrollFrameRef.current = null; + if (!pendingViewportRef.current) return; + setViewport(pendingViewportRef.current); + pendingViewportRef.current = null; + }); + }, []); + + useEffect(() => { + const node = scrollerRef.current; + if (!node) return; + updateViewport(node); + const observer = new ResizeObserver(() => updateViewport(node)); + observer.observe(node); + return () => { + observer.disconnect(); + if (scrollFrameRef.current !== null) window.cancelAnimationFrame(scrollFrameRef.current); + scrollFrameRef.current = null; + pendingViewportRef.current = null; + }; + }, [updateViewport]); + + // Filter/density changes re-pin to the newest event: this is a live ledger, + // so the meaningful default end is always the bottom (not the first row). + useEffect(() => { + const node = scrollerRef.current; + if (!node) return; + pinnedRef.current = true; + setPinned(true); + setUnread(0); + prevLenRef.current = items.length; + node.scrollTop = node.scrollHeight; + updateViewport(node); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [compact, query, shown, updateViewport]); + + // Live tail: while pinned, keep the latest event in view as rows stream in; + // while unpinned, just accumulate the unread count for the jump button. + useEffect(() => { + const prev = prevLenRef.current; + prevLenRef.current = items.length; + const node = scrollerRef.current; + if (!node) return; + if (pinnedRef.current) { + node.scrollTop = node.scrollHeight; + updateViewport(node); + setUnread(0); + return; + } + if (items.length > prev) setUnread((count) => count + (items.length - prev)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items.length]); + + // Scroll tracking: leaving the bottom unpins; returning to the bottom (by + // any means) re-pins. During a programmatic smooth jump, intermediate + // positions are ignored until the bottom is reached. + const handleLedgerScroll = useCallback((node: HTMLDivElement) => { + updateViewport(node); + const atBottom = node.scrollTop + node.clientHeight >= node.scrollHeight - 48; + if (jumpingRef.current) { + if (atBottom) { + jumpingRef.current = false; + pinnedRef.current = true; + setPinned(true); + setUnread(0); + } + return; + } + if (atBottom !== pinnedRef.current) { + pinnedRef.current = atBottom; + setPinned(atBottom); + if (atBottom) setUnread(0); + } + }, [updateViewport]); + + const jumpToLatest = useCallback(() => { + const node = scrollerRef.current; + if (!node) return; + jumpingRef.current = true; + pinnedRef.current = true; + node.scrollTo({ top: node.scrollHeight, behavior: "smooth" }); + updateViewport(node); + }, [updateViewport]); + + useEffect(() => { + if (!selectedEventId) return; + const index = items.findIndex((item) => ledgerItemContainsId(item, selectedEventId)); + const node = scrollerRef.current; + if (index < 0 || !node) return; + const target = headerHeight + offsets[index]; + const nextTop = Math.max(0, Math.min(target - node.clientHeight * .35, headerHeight + totalRowsHeight - node.clientHeight)); + node.scrollTo({ top: nextTop, behavior: "smooth" }); + }, [items, offsets, selectedEventId, totalRowsHeight]); + return ( +
+
+
{t("runtime.ledger.title")}{t("runtime.ledger.visible", { visible: items.length, total: deck.chat.length })}
+ + +
+ {speakers.length > 1 &&
+ + {speakers.map(([key]) => { + const identity = toWorkerIdentity(key, deck.lanes[key]); + const label = actorDisplayTitle(key, t, identity, workerSiblings); + const worker = isWorkerLane(key); + const color = worker ? workerColor(key, deck.lanes[key]?.engine) : undefined; + const title = worker ? workerDisplayName(key, identity, workerSiblings).titleAttr : label; + return ; + })} +
} +
handleLedgerScroll(event.currentTarget)}> + {!deck.chat.length ? : !visible.length ? (silentFocusedWorker ? : ) : +
+ + {virtualRows.map((item, virtualIndex) => { + const index = startIndex + virtualIndex; + const top = headerHeight + offsets[index]; + const height = heights[index]; + if (item.type === "tools") { + const solverId = item.solverId; + const isWorker = !!solverId && isWorkerLane(solverId); + const color = isWorker ? workerColor(solverId, deck.lanes[solverId]?.engine) : undefined; + const actorTitle = actorDisplayTitle(solverId, t, toWorkerIdentity(solverId, deck.lanes[solverId]), workerSiblings); + const open = groupOpen(item.id); + const failed = toolGroupFailedCommand(item.messages); + const latest = toolGroupLatestCommand(item.messages); + const pending = item.messages.some((message) => message.toolPending); + const summary = failed + ? `${t("runtime.ledger.toolGroup", { n: item.messages.length })} · ${t("runtime.ledger.toolFailed", { cmd: failed })}` + : latest + ? t("runtime.ledger.toolGroupLatest", { n: item.messages.length, cmd: latest }) + : t("runtime.ledger.toolGroup", { n: item.messages.length }); + const selected = item.messages.some((message) => message.id === selectedEventId); + return ( +
+ + {open && ( +
+ {item.messages.map((message) => { + const childExpanded = activeExpandedId === message.id; + const command = toolCommandLabel(message); + const body = childExpanded && message.toolOutput ? message.toolOutput : command; + return ( + + ); + })} +
+ )} +
+ ); + } + if (item.type !== "single") { + const exhaustive: never = item; + void exhaustive; + return null; + } + const message = item.message; + const text = message.i18nKey ? t(message.i18nKey, message.i18nVars) : message.content; + const isWorker = !!message.solverId && isWorkerLane(message.solverId); + const selected = selectedEventId === message.id; + const expanded = expandedId === message.id || selected; + const color = isWorker ? workerColor(message.solverId!) : undefined; + const actorId = message.solverId || message.role; + const actorTitle = actorDisplayTitle(actorId, t, toWorkerIdentity(actorId, deck.lanes[actorId]), workerSiblings); + return ( + + ); + })} +
} +
+ {!pinned && items.length > 0 && } +
+ ); +} + +function RuntimeWorkerView({ deck, running, focusWorker, onSpawnWorker, onKillWorker, onOpenSpeakerTimeline }: { + deck: DeckState; running: boolean; focusWorker?: { id: string; nonce: number } | null; + onSpawnWorker: (engine?: string) => void; onKillWorker: (id: string) => void; + onOpenSpeakerTimeline?: (id: string) => void; +}) { + const ids = workerIds(deck); + const lanes = ids.map((id) => deck.lanes[id]).filter(Boolean); + const calls = lanes.reduce((sum, lane) => sum + lane.toolLines.length, 0); + const digest = swarmDigest(deck); + const elapsed = useRuntimeElapsed(digest.startedAt, digest.finishedAt); + return ( + + ); +} + +function RuntimeList({ children }: { children: ReactNode }) { return
{children}
; } +function RuntimeEmpty() { const t = useRuntimeT(); return
{t("panel.empty")}
; } +type CredRow = { entity: string; value: string; seq?: number }; +type CredFetch = { status: "loading" | "ready" | "error"; rows: CredRow[]; message?: string; retry: () => void }; + +function useRunCredentials(runId: string, factGen: number): CredFetch { + const t = useRuntimeT(); + const [state, setState] = useState>({ status: "loading", rows: [] }); + const [tick, setTick] = useState(0); + useEffect(() => { + let alive = true; + setState((prev) => ({ ...prev, status: prev.rows.length ? "ready" : "loading", message: undefined })); + apiFetch(`/api/runs/${encodeURIComponent(runId)}/credentials`) + .then(async (response) => { + if (!response.ok) { + const message = response.status === 409 + ? t("runtime.credentials.unavailable") + : response.status === 404 + ? t("runtime.credentials.missing") + : `${t("runtime.credentials.error")} (${response.status})`; + throw Object.assign(new Error(message), { shown: true }); + } + return response.json(); + }) + .then((data) => { + if (!alive) return; + setState({ status: "ready", rows: Array.isArray(data.credentials) ? data.credentials : [] }); + }) + .catch((err) => { + if (!alive) return; + setState({ + status: "error", + rows: [], + message: err?.shown ? err.message : t("runtime.credentials.error"), + }); + }); + return () => { alive = false; }; + }, [runId, factGen, tick, t]); + return { ...state, retry: () => setTick((n) => n + 1) }; +} + +function RuntimeCredentials({ creds, onOpenFact }: { creds: CredFetch; onOpenFact?: (seq: number) => void }) { + const t = useRuntimeT(); + const [revealed, setRevealed] = useState>({}); + const [copied, setCopied] = useState(""); + if (creds.status === "loading" && !creds.rows.length) return
; + if (creds.status === "error") { + return
+
{creds.message || t("runtime.credentials.error")}
+ +
; + } + if (!creds.rows.length) return
{t("runtime.credentials.empty")}
; + return {creds.rows.map((row) => { + const key = `${row.entity}-${row.seq ?? row.value}`; + const open = !!revealed[key]; + return
+
+ cred + {row.entity} + {typeof row.seq === "number" && row.seq > 0 && ( + + )} +
+ {open ? row.value : "••••••••"} +
+ + +
+
; + })}
; +} + +function reviewSeverityRank(severity: string): number { + if (severity === "blocker") return 0; + if (severity === "warn" || severity === "high") return 1; + if (severity === "info") return 3; + return 2; +} + +function reviewSeverityLabel(severity: string, t: (key: string) => string): string { + if (severity === "blocker") return t("runtime.findings.sev.blocker"); + if (severity === "warn") return t("runtime.findings.sev.warn"); + if (severity === "info") return t("runtime.findings.sev.info"); + return severity; +} + +function reviewKindLabel(kind: string, t: (key: string) => string): string { + const key = `runtime.findings.kind.${kind}`; + const label = t(key); + return label === key ? kind : label; +} + +function pocStatusLabel(status: string, t: (key: string) => string): string { + const key = `runtime.pocs.status.${status}`; + const label = t(key); + return label === key ? status : label; +} + +function directiveStatusLabel(status: string, t: (key: string) => string): string { + const mapped = status === "applied" ? "acted" : status; + const key = `directive.${mapped}`; + const label = t(key); + return label === key ? status : label; +} + +function directiveRowKey(row: { actor: string; ts: number; action: string; directive: string }): string { + const raw = `${row.actor}\0${row.ts}\0${row.action}\0${row.directive}`; + let hash = 0; + for (let i = 0; i < raw.length; i += 1) hash = (hash * 31 + raw.charCodeAt(i)) | 0; + return `dir-${row.ts}-${(hash >>> 0).toString(36)}`; +} + +function RuntimeDataView({ view, deck, focusReport, focusPoc, credentials, onOpenFact, onOpenPoc, onAdoptReview }: { + view: ArtifactView; + deck: DeckState; + focusReport?: { id: string; nonce: number } | null; + focusPoc?: { id: string; nonce: number } | null; + credentials?: CredRow[]; + onOpenFact?: (factSeq: number) => void; + onOpenPoc?: (pocId: string) => void; + onAdoptReview?: (text: string) => void; +}) { + const t = useRuntimeT(); + useEffect(() => { + if (!focusPoc?.id) return; + document.getElementById(`poc-${focusPoc.id}`)?.scrollIntoView({ block: "nearest" }); + }, [focusPoc]); + if (view === "reports") { + return ( + } + clock={runtimeClock} + collectionTitle={deck.challengeName ? `${deck.challengeName} 漏洞报告集` : "漏洞报告集"} + focusReport={focusReport} + facts={deck.blackboard.facts} + pocs={deck.blackboard.pocs} + reviews={deck.blackboard.reviewFindings} + credentials={credentials} + truncated={!!deck.blackboard.truncated?.reports} + onOpenFact={onOpenFact} + onOpenPoc={onOpenPoc} + /> + ); + } + if (view === "findings") { + const reviewed = [...(deck.blackboard.reviewFindings ?? [])] + .sort((a, b) => reviewSeverityRank(a.severity) - reviewSeverityRank(b.severity) || b.ts - a.ts); + if (!reviewed.length) return
{t("runtime.findings.empty")}
; + const groups = new Map(); + for (const row of reviewed) { + const key = row.routeHash || ""; + const list = groups.get(key) ?? []; + list.push(row); + groups.set(key, list); + } + return
+ {deck.blackboard.truncated?.reviews &&
{t("runtime.truncated", { n: 80 })}
} + + {[...groups.entries()].map(([route, items]) => ( +
+
{route ? route : t("runtime.findings.unrouted")}
+ {items.map((row) => { + const action = row.recommendedActions?.[0] || row.summary; + return
+
+ + {t("runtime.findings.review")} · {reviewSeverityLabel(row.severity, t)} + + {reviewKindLabel(row.kind, t)} +
+
{row.summary}
+
{row.actor}
+ {action &&
+ +
} +
; + })} +
+ ))} +
+
; + } + if (view === "pocs") { + const rows = deck.blackboard.pocs ?? []; if (!rows.length) return ; + return + {deck.blackboard.truncated?.pocs &&
{t("runtime.truncated", { n: 80 })}
} + {[...rows].reverse().map((row) =>
+
{pocStatusLabel(row.status, t)}{row.name || row.id}
+ {row.entryCommand && {row.entryCommand}} + {row.note &&
{row.note}
} +
{[row.worker, row.intentId, row.path].filter(Boolean).join(" · ")}
+
)} +
; + } + if (view === "routes") { + const routes = deck.blackboard.suppressedRoutes ?? []; + const branches = deck.blackboard.branches ?? []; + if (!routes.length && !branches.length) return ; + const suppressed = routes.filter((row) => !row.reopened); + const reopened = routes.filter((row) => row.reopened); + return
+ {deck.blackboard.truncated?.routes &&
{t("runtime.truncated", { n: 60 })}
} + + {suppressed.length > 0 &&
{t("runtime.routes.group.suppressed")}
+ {[...suppressed].reverse().map((row) =>
{t("panel.suppressed")}{row.label || row.routeHash}
{row.reason}
{row.routeHash}
)} +
} + {reopened.length > 0 &&
{t("runtime.routes.group.reopened")}
+ {[...reopened].reverse().map((row) =>
{t("panel.reopened")}{row.label || row.routeHash}
{row.reason}
{row.routeHash}
)} +
} + {branches.length > 0 &&
{t("runtime.routes.group.branches")}
+ {[...branches].reverse().map((row) =>
{row.status === "resolved" ? t("runtime.routes.status.resolved") : t("runtime.routes.status.open")}{row.title || row.branchId}
{row.branchId} · {row.actor}
)} +
} +
+
; + } + const rows = deck.blackboard.directives ?? []; + const lifecycle = deck.operatorDirectives ?? []; + if (!rows.length && !lifecycle.length) return ; + return
+ {deck.blackboard.truncated?.directives &&
{t("runtime.truncated", { n: 60 })}
} + + {lifecycle.length > 0 &&
{t("runtime.directives.operator")}
+ {[...lifecycle].reverse().map((row) =>
{directiveStatusLabel(row.status, t)}{row.action}
{row.text}
{row.boundWorker &&
{row.boundWorker}
}
)} +
} + {rows.length > 0 &&
{t("runtime.directives.coordinator")}
+ {[...rows].reverse().map((row) =>
{row.action}{row.actor}
{row.directive}
)} +
} +
+
; +} + +function RuntimeArtifactPanel({ open, width, view, deck, running, loading, selected, onSelect, onView, onClose, onResize, minWidth, maxWidth, defaultWidth, onSpawnWorker, onKillWorker, focusWorker, focusReport, focusPoc, focusFact, focusSpeaker, onOpenSpeakerTimeline, onOpenFact, onOpenPoc, onAdoptReview, workspaceMode = false }: { + open: boolean; width: number; view: ArtifactView; deck: DeckState; running: boolean; loading: boolean; selected: GraphNode | null; + onSelect: (node: GraphNode | null) => void; onView: (view: ArtifactView) => void; onClose: () => void; onResize: (width: number) => void; + minWidth: number; maxWidth: number; defaultWidth: number; onSpawnWorker: (engine?: string) => void; onKillWorker: (id: string) => void; focusWorker?: { id: string; nonce: number } | null; focusReport?: { id: string; nonce: number } | null; focusPoc?: { id: string; nonce: number } | null; focusFact?: { seq: number; nonce: number } | null; focusSpeaker?: { id: string; nonce: number } | null; onOpenSpeakerTimeline?: (id: string) => void; onOpenFact?: (seq: number) => void; onOpenPoc?: (pocId: string) => void; onAdoptReview?: (text: string) => void; workspaceMode?: boolean; +}) { + const t = useRuntimeT(); + const factGen = Math.max(0, ...deck.blackboard.facts.filter((fact) => fact.verified && typeof fact.factSeq === "number").map((fact) => fact.factSeq as number)); + const creds = useRunCredentials(deck.runId, factGen); + const workspaceActive = workspaceMode && open; + const [resizing, setResizing] = useState(false); + const [selectedRuntimeEventId, setSelectedRuntimeEventId] = useState(null); + const currentTab = RUNTIME_TABS.find((tab) => tab.view === view) ?? RUNTIME_TABS[0]; + const currentGroup = RUNTIME_GROUPS.find((group) => group.id === currentTab.group) ?? RUNTIME_GROUPS[0]; + const groupTabs = RUNTIME_TABS.filter((tab) => tab.group === currentGroup.id); + const startResize = (event: ReactPointerEvent) => { + event.preventDefault(); setResizing(true); document.body.classList.add("artifact-resizing"); + const move = (pointer: PointerEvent) => onResize(window.innerWidth - pointer.clientX); + const stop = () => { setResizing(false); document.body.classList.remove("artifact-resizing"); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); }; + window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop); onResize(window.innerWidth - event.clientX); + }; + const resizeKey = (event: ReactKeyboardEvent) => { + if (event.key === "ArrowLeft") { event.preventDefault(); onResize(width + (event.shiftKey ? 32 : 12)); } + if (event.key === "ArrowRight") { event.preventDefault(); onResize(width - (event.shiftKey ? 32 : 12)); } + if (event.key === "Home") { event.preventDefault(); onResize(minWidth); } + if (event.key === "End") { event.preventDefault(); onResize(maxWidth); } + if (event.key === "Enter") { event.preventDefault(); onResize(defaultWidth); } + }; + return
+ {open && <> + {!workspaceActive &&
onResize(defaultWidth)} />} + {!workspaceActive &&
{t("runtime.title")}
{t(currentTab.key)}{t(running ? "runtime.status.live" : deck.finished ? "runtime.status.complete" : "runtime.status.standby")}
{deck.challengeName || t("runtime.untitled")} · {deck.runId}
} +
+
+ + {RUNTIME_GROUPS.map((group) => )} +
+
+ {(() => { + const body =
+ {loading ?
+ : view === "graph" ? <>{selected &&
onSelect(null)} />
} + : view === "blackboard" ? + : view === "workers" ? + : view === "timeline" ? + : view === "evidence" ? + : view === "credentials" ? + : } +
; + // The time-lane graph only belongs on the activity timeline. Worker + // detail and the fact graph need the height for their own content. + if (currentGroup.id !== "observe" || view !== "timeline") return body; + return
+ + {body} +
; + })()} + } +
; +} + export default function Page() { return ( @@ -115,12 +1299,28 @@ function Deck() { const [railCollapsed, setRailCollapsed] = useState(false); const [railWidth, setRailWidth] = useState(RAIL_WIDTH_DEFAULT); const [railWidthReady, setRailWidthReady] = useState(false); - const [theme, setTheme] = useState("light"); - const [showSettings, setShowSettings] = useState(false); + const [theme, setTheme] = useState("dark"); + // Worker configuration now lives on a dedicated route. Keep this boolean as + // the shared keyboard-layer guard; the old modal no longer mounts here. + const showSettings = false; const [paletteOpen, setPaletteOpen] = useState(false); const [btwOpen, setBtwOpen] = useState(false); const [artifactOpen, setArtifactOpen] = useState(false); - const [artifactView, setArtifactView] = useState("graph"); + // Default runtime view is the activity stream (live event ledger); the last + // picked view is remembered per browser so a reopen restores the operator's + // working context instead of forcing the fact graph. + const [artifactView, setArtifactViewState] = useState(() => { + if (typeof window === "undefined") return "timeline"; + try { + const saved = window.localStorage.getItem("muteki.runtimeView"); + if (saved && RUNTIME_TABS.some((tab) => tab.view === saved)) return saved as ArtifactView; + } catch { /* storage unavailable */ } + return "timeline"; + }); + const setArtifactView = useCallback((view: ArtifactView) => { + setArtifactViewState(view); + try { window.localStorage.setItem("muteki.runtimeView", view); } catch { /* storage unavailable */ } + }, []); const [artifactWidth, setArtifactWidth] = useState(() => artifactWidthDefault(typeof window !== "undefined" ? window.innerWidth : 1280)); const [artifactWidthReady, setArtifactWidthReady] = useState(false); const [selected, setSelected] = useState(null); @@ -128,12 +1328,22 @@ function Deck() { // re-clicking the same worker re-focuses the lanes panel; WorkerLanes reacts // only to a new nonce, leaving the operator's manual chip filtering intact. const [focusedWorker, setFocusedWorker] = useState<{ id: string; nonce: number } | null>(null); + const [focusedSpeaker, setFocusedSpeaker] = useState<{ id: string; nonce: number } | null>(null); + // Inspector report-row → 运行时漏洞报告集, same nonce pattern so a second + // click on the same finding still expands and scrolls to it. + const [focusedReport, setFocusedReport] = useState<{ id: string; nonce: number } | null>(null); + const [focusedPoc, setFocusedPoc] = useState<{ id: string; nonce: number } | null>(null); + const [focusedFact, setFocusedFact] = useState<{ seq: number; nonce: number } | null>(null); const [winW, setWinW] = useState(typeof window !== "undefined" ? window.innerWidth : 1280); const [listBump, setListBump] = useState(0); // Unified toast/action-feedback lane: every operator mutation confirms here // (or reports failure). pushToast(...) is threaded into the action handlers. const { toasts, push: pushToast, dismiss: dismissToast } = useToasts(); const shellRef = useRef(null); + const openWorkerSettings = useCallback(() => { + const returnTo = `${window.location.pathname}${window.location.search}`; + window.location.assign(`/settings/workers?return=${encodeURIComponent(returnTo)}`); + }, []); // Files attached to the NEXT dispatch (file-based tracks). Saved server-side // the moment they're attached; we hold the returned absolute paths here so // dispatch() can put them on challenge.attachments. Lives at this level (not @@ -157,7 +1367,7 @@ function Deck() { } if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) setTheme("dark"); } catch { - // keep the default light theme when storage/media is unavailable + // keep the default dark theme when storage/media is unavailable } }, []); @@ -170,6 +1380,13 @@ function Deck() { } }, [theme]); + // Color scheme: the picker lives on the settings page; here we only + // re-apply the saved selection whenever the light/dark mode flips, so the + // palette is regenerated with the new mode's lightness/chroma curves. + useEffect(() => { + applySelection(readSavedSelection(), theme); + }, [theme]); + const toggleTheme = () => setTheme((cur) => (cur === "dark" ? "light" : "dark")); // Operator command → swarm. Wraps sendHitl so the otherwise-silent "生成复盘" @@ -177,11 +1394,18 @@ function Deck() { // produce the report and it lands as a normal chat bubble, so without this the // button looked dead. Every other command path is untouched (pass-through). const onCommand = useCallback( - (target: string, action: string, text: string) => { - if (action === "writeup") { - pushToast({ msg: t("toast.writeupRequested"), variant: "info", icon: "pencil" }); + async (target: string, action: string, text: string, + opts?: ControlCommandOpts): Promise => { + try { + await sendHitl(target, action, text, opts); + if (action === "writeup") { + pushToast({ msg: t("toast.writeupRequested"), variant: "info", icon: "pencil" }); + } + return true; + } catch { + pushToast({ msg: t("toast.actionFailed"), variant: "error" }); + return false; } - return sendHitl(target, action, text); }, [sendHitl, pushToast, t] ); @@ -309,7 +1533,7 @@ function Deck() { // are meaningless on the welcome screen) and no modal is up. const PANEL_KEYS: Record = { e: "evidence", w: "workers", g: "graph", t: "timeline", b: "blackboard", - f: "findings", c: "credentials", p: "pocs", r: "routes", d: "directives", + f: "findings", o: "reports", c: "credentials", p: "pocs", r: "routes", d: "directives", }; useEffect(() => { if (!deck.started || showSettings || paletteOpen || btwOpen) return; @@ -374,7 +1598,54 @@ function Deck() { // backend infers category/target from the prompt and races the shelled CLI // workers (claude + codex). The flag still only counts if it traces to real // execution output (provenance gate). + const [collectConfirm, setCollectConfirm] = useState< + { prompt: string; opts?: DispatchOpts } | null + >(null); + // Open-ended collect guard (pentest only): dispatching with NO count (form + // blank AND the planner LLM / regex finds no quota in the text) means the + // run may never stop on its own. Confirm with the operator before burning + // budget. CTF collect without a count is NOT gated here: it auto-pauses on + // no progress, and the composer tooltip already documents that semantics. + const needsCollectCountConfirm = async ( + prompt: string, opts?: DispatchOpts, + ): Promise => { + if (!opts || opts.mode !== "pentest") return false; + if ((opts.collectCount ?? 0) > 0) return false; + { + try { + const res = await apiFetch("/api/dispatch/parse", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt, goal: opts.goal ?? "", mode: "pentest", + }), + }); + const data = res.ok ? await res.json().catch(() => ({})) : {}; + const parsed = (data?.parsed ?? {}) as Record; + const expected = parsed.expected_findings; + if (typeof expected === "number" && expected >= 1) return false; + if (parsed.quantity === "first" || parsed.quantity === "recon") { + return false; + } + if (parsed.quantity === "collect") { + return parsed.collect_until_coverage !== false; + } + } catch { /* LLM unavailable — fall through to the regex mirror */ } + // Same fallback the backend applies (parse_engagement_goal): a collect + // keyword without any digit in the goal text is open-ended. + const text = (opts.goal ?? "").trim() || prompt; + return /收集|全部|所有|collect/i.test(text) && !/\d/.test(text); + } + }; const dispatch = async (prompt: string, opts?: DispatchOpts) => { + if (await needsCollectCountConfirm(prompt, opts)) { + setCollectConfirm({ prompt, opts }); + return false; // intercepted — composer keeps the prompt text + } + await doDispatch(prompt, opts); + return true; + }; + const doDispatch = async (prompt: string, opts?: DispatchOpts) => { setSelected(null); // Promote a local draft to a real backend run id at send time, so the run // persists + orders as run-NNNN. Already-real ids (selected from the rail, @@ -399,6 +1670,9 @@ function Deck() { challenge.mode = "pentest"; if (opts.goal) challenge.goal = opts.goal; if (opts.scope) challenge.scope = opts.scope; + if (opts.collectCount && opts.collectCount > 0) { + challenge.expected_findings = opts.collectCount; + } } else { if (opts?.flagFormat === "token") { challenge.flag_format = "token"; @@ -527,7 +1801,6 @@ function Deck() { const openArtifact = (view: ArtifactView) => { setArtifactView(view); setArtifactOpen(true); - setRailCollapsed(true); }; // Roster mini-row click → open the Worker 详情 panel focused on that worker. @@ -537,6 +1810,18 @@ function Deck() { openArtifact("workers"); }; + // Worker 详情 "在活动流中查看" → open the timeline tab with that worker's + // speaker chip pre-selected (reverse of onOpenWorker; bump nonce to re-seed). + const onOpenSpeakerTimeline = (id: string) => { + setFocusedSpeaker((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 })); + openArtifact("timeline"); + }; + + const onOpenReport = (id: string) => { + setFocusedReport((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 })); + openArtifact("reports"); + }; + // operator runtime worker control (BE-worker-management): add/kill an engine on // the LIVE run. Best-effort; the coordinator drains the command next tick and the // worker lifecycle events (worker_spawned / worker_killed) fold back over SSE. @@ -570,7 +1855,7 @@ function Deck() { onSelect={onSelectRun} onAction={onRailAction} onResize={onRailResize} - onOpenSettings={() => setShowSettings(true)} + onOpenSettings={openWorkerSettings} />
setArtifactOpen(false)} + runtimePanel={( + setArtifactOpen(false)} + onResize={onArtifactResize} + minWidth={ARTIFACT_WIDTH_MIN} + maxWidth={artifactWidthMax(winW)} + defaultWidth={artifactWidthDefault(winW)} + onSpawnWorker={onSpawnWorker} + onKillWorker={onKillWorker} + focusWorker={focusedWorker} + focusReport={focusedReport} + focusPoc={focusedPoc} + focusFact={focusedFact} + focusSpeaker={focusedSpeaker} + onOpenSpeakerTimeline={onOpenSpeakerTimeline} + onOpenFact={(seq) => { + setArtifactOpen(true); + setArtifactView("evidence"); + setFocusedFact({ seq, nonce: Date.now() }); + }} + onOpenPoc={(id) => { + setArtifactOpen(true); + setArtifactView("pocs"); + setFocusedPoc({ id, nonce: Date.now() }); + }} + onAdoptReview={(text) => { void onCommand("global", "directive", text); }} + workspaceMode + /> + )} onToggleRail={() => setRailCollapsed((v) => !v)} theme={theme} onToggleTheme={toggleTheme} onSpawnWorker={onSpawnWorker} onKillWorker={onKillWorker} onOpenWorker={onOpenWorker} + onOpenReport={onOpenReport} onOpenWorkspace={onOpenWorkspace} onHitlAnswered={() => pushToast({ msg: t("hitl.answered"), variant: "success" })} onOpenBtw={() => setBtwOpen(true)} /> - setArtifactOpen(false)} - onResize={onArtifactResize} - minWidth={ARTIFACT_WIDTH_MIN} - maxWidth={artifactWidthMax(winW)} - defaultWidth={artifactWidthDefault(winW)} - onSpawnWorker={onSpawnWorker} - onKillWorker={onKillWorker} - focusWorker={focusedWorker} - />
- setShowSettings(false)} /> setPaletteOpen(false)} @@ -630,13 +1935,37 @@ function Deck() { onOpenArtifact={openArtifact} onSelectRun={onSelectRun} onSpawnWorker={onSpawnWorker} - onOpenSettings={() => setShowSettings(true)} + onOpenSettings={openWorkerSettings} /> setBtwOpen(false)} runId={runId} /> + {collectConfirm && ( +
setCollectConfirm(null)}> +
e.stopPropagation()}> +
{t("collectWarn.title")}
+
{t("collectWarn.body")}
+
+ + +
+
+
+ )}
); } diff --git a/apps/web/ui/app/settings/workers/page.tsx b/apps/web/ui/app/settings/workers/page.tsx new file mode 100644 index 0000000..0c2eada --- /dev/null +++ b/apps/web/ui/app/settings/workers/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { LoginGate } from "@/components/LoginGate"; +import { WorkerOrchestration } from "@/components/WorkerOrchestration"; +import { I18nProvider } from "@/lib/i18n"; + +export default function WorkerSettingsPage() { + return ( + + + + + + ); +} + diff --git a/apps/web/ui/components/ActivityStream.tsx b/apps/web/ui/components/ActivityStream.tsx deleted file mode 100644 index ef52cde..0000000 --- a/apps/web/ui/components/ActivityStream.tsx +++ /dev/null @@ -1,304 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import type { CSSProperties } from "react"; -import { ChatMessage, DeckState, isReviewWorkerLane, isWorkerLane, COORDINATOR_IDS } from "@/lib/events"; -import { useT, useLang } from "@/lib/i18n"; -import { workerColor, workerEngine, workerInitial, workerShortLabel } from "@/lib/workers"; -import { Icon } from "@/components/Icon"; -import { PanelEmpty } from "@/components/PanelEmpty"; -import { ChipFilterBar } from "@/components/ChipFilterBar"; - -// Stable filter key + display metadata for a message's speaker. Workers key by -// their solverId (so each cli-claude-N filters independently); coordinator, -// system, and human collapse into one bucket each. -const COORD_KEY = "__coordinator"; -const SYSTEM_KEY = "__system"; -const HUMAN_KEY = "__human"; - -function speakerKey(m: ChatMessage): string { - if (m.role === "human") return HUMAN_KEY; - if (m.role === "system") return SYSTEM_KEY; - if (m.solverId && isWorkerLane(m.solverId)) return m.solverId; - return COORD_KEY; -} - -/** - * The raw activity stream — every worker + coordinator + system event in time - * order (the old main-timeline firehose). Lives in a secondary panel now so the - * coordinator conversation stays readable; this is where the operator inspects - * the full play-by-play. - */ - -function tsMs(ts: number): number { - if (!ts) return 0; - return ts < 1e12 ? ts * 1000 : ts; -} - -function clock(ts: number): string { - const ms = tsMs(ts); - if (!ms) return ""; - const d = new Date(ms); - if (isNaN(d.getTime())) return ""; - const p = (n: number) => String(n).padStart(2, "0"); - return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; -} - -// Coarse "x ago" string for the row's hover title — light touch, recomputed only -// on render (good enough; the tooltip is on-demand). zh/en chosen by the `lang`. -function relTime(ts: number, lang: string): string { - const ms = tsMs(ts); - if (!ms) return ""; - const sec = Math.max(0, Math.round((Date.now() - ms) / 1000)); - const zh = lang === "zh"; - if (sec < 5) return zh ? "刚刚" : "just now"; - if (sec < 60) return zh ? `${sec} 秒前` : `${sec}s ago`; - const min = Math.floor(sec / 60); - if (min < 60) return zh ? `${min} 分钟前` : `${min}m ago`; - const hr = Math.floor(min / 60); - if (hr < 24) return zh ? `${hr} 小时前` : `${hr}h ago`; - const day = Math.floor(hr / 24); - return zh ? `${day} 天前` : `${day}d ago`; -} - -function rowClass(m: ChatMessage): string { - if (m.role === "human") return "human"; - if (m.role === "system") return `system ${m.kind}`; - if (m.solverId && isWorkerLane(m.solverId)) return `worker ${m.kind}`; - return `coordinator ${m.kind}`; -} - -// Distance (px) from the bottom within which we consider the operator "pinned" -// and keep auto-following the tail. Small so a deliberate scroll-up of even a -// row or two releases the follow and surfaces the unread pill instead of yanking. -const PIN_SLOP = 40; -// Same speaker within this window (ms) → suppress the repeated header for a -// denser, iMessage/Slack-style grouped log. -const GROUP_WINDOW_MS = 60_000; -const COMPACT_KEY = "muteki.activity.compact"; - -export function ActivityStream({ deck }: { deck: DeckState }) { - const t = useT(); - const { lang } = useLang(); - const feedRef = useRef(null); - - // ── density toggle (comfortable ↔ compact), persisted to localStorage ────── - const [compact, setCompact] = useState(false); - useEffect(() => { - try { if (window.localStorage.getItem(COMPACT_KEY) === "1") setCompact(true); } catch { /* ignore */ } - }, []); - const toggleCompact = () => - setCompact((v) => { - const next = !v; - try { window.localStorage.setItem(COMPACT_KEY, next ? "1" : "0"); } catch { /* ignore */ } - return next; - }); - - // ── per-speaker filter ────────────────────────────────────────────────── - // CLICK-TO-SHOW: `shown` holds the keys the operator has SELECTED to display. - // Empty = show everyone (default); click a chip to light it up and show only - // the selected speakers (multi-select — click several to watch them together). - // Distinct speakers are derived from the live chat in first-appearance order. - const [shown, setShown] = useState>(new Set()); - const speakers = useMemo(() => { - const seen = new Map(); - for (const m of deck.chat) { - const key = speakerKey(m); - if (seen.has(key)) continue; - if (key === HUMAN_KEY) seen.set(key, { key, label: t("coord.you"), fullLabel: t("coord.you"), sub: "", initial: "你" }); - else if (key === SYSTEM_KEY) seen.set(key, { key, label: "system", fullLabel: "system", sub: "", initial: "S" }); - else if (key === COORD_KEY) seen.set(key, { key, label: t("coord.title"), fullLabel: t("coord.title"), sub: "", color: workerColor("reason"), initial: "CO" }); - else { - const review = isReviewWorkerLane(deck.lanes[key]); - seen.set(key, { - key, - label: workerShortLabel(key), - fullLabel: key, - sub: review ? `${t("worker.role.review")} · ${workerEngine(key)}` : workerEngine(key), - color: workerColor(key), - initial: workerInitial(key), - review, - }); - } - } - return [...seen.values()]; - }, [deck.chat, deck.lanes, t]); - - const toggleSpeaker = (key: string) => - setShown((prev) => { const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n; }); - const showAll = () => setShown(new Set()); - - const visibleChat = useMemo( - () => (shown.size === 0 ? deck.chat : deck.chat.filter((m) => shown.has(speakerKey(m)))), - [deck.chat, shown], - ); - const filterSummary = shown.size === 0 - ? t("activity.filterAll") - : t("activity.filterSelected", { n: shown.size, total: speakers.length }); - // "stick to bottom" follow mode: only auto-scroll to the newest message when the - // operator is ALREADY pinned to (within PIN_SLOP px of) the bottom. If they've - // scrolled up to read history, new messages must NOT yank them down — instead we - // surface a "↓ N new" pill they click to jump down and re-enable follow. (Was: - // unconditional scrollTop=scrollHeight on every deck.chat change → "疯狂闪现到最 - // 下面" while reading history.) - const stick = useRef(true); - // count of new messages that arrived while the operator was scrolled up. 0 = pinned - // (pill hidden); >0 → pill shows "↓ N new". Reset to 0 on jump / re-pin. - const [unread, setUnread] = useState(0); - // deck.chat is a CHAT_CAP-message ring buffer (events.ts: `.slice(-CHAT_CAP)`, - // raised from 400 to 4000 in defect-7 so a long run's history isn't dropped). Once - // it fills, .length stays at the cap while messages cycle — a length check would - // NEVER see growth. Detect a genuinely new message by the LAST message's unique id - // (streaming updates mutate the last message in place, keeping its id, so those - // don't false-trigger the pill). - const lastId = useRef(deck.chat[deck.chat.length - 1]?.id); - - const scrollToBottom = (smooth: boolean) => { - const el = feedRef.current; - if (!el) return; - const reduce = typeof window !== "undefined" - && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; - if (smooth && !reduce && typeof el.scrollTo === "function") { - el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - } else { - el.scrollTop = el.scrollHeight; - } - }; - - const jumpToBottom = () => { - scrollToBottom(true); - stick.current = true; - setUnread(0); - }; - - useEffect(() => { - const curLast = deck.chat[deck.chat.length - 1]?.id; - const arrived = curLast !== lastId.current; // a new message (new id) landed - lastId.current = curLast; - if (stick.current) { - scrollToBottom(false); // following the tail on append → keep pinned (instant) - if (unread) setUnread(0); - } else if (arrived) { - setUnread((n) => n + 1); // reading history + a new message → bump the pill count - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deck.chat]); - - const speaker = (m: ChatMessage): string => { - if (m.role === "human") return t("coord.you"); - if (m.role === "system") return m.kind === "insight" ? "insight" : "system"; - if (m.solverId && COORDINATOR_IDS.has(m.solverId)) return t("coord.title"); - return m.solverId || t("hitl.agent"); - }; - - return ( -
- {speakers.length > 1 && ( - 0} - onClear={showAll} - > - {speakers.map((s) => ( - - ))} - - )} -
{ - const el = e.currentTarget; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_SLOP; - stick.current = atBottom; - if (atBottom && unread) setUnread(0); - }} - > -
-
{t("activity.title")}
- -
-
{t("activity.subtitle")}
- {deck.chat.length === 0 ? ( - - ) : visibleChat.length === 0 ? ( - - ) : ( -
- {visibleChat.map((m, i) => { - const text = m.i18nKey ? t(m.i18nKey, m.i18nVars) : m.content; - const isWorker = !!m.solverId && isWorkerLane(m.solverId); - const isReview = isWorker && isReviewWorkerLane(deck.lanes[m.solverId!]); - const color = isWorker ? workerColor(m.solverId!, undefined) : undefined; - // Group consecutive rows from the same speaker within a short window: - // suppress the repeated header (avatar + who line) for a denser log. - const prev = visibleChat[i - 1]; - const grouped = !!prev - && speakerKey(prev) === speakerKey(m) - && Math.abs(tsMs(m.ts) - tsMs(prev.ts)) <= GROUP_WINDOW_MS; - return ( -
- -
-
- {speaker(m)} - {isReview && {t("worker.role.review")}} - {isWorker ? workerEngine(m.solverId!) : t(`msg.kind.${m.kind}`)} - {clock(m.ts) && {clock(m.ts)}} -
-
{text}
-
-
- ); - })} -
- )} -
- {unread > 0 && ( - - )} -
- ); -} diff --git a/apps/web/ui/components/ArtifactPanel.tsx b/apps/web/ui/components/ArtifactPanel.tsx deleted file mode 100644 index 8d11daf..0000000 --- a/apps/web/ui/components/ArtifactPanel.tsx +++ /dev/null @@ -1,361 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; -import { DeckState, GraphNode } from "@/lib/events"; -import { useT } from "@/lib/i18n"; -import { GraphView } from "@/components/GraphView"; -import { NodeInspector } from "@/components/NodeInspector"; -import { Blackboard } from "@/components/Blackboard"; -import { WorkerLanes } from "@/components/WorkerLanes"; -import { ActivityStream } from "@/components/ActivityStream"; -import { EvidenceChain } from "@/components/EvidenceChain"; -import { PanelSkeleton } from "@/components/Skeleton"; -import { Icon } from "@/components/Icon"; -import { apiFetch } from "@/lib/useRun"; - -/** The five secondary detail panels (the conversation stays the primary view). */ -export type ArtifactView = - | "graph" | "blackboard" | "workers" | "timeline" | "evidence" - | "findings" | "credentials" | "pocs" | "routes" | "directives"; - -const TABS: { view: ArtifactView; key: string }[] = [ - { view: "evidence", key: "panelbtn.evidence" }, - { view: "workers", key: "panelbtn.workers" }, - { view: "graph", key: "rc.factGraph" }, - { view: "timeline", key: "panelbtn.timeline" }, - { view: "blackboard", key: "rc.blackboard" }, - { view: "findings", key: "panelbtn.findings" }, - { view: "credentials", key: "panelbtn.credentials" }, - { view: "pocs", key: "panelbtn.pocs" }, - { view: "routes", key: "panelbtn.routes" }, - { view: "directives", key: "panelbtn.directives" }, -]; - -type CredentialRow = { entity: string; value: string; seq?: number }; - -function EmptyPanel({ label }: { label: string }) { - return
{label}
; -} - -function ReviewFindingsPanel({ deck }: { deck: DeckState }) { - const t = useT(); - const rows = deck.blackboard.reviewFindings ?? []; - if (!rows.length) return ; - return ( -
- {rows.slice().reverse().map((r) => ( -
-
- {r.severity} - {r.kind} - {r.routeHash && {r.routeHash}} -
-
{r.summary}
-
{r.actor}
-
- ))} -
- ); -} - -function PocsPanel({ deck }: { deck: DeckState }) { - const t = useT(); - const rows = deck.blackboard.pocs ?? []; - if (!rows.length) return ; - return ( -
- {rows.slice().reverse().map((p) => ( -
-
- {p.status} - {p.name || p.id} -
- {p.entryCommand && {p.entryCommand}} - {p.note &&
{p.note}
} -
{[p.worker, p.intentId, p.path].filter(Boolean).join(" · ")}
-
- ))} -
- ); -} - -function RoutesPanel({ deck }: { deck: DeckState }) { - const t = useT(); - const routes = deck.blackboard.suppressedRoutes ?? []; - const branches = deck.blackboard.branches ?? []; - if (!routes.length && !branches.length) return ; - return ( -
- {routes.slice().reverse().map((r) => ( -
-
- {r.reopened ? t("panel.reopened") : t("panel.suppressed")} - {r.label || r.routeHash} -
-
{r.reason}
-
{r.routeHash}
-
- ))} - {branches.slice().reverse().map((b) => ( -
-
- {b.status || "open"} - {b.title || b.branchId} -
-
{b.branchId} · {b.actor}
-
- ))} -
- ); -} - -function DirectivesPanel({ deck }: { deck: DeckState }) { - const t = useT(); - const rows = deck.blackboard.directives ?? []; - const lifecycle = deck.operatorDirectives ?? []; - if (!rows.length && !lifecycle.length) return ; - return ( -
- {lifecycle.slice().reverse().map((d) => ( -
-
- {d.status} - {d.action} -
-
{d.text}
- {d.boundWorker &&
{d.boundWorker}
} -
- ))} - {rows.slice().reverse().map((d, i) => ( -
-
- {d.action} - {d.actor} -
-
{d.directive}
-
- ))} -
- ); -} - -function CredentialsPanel({ runId }: { runId: string }) { - const t = useT(); - const [rows, setRows] = useState([]); - const [loading, setLoading] = useState(true); - useEffect(() => { - let alive = true; - setLoading(true); - apiFetch(`/api/runs/${encodeURIComponent(runId)}/credentials`) - .then((r) => r.ok ? r.json() : { credentials: [] }) - .then((j) => { if (alive) setRows(Array.isArray(j.credentials) ? j.credentials : []); }) - .catch(() => { if (alive) setRows([]); }) - .finally(() => { if (alive) setLoading(false); }); - return () => { alive = false; }; - }, [runId]); - if (loading) return
; - if (!rows.length) return ; - return ( -
- {rows.map((c) => ( -
-
- cred - {c.entity} - {c.seq && #{c.seq}} -
- {c.value} -
- ))} -
- ); -} - -/** - * The secondary-panel canvas. Opens beside the conversation (replacing the - * inspector column) and shows ONE detail view at a time: the live fact-graph, - * the collaborative blackboard, the rich worker lanes, the raw activity stream, - * or the evidence chain. The main coordinator conversation is never displaced. - */ -export function ArtifactPanel({ - open, - width, - view, - deck, - running, - loading, - selected, - onSelect, - onView, - onClose, - onResize, - minWidth, - maxWidth, - defaultWidth, - onSpawnWorker, - onKillWorker, - focusWorker, -}: { - open: boolean; - width: number; - view: ArtifactView; - deck: DeckState; - running: boolean; - loading: boolean; - selected: GraphNode | null; - onSelect: (n: GraphNode | null) => void; - onView: (v: ArtifactView) => void; - onClose: () => void; - onResize: (width: number) => void; - minWidth: number; - maxWidth: number; - defaultWidth: number; - onSpawnWorker: (engine?: string) => void; - onKillWorker: (id: string) => void; - // seed the WorkerLanes focus filter to a single worker (roster row click). - focusWorker?: { id: string; nonce: number } | null; -}) { - const t = useT(); - const [resizing, setResizing] = useState(false); - const cleanupRef = useRef<(() => void) | null>(null); - useEffect(() => () => cleanupRef.current?.(), []); - - const resizeToClientX = (clientX: number) => { - if (typeof window === "undefined") return; - onResize(window.innerWidth - clientX); - }; - - const startResize = (e: ReactPointerEvent) => { - if (!open) return; - e.preventDefault(); - e.stopPropagation(); - cleanupRef.current?.(); - setResizing(true); - document.body.classList.add("artifact-resizing"); - - const onMove = (ev: PointerEvent) => { - ev.preventDefault(); - resizeToClientX(ev.clientX); - }; - const stop = () => { - setResizing(false); - document.body.classList.remove("artifact-resizing"); - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", stop); - window.removeEventListener("pointercancel", stop); - cleanupRef.current = null; - }; - - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", stop); - window.addEventListener("pointercancel", stop); - cleanupRef.current = stop; - resizeToClientX(e.clientX); - }; - - const onResizeKey = (e: ReactKeyboardEvent) => { - if (!open) return; - if (e.key === "ArrowLeft") { - e.preventDefault(); - onResize(width + (e.shiftKey ? 32 : 12)); - } else if (e.key === "ArrowRight") { - e.preventDefault(); - onResize(width - (e.shiftKey ? 32 : 12)); - } else if (e.key === "Home") { - e.preventDefault(); - onResize(minWidth); - } else if (e.key === "End") { - e.preventDefault(); - onResize(maxWidth); - } else if (e.key === "Enter") { - e.preventDefault(); - onResize(defaultWidth); - } - }; - - return ( -
- {open && ( - <> -
onResize(defaultWidth)} - /> -
-
- {TABS.map((tab) => ( - - ))} -
- {deck.challengeName} - - -
- -
- {/* Keyed by loading/view so React remounts on every tab switch and the - .artifact-view mount animation (CSS) re-fires as a quick cross-fade. - The heavy canvases (cytoscape GraphView / react-flow Blackboard) are - rendered INSIDE this keyed branch — they already unmount when you - switch tabs, so the key adds the fade without any extra teardown. */} -
- {loading ? ( -
- -
- ) : view === "graph" ? ( - <> - - {selected && ( -
- onSelect(null)} /> -
- )} - - ) : view === "blackboard" ? ( - - ) : view === "workers" ? ( - - ) : view === "timeline" ? ( - - ) : view === "evidence" ? ( - - ) : view === "findings" ? ( - - ) : view === "credentials" ? ( - - ) : view === "pocs" ? ( - - ) : view === "routes" ? ( - - ) : ( - - )} -
-
- - )} -
- ); -} diff --git a/apps/web/ui/components/Blackboard.tsx b/apps/web/ui/components/Blackboard.tsx index 1ad1d3b..3f98def 100644 --- a/apps/web/ui/components/Blackboard.tsx +++ b/apps/web/ui/components/Blackboard.tsx @@ -8,9 +8,9 @@ import { useNodesState, useEdgesState, type Node, type Edge, type NodeProps, type NodeChange, useReactFlow, useNodesInitialized, } from "@xyflow/react"; -import { BlackboardView } from "@/lib/events"; +import { BlackboardView, SolverLane, isWorkerLane } from "@/lib/events"; import { useT } from "@/lib/i18n"; -import { workerColor as engineColor, workerInitial, workerShortLabel } from "@/lib/workers"; +import { workerColor as engineColor, workerInitial, toWorkerIdentity, workerDisplayName } from "@/lib/workers"; import { Icon, type IconName } from "@/components/Icon"; import { CopyText } from "@/components/CopyText"; import { ChipFilterBar } from "@/components/ChipFilterBar"; @@ -31,8 +31,10 @@ import { ChipFilterBar } from "@/components/ChipFilterBar"; */ // Read a :root CSS variable at runtime so React Flow chrome (background dots, -// minimap) follows the active theme — single source of truth in globals.css. -function cssVar(name: string, fallback: string): string { +// minimap) follows the active theme + color scheme — single source of truth: +// globals.css tokens, regenerated by palette-engine. Tokens used here are +// always defined, so no literal fallback is needed. +function cssVar(name: string, fallback = ""): string { if (typeof window === "undefined") return fallback; const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; @@ -41,18 +43,18 @@ function cssVar(name: string, fallback: string): string { // worker accent is keyed by ENGINE (shared lib/workers palette) so a solver reads // the same colour across the whole deck (graph / blackboard / inspector lanes). function workerColor(_workers: string[], w?: string): string { - if (!w) return "#6b7385"; + if (!w) return cssVar("--dim"); return engineColor(w); } function initials(w?: string): string { if (!w) return "·"; return workerInitial(w); } -function Avatar({ worker, workers, title }: { worker?: string; workers: string[]; title?: string }) { +function Avatar({ worker, workers, title, initial }: { worker?: string; workers: string[]; title?: string; initial?: string }) { const c = workerColor(workers, worker); return ( - {initials(worker)} + {initial || initials(worker)} ); } @@ -224,7 +226,7 @@ function GroupNode({ data }: NodeProps>) { const childCount = Number(d.childCount || 0); return (
+ style={{ ["--gc" as any]: d.accent || cssVar("--accent") }}>
{childCount > 0 && d.onToggle && ( @@ -367,6 +369,8 @@ function flattenToFlow(rawBb: BlackboardView, opts: FlowOpts): { nodes: Node visible(d.actor)), intents: rawBb.intents.filter((it) => !it.worker || visible(it.worker)), pocs: rawBb.pocs.filter((p) => visible(p.worker)), + gatedFindings: rawBb.gatedFindings.filter((f) => visible(f.actor)), + vulnReports: (rawBb.vulnReports ?? []).filter((f) => visible(f.actor)), reviewFindings: rawBb.reviewFindings.filter((f) => visible(f.actor)), suppressedRoutes: rawBb.suppressedRoutes.filter((r) => visible(r.actor)), branches: rawBb.branches.filter((b) => visible(b.actor)), @@ -444,6 +448,11 @@ function flattenToFlow(rawBb: BlackboardView, opts: FlowOpts): { nodes: Node = [ + ...(bb.vulnReports ?? []).map((f) => ({ + id: `report:${f.id}`, kind: `report ${f.status}`, title: f.title || f.resourceId, + text: [f.findingClass, f.resourceId, f.impactWho].filter(Boolean).join(" · "), actor: f.actor, + icon: "check" as IconName, tone: f.status === "accepted" ? "ok" : f.status === "rejected" ? "bad" : "", + })), ...bb.reviewFindings.map((f) => ({ id: `review:${f.id}`, kind: f.severity || f.kind, title: f.summary || f.kind, text: [f.routeHash, f.branchId].filter(Boolean).join(" · "), actor: f.actor, @@ -697,7 +706,7 @@ function DetailPanel({ detail, onClose }: { detail: NodeDetail; onClose: () => v ); } -function Canvas({ bb, runId }: { bb: BlackboardView; runId: string }) { +function Canvas({ bb, runId, lanes }: { bb: BlackboardView; runId: string; lanes?: Record }) { const t = useT(); const { fitView } = useReactFlow(); const [showEdges, setShowEdges] = useState(true); @@ -744,9 +753,17 @@ function Canvas({ bb, runId }: { bb: BlackboardView; runId: string }) { const onNodeClick = useCallback((_e: ReactMouseEvent, node: Node) => { setDetail(nodeToDetail(node)); }, []); + const boardWorkers = useMemo( + () => bb.workers.filter((id) => isWorkerLane(id)), + [bb.workers], + ); const workerFilterSummary = hidden.size === 0 ? t("bb.workersAll") - : t("bb.workersVisible", { n: Math.max(0, bb.workers.length - hidden.size), total: bb.workers.length }); + : t("bb.workersVisible", { n: Math.max(0, boardWorkers.length - hidden.size), total: boardWorkers.length }); + const workerSiblings = useMemo( + () => boardWorkers.map((id) => toWorkerIdentity(id, lanes?.[id])), + [boardWorkers, lanes], + ); // dim a node when a non-empty search doesn't match its text (keeps position). const withDim = useCallback((n: Node): Node => { @@ -859,15 +876,18 @@ function Canvas({ bb, runId }: { bb: BlackboardView; runId: string }) { expandTitle={t("bb.workersExpand")} collapseTitle={t("bb.workersCollapse")} > - {bb.workers.map((w) => ( + {boardWorkers.map((w) => { + const display = workerDisplayName(w, toWorkerIdentity(w, lanes?.[w]), workerSiblings); + return ( - ))} + ); + })} {bb.flag && ( {bb.flag} @@ -892,20 +912,21 @@ function Canvas({ bb, runId }: { bb: BlackboardView; runId: string }) { maxZoom={2} proOptions={{ hideAttribution: true }} > - + + style={{ background: cssVar("--panel2"), border: `1px solid ${cssVar("--border")}` }} /> {detail && setDetail(null)} />} ); } -export function Blackboard({ bb, runId }: { bb: BlackboardView; runId: string }) { +export function Blackboard({ bb, runId, lanes }: { bb: BlackboardView; runId: string; lanes?: Record }) { const t = useT(); const isEmpty = bb.intents.length === 0 && bb.facts.length === 0 && bb.deadEnds.length === 0 && bb.pocs.length === 0 + && (bb.vulnReports?.length ?? 0) === 0 && bb.reviewFindings.length === 0 && bb.suppressedRoutes.length === 0 && bb.branches.length === 0 && bb.directives.length === 0 && !bb.flag; if (isEmpty) @@ -921,7 +942,7 @@ export function Blackboard({ bb, runId }: { bb: BlackboardView; runId: string }) return (
- +
); diff --git a/apps/web/ui/components/CommandPalette.tsx b/apps/web/ui/components/CommandPalette.tsx index 676667e..41b95d2 100644 --- a/apps/web/ui/components/CommandPalette.tsx +++ b/apps/web/ui/components/CommandPalette.tsx @@ -4,7 +4,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Icon, type IconName } from "@/components/Icon"; import { useT, useLang } from "@/lib/i18n"; import type { RunSummary } from "@/lib/useRun"; -import type { ArtifactView } from "@/components/ArtifactPanel"; +import type { ArtifactView } from "@/lib/events"; +import { SelectionGlider } from "@/components/SelectionGlider"; /** * Cmd/Ctrl+K command palette — a Linear/Slack/VSCode-style centered overlay that @@ -82,7 +83,6 @@ export function CommandPalette(props: PaletteData) { const [active, setActive] = useState(0); const inputRef = useRef(null); - const listRef = useRef(null); const triggerRef = useRef(null); // Build the full command list (pre-filter). Only commands whose `when` holds @@ -213,13 +213,6 @@ export function CommandPalette(props: PaletteData) { setActive((i) => (filtered.length === 0 ? 0 : Math.min(i, filtered.length - 1))); }, [filtered.length]); - // keep the highlighted row scrolled into view. - useEffect(() => { - if (!open) return; - const el = listRef.current?.querySelector(`[data-idx="${active}"]`); - el?.scrollIntoView({ block: "nearest" }); - }, [active, open]); - if (!open) return null; const choose = (c: Command | undefined) => { @@ -281,7 +274,8 @@ export function CommandPalette(props: PaletteData) { esc
-
+
+ {filtered.length === 0 ? (
{t("palette.empty")}
) : ( diff --git a/apps/web/ui/components/ContextGauge.tsx b/apps/web/ui/components/ContextGauge.tsx index 9f412be..a358fce 100644 --- a/apps/web/ui/components/ContextGauge.tsx +++ b/apps/web/ui/components/ContextGauge.tsx @@ -3,7 +3,9 @@ import { ContextGauge } from "@/lib/events"; import { useT } from "@/lib/i18n"; -const COLORS = ["#4ea1ff", "#d29922", "#3fb950", "#bc8cff", "#6b7385"]; +// zone colours are palette tokens (resolved inline so the stacked bar tracks +// the active scheme); the last slot is the quiet "free/headroom" zone. +const ZONE_TOKENS = ["--blue", "--amber", "--green", "--violet", "--dim"]; /** Context window "fuel gauge" (§14.3 #2): stacked zones vs the model limit, * driven by CONTEXT_STATE events. */ @@ -19,7 +21,7 @@ export function ContextGaugeBar({ gauge }: { gauge: ContextGauge }) { ); })} diff --git a/apps/web/ui/components/Conversation.tsx b/apps/web/ui/components/Conversation.tsx index 536a2f5..17fe8a2 100644 --- a/apps/web/ui/components/Conversation.tsx +++ b/apps/web/ui/components/Conversation.tsx @@ -1,27 +1,33 @@ "use client"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react"; +import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react"; import { ChatMessage, DeckState, HitlRequest, SolverCost, SwarmDigest, - coordinatorThread, swarmDigest, + coordinatorThread, hitlDeliveryState, swarmDigest, } from "@/lib/events"; import { getWorkerSettings, checkAuth, SavedFile } from "@/lib/useRun"; +import { + commandIdForDecision, type DecisionControlAction, +} from "@/lib/controlClient"; import { useT, useLang } from "@/lib/i18n"; import { EngineBar } from "@/components/EngineBar"; import { Icon, type IconName } from "@/components/Icon"; +import { SelectionGlider } from "@/components/SelectionGlider"; import { RunInspector } from "@/components/RunInspector"; import { CopyText } from "@/components/CopyText"; +import { NumberField } from "@/components/NumberField"; +import { reportToMarkdown } from "@/lib/reportMarkdown"; import { useCopied } from "@/lib/useCopied"; import { InspectorSkeleton, SkelLine } from "@/components/Skeleton"; -import type { ArtifactView } from "@/components/ArtifactPanel"; +import type { ArtifactView } from "@/lib/events"; /** * The conversation spine (the redesign's centre column): a ChatGPT/Claude-style * thread between the operator and the COORDINATOR (DeepSeek `reason`). It owns * the welcome/dispatch state and the dual-mode composer. The worker firehose, - * fact-graph and blackboard are NOT here — they live in the persistent right- - * column RunInspector + the secondary panels. The deck stays a dumb subscriber. + * fact-graph and blackboard live in the persistent right-column RunInspector + * and the peer runtime workspace. The deck stays a dumb subscriber. * * i18n: static UI is translated; agent-produced text renders verbatim. Only * system lifecycle lines + the synthesized progress/answer turns carry keys. @@ -50,20 +56,26 @@ export interface DispatchOpts { raceEngines?: string[]; } +export interface ControlCommandOpts { + requestId?: string; + commandId?: string; +} + // RUNNING — steer the live swarm: -const QUICK_RUNNING = [ - { key: "hint", labelKey: "quick.hint", tipKey: "quick.hint.tip" }, - { key: "directive", labelKey: "quick.directive", tipKey: "quick.directive.tip" }, - { key: "redirect", labelKey: "quick.redirect", tipKey: "quick.redirect.tip" }, - { key: "focus", labelKey: "quick.focus", tipKey: "quick.focus.tip" }, - { key: "pause", labelKey: "quick.pause", tipKey: "quick.pause.tip" }, - { key: "resume", labelKey: "quick.thaw", tipKey: "quick.thaw.tip" }, +const QUICK_RUNNING: Array<{ key: string; labelKey: string; tipKey: string; icon: IconName }> = [ + { key: "hint", labelKey: "quick.hint", tipKey: "quick.hint.tip", icon: "help" }, + { key: "directive", labelKey: "quick.directive", tipKey: "quick.directive.tip", icon: "pencil" }, + { key: "redirect", labelKey: "quick.redirect", tipKey: "quick.redirect.tip", icon: "network" }, + { key: "focus", labelKey: "quick.focus", tipKey: "quick.focus.tip", icon: "target" }, + { key: "pause", labelKey: "quick.pause", tipKey: "quick.pause.tip", icon: "pause" }, + { key: "freeze", labelKey: "quick.freeze", tipKey: "quick.freeze.tip", icon: "lock" }, + { key: "thaw", labelKey: "quick.thaw", tipKey: "quick.thaw.tip", icon: "play" }, ]; // FINISHED — relaunch / converse / wrap up. -const QUICK_FINISHED = [ - { key: "resolve", labelKey: "quick.resolve", tipKey: "quick.resolve.tip", primary: true }, - { key: "ask", labelKey: "quick.ask", tipKey: "quick.ask.tip" }, - { key: "writeup", labelKey: "quick.writeup", tipKey: "quick.writeup.tip" }, +const QUICK_FINISHED: Array<{ key: string; labelKey: string; tipKey: string; icon: IconName; primary?: boolean }> = [ + { key: "resolve", labelKey: "quick.resolve", tipKey: "quick.resolve.tip", icon: "play", primary: true }, + { key: "ask", labelKey: "quick.ask", tipKey: "quick.ask.tip", icon: "help" }, + { key: "writeup", labelKey: "quick.writeup", tipKey: "quick.writeup.tip", icon: "pencil" }, ]; // max height the dispatch textarea auto-grows to (~6–7 rows) before it scrolls @@ -99,13 +111,6 @@ function fmtSize(n: number): string { return `${(n / 1024 / 1024).toFixed(1)} MB`; } -/** Compact token count: "842" / "12.3k" / "1.4M". */ -function fmtTokens(n: number): string { - if (n < 1000) return `${n}`; - if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; - return `${(n / 1_000_000).toFixed(2)}M`; -} - /** event ts (seconds or ms) → ms. */ function tsMs(ts: number): number { return ts < 1e12 ? ts * 1000 : ts; @@ -124,16 +129,19 @@ function fmtDuration(ms: number): string { /** Live run duration: ticks every second while the run is open, freezes at * finishedAt − startedAt once it ends. "" when the run hasn't started. */ -function useElapsed(startedAt?: number, finishedAt?: number): string { +function useElapsed(startedAt?: number, finishedAt?: number, freeze = false): string { const [now, setNow] = useState(() => Date.now()); - const live = startedAt != null && finishedAt == null; + const freezeRef = useRef(undefined); + if (freeze && freezeRef.current == null) freezeRef.current = now; + if (!freeze) freezeRef.current = undefined; + const live = startedAt != null && finishedAt == null && !freeze; useEffect(() => { if (!live) return; const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, [live]); if (startedAt == null) return ""; - const end = finishedAt != null ? tsMs(finishedAt) : now; + const end = finishedAt != null ? tsMs(finishedAt) : (freezeRef.current ?? now); return fmtDuration(end - tsMs(startedAt)); } @@ -184,8 +192,12 @@ function CoordBubble({ // the natural terminal action (mirrors flag-copy). Skip terse/empty bubbles and // the operator's own + system lifecycle lines. const copyable = m.role === "agent" && (m.kind === "text" || m.kind === "reasoning") && text.trim().length > 40; + const nodeIcon: IconName = m.role === "human" ? "send" + : m.role === "agent" ? "cpu" + : m.kind === "insight" ? "pencil" : "clock"; return (
+
{who} {t(`msg.kind.${m.kind}`)} {copyable && ( @@ -217,6 +229,7 @@ function CoordBubble({ function DigestBubble({ digest, t }: { digest: SwarmDigest; t: (k: string, v?: Record) => string }) { return (
+
{t("coord.digestTitle")}
{t("coord.digest", { @@ -234,17 +247,38 @@ function DigestBubble({ digest, t }: { digest: SwarmDigest; t: (k: string, v?: R } function AnswerBubble({ digest, t }: { digest: SwarmDigest; t: (k: string, v?: Record) => string }) { - const none = digest.flags.length === 0 && digest.phase !== "goal_met"; - const multi = digest.expectedFlags > 1; + const pentest = digest.mode === "pentest"; + const none = pentest + ? digest.reports.length === 0 && digest.phase !== "goal_met" + : digest.flags.length === 0 && digest.phase !== "goal_met"; + const multi = pentest ? digest.expectedReports > 1 : digest.expectedFlags > 1; return (
+
{t("coord.answerTitle")} - {multi && digest.flags.length > 0 && ( - {digest.flags.length}/{digest.expectedFlags} + {multi && (pentest ? digest.reports.length > 0 : digest.flags.length > 0) && ( + + {pentest ? `${digest.reports.length}/${digest.expectedReports}` : `${digest.flags.length}/${digest.expectedFlags}`} + )}
- {digest.flags.length > 0 ? ( + {pentest && digest.reports.length > 0 ? ( +
+ {digest.reports.map((row) => ( +
+ + {row.title} + +
+ ))} +
+ ) : !pentest && digest.flags.length > 0 ? (
{digest.flags.map((f) => (
@@ -283,12 +317,10 @@ const PHASE_ICON: Record = { finished: "check", }; -/** Always-visible run-status hero band at the top of the coordinator column. - * Surfaces the single thing an operator wants at a glance — the run's phase - * (solved / collecting / paused / running / finished) plus the one most-relevant - * detail — instead of forcing them to read the bottom of a scrolled thread or - * the side inspector. Reads only existing `swarmDigest` fields; the live pulse - * is gated on prefers-reduced-motion in CSS. */ +/** Always-visible run-status summary at the top of the coordinator column. + * It keeps the current phase and timing compact while exposing every accepted + * flag through an expandable result ledger. Reads only existing `swarmDigest` + * fields; the live pulse is gated on prefers-reduced-motion in CSS. */ function FlowPopover({ digest, hitlCount, @@ -343,11 +375,25 @@ function FlowPopover({ function StatusHero({ digest, hitlCount, t }: { digest: SwarmDigest; hitlCount: number; t: (k: string, v?: Record) => string }) { const [flowOpen, setFlowOpen] = useState(false); - const elapsed = useElapsed(digest.startedAt, digest.finishedAt); + const [resultsOpen, setResultsOpen] = useState(false); + const elapsed = useElapsed( + digest.startedAt, + digest.finishedAt, + digest.phase === "solved" || digest.phase === "goal_met" || digest.phase === "finished", + ); const live = digest.phase === "running" || digest.phase === "collecting" || digest.phase === "racing"; - // when the hero detail embeds a single raw flag, make it click-to-copy; copyFlag - // holds that raw flag (single-flag solved only — multi/no-flag stay plain text). - const copyFlag = digest.phase === "solved" && digest.expectedFlags <= 1 ? digest.flags[0] : ""; + const singleFlag = digest.flags.length === 1 ? digest.flags[0] : ""; + const hasResultLedger = digest.mode === "pentest" + ? digest.reports.length > 1 || digest.expectedReports > 1 || (digest.phase === "collecting" && digest.reports.length > 0) + : digest.flags.length > 1 || digest.expectedFlags > 1 || (digest.phase === "collecting" && digest.flags.length > 0); + const resultsVisible = resultsOpen && hasResultLedger; + const resultCount = digest.mode === "pentest" + ? (digest.expectedReports > 1 + ? t("hero.results.reportProgress", { n: digest.reports.length, total: digest.expectedReports }) + : t("hero.results.reportCount", { n: digest.reports.length })) + : (digest.expectedFlags > 1 + ? t("hero.results.progress", { n: digest.flags.length, total: digest.expectedFlags }) + : t("hero.results.count", { n: digest.flags.length })); // the one detail line that matters most for THIS phase. let detail: string; if (digest.phase === "solved") { @@ -355,7 +401,14 @@ function StatusHero({ digest, hitlCount, t }: { digest: SwarmDigest; hitlCount: ? t("hero.detail.solvedMulti", { n: digest.flags.length, total: digest.expectedFlags }) : (digest.flags[0] ? t("hero.detail.solved", { flag: digest.flags[0] }) : t("hero.detail.solvedNoFlag")); } else if (digest.phase === "collecting") { - detail = t("hero.detail.collecting", { n: digest.flags.length, total: digest.expectedFlags }); + detail = digest.mode === "pentest" + ? t("hero.detail.reportPipeline", { + submitted: digest.reportSubmitted, + reproducing: digest.reportReproducing, + accepted: digest.reportAccepted, + total: digest.expectedReports, + }) + : t("hero.detail.collecting", { n: digest.flags.length, total: digest.expectedFlags }); } else if (digest.phase === "paused") { detail = hitlCount > 0 ? t("hero.detail.pausedN", { n: hitlCount }) : t("hero.detail.paused"); } else if (digest.phase === "goal_met") { @@ -363,163 +416,157 @@ function StatusHero({ digest, hitlCount, t }: { digest: SwarmDigest; hitlCount: } else if (digest.phase === "finished") { detail = t("hero.detail.finished", { verified: digest.verified, dead: digest.deadEnds }); } else if (digest.phase === "racing") { - detail = t("hero.detail.racing", { online: digest.onlineWorkers, total: digest.totalWorkers }); + detail = digest.verifyingActive > 0 || digest.raceTotal > 0 + ? t("hero.detail.racingVerify", { + raceActive: digest.raceActive || digest.onlineWorkers, + raceTotal: digest.raceTotal || digest.totalWorkers, + verifyingActive: digest.verifyingActive, + verifyingMax: digest.verifyingMax, + }) + : t("hero.detail.racing", { online: digest.onlineWorkers, total: digest.totalWorkers }); } else if (digest.phase === "running") { - detail = digest.latestVerified - ? t("hero.detail.runningFact", { fact: digest.latestVerified }) - : digest.onlineWorkers > 0 - ? t("hero.detail.running", { online: digest.onlineWorkers, total: digest.totalWorkers }) - : t("hero.detail.runningIdle", { total: digest.totalWorkers }); + detail = digest.mode === "pentest" + && (digest.reportSubmitted > 0 || digest.reportReproducing > 0 || digest.verifyingActive > 0) + ? t("hero.detail.reportPipeline", { + submitted: digest.reportSubmitted, + reproducing: digest.reportReproducing, + accepted: digest.reportAccepted, + total: digest.expectedReports, + }) + : digest.latestVerified + ? t("hero.detail.runningFact", { fact: digest.latestVerified }) + : digest.onlineWorkers > 0 + ? t("hero.detail.running", { online: digest.onlineWorkers, total: digest.totalWorkers }) + : t("hero.detail.runningIdle", { total: digest.totalWorkers }); } else { detail = t("hero.detail.draft"); } useEffect(() => { - if (!flowOpen) return; - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setFlowOpen(false); }; + if (!flowOpen && !resultsVisible) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + setFlowOpen(false); + setResultsOpen(false); + }; const onDoc = () => setFlowOpen(false); window.addEventListener("keydown", onKey); - window.addEventListener("click", onDoc); + if (flowOpen) window.addEventListener("click", onDoc); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("click", onDoc); }; - }, [flowOpen]); + }, [flowOpen, resultsVisible]); - const toggleFlow = (e?: { stopPropagation: () => void }) => { - e?.stopPropagation(); + const toggleFlow = (e: { stopPropagation: () => void }) => { + e.stopPropagation(); + setResultsOpen(false); setFlowOpen((v) => !v); }; - + const toggleResults = () => { + setFlowOpen(false); + setResultsOpen((value) => !value); + }; return ( -
+
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleFlow(e); } }} + className={`status-hero phase-${digest.phase} ${live ? "live" : ""} ${flowOpen || resultsVisible ? "open" : ""}`} > -
- {flowOpen && setFlowOpen(false)} t={t} />} -
- ); -} - -function QuietMeta({ digest, t }: { digest: SwarmDigest; t: (k: string) => string }) { - const elapsed = useElapsed(digest.startedAt, digest.finishedAt); - // "Warming up": the run is genuinely live (running, not finished, started) but no - // progress has landed yet — all four progress metrics are still 0. In that window the - // strip of zeros otherwise reads as "broken/nothing happening" rather than "spinning - // up". We only soften the cells (dim + gentle pulse, reduced-motion-gated in CSS); the - // moment any metric becomes non-zero this flips false and the strip looks normal again. - // Gated on running + !finished so a real finished run that ended with zeros stays plain. - const warming = digest.phase === "running" && digest.finishedAt == null && digest.startedAt != null - && digest.verified === 0 && digest.candidates === 0 && digest.openIntents === 0 && digest.deadEnds === 0; - // per-cell: dim the genuinely-zero progress values while warming so they don't look final. - const z = (n: number) => (warming && n === 0 ? " zero" : ""); - return ( -
-
{t("meta.verified")}{digest.verified}
-
{t("meta.candidates")}{digest.candidates}
-
{t("meta.intents")}{digest.openIntents}
-
{t("meta.dead")}{digest.deadEnds}
-
{t("meta.workers")}{digest.onlineWorkers}/{digest.totalWorkers}
-
- {t("meta.cost")}${digest.usd.toFixed(4)} - -
-
- {t("meta.tokens")} - {fmtTokens(digest.tokensIn + digest.tokensOut)} - -
-
{t("meta.elapsed")}{elapsed || "—"}
-
- ); -} - -const ENGINE_LABELS: Record = { - claude: "Claude", codex: "Codex", cursor: "Cursor", deepseek: "DeepSeek", -}; - -/** A friendly agent name from a solverId + engine. solverIds look like - * "cli-claude-2" / "reason" / "coordinator"; prefer the engine label, append a - * short suffix (the trailing index) when several share an engine. */ -function agentLabel(solverId: string, engine?: string): string { - const base = engine ? (ENGINE_LABELS[engine] || engine) : null; - const idx = solverId.match(/-(\d+)$/)?.[1]; - if (base) return idx ? `${base} #${idx}` : base; - // no engine (deepseek reason/coordinator) — title-case the raw id - return solverId.charAt(0).toUpperCase() + solverId.slice(1); -} - -/** Hover card over the cost / token cell: a per-agent breakdown, styled like the - * engine quota popover. `metric` decides what the bar + headline figure show - * ($ spent, or total tokens) while the row always lists both. */ -function CostPopover({ digest, t, metric }: { - digest: SwarmDigest; t: (k: string) => string; metric: "usd" | "tokens"; -}) { - const rows = Object.entries(digest.costBySolver) - .map(([sid, c]) => ({ - sid, engine: c.engine, usd: c.usd, - tokens: c.tokensIn + c.tokensOut, tokensIn: c.tokensIn, tokensOut: c.tokensOut, - })) - .filter((r) => r.usd > 0 || r.tokens > 0) - .sort((a, b) => (metric === "usd" ? b.usd - a.usd : b.tokens - a.tokens)); - const total = metric === "usd" ? digest.usd : digest.tokensIn + digest.tokensOut; - const max = Math.max(1, ...rows.map((r) => (metric === "usd" ? r.usd : r.tokens))); - return ( - - - {t(metric === "usd" ? "meta.costByAgent" : "meta.tokensByAgent")} - - {metric === "usd" ? `$${total.toFixed(4)}` : fmtTokens(total as number)} - - - {rows.length > 0 ? ( - - {rows.map((r) => { - const val = metric === "usd" ? r.usd : r.tokens; - const pct = (val / max) * 100; - return ( - - {agentLabel(r.sid, r.engine)} - - + + + {t(`coord.phase.${digest.phase}`)} + {(digest.phase === "racing" || digest.verifyingActive > 0) && ( + + {(digest.phase === "racing" || digest.raceTotal > 0) && ( + + {t("coord.phasePill.race", { + n: digest.raceFinished || 0, + m: digest.raceTotal || digest.totalWorkers, + })} - - {metric === "usd" ? `$${r.usd.toFixed(4)}` : fmtTokens(r.tokens)} - - - {metric === "usd" - ? `${fmtTokens(r.tokens)} ${t("meta.tokens")}` - : `${r.tokensIn.toLocaleString()} ${t("meta.tokensIn")} / ${r.tokensOut.toLocaleString()} ${t("meta.tokensOut")}`} + )} + {digest.verifyingActive > 0 && ( + + {t("coord.phasePill.verifying", { + k: digest.verifyingActive, + l: digest.verifyingMax, + })} - - ); - })} + )} + + )} - ) : ( - {t("meta.costNoData")} + + {hasResultLedger ? ( + + ) : singleFlag ? ( + {singleFlag} + ) : ( + {detail} + )} + + + {live && digest.onlineWorkers > 0 && ( + + {digest.onlineWorkers}/{digest.totalWorkers} + + )} + {elapsed && {elapsed}} + + +
+ {resultsVisible && ( +
+
+ {digest.mode === "pentest" ? t("hero.results.reportTitle") : t("hero.results.title")} + {resultCount} +
+
+ {digest.mode === "pentest" + ? digest.reports.map((row, index) => ( +
+ {String(index + 1).padStart(2, "0")} + + {row.title} + +
+ )) + : digest.flags.map((flag, index) => ( +
+ {String(index + 1).padStart(2, "0")} + {flag} +
+ ))} +
+
)} - + {flowOpen && setFlowOpen(false)} t={t} />} +
); } @@ -528,19 +575,27 @@ function CostPopover({ digest, t, metric }: { * "needs your decision" heading). When the request carries `options`, each is a * one-click answer button; the free-text input is always available for a custom * answer (Enter submits). The FIRST pending card autofocuses its input so the - * operator can just type + Enter. `sending` disables controls until the request - * leaves deck.hitlRequests (a HITL_RESPONSE clears it). */ + * operator can just type + Enter. Admission locks the answer exactly once; the + * correlated durable control receipt then renders pending/recovery state until + * EFFECT_OBSERVED closes the card. */ function HitlCard({ req, first, onAnswer, onDismiss, }: { req: HitlRequest; first: boolean; - onAnswer: (opt: string) => void; - onDismiss?: () => void; + onAnswer: (requestId: string, opt: string, commandId: string) => Promise; + onDismiss?: (requestId: string, commandId: string) => Promise; }) { const t = useT(); const [free, setFree] = useState(""); const [sending, setSending] = useState(false); + const [locallyRecorded, setLocallyRecorded] = useState(false); + const commandIdsRef = useRef>>({}); + const attemptRef = useRef<{ + action: DecisionControlAction; + value: string; + commandId: string; + } | null>(null); const inputRef = useRef(null); const hasOptions = req.options.length > 0; // F: only an external_blocker actually freezes the swarm + needs an operator @@ -548,20 +603,61 @@ function HitlCard({ // candidate). An unclassified card (no needKind) defaults to blocking (back-compat). const pauses = req.pausesBehavior ?? true; const kindLabel = req.needKind ? t(`hitl.kind.${req.needKind}`) : t("hitl.title"); + const durableDelivery = hitlDeliveryState(req); + // Lock immediately when POST /control succeeds; the SSE projection replaces + // this local bridge as soon as PERSISTED/terminal lifecycle events arrive. + const answerRecorded = locallyRecorded || durableDelivery.locked; + const deliveryPhase = durableDelivery.locked ? durableDelivery.phase + : locallyRecorded ? "pending" : "open"; // autofocus the topmost pending request's input — when the current first card // is answered and clears, the next one becomes `first` and grabs focus. useEffect(() => { if (first && pauses) inputRef.current?.focus(); }, [first, pauses]); - const submit = (value: string) => { + useEffect(() => { + if (durableDelivery.locked) { + setLocallyRecorded(true); + setSending(false); + } + }, [durableDelivery.locked]); + const submit = async (value: string) => { const v = value.trim(); - if (!v || sending) return; + if (!v || sending || answerRecorded) return; + const commandId = commandIdForDecision(commandIdsRef.current, "answer_decision"); + attemptRef.current = { action: "answer_decision", value: v, commandId }; + setSending(true); + const ok = await onAnswer(req.id, v, commandId); + setSending(false); + if (ok) setLocallyRecorded(true); + }; + const dismiss = async () => { + if (!onDismiss || sending || answerRecorded) return; + const commandId = commandIdForDecision(commandIdsRef.current, "dismiss"); + attemptRef.current = { action: "dismiss", value: "", commandId }; + setSending(true); + const ok = await onDismiss(req.id, commandId); + setSending(false); + if (ok) setLocallyRecorded(true); + }; + const retryDelivery = async () => { + const attempt = attemptRef.current; + if (!attempt || sending) return; setSending(true); - onAnswer(v); + const ok = attempt.action === "answer_decision" + ? await onAnswer(req.id, attempt.value, attempt.commandId) + : await onDismiss?.(req.id, attempt.commandId) ?? false; + setSending(false); + if (ok) setLocallyRecorded(true); }; + const deliveryKey = deliveryPhase === "open" + ? undefined : `hitl.delivery.${deliveryPhase}`; + const retryable = answerRecorded && deliveryPhase !== "observed" + && attemptRef.current !== null; + const activeCommandId = durableDelivery.commandId + ?? attemptRef.current?.commandId; return (
@@ -580,7 +676,29 @@ function HitlCard({ )} {/* F: auto-resolving cards are informational — no input, the swarm handles it */} - {pauses &&
+ {pauses && answerRecorded && deliveryKey && ( +
+ + {t(deliveryKey)} + {retryable && ( + + )} +
+ )} + {pauses && !answerRecorded &&
{req.options.map((o) => ( ))} @@ -608,7 +726,7 @@ function HitlCard({ className="hitl-dismiss" disabled={sending} title={t("hitl.dismiss.tip")} - onClick={() => { setSending(true); onDismiss(); }} + onClick={dismiss} > {t("hitl.dismiss")} @@ -626,8 +744,8 @@ function CoordinatorThread({ }: { deck: DeckState; running: boolean; - onAnswer: (opt: string) => void; - onDismiss?: () => void; + onAnswer: (requestId: string, opt: string, commandId: string) => Promise; + onDismiss?: (requestId: string, commandId: string) => Promise; }) { const t = useT(); const messages = coordinatorThread(deck); @@ -667,6 +785,7 @@ function Composer({ started, solved, running, + paused, solvers, flags, onDispatch, @@ -675,18 +794,26 @@ function Composer({ attachments, onAddFiles, onRemoveFile, + prefill, + onPrefillConsumed, }: { started: boolean; solved: boolean; running: boolean; + paused: boolean; solvers: string[]; flags: string[]; - onDispatch: (prompt: string, opts: DispatchOpts) => void; - onCommand: (target: string, action: string, text: string) => void; + // Returns false when the dispatch was intercepted before launch (e.g. the + // open-ended collect confirm) — the composer keeps the prompt text then. + onDispatch: (prompt: string, opts: DispatchOpts) => void | boolean | Promise; + onCommand: (target: string, action: string, text: string, + opts?: ControlCommandOpts) => Promise; onResolve: (text?: string) => void; attachments: SavedFile[]; onAddFiles: (files: FileList | File[]) => void; onRemoveFile: (path: string) => void; + prefill: ComposerPrefill | null; + onPrefillConsumed: () => void; }) { const t = useT(); const [text, setText] = useState(""); @@ -755,6 +882,8 @@ function Composer({ const [maxTotalWorkers, setMaxTotalWorkers] = useState("0"); const [costBudgetUsd, setCostBudgetUsd] = useState("0"); const [advancedOpen, setAdvancedOpen] = useState(false); + /** Per-run overrides are sent only after the operator edits advanced fields. */ + const [advancedTouched, setAdvancedTouched] = useState(false); useEffect(() => { let cancelled = false; try { @@ -814,7 +943,21 @@ function Composer({ try { window.localStorage.setItem("muteki.mode", m); } catch { /* ignore */ } }; - const dispatch = () => { + useEffect(() => { + if (!prefill || started) return; + setText(prefill.text); + setMode(prefill.mode); + setGoal(prefill.goal ?? ""); + setScope(prefill.scope ?? ""); + try { window.localStorage.setItem("muteki.mode", prefill.mode); } catch { /* ignore */ } + window.requestAnimationFrame(() => { + dispatchRef.current?.focus(); + dispatchRef.current?.setSelectionRange(prefill.text.length, prefill.text.length); + }); + onPrefillConsumed(); + }, [onPrefillConsumed, prefill, started]); + + const dispatch = async () => { const v = text.trim(); if (!v) return; const optionalInt = (raw: string) => { @@ -825,24 +968,31 @@ function Composer({ const parsed = parseFloat(raw); return Number.isNaN(parsed) ? undefined : parsed; }; - const runCaps = { - raceTimeout: parseInt(raceTimeout, 10) || undefined, - wallClockBudget: optionalInt(wallClockBudget), - maxTotalWorkers: optionalInt(maxTotalWorkers), - costBudgetUsd: optionalFloat(costBudgetUsd), - }; - onDispatch(v, mode === "pentest" - ? { webSearch, mode, goal: goal.trim(), scope: scope.trim(), containerMode, ...runCaps } + const runCaps = advancedTouched + ? { + raceTimeout: parseInt(raceTimeout, 10) || undefined, + wallClockBudget: optionalInt(wallClockBudget), + maxTotalWorkers: optionalInt(maxTotalWorkers), + costBudgetUsd: optionalFloat(costBudgetUsd), + } + : {}; + const dispatched = await onDispatch(v, mode === "pentest" + ? { webSearch, mode, goal: goal.trim(), scope: scope.trim(), + collectCount: parseInt(collectCount, 10) || 0, containerMode, ...runCaps } : { webSearch, mode: "ctf", collect, containerMode, flagFormat, flagWrapper: flagFormat === "custom" ? flagWrapper.trim() : undefined, collectCount: collect ? (parseInt(collectCount, 10) || 0) : undefined, ...runCaps }); - setText(""); + // Intercepted dispatches (open-ended collect confirm) keep the text so + // "返回填写数量" does not throw the prompt away. + if (dispatched !== false) setText(""); }; const command = (action: string) => { const raw = text.trim(); - if (action === "pause" || action === "resume") { onCommand(cmdTarget, action, ""); return; } + if (["pause", "resume", "freeze", "thaw"].includes(action)) { + onCommand(cmdTarget, action, ""); return; + } let a = action, payload = raw; if (raw.startsWith("/")) { const [v, ...rest] = raw.slice(1).split(" "); a = v; payload = rest.join(" "); } if (a === "resolve") { onResolve(payload || undefined); setText(""); return; } @@ -850,12 +1000,19 @@ function Composer({ setMarkFalseOpen((v) => !v); return; } - const NO_ARG = new Set(["writeup", "mark_false", "ask", "stop", "pause", "resume"]); + const NO_ARG = new Set([ + "writeup", "mark_false", "ask", "stop", "pause", "resume", "freeze", "thaw", + ]); if (!payload && !NO_ARG.has(a)) return; onCommand(cmdTarget, a, payload); setMarkFalseOpen(false); setText(""); }; + const runningActions = paused + ? QUICK_RUNNING.map((action) => action.key === "pause" + ? { key: "resume", labelKey: "quick.resume", tipKey: "quick.resume.tip", icon: "play" as IconName } + : action) + : QUICK_RUNNING; if (!started) { return ( @@ -909,7 +1066,7 @@ function Composer({ value={text} onChange={(e) => setText(e.target.value)} onInput={autoGrow} - onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); dispatch(); } }} + onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void dispatch(); } }} onPaste={(e) => { // Paste-to-attach: if the clipboard carries files (e.g. a screenshot, // a pcap, a binary), attach them instead of dumping bytes/text. Check @@ -929,6 +1086,17 @@ function Composer({
setGoal(e.target.value)} placeholder={t("composer.goalPlaceholder")} /> setScope(e.target.value)} placeholder={t("composer.scopePlaceholder")} /> +
)} {attachments.length > 0 && ( @@ -969,12 +1137,16 @@ function Composer({ )} {mode === "ctf" && collect && ( - setCollectCount(e.target.value)} + onChange={setCollectCount} + scrubLabel="#" placeholder={t("composer.collectCountPlaceholder")} title={t("composer.collectCountTitle")} + ariaLabel={t("composer.collectCountPlaceholder")} /> )}
@@ -1088,19 +1263,20 @@ function Composer({ } return ( -
-
-
- {t("composer.to")} +
+
+
+ setText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); command("hint"); } }} @@ -1108,104 +1284,121 @@ function Composer({ />
-
-
- {running ? ( - <> - {QUICK_RUNNING.map((a) => ( - - ))} - - - - ) : ( - <> - {QUICK_FINISHED.map((a) => ( - - ))} - {solved && ( +
+
+ {running ? ( <> + {runningActions.map((a) => ( + + ))} - + + + ) : ( + <> + {QUICK_FINISHED.map((a) => ( + + ))} + {solved && ( + <> + + + + )} )} - - )} -
- {solved && markFalseOpen && flags.length > 1 && ( -
- {flags.map((f) => ( - - ))} +
+
{running ? t("composer.steerHint") : t("composer.finishedHint")}
- )} -
- {running ? t("composer.steerHint") : t("composer.finishedHint")} + {solved && markFalseOpen && flags.length > 1 && ( +
+ {flags.map((f) => ( + + ))} +
+ )}
); } -/** First-run hero: the brand mark, the one-line pitch, and a row of example - * cards that hint at the kinds of challenges the swarm takes. The cards are - * presentational (the composer below is the single dispatch surface — kept - * untouched), so they read as "here's what this does", not dead buttons. */ -const WELCOME_EXAMPLES: { key: string; icon: IconName }[] = [ - { key: "ex1", icon: "globe" }, - { key: "ex2", icon: "lock" }, - { key: "ex3", icon: "target" }, +type ComposerPrefill = { + mode: "ctf" | "pentest"; + text: string; + goal?: string; + scope?: string; +}; + +/** First-run actions that prefill the real dispatch composer. */ +const WELCOME_EXAMPLES: Array<{ key: string; icon: IconName; mode: "ctf" | "pentest" }> = [ + { key: "ex1", icon: "globe", mode: "ctf" }, + { key: "ex2", icon: "lock", mode: "ctf" }, + { key: "ex3", icon: "target", mode: "pentest" }, ]; -function Welcome({ t }: { t: (k: string) => string }) { +function Welcome({ + t, + onChoose, +}: { + t: (k: string) => string; + onChoose: (prefill: ComposerPrefill) => void; +}) { return (
無敵 Muteki
-
- {t("welcome.sub")}{t("welcome.subCode")}{t("welcome.subTail")} -
+
{t("welcome.sub")}
{t("welcome.examplesLabel")}
{WELCOME_EXAMPLES.map((ex) => ( -
+
+ ))}
@@ -1225,6 +1418,8 @@ export function Conversation({ artifactOpen, artifactView, onOpenArtifact, + onShowConversation, + runtimePanel, onToggleRail, theme, onToggleTheme, @@ -1232,6 +1427,7 @@ export function Conversation({ onKillWorker, onOpenWorker, onOpenWorkspace, + onOpenReport, onHitlAnswered, connected, onOpenBtw, @@ -1239,15 +1435,20 @@ export function Conversation({ deck: DeckState; running: boolean; loading: boolean; - onCommand: (target: string, action: string, text: string) => void; + onCommand: (target: string, action: string, text: string, + opts?: ControlCommandOpts) => Promise; onResolve: (text?: string) => void; - onDispatch: (prompt: string, opts: DispatchOpts) => void; + // Returns false when the dispatch was intercepted before launch (e.g. the + // open-ended collect confirm) — the composer keeps the prompt text then. + onDispatch: (prompt: string, opts: DispatchOpts) => void | boolean | Promise; attachments: SavedFile[]; onAddFiles: (files: FileList | File[]) => void; onRemoveFile: (path: string) => void; artifactOpen: boolean; artifactView: ArtifactView; onOpenArtifact: (view: ArtifactView) => void; + onShowConversation: () => void; + runtimePanel: ReactNode; onToggleRail: () => void; theme: "light" | "dark"; onToggleTheme: () => void; @@ -1256,6 +1457,7 @@ export function Conversation({ // open the "Worker 详情" panel focused on a single worker (roster row click). onOpenWorker: (solverId: string) => void; onOpenWorkspace: () => void; + onOpenReport: (reportId: string) => void; // fired after an operator answers a blocking HITL decision — owner toasts. onHitlAnswered?: () => void; connected: boolean; @@ -1266,6 +1468,8 @@ export function Conversation({ const scrollRef = useRef(null); const stick = useRef(true); const [inspectorWidth, setInspectorWidth] = useState(INSPECTOR_WIDTH_DEFAULT); + const [composerPrefill, setComposerPrefill] = useState(null); + const consumeComposerPrefill = useCallback(() => setComposerPrefill(null), []); const [inspectorResizing, setInspectorResizing] = useState(false); const inspectorResizeCleanup = useRef<(() => void) | null>(null); useEffect(() => { @@ -1360,12 +1564,15 @@ export function Conversation({ : !deck.started ? t("convo.idle") : t("convo.disconnected"); - const runStateLabel = digest.phase === "paused" + const runStateLabel = deck.preparing + ? t("convo.preparing") + : digest.phase === "paused" ? t("convo.paused") : running ? t("convo.live") : t("convo.finished"); - const runStateClass = digest.phase === "paused" ? "paused" : running ? "live" : "done"; + const runStateClass = deck.preparing ? "live" : digest.phase === "paused" ? "paused" : running ? "live" : "done"; + const latestControl = deck.controlCommands[deck.controlCommands.length - 1]; // Screen-reader live region: mirror ONLY the latest system lifecycle line // (run started, solved, finished, reopened, goal met, …) into a visually-hidden @@ -1379,7 +1586,7 @@ export function Conversation({ return (
{liveStatus}
@@ -1395,6 +1602,19 @@ export function Conversation({ )} )} + {deck.started && ( +
+ + + +
+ )} {onOpenBtw && ( +
+ + {t("engines.popoverTitle")} + {up}/{total} + +
    + {rows.map((row) => ( +
  • +
  • + ))} +
+
); } diff --git a/apps/web/ui/components/EngineLogo.tsx b/apps/web/ui/components/EngineLogo.tsx new file mode 100644 index 0000000..d0c6948 --- /dev/null +++ b/apps/web/ui/components/EngineLogo.tsx @@ -0,0 +1,121 @@ +type EngineLogoProps = { + engine: string; + size?: number; + className?: string; + title?: string; +}; + +function logoProps(engine: string, size: number, className?: string, title?: string) { + return { + width: size, + height: size, + className: `engine-logo${className ? ` ${className}` : ""}`, + "data-engine": engine, + role: title ? "img" : undefined, + "aria-label": title, + "aria-hidden": title ? undefined : true, + focusable: false, + } as const; +} + +export function EngineLogo({ engine, size = 18, className, title }: EngineLogoProps) { + const normalized = engine.trim().toLowerCase().replaceAll("_", "-"); + + if (normalized === "pi") { + return ( + + {title ? {title} : null} + + + + ); + } + + if (normalized === "claude" || normalized === "anthropic") { + return ( + + {title ? {title} : null} + + + ); + } + + if (normalized === "codex" || normalized === "openai") { + return ( + + {title ? {title} : null} + + + ); + } + + if (normalized === "cursor") { + return ( + + {title ? {title} : null} + + + ); + } + + if (normalized === "kimi" || normalized === "kimi-code") { + return ( + + {title ? {title} : null} + + + + ); + } + + if (normalized === "grok" || normalized === "grok-build") { + return ( + + {title ? {title} : null} + + + + ); + } + + if (normalized === "opencode" || normalized === "opencode-cli") { + return ( + + {title ? {title} : null} + + + ); + } + + if (normalized === "dsh" || normalized === "deepseek-harness") { + return ( + + {title ? {title} : null} + + + ); + } + + return ( + + {title ? {title} : null} + + + + + + + + + + ); +} diff --git a/apps/web/ui/components/EvidenceChain.tsx b/apps/web/ui/components/EvidenceChain.tsx index 6080878..b4d129e 100644 --- a/apps/web/ui/components/EvidenceChain.tsx +++ b/apps/web/ui/components/EvidenceChain.tsx @@ -80,7 +80,7 @@ function FactItem({ f, t, zh, expanded, onToggle }: { const hasRaw = !!gist && gist !== f.fact; const when = relTime(f.ts, zh); return ( -
+
+ onClick={() => setFocusId((cur) => (cur === s.id ? null : s.id))} aria-pressed={focusId === s.id} title={solverLabel(s)}>{solverLabel(s)} ))} ) :
diff --git a/apps/web/ui/components/LoginGate.tsx b/apps/web/ui/components/LoginGate.tsx index cf5d1fe..1f9178a 100644 --- a/apps/web/ui/components/LoginGate.tsx +++ b/apps/web/ui/components/LoginGate.tsx @@ -78,42 +78,17 @@ export function LoginGate({ children }: { children: React.ReactNode }) { if (phase === "open") return <>{children}; return ( -
+
{phase === "checking" ? ( -
{t("login.checking")}
+
{t("login.checking")}
) : ( -
-
-
- Project Muteki -
-
- {t("login.subtitle")} -
+ +
+
Project Muteki
+
{t("login.subtitle")}
setPassword(e.target.value)} placeholder={t("login.placeholder")} disabled={busy} - style={{ - padding: "10px 12px", - fontSize: 14, - color: "var(--text)", - background: "var(--panel2)", - border: `1px solid ${error ? "var(--red)" : "var(--line2)"}`, - borderRadius: 9, - outline: "none", - }} /> - {error ? ( -
{error}
- ) : null} - diff --git a/apps/web/ui/components/NumberField.tsx b/apps/web/ui/components/NumberField.tsx new file mode 100644 index 0000000..54d079e --- /dev/null +++ b/apps/web/ui/components/NumberField.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useCallback, useEffect, useRef, type KeyboardEvent, type PointerEvent } from "react"; +import { Icon } from "@/components/Icon"; +import { useT } from "@/lib/i18n"; + +/** + * Number field in the Beautiful UI Fine-tune Card style, plus a quiet + * custom stepper so the control is obviously adjustable. + */ + +type NumberFieldProps = { + value: string | number; + onChange: (next: string) => void; + min?: number; + max?: number; + step?: number; + placeholder?: string; + title?: string; + suffix?: string; + scrubLabel?: string; + className?: string; + disabled?: boolean; + allowEmpty?: boolean; + ariaLabel?: string; +}; + +function decimalsOf(step: number): number { + const text = String(step); + const dot = text.indexOf("."); + return dot === -1 ? 0 : text.length - dot - 1; +} + +function clamp(value: number, min?: number, max?: number): number { + let next = value; + if (min != null && Number.isFinite(min)) next = Math.max(min, next); + if (max != null && Number.isFinite(max)) next = Math.min(max, next); + return next; +} + +function formatNumber(value: number, step: number): string { + const places = decimalsOf(step); + return places > 0 ? value.toFixed(places) : String(Math.round(value)); +} + +function parseLoose(raw: string, fallback: number): number { + const n = Number(raw); + return raw.trim() === "" || !Number.isFinite(n) ? fallback : n; +} + +function stepFrom(raw: string, delta: number, min: number | undefined, max: number | undefined, step: number, allowEmpty: boolean): string { + if (allowEmpty && raw.trim() === "" && delta === 0) return ""; + const origin = min ?? 0; + const current = parseLoose(raw, origin); + return formatNumber(clamp(current + delta, min, max), step); +} + +export function NumberField({ + value, + onChange, + min, + max, + step = 1, + placeholder, + title, + suffix, + scrubLabel, + className, + disabled, + allowEmpty = false, + ariaLabel, +}: NumberFieldProps) { + const t = useT(); + const text = value === undefined || value === null ? "" : String(value); + const textRef = useRef(text); + textRef.current = text; + const drag = useRef<{ x: number; start: number } | null>(null); + const holdRef = useRef(null); + + const applyDelta = useCallback((delta: number) => { + if (disabled) return; + onChange(stepFrom(textRef.current, delta, min, max, step, allowEmpty)); + }, [allowEmpty, disabled, max, min, onChange, step]); + + const stopHold = useCallback(() => { + if (holdRef.current != null) { + window.clearTimeout(holdRef.current); + holdRef.current = null; + } + }, []); + + useEffect(() => stopHold, [stopHold]); + + const startHold = (delta: number) => { + if (disabled) return; + applyDelta(delta); + stopHold(); + const tick = (delay: number) => { + holdRef.current = window.setTimeout(() => { + applyDelta(delta); + tick(56); + }, delay); + }; + tick(360); + }; + + const onScrubPointerDown = (event: PointerEvent) => { + if (disabled) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + const origin = min ?? 0; + drag.current = { x: event.clientX, start: parseLoose(text, origin) }; + }; + + const onScrubPointerMove = (event: PointerEvent) => { + if (!drag.current || disabled) return; + const pixelsPerStep = 8; + const deltaSteps = Math.round((event.clientX - drag.current.x) / pixelsPerStep); + onChange(formatNumber(clamp(drag.current.start + deltaSteps * step, min, max), step)); + }; + + const onScrubPointerUp = (event: PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + drag.current = null; + }; + + const onScrubKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowRight" || event.key === "ArrowUp") { + event.preventDefault(); + applyDelta(step); + } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") { + event.preventDefault(); + applyDelta(-step); + } + }; + + const numericNow = parseLoose(text, min ?? 0); + const atMax = max != null && Number.isFinite(max) && numericNow >= max && text.trim() !== ""; + const atMin = min != null && Number.isFinite(min) && numericNow <= min && text.trim() !== ""; + + return ( +
+ {scrubLabel ? ( + + {scrubLabel} + + ) : null} + 0 ? "decimal" : "numeric"} + aria-label={ariaLabel} + title={title} + placeholder={placeholder} + disabled={disabled} + value={text} + onChange={(event) => onChange(event.target.value)} + onBlur={() => { + if (allowEmpty && text.trim() === "") { + onChange(""); + return; + } + onChange(stepFrom(text, 0, min, max, step, allowEmpty)); + }} + onKeyDown={(event) => { + if (event.key === "ArrowUp") { + event.preventDefault(); + applyDelta(step); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + applyDelta(-step); + } + }} + /> + {suffix ? {suffix} : null} + + + + +
+ ); +} diff --git a/apps/web/ui/components/PlatformUpdate.tsx b/apps/web/ui/components/PlatformUpdate.tsx new file mode 100644 index 0000000..48e3033 --- /dev/null +++ b/apps/web/ui/components/PlatformUpdate.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Icon } from "@/components/Icon"; +import { + type PlatformUpdateStatus, + checkPlatformUpdate, + getPlatformUpdateStatus, + installPlatformUpdate, + rollbackPlatformUpdate, +} from "@/lib/useRun"; + +const ACTIVE_STATES = new Set(["checking", "downloading", "preparing", "switching"]); + +function formatTime(value?: string | null): string { + if (!value) return "尚未检查"; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? value : parsed.toLocaleString("zh-CN", { hour12: false }); +} + +export function PlatformUpdate() { + const [update, setUpdate] = useState(null); + const [action, setAction] = useState<"check" | "install" | "rollback" | null>(null); + const [requestError, setRequestError] = useState(""); + + const refresh = useCallback(async () => { + const next = await getPlatformUpdateStatus(); + if (next) setUpdate(next); + }, []); + + useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { + if (!update || (!update.running && !ACTIVE_STATES.has(update.status))) return; + const timer = window.setInterval(() => void refresh(), 900); + return () => window.clearInterval(timer); + }, [refresh, update]); + + const busy = Boolean(action || update?.running || (update && ACTIVE_STATES.has(update.status))); + const statusLabel = useMemo(() => { + if (!update) return "正在读取"; + if (update.status === "available") return "发现新版本"; + if (update.status === "current") return "当前已是最新版本"; + if (update.status === "installed") return "安装完成"; + if (update.status === "rolled_back") return "回滚完成"; + if (update.status === "error") return "操作失败"; + if (ACTIVE_STATES.has(update.status)) return update.message || "升级处理中"; + return "等待检查"; + }, [update]); + + const run = useCallback(async (kind: "check" | "install" | "rollback") => { + setAction(kind); + setRequestError(""); + const next = kind === "check" + ? await checkPlatformUpdate() + : kind === "install" + ? await installPlatformUpdate() + : await rollbackPlatformUpdate(); + if (next) setUpdate(next); + else setRequestError(kind === "check" ? "检查更新失败,请确认网络连接和发布地址。" : "操作未能启动,请查看服务端日志。") + setAction(null); + }, []); + + return ( +
+
+

系统更新

通过发布清单下载完整应用包,校验后切换版本。任务数据和凭据配置保留在独立目录。

+ +
+ +
+
+ 当前运行版本v{update?.active_version || update?.current_version || "—"} + {update?.install_kind === "compose" ? "容器部署" : update?.install_kind === "managed" ? "托管安装" : "源码运行"} · {update?.channel || "stable"} 通道 +
+
+ 最新版本{update?.latest_version ? `v${update.latest_version}` : "待检查"} + 上次检查:{formatTime(update?.checked_at)} +
+
+