From f8b35d2a385021fa0e280ca8593c2be2637614a2 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:12:12 -0400 Subject: [PATCH 001/162] Remove native monitoring integrations while preserving upgrade compatibility --- CHANGELOG.md | 9 + README.md | 2 +- config.yml | 8 - docs/reference/api.md | 64 +- docs/reference/tools.md | 6 +- docs/scheduling.md | 14 +- src/__main__.py | 39 +- src/agents/trajectory.py | 3 - src/config/apply_registry.py | 8 - src/config/schema.py | 28 - src/discord/response_guards.py | 2 +- src/discord/wiring.py | 2 +- src/health/grafana_alerts.py | 450 - src/health/metrics.py | 458 - src/health/server.py | 219 +- src/llm/cost_tracker.py | 21 +- src/sessions/manager.py | 2 +- src/setup_wizard.py | 1 - src/tools/bulkhead.py | 11 +- src/tools/defs/media_scheduling.py | 8 +- src/tools/executor.py | 2 +- src/tools/skill_context.py | 26 +- src/tools/ssh_pool.py | 9 - src/trajectories/saver.py | 3 - src/web/api/__init__.py | 2 - src/web/api/integrations.py | 87 - .../characterization/test_api_route_parity.py | 6 - tests/test_agent_trajectory.py | 12 +- tests/test_apply_registry.py | 6 +- tests/test_bulkhead.py | 85 +- tests/test_config_schema_validators.py | 7 +- tests/test_connection_pools.py | 89 - tests/test_cost_tracker.py | 102 +- tests/test_generated_api_reference.py | 4 +- tests/test_grafana_alerts.py | 1594 ---- tests/test_health_shutdown.py | 76 - tests/test_local_workspace.py | 44 - tests/test_metrics.py | 350 - tests/test_native_monitoring_removal.py | 49 + tests/test_self_audit_fixes.py | 6 +- tests/test_skill_context.py | 8 +- tests/test_smoke_run_bot.py | 6 - tests/test_token_budget.py | 38 - tests/test_trajectories.py | 45 - tests/test_web_api_config_admin.py | 3 +- tests/test_web_api_integrations.py | 69 +- ui/dist/assets/index-BwvBDkb8.js | 7595 ----------------- ui/dist/index.html | 2 +- ui/js/config-meta-fixture.js | 1 - ui/js/pages/config.js | 2 +- ui/js/pages/tools.js | 2 +- 51 files changed, 133 insertions(+), 11552 deletions(-) delete mode 100644 src/health/grafana_alerts.py delete mode 100644 src/health/metrics.py delete mode 100644 tests/test_grafana_alerts.py delete mode 100644 tests/test_metrics.py create mode 100644 tests/test_native_monitoring_removal.py delete mode 100644 ui/dist/assets/index-BwvBDkb8.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 670869510..a498902c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Each GitHub release body is the matching section of this file. ## [Unreleased] +### Removed +- Removed native Grafana alert receiving, remediation, management routes and + scheduler trigger publication, the unauthenticated Prometheus `/metrics` + endpoint and its exporters, and `SkillContext.query_prometheus`. Existing + configuration keys are tolerated and ignored; component checks remain on + `/health`. Legacy removed-source schedules remain readable but inert. + External operator-configured MCP integrations are unchanged. There was no + native Loki integration to remove. + ## [4.10.0] - 2026-09-29 ### Added diff --git a/README.md b/README.md index 6e8653d2d..26a17ec30 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ The package also grants the `odin` service account passwordless sudo; restrict ` - Create, edit, enable, disable, import, export, and invoke Python skills at runtime. - Configure skills with JSON schemas, dependencies, and operator-managed settings. -- Integrate external systems through webhooks, email, MCP servers, Grafana alerts, and custom skill code. +- Integrate external systems through webhooks, email, MCP servers, and custom skill code. ### Management interface diff --git a/config.yml b/config.yml index 5fae3395b..bdbbffc40 100644 --- a/config.yml +++ b/config.yml @@ -163,7 +163,6 @@ webhook: secret: '' channel_id: '' gitea_channel_id: '' - grafana_channel_id: '' github_channel_id: '' gitlab_channel_id: '' @@ -322,13 +321,6 @@ web: # --- Optional integrations (uncomment and configure to enable) --- -# # Grafana alert auto-remediation -# grafana_alerts: -# auto_remediate: false -# rules: [] -# cooldown_seconds: 300 -# max_concurrent_remediations: 5 - # # Outbound webhooks — notify external services on events # outbound_webhooks: # enabled: false diff --git a/docs/reference/api.md b/docs/reference/api.md index 7b574cd8b..706bc436b 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -4,7 +4,7 @@ Source commit: `e1318eca83a40b9418d873ad85c31686ea6b57d0`. Generated by `scripts/docs/generate_api_reference.py`; do not edit by hand. -The **237 REST registrations** below follow [`create_api_routes`](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/__init__.py) in exact order, using the same route-table mechanism as the [parity test](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/tests/characterization/test_api_route_parity.py). Order matters for overlapping literal and parameter paths. Implicit aiohttp HEAD routes are not counted separately; GET registrations also accept HEAD. Purpose is the first handler-docstring line; **—** means the handler has no docstring, not a guessed description. +The **231 REST registrations** below follow [`create_api_routes`](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/__init__.py) in exact order, using the same route-table mechanism as the [parity test](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/tests/characterization/test_api_route_parity.py). Order matters for overlapping literal and parameter paths. Implicit aiohttp HEAD routes are not counted separately; GET registrations also accept HEAD. Purpose is the first handler-docstring line; **—** means the handler has no docstring, not a guessed description. ## Authentication and authorization @@ -98,23 +98,17 @@ This describes the normal authenticated deployment. With no configured tokens (i | POST | /api/skills/{name}/disable | [src.web.api.skills_api](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/skills_api.py#L156) | Yes | — | | GET | /api/skills/{name}/config | [src.web.api.skills_api](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/skills_api.py#L166) | Yes | — | | PUT | /api/skills/{name}/config | [src.web.api.skills_api](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/skills_api.py#L177) | Yes | — | -| GET | /api/mcp/servers | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L211) | Yes | — | -| GET | /api/mcp/servers/{name}/tools | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L215) | Yes | — | -| POST | /api/mcp/servers | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L223) | Yes | — | -| DELETE | /api/mcp/servers/{name} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L269) | Yes | — | -| PUT | /api/mcp/servers/{name} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L300) | Yes | — | -| POST | /api/mcp/servers/{name}/reconnect | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L342) | Yes | — | -| POST | /api/mcp/servers/{name}/refresh-tools | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L350) | Yes | — | -| GET | /api/mcp/status | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L358) | Yes | — | -| POST | /api/mcp/enabled | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L364) | Yes | — | -| POST | /api/mcp/limits | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L401) | Yes | Persist only submitted limits; publication reads them live, without reconnecting. | -| POST | /api/mcp/servers/{name}/enabled | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L454) | Yes | Single-purpose per-server switch (panel card toggle). | -| GET | /api/grafana-alerts/status | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L514) | Yes | — | -| GET | /api/grafana-alerts/history | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L522) | Yes | — | -| GET | /api/grafana-alerts/rules | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L532) | Yes | — | -| POST | /api/grafana-alerts/rules | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L540) | Yes | — | -| DELETE | /api/grafana-alerts/rules/{rule_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L574) | Yes | — | -| GET | /api/grafana-alerts/remediations | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L585) | Yes | — | +| GET | /api/mcp/servers | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L210) | Yes | — | +| GET | /api/mcp/servers/{name}/tools | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L214) | Yes | — | +| POST | /api/mcp/servers | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L222) | Yes | — | +| DELETE | /api/mcp/servers/{name} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L268) | Yes | — | +| PUT | /api/mcp/servers/{name} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L299) | Yes | — | +| POST | /api/mcp/servers/{name}/reconnect | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L341) | Yes | — | +| POST | /api/mcp/servers/{name}/refresh-tools | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L349) | Yes | — | +| GET | /api/mcp/status | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L357) | Yes | — | +| POST | /api/mcp/enabled | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L363) | Yes | — | +| POST | /api/mcp/limits | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L400) | Yes | Persist only submitted limits; publication reads them live, without reconnecting. | +| POST | /api/mcp/servers/{name}/enabled | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L453) | Yes | Single-purpose per-server switch (panel card toggle). | | GET | /api/knowledge | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L95) | Yes | — | | POST | /api/knowledge | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L102) | Yes | — | | DELETE | /api/knowledge/{source} | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L127) | Yes | — | @@ -245,12 +239,12 @@ This describes the normal authenticated deployment. With no configured tokens (i | GET | /api/agent-trajectories/agent/{agent_id} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L496) | Yes | — | | GET | /api/agent-trajectories/search/query | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L507) | Yes | — | | GET | /api/agent-trajectories/{filename} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L526) | Yes | — | -| GET | /api/outbound-webhooks | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L738) | Yes | — | -| POST | /api/outbound-webhooks | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L745) | Yes | — | -| PUT | /api/outbound-webhooks/{webhook_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L786) | Yes | — | -| DELETE | /api/outbound-webhooks/{webhook_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L825) | Yes | — | -| POST | /api/outbound-webhooks/{webhook_id}/test | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L839) | Yes | — | -| GET | /api/outbound-webhooks/stats | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L850) | Yes | — | +| GET | /api/outbound-webhooks | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L651) | Yes | — | +| POST | /api/outbound-webhooks | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L658) | Yes | — | +| PUT | /api/outbound-webhooks/{webhook_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L699) | Yes | — | +| DELETE | /api/outbound-webhooks/{webhook_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L738) | Yes | — | +| POST | /api/outbound-webhooks/{webhook_id}/test | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L752) | Yes | — | +| GET | /api/outbound-webhooks/stats | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L763) | Yes | — | | GET | /api/turn-state/turns | [src.web.api.turn_state](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/turn_state.py#L55) | Yes + local | — | | GET | /api/turn-state/capacity-breakers | [src.web.api.turn_state](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/turn_state.py#L86) | Yes + local | — | @@ -260,18 +254,16 @@ Registered by [HealthServer](https://github.com/Calmingstorm/Odin/blob/e1318eca8 | Method | Path | Owning module / handler source | Registration / access | Purpose | | --- | --- | --- | --- | --- | -| GET | /health | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1257) | HealthServer construction; no API authentication | Combined health endpoint. | -| GET | /health/live | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1315) | HealthServer construction; no API authentication | Liveness probe — always 200 if the process is running. | -| GET | /health/ready | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1324) | HealthServer construction; no API authentication | Readiness probe — 200 only when the bot is fully initialised. | -| GET | /metrics | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1350) | HealthServer construction; no API authentication | Prometheus metrics endpoint. | -| POST | /webhook/gitea | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1444) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| POST | /webhook/grafana | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1492) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| POST | /webhook/generic | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1567) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| POST | /webhook/github | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1590) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| POST | /webhook/gitlab | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1663) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| GET | / | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1051) | web.enabled + UI directory exists; no API authentication | Redirect / to /ui/. | -| GET | /ui/{path:.*} | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1055) | web.enabled + UI directory exists; no API authentication | Serve static UI files, defaulting to index.html for SPA routing. | -| GET | /ui | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1051) | web.enabled + UI directory exists; no API authentication | Redirect / to /ui/. | +| GET | /health | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1146) | HealthServer construction; no API authentication | Combined health endpoint. | +| GET | /health/live | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1204) | HealthServer construction; no API authentication | Liveness probe — always 200 if the process is running. | +| GET | /health/ready | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1213) | HealthServer construction; no API authentication | Readiness probe — 200 only when the bot is fully initialised. | +| POST | /webhook/gitea | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1314) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| POST | /webhook/generic | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1362) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| POST | /webhook/github | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1385) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| POST | /webhook/gitlab | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1458) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| GET | / | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L976) | web.enabled + UI directory exists; no API authentication | Redirect / to /ui/. | +| GET | /ui/{path:.*} | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L980) | web.enabled + UI directory exists; no API authentication | Serve static UI files, defaulting to index.html for SPA routing. | +| GET | /ui | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L976) | web.enabled + UI directory exists; no API authentication | Redirect / to /ui/. | | GET | /api/ws | [src.web.websocket](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/websocket.py#L462) | web.enabled + set_bot; authenticated, not admin-only; scoped subscriptions | Handle a WebSocket connection at /api/ws. | ### Conditional registration diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 7cc34336d..0a4a41599 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -151,10 +151,9 @@ Source: [`src/tools/defs/media_scheduling.py`](https://github.com/Calmingstorm/O | cron_timezone | string | No | IANA timezone for the cron expression (e.g. 'America/New_York'). The task fires on that timezone's wall clock across DST. Defaults to UTC. | | run_at | string | No | Offset-aware ISO datetime for one-time tasks (e.g. '2026-03-20T09:00:00Z'). Use parse_time to convert natural language. Omit for recurring. | | trigger | object | No | Webhook trigger (AND logic). E.g. {"source": "github", "event": "push", "repo": "myproject"}.
Constraints: {"additionalProperties":false} | -| trigger.source | string | No | Webhook source to match
Constraints: {"enum":["gitea","grafana","generic","github","gitlab"]} | +| trigger.source | string | No | Webhook source to match
Constraints: {"enum":["gitea","generic","github","gitlab"]} | | trigger.event | string | No | Event type (e.g. 'push', 'pull_request', 'alert') | | trigger.repo | string | No | Repository name substring (case-insensitive) | -| trigger.alert_name | string | No | Grafana alert name substring (case-insensitive) | | action | string | Yes | 'reminder' = post message, 'check' = run_command check, 'digest' = infrastructure digest, 'workflow' = multi-step tool chain
Constraints: {"enum":["reminder","check","digest","workflow"]} | | message | string | No | For reminders: the message to post | | tool_name | string | No | Tool to run for 'check' action (e.g. 'run_command') | @@ -195,10 +194,9 @@ No input properties. | cron_timezone | string | No | IANA timezone for the cron expression (e.g. 'America/New_York'). Defaults to UTC. | | run_at | string | No | New offset-aware ISO datetime for one-time (replaces previous timing) | | trigger | object | No | New webhook trigger (replaces previous timing)
Constraints: {"additionalProperties":false} | -| trigger.source | string | No |
Constraints: {"enum":["gitea","grafana","generic","github","gitlab"]} | +| trigger.source | string | No |
Constraints: {"enum":["gitea","generic","github","gitlab"]} | | trigger.event | string | No | — | | trigger.repo | string | No | — | -| trigger.alert_name | string | No | — | | message | string | No | New message (for reminder actions) | | tool_name | string | No | New tool name (for check actions) | | tool_input | object | No | New tool input parameters | diff --git a/docs/scheduling.md b/docs/scheduling.md index 3a948d4b1..2547fba0d 100644 --- a/docs/scheduling.md +++ b/docs/scheduling.md @@ -69,20 +69,16 @@ but before an effect starts. Quarantine chooses safety over automatic replay. ## Inbound webhooks -Endpoints are `/webhook/gitea`, `/webhook/github`, `/webhook/gitlab`, -`/webhook/grafana` and `/webhook/generic`, registered only with -`webhook.enabled`. Gitea and GitHub use HMAC authentication; GitLab, Grafana +Endpoints are `/webhook/gitea`, `/webhook/github`, `/webhook/gitlab` +and `/webhook/generic`, registered only with +`webhook.enabled`. Gitea and GitHub use HMAC authentication; GitLab and generic use a shared token. Authentication fails closed with 403 when no secret is configured. Invalid JSON receives 400. The body limit is 10 MiB, and there is no per-endpoint rate limit. -Processing order: parse the request, run Grafana remediation where applicable, -run matching schedules **to completion**, then post the channel notification +Processing order: parse the request, run matching schedules **to completion**, then post the channel notification and respond. A slow action can outlast the sender's timeout; the sender may -retry while Odin is still working. A named Grafana trigger compares its -case-insensitive alert-name substring against **every** alert in a notification, -including resolved alerts. Each schedule fires at most once per delivery, -even if several alerts match. Unnamed triggers also fire once per delivery. +retry while Odin is still working. Each schedule fires at most once per delivery. The HTTP response describes **the notification only**, not whether triggered actions succeeded: 200 `{"status":"delivered"}` means notification posted; diff --git a/src/__main__.py b/src/__main__.py index ba937e7d6..bda51e9be 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -24,42 +24,8 @@ def _wire_observability(health, bot, log) -> None: - """Register Prometheus metric sources and component health checks. - - HealthServer wires only an "active sessions" gauge by default, so without - this /metrics exposes liveness only and /health/ready can never reflect a - degraded subsystem — even though MetricsCollector and the component registry - already support all of the below. Every source/check is guarded so a missing - optional subsystem is skipped rather than fatal, and every check is cheap and - synchronous (called on each health/metrics request). - """ - metrics = health.metrics - - tool_executor = getattr(bot, "tool_executor", None) - if tool_executor is not None and hasattr(tool_executor, "get_metrics"): - metrics.register_source("tools", tool_executor.get_metrics) - if tool_executor is not None and hasattr(tool_executor, "get_workspace_metrics"): - # Paired with the deliberate absence of auto-pruning: growth in the - # local command workspace must be alertable rather than silently - # unbounded (PR #239 round-2 review). - metrics.register_source("workspace", tool_executor.get_workspace_metrics) - - cost_tracker = getattr(bot, "cost_tracker", None) - if cost_tracker is not None and hasattr(cost_tracker, "get_prometheus_metrics"): - metrics.register_source("cost_tracker", cost_tracker.get_prometheus_metrics) - - trajectory_saver = getattr(bot, "trajectory_saver", None) - if trajectory_saver is not None and hasattr(trajectory_saver, "get_prometheus_metrics"): - metrics.register_source("trajectories", trajectory_saver.get_prometheus_metrics) - + """Register cheap component health checks for the health endpoints.""" scheduler = getattr(bot, "scheduler", None) - if scheduler is not None: - metrics.register_source("scheduler", lambda: len(getattr(scheduler, "_schedules", []))) - - loop_manager = getattr(bot, "loop_manager", None) - if loop_manager is not None: - metrics.register_source("loops", lambda: len(getattr(loop_manager, "_loops", {}))) - def _discord_health() -> tuple[bool, str]: try: latency = bot.latency @@ -82,7 +48,7 @@ def _scheduler_health() -> tuple[bool, str]: health.register_component("scheduler", _scheduler_health) - log.info("Observability wired: metric sources and component health checks registered") + log.info("Observability wired: component health checks registered") def _enable_process_containment(log) -> bool: @@ -632,7 +598,6 @@ def _warn_fallback(path, configured, reason) -> None: port=config.web.port, webhook_config=config.webhook, web_config=config.web, - grafana_alert_config=getattr(config, "grafana_alerts", None), ) bot = OdinBot(config) from src.web.bootstrap_policy import CredentialInventory diff --git a/src/agents/trajectory.py b/src/agents/trajectory.py index d0b4c2366..db6dc3427 100644 --- a/src/agents/trajectory.py +++ b/src/agents/trajectory.py @@ -397,6 +397,3 @@ async def search( except Exception as e: log.error("Error reading %s for agent trajectory search: %s", filename, e) return results - - def get_prometheus_metrics(self) -> dict: - return {"agent_trajectories_saved_total": self._count} diff --git a/src/config/apply_registry.py b/src/config/apply_registry.py index 54ab8f338..45f1e0a1c 100644 --- a/src/config/apply_registry.py +++ b/src/config/apply_registry.py @@ -276,13 +276,6 @@ class SectionSpec: "live_for_new_work", "Spawned-agent budgets, inheritance, and tree limits.", ), - "grafana_alerts": SectionSpec( - "restart", - "Grafana alert routing and remediation policy.", - owner="grafana_alerts", - restart_reason="The GrafanaAlertHandler is constructed from these values " - "at startup; saving does not rebuild it.", - ), "outbound_webhooks": SectionSpec( "restart", "Outbound event targets, delivery, and safety policy.", @@ -347,7 +340,6 @@ class SectionSpec: GROUP_DESCRIPTIONS: dict[str, str] = { "email.imap": "How Odin reads mail: server, credentials, and polling.", "email.smtp": "How Odin sends mail: server, credentials, and identity.", - "grafana_alerts.rules": "Per-alert routing and remediation rules.", "image.openai": "Native OpenAI image generation behaviour.", "mcp.servers": "Configured Model Context Protocol servers.", "observability.context_trace": "What each per-turn context trace records, " diff --git a/src/config/schema.py b/src/config/schema.py index fb22a0965..cd6b9f87d 100644 --- a/src/config/schema.py +++ b/src/config/schema.py @@ -1281,7 +1281,6 @@ class WebhookConfig(BaseModel): secret: str = "" channel_id: str = "" gitea_channel_id: str = "" - grafana_channel_id: str = "" github_channel_id: str = "" gitlab_channel_id: str = "" @@ -1507,32 +1506,6 @@ class ImageConfig(BaseModel): openai: ImageOpenAIConfig = ImageOpenAIConfig() -class GrafanaRemediationRuleConfig(BaseModel): - id: str = "" - name_pattern: str = "*" # fnmatch pattern for alertname - label_matchers: dict[str, str] = Field(default_factory=dict) - severity_filter: list[str] = Field(default_factory=list) # empty = match all - remediation_goal: str = "" - mode: str = "notify" # "notify", "act", "silent" - interval_seconds: int = 30 - max_iterations: int = 10 - cooldown_seconds: int = 300 - - @field_validator("mode") - @classmethod - def _validate_mode(cls, v: str) -> str: - if v not in ("notify", "act", "silent"): - raise ValueError(f"Invalid mode '{v}'. Must be 'notify', 'act', or 'silent'.") - return v - - -class GrafanaAlertConfig(BaseModel): - auto_remediate: bool = False - rules: list[GrafanaRemediationRuleConfig] = Field(default_factory=list) - cooldown_seconds: int = 300 - max_concurrent_remediations: int = 5 - - class MCPServerConfig(BaseModel): enabled: bool = True transport: str = "stdio" # "stdio" or "http" @@ -1797,7 +1770,6 @@ class Config(BaseModel): mcp: MCPConfig = MCPConfig() audit: AuditConfig = AuditConfig() agents: AgentsConfig = AgentsConfig() - grafana_alerts: GrafanaAlertConfig = GrafanaAlertConfig() outbound_webhooks: OutboundWebhooksConfig = OutboundWebhooksConfig() graceful_degradation: GracefulDegradationConfig = GracefulDegradationConfig() llm_recovery: LLMRecoveryConfig = LLMRecoveryConfig() diff --git a/src/discord/response_guards.py b/src/discord/response_guards.py index 217925bc1..f4715b0f6 100644 --- a/src/discord/response_guards.py +++ b/src/discord/response_guards.py @@ -800,7 +800,7 @@ def truncate_tool_output(text: str, max_chars: int = TOOL_OUTPUT_MAX_CHARS) -> s Tool results stay in the messages list and are re-sent as input tokens on every subsequent iteration of the tool loop. Capping output prevents - a single large result (Prometheus JSON, file contents, long command output) + a single large result (API JSON, file contents, long command output) from ballooning costs across iterations. """ from ..tools.output_delivery import DeliveredOutput, deliver diff --git a/src/discord/wiring.py b/src/discord/wiring.py index 4f1fd0548..c85ad650b 100644 --- a/src/discord/wiring.py +++ b/src/discord/wiring.py @@ -458,7 +458,7 @@ def build_services( trajectory_saver.set_usage_observer(usage_rollup) agent_trajectory_saver.set_usage_observer(usage_rollup) - # Cost tracking remains the hot-process Prometheus accumulator; persistent + # Cost tracking remains the hot-process usage accumulator; persistent # WebUI history is served by usage_rollup. cost_tracker = CostTracker() diff --git a/src/health/grafana_alerts.py b/src/health/grafana_alerts.py deleted file mode 100644 index a92fa2f94..000000000 --- a/src/health/grafana_alerts.py +++ /dev/null @@ -1,450 +0,0 @@ -"""Grafana alert parser and auto-remediation handler. - -Parses Grafana unified alerting webhook payloads into structured objects, -matches alerts against configurable rules, and spawns autonomous remediation -loops for matching firing alerts. -""" -from __future__ import annotations - -import fnmatch -import time -from collections import deque -from dataclasses import dataclass, field -from typing import Any - -from ..odin_log import get_logger - -log = get_logger("grafana_alerts") - -MAX_ALERT_HISTORY = 200 -MAX_CONCURRENT_REMEDIATIONS = 5 -DEFAULT_COOLDOWN_SECONDS = 300 # 5 minutes between remediations for same alert -# Cadence for the health lifecycle's stale-record/cooldown pruning. Records -# only become stale after an hour (see cleanup_old_remediations), so this is -# deliberately coarse: it bounds growth without a per-tick cost. -REMEDIATION_CLEANUP_INTERVAL_SECONDS = 300 -DEFAULT_REMEDIATION_INTERVAL = 30 # seconds between loop iterations -DEFAULT_REMEDIATION_MAX_ITER = 10 -MAX_ANNOTATION_LEN = 1000 -MAX_LABEL_VALUE_LEN = 500 - - -@dataclass -class GrafanaAlert: - """Parsed Grafana alert with all relevant fields.""" - fingerprint: str - status: str # "firing" or "resolved" - alert_name: str - labels: dict[str, str] - annotations: dict[str, str] - starts_at: str - ends_at: str - generator_url: str - silence_url: str - dashboard_url: str - panel_url: str - values: dict[str, Any] - severity: str # extracted from labels or "unknown" - instance: str # extracted from labels or "" - summary: str # extracted from annotations or "" - description: str # extracted from annotations or "" - received_at: float = field(default_factory=time.monotonic) - - -@dataclass -class RemediationRule: - """Rule mapping alert patterns to remediation goals.""" - id: str - name_pattern: str # fnmatch pattern for alert_name - label_matchers: dict[str, str] = field(default_factory=dict) # label_key -> fnmatch pattern - severity_filter: list[str] = field(default_factory=list) # empty = match all - remediation_goal: str = "" - mode: str = "notify" # "notify", "act", "silent" - interval_seconds: int = DEFAULT_REMEDIATION_INTERVAL - max_iterations: int = DEFAULT_REMEDIATION_MAX_ITER - cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS - enabled: bool = True - - -@dataclass -class RemediationRecord: - """Tracks an active or completed remediation spawned from an alert.""" - alert_fingerprint: str - alert_name: str - rule_id: str - loop_id: str - started_at: float - status: str = "running" # "running", "completed", "error" - - -def parse_grafana_payload(data: dict) -> list[GrafanaAlert]: - """Parse a Grafana unified alerting webhook payload into GrafanaAlert objects. - - Handles both the unified alerting format (alerts array) and the legacy - single-alert format (title + message). - """ - alerts_raw = data.get("alerts", []) - if not alerts_raw: - title = data.get("title", data.get("ruleName", "")) - message = data.get("message", data.get("state", "")) - if not title and not message: - return [] - return [GrafanaAlert( - fingerprint=_make_fingerprint(title, {}), - status=data.get("state", "alerting"), - alert_name=title, - labels={}, - annotations={"summary": message} if message else {}, - starts_at="", - ends_at="", - generator_url=data.get("ruleUrl", ""), - silence_url="", - dashboard_url="", - panel_url="", - values={}, - severity="unknown", - instance="", - summary=message[:MAX_ANNOTATION_LEN] if message else "", - description="", - )] - - parsed: list[GrafanaAlert] = [] - for raw in alerts_raw: - labels = raw.get("labels", {}) - annotations = raw.get("annotations", {}) - values = raw.get("values") or raw.get("valueString") or {} - if isinstance(values, str): - values = {"raw": values} - - alert_name = labels.get("alertname", raw.get("alertname", "Unknown")) - severity = labels.get("severity", "unknown") - instance = labels.get("instance", "") - summary = annotations.get("summary", "")[:MAX_ANNOTATION_LEN] - description = annotations.get("description", "")[:MAX_ANNOTATION_LEN] - - fingerprint = raw.get("fingerprint", _make_fingerprint(alert_name, labels)) - - parsed.append(GrafanaAlert( - fingerprint=fingerprint, - status=raw.get("status", "unknown"), - alert_name=alert_name, - labels={k: str(v)[:MAX_LABEL_VALUE_LEN] for k, v in labels.items()}, - annotations={k: str(v)[:MAX_ANNOTATION_LEN] for k, v in annotations.items()}, - starts_at=raw.get("startsAt", ""), - ends_at=raw.get("endsAt", ""), - generator_url=raw.get("generatorURL", ""), - silence_url=raw.get("silenceURL", ""), - dashboard_url=raw.get("dashboardURL", ""), - panel_url=raw.get("panelURL", ""), - values=values, - severity=severity, - instance=instance, - summary=summary, - description=description, - )) - - return parsed - - -def format_alert_message(alerts: list[GrafanaAlert]) -> str: - """Format parsed alerts into a Discord message.""" - if not alerts: - return "**Grafana Alert** \u2014 (no alerts in payload)" - - lines: list[str] = [] - for alert in alerts[:10]: - emoji = "\U0001f534" if alert.status == "firing" else "\U0001f7e2" - line = f"{emoji} **{alert.alert_name}** ({alert.status})" - if alert.instance: - line += f" \u2014 `{alert.instance}`" - if alert.severity and alert.severity != "unknown": - line += f" [{alert.severity}]" - if alert.summary: - line += f"\n {alert.summary[:200]}" - elif alert.description: - line += f"\n {alert.description[:200]}" - if alert.values and isinstance(alert.values, dict) and "raw" not in alert.values: - val_parts = [f"{k}={v}" for k, v in list(alert.values.items())[:3]] - if val_parts: - line += f"\n Values: {', '.join(val_parts)}" - lines.append(line) - - header = f"**Grafana Alerts** ({len(alerts)} alert(s)):" - if len(alerts) == 1: - header = "**Grafana Alert**:" - return header + "\n" + "\n".join(lines) - - -def _make_fingerprint(name: str, labels: dict) -> str: - """Generate a deterministic fingerprint from alert name + labels.""" - parts = [name] + [f"{k}={v}" for k, v in sorted(labels.items())] - import hashlib - return hashlib.md5("|".join(parts).encode()).hexdigest()[:16] - - -def matches_rule(alert: GrafanaAlert, rule: RemediationRule) -> bool: - """Check if an alert matches a remediation rule.""" - if not rule.enabled: - return False - if not fnmatch.fnmatch(alert.alert_name, rule.name_pattern): - return False - if rule.severity_filter and alert.severity not in rule.severity_filter: - return False - for label_key, pattern in rule.label_matchers.items(): - label_val = alert.labels.get(label_key, "") - if not fnmatch.fnmatch(label_val, pattern): - return False - return True - - -def build_remediation_prompt(alert: GrafanaAlert, rule: RemediationRule) -> str: - """Build the goal prompt for a remediation loop from alert + rule context.""" - parts = [ - f"ALERT REMEDIATION: {alert.alert_name} is {alert.status}", - ] - if alert.instance: - parts.append(f"Instance: {alert.instance}") - if alert.severity != "unknown": - parts.append(f"Severity: {alert.severity}") - if alert.summary: - parts.append(f"Summary: {alert.summary[:300]}") - elif alert.description: - parts.append(f"Description: {alert.description[:300]}") - if alert.values and isinstance(alert.values, dict): - val_str = ", ".join(f"{k}={v}" for k, v in list(alert.values.items())[:5]) - if val_str: - parts.append(f"Values: {val_str}") - - parts.append("") - if rule.remediation_goal: - parts.append(f"Goal: {rule.remediation_goal}") - else: - parts.append( - f"Goal: Investigate and remediate the alert '{alert.alert_name}'. " - "Check the affected service, diagnose root cause, and take corrective action if safe." - ) - - parts.append("") - parts.append( - "Stop condition: The alert is resolved or you have diagnosed the issue " - "and taken corrective action (or determined no action is needed)." - ) - - return "\n".join(parts) - - -class GrafanaAlertHandler: - """Manages Grafana alert parsing, rule matching, and remediation loop spawning.""" - - def __init__( - self, - rules: list[RemediationRule] | None = None, - auto_remediate: bool = False, - cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS, - max_concurrent: int = MAX_CONCURRENT_REMEDIATIONS, - ) -> None: - self._rules: list[RemediationRule] = list(rules or []) - self._auto_remediate = auto_remediate - self._cooldown_seconds = cooldown_seconds - self._max_concurrent = max_concurrent - self._alert_history: deque[dict] = deque(maxlen=MAX_ALERT_HISTORY) - self._remediations: dict[str, RemediationRecord] = {} - self._cooldowns: dict[str, float] = {} # fingerprint -> last_remediation_time - self._stats = {"alerts_received": 0, "remediations_spawned": 0, "rules_matched": 0} - - @property - def rules(self) -> list[RemediationRule]: - return list(self._rules) - - @property - def alert_history(self) -> list[dict]: - return list(self._alert_history) - - @property - def active_remediations(self) -> dict[str, RemediationRecord]: - return dict(self._remediations) - - def add_rule(self, rule: RemediationRule) -> None: - for existing in self._rules: - if existing.id == rule.id: - raise ValueError(f"Rule with id '{rule.id}' already exists") - self._rules.append(rule) - log.info("Added remediation rule: %s (pattern=%s)", rule.id, rule.name_pattern) - - def remove_rule(self, rule_id: str) -> bool: - for i, rule in enumerate(self._rules): - if rule.id == rule_id: - self._rules.pop(i) - log.info("Removed remediation rule: %s", rule_id) - return True - return False - - def get_rule(self, rule_id: str) -> RemediationRule | None: - for rule in self._rules: - if rule.id == rule_id: - return rule - return None - - def process_alerts( - self, - alerts: list[GrafanaAlert], - ) -> list[tuple[GrafanaAlert, RemediationRule]]: - """Process alerts and return (alert, rule) pairs that should spawn remediations. - - Only firing alerts are considered for remediation. Resolved alerts are - recorded in history but don't trigger new loops. - """ - self._stats["alerts_received"] += len(alerts) - matches: list[tuple[GrafanaAlert, RemediationRule]] = [] - - for alert in alerts: - self._alert_history.append({ - "fingerprint": alert.fingerprint, - "alert_name": alert.alert_name, - "status": alert.status, - "severity": alert.severity, - "instance": alert.instance, - "summary": alert.summary[:200], - "received_at": alert.received_at, - "labels": dict(list(alert.labels.items())[:10]), - }) - - if alert.status != "firing": - self._mark_resolved(alert.fingerprint) - continue - - if not self._auto_remediate: - continue - - for rule in self._rules: - if matches_rule(alert, rule): - cooldown_key = f"{alert.fingerprint}:{rule.id}" - if self._is_on_cooldown(cooldown_key, rule.cooldown_seconds): - log.debug( - "Alert %s matches rule %s but is on cooldown", - alert.alert_name, rule.id, - ) - continue - if self._count_active() >= self._max_concurrent: - log.warning( - "Max concurrent remediations (%d) reached, skipping %s", - self._max_concurrent, alert.alert_name, - ) - continue - self._stats["rules_matched"] += 1 - matches.append((alert, rule)) - self._cooldowns[cooldown_key] = time.monotonic() - break # first matching rule wins - - return matches - - def record_remediation( - self, alert: GrafanaAlert, rule: RemediationRule, loop_id: str, - ) -> None: - """Record that a remediation loop was spawned for an alert.""" - self._remediations[loop_id] = RemediationRecord( - alert_fingerprint=alert.fingerprint, - alert_name=alert.alert_name, - rule_id=rule.id, - loop_id=loop_id, - started_at=time.monotonic(), - ) - self._stats["remediations_spawned"] += 1 - log.info( - "Remediation loop %s spawned for alert %s (rule %s)", - loop_id, alert.alert_name, rule.id, - ) - - def update_remediation_status(self, loop_id: str, status: str) -> None: - """Update the status of a tracked remediation.""" - rec = self._remediations.get(loop_id) - if rec: - rec.status = status - - def _mark_resolved(self, fingerprint: str) -> None: - """Mark active remediations for a fingerprint as completed.""" - for rec in self._remediations.values(): - if rec.alert_fingerprint == fingerprint and rec.status == "running": - rec.status = "completed" - - def _is_on_cooldown(self, key: str, cooldown_seconds: int) -> bool: - last = self._cooldowns.get(key) - if last is None: - # Never remediated. A `0` sentinel here reads as "on cooldown - # since boot" on the monotonic clock, silently dropping every - # alert for the first cooldown_seconds after a host reboot — - # exactly the window when alerts fire (caught by the first CI - # run on a fresh runner, RFC-003 P0). - return False - return (time.monotonic() - last) < cooldown_seconds - - def _count_active(self) -> int: - return sum(1 for r in self._remediations.values() if r.status == "running") - - def cleanup_old_remediations(self) -> int: - """Remove completed/errored remediations older than 1 hour and stale cooldowns.""" - now = time.monotonic() - to_remove = [ - lid for lid, rec in self._remediations.items() - if rec.status != "running" and (now - rec.started_at) > 3600 - ] - for lid in to_remove: - del self._remediations[lid] - max_cooldown = max( - (r.cooldown_seconds for r in self._rules), - default=self._cooldown_seconds, - ) - stale_keys = [ - k for k, ts in self._cooldowns.items() - if (now - ts) > max_cooldown * 2 - ] - for k in stale_keys: - del self._cooldowns[k] - return len(to_remove) + len(stale_keys) - - def get_status(self) -> dict: - """Return handler status for API/status endpoints.""" - active = self._count_active() - return { - "auto_remediate": self._auto_remediate, - "rules_count": len(self._rules), - "alerts_received": self._stats["alerts_received"], - "remediations_spawned": self._stats["remediations_spawned"], - "rules_matched": self._stats["rules_matched"], - "active_remediations": active, - "alert_history_size": len(self._alert_history), - "max_concurrent": self._max_concurrent, - "cooldown_seconds": self._cooldown_seconds, - } - - def get_rules_list(self) -> list[dict]: - """Return rules as serializable dicts.""" - return [ - { - "id": r.id, - "name_pattern": r.name_pattern, - "label_matchers": r.label_matchers, - "severity_filter": r.severity_filter, - "remediation_goal": r.remediation_goal, - "mode": r.mode, - "interval_seconds": r.interval_seconds, - "max_iterations": r.max_iterations, - "cooldown_seconds": r.cooldown_seconds, - "enabled": r.enabled, - } - for r in self._rules - ] - - def get_remediations_list(self) -> list[dict]: - """Return remediations as serializable dicts.""" - return [ - { - "loop_id": r.loop_id, - "alert_fingerprint": r.alert_fingerprint, - "alert_name": r.alert_name, - "rule_id": r.rule_id, - "started_at": r.started_at, - "status": r.status, - } - for r in self._remediations.values() - ] diff --git a/src/health/metrics.py b/src/health/metrics.py deleted file mode 100644 index 5c46b3426..000000000 --- a/src/health/metrics.py +++ /dev/null @@ -1,458 +0,0 @@ -"""Prometheus exposition format metrics for Odin. - -Generates /metrics output in Prometheus text format (text/plain; version=0.0.4). -No external dependencies — the exposition format is trivially serializable. - -Usage: - collector = MetricsCollector() - collector.register_source("tools", lambda: executor.get_metrics()) - text = collector.render() -""" -from __future__ import annotations - -import time -from collections.abc import Callable -from typing import Any - -# Type for a callable that returns arbitrary metric data -MetricSource = Callable[[], Any] - - -def _escape_label_value(v: str) -> str: - """Escape a Prometheus label value (backslash, double-quote, newline).""" - return v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") - - -def _format_metric( - name: str, - value: float | int, - *, - metric_type: str = "gauge", - help_text: str = "", - labels: dict[str, str] | None = None, - include_header: bool = True, -) -> str: - """Format a single metric line in Prometheus exposition format.""" - lines = [] - if include_header: - if help_text: - lines.append(f"# HELP {name} {help_text}") - lines.append(f"# TYPE {name} {metric_type}") - if labels: - label_str = ",".join( - f'{k}="{_escape_label_value(str(v))}"' for k, v in sorted(labels.items()) - ) - lines.append(f"{name}{{{label_str}}} {value}") - else: - lines.append(f"{name} {value}") - return "\n".join(lines) - - -class MetricsCollector: - """Collects and renders Prometheus-format metrics from registered sources.""" - - def __init__(self) -> None: - self._start_time = time.time() - self._sources: dict[str, MetricSource] = {} - self._ready = False - self._component_check: Callable[[], dict[str, dict]] | None = None - - def set_ready(self, ready: bool) -> None: - self._ready = ready - - def set_component_check(self, check: Callable[[], dict[str, dict]]) -> None: - """Register the component health checker from HealthServer.""" - self._component_check = check - - def register_source(self, name: str, source: MetricSource) -> None: - """Register a named metric source. - - Sources are callables that return data used to generate metrics. - Supported source names and their expected return types: - - "tools": dict[str, dict[str, int]] from ToolExecutor.get_metrics() - - "circuit_breaker": CircuitBreaker instance (has .state, ._failure_count) - - "sessions": SessionManager instance (has .active_count) - - "scheduler": callable returning int (schedule count) - - "loops": callable returning int (active loop count) - """ - self._sources[name] = source - - def render(self) -> str: - """Render all metrics in Prometheus exposition format.""" - sections: list[str] = [] - - # -- Process info -- - sections.append(_format_metric( - "odin_up", 1 if self._ready else 0, - help_text="Whether the Odin bot is ready (1=ready, 0=starting)", - )) - sections.append(_format_metric( - "odin_start_time_seconds", self._start_time, - metric_type="gauge", - help_text="Unix timestamp when Odin started", - )) - uptime = time.time() - self._start_time - sections.append(_format_metric( - "odin_uptime_seconds", round(uptime, 1), - metric_type="gauge", - help_text="Seconds since Odin started", - )) - - # -- Component health -- - if self._component_check: - try: - components = self._component_check() - if components: - sections.append("# HELP odin_component_healthy Component health status " - "(1=healthy, 0=unhealthy)") - sections.append("# TYPE odin_component_healthy gauge") - for name, info in sorted(components.items()): - healthy = 1 if info.get("healthy") else 0 - sections.append( - _format_metric( - "odin_component_healthy", healthy, - labels={"component": name}, - include_header=False, - ) - ) - except Exception: - pass - - # -- Local command workspace (no auto-prune, so growth is observable) -- - ws_source = self._sources.get("workspace") - if ws_source: - try: - ws = ws_source() or {} - if ws: - gauges = { - "bytes": ( - "odin_workspace_bytes", - "Bytes stored in the local command workspace", - ), - "files": ( - "odin_workspace_files", - "Files in the local command workspace", - ), - "free_bytes": ( - "odin_workspace_free_bytes", - "Free bytes on the workspace filesystem", - ), - "free_inodes": ( - "odin_workspace_free_inodes", - "Free inodes on the workspace filesystem", - ), - } - for key, (name, helptext) in gauges.items(): - if key in ws: - sections.append(f"# HELP {name} {helptext}") - sections.append(f"# TYPE {name} gauge") - sections.append(_format_metric(name, ws[key], include_header=False)) - except Exception: - pass - - # -- Tool execution metrics -- - tool_source = self._sources.get("tools") - if tool_source: - try: - tool_metrics = tool_source() - if tool_metrics: - sections.append("# HELP odin_tool_calls_total Total tool invocations") - sections.append("# TYPE odin_tool_calls_total counter") - for tool_name, counts in sorted(tool_metrics.items()): - calls = counts.get("calls", 0) - sections.append(_format_metric( - "odin_tool_calls_total", calls, - labels={"tool": tool_name}, - include_header=False, - )) - sections.append("# HELP odin_tool_errors_total Total tool errors") - sections.append("# TYPE odin_tool_errors_total counter") - for tool_name, counts in sorted(tool_metrics.items()): - errors = counts.get("errors", 0) - sections.append(_format_metric( - "odin_tool_errors_total", errors, - labels={"tool": tool_name}, - include_header=False, - )) - sections.append("# HELP odin_tool_timeouts_total Total tool timeouts") - sections.append("# TYPE odin_tool_timeouts_total counter") - for tool_name, counts in sorted(tool_metrics.items()): - timeouts = counts.get("timeouts", 0) - sections.append(_format_metric( - "odin_tool_timeouts_total", timeouts, - labels={"tool": tool_name}, - include_header=False, - )) - except Exception: - pass - - # -- Circuit breaker -- - cb_source = self._sources.get("circuit_breaker") - if cb_source: - try: - cb = cb_source() - state_map = {"closed": 0, "half_open": 1, "open": 2} - state_val = state_map.get(cb.state, -1) - sections.append(_format_metric( - "odin_circuit_breaker_state", - state_val, - help_text="Circuit breaker state (0=closed, 1=half_open, 2=open)", - )) - sections.append(_format_metric( - "odin_circuit_breaker_failures", - cb._failure_count, - metric_type="gauge", - help_text="Current consecutive failure count", - )) - except Exception: - pass - - # -- Sessions -- - session_source = self._sources.get("sessions") - if session_source: - try: - count = session_source() - sections.append(_format_metric( - "odin_active_sessions", count, - help_text="Number of active web sessions", - )) - except Exception: - pass - - # -- Scheduler -- - sched_source = self._sources.get("scheduler") - if sched_source: - try: - count = sched_source() - sections.append(_format_metric( - "odin_schedules_total", count, - help_text="Number of configured schedules", - )) - except Exception: - pass - - # -- Autonomous loops -- - loop_source = self._sources.get("loops") - if loop_source: - try: - count = loop_source() - sections.append(_format_metric( - "odin_active_loops", count, - help_text="Number of running autonomous loops", - )) - except Exception: - pass - - # -- LLM cost tracking -- - cost_source = self._sources.get("cost_tracker") - if cost_source: - try: - cost_data = cost_source() - if cost_data: - sections.append(_format_metric( - "odin_llm_input_tokens_total", - cost_data.get("total_input_tokens", 0), - metric_type="counter", - help_text="Total estimated LLM input tokens", - )) - sections.append(_format_metric( - "odin_llm_output_tokens_total", - cost_data.get("total_output_tokens", 0), - metric_type="counter", - help_text="Total estimated LLM output tokens", - )) - sections.append(_format_metric( - "odin_llm_cost_usd_total", - cost_data.get("total_cost_usd", 0), - metric_type="counter", - help_text="Total estimated LLM cost in USD", - )) - sections.append(_format_metric( - "odin_llm_requests_total", - cost_data.get("total_requests", 0), - metric_type="counter", - help_text="Total LLM API requests", - )) - by_user = cost_data.get("by_user", {}) - if by_user: - sections.append("# HELP odin_llm_user_cost_usd LLM cost in USD by user") - sections.append("# TYPE odin_llm_user_cost_usd counter") - for uid, info in sorted(by_user.items()): - sections.append(_format_metric( - "odin_llm_user_cost_usd", info.get("cost_usd", 0), - labels={"user": uid}, - include_header=False, - )) - by_channel = cost_data.get("by_channel", {}) - if by_channel: - sections.append("# HELP odin_llm_channel_cost_usd LLM cost in USD by " - "channel") - sections.append("# TYPE odin_llm_channel_cost_usd counter") - for cid, info in sorted(by_channel.items()): - sections.append(_format_metric( - "odin_llm_channel_cost_usd", info.get("cost_usd", 0), - labels={"channel": cid}, - include_header=False, - )) - except Exception: - pass - - # -- Session token budget -- - session_tokens_source = self._sources.get("session_tokens") - if session_tokens_source: - try: - token_data = session_tokens_source() - if token_data: - sections.append(_format_metric( - "odin_session_tokens_total", - token_data.get("total_tokens", 0), - help_text="Total estimated tokens across all active sessions", - )) - sections.append(_format_metric( - "odin_session_token_budget", - token_data.get("token_budget", 0), - help_text="Configured per-session token budget", - )) - sections.append(_format_metric( - "odin_sessions_over_budget", - token_data.get("over_budget_count", 0), - help_text="Number of sessions exceeding token budget", - )) - per_session = token_data.get("per_session", {}) - if per_session: - sections.append("# HELP odin_session_tokens Estimated tokens per session") - sections.append("# TYPE odin_session_tokens gauge") - for cid, tokens in sorted(per_session.items()): - sections.append(_format_metric( - "odin_session_tokens", tokens, - labels={"channel": cid}, - include_header=False, - )) - except Exception: - pass - - # -- Trajectories -- - trajectory_source = self._sources.get("trajectories") - if trajectory_source: - try: - traj_data = trajectory_source() - if traj_data: - sections.append(_format_metric( - "odin_trajectories_saved_total", - traj_data.get("trajectories_saved_total", 0), - help_text="Total trajectory turns saved to disk", - )) - except Exception: - pass - - # -- Bulkheads -- - bulkhead_source = self._sources.get("bulkheads") - if bulkhead_source: - try: - bh_data = bulkhead_source() - if bh_data: - bh_count = bh_data.get("bulkhead_count", 0) - sections.append(_format_metric( - "odin_bulkhead_count", bh_count, - help_text="Number of registered bulkheads", - )) - active_keys = sorted( - k for k in bh_data if k.endswith("_active") - ) - if active_keys: - sections.append( - "# HELP odin_bulkhead_active Current active operations per bulkhead" - ) - sections.append("# TYPE odin_bulkhead_active gauge") - for key in active_keys: - name = key.replace("bulkhead_", "").replace("_active", "") - sections.append(_format_metric( - "odin_bulkhead_active", bh_data[key], - labels={"bulkhead": name}, - include_header=False, - )) - rejected_keys = sorted( - k for k in bh_data if k.endswith("_rejected") - ) - if rejected_keys: - sections.append( - "# HELP odin_bulkhead_rejected_total Rejected requests per bulkhead" - ) - sections.append("# TYPE odin_bulkhead_rejected_total counter") - for key in rejected_keys: - name = key.replace("bulkhead_", "").replace("_rejected", "") - sections.append(_format_metric( - "odin_bulkhead_rejected_total", bh_data[key], - labels={"bulkhead": name}, - include_header=False, - )) - total_keys = sorted( - k for k in bh_data if k.endswith("_total") - ) - if total_keys: - sections.append( - "# HELP odin_bulkhead_operations_total Total operations per bulkhead" - ) - sections.append("# TYPE odin_bulkhead_operations_total counter") - for key in total_keys: - name = key.replace("bulkhead_", "").replace("_total", "") - sections.append(_format_metric( - "odin_bulkhead_operations_total", bh_data[key], - labels={"bulkhead": name}, - include_header=False, - )) - except Exception: - pass - - # -- SSH Connection Pool -- - ssh_pool_source = self._sources.get("ssh_pool") - if ssh_pool_source: - try: - pool_data = ssh_pool_source() - if pool_data: - sections.append(_format_metric( - "odin_ssh_pool_active_connections", - pool_data.get("ssh_pool_active_connections", 0), - help_text="Active SSH ControlMaster connections", - )) - sections.append(_format_metric( - "odin_ssh_pool_total_opened", - pool_data.get("ssh_pool_total_opened", 0), - help_text="Total SSH connections opened", - metric_type="counter", - )) - sections.append(_format_metric( - "odin_ssh_pool_total_reused", - pool_data.get("ssh_pool_total_reused", 0), - help_text="Total SSH connections reused via multiplexing", - metric_type="counter", - )) - except Exception: - pass - - # -- HTTP Connection Pool -- - http_pool_source = self._sources.get("http_pool") - if http_pool_source: - try: - pool_data = http_pool_source() - if pool_data: - sections.append(_format_metric( - "odin_http_pool_active_connections", - pool_data.get("http_pool_active_connections", 0), - help_text="Active HTTP keepalive connections", - )) - sections.append(_format_metric( - "odin_http_pool_max_connections", - pool_data.get("http_pool_max_connections", 0), - help_text="HTTP connection pool max size", - )) - sections.append(_format_metric( - "odin_http_pool_total_requests", - pool_data.get("http_pool_total_requests", 0), - help_text="Total HTTP requests made via pool", - metric_type="counter", - )) - except Exception: - pass - - return "\n".join(sections) + "\n" diff --git a/src/health/server.py b/src/health/server.py index 328dc2c11..bb036650f 100644 --- a/src/health/server.py +++ b/src/health/server.py @@ -15,19 +15,10 @@ from aiohttp import web -from ..config.schema import GrafanaAlertConfig, WebConfig, WebhookConfig +from ..config.schema import WebConfig, WebhookConfig from ..odin_log import get_logger from ..version import get_version from ..web.api_common import contains_redaction_mask -from .grafana_alerts import ( - REMEDIATION_CLEANUP_INTERVAL_SECONDS, - GrafanaAlertHandler, - RemediationRule, - build_remediation_prompt, - format_alert_message, - parse_grafana_payload, -) -from .metrics import MetricsCollector if TYPE_CHECKING: from aiohttp.typedefs import Middleware @@ -56,7 +47,7 @@ def getsockname(self) -> tuple[object, ...]: ... # PUBLIC_PREFIXES: any path starting with these skips auth entirely. # PUBLIC_EXACT: specific /api/ paths that skip auth (e.g. login). # Everything else under /api/ requires a valid Bearer token or session. -AUTH_PUBLIC_PREFIXES = ("/health", "/metrics", "/webhook/", "/ui") +AUTH_PUBLIC_PREFIXES = ("/health", "/webhook/", "/ui") AUTH_PUBLIC_EXACT = frozenset({"/api/auth/login"}) _AUTH_SKIP_PREFIXES = AUTH_PUBLIC_PREFIXES @@ -88,7 +79,6 @@ def getsockname(self) -> tuple[object, ...]: ... "/api/tools/builtins", "/api/pools", "/api/outbound-webhooks", - "/api/grafana-alerts", "/api/context", "/api/restart", "/api/turn-state", @@ -836,64 +826,25 @@ def __init__( port: int = 3000, webhook_config: WebhookConfig | None = None, web_config: WebConfig | None = None, - grafana_alert_config: GrafanaAlertConfig | None = None, initialization_store=None, ) -> None: self.port = port self._ready = False self._webhook_config = webhook_config or WebhookConfig() self._web_config = web_config or WebConfig() - self._grafana_alert_config = grafana_alert_config or GrafanaAlertConfig() self._send_message: SendMessageCallback | None = None self._trigger_callback: TriggerCallback | None = None - self._loop_spawn_callback: Callable | None = None self._start_time = time.monotonic() self._components: dict[str, ComponentCheck] = {} self._initialization_store = initialization_store self._config_owner: OdinBot | None = None self._effective_bind_host: str | None = None self._listener_sockets: tuple[ListenerSocket, ...] = () - # Owned lifecycle task that prunes stale Grafana remediation records - # and cooldown keys. L6: cleanup_old_remediations() had no production - # caller, so both maps grew without bound on a long-lived install with - # auto-remediate enabled. - self._grafana_cleanup_task: asyncio.Task[None] | None = None - - # Grafana alert handler - rules: list[RemediationRule] = [] - for rc in self._grafana_alert_config.rules: - rules.append( - RemediationRule( - id=rc.id or f"rule_{len(rules)}", - name_pattern=rc.name_pattern, - label_matchers=rc.label_matchers, - severity_filter=rc.severity_filter, - remediation_goal=rc.remediation_goal, - mode=rc.mode, - interval_seconds=rc.interval_seconds, - max_iterations=rc.max_iterations, - cooldown_seconds=rc.cooldown_seconds, - ) - ) - self._grafana_handler = GrafanaAlertHandler( - rules=rules, - auto_remediate=self._grafana_alert_config.auto_remediate, - cooldown_seconds=self._grafana_alert_config.cooldown_seconds, - max_concurrent=self._grafana_alert_config.max_concurrent_remediations, - ) - # Session management self._session_manager = SessionManager( timeout_minutes=self._web_config.session_timeout_minutes, ) - # Prometheus metrics collector - self._metrics_collector = MetricsCollector() - self._metrics_collector.register_source( - "sessions", - lambda: self._session_manager.active_count, - ) - middlewares = [] if self._web_config.enabled: middlewares.append(_make_bootstrap_gate_middleware()) @@ -915,10 +866,8 @@ def __init__( self._app.router.add_get("/health", self._health) self._app.router.add_get("/health/live", self._health_live) self._app.router.add_get("/health/ready", self._health_ready) - self._app.router.add_get("/metrics", self._metrics) if self._webhook_config.enabled: self._app.router.add_post("/webhook/gitea", self._webhook_gitea) - self._app.router.add_post("/webhook/grafana", self._webhook_grafana) self._app.router.add_post("/webhook/generic", self._webhook_generic) self._app.router.add_post("/webhook/github", self._webhook_github) self._app.router.add_post("/webhook/gitlab", self._webhook_gitlab) @@ -939,14 +888,8 @@ def __init__( log.info("Serving web UI from %s", ui_dir) self._runner: web.AppRunner | None = None - @property - def metrics(self) -> MetricsCollector: - """Access the Prometheus metrics collector for registering sources.""" - return self._metrics_collector - def set_ready(self, ready: bool = True) -> None: self._ready = ready - self._metrics_collector.set_ready(ready) def register_component(self, name: str, check: ComponentCheck) -> None: """Register a named component health check. @@ -958,12 +901,6 @@ def register_component(self, name: str, check: ComponentCheck) -> None: health request. """ self._components[name] = check - # Keep metrics collector aware of component checks - self._metrics_collector.set_component_check(self._check_components) - - @property - def grafana_handler(self) -> GrafanaAlertHandler: - return self._grafana_handler def set_send_message(self, callback: SendMessageCallback) -> None: self._send_message = callback @@ -972,22 +909,10 @@ def set_trigger_callback(self, callback: TriggerCallback) -> None: """Set callback for webhook-triggered scheduler actions.""" self._trigger_callback = callback - def set_loop_spawn_callback(self, callback: Callable) -> None: - """Set callback for spawning remediation loops from alerts. - - Callback signature: (goal, channel_id, mode, interval, max_iter) -> loop_id - """ - self._loop_spawn_callback = callback - def set_bot(self, bot: OdinBot) -> None: """Wire the bot instance to enable the REST API and WebSocket endpoints.""" - # Backlink first, and before the enabled check: the bot-facing admin - # routes reach the Grafana handler through - # ``bot.health_server``, and nothing ever assigned it — so - # /api/grafana-alerts/status reported - # ``enabled: false`` on a working install while every mutating route - # 503'd. Doing it here rather than at the __main__ call site covers - # every construction path, including tests and future entry points. + # Backlink before the enabled check so all construction paths expose + # component health and listener policy to the bot. bot.health_server = self self._config_owner = bot # The startup coordinator owns the persisted initialization store used @@ -1148,7 +1073,6 @@ async def start(self) -> None: if isinstance(listener_socket, ListenerSocket) ) log.info("Health server listening on %s:%d", bind_host, self.port) - self._start_grafana_cleanup() except BaseException: runner, self._runner = self._runner, None self._effective_bind_host = None @@ -1157,31 +1081,6 @@ async def start(self) -> None: await runner.cleanup() raise - def _start_grafana_cleanup(self) -> None: - """Own the periodic stale-record sweep for the Grafana handler. - - Idempotent so a repeated ``start()`` cannot leak a second sweep loop. - """ - if self._grafana_cleanup_task is not None and not self._grafana_cleanup_task.done(): - return - self._grafana_cleanup_task = asyncio.create_task(self._grafana_cleanup_loop()) - - async def _grafana_cleanup_loop(self) -> None: - """Prune stale remediation records and cooldown keys until shutdown. - - Bounded and fail-open: a sweep fault is logged and the loop continues, - because cleanup is a resource bound, not a health-critical path. - Cancellation (a normal shutdown) propagates for ``stop()`` to await. - """ - while True: - await asyncio.sleep(REMEDIATION_CLEANUP_INTERVAL_SECONDS) - try: - removed = self._grafana_handler.cleanup_old_remediations() - if removed: - log.info("Pruned %d stale Grafana remediation record(s)", removed) - except Exception: - log.exception("Grafana remediation cleanup failed") - def may_remove_credential_inventory(self, candidate) -> bool: """Live-socket candidate guard for all credential mutation paths.""" from ..web.bootstrap_policy import may_remove_last_credential, numeric_loopback @@ -1232,16 +1131,6 @@ def validate_web_credential_transition(self, candidate_web) -> None: ) async def stop(self) -> None: - # Retire the owned cleanup sweep first: it is the only task this object - # spawns, and leaving it running past cleanup would let it touch the - # handler after the listener is gone. - task, self._grafana_cleanup_task = self._grafana_cleanup_task, None - if task is not None and not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass # Quiesce the HTTP server first. A cleanup failure must not leave # the runner (and its open handlers) alive past the stop window. if self._runner: @@ -1347,19 +1236,6 @@ async def _health_ready(self, _request: web.Request) -> web.Response: status=status_code, ) - async def _metrics(self, _request: web.Request) -> web.Response: - """Prometheus metrics endpoint. - - Returns metrics in Prometheus exposition format (text/plain). - Unauthenticated — intended for Prometheus scraper access. - """ - body = self._metrics_collector.render() - return web.Response( - text=body, - content_type="text/plain", - charset="utf-8", - ) - def _check_components(self) -> dict[str, dict]: """Run all registered component checks and return a summary dict.""" results: dict[str, dict] = {} @@ -1385,15 +1261,11 @@ def _verify_hmac_sha256(self, body: bytes, signature: str) -> bool: return hmac.compare_digest(expected, signature) def _verify_shared_secret(self, header_value: str) -> bool: - """Verify a shared-secret header (e.g. Grafana X-Webhook-Secret, - GitLab X-Gitlab-Token) against the configured webhook secret. + """Verify a shared-secret header against the configured webhook secret. **Fails closed**: returns False if no secret is configured so webhooks don't accept unauthenticated POSTs when the operator - hasn't set one up. Odin's PR #18 self-audit finding #1: the - Grafana and generic webhook handlers previously gated auth on - ``if self._webhook_config.secret:``, silently accepting - everything when the secret was empty. + hasn't set one up. """ secret = self._webhook_config.secret if not secret: @@ -1405,8 +1277,6 @@ def _get_channel_id(self, source: str) -> str | None: """Get the channel ID for a webhook source.""" if source == "gitea" and self._webhook_config.gitea_channel_id: return self._webhook_config.gitea_channel_id - if source == "grafana" and self._webhook_config.grafana_channel_id: - return self._webhook_config.grafana_channel_id if source == "github" and self._webhook_config.github_channel_id: return self._webhook_config.github_channel_id if source == "gitlab" and self._webhook_config.gitlab_channel_id: @@ -1489,85 +1359,10 @@ async def _webhook_gitea(self, request: web.Request) -> web.Response: await self._notify_triggers("gitea", {"event": event, "repo": repo}) return await self._send("gitea", text) - async def _webhook_grafana(self, request: web.Request) -> web.Response: - body = await request.read() - - # Authenticate via shared secret header (configure in Grafana contact - # point). Fails closed — if no secret is configured server-side the - # request is rejected rather than accepted unauthenticated. - secret_header = request.headers.get("X-Webhook-Secret", "") - if not self._verify_shared_secret(secret_header): - return web.json_response({"error": "invalid secret"}, status=403) - - try: - data = json.loads(body) - except json.JSONDecodeError: - return web.json_response({"error": "invalid JSON"}, status=400) - - # Parse alerts using structured parser - parsed_alerts = parse_grafana_payload(data) - text = format_alert_message(parsed_alerts) - - # Process alerts through remediation handler - matches = self._grafana_handler.process_alerts(parsed_alerts) - - # Spawn remediation loops for matched alerts - spawned_loops: list[str] = [] - if matches and self._loop_spawn_callback: - channel_id = self._get_channel_id("grafana") - for alert, rule in matches: - try: - goal = build_remediation_prompt(alert, rule) - loop_id = await self._loop_spawn_callback( - goal, - channel_id, - rule.mode, - rule.interval_seconds, - rule.max_iterations, - ) - if loop_id and not loop_id.startswith("Error"): - self._grafana_handler.record_remediation(alert, rule, loop_id) - spawned_loops.append(loop_id) - except Exception as exc: - log.warning( - "Failed to spawn remediation for %s: %s", - alert.alert_name, - exc, - ) - - if spawned_loops: - text += ( - "\n\n\U0001f527 Auto-remediation started: " - f"{', '.join(f'`{lid}`' for lid in spawned_loops)}" - ) - - # Build event data for trigger matching - alert_name = "" - if parsed_alerts: - alert_name = parsed_alerts[0].alert_name - else: - alert_name = data.get("ruleName", data.get("title", "")) - - event_data: dict = { - "event": "alert", - "alert_name": alert_name, - "alert_names": [a.alert_name for a in parsed_alerts] or [alert_name], - "alert_count": len(parsed_alerts), - "firing_count": sum(1 for a in parsed_alerts if a.status == "firing"), - "resolved_count": sum(1 for a in parsed_alerts if a.status == "resolved"), - } - if parsed_alerts: - first = parsed_alerts[0] - event_data["severity"] = first.severity - event_data["instance"] = first.instance - - await self._notify_triggers("grafana", event_data) - return await self._send("grafana", text) - async def _webhook_generic(self, request: web.Request) -> web.Response: body = await request.read() secret_header = request.headers.get("X-Webhook-Secret", "") - # Fails closed — same hardening as grafana (PR #18 finding #1). + # Reject unauthenticated requests even when no secret is configured. if not self._verify_shared_secret(secret_header): return web.json_response({"error": "invalid secret"}, status=403) diff --git a/src/llm/cost_tracker.py b/src/llm/cost_tracker.py index 1aafc0805..686faca3e 100644 --- a/src/llm/cost_tracker.py +++ b/src/llm/cost_tracker.py @@ -1,8 +1,7 @@ """LLM cost tracking — token estimation and USD cost aggregation. Tracks estimated prompt/completion tokens and USD cost per Codex call, -aggregated by user, channel, and tool. Exposes data for Prometheus -metrics and the REST API. +aggregated by user, channel, and tool. Exposes data through the REST API. Token counts are *estimates* (≈4 chars per token) because the Codex Responses API does not return usage metadata in its SSE stream. @@ -212,21 +211,3 @@ def get_summary(self) -> dict: "note": "Token counts are estimates (~4 chars/token)", }, } - - def get_prometheus_metrics(self) -> dict: - """Return dict consumed by MetricsCollector.""" - with self._lock: - return { - "total_input_tokens": self._total_input_tokens, - "total_output_tokens": self._total_output_tokens, - "total_cost_usd": round(self._total_cost_usd, 6), - "total_requests": self._total_requests, - "by_user": { - uid: {**v, "cost_usd": round(v["cost_usd"], 6)} - for uid, v in self._by_user.items() - }, - "by_channel": { - cid: {**v, "cost_usd": round(v["cost_usd"], 6)} - for cid, v in self._by_channel.items() - }, - } diff --git a/src/sessions/manager.py b/src/sessions/manager.py index 6491b98ee..42fed4b42 100644 --- a/src/sessions/manager.py +++ b/src/sessions/manager.py @@ -853,7 +853,7 @@ def get_session_token_usage(self) -> dict[str, dict]: return result def get_token_metrics(self) -> dict: - """Return aggregate token metrics for Prometheus exposition.""" + """Return aggregate token metrics for diagnostics.""" total_tokens = 0 session_count = len(self._sessions) over_budget = 0 diff --git a/src/setup_wizard.py b/src/setup_wizard.py index 0c0d091eb..f5c7b0acb 100644 --- a/src/setup_wizard.py +++ b/src/setup_wizard.py @@ -71,7 +71,6 @@ "secret": "${WEBHOOK_SECRET:-}", "channel_id": "", "gitea_channel_id": "", - "grafana_channel_id": "", }, "learning": { "max_entries": 30, diff --git a/src/tools/bulkhead.py b/src/tools/bulkhead.py index c071a04d4..7c9d0bf14 100644 --- a/src/tools/bulkhead.py +++ b/src/tools/bulkhead.py @@ -118,7 +118,7 @@ async def acquire(self) -> AsyncIterator[None]: self._semaphore.release() def get_metrics(self) -> dict: - """Return current bulkhead state for Prometheus/observability.""" + """Return current bulkhead state for the management API.""" return { "name": self.name, "max_concurrent": self._max_concurrent, @@ -181,12 +181,3 @@ def names(self) -> list[str]: def get_all_metrics(self) -> dict[str, dict]: """Return metrics for all registered bulkheads.""" return {name: bh.get_metrics() for name, bh in self._bulkheads.items()} - - def get_prometheus_metrics(self) -> dict: - """Return flattened metrics dict for the Prometheus collector.""" - result: dict = {"bulkhead_count": len(self._bulkheads)} - for name, bh in self._bulkheads.items(): - m = bh.get_metrics() - for key in ("active", "queued", "total", "rejected", "errors", "max_concurrent"): - result[f"bulkhead_{name}_{key}"] = m[key] - return result diff --git a/src/tools/defs/media_scheduling.py b/src/tools/defs/media_scheduling.py index c5178d229..b053ab136 100644 --- a/src/tools/defs/media_scheduling.py +++ b/src/tools/defs/media_scheduling.py @@ -128,7 +128,6 @@ "type": "string", "enum": [ "gitea", - "grafana", "generic", "github", "gitlab", @@ -143,10 +142,6 @@ "type": "string", "description": "Repository name substring (case-insensitive)", }, - "alert_name": { - "type": "string", - "description": "Grafana alert name substring (case-insensitive)", - }, }, "additionalProperties": False, }, @@ -286,11 +281,10 @@ "properties": { "source": { "type": "string", - "enum": ["gitea", "grafana", "generic", "github", "gitlab"], + "enum": ["gitea", "generic", "github", "gitlab"], }, "event": {"type": "string"}, "repo": {"type": "string"}, - "alert_name": {"type": "string"}, }, "additionalProperties": False, }, diff --git a/src/tools/executor.py b/src/tools/executor.py index 5ba3df338..e446be5b4 100644 --- a/src/tools/executor.py +++ b/src/tools/executor.py @@ -461,7 +461,7 @@ def _protected_roots(self) -> list[str]: ) def get_workspace_metrics(self) -> dict[str, float]: - """Usage of the local command workspace, for Prometheus. + """Usage of the local command workspace for diagnostics. The accepted design deliberately does NOT auto-prune — age-based deletion would destroy the cross-command continuity the stable diff --git a/src/tools/skill_context.py b/src/tools/skill_context.py index 929fbeae8..a18748454 100644 --- a/src/tools/skill_context.py +++ b/src/tools/skill_context.py @@ -124,7 +124,7 @@ def is_url_blocked(url: str) -> bool: class SkillContext: """API surface passed to user-created skills. - Provides SSH execution, HTTP helpers, Prometheus queries, file reading, + Provides SSH execution, HTTP helpers, file reading, persistent memory, channel messaging, config access, knowledge base, conversation history search, scheduler, and generic tool execution. """ @@ -192,30 +192,6 @@ async def run_on_host(self, alias: str, command: str) -> str: return raw[0] return raw - async def query_prometheus(self, query: str) -> str: - """Run a PromQL instant query against Prometheus via curl. - - Requires Prometheus to be reachable from a configured host. - """ - # Use run_command with curl since the dedicated query_prometheus tool was removed. - hosts = self.get_hosts() - if not hosts: - return "No hosts configured to reach Prometheus." - host = hosts[0] - from urllib.parse import quote as url_quote - - encoded_query = url_quote(query) - return str( - await self._executor.execute( - "run_command", - { - "host": host, - "command": f"curl -sf 'http://localhost:9090/api/v1/query?query={encoded_query}'", - }, - user_id=self._requester_id, - ) - ) - async def read_file( self, host: str, diff --git a/src/tools/ssh_pool.py b/src/tools/ssh_pool.py index 2b8984290..777b822fd 100644 --- a/src/tools/ssh_pool.py +++ b/src/tools/ssh_pool.py @@ -480,12 +480,3 @@ def get_metrics(self) -> dict: "control_persist": self.control_persist, "socket_dir": self.socket_dir, } - - def get_prometheus_metrics(self) -> dict: - """Return flat metrics dict for Prometheus collector.""" - active = len(self.get_active_hosts()) - return { - "ssh_pool_active_connections": active, - "ssh_pool_total_opened": self._total_opened, - "ssh_pool_total_reused": self._total_reused, - } diff --git a/src/trajectories/saver.py b/src/trajectories/saver.py index f6bb45301..8dd113458 100644 --- a/src/trajectories/saver.py +++ b/src/trajectories/saver.py @@ -539,6 +539,3 @@ async def find_by_loop_id(self, loop_id: str, limit: int = 1000) -> list[dict]: except Exception: pass return results - - def get_prometheus_metrics(self) -> dict: - return {"trajectories_saved_total": self._count} diff --git a/src/web/api/__init__.py b/src/web/api/__init__.py index 063d96ecf..9dd655387 100644 --- a/src/web/api/__init__.py +++ b/src/web/api/__init__.py @@ -58,7 +58,6 @@ from .discord_identity import register_discord_identity from .hosts import register_hosts from .integrations import ( - register_grafana_alerts, register_mcp_servers, register_outbound_webhooks, ) @@ -158,7 +157,6 @@ def create_api_routes(bot: OdinBot) -> web.RouteTableDef: register_mcp_servers(routes, bot) - register_grafana_alerts(routes, bot) register_knowledge(routes, bot) diff --git a/src/web/api/integrations.py b/src/web/api/integrations.py index aa924d594..dfe2a839f 100644 --- a/src/web/api/integrations.py +++ b/src/web/api/integrations.py @@ -14,7 +14,6 @@ from ...odin_log import get_logger from ..api_common import ( - _safe_int_param, _validate_string, ) @@ -505,92 +504,6 @@ async def mutate(): return response -def register_grafana_alerts(routes: web.RouteTableDef, bot) -> None: - """Grafana alerts (verbatim from the monolith).""" - # ------------------------------------------------------------------ - # Grafana alerts - # ------------------------------------------------------------------ - - @routes.get("/api/grafana-alerts/status") - async def grafana_alerts_status(_request: web.Request) -> web.Response: - hs = getattr(bot, "health_server", None) - handler = getattr(hs, "grafana_handler", None) if hs else None - if handler is None: - return web.json_response({"enabled": False}) - return web.json_response({"enabled": True, **handler.get_status()}) - - @routes.get("/api/grafana-alerts/history") - async def grafana_alerts_history(request: web.Request) -> web.Response: - hs = getattr(bot, "health_server", None) - handler = getattr(hs, "grafana_handler", None) if hs else None - if handler is None: - return web.json_response({"error": "Grafana alert handler not available"}, status=503) - limit = _safe_int_param(request, "limit", 50, hi=200) - history = handler.alert_history[-limit:] - return web.json_response({"alerts": history, "total": len(handler.alert_history)}) - - @routes.get("/api/grafana-alerts/rules") - async def grafana_alerts_rules(_request: web.Request) -> web.Response: - hs = getattr(bot, "health_server", None) - handler = getattr(hs, "grafana_handler", None) if hs else None - if handler is None: - return web.json_response({"error": "Grafana alert handler not available"}, status=503) - return web.json_response({"rules": handler.get_rules_list()}) - - @routes.post("/api/grafana-alerts/rules") - async def grafana_alerts_add_rule(request: web.Request) -> web.Response: - hs = getattr(bot, "health_server", None) - handler = getattr(hs, "grafana_handler", None) if hs else None - if handler is None: - return web.json_response({"error": "Grafana alert handler not available"}, status=503) - try: - data = await request.json() - except Exception: - return web.json_response({"error": "invalid JSON"}, status=400) - rule_id = data.get("id", "") - name_pattern = data.get("name_pattern", "") - if not rule_id or not name_pattern: - return web.json_response({"error": "id and name_pattern are required"}, status=400) - try: - from ...health.grafana_alerts import RemediationRule - - rule = RemediationRule( - id=rule_id, - name_pattern=name_pattern, - label_matchers=data.get("label_matchers", {}), - severity_filter=data.get("severity_filter", []), - remediation_goal=data.get("remediation_goal", ""), - mode=data.get("mode", "notify"), - interval_seconds=data.get("interval_seconds", 30), - max_iterations=data.get("max_iterations", 10), - cooldown_seconds=data.get("cooldown_seconds", 300), - enabled=data.get("enabled", True), - ) - handler.add_rule(rule) - return web.json_response({"ok": True, "rule": rule_id}, status=201) - except ValueError as exc: - return web.json_response({"error": str(exc)}, status=400) - - @routes.delete("/api/grafana-alerts/rules/{rule_id}") - async def grafana_alerts_delete_rule(request: web.Request) -> web.Response: - hs = getattr(bot, "health_server", None) - handler = getattr(hs, "grafana_handler", None) if hs else None - if handler is None: - return web.json_response({"error": "Grafana alert handler not available"}, status=503) - rule_id = request.match_info["rule_id"] - if handler.remove_rule(rule_id): - return web.json_response({"ok": True}) - return web.json_response({"error": f"Rule '{rule_id}' not found"}, status=404) - - @routes.get("/api/grafana-alerts/remediations") - async def grafana_alerts_remediations(_request: web.Request) -> web.Response: - hs = getattr(bot, "health_server", None) - handler = getattr(hs, "grafana_handler", None) if hs else None - if handler is None: - return web.json_response({"error": "Grafana alert handler not available"}, status=503) - return web.json_response({"remediations": handler.get_remediations_list()}) - - def register_outbound_webhooks(routes: web.RouteTableDef, bot) -> None: """Outbound webhook CRUD. Persist before changing the running dispatcher.""" from copy import deepcopy diff --git a/tests/characterization/test_api_route_parity.py b/tests/characterization/test_api_route_parity.py index 711761267..cb3aa72ad 100644 --- a/tests/characterization/test_api_route_parity.py +++ b/tests/characterization/test_api_route_parity.py @@ -117,12 +117,6 @@ ("POST", "/api/mcp/enabled", "set_mcp_enabled"), ("POST", "/api/mcp/limits", "set_mcp_publication_limits"), ("POST", "/api/mcp/servers/{name}/enabled", "set_mcp_server_enabled"), - ("GET", "/api/grafana-alerts/status", "grafana_alerts_status"), - ("GET", "/api/grafana-alerts/history", "grafana_alerts_history"), - ("GET", "/api/grafana-alerts/rules", "grafana_alerts_rules"), - ("POST", "/api/grafana-alerts/rules", "grafana_alerts_add_rule"), - ("DELETE", "/api/grafana-alerts/rules/{rule_id}", "grafana_alerts_delete_rule"), - ("GET", "/api/grafana-alerts/remediations", "grafana_alerts_remediations"), ("GET", "/api/knowledge", "list_knowledge"), ("POST", "/api/knowledge", "ingest_knowledge"), ("DELETE", "/api/knowledge/{source}", "delete_knowledge"), diff --git a/tests/test_agent_trajectory.py b/tests/test_agent_trajectory.py index e3fae4435..a414aa93f 100644 --- a/tests/test_agent_trajectory.py +++ b/tests/test_agent_trajectory.py @@ -423,20 +423,18 @@ async def test_search_combined_filters(self, tmp_path): assert results[0]["agent_id"] == "a1" -class TestAgentTrajectorySaverMetrics: - def test_prometheus_metrics(self, tmp_path): +class TestAgentTrajectorySaverCount: + def test_initial_count(self, tmp_path): saver = AgentTrajectorySaver(directory=str(tmp_path)) - metrics = saver.get_prometheus_metrics() - assert metrics == {"agent_trajectories_saved_total": 0} + assert saver.count == 0 - async def test_prometheus_metrics_after_saves(self, tmp_path): + async def test_count_after_saves(self, tmp_path): saver = AgentTrajectorySaver(directory=str(tmp_path)) for i in range(3): turn = AgentTrajectoryTurn(agent_id=f"a{i}") turn.finalize(final_state="completed") await saver.save(turn) - metrics = saver.get_prometheus_metrics() - assert metrics == {"agent_trajectories_saved_total": 3} + assert saver.count == 3 # --------------------------------------------------------------------------- diff --git a/tests/test_apply_registry.py b/tests/test_apply_registry.py index d87346aca..3aaa51202 100644 --- a/tests/test_apply_registry.py +++ b/tests/test_apply_registry.py @@ -186,7 +186,7 @@ def test_removed_noop_switches_are_absent_and_siblings_require_restart(self): facts = schema_facts() # Includes managed activation and its qualified companion manifest. - assert len(facts) == 323 # Slack was removed from the schema. + assert len(facts) == 309 # Native monitoring sections were removed. assert "openai_compatible.openrouter.model_pins" in facts assert "openai_compatible.openrouter.catalogue_profiles" in facts assert "mcp.max_published_tools_per_server" in facts @@ -206,10 +206,6 @@ def test_removed_noop_switches_are_absent_and_siblings_require_restart(self): for path in ( "graceful_degradation.degraded_threshold", "graceful_degradation.unavailable_threshold", - "grafana_alerts.auto_remediate", - "grafana_alerts.rules", - "grafana_alerts.cooldown_seconds", - "grafana_alerts.max_concurrent_remediations", ): spec = spec_for(path) assert spec.apply_mode == "restart", path diff --git a/tests/test_bulkhead.py b/tests/test_bulkhead.py index 63f6b451e..9e28ef729 100644 --- a/tests/test_bulkhead.py +++ b/tests/test_bulkhead.py @@ -199,21 +199,9 @@ def test_get_all_metrics(self): assert "subprocess" in m assert m["ssh"]["max_concurrent"] == 10 - def test_get_prometheus_metrics(self): - reg = BulkheadRegistry() - reg.register("ssh", 10) - m = reg.get_prometheus_metrics() - assert m["bulkhead_count"] == 1 - assert "bulkhead_ssh_active" in m - assert "bulkhead_ssh_total" in m - assert "bulkhead_ssh_rejected" in m - assert "bulkhead_ssh_errors" in m - assert "bulkhead_ssh_max_concurrent" in m - def test_empty_registry_metrics(self): reg = BulkheadRegistry() - m = reg.get_prometheus_metrics() - assert m == {"bulkhead_count": 0} + assert reg.get_all_metrics() == {} # ===================================================================== @@ -404,77 +392,6 @@ async def test_browser_bulkhead_wraps_handler(self): -# ===================================================================== -# Prometheus metrics -# ===================================================================== - - -class TestBulkheadPrometheusMetrics: - def test_metrics_rendered(self): - from src.health.metrics import MetricsCollector - reg = BulkheadRegistry() - reg.register("ssh", 10) - reg.register("subprocess", 20) - collector = MetricsCollector() - collector.register_source("bulkheads", reg.get_prometheus_metrics) - output = collector.render() - assert "odin_bulkhead_count" in output - assert "odin_bulkhead_active" in output - assert 'bulkhead="ssh"' in output - assert 'bulkhead="subprocess"' in output - - def test_metrics_absent(self): - from src.health.metrics import MetricsCollector - collector = MetricsCollector() - output = collector.render() - assert "odin_bulkhead" not in output - - def test_metrics_empty_registry(self): - from src.health.metrics import MetricsCollector - reg = BulkheadRegistry() - collector = MetricsCollector() - collector.register_source("bulkheads", reg.get_prometheus_metrics) - output = collector.render() - assert "odin_bulkhead_count 0" in output - - @pytest.mark.asyncio - async def test_metrics_update_after_operations(self): - from src.health.metrics import MetricsCollector - reg = BulkheadRegistry() - bh = reg.register("ssh", 10) - async with bh.acquire(): - pass - async with bh.acquire(): - pass - collector = MetricsCollector() - collector.register_source("bulkheads", reg.get_prometheus_metrics) - output = collector.render() - assert "odin_bulkhead_operations_total" in output - - def test_rejected_metrics(self): - from src.health.metrics import MetricsCollector - reg = BulkheadRegistry() - reg.register("ssh", 10) - collector = MetricsCollector() - collector.register_source("bulkheads", reg.get_prometheus_metrics) - output = collector.render() - assert "odin_bulkhead_rejected_total" in output - - def test_render_does_not_mutate_source_data(self): - """Rendering metrics must not modify the dict returned by the source.""" - from src.health.metrics import MetricsCollector - reg = BulkheadRegistry() - reg.register("ssh", 10) - collector = MetricsCollector() - collector.register_source("bulkheads", reg.get_prometheus_metrics) - data_before = reg.get_prometheus_metrics() - assert "bulkhead_count" in data_before - collector.render() - data_after = reg.get_prometheus_metrics() - assert "bulkhead_count" in data_after - assert data_before == data_after - - # ===================================================================== # REST API # ===================================================================== diff --git a/tests/test_config_schema_validators.py b/tests/test_config_schema_validators.py index b0834de22..b31ce7707 100644 --- a/tests/test_config_schema_validators.py +++ b/tests/test_config_schema_validators.py @@ -120,7 +120,7 @@ def test_removed_settings_are_ignored_without_losing_neighbors( ``false`` was ignored by both runtime construction paths before removal, so dropping it now is behaviour-preserving. Pin adjacent supported values so a future ``extra=forbid`` change cannot strand an upgrade or discard - the settings that actually construct the guard and Grafana handler. + the settings that actually construct the guard. """ old_guard_bool = str(legacy_guard_enabled).lower() old_grafana_bool = str(legacy_grafana_enabled).lower() @@ -147,10 +147,7 @@ def test_removed_settings_are_ignored_without_losing_neighbors( assert not hasattr(cfg.graceful_degradation, "enabled") assert cfg.graceful_degradation.degraded_threshold == 7 assert cfg.graceful_degradation.unavailable_threshold == 19 - assert not hasattr(cfg.grafana_alerts, "enabled") - assert cfg.grafana_alerts.auto_remediate is True - assert cfg.grafana_alerts.cooldown_seconds == 612 - assert cfg.grafana_alerts.max_concurrent_remediations == 4 + assert not hasattr(cfg, "grafana_alerts") def test_real_legacy_image_shape_loads_without_rewrite_or_false_typo_warning( self, tmp_path, caplog diff --git a/tests/test_connection_pools.py b/tests/test_connection_pools.py index 9934f1414..ea3bf1712 100644 --- a/tests/test_connection_pools.py +++ b/tests/test_connection_pools.py @@ -337,23 +337,6 @@ def test_get_metrics_after_activity(self): assert m["active_connections"] == 1 assert m["total_opened"] == 1 - def test_get_prometheus_metrics_structure(self): - with tempfile.TemporaryDirectory() as td: - pool = SSHConnectionPool(socket_dir=td) - m = pool.get_prometheus_metrics() - assert "ssh_pool_active_connections" in m - assert "ssh_pool_total_opened" in m - assert "ssh_pool_total_reused" in m - - def test_get_prometheus_metrics_values(self): - with tempfile.TemporaryDirectory() as td: - pool = SSHConnectionPool(socket_dir=td) - pool._total_opened = 5 - pool._total_reused = 3 - m = pool.get_prometheus_metrics() - assert m["ssh_pool_total_opened"] == 5 - assert m["ssh_pool_total_reused"] == 3 - # --------------------------------------------------------------------------- # run_ssh_command with pool @@ -529,78 +512,6 @@ def test_metrics_tracks_requests(self): assert m["http_pool_total_requests"] == 42 -# --------------------------------------------------------------------------- -# Prometheus metrics rendering -# --------------------------------------------------------------------------- - -class TestSSHPoolPrometheusMetrics: - def test_rendered(self): - from src.health.metrics import MetricsCollector - mc = MetricsCollector() - - def source(): - return { - "ssh_pool_active_connections": 3, - "ssh_pool_total_opened": 10, - "ssh_pool_total_reused": 7, - } - - mc.register_source("ssh_pool", source) - output = mc.render() - assert "odin_ssh_pool_active_connections" in output - assert "odin_ssh_pool_total_opened" in output - assert "odin_ssh_pool_total_reused" in output - - def test_absent(self): - from src.health.metrics import MetricsCollector - mc = MetricsCollector() - output = mc.render() - assert "odin_ssh_pool" not in output - - def test_empty_values(self): - from src.health.metrics import MetricsCollector - mc = MetricsCollector() - mc.register_source("ssh_pool", lambda: { - "ssh_pool_active_connections": 0, - "ssh_pool_total_opened": 0, - "ssh_pool_total_reused": 0, - }) - output = mc.render() - assert "odin_ssh_pool_active_connections 0" in output - - -class TestHTTPPoolPrometheusMetrics: - def test_rendered(self): - from src.health.metrics import MetricsCollector - mc = MetricsCollector() - mc.register_source("http_pool", lambda: { - "http_pool_active_connections": 2, - "http_pool_max_connections": 10, - "http_pool_total_requests": 50, - }) - output = mc.render() - assert "odin_http_pool_active_connections" in output - assert "odin_http_pool_max_connections" in output - assert "odin_http_pool_total_requests" in output - - def test_absent(self): - from src.health.metrics import MetricsCollector - mc = MetricsCollector() - output = mc.render() - assert "odin_http_pool" not in output - - def test_counter_type(self): - from src.health.metrics import MetricsCollector - mc = MetricsCollector() - mc.register_source("http_pool", lambda: { - "http_pool_active_connections": 0, - "http_pool_max_connections": 10, - "http_pool_total_requests": 100, - }) - output = mc.render() - assert "# TYPE odin_http_pool_total_requests counter" in output - - # --------------------------------------------------------------------------- # REST API endpoints # --------------------------------------------------------------------------- diff --git a/tests/test_cost_tracker.py b/tests/test_cost_tracker.py index 538e9c8fb..ccea01b77 100644 --- a/tests/test_cost_tracker.py +++ b/tests/test_cost_tracker.py @@ -1,13 +1,8 @@ -"""Tests for LLM cost tracking — CostTracker, token estimation, Prometheus metrics, API.""" +"""Tests for LLM cost tracking and token estimation.""" from __future__ import annotations import time -from aiohttp.test_utils import TestClient, TestServer - -from src.config.schema import WebhookConfig -from src.health.metrics import MetricsCollector -from src.health.server import HealthServer from src.llm.cost_tracker import CostTracker, UsageRecord, estimate_tokens # --------------------------------------------------------------------------- @@ -166,101 +161,6 @@ def test_summary_empty(self): assert summary["by_user"] == {} -# --------------------------------------------------------------------------- -# CostTracker.get_prometheus_metrics -# --------------------------------------------------------------------------- - -class TestCostTrackerPrometheus: - def test_prometheus_metrics_structure(self): - tracker = CostTracker() - tracker.record(100, 50, user_id="u1", channel_id="c1") - pm = tracker.get_prometheus_metrics() - assert pm["total_input_tokens"] == 100 - assert pm["total_output_tokens"] == 50 - assert pm["total_requests"] == 1 - assert "u1" in pm["by_user"] - assert "c1" in pm["by_channel"] - - def test_prometheus_metrics_empty(self): - tracker = CostTracker() - pm = tracker.get_prometheus_metrics() - assert pm["total_requests"] == 0 - assert pm["by_user"] == {} - - -# --------------------------------------------------------------------------- -# MetricsCollector integration -# --------------------------------------------------------------------------- - -class TestCostMetricsInCollector: - def test_cost_metrics_rendered(self): - mc = MetricsCollector() - tracker = CostTracker() - tracker.record(500, 200, user_id="alice", channel_id="general") - mc.register_source("cost_tracker", tracker.get_prometheus_metrics) - output = mc.render() - assert "odin_llm_input_tokens_total" in output - assert "odin_llm_output_tokens_total" in output - assert "odin_llm_cost_usd_total" in output - assert "odin_llm_requests_total" in output - assert 'odin_llm_user_cost_usd{user="alice"}' in output - assert 'odin_llm_channel_cost_usd{channel="general"}' in output - - def test_cost_metrics_not_rendered_when_no_source(self): - mc = MetricsCollector() - output = mc.render() - assert "odin_llm_input_tokens_total" not in output - - def test_cost_metrics_empty_tracker(self): - mc = MetricsCollector() - tracker = CostTracker() - mc.register_source("cost_tracker", tracker.get_prometheus_metrics) - output = mc.render() - assert "odin_llm_requests_total 0" in output - - def test_cost_metrics_no_user_labels_when_empty(self): - mc = MetricsCollector() - tracker = CostTracker() - mc.register_source("cost_tracker", tracker.get_prometheus_metrics) - output = mc.render() - assert "odin_llm_user_cost_usd" not in output - - def test_cost_source_error_does_not_crash(self): - mc = MetricsCollector() - mc.register_source("cost_tracker", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) - output = mc.render() - assert "odin_up" in output - assert "odin_llm_input_tokens_total" not in output - - -# --------------------------------------------------------------------------- -# /metrics HTTP endpoint with cost data -# --------------------------------------------------------------------------- - -def _make_server(*, ready: bool = True) -> HealthServer: - cfg = WebhookConfig(enabled=False) - server = HealthServer(port=0, webhook_config=cfg) - if ready: - server.set_ready(True) - return server - - -class TestCostMetricsEndpoint: - async def test_cost_metrics_in_endpoint(self): - server = _make_server(ready=True) - tracker = CostTracker() - tracker.record(1000, 500, user_id="test_user", channel_id="test_chan") - server.metrics.register_source("cost_tracker", tracker.get_prometheus_metrics) - async with TestClient(TestServer(server._app)) as client: - resp = await client.get("/metrics") - text = await resp.text() - assert "odin_llm_input_tokens_total 1000" in text - assert "odin_llm_output_tokens_total 500" in text - assert "odin_llm_requests_total 1" in text - assert 'odin_llm_user_cost_usd{user="test_user"}' in text - assert 'odin_llm_channel_cost_usd{channel="test_chan"}' in text - - # --------------------------------------------------------------------------- # LLMResponse token fields # --------------------------------------------------------------------------- diff --git a/tests/test_generated_api_reference.py b/tests/test_generated_api_reference.py index 2063a8a76..4188fb582 100644 --- a/tests/test_generated_api_reference.py +++ b/tests/test_generated_api_reference.py @@ -160,8 +160,8 @@ async def documented(request): assert reference.first_doc_line(documented) == "First line | {{literal}}." -HEALTH = ["/health", "/health/live", "/health/ready", "/metrics"] -WEBHOOKS = [f"/webhook/{name}" for name in ("gitea", "grafana", "generic", "github", "gitlab")] +HEALTH = ["/health", "/health/live", "/health/ready"] +WEBHOOKS = [f"/webhook/{name}" for name in ("gitea", "generic", "github", "gitlab")] UI = ["/", "/ui/{path:.*}", "/ui"] diff --git a/tests/test_grafana_alerts.py b/tests/test_grafana_alerts.py deleted file mode 100644 index d6b82ffaa..000000000 --- a/tests/test_grafana_alerts.py +++ /dev/null @@ -1,1594 +0,0 @@ -"""Tests for Grafana alert parsing, remediation handler, and auto-remediation (Round 19). - -Tests the GrafanaAlertHandler module: alert parsing, rule matching, -remediation spawning, config schema, health server integration, and REST API endpoints. -""" - -from __future__ import annotations - -import time -from unittest.mock import AsyncMock, MagicMock - -import pytest -from aiohttp.test_utils import TestClient, TestServer - -from src.config.schema import ( - Config, - GrafanaAlertConfig, - GrafanaRemediationRuleConfig, - WebhookConfig, -) -from src.health.grafana_alerts import ( - DEFAULT_COOLDOWN_SECONDS, - DEFAULT_REMEDIATION_INTERVAL, - DEFAULT_REMEDIATION_MAX_ITER, - MAX_ALERT_HISTORY, - MAX_ANNOTATION_LEN, - MAX_CONCURRENT_REMEDIATIONS, - MAX_LABEL_VALUE_LEN, - GrafanaAlert, - GrafanaAlertHandler, - RemediationRecord, - RemediationRule, - _make_fingerprint, - build_remediation_prompt, - format_alert_message, - matches_rule, - parse_grafana_payload, -) -from src.health.server import HealthServer - -# --------------------------------------------------------------------------- -# Sample payloads -# --------------------------------------------------------------------------- - -def _firing_alert(name="HighCPU", instance="web-1:9090", severity="critical"): - return { - "status": "firing", - "labels": {"alertname": name, "instance": instance, "severity": severity, "job": "node"}, - "annotations": {"summary": f"{name} on {instance}", "description": "CPU usage is high"}, - "startsAt": "2026-04-15T10:00:00Z", - "endsAt": "0001-01-01T00:00:00Z", - "generatorURL": "http://grafana:3000/alerting/1/edit", - "fingerprint": "abc123", - "silenceURL": "http://grafana:3000/alerting/silence/new?matcher=alertname%3DHighCPU", - "dashboardURL": "http://grafana:3000/d/abc", - "panelURL": "http://grafana:3000/d/abc?viewPanel=1", - "values": {"A": 95.2, "B": 90}, - } - - -def _resolved_alert(name="HighCPU", instance="web-1:9090"): - return { - "status": "resolved", - "labels": {"alertname": name, "instance": instance, "severity": "critical"}, - "annotations": {"summary": f"{name} resolved on {instance}"}, - "startsAt": "2026-04-15T10:00:00Z", - "endsAt": "2026-04-15T10:15:00Z", - "generatorURL": "http://grafana:3000/alerting/1/edit", - "fingerprint": "abc123", - } - - -def _unified_payload(*alerts): - return {"alerts": list(alerts), "status": "firing", "groupLabels": {"alertname": "HighCPU"}} - - -def _legacy_payload(title="Disk Full", message="Disk /dev/sda1 is 95% full"): - return {"title": title, "message": message, "state": "alerting", "ruleUrl": "http://grafana:3000/alerting/1"} - - -# --------------------------------------------------------------------------- -# GrafanaAlertConfig schema -# --------------------------------------------------------------------------- - - -class TestGrafanaAlertConfigDefaults: - def test_defaults(self): - cfg = GrafanaAlertConfig() - assert not hasattr(cfg, "enabled") - assert cfg.auto_remediate is False - assert cfg.rules == [] - assert cfg.cooldown_seconds == 300 - assert cfg.max_concurrent_remediations == 5 - - def test_custom_values(self): - cfg = GrafanaAlertConfig( - auto_remediate=True, - cooldown_seconds=600, - max_concurrent_remediations=3, - ) - assert cfg.auto_remediate is True - assert cfg.cooldown_seconds == 600 - assert cfg.max_concurrent_remediations == 3 - - def test_config_includes_grafana_alerts(self): - cfg = Config(discord={"token": "test"}) - assert hasattr(cfg, "grafana_alerts") - assert isinstance(cfg.grafana_alerts, GrafanaAlertConfig) - assert not hasattr(cfg.grafana_alerts, "enabled") - - def test_config_with_grafana_alerts(self): - cfg = Config( - discord={"token": "test"}, - grafana_alerts={"auto_remediate": True}, - ) - assert cfg.grafana_alerts.auto_remediate is True - - def test_config_with_rules(self): - cfg = Config( - discord={"token": "test"}, - grafana_alerts={ - "rules": [ - { - "id": "cpu_high", - "name_pattern": "HighCPU*", - "remediation_goal": "Check CPU usage", - "mode": "act", - } - ], - }, - ) - assert len(cfg.grafana_alerts.rules) == 1 - assert cfg.grafana_alerts.rules[0].id == "cpu_high" - assert cfg.grafana_alerts.rules[0].name_pattern == "HighCPU*" - assert cfg.grafana_alerts.rules[0].mode == "act" - - -class TestGrafanaRemediationRuleConfig: - def test_defaults(self): - cfg = GrafanaRemediationRuleConfig() - assert cfg.id == "" - assert cfg.name_pattern == "*" - assert cfg.label_matchers == {} - assert cfg.severity_filter == [] - assert cfg.remediation_goal == "" - assert cfg.mode == "notify" - assert cfg.interval_seconds == 30 - assert cfg.max_iterations == 10 - assert cfg.cooldown_seconds == 300 - - def test_custom_values(self): - cfg = GrafanaRemediationRuleConfig( - id="disk_full", - name_pattern="DiskFull*", - label_matchers={"job": "node"}, - severity_filter=["critical", "warning"], - remediation_goal="Clean up disk space", - mode="act", - interval_seconds=60, - max_iterations=5, - cooldown_seconds=600, - ) - assert cfg.id == "disk_full" - assert cfg.name_pattern == "DiskFull*" - assert cfg.label_matchers == {"job": "node"} - - def test_invalid_mode(self): - with pytest.raises(ValueError, match="Invalid mode"): - GrafanaRemediationRuleConfig(mode="invalid") - - def test_valid_modes(self): - for mode in ("notify", "act", "silent"): - cfg = GrafanaRemediationRuleConfig(mode=mode) - assert cfg.mode == mode - - -# --------------------------------------------------------------------------- -# Alert parsing — unified alerting format -# --------------------------------------------------------------------------- - - -class TestParseGrafanaPayloadUnified: - def test_single_firing_alert(self): - payload = _unified_payload(_firing_alert()) - alerts = parse_grafana_payload(payload) - assert len(alerts) == 1 - a = alerts[0] - assert a.alert_name == "HighCPU" - assert a.status == "firing" - assert a.instance == "web-1:9090" - assert a.severity == "critical" - assert a.fingerprint == "abc123" - assert a.summary == "HighCPU on web-1:9090" - assert a.description == "CPU usage is high" - assert a.generator_url == "http://grafana:3000/alerting/1/edit" - assert a.silence_url != "" - assert a.dashboard_url != "" - assert a.panel_url != "" - assert a.values == {"A": 95.2, "B": 90} - - def test_resolved_alert(self): - payload = _unified_payload(_resolved_alert()) - alerts = parse_grafana_payload(payload) - assert len(alerts) == 1 - assert alerts[0].status == "resolved" - assert alerts[0].ends_at == "2026-04-15T10:15:00Z" - - def test_multiple_alerts(self): - payload = _unified_payload( - _firing_alert("HighCPU", "web-1:9090"), - _firing_alert("HighMemory", "web-2:9090", "warning"), - _resolved_alert("DiskFull", "db-1:9090"), - ) - alerts = parse_grafana_payload(payload) - assert len(alerts) == 3 - assert alerts[0].alert_name == "HighCPU" - assert alerts[1].alert_name == "HighMemory" - assert alerts[1].severity == "warning" - assert alerts[2].status == "resolved" - - def test_missing_fields_defaults(self): - payload = {"alerts": [{"labels": {"alertname": "Minimal"}, "status": "firing"}]} - alerts = parse_grafana_payload(payload) - assert len(alerts) == 1 - a = alerts[0] - assert a.alert_name == "Minimal" - assert a.status == "firing" - assert a.instance == "" - assert a.severity == "unknown" - assert a.summary == "" - assert a.starts_at == "" - assert a.generator_url == "" - - def test_empty_alerts_array(self): - payload = {"alerts": []} - alerts = parse_grafana_payload(payload) - assert alerts == [] - - def test_no_alerts_key(self): - payload = {} - alerts = parse_grafana_payload(payload) - assert alerts == [] - - def test_value_string_format(self): - raw = _firing_alert() - raw["values"] = None - raw["valueString"] = "[ var='A' labels={} value=95.2 ]" - payload = _unified_payload(raw) - alerts = parse_grafana_payload(payload) - assert alerts[0].values == {"raw": "[ var='A' labels={} value=95.2 ]"} - - def test_annotation_truncation(self): - raw = _firing_alert() - raw["annotations"]["summary"] = "x" * 2000 - payload = _unified_payload(raw) - alerts = parse_grafana_payload(payload) - assert len(alerts[0].summary) == MAX_ANNOTATION_LEN - - def test_label_value_truncation(self): - raw = _firing_alert() - raw["labels"]["long_label"] = "y" * 1000 - payload = _unified_payload(raw) - alerts = parse_grafana_payload(payload) - assert len(alerts[0].labels["long_label"]) == MAX_LABEL_VALUE_LEN - - def test_alertname_fallback(self): - raw = {"status": "firing", "labels": {}, "alertname": "FallbackName"} - payload = _unified_payload(raw) - alerts = parse_grafana_payload(payload) - assert alerts[0].alert_name == "FallbackName" - - def test_fingerprint_generated_when_missing(self): - raw = {"status": "firing", "labels": {"alertname": "Test"}} - payload = _unified_payload(raw) - alerts = parse_grafana_payload(payload) - assert alerts[0].fingerprint # not empty - assert len(alerts[0].fingerprint) == 16 - - -# --------------------------------------------------------------------------- -# Alert parsing — legacy format -# --------------------------------------------------------------------------- - - -class TestParseGrafanaPayloadLegacy: - def test_legacy_title_message(self): - payload = _legacy_payload() - alerts = parse_grafana_payload(payload) - assert len(alerts) == 1 - a = alerts[0] - assert a.alert_name == "Disk Full" - assert a.summary == "Disk /dev/sda1 is 95% full" - assert a.status == "alerting" - assert a.generator_url == "http://grafana:3000/alerting/1" - - def test_legacy_rulename(self): - payload = {"ruleName": "Memory High", "state": "alerting"} - alerts = parse_grafana_payload(payload) - assert len(alerts) == 1 - assert alerts[0].alert_name == "Memory High" - assert alerts[0].status == "alerting" - - def test_legacy_empty_payload(self): - payload = {"title": "", "message": ""} - alerts = parse_grafana_payload(payload) - assert alerts == [] - - def test_legacy_no_message_uses_state(self): - payload = {"title": "Alert", "state": "ok"} - alerts = parse_grafana_payload(payload) - assert len(alerts) == 1 - assert alerts[0].status == "ok" - assert alerts[0].summary == "ok" - - -# --------------------------------------------------------------------------- -# Alert formatting -# --------------------------------------------------------------------------- - - -class TestFormatAlertMessage: - def test_single_firing(self): - alerts = parse_grafana_payload(_unified_payload(_firing_alert())) - msg = format_alert_message(alerts) - assert "**Grafana Alert**:" in msg - assert "HighCPU" in msg - assert "firing" in msg - assert "web-1:9090" in msg - assert "critical" in msg - - def test_multiple_alerts(self): - alerts = parse_grafana_payload(_unified_payload( - _firing_alert("A"), _firing_alert("B"), - )) - msg = format_alert_message(alerts) - assert "2 alert(s)" in msg - assert "**A**" in msg - assert "**B**" in msg - - def test_resolved_shows_green(self): - alerts = parse_grafana_payload(_unified_payload(_resolved_alert())) - msg = format_alert_message(alerts) - assert "\U0001f7e2" in msg - - def test_firing_shows_red(self): - alerts = parse_grafana_payload(_unified_payload(_firing_alert())) - msg = format_alert_message(alerts) - assert "\U0001f534" in msg - - def test_empty_alerts(self): - msg = format_alert_message([]) - assert "no alerts" in msg - - def test_values_shown(self): - alerts = parse_grafana_payload(_unified_payload(_firing_alert())) - msg = format_alert_message(alerts) - assert "Values:" in msg - assert "A=95.2" in msg - - def test_summary_shown(self): - alerts = parse_grafana_payload(_unified_payload(_firing_alert())) - msg = format_alert_message(alerts) - assert "HighCPU on web-1:9090" in msg - - -# --------------------------------------------------------------------------- -# Fingerprint generation -# --------------------------------------------------------------------------- - - -class TestMakeFingerprint: - def test_deterministic(self): - fp1 = _make_fingerprint("Test", {"a": "1", "b": "2"}) - fp2 = _make_fingerprint("Test", {"a": "1", "b": "2"}) - assert fp1 == fp2 - - def test_different_names(self): - fp1 = _make_fingerprint("A", {}) - fp2 = _make_fingerprint("B", {}) - assert fp1 != fp2 - - def test_different_labels(self): - fp1 = _make_fingerprint("Test", {"a": "1"}) - fp2 = _make_fingerprint("Test", {"a": "2"}) - assert fp1 != fp2 - - def test_length(self): - fp = _make_fingerprint("Alert", {"x": "y"}) - assert len(fp) == 16 - - -# --------------------------------------------------------------------------- -# Rule matching -# --------------------------------------------------------------------------- - - -class TestMatchesRule: - def _alert(self, name="HighCPU", severity="critical", labels=None): - return GrafanaAlert( - fingerprint="fp1", status="firing", alert_name=name, - labels={"severity": severity, **(labels or {})}, - annotations={}, starts_at="", ends_at="", generator_url="", - silence_url="", dashboard_url="", panel_url="", - values={}, severity=severity, instance="", summary="", description="", - ) - - def test_exact_name_match(self): - rule = RemediationRule(id="r1", name_pattern="HighCPU") - assert matches_rule(self._alert("HighCPU"), rule) is True - assert matches_rule(self._alert("LowCPU"), rule) is False - - def test_wildcard_name(self): - rule = RemediationRule(id="r1", name_pattern="High*") - assert matches_rule(self._alert("HighCPU"), rule) is True - assert matches_rule(self._alert("HighMemory"), rule) is True - assert matches_rule(self._alert("LowCPU"), rule) is False - - def test_catch_all(self): - rule = RemediationRule(id="r1", name_pattern="*") - assert matches_rule(self._alert("Anything"), rule) is True - - def test_severity_filter(self): - rule = RemediationRule(id="r1", name_pattern="*", severity_filter=["critical"]) - assert matches_rule(self._alert(severity="critical"), rule) is True - assert matches_rule(self._alert(severity="warning"), rule) is False - - def test_severity_filter_multiple(self): - rule = RemediationRule(id="r1", name_pattern="*", severity_filter=["critical", "warning"]) - assert matches_rule(self._alert(severity="critical"), rule) is True - assert matches_rule(self._alert(severity="warning"), rule) is True - assert matches_rule(self._alert(severity="info"), rule) is False - - def test_label_matchers(self): - rule = RemediationRule(id="r1", name_pattern="*", label_matchers={"job": "node"}) - assert matches_rule(self._alert(labels={"job": "node"}), rule) is True - assert matches_rule(self._alert(labels={"job": "prometheus"}), rule) is False - - def test_label_matchers_wildcard(self): - rule = RemediationRule(id="r1", name_pattern="*", label_matchers={"instance": "web-*"}) - assert matches_rule(self._alert(labels={"instance": "web-1"}), rule) is True - assert matches_rule(self._alert(labels={"instance": "db-1"}), rule) is False - - def test_label_matchers_missing_label(self): - rule = RemediationRule(id="r1", name_pattern="*", label_matchers={"missing": "val"}) - assert matches_rule(self._alert(), rule) is False - - def test_disabled_rule(self): - rule = RemediationRule(id="r1", name_pattern="*", enabled=False) - assert matches_rule(self._alert(), rule) is False - - def test_combined_filters(self): - rule = RemediationRule( - id="r1", name_pattern="High*", - severity_filter=["critical"], - label_matchers={"job": "node"}, - ) - assert matches_rule(self._alert("HighCPU", "critical", {"job": "node"}), rule) is True - assert matches_rule(self._alert("HighCPU", "warning", {"job": "node"}), rule) is False - assert matches_rule(self._alert("HighCPU", "critical", {"job": "prom"}), rule) is False - assert matches_rule(self._alert("LowCPU", "critical", {"job": "node"}), rule) is False - - -# --------------------------------------------------------------------------- -# Remediation prompt building -# --------------------------------------------------------------------------- - - -class TestBuildRemediationPrompt: - def _alert(self, **kwargs): - defaults = dict( - fingerprint="fp1", status="firing", alert_name="HighCPU", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={"A": 95.2}, severity="critical", instance="web-1:9090", - summary="CPU is high", description="", - ) - defaults.update(kwargs) - return GrafanaAlert(**defaults) - - def test_includes_alert_info(self): - prompt = build_remediation_prompt( - self._alert(), RemediationRule(id="r1", name_pattern="*"), - ) - assert "HighCPU" in prompt - assert "firing" in prompt - assert "web-1:9090" in prompt - assert "critical" in prompt - assert "CPU is high" in prompt - - def test_custom_goal(self): - rule = RemediationRule( - id="r1", name_pattern="*", remediation_goal="Restart the service", - ) - prompt = build_remediation_prompt(self._alert(), rule) - assert "Restart the service" in prompt - - def test_default_goal(self): - rule = RemediationRule(id="r1", name_pattern="*", remediation_goal="") - prompt = build_remediation_prompt(self._alert(), rule) - assert "Investigate and remediate" in prompt - - def test_includes_values(self): - prompt = build_remediation_prompt( - self._alert(), RemediationRule(id="r1", name_pattern="*"), - ) - assert "A=95.2" in prompt - - def test_stop_condition(self): - prompt = build_remediation_prompt( - self._alert(), RemediationRule(id="r1", name_pattern="*"), - ) - assert "Stop condition" in prompt - - def test_description_fallback(self): - alert = self._alert(summary="", description="Detailed description") - prompt = build_remediation_prompt( - alert, RemediationRule(id="r1", name_pattern="*"), - ) - assert "Detailed description" in prompt - - -# --------------------------------------------------------------------------- -# GrafanaAlertHandler — init and rules -# --------------------------------------------------------------------------- - - -class TestGrafanaAlertHandlerInit: - def test_defaults(self): - handler = GrafanaAlertHandler() - assert handler.rules == [] - assert handler.alert_history == [] - assert handler.active_remediations == {} - status = handler.get_status() - assert status["auto_remediate"] is False - assert status["rules_count"] == 0 - - def test_with_rules(self): - rules = [ - RemediationRule(id="r1", name_pattern="High*"), - RemediationRule(id="r2", name_pattern="Low*"), - ] - handler = GrafanaAlertHandler(rules=rules, auto_remediate=True) - assert len(handler.rules) == 2 - assert handler.get_status()["auto_remediate"] is True - - def test_custom_params(self): - handler = GrafanaAlertHandler( - cooldown_seconds=600, max_concurrent=3, - ) - status = handler.get_status() - assert status["cooldown_seconds"] == 600 - assert status["max_concurrent"] == 3 - - -class TestGrafanaAlertHandlerRules: - def test_add_rule(self): - handler = GrafanaAlertHandler() - handler.add_rule(RemediationRule(id="r1", name_pattern="*")) - assert len(handler.rules) == 1 - assert handler.rules[0].id == "r1" - - def test_add_duplicate_rule(self): - handler = GrafanaAlertHandler() - handler.add_rule(RemediationRule(id="r1", name_pattern="*")) - with pytest.raises(ValueError, match="already exists"): - handler.add_rule(RemediationRule(id="r1", name_pattern="*")) - - def test_remove_rule(self): - handler = GrafanaAlertHandler() - handler.add_rule(RemediationRule(id="r1", name_pattern="*")) - assert handler.remove_rule("r1") is True - assert handler.rules == [] - - def test_remove_nonexistent_rule(self): - handler = GrafanaAlertHandler() - assert handler.remove_rule("nope") is False - - def test_get_rule(self): - handler = GrafanaAlertHandler() - handler.add_rule(RemediationRule(id="r1", name_pattern="Test*")) - rule = handler.get_rule("r1") - assert rule is not None - assert rule.name_pattern == "Test*" - - def test_get_rule_not_found(self): - handler = GrafanaAlertHandler() - assert handler.get_rule("nope") is None - - def test_get_rules_list(self): - handler = GrafanaAlertHandler() - handler.add_rule(RemediationRule(id="r1", name_pattern="A*", mode="act")) - rules = handler.get_rules_list() - assert len(rules) == 1 - assert rules[0]["id"] == "r1" - assert rules[0]["name_pattern"] == "A*" - assert rules[0]["mode"] == "act" - - -# --------------------------------------------------------------------------- -# GrafanaAlertHandler — process_alerts -# --------------------------------------------------------------------------- - - -class TestProcessAlerts: - def _handler(self, **kwargs): - defaults = dict(auto_remediate=True, cooldown_seconds=0) - defaults.update(kwargs) - return GrafanaAlertHandler(**defaults) - - def _firing(self, name="HighCPU", fp="fp1"): - return GrafanaAlert( - fingerprint=fp, status="firing", alert_name=name, - labels={"severity": "critical"}, annotations={}, - starts_at="", ends_at="", generator_url="", - silence_url="", dashboard_url="", panel_url="", - values={}, severity="critical", instance="web-1", - summary="Alert", description="", - ) - - def _resolved(self, fp="fp1"): - return GrafanaAlert( - fingerprint=fp, status="resolved", alert_name="HighCPU", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="critical", instance="", summary="", description="", - ) - - def test_firing_matches_rule(self): - handler = self._handler() - handler.add_rule(RemediationRule(id="r1", name_pattern="High*")) - matches = handler.process_alerts([self._firing()]) - assert len(matches) == 1 - assert matches[0][0].alert_name == "HighCPU" - assert matches[0][1].id == "r1" - - def test_resolved_no_match(self): - handler = self._handler() - handler.add_rule(RemediationRule(id="r1", name_pattern="*")) - matches = handler.process_alerts([self._resolved()]) - assert matches == [] - - def test_no_auto_remediate(self): - handler = GrafanaAlertHandler(auto_remediate=False) - handler.add_rule(RemediationRule(id="r1", name_pattern="*")) - matches = handler.process_alerts([self._firing()]) - assert matches == [] - - def test_no_matching_rule(self): - handler = self._handler() - handler.add_rule(RemediationRule(id="r1", name_pattern="DiskFull")) - matches = handler.process_alerts([self._firing("HighCPU")]) - assert matches == [] - - def test_cooldown_prevents_duplicate(self): - handler = self._handler(cooldown_seconds=300) - handler.add_rule(RemediationRule(id="r1", name_pattern="*", cooldown_seconds=300)) - matches1 = handler.process_alerts([self._firing()]) - assert len(matches1) == 1 - matches2 = handler.process_alerts([self._firing()]) - assert matches2 == [] - - def test_max_concurrent_limit(self): - handler = self._handler(max_concurrent=1) - handler.add_rule(RemediationRule(id="r1", name_pattern="*")) - handler.record_remediation(self._firing(fp="fp1"), handler.rules[0], "loop1") - matches = handler.process_alerts([self._firing("HighMem", "fp2")]) - assert matches == [] - - def test_first_matching_rule_wins(self): - handler = self._handler() - handler.add_rule(RemediationRule(id="r1", name_pattern="High*", remediation_goal="A")) - handler.add_rule(RemediationRule(id="r2", name_pattern="*", remediation_goal="B")) - matches = handler.process_alerts([self._firing()]) - assert len(matches) == 1 - assert matches[0][1].id == "r1" - - def test_alert_history_recorded(self): - handler = self._handler() - handler.process_alerts([self._firing(), self._resolved()]) - assert len(handler.alert_history) == 2 - assert handler.alert_history[0]["alert_name"] == "HighCPU" - assert handler.alert_history[0]["status"] == "firing" - assert handler.alert_history[1]["status"] == "resolved" - - def test_stats_updated(self): - handler = self._handler() - handler.add_rule(RemediationRule(id="r1", name_pattern="*")) - handler.process_alerts([self._firing()]) - status = handler.get_status() - assert status["alerts_received"] == 1 - assert status["rules_matched"] == 1 - - -# --------------------------------------------------------------------------- -# Remediation tracking -# --------------------------------------------------------------------------- - - -class TestRemediationTracking: - def _handler(self): - return GrafanaAlertHandler(auto_remediate=True, cooldown_seconds=0) - - def _alert(self, fp="fp1"): - return GrafanaAlert( - fingerprint=fp, status="firing", alert_name="Test", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="critical", instance="", summary="", description="", - ) - - def test_record_remediation(self): - handler = self._handler() - rule = RemediationRule(id="r1", name_pattern="*") - handler.record_remediation(self._alert(), rule, "loop1") - rems = handler.get_remediations_list() - assert len(rems) == 1 - assert rems[0]["loop_id"] == "loop1" - assert rems[0]["rule_id"] == "r1" - assert rems[0]["status"] == "running" - - def test_update_remediation_status(self): - handler = self._handler() - rule = RemediationRule(id="r1", name_pattern="*") - handler.record_remediation(self._alert(), rule, "loop1") - handler.update_remediation_status("loop1", "completed") - rems = handler.get_remediations_list() - assert rems[0]["status"] == "completed" - - def test_update_nonexistent(self): - handler = self._handler() - handler.update_remediation_status("nope", "error") # no-op, no crash - - def test_resolved_marks_remediation_completed(self): - handler = self._handler() - rule = RemediationRule(id="r1", name_pattern="*") - handler.record_remediation(self._alert(fp="fp1"), rule, "loop1") - resolved = GrafanaAlert( - fingerprint="fp1", status="resolved", alert_name="Test", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="", instance="", summary="", description="", - ) - handler.process_alerts([resolved]) - rems = handler.get_remediations_list() - assert rems[0]["status"] == "completed" - - def test_cleanup_old_remediations(self): - handler = self._handler() - rule = RemediationRule(id="r1", name_pattern="*") - handler.record_remediation(self._alert(), rule, "loop1") - handler.update_remediation_status("loop1", "completed") - handler._remediations["loop1"].started_at = time.monotonic() - 7200 - removed = handler.cleanup_old_remediations() - assert removed == 1 - assert handler.get_remediations_list() == [] - - def test_cleanup_skips_running(self): - handler = self._handler() - rule = RemediationRule(id="r1", name_pattern="*") - handler.record_remediation(self._alert(), rule, "loop1") - handler._remediations["loop1"].started_at = time.monotonic() - 7200 - removed = handler.cleanup_old_remediations() - assert removed == 0 - - def test_stats_remediations_spawned(self): - handler = self._handler() - rule = RemediationRule(id="r1", name_pattern="*") - handler.record_remediation(self._alert(), rule, "loop1") - assert handler.get_status()["remediations_spawned"] == 1 - assert handler.get_status()["active_remediations"] == 1 - - -# --------------------------------------------------------------------------- -# RemediationRule dataclass -# --------------------------------------------------------------------------- - - -class TestRemediationRule: - def test_defaults(self): - rule = RemediationRule(id="r1", name_pattern="*") - assert rule.enabled is True - assert rule.mode == "notify" - assert rule.interval_seconds == DEFAULT_REMEDIATION_INTERVAL - assert rule.max_iterations == DEFAULT_REMEDIATION_MAX_ITER - assert rule.cooldown_seconds == DEFAULT_COOLDOWN_SECONDS - - def test_custom(self): - rule = RemediationRule( - id="custom", name_pattern="DiskFull*", - label_matchers={"env": "prod"}, - severity_filter=["critical"], - remediation_goal="Cleanup", - mode="act", - interval_seconds=60, - max_iterations=5, - cooldown_seconds=600, - enabled=False, - ) - assert rule.id == "custom" - assert rule.label_matchers == {"env": "prod"} - assert rule.enabled is False - - -# --------------------------------------------------------------------------- -# GrafanaAlert dataclass -# --------------------------------------------------------------------------- - - -class TestGrafanaAlertDataclass: - def test_fields(self): - alert = GrafanaAlert( - fingerprint="fp", status="firing", alert_name="Test", - labels={"a": "b"}, annotations={"c": "d"}, - starts_at="2026-01-01", ends_at="", generator_url="http://g", - silence_url="http://s", dashboard_url="http://d", panel_url="http://p", - values={"x": 1}, severity="critical", instance="host:9090", - summary="summary", description="desc", - ) - assert alert.fingerprint == "fp" - assert alert.labels == {"a": "b"} - assert alert.severity == "critical" - assert alert.received_at > 0 - - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - - -class TestConstants: - def test_max_alert_history(self): - assert MAX_ALERT_HISTORY == 200 - - def test_max_concurrent_remediations(self): - assert MAX_CONCURRENT_REMEDIATIONS == 5 - - def test_default_cooldown(self): - assert DEFAULT_COOLDOWN_SECONDS == 300 - - def test_default_interval(self): - assert DEFAULT_REMEDIATION_INTERVAL == 30 - - def test_default_max_iter(self): - assert DEFAULT_REMEDIATION_MAX_ITER == 10 - - def test_max_annotation_len(self): - assert MAX_ANNOTATION_LEN == 1000 - - def test_max_label_value_len(self): - assert MAX_LABEL_VALUE_LEN == 500 - - -# --------------------------------------------------------------------------- -# Health server Grafana integration -# --------------------------------------------------------------------------- - - -# PR #18: grafana webhook now fails closed when no secret is -# configured. The integration tests default to a non-empty test secret -# and pass the corresponding header on each POST. The explicit -# auth-required test overrides this and verifies both paths. -_TEST_WEBHOOK_SECRET = "test-webhook-secret" -_TEST_WEBHOOK_HEADERS = {"X-Webhook-Secret": _TEST_WEBHOOK_SECRET} - - -def _make_grafana_server( - *, rules=None, auto_remediate=False, webhook_secret=_TEST_WEBHOOK_SECRET, -): - wh_cfg = WebhookConfig(enabled=True, secret=webhook_secret, grafana_channel_id="chan1") - ga_cfg = GrafanaAlertConfig( - auto_remediate=auto_remediate, - rules=[ - GrafanaRemediationRuleConfig( - id=r["id"], name_pattern=r["name_pattern"], - remediation_goal=r.get("remediation_goal", ""), - mode=r.get("mode", "notify"), - ) - for r in (rules or []) - ], - ) - server = HealthServer( - port=0, webhook_config=wh_cfg, grafana_alert_config=ga_cfg, - ) - server.set_ready(True) - return server - - -class TestHealthServerGrafanaIntegration: - @pytest.mark.parametrize("legacy_enabled", [False, True]) - def test_removed_switch_never_changes_handler_construction(self, legacy_enabled): - """Both historical values construct the same fully configured handler.""" - cfg = GrafanaAlertConfig.model_validate({ - "enabled": legacy_enabled, - "auto_remediate": True, - "cooldown_seconds": 612, - "max_concurrent_remediations": 4, - "rules": [{ - "id": "legacy-rule", - "name_pattern": "High*", - "remediation_goal": "Inspect load", - "mode": "act", - }], - }) - - server = HealthServer( - port=0, - webhook_config=WebhookConfig(enabled=False), - grafana_alert_config=cfg, - ) - status = server.grafana_handler.get_status() - - assert not hasattr(cfg, "enabled") - assert status["auto_remediate"] is True - assert status["cooldown_seconds"] == 612 - assert status["max_concurrent"] == 4 - assert [rule.id for rule in server.grafana_handler.rules] == ["legacy-rule"] - - async def test_grafana_handler_created(self): - server = _make_grafana_server() - assert server.grafana_handler is not None - - async def test_grafana_handler_has_rules_from_config(self): - server = _make_grafana_server( - rules=[{"id": "r1", "name_pattern": "High*"}], - auto_remediate=True, - ) - assert len(server.grafana_handler.rules) == 1 - assert server.grafana_handler.rules[0].id == "r1" - - async def test_webhook_parses_unified_alerts(self): - server = _make_grafana_server() - sent = [] - server.set_send_message(AsyncMock(side_effect=lambda ch, txt: sent.append(txt))) - - payload = _unified_payload(_firing_alert()) - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", - json=payload, - headers=_TEST_WEBHOOK_HEADERS, - ) - assert resp.status == 200 - assert len(sent) == 1 - assert "HighCPU" in sent[0] - assert "firing" in sent[0] - assert "web-1:9090" in sent[0] - - async def test_webhook_parses_legacy_alerts(self): - server = _make_grafana_server() - sent = [] - server.set_send_message(AsyncMock(side_effect=lambda ch, txt: sent.append(txt))) - - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", - json=_legacy_payload(), - headers=_TEST_WEBHOOK_HEADERS, - ) - assert resp.status == 200 - assert len(sent) == 1 - assert "Disk Full" in sent[0] - - async def test_webhook_spawns_remediation(self): - server = _make_grafana_server( - rules=[{"id": "r1", "name_pattern": "High*", "remediation_goal": "Fix it"}], - auto_remediate=True, - ) - sent = [] - server.set_send_message(AsyncMock(side_effect=lambda ch, txt: sent.append(txt))) - loop_callback = AsyncMock(return_value="loop123") - server.set_loop_spawn_callback(loop_callback) - - payload = _unified_payload(_firing_alert()) - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", json=payload, - headers=_TEST_WEBHOOK_HEADERS, - ) - assert resp.status == 200 - - loop_callback.assert_called_once() - assert "loop123" in sent[0] - assert server.grafana_handler.get_status()["remediations_spawned"] == 1 - - async def test_webhook_no_spawn_without_callback(self): - server = _make_grafana_server( - rules=[{"id": "r1", "name_pattern": "*"}], - auto_remediate=True, - ) - sent = [] - server.set_send_message(AsyncMock(side_effect=lambda ch, txt: sent.append(txt))) - - payload = _unified_payload(_firing_alert()) - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", json=payload, - headers=_TEST_WEBHOOK_HEADERS, - ) - assert resp.status == 200 - assert "remediation" not in sent[0].lower() - - async def test_webhook_spawn_error_doesnt_crash(self): - server = _make_grafana_server( - rules=[{"id": "r1", "name_pattern": "*"}], - auto_remediate=True, - ) - server.set_send_message(AsyncMock()) - server.set_loop_spawn_callback(AsyncMock(side_effect=RuntimeError("boom"))) - - payload = _unified_payload(_firing_alert()) - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", json=payload, - headers=_TEST_WEBHOOK_HEADERS, - ) - assert resp.status == 200 - - async def test_webhook_auth_required(self): - server = _make_grafana_server(webhook_secret="mysecret") - server.set_send_message(AsyncMock()) - - payload = _unified_payload(_firing_alert()) - async with TestClient(TestServer(server._app)) as client: - resp = await client.post("/webhook/grafana", json=payload) - assert resp.status == 403 - - resp = await client.post( - "/webhook/grafana", json=payload, - headers={"X-Webhook-Secret": "mysecret"}, - ) - assert resp.status == 200 - - async def test_webhook_invalid_json(self): - server = _make_grafana_server() - server.set_send_message(AsyncMock()) - - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", - data=b"not json", - headers={ - "Content-Type": "application/json", - **_TEST_WEBHOOK_HEADERS, - }, - ) - assert resp.status == 400 - - async def test_webhook_resolved_no_remediation(self): - server = _make_grafana_server( - rules=[{"id": "r1", "name_pattern": "*"}], - auto_remediate=True, - ) - sent = [] - server.set_send_message(AsyncMock(side_effect=lambda ch, txt: sent.append(txt))) - loop_callback = AsyncMock(return_value="loop123") - server.set_loop_spawn_callback(loop_callback) - - payload = _unified_payload(_resolved_alert()) - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", json=payload, - headers=_TEST_WEBHOOK_HEADERS, - ) - assert resp.status == 200 - loop_callback.assert_not_called() - - async def test_webhook_trigger_callback_called(self): - server = _make_grafana_server() - server.set_send_message(AsyncMock()) - trigger_cb = AsyncMock(return_value=0) - server.set_trigger_callback(trigger_cb) - - payload = _unified_payload(_firing_alert()) - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", json=payload, - headers=_TEST_WEBHOOK_HEADERS, - ) - assert resp.status == 200 - trigger_cb.assert_called_once() - call_args = trigger_cb.call_args - assert call_args[0][0] == "grafana" - event_data = call_args[0][1] - assert event_data["event"] == "alert" - assert event_data["alert_name"] == "HighCPU" - assert event_data["firing_count"] == 1 - - async def test_webhook_enriched_event_data(self): - server = _make_grafana_server() - server.set_send_message(AsyncMock()) - trigger_cb = AsyncMock(return_value=0) - server.set_trigger_callback(trigger_cb) - - payload = _unified_payload( - _firing_alert(), _resolved_alert("Other"), - ) - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", json=payload, - headers=_TEST_WEBHOOK_HEADERS, - ) - assert resp.status == 200 - event_data = trigger_cb.call_args[0][1] - assert event_data["alert_count"] == 2 - assert event_data["firing_count"] == 1 - assert event_data["resolved_count"] == 1 - assert event_data["severity"] == "critical" - - async def test_webhook_event_data_lists_every_alert_name(self): - server = _make_grafana_server() - server.set_send_message(AsyncMock()) - trigger_cb = AsyncMock(return_value=0) - server.set_trigger_callback(trigger_cb) - - payload = _unified_payload( - _firing_alert("FirstAlert"), _resolved_alert("SecondAlert"), - ) - async with TestClient(TestServer(server._app)) as client: - resp = await client.post( - "/webhook/grafana", json=payload, - headers=_TEST_WEBHOOK_HEADERS, - ) - assert resp.status == 200 - event_data = trigger_cb.call_args[0][1] - assert event_data["alert_name"] == "FirstAlert" - assert event_data["alert_names"] == ["FirstAlert", "SecondAlert"] - assert event_data["severity"] == "critical" - - -# --------------------------------------------------------------------------- -# REST API endpoints -# --------------------------------------------------------------------------- - - -def _make_api_server_with_handler(): - """Create a minimal server with a mock bot for API testing.""" - from aiohttp import web as aio_web - - handler = GrafanaAlertHandler( - auto_remediate=True, cooldown_seconds=0, - ) - handler.add_rule(RemediationRule(id="r1", name_pattern="Test*")) - - bot = MagicMock() - hs = MagicMock() - hs.grafana_handler = handler - bot.health_server = hs - - app = aio_web.Application() - routes = aio_web.RouteTableDef() - - @routes.get("/api/grafana-alerts/status") - async def status(_req): - h = getattr(bot, "health_server", None) - gh = getattr(h, "grafana_handler", None) if h else None - if gh is None: - return aio_web.json_response({"enabled": False}) - return aio_web.json_response({"enabled": True, **gh.get_status()}) - - @routes.get("/api/grafana-alerts/history") - async def history(req): - h = getattr(bot, "health_server", None) - gh = getattr(h, "grafana_handler", None) if h else None - if gh is None: - return aio_web.json_response({"error": "not available"}, status=503) - limit = min(int(req.query.get("limit", "50")), 200) - hist = gh.alert_history[-limit:] - return aio_web.json_response({"alerts": hist, "total": len(gh.alert_history)}) - - @routes.get("/api/grafana-alerts/rules") - async def rules_list(_req): - h = getattr(bot, "health_server", None) - gh = getattr(h, "grafana_handler", None) if h else None - if gh is None: - return aio_web.json_response({"error": "not available"}, status=503) - return aio_web.json_response({"rules": gh.get_rules_list()}) - - @routes.post("/api/grafana-alerts/rules") - async def add_rule(req): - h = getattr(bot, "health_server", None) - gh = getattr(h, "grafana_handler", None) if h else None - if gh is None: - return aio_web.json_response({"error": "not available"}, status=503) - data = await req.json() - rule_id = data.get("id", "") - name_pattern = data.get("name_pattern", "") - if not rule_id or not name_pattern: - return aio_web.json_response({"error": "id and name_pattern required"}, status=400) - try: - rule = RemediationRule(id=rule_id, name_pattern=name_pattern, - remediation_goal=data.get("remediation_goal", "")) - gh.add_rule(rule) - return aio_web.json_response({"ok": True, "rule": rule_id}, status=201) - except ValueError as e: - return aio_web.json_response({"error": str(e)}, status=400) - - @routes.delete("/api/grafana-alerts/rules/{rule_id}") - async def delete_rule(req): - h = getattr(bot, "health_server", None) - gh = getattr(h, "grafana_handler", None) if h else None - if gh is None: - return aio_web.json_response({"error": "not available"}, status=503) - rule_id = req.match_info["rule_id"] - if gh.remove_rule(rule_id): - return aio_web.json_response({"ok": True}) - return aio_web.json_response({"error": "not found"}, status=404) - - @routes.get("/api/grafana-alerts/remediations") - async def remediations(_req): - h = getattr(bot, "health_server", None) - gh = getattr(h, "grafana_handler", None) if h else None - if gh is None: - return aio_web.json_response({"error": "not available"}, status=503) - return aio_web.json_response({"remediations": gh.get_remediations_list()}) - - app.router.add_routes(routes) - return app, handler - - -class TestGrafanaAlertsAPIStatus: - async def test_status_enabled(self): - app, handler = _make_api_server_with_handler() - async with TestClient(TestServer(app)) as client: - resp = await client.get("/api/grafana-alerts/status") - assert resp.status == 200 - data = await resp.json() - assert data["enabled"] is True - assert data["rules_count"] == 1 - assert data["auto_remediate"] is True - - -class TestGrafanaAlertsAPIHistory: - async def test_empty_history(self): - app, handler = _make_api_server_with_handler() - async with TestClient(TestServer(app)) as client: - resp = await client.get("/api/grafana-alerts/history") - assert resp.status == 200 - data = await resp.json() - assert data["alerts"] == [] - assert data["total"] == 0 - - async def test_history_with_alerts(self): - app, handler = _make_api_server_with_handler() - alert = GrafanaAlert( - fingerprint="fp", status="firing", alert_name="Test", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="critical", instance="host", - summary="summary", description="", - ) - handler.process_alerts([alert]) - async with TestClient(TestServer(app)) as client: - resp = await client.get("/api/grafana-alerts/history") - data = await resp.json() - assert data["total"] == 1 - assert data["alerts"][0]["alert_name"] == "Test" - - async def test_history_limit(self): - app, handler = _make_api_server_with_handler() - for i in range(10): - alert = GrafanaAlert( - fingerprint=f"fp{i}", status="firing", alert_name=f"Alert{i}", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="", instance="", summary="", description="", - ) - handler.process_alerts([alert]) - async with TestClient(TestServer(app)) as client: - resp = await client.get("/api/grafana-alerts/history?limit=3") - data = await resp.json() - assert len(data["alerts"]) == 3 - assert data["total"] == 10 - - -class TestGrafanaAlertsAPIRules: - async def test_list_rules(self): - app, handler = _make_api_server_with_handler() - async with TestClient(TestServer(app)) as client: - resp = await client.get("/api/grafana-alerts/rules") - assert resp.status == 200 - data = await resp.json() - assert len(data["rules"]) == 1 - assert data["rules"][0]["id"] == "r1" - - async def test_add_rule(self): - app, handler = _make_api_server_with_handler() - async with TestClient(TestServer(app)) as client: - resp = await client.post( - "/api/grafana-alerts/rules", - json={"id": "r2", "name_pattern": "Disk*", "remediation_goal": "Clean disk"}, - ) - assert resp.status == 201 - data = await resp.json() - assert data["ok"] is True - assert len(handler.rules) == 2 - - async def test_add_rule_missing_fields(self): - app, handler = _make_api_server_with_handler() - async with TestClient(TestServer(app)) as client: - resp = await client.post( - "/api/grafana-alerts/rules", - json={"id": "r2"}, - ) - assert resp.status == 400 - - async def test_add_duplicate_rule(self): - app, handler = _make_api_server_with_handler() - async with TestClient(TestServer(app)) as client: - resp = await client.post( - "/api/grafana-alerts/rules", - json={"id": "r1", "name_pattern": "dup"}, - ) - assert resp.status == 400 - - async def test_delete_rule(self): - app, handler = _make_api_server_with_handler() - async with TestClient(TestServer(app)) as client: - resp = await client.delete("/api/grafana-alerts/rules/r1") - assert resp.status == 200 - data = await resp.json() - assert data["ok"] is True - assert handler.rules == [] - - async def test_delete_nonexistent_rule(self): - app, handler = _make_api_server_with_handler() - async with TestClient(TestServer(app)) as client: - resp = await client.delete("/api/grafana-alerts/rules/nope") - assert resp.status == 404 - - -class TestGrafanaAlertsAPIRemediations: - async def test_empty_remediations(self): - app, handler = _make_api_server_with_handler() - async with TestClient(TestServer(app)) as client: - resp = await client.get("/api/grafana-alerts/remediations") - assert resp.status == 200 - data = await resp.json() - assert data["remediations"] == [] - - async def test_remediations_with_data(self): - app, handler = _make_api_server_with_handler() - alert = GrafanaAlert( - fingerprint="fp", status="firing", alert_name="Test", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="", instance="", summary="", description="", - ) - handler.record_remediation(alert, handler.rules[0], "loop1") - async with TestClient(TestServer(app)) as client: - resp = await client.get("/api/grafana-alerts/remediations") - data = await resp.json() - assert len(data["remediations"]) == 1 - assert data["remediations"][0]["loop_id"] == "loop1" - - -# --------------------------------------------------------------------------- -# Edge cases -# --------------------------------------------------------------------------- - - -class TestEdgeCases: - def test_alert_history_bounded(self): - handler = GrafanaAlertHandler() - for i in range(MAX_ALERT_HISTORY + 50): - alert = GrafanaAlert( - fingerprint=f"fp{i}", status="firing", alert_name=f"A{i}", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="", instance="", summary="", description="", - ) - handler.process_alerts([alert]) - assert len(handler.alert_history) <= MAX_ALERT_HISTORY - - def test_parse_no_labels_key(self): - payload = {"alerts": [{"status": "firing"}]} - alerts = parse_grafana_payload(payload) - assert len(alerts) == 1 - assert alerts[0].alert_name == "Unknown" - assert alerts[0].labels == {} - - def test_parse_null_values(self): - raw = _firing_alert() - raw["values"] = None - raw.pop("valueString", None) - payload = _unified_payload(raw) - alerts = parse_grafana_payload(payload) - assert alerts[0].values == {} - - def test_format_no_severity(self): - alerts = [GrafanaAlert( - fingerprint="fp", status="firing", alert_name="Test", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="unknown", instance="", summary="", description="", - )] - msg = format_alert_message(alerts) - assert "[unknown]" not in msg # unknown severity not shown - - def test_format_description_fallback(self): - alerts = [GrafanaAlert( - fingerprint="fp", status="firing", alert_name="Test", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="", instance="", summary="", - description="Fallback description", - )] - msg = format_alert_message(alerts) - assert "Fallback description" in msg - - def test_handler_rules_list_returns_copy(self): - handler = GrafanaAlertHandler() - handler.add_rule(RemediationRule(id="r1", name_pattern="*")) - rules = handler.rules - rules.clear() - assert len(handler.rules) == 1 - - def test_health_server_no_grafana_config(self): - server = HealthServer(port=0, webhook_config=WebhookConfig(enabled=False)) - assert server.grafana_handler is not None - assert server.grafana_handler.get_status()["auto_remediate"] is False - - def test_loop_spawn_callback_setter(self): - server = _make_grafana_server() - cb = AsyncMock() - server.set_loop_spawn_callback(cb) - assert server._loop_spawn_callback is cb - - def test_remediation_record_dataclass(self): - rec = RemediationRecord( - alert_fingerprint="fp", - alert_name="Test", - rule_id="r1", - loop_id="loop1", - started_at=time.monotonic(), - ) - assert rec.status == "running" - assert rec.loop_id == "loop1" - - def test_parse_many_alerts_truncated(self): - raw_alerts = [_firing_alert(f"Alert{i}", f"host{i}:9090") for i in range(20)] - payload = {"alerts": raw_alerts} - alerts = parse_grafana_payload(payload) - assert len(alerts) == 20 - msg = format_alert_message(alerts) - assert "20 alert(s)" in msg - - def test_cooldown_key_per_rule(self): - handler = GrafanaAlertHandler(auto_remediate=True, cooldown_seconds=300) - handler.add_rule(RemediationRule(id="r1", name_pattern="A*", cooldown_seconds=300)) - handler.add_rule(RemediationRule(id="r2", name_pattern="A*", cooldown_seconds=300)) - alert = GrafanaAlert( - fingerprint="fp", status="firing", alert_name="Alert", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="", instance="", summary="", description="", - ) - matches = handler.process_alerts([alert]) - assert len(matches) == 1 - assert matches[0][1].id == "r1" - - def test_loop_spawn_error_return_skipped(self): - """Error string from loop spawn should not record remediation.""" - server = _make_grafana_server( - rules=[{"id": "r1", "name_pattern": "*"}], - auto_remediate=True, - ) - server.set_send_message(AsyncMock()) - server.set_loop_spawn_callback(AsyncMock(return_value="Error: max loops reached")) - # We can't easily call the webhook in a sync test, but this validates - # the error string check logic indirectly - assert server._loop_spawn_callback is not None - - -# --------------------------------------------------------------------------- -# Module imports -# --------------------------------------------------------------------------- - - -class TestRound20CooldownCleanup: - """Round 20 REVIEWER: verify cooldown entries are cleaned up.""" - - def _make_handler(self, **kwargs): - defaults = {"auto_remediate": True, "cooldown_seconds": 300} - defaults.update(kwargs) - return GrafanaAlertHandler(**defaults) - - def _make_rule(self, **kwargs): - defaults = { - "id": "r1", "name_pattern": "*", "enabled": True, - "cooldown_seconds": 300, "severity_filter": [], - "label_matchers": {}, "remediation_goal": "fix it", - "mode": "act", "interval_seconds": 30, "max_iterations": 10, - } - defaults.update(kwargs) - return RemediationRule(**defaults) - - def test_cleanup_removes_stale_cooldowns(self): - handler = self._make_handler() - rule = self._make_rule() - handler.add_rule(rule) - handler._cooldowns["fp1::r1"] = time.monotonic() - 700 - handler._cooldowns["fp2::r1"] = time.monotonic() - 10 - handler.cleanup_old_remediations() - assert "fp1::r1" not in handler._cooldowns - assert "fp2::r1" in handler._cooldowns - - def test_cleanup_keeps_fresh_cooldowns(self): - handler = self._make_handler() - rule = self._make_rule() - handler.add_rule(rule) - handler._cooldowns["fp1::r1"] = time.monotonic() - 5 - handler.cleanup_old_remediations() - assert "fp1::r1" in handler._cooldowns - - def test_cleanup_removes_both_remediations_and_cooldowns(self): - handler = self._make_handler() - rule = self._make_rule() - handler.add_rule(rule) - handler._cooldowns["fp1::r1"] = time.monotonic() - 700 - handler._remediations["loop1"] = RemediationRecord( - alert_fingerprint="fp1", - alert_name="test", - rule_id="r1", - loop_id="loop1", - started_at=time.monotonic() - 7200, - status="completed", - ) - removed = handler.cleanup_old_remediations() - assert removed >= 2 - assert "fp1::r1" not in handler._cooldowns - assert "loop1" not in handler._remediations - - def test_cleanup_no_rules_uses_default_cooldown(self): - handler = self._make_handler(cooldown_seconds=60) - handler._cooldowns["key1"] = time.monotonic() - 200 - handler._cooldowns["key2"] = time.monotonic() - 5 - handler.cleanup_old_remediations() - assert "key1" not in handler._cooldowns - assert "key2" in handler._cooldowns - - -class TestModuleImports: - def test_grafana_alerts_module(self): - from src.health import grafana_alerts - assert hasattr(grafana_alerts, "GrafanaAlertHandler") - assert hasattr(grafana_alerts, "parse_grafana_payload") - - def test_config_has_grafana_alert_config(self): - from src.config.schema import GrafanaAlertConfig, GrafanaRemediationRuleConfig - assert GrafanaAlertConfig is not None - assert GrafanaRemediationRuleConfig is not None - - -class TestFreshBootClock: - """Regression: a `0` cooldown sentinel on the monotonic clock read as - "on cooldown since boot" — every alert was silently dropped for the - first cooldown window after a host reboot. Caught by the first CI run - on a fresh runner (uptime < cooldown), invisible on long-uptime dev - machines (RFC-003 P0).""" - - def test_never_remediated_alert_matches_on_fresh_boot(self, monkeypatch): - import time as _time - - monkeypatch.setattr(_time, "monotonic", lambda: 10.0) # 10s uptime - handler = GrafanaAlertHandler(auto_remediate=True, cooldown_seconds=300) - handler.add_rule(RemediationRule(id="r1", name_pattern="High*", cooldown_seconds=300)) - alert = GrafanaAlert( - fingerprint="fb1", status="firing", alert_name="HighCPU", - labels={"severity": "critical"}, annotations={}, starts_at="", - ends_at="", generator_url="", silence_url="", dashboard_url="", - panel_url="", values={}, severity="critical", instance="", - summary="", description="", - ) - matches = handler.process_alerts([alert]) - assert len(matches) == 1, "fresh-boot clock must not fake a cooldown" - - def test_real_cooldown_still_enforced_on_fresh_boot(self, monkeypatch): - import time as _time - - clock = {"now": 10.0} - monkeypatch.setattr(_time, "monotonic", lambda: clock["now"]) - handler = GrafanaAlertHandler(auto_remediate=True, cooldown_seconds=300) - handler.add_rule(RemediationRule(id="r1", name_pattern="*", cooldown_seconds=300)) - handler._cooldowns["fb2:r1"] = 5.0 # remediated 5s after boot - alert = GrafanaAlert( - fingerprint="fb2", status="firing", alert_name="Anything", - labels={}, annotations={}, starts_at="", ends_at="", - generator_url="", silence_url="", dashboard_url="", panel_url="", - values={}, severity="unknown", instance="", summary="", - description="", - ) - assert handler.process_alerts([alert]) == [] # genuinely on cooldown - clock["now"] = 320.0 # past the window - assert len(handler.process_alerts([alert])) == 1 diff --git a/tests/test_health_shutdown.py b/tests/test_health_shutdown.py index f105fc2d6..74870df12 100644 --- a/tests/test_health_shutdown.py +++ b/tests/test_health_shutdown.py @@ -208,82 +208,6 @@ async def __call__(self): class TestStopIsolation: - async def test_start_owns_the_grafana_cleanup_sweep(self): - """L6: the stale-record sweep is actually scheduled by the lifecycle.""" - server = _bare_server() - await server.start() - try: - task = server._grafana_cleanup_task - assert task is not None and not task.done() - finally: - await server.stop() - assert server._grafana_cleanup_task is None - - async def test_start_is_idempotent_for_the_cleanup_sweep(self): - """A repeated start() must not leak a second sweep task.""" - server = _bare_server() - await server.start() - first = server._grafana_cleanup_task - server._start_grafana_cleanup() - try: - assert server._grafana_cleanup_task is first - finally: - await server.stop() - - async def test_stop_cancels_and_awaits_the_cleanup_sweep(self): - """A shutdown must not leave the sweep running behind it.""" - server = _bare_server() - await server.start() - task = server._grafana_cleanup_task - assert task is not None - await server.stop() - assert task.done() - - async def test_cleanup_sweep_prunes_stale_records(self, monkeypatch): - """The sweep calls the handler's cleanup on its interval.""" - server = _bare_server() - pruned: list[int] = [] - - def cleanup(): - pruned.append(1) - return 2 - - server.grafana_handler.cleanup_old_remediations = cleanup - # Drive the loop body directly rather than waiting a real interval. - monkeypatch.setattr("src.health.server.REMEDIATION_CLEANUP_INTERVAL_SECONDS", 0) - server._start_grafana_cleanup() - try: - for _ in range(50): - if pruned: - break - await asyncio.sleep(0.01) - finally: - await server.stop() - assert pruned # the production caller now exists - - async def test_cleanup_sweep_survives_a_handler_fault(self, monkeypatch): - """A raising handler must not kill the sweep loop.""" - server = _bare_server() - attempts: list[int] = [] - - def cleanup(): - attempts.append(1) - raise RuntimeError("handler boom") - - server.grafana_handler.cleanup_old_remediations = cleanup - monkeypatch.setattr("src.health.server.REMEDIATION_CLEANUP_INTERVAL_SECONDS", 0) - server._start_grafana_cleanup() - try: - for _ in range(60): - if len(attempts) >= 3: - break - await asyncio.sleep(0.01) - task = server._grafana_cleanup_task - assert task is not None and not task.done() - finally: - await server.stop() - assert len(attempts) >= 3 - async def test_runner_gets_bounded_shutdown_timeout(self): server = _bare_server() await server.start() diff --git a/tests/test_local_workspace.py b/tests/test_local_workspace.py index 2bf42551c..0e8445d61 100644 --- a/tests/test_local_workspace.py +++ b/tests/test_local_workspace.py @@ -828,50 +828,6 @@ def test_workspace_metrics_never_raise_on_an_invalid_workspace( assert executor.get_workspace_metrics() == {} -def test_workspace_gauges_render_for_prometheus(workspace: Path, fake_install: Path) -> None: - from src.health.metrics import MetricsCollector - - executor = _executor_with_workspace(workspace, fake_install) - (workspace / "f").write_bytes(b"12345") - _metrics_after_refresh(executor) # let the background usage walk land - collector = MetricsCollector() - collector.register_source("workspace", executor.get_workspace_metrics) - rendered = collector.render() - for name in ( - "odin_workspace_bytes", - "odin_workspace_files", - "odin_workspace_free_bytes", - "odin_workspace_free_inodes", - ): - assert f"# TYPE {name} gauge" in rendered - assert any(line.startswith(f"{name} ") for line in rendered.splitlines()), ( - f"{name} value line missing" - ) - - -def test_workspace_gauges_tolerate_partial_and_failing_sources() -> None: - """A partial dict renders what it has; a raising source is swallowed so the - metrics endpoint cannot be taken down by workspace trouble.""" - from src.health.metrics import MetricsCollector - - partial = MetricsCollector() - partial.register_source("workspace", lambda: {"bytes": 10.0}) - rendered = partial.render() - assert "odin_workspace_bytes 10" in rendered - assert "odin_workspace_free_inodes" not in rendered - - def _boom() -> dict[str, float]: - raise OSError("filesystem unavailable") - - failing = MetricsCollector() - failing.register_source("workspace", _boom) - assert "odin_workspace_bytes" not in failing.render() - - empty = MetricsCollector() - empty.register_source("workspace", dict) - assert "odin_workspace_bytes" not in empty.render() - - def test_blank_configured_data_paths_are_skipped(tmp_path: Path, workspace: Path) -> None: """A blank/whitespace data path contributes no protected root rather than protecting the process's current directory by accident.""" diff --git a/tests/test_metrics.py b/tests/test_metrics.py deleted file mode 100644 index 6611ef3e8..000000000 --- a/tests/test_metrics.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Tests for Prometheus metrics endpoint and MetricsCollector. - -Covers: -- /metrics endpoint returns text/plain Prometheus format -- Process info metrics (odin_up, odin_start_time_seconds, odin_uptime_seconds) -- Tool execution metrics (calls, errors, timeouts per tool) -- Component health metrics -- Circuit breaker metrics -- Session count metrics -- Scheduler count metrics -- Autonomous loop count metrics -- Label escaping -- Error resilience (broken sources don't crash) -- MetricsCollector in isolation -""" - -from __future__ import annotations - -from aiohttp.test_utils import TestClient, TestServer - -from src.config.schema import WebhookConfig -from src.health.metrics import MetricsCollector, _escape_label_value, _format_metric -from src.health.server import HealthServer - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _make_server(*, ready: bool = True) -> HealthServer: - cfg = WebhookConfig(enabled=False) - server = HealthServer(port=0, webhook_config=cfg) - if ready: - server.set_ready(True) - return server - - -# --------------------------------------------------------------------------- -# MetricsCollector unit tests -# --------------------------------------------------------------------------- - -class TestMetricsCollector: - def test_render_minimal(self): - mc = MetricsCollector() - output = mc.render() - assert "odin_up 0" in output - assert "odin_start_time_seconds" in output - assert "odin_uptime_seconds" in output - - def test_render_ready(self): - mc = MetricsCollector() - mc.set_ready(True) - output = mc.render() - assert "odin_up 1" in output - - def test_render_not_ready(self): - mc = MetricsCollector() - mc.set_ready(False) - output = mc.render() - assert "odin_up 0" in output - - def test_tool_metrics(self): - mc = MetricsCollector() - mc.register_source("tools", lambda: { - "run_command": {"calls": 10, "errors": 2, "timeouts": 1}, - "read_file": {"calls": 5, "errors": 0, "timeouts": 0}, - }) - output = mc.render() - assert 'odin_tool_calls_total{tool="run_command"} 10' in output - assert 'odin_tool_errors_total{tool="run_command"} 2' in output - assert 'odin_tool_timeouts_total{tool="run_command"} 1' in output - assert 'odin_tool_calls_total{tool="read_file"} 5' in output - assert 'odin_tool_errors_total{tool="read_file"} 0' in output - - def test_tool_metrics_empty(self): - mc = MetricsCollector() - mc.register_source("tools", lambda: {}) - output = mc.render() - # No tool metrics when empty - assert "odin_tool_calls_total" not in output - - def test_component_health_metrics(self): - mc = MetricsCollector() - mc.set_component_check(lambda: { - "database": {"healthy": True, "detail": "connected"}, - "llm": {"healthy": False, "detail": "circuit open"}, - }) - output = mc.render() - assert 'odin_component_healthy{component="database"} 1' in output - assert 'odin_component_healthy{component="llm"} 0' in output - - def test_component_health_empty(self): - mc = MetricsCollector() - mc.set_component_check(lambda: {}) - output = mc.render() - assert "odin_component_healthy" not in output - - def test_circuit_breaker_metrics(self): - class FakeCB: - state = "closed" - _failure_count = 0 - - mc = MetricsCollector() - mc.register_source("circuit_breaker", lambda: FakeCB()) - output = mc.render() - assert "odin_circuit_breaker_state 0" in output - assert "odin_circuit_breaker_failures 0" in output - - def test_circuit_breaker_open(self): - class FakeCB: - state = "open" - _failure_count = 5 - - mc = MetricsCollector() - mc.register_source("circuit_breaker", lambda: FakeCB()) - output = mc.render() - assert "odin_circuit_breaker_state 2" in output - assert "odin_circuit_breaker_failures 5" in output - - def test_circuit_breaker_half_open(self): - class FakeCB: - state = "half_open" - _failure_count = 3 - - mc = MetricsCollector() - mc.register_source("circuit_breaker", lambda: FakeCB()) - output = mc.render() - assert "odin_circuit_breaker_state 1" in output - - def test_session_count(self): - mc = MetricsCollector() - mc.register_source("sessions", lambda: 7) - output = mc.render() - assert "odin_active_sessions 7" in output - - def test_scheduler_count(self): - mc = MetricsCollector() - mc.register_source("scheduler", lambda: 12) - output = mc.render() - assert "odin_schedules_total 12" in output - - def test_loop_count(self): - mc = MetricsCollector() - mc.register_source("loops", lambda: 3) - output = mc.render() - assert "odin_active_loops 3" in output - - def test_broken_source_does_not_crash(self): - mc = MetricsCollector() - mc.register_source("tools", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) - mc.register_source("sessions", lambda: 5) - output = mc.render() - # Tool metrics absent but sessions still present - assert "odin_tool_calls_total" not in output - assert "odin_active_sessions 5" in output - - def test_broken_component_check_does_not_crash(self): - mc = MetricsCollector() - - def bad_check(): - raise RuntimeError("check failed") - - mc.set_component_check(bad_check) - output = mc.render() - # Should still render process info - assert "odin_up" in output - assert "odin_component_healthy" not in output - - def test_help_and_type_headers(self): - mc = MetricsCollector() - mc.set_ready(True) - output = mc.render() - assert "# HELP odin_up" in output - assert "# TYPE odin_up gauge" in output - assert "# HELP odin_uptime_seconds" in output - - def test_ends_with_newline(self): - mc = MetricsCollector() - output = mc.render() - assert output.endswith("\n") - - def test_uptime_is_positive(self): - mc = MetricsCollector() - output = mc.render() - for line in output.split("\n"): - if line.startswith("odin_uptime_seconds "): - val = float(line.split()[-1]) - assert val >= 0 - - -# --------------------------------------------------------------------------- -# Label escaping -# --------------------------------------------------------------------------- - -class TestLabelEscaping: - def test_escape_backslash(self): - assert _escape_label_value("a\\b") == "a\\\\b" - - def test_escape_double_quote(self): - assert _escape_label_value('a"b') == 'a\\"b' - - def test_escape_newline(self): - assert _escape_label_value("a\nb") == "a\\nb" - - def test_escape_combined(self): - assert _escape_label_value('a\\"\n') == 'a\\\\\\"\\n' - - def test_no_escape_needed(self): - assert _escape_label_value("run_command") == "run_command" - - -# --------------------------------------------------------------------------- -# _format_metric -# --------------------------------------------------------------------------- - -class TestFormatMetric: - def test_simple_gauge(self): - result = _format_metric("test_metric", 42, help_text="A test") - assert "# HELP test_metric A test" in result - assert "# TYPE test_metric gauge" in result - assert "test_metric 42" in result - - def test_counter_with_labels(self): - result = _format_metric( - "test_total", 10, - metric_type="counter", - labels={"method": "GET", "status": "200"}, - ) - assert "# TYPE test_total counter" in result - assert 'test_total{method="GET",status="200"} 10' in result - - def test_no_header(self): - result = _format_metric("test_val", 5, include_header=False) - assert "# HELP" not in result - assert "# TYPE" not in result - assert "test_val 5" in result - - def test_labels_sorted(self): - result = _format_metric( - "m", 1, - labels={"z_label": "z", "a_label": "a"}, - include_header=False, - ) - assert 'a_label="a",z_label="z"' in result - - -# --------------------------------------------------------------------------- -# /metrics HTTP endpoint -# --------------------------------------------------------------------------- - -class TestMetricsEndpoint: - async def test_metrics_returns_200(self): - server = _make_server(ready=True) - async with TestClient(TestServer(server._app)) as client: - resp = await client.get("/metrics") - assert resp.status == 200 - text = await resp.text() - assert "odin_up 1" in text - - async def test_metrics_content_type(self): - server = _make_server(ready=True) - async with TestClient(TestServer(server._app)) as client: - resp = await client.get("/metrics") - ct = resp.headers.get("Content-Type", "") - assert "text/plain" in ct - - async def test_metrics_when_not_ready(self): - server = _make_server(ready=False) - async with TestClient(TestServer(server._app)) as client: - resp = await client.get("/metrics") - assert resp.status == 200 # /metrics always returns 200 - text = await resp.text() - assert "odin_up 0" in text - - async def test_metrics_with_components(self): - server = _make_server(ready=True) - server.register_component("db", lambda: (True, "ok")) - server.register_component("cache", lambda: (False, "down")) - async with TestClient(TestServer(server._app)) as client: - resp = await client.get("/metrics") - text = await resp.text() - assert 'odin_component_healthy{component="db"} 1' in text - assert 'odin_component_healthy{component="cache"} 0' in text - - async def test_metrics_with_tool_source(self): - server = _make_server(ready=True) - server.metrics.register_source("tools", lambda: { - "run_command": {"calls": 5, "errors": 1, "timeouts": 0}, - }) - async with TestClient(TestServer(server._app)) as client: - resp = await client.get("/metrics") - text = await resp.text() - assert 'odin_tool_calls_total{tool="run_command"} 5' in text - assert 'odin_tool_errors_total{tool="run_command"} 1' in text - - async def test_metrics_with_session_source(self): - server = _make_server(ready=True) - async with TestClient(TestServer(server._app)) as client: - resp = await client.get("/metrics") - text = await resp.text() - # Default sessions source returns 0 - assert "odin_active_sessions 0" in text - - async def test_metrics_accessible_without_auth(self): - """Verify /metrics is in the auth-skip list and accessible without token.""" - from src.health.server import _AUTH_SKIP_PREFIXES - assert any("/metrics".startswith(p) for p in _AUTH_SKIP_PREFIXES) - - async def test_metrics_includes_all_sections(self): - server = _make_server(ready=True) - server.register_component("test_comp", lambda: (True, "ok")) - server.metrics.register_source("tools", lambda: { - "web_search": {"calls": 3, "errors": 0, "timeouts": 0}, - }) - server.metrics.register_source("scheduler", lambda: 4) - server.metrics.register_source("loops", lambda: 2) - async with TestClient(TestServer(server._app)) as client: - resp = await client.get("/metrics") - text = await resp.text() - assert "odin_up 1" in text - assert "odin_start_time_seconds" in text - assert "odin_uptime_seconds" in text - assert "odin_component_healthy" in text - assert "odin_tool_calls_total" in text - assert "odin_schedules_total 4" in text - assert "odin_active_loops 2" in text - assert "odin_active_sessions 0" in text - - -# --------------------------------------------------------------------------- -# HealthServer.metrics property -# --------------------------------------------------------------------------- - -class TestHealthServerMetricsProperty: - def test_metrics_property_returns_collector(self): - server = _make_server() - assert isinstance(server.metrics, MetricsCollector) - - def test_set_ready_syncs_to_collector(self): - server = _make_server(ready=False) - output = server.metrics.render() - assert "odin_up 0" in output - server.set_ready(True) - output = server.metrics.render() - assert "odin_up 1" in output - - def test_register_component_syncs_to_collector(self): - server = _make_server(ready=True) - server.register_component("my_comp", lambda: (True, "fine")) - output = server.metrics.render() - assert 'odin_component_healthy{component="my_comp"} 1' in output diff --git a/tests/test_native_monitoring_removal.py b/tests/test_native_monitoring_removal.py new file mode 100644 index 000000000..4ef6435c4 --- /dev/null +++ b/tests/test_native_monitoring_removal.py @@ -0,0 +1,49 @@ +"""Native monitoring removal preserves upgrades and component health.""" +from __future__ import annotations + +from aiohttp.test_utils import TestClient, TestServer + +from src.config.schema import Config, WebConfig, WebhookConfig +from src.health.server import AUTH_PUBLIC_PREFIXES, HealthServer +from src.tools.defs.media_scheduling import TOOLS_SECTION as TOOLS +from src.tools.skill_context import SkillContext + + +def test_removed_settings_are_inert_and_neighbors_survive(): + config = Config.model_validate({ + "discord": {"token": "fixture"}, + "grafana_alerts": {"auto_remediate": True, "rules": [{"id": "old"}]}, + "webhook": {"grafana_channel_id": "old", "enabled": True, "channel_id": "kept"}, + }) + assert not hasattr(config, "grafana_alerts") + assert not hasattr(config.webhook, "grafana_channel_id") + assert config.webhook.enabled + assert config.webhook.channel_id == "kept" + assert "grafana_alerts" not in config.model_dump() + + +def test_scheduling_catalog_no_longer_advertises_removed_trigger(): + for tool in TOOLS: + if tool["name"] in {"schedule_task", "update_schedule"}: + trigger = tool["input_schema"]["properties"]["trigger"]["properties"] + assert "grafana" not in trigger["source"]["enum"] + assert "alert_name" not in trigger + assert not hasattr(SkillContext, "query_prometheus") + assert "/metrics" not in AUTH_PUBLIC_PREFIXES + + +async def test_removed_routes_absent_but_component_health_remains(): + server = HealthServer( + webhook_config=WebhookConfig(enabled=True), web_config=WebConfig(enabled=False), + ) + server.set_ready() + server.register_component("fixture", lambda: (True, "healthy")) + assert not hasattr(server, "metrics") + async with TestClient(TestServer(server._app)) as client: + for path in ("/metrics", "/api/grafana-alerts/status"): + assert (await client.get(path)).status == 404 + assert (await client.post("/webhook/grafana", json={})).status == 404 + response = await client.get("/health/ready") + assert response.status == 200 + body = await response.json() + assert body["components"]["fixture"] == {"healthy": True, "detail": "healthy"} diff --git a/tests/test_self_audit_fixes.py b/tests/test_self_audit_fixes.py index 5f30b3802..b311fa2a7 100644 --- a/tests/test_self_audit_fixes.py +++ b/tests/test_self_audit_fixes.py @@ -11,11 +11,11 @@ import pytest # ==================================================================== -# Finding #1 — Grafana webhook fail-close +# Finding #1 — Shared-secret webhook fail-close # ==================================================================== -class TestGrafanaWebhookFailClose: - """_webhook_grafana and _webhook_generic used to accept +class TestSharedSecretWebhookFailClose: + """_webhook_generic used to accept unauthenticated POSTs whenever the shared secret was empty. _verify_shared_secret now fails closed.""" diff --git a/tests/test_skill_context.py b/tests/test_skill_context.py index 99d5fa862..a0855036f 100644 --- a/tests/test_skill_context.py +++ b/tests/test_skill_context.py @@ -106,7 +106,7 @@ def test_save_memory_no_path_is_noop(self): # --------------------------------------------------------------------------- # -# host / prometheus / file +# host / file # --------------------------------------------------------------------------- # class TestHostAndFile: async def test_run_on_host_uses_executor_with_requester(self, tmp_path): @@ -116,12 +116,6 @@ async def test_run_on_host_uses_executor_with_requester(self, tmp_path): "run_command", {"host": "srv", "command": "uname"}, user_id=None ) - async def test_query_prometheus(self, tmp_path): - c = _ctx(tmp_path) - assert "curl" not in await c.query_prometheus("up") # returns execute result - c._executor.config = SimpleNamespace(hosts={}) - assert "No hosts" in await c.query_prometheus("up") - async def test_read_file(self, tmp_path): c = _ctx(tmp_path) assert await c.read_file( diff --git a/tests/test_smoke_run_bot.py b/tests/test_smoke_run_bot.py index 85a24847b..6bd2c6573 100644 --- a/tests/test_smoke_run_bot.py +++ b/tests/test_smoke_run_bot.py @@ -114,7 +114,6 @@ def test_config_has_all_round_fields(self): # Phase 8 assert hasattr(cfg, "web") # Phase 9 - assert hasattr(cfg, "grafana_alerts") # Phase 10 assert hasattr(cfg, "outbound_webhooks") assert hasattr(cfg, "graceful_degradation") @@ -187,10 +186,6 @@ def test_mcp_client(self): assert MCPServerConnection is not None - def test_grafana_alerts(self): - from src.health.grafana_alerts import GrafanaAlertHandler - assert GrafanaAlertHandler is not None - # --------------------------------------------------------------------------- # Phase 5 — Memory & knowledge @@ -475,7 +470,6 @@ class TestImportSweep: "src.scheduler.scheduler", "src.health.startup", "src.health.subsystem_guard", - "src.health.grafana_alerts", "src.notifications.outbound_webhooks", "src.permissions.manager", "src.monitoring.resource_usage", diff --git a/tests/test_token_budget.py b/tests/test_token_budget.py index 0d4537365..813b61879 100644 --- a/tests/test_token_budget.py +++ b/tests/test_token_budget.py @@ -21,7 +21,6 @@ import pytest from src.config.schema import SessionsConfig -from src.health.metrics import MetricsCollector from src.llm.cost_tracker import estimate_tokens from src.sessions.manager import ( COMPACTION_THRESHOLD, @@ -284,43 +283,6 @@ def test_over_budget_count(self, tmp_path): assert metrics["over_budget_count"] == 1 -# --------------------------------------------------------------------------- -# Prometheus metrics rendering -# --------------------------------------------------------------------------- - -class TestSessionTokenPrometheusMetrics: - def test_session_token_metrics_rendered(self, tmp_path): - mgr = _make_manager(tmp_path) - mgr.add_message("ch1", "user", "a" * 400) - collector = MetricsCollector() - collector.register_source("session_tokens", mgr.get_token_metrics) - output = collector.render() - assert "odin_session_tokens_total" in output - assert "odin_session_token_budget" in output - assert "odin_sessions_over_budget" in output - assert 'channel="ch1"' in output - - def test_session_token_metrics_absent_when_no_source(self): - collector = MetricsCollector() - output = collector.render() - assert "odin_session_tokens_total" not in output - - def test_session_token_metrics_empty_sessions(self, tmp_path): - mgr = _make_manager(tmp_path) - collector = MetricsCollector() - collector.register_source("session_tokens", mgr.get_token_metrics) - output = collector.render() - assert "odin_session_tokens_total 0" in output - - def test_over_budget_metric_value(self, tmp_path): - mgr = _make_manager(tmp_path, token_budget=10) - mgr.add_message("ch1", "user", "a" * 400) - collector = MetricsCollector() - collector.register_source("session_tokens", mgr.get_token_metrics) - output = collector.render() - assert "odin_sessions_over_budget 1" in output - - # --------------------------------------------------------------------------- # Config: SessionsConfig.token_budget # --------------------------------------------------------------------------- diff --git a/tests/test_trajectories.py b/tests/test_trajectories.py index 6abe11740..8e699f7af 100644 --- a/tests/test_trajectories.py +++ b/tests/test_trajectories.py @@ -9,9 +9,6 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer -from src.config.schema import WebhookConfig -from src.health.metrics import MetricsCollector -from src.health.server import HealthServer from src.trajectories.saver import ( DEFAULT_TRAJECTORY_DIR, ToolIteration, @@ -529,48 +526,6 @@ async def test_search_combined_filters(self, saver_with_data): assert len(results) == 2 -# --------------------------------------------------------------------------- -# Prometheus metrics -# --------------------------------------------------------------------------- - -class TestTrajectoryPrometheusMetrics: - def test_get_prometheus_metrics(self): - saver = TrajectorySaver.__new__(TrajectorySaver) - saver._count = 42 - metrics = saver.get_prometheus_metrics() - assert metrics == {"trajectories_saved_total": 42} - - def test_metrics_rendered(self): - mc = MetricsCollector() - mc.register_source("trajectories", lambda: {"trajectories_saved_total": 10}) - rendered = mc.render() - assert "odin_trajectories_saved_total" in rendered - assert "10" in rendered - - def test_metrics_absent(self): - mc = MetricsCollector() - rendered = mc.render() - assert "odin_trajectories_saved_total" not in rendered - - def test_metrics_zero(self): - mc = MetricsCollector() - mc.register_source("trajectories", lambda: {"trajectories_saved_total": 0}) - rendered = mc.render() - assert "odin_trajectories_saved_total" in rendered - - async def test_metrics_in_endpoint(self): - cfg = WebhookConfig(enabled=False) - server = HealthServer(port=0, webhook_config=cfg) - server.set_ready(True) - saver = TrajectorySaver.__new__(TrajectorySaver) - saver._count = 7 - server.metrics.register_source("trajectories", saver.get_prometheus_metrics) - async with TestClient(TestServer(server._app)) as client: - resp = await client.get("/metrics") - text = await resp.text() - assert "odin_trajectories_saved_total 7" in text - - # --------------------------------------------------------------------------- # REST API endpoints # --------------------------------------------------------------------------- diff --git a/tests/test_web_api_config_admin.py b/tests/test_web_api_config_admin.py index 6c78aecf3..464ffc27e 100644 --- a/tests/test_web_api_config_admin.py +++ b/tests/test_web_api_config_admin.py @@ -495,8 +495,7 @@ async def test_update_config_persists_normalized_dropping_removed_keys(self, _ac assert "max_system_prompt_tokens" not in saved.get("context", {}) assert "enabled" not in saved["graceful_degradation"] assert saved["graceful_degradation"]["degraded_threshold"] == 7 - assert "enabled" not in saved["grafana_alerts"] - assert saved["grafana_alerts"]["cooldown_seconds"] == 612 + assert "grafana_alerts" not in saved @pytest.mark.asyncio async def test_context_budget_alias_persists_canonical_key_through_restart( diff --git a/tests/test_web_api_integrations.py b/tests/test_web_api_integrations.py index 26c415eee..eface3575 100644 --- a/tests/test_web_api_integrations.py +++ b/tests/test_web_api_integrations.py @@ -1,14 +1,13 @@ """Route coverage for web/api/integrations.py (RFC-006 P4-continuation, CONT-1). Per Odin's advisory: fake the remote services hard. These tests validate request -parsing, validation, and delegation/response shaping for MCP / Grafana -alerts / outbound webhooks — never the network. Each service +parsing, validation, and delegation/response shaping for MCP and +outbound webhooks — never the network. Each service is a faked object; the "disabled" path is simply the attribute being absent. """ from __future__ import annotations -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -19,7 +18,6 @@ from src.config.schema import load_config, set_active_config_path from src.notifications.outbound_webhooks import OutboundWebhookDispatcher from src.web.api.integrations import ( - register_grafana_alerts, register_mcp_servers, register_outbound_webhooks, ) @@ -106,69 +104,6 @@ async def test_enabled_requires_boolean(self): assert (await c.post("/api/mcp/enabled", json={"enabled": "yes"})).status == 400 -# --------------------------------------------------------------------------- # -# Grafana alerts -# --------------------------------------------------------------------------- # -class TestGrafanaAlerts: - def _handler(self): - h = MagicMock() - h.get_status.return_value = {"rules": 2} - h.alert_history = [{"n": 1}, {"n": 2}, {"n": 3}] - h.get_rules_list.return_value = [{"id": "r1"}] - h.get_remediations_list.return_value = [{"id": "rem1"}] - h.remove_rule.return_value = True - return h - - async def test_status_and_disabled(self): - async with TestClient(TestServer(_app(register_grafana_alerts, bot=_bot()))) as c: - assert (await (await c.get("/api/grafana-alerts/status")).json())["enabled"] is False - assert (await c.get("/api/grafana-alerts/history")).status == 503 - assert (await c.get("/api/grafana-alerts/rules")).status == 503 - assert (await c.post("/api/grafana-alerts/rules", json={})).status == 503 - assert (await c.delete("/api/grafana-alerts/rules/r1")).status == 503 - assert (await c.get("/api/grafana-alerts/remediations")).status == 503 - - async def test_enabled_reads(self): - bot = _bot(health_server=SimpleNamespace(grafana_handler=self._handler())) - async with TestClient(TestServer(_app(register_grafana_alerts, bot=bot))) as c: - assert (await (await c.get("/api/grafana-alerts/status")).json())["rules"] == 2 - hist = await (await c.get("/api/grafana-alerts/history?limit=2")).json() - assert hist["total"] == 3 and len(hist["alerts"]) == 2 - rules = await (await c.get("/api/grafana-alerts/rules")).json() - assert rules["rules"][0]["id"] == "r1" - rems = await (await c.get("/api/grafana-alerts/remediations")).json() - assert rems["remediations"][0]["id"] == "rem1" - - async def test_add_rule(self): - handler = self._handler() - bot = _bot(health_server=SimpleNamespace(grafana_handler=handler)) - async with TestClient(TestServer(_app(register_grafana_alerts, bot=bot))) as c: - assert (await c.post("/api/grafana-alerts/rules", data="bad")).status == 400 - # missing name_pattern → 400 - assert (await c.post("/api/grafana-alerts/rules", json={"id": "r1"})).status == 400 - r = await c.post( - "/api/grafana-alerts/rules", - json={"id": "r1", "name_pattern": "CPU.*", "remediation_goal": "go"}, - ) - assert r.status == 201 and (await r.json())["rule"] == "r1" - handler.add_rule.assert_called_once() - # a rule the handler rejects surfaces as 400 - handler.add_rule.side_effect = ValueError("duplicate rule id") - assert ( - await c.post( - "/api/grafana-alerts/rules", json={"id": "r2", "name_pattern": "Mem.*"} - ) - ).status == 400 - - async def test_delete_rule(self): - handler = self._handler() - bot = _bot(health_server=SimpleNamespace(grafana_handler=handler)) - async with TestClient(TestServer(_app(register_grafana_alerts, bot=bot))) as c: - assert (await c.delete("/api/grafana-alerts/rules/r1")).status == 200 - handler.remove_rule.return_value = False - assert (await c.delete("/api/grafana-alerts/rules/ghost")).status == 404 - - # --------------------------------------------------------------------------- # # Outbound webhooks # --------------------------------------------------------------------------- # diff --git a/ui/dist/assets/index-BwvBDkb8.js b/ui/dist/assets/index-BwvBDkb8.js deleted file mode 100644 index 06262fa4f..000000000 --- a/ui/dist/assets/index-BwvBDkb8.js +++ /dev/null @@ -1,7595 +0,0 @@ -var ly=Object.defineProperty;var oy=(e,t,s)=>t in e?ly(e,t,{enumerable:!0,configurable:!0,writable:!0,value:s}):e[t]=s;var Et=(e,t,s)=>oy(e,typeof t!="symbol"?t+"":t,s);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))a(n);new MutationObserver(n=>{for(const i of n)if(i.type==="childList")for(const l of i.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&a(l)}).observe(document,{childList:!0,subtree:!0});function s(n){const i={};return n.integrity&&(i.integrity=n.integrity),n.referrerPolicy&&(i.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?i.credentials="include":n.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function a(n){if(n.ep)return;n.ep=!0;const i=s(n);fetch(n.href,i)}})();class ry{constructor(){this._persist=localStorage.getItem("odin_persist")==="1",this._token=this._persist?localStorage.getItem("odin_token")||"":sessionStorage.getItem("odin_token")||"";const t=this._persist?localStorage:sessionStorage;this._sessionTimeout=parseInt(t.getItem("odin_session_timeout")||"0",10),this._lastActivity=Date.now(),this._activityTimer=null,this.onSessionExpired=null,this._token&&this._sessionTimeout>0&&this._startActivityMonitor()}get token(){return this._token}get sessionTimeout(){return this._sessionTimeout}setToken(t,s=0){this._token=t,this._sessionTimeout=s,this._lastActivity=Date.now(),t?(this._persist?(localStorage.setItem("odin_token",t),s>0?localStorage.setItem("odin_session_timeout",String(s)):localStorage.removeItem("odin_session_timeout"),localStorage.setItem("odin_persist","1"),sessionStorage.removeItem("odin_token"),sessionStorage.removeItem("odin_session_timeout")):(localStorage.removeItem("odin_persist"),localStorage.removeItem("odin_token"),localStorage.removeItem("odin_session_timeout"),sessionStorage.setItem("odin_token",t),s>0?sessionStorage.setItem("odin_session_timeout",String(s)):sessionStorage.removeItem("odin_session_timeout")),this._startActivityMonitor()):(localStorage.removeItem("odin_persist"),sessionStorage.removeItem("odin_token"),sessionStorage.removeItem("odin_session_timeout"),localStorage.removeItem("odin_token"),localStorage.removeItem("odin_session_timeout"),this._stopActivityMonitor())}setPersist(t){this._persist=t}_startActivityMonitor(){this._stopActivityMonitor(),!(this._sessionTimeout<=0)&&(this._activityTimer=setInterval(()=>{(Date.now()-this._lastActivity)/1e3>=this._sessionTimeout&&(this._stopActivityMonitor(),this.onSessionExpired&&this.onSessionExpired())},1e4))}_stopActivityMonitor(){this._activityTimer&&(clearInterval(this._activityTimer),this._activityTimer=null)}_headers(t={}){const s={"Content-Type":"application/json",...t};return this._token&&(s.Authorization=`Bearer ${this._token}`),s}async _request(t,s,a=null,{signal:n}={}){this._lastActivity=Date.now();const i={method:t,headers:this._headers(),signal:n};a!==null&&(i.body=JSON.stringify(a));const l=await fetch(s,i);if(l.status===401)throw new Eo("Unauthorized");const o=await l.json().catch(()=>null);if(!l.ok){const r=(o==null?void 0:o.error)||`HTTP ${l.status}`;throw new Rc(r,l.status,o)}return o}get(t,s={}){return this._request("GET",t,null,s)}async getBlob(t){this._lastActivity=Date.now();const s=await fetch(t,{method:"GET",headers:this._headers()});if(s.status===401)throw new Eo("Unauthorized");if(!s.ok){const a=await s.json().catch(()=>null);throw new Rc((a==null?void 0:a.error)||`HTTP ${s.status}`,s.status,a)}return s.blob()}post(t,s){return this._request("POST",t,s)}async setListenerExposure(t,s){const a=await fetch("/api/setup/listener",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${t}`},body:JSON.stringify({expose_beyond_loopback:s})}),n=await a.json().catch(()=>null);if(!a.ok)throw new Rc((n==null?void 0:n.error)||"Listener reauthentication failed",a.status,n);return n}put(t,s){return this._request("PUT",t,s)}del(t){return this._request("DELETE",t)}async login(t){const s=await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:t})}),a=await s.json().catch(()=>null);if(!s.ok)throw new Eo((a==null?void 0:a.error)||"Login failed");return this.setToken(a.session_id,a.timeout_seconds||0),a}async logout(){const t=this.post("/api/auth/logout",{});this.setToken("");try{await t}catch{}}async check(){try{return await this.get("/api/status"),{ok:!0,needsAuth:!1}}catch(t){return t instanceof Eo?{ok:!1,needsAuth:!0}:{ok:!1,needsAuth:!1,error:t.message}}}}class Eo extends Error{constructor(t){super(t),this.name="AuthError"}}class Rc extends Error{constructor(t,s,a){super(t),this.name="ApiError",this.status=s,this.data=a}}class cy{constructor(t){this._api=t,this._ws=null,this._handlers={logs:[],events:[],chat:[]},this._reconnectDelay=1e3,this._maxReconnectDelay=3e4,this._shouldConnect=!1,this._subscriptions=new Set,this._reconnectAttempt=0,this._reconnectTimer=null,this._lastPongTime=0,this._pingInterval=null,this._forcedRetireTimer=null,this._subscriptionAckTimer=null,this._pendingReconnect=null,this._latency=-1,this._chatPending=!1,this._state="disconnected",this._lifecycle={status:new Set,state:new Set,latency:new Set,reconnected:new Set},this._everConnected=!1,this._reconnectEpoch=0}onStatus(t){return this._addLifecycle("status",t)}onState(t){return this._addLifecycle("state",t)}onLatencyChange(t){return this._addLifecycle("latency",t)}onReconnected(t){return this._addLifecycle("reconnected",t)}_addLifecycle(t,s){return this._lifecycle[t].add(s),()=>{this._lifecycle[t].delete(s)}}_emitLifecycle(t,...s){for(const a of[...this._lifecycle[t]])try{a(...s)}catch{}}get connected(){var t;return((t=this._ws)==null?void 0:t.readyState)===WebSocket.OPEN}get state(){return this._state}get reconnectAttempt(){return this._reconnectAttempt}get latency(){return this._latency}get reconnectEpoch(){return this._reconnectEpoch}_resetLatency(){this._latency=-1,this._emitLifecycle("latency",-1)}connect(){this._shouldConnect=!0,this._setState("connecting"),this._open()}disconnect(){this._shouldConnect=!1,this._everConnected=!1,this._reconnectTimer&&(clearTimeout(this._reconnectTimer),this._reconnectTimer=null),this._forcedRetireTimer&&(clearTimeout(this._forcedRetireTimer),this._forcedRetireTimer=null),this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._pendingReconnect=null,this._reconnectAttempt=0,this._resetLatency(),this._stopPing(),this._ws&&(this._ws.close(),this._ws=null),this._setState("disconnected")}_setState(t){this._state!==t&&(this._state=t,this._emitLifecycle("state",t,{attempt:this._reconnectAttempt,latency:this._latency}))}_startPing(t){this._stopPing(),this._lastPongTime=Date.now(),this._pingInterval=setInterval(()=>{if(!(this._ws!==t||t.readyState!==WebSocket.OPEN)){if(this._lastPongTime&&Date.now()-this._lastPongTime>47e3){this._beginForcedRetirement(t,"pong timeout");return}try{t.send(JSON.stringify({type:"ping",ts:Date.now()}))}catch{}}},15e3)}_beginForcedRetirement(t,s){if(!(this._ws!==t||this._forcedRetireTimer)){this._stopPing(),this._reconnectAttempt++,this._setState("reconnecting"),this._emitLifecycle("status",!1),this._forcedRetireTimer=setTimeout(()=>{this._forcedRetireTimer=null,this._retireSocket(t,!0,!0)},1e3);try{t.close(4e3,s)}catch{}}}_scheduleReconnect(t=!0){!this._shouldConnect||this._reconnectTimer||(t&&this._reconnectAttempt++,this._setState("reconnecting"),this._reconnectTimer=setTimeout(()=>{this._reconnectTimer=null,this._open()},this._reconnectDelay),this._reconnectDelay=Math.min(this._reconnectDelay*2,this._maxReconnectDelay))}_retireSocket(t,s=!1,a=!1){if(this._ws===t){if(this._forcedRetireTimer&&(clearTimeout(this._forcedRetireTimer),this._forcedRetireTimer=null),this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._pendingReconnect=null,this._ws=null,this._stopPing(),this._resetLatency(),this._chatPending){this._chatPending=!1;const n={type:"chat_error",error:"Connection lost — the response may still complete; check session history."};for(const i of this._handlers.chat||[])i(n)}s||this._emitLifecycle("status",!1),this._shouldConnect?this._scheduleReconnect(!a):this._setState("disconnected")}}_beginReconnectBarrier(t,s){if(!s)return;const a=new Set(this._subscriptions);if(a.size===0){this._reconnectEpoch+=1,this._emitLifecycle("reconnected",this._reconnectEpoch);return}this._pendingReconnect={socket:t,channels:a},this._subscriptionAckTimer=setTimeout(()=>{var n;((n=this._pendingReconnect)==null?void 0:n.socket)===t&&this._beginForcedRetirement(t,"subscription acknowledgement timeout")},5e3)}_ackSubscription(t,s){const a=this._pendingReconnect;!a||a.socket!==t||!a.channels.has(s)||(a.channels.delete(s),!(a.channels.size>0)&&(this._pendingReconnect=null,this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._reconnectEpoch+=1,this._emitLifecycle("reconnected",this._reconnectEpoch)))}_stopPing(){this._pingInterval&&(clearInterval(this._pingInterval),this._pingInterval=null)}subscribe(t,s){var a;if(this._handlers[t]||(this._handlers[t]=[]),this._handlers[t].push(s),t!=="chat"&&(this._subscriptions.add(t),this.connected)){const n=this._ws;((a=this._pendingReconnect)==null?void 0:a.socket)===n&&this._pendingReconnect.channels.add(t),n.send(JSON.stringify({subscribe:t}))}}unsubscribe(t,s){const a=this._handlers[t];if(a){const n=a.indexOf(s);if(n>=0&&a.splice(n,1),a.length===0&&t!=="chat"&&(this._subscriptions.delete(t),this.connected)){const i=this._ws;i.send(JSON.stringify({unsubscribe:t})),this._ackSubscription(i,t)}}}on(t,s){return this.subscribe(t,s)}off(t,s){return this.unsubscribe(t,s)}sendChat(t,{channelId:s,userId:a,username:n}={}){return this.connected?(this._ws.send(JSON.stringify({type:"chat",content:t,channel_id:s||"web-default",user_id:a||void 0,username:n||void 0})),this._chatPending=!0,!0):!1}_open(){if(this._ws||!this._shouldConnect)return;const s=`${location.protocol==="https:"?"wss:":"ws:"}//${location.host}/api/ws`,a=this._api.token?["odin.bearer."+btoa(this._api.token).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")]:void 0,n=a?new WebSocket(s,a):new WebSocket(s);this._ws=n;const i=()=>this._ws===n;n.onopen=()=>{if(!i())return;const l=this._everConnected;this._everConnected=!0,this._reconnectDelay=1e3,this._reconnectAttempt=0;for(const o of this._subscriptions)n.send(JSON.stringify({subscribe:o}));this._startPing(n),this._setState("connected"),this._emitLifecycle("status",!0),this._beginReconnectBarrier(n,l)},n.onmessage=l=>{if(!i())return;let o;try{o=JSON.parse(l.data)}catch{return}const r=o.type;if(r==="pong"){o.ts&&(this._latency=Date.now()-o.ts,this._lastPongTime=Date.now(),this._emitLifecycle("latency",this._latency));return}if(r==="subscribed"){this._ackSubscription(n,o.channel);return}if(r==="log")for(const c of this._handlers.logs||[])c(o);else if(r==="event")for(const c of this._handlers.events||[])c(o);else if(r==="chat_response"||r==="chat_error"){this._chatPending=!1;for(const c of this._handlers.chat||[])c(o)}},n.onclose=()=>{const l=!!this._forcedRetireTimer;this._retireSocket(n,l,l)},n.onerror=()=>{}}}const z=new ry,mt=new cy(z);/** -* @vue/shared v3.5.38 -* (c) 2018-present Yuxi (Evan) You and Vue contributors -* @license MIT -**/function Xs(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const ct={},Li=[],vs=()=>{},Ri=()=>!1,li=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Br=e=>e.startsWith("onUpdate:"),lt=Object.assign,Gd=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},dy=Object.prototype.hasOwnProperty,bt=(e,t)=>dy.call(e,t),Me=Array.isArray,Ni=e=>el(e)==="[object Map]",oi=e=>el(e)==="[object Set]",vp=e=>el(e)==="[object Date]",uy=e=>el(e)==="[object RegExp]",Ve=e=>typeof e=="function",Ze=e=>typeof e=="string",Cs=e=>typeof e=="symbol",ht=e=>e!==null&&typeof e=="object",Wd=e=>(ht(e)||Ve(e))&&Ve(e.then)&&Ve(e.catch),Em=Object.prototype.toString,el=e=>Em.call(e),py=e=>el(e).slice(8,-1),Ur=e=>el(e)==="[object Object]",zr=e=>Ze(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,en=Xs(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),fy=Xs("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),Hr=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},my=/-\w/g,Tt=Hr(e=>e.replace(my,t=>t.slice(1).toUpperCase())),vy=/\B([A-Z])/g,Hs=Hr(e=>e.replace(vy,"-$1").toLowerCase()),ri=Hr(e=>e.charAt(0).toUpperCase()+e.slice(1)),Mi=Hr(e=>e?`on${ri(e)}`:""),os=(e,t)=>!Object.is(e,t),Di=(e,...t)=>{for(let s=0;s{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:a,value:s})},jr=e=>{const t=parseFloat(e);return isNaN(t)?e:t},rr=e=>{const t=Ze(e)?Number(e):NaN;return isNaN(t)?e:t};let hp;const qr=()=>hp||(hp=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function hy(e,t){return e+JSON.stringify(t,(s,a)=>typeof a=="function"?a.toString():a)}const gy="Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol",by=Xs(gy);function ro(e){if(Me(e)){const t={};for(let s=0;s{if(s){const a=s.split(xy);a.length>1&&(t[a[0].trim()]=a[1].trim())}}),t}function co(e){let t="";if(Ze(e))t=e;else if(Me(e))for(let s=0;snn(s,t))}const Om=e=>!!(e&&e.__v_isRef===!0),Lm=e=>Ze(e)?e:e==null?"":Me(e)||ht(e)&&(e.toString===Em||!Ve(e.toString))?Om(e)?Lm(e.value):JSON.stringify(e,Nm,2):String(e),Nm=(e,t)=>Om(t)?Nm(e,t.value):Ni(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[a,n],i)=>(s[Ic(a,i)+" =>"]=n,s),{})}:oi(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ic(s))}:Cs(t)?Ic(t):ht(t)&&!Me(t)&&!Ur(t)?String(t):t,Ic=(e,t="")=>{var s;return Cs(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};function My(e){return e==null?"initial":typeof e=="string"?e===""?" ":e:String(e)}/** -* @vue/reactivity v3.5.38 -* (c) 2018-present Yuxi (Evan) You and Vue contributors -* @license MIT -**/let as;class Kd{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&as&&(as.active?(this.parent=as,this.index=(as.scopes||(as.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t0&&--this._on===0){if(as===this)as=this.prevScope;else{let t=as;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,a;for(s=0,a=this.effects.length;s0)return;if(Ll){let t=Ll;for(Ll=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Ol;){let t=Ol;for(Ol=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(a){e||(e=a)}t=s}}if(e)throw e}function $m(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Fm(e){let t,s=e.depsTail,a=s;for(;a;){const n=a.prevDep;a.version===-1?(a===s&&(s=n),Qd(a),$y(a)):t=a,a.dep.activeLink=a.prevActiveLink,a.prevActiveLink=void 0,a=n}e.deps=t,e.depsTail=s}function id(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Bm(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Bm(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Hl)||(e.globalVersion=Hl,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!id(e))))return;e.flags|=2;const t=e.dep,s=At,a=ma;At=e,ma=!0;try{$m(e);const n=e.fn(e._value);(t.version===0||os(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(n){throw t.version++,n}finally{At=s,ma=a,Fm(e),e.flags&=-3}}function Qd(e,t=!1){const{dep:s,prevSub:a,nextSub:n}=e;if(a&&(a.nextSub=n,e.prevSub=void 0),n&&(n.prevSub=a,e.nextSub=void 0),s.subs===e&&(s.subs=a,!a&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)Qd(i,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function $y(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}function Fy(e,t){e.effect instanceof zl&&(e=e.effect.fn);const s=new zl(e);t&<(s,t);try{s.run()}catch(n){throw s.stop(),n}const a=s.run.bind(s);return a.effect=s,a}function By(e){e.effect.stop()}let ma=!0;const Um=[];function ln(){Um.push(ma),ma=!1}function on(){const e=Um.pop();ma=e===void 0?!0:e}function gp(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=At;At=void 0;try{t()}finally{At=s}}}let Hl=0;class Uy{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Gr{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!At||!ma||At===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==At)s=this.activeLink=new Uy(At,this),At.deps?(s.prevDep=At.depsTail,At.depsTail.nextDep=s,At.depsTail=s):At.deps=At.depsTail=s,zm(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const a=s.nextDep;a.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=a),s.prevDep=At.depsTail,s.nextDep=void 0,At.depsTail.nextDep=s,At.depsTail=s,At.deps===s&&(At.deps=a)}return s}trigger(t){this.version++,Hl++,this.notify(t)}notify(t){Jd();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{Zd()}}}function zm(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let a=t.deps;a;a=a.nextDep)zm(a)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const cr=new WeakMap,Qn=Symbol(""),ld=Symbol(""),jl=Symbol("");function ws(e,t,s){if(ma&&At){let a=cr.get(e);a||cr.set(e,a=new Map);let n=a.get(s);n||(a.set(s,n=new Gr),n.map=a,n.key=s),n.track()}}function Ja(e,t,s,a,n,i){const l=cr.get(e);if(!l){Hl++;return}const o=r=>{r&&r.trigger()};if(Jd(),t==="clear")l.forEach(o);else{const r=Me(e),c=r&&zr(s);if(r&&s==="length"){const d=Number(a);l.forEach((u,p)=>{(p==="length"||p===jl||!Cs(p)&&p>=d)&&o(u)})}else switch((s!==void 0||l.has(void 0))&&o(l.get(s)),c&&o(l.get(jl)),t){case"add":r?c&&o(l.get("length")):(o(l.get(Qn)),Ni(e)&&o(l.get(ld)));break;case"delete":r||(o(l.get(Qn)),Ni(e)&&o(l.get(ld)));break;case"set":Ni(e)&&o(l.get(Qn));break}}Zd()}function zy(e,t){const s=cr.get(e);return s&&s.get(t)}function yi(e){const t=ft(e);return t===e?t:(ws(t,"iterate",jl),qs(e)?t:t.map(ha))}function Wr(e){return ws(e=ft(e),"iterate",jl),e}function Oa(e,t){return Na(e)?Hi(tn(e)?ha(t):t):ha(t)}const Hy={__proto__:null,[Symbol.iterator](){return Lc(this,Symbol.iterator,e=>Oa(this,e))},concat(...e){return yi(this).concat(...e.map(t=>Me(t)?yi(t):t))},entries(){return Lc(this,"entries",e=>(e[1]=Oa(this,e[1]),e))},every(e,t){return za(this,"every",e,t,void 0,arguments)},filter(e,t){return za(this,"filter",e,t,s=>s.map(a=>Oa(this,a)),arguments)},find(e,t){return za(this,"find",e,t,s=>Oa(this,s),arguments)},findIndex(e,t){return za(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return za(this,"findLast",e,t,s=>Oa(this,s),arguments)},findLastIndex(e,t){return za(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return za(this,"forEach",e,t,void 0,arguments)},includes(...e){return Nc(this,"includes",e)},indexOf(...e){return Nc(this,"indexOf",e)},join(e){return yi(this).join(e)},lastIndexOf(...e){return Nc(this,"lastIndexOf",e)},map(e,t){return za(this,"map",e,t,void 0,arguments)},pop(){return ul(this,"pop")},push(...e){return ul(this,"push",e)},reduce(e,...t){return bp(this,"reduce",e,t)},reduceRight(e,...t){return bp(this,"reduceRight",e,t)},shift(){return ul(this,"shift")},some(e,t){return za(this,"some",e,t,void 0,arguments)},splice(...e){return ul(this,"splice",e)},toReversed(){return yi(this).toReversed()},toSorted(e){return yi(this).toSorted(e)},toSpliced(...e){return yi(this).toSpliced(...e)},unshift(...e){return ul(this,"unshift",e)},values(){return Lc(this,"values",e=>Oa(this,e))}};function Lc(e,t,s){const a=Wr(e),n=a[t]();return a!==e&&!qs(e)&&(n._next=n.next,n.next=()=>{const i=n._next();return i.done||(i.value=s(i.value)),i}),n}const jy=Array.prototype;function za(e,t,s,a,n,i){const l=Wr(e),o=l!==e&&!qs(e),r=l[t];if(r!==jy[t]){const u=r.apply(e,i);return o?ha(u):u}let c=s;l!==e&&(o?c=function(u,p){return s.call(this,Oa(e,u),p,e)}:s.length>2&&(c=function(u,p){return s.call(this,u,p,e)}));const d=r.call(l,c,a);return o&&n?n(d):d}function bp(e,t,s,a){const n=Wr(e),i=n!==e&&!qs(e);let l=s,o=!1;n!==e&&(i?(o=a.length===0,l=function(c,d,u){return o&&(o=!1,c=Oa(e,c)),s.call(this,c,Oa(e,d),u,e)}):s.length>3&&(l=function(c,d,u){return s.call(this,c,d,u,e)}));const r=n[t](l,...a);return o?Oa(e,r):r}function Nc(e,t,s){const a=ft(e);ws(a,"iterate",jl);const n=a[t](...s);return(n===-1||n===!1)&&uo(s[0])?(s[0]=ft(s[0]),a[t](...s)):n}function ul(e,t,s=[]){ln(),Jd();const a=ft(e)[t].apply(e,s);return Zd(),on(),a}const qy=Xs("__proto__,__v_isRef,__isVue"),Hm=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Cs));function Vy(e){Cs(e)||(e=String(e));const t=ft(this);return ws(t,"has",e),t.hasOwnProperty(e)}class jm{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,a){if(s==="__v_skip")return t.__v_skip;const n=this._isReadonly,i=this._isShallow;if(s==="__v_isReactive")return!n;if(s==="__v_isReadonly")return n;if(s==="__v_isShallow")return i;if(s==="__v_raw")return a===(n?i?Jm:Km:i?Wm:Gm).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(a)?t:void 0;const l=Me(t);if(!n){let r;if(l&&(r=Hy[s]))return r;if(s==="hasOwnProperty")return Vy}const o=Reflect.get(t,s,Xt(t)?t:a);if((Cs(s)?Hm.has(s):qy(s))||(n||ws(t,"get",s),i))return o;if(Xt(o)){const r=l&&zr(s)?o:o.value;return n&&ht(r)?dr(r):r}return ht(o)?n?dr(o):Mn(o):o}}class qm extends jm{constructor(t=!1){super(!1,t)}set(t,s,a,n){let i=t[s];const l=Me(t)&&zr(s);if(!this._isShallow){const c=Na(i);if(!qs(a)&&!Na(a)&&(i=ft(i),a=ft(a)),!l&&Xt(i)&&!Xt(a))return c||(i.value=a),!0}const o=l?Number(s)e,Ao=e=>Reflect.getPrototypeOf(e);function Zy(e,t,s){return function(...a){const n=this.__v_raw,i=ft(n),l=Ni(i),o=e==="entries"||e===Symbol.iterator&&l,r=e==="keys"&&l,c=n[e](...a),d=s?od:t?Hi:ha;return!t&&ws(i,"iterate",r?ld:Qn),lt(Object.create(c),{next(){const{value:u,done:p}=c.next();return p?{value:u,done:p}:{value:o?[d(u[0]),d(u[1])]:d(u),done:p}}})}}function Ro(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Qy(e,t){const s={get(n){const i=this.__v_raw,l=ft(i),o=ft(n);e||(os(n,o)&&ws(l,"get",n),ws(l,"get",o));const{has:r}=Ao(l),c=t?od:e?Hi:ha;if(r.call(l,n))return c(i.get(n));if(r.call(l,o))return c(i.get(o));i!==l&&i.get(n)},get size(){const n=this.__v_raw;return!e&&ws(ft(n),"iterate",Qn),n.size},has(n){const i=this.__v_raw,l=ft(i),o=ft(n);return e||(os(n,o)&&ws(l,"has",n),ws(l,"has",o)),n===o?i.has(n):i.has(n)||i.has(o)},forEach(n,i){const l=this,o=l.__v_raw,r=ft(o),c=t?od:e?Hi:ha;return!e&&ws(r,"iterate",Qn),o.forEach((d,u)=>n.call(i,c(d),c(u),l))}};return lt(s,e?{add:Ro("add"),set:Ro("set"),delete:Ro("delete"),clear:Ro("clear")}:{add(n){const i=ft(this),l=Ao(i),o=ft(n),r=!t&&!qs(n)&&!Na(n)?o:n;return l.has.call(i,r)||os(n,r)&&l.has.call(i,n)||os(o,r)&&l.has.call(i,o)||(i.add(r),Ja(i,"add",r,r)),this},set(n,i){!t&&!qs(i)&&!Na(i)&&(i=ft(i));const l=ft(this),{has:o,get:r}=Ao(l);let c=o.call(l,n);c||(n=ft(n),c=o.call(l,n));const d=r.call(l,n);return l.set(n,i),c?os(i,d)&&Ja(l,"set",n,i):Ja(l,"add",n,i),this},delete(n){const i=ft(this),{has:l,get:o}=Ao(i);let r=l.call(i,n);r||(n=ft(n),r=l.call(i,n)),o&&o.call(i,n);const c=i.delete(n);return r&&Ja(i,"delete",n,void 0),c},clear(){const n=ft(this),i=n.size!==0,l=n.clear();return i&&Ja(n,"clear",void 0,void 0),l}}),["keys","values","entries",Symbol.iterator].forEach(n=>{s[n]=Zy(n,e,t)}),s}function Kr(e,t){const s=Qy(e,t);return(a,n,i)=>n==="__v_isReactive"?!e:n==="__v_isReadonly"?e:n==="__v_raw"?a:Reflect.get(bt(s,n)&&n in a?s:a,n,i)}const Yy={get:Kr(!1,!1)},Xy={get:Kr(!1,!0)},ex={get:Kr(!0,!1)},tx={get:Kr(!0,!0)},Gm=new WeakMap,Wm=new WeakMap,Km=new WeakMap,Jm=new WeakMap;function sx(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Mn(e){return Na(e)?e:Jr(e,!1,Gy,Yy,Gm)}function Yd(e){return Jr(e,!1,Ky,Xy,Wm)}function dr(e){return Jr(e,!0,Wy,ex,Km)}function ax(e){return Jr(e,!0,Jy,tx,Jm)}function Jr(e,t,s,a,n){if(!ht(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=n.get(e);if(i)return i;const l=sx(py(e));if(l===0)return e;const o=new Proxy(e,l===2?a:s);return n.set(e,o),o}function tn(e){return Na(e)?tn(e.__v_raw):!!(e&&e.__v_isReactive)}function Na(e){return!!(e&&e.__v_isReadonly)}function qs(e){return!!(e&&e.__v_isShallow)}function uo(e){return e?!!e.__v_raw:!1}function ft(e){const t=e&&e.__v_raw;return t?ft(t):e}function Zm(e){return!bt(e,"__v_skip")&&Object.isExtensible(e)&&Am(e,"__v_skip",!0),e}const ha=e=>ht(e)?Mn(e):e,Hi=e=>ht(e)?dr(e):e;function Xt(e){return e?e.__v_isRef===!0:!1}function f(e){return Qm(e,!1)}function Xd(e){return Qm(e,!0)}function Qm(e,t){return Xt(e)?e:new nx(e,t)}class nx{constructor(t,s){this.dep=new Gr,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:ft(t),this._value=s?t:ha(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,a=this.__v_isShallow||qs(t)||Na(t);t=a?t:ft(t),os(t,s)&&(this._rawValue=t,this._value=a?t:ha(t),this.dep.trigger())}}function ix(e){e.dep&&e.dep.trigger()}function La(e){return Xt(e)?e.value:e}function lx(e){return Ve(e)?e():La(e)}const ox={get:(e,t,s)=>t==="__v_raw"?e:La(Reflect.get(e,t,s)),set:(e,t,s,a)=>{const n=e[t];return Xt(n)&&!Xt(s)?(n.value=s,!0):Reflect.set(e,t,s,a)}};function eu(e){return tn(e)?e:new Proxy(e,ox)}class rx{constructor(t){this.__v_isRef=!0,this._value=void 0;const s=this.dep=new Gr,{get:a,set:n}=t(s.track.bind(s),s.trigger.bind(s));this._get=a,this._set=n}get value(){return this._value=this._get()}set value(t){this._set(t)}}function Ym(e){return new rx(e)}function cx(e){const t=Me(e)?new Array(e.length):{};for(const s in e)t[s]=Xm(e,s);return t}class dx{constructor(t,s,a){this._object=t,this._defaultValue=a,this.__v_isRef=!0,this._value=void 0,this._key=Cs(s)?s:String(s),this._raw=ft(t);let n=!0,i=t;if(!Me(t)||Cs(this._key)||!zr(this._key))do n=!uo(i)||qs(i);while(n&&(i=i.__v_raw));this._shallow=n}get value(){let t=this._object[this._key];return this._shallow&&(t=La(t)),this._value=t===void 0?this._defaultValue:t}set value(t){if(this._shallow&&Xt(this._raw[this._key])){const s=this._object[this._key];if(Xt(s)){s.value=t;return}}this._object[this._key]=t}get dep(){return zy(this._raw,this._key)}}class ux{constructor(t){this._getter=t,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}}function px(e,t,s){return Xt(e)?e:Ve(e)?new ux(e):ht(e)&&arguments.length>1?Xm(e,t,s):f(e)}function Xm(e,t,s){return new dx(e,t,s)}class fx{constructor(t,s,a){this.fn=t,this.setter=s,this._value=void 0,this.dep=new Gr(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Hl-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=a}notify(){if(this.flags|=16,!(this.flags&8)&&At!==this)return Pm(this,!0),!0}get value(){const t=this.dep.track();return Bm(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function mx(e,t,s=!1){let a,n;return Ve(e)?a=e:(a=e.get,n=e.set),new fx(a,n,s)}const vx={GET:"get",HAS:"has",ITERATE:"iterate"},hx={SET:"set",ADD:"add",DELETE:"delete",CLEAR:"clear"},Io={},ur=new WeakMap;let Tn;function gx(){return Tn}function ev(e,t=!1,s=Tn){if(s){let a=ur.get(s);a||ur.set(s,a=[]),a.push(e)}}function bx(e,t,s=ct){const{immediate:a,deep:n,once:i,scheduler:l,augmentJob:o,call:r}=s,c=x=>n?x:qs(x)||n===!1||n===0?Za(x,1):Za(x);let d,u,p,m,h=!1,y=!1;if(Xt(e)?(u=()=>e.value,h=qs(e)):tn(e)?(u=()=>c(e),h=!0):Me(e)?(y=!0,h=e.some(x=>tn(x)||qs(x)),u=()=>e.map(x=>{if(Xt(x))return x.value;if(tn(x))return c(x);if(Ve(x))return r?r(x,2):x()})):Ve(e)?t?u=r?()=>r(e,2):e:u=()=>{if(p){ln();try{p()}finally{on()}}const x=Tn;Tn=d;try{return r?r(e,3,[m]):e(m)}finally{Tn=x}}:u=vs,t&&n){const x=u,w=n===!0?1/0:n;u=()=>Za(x(),w)}const k=Mm(),A=()=>{d.stop(),k&&k.active&&Gd(k.effects,d)};if(i&&t){const x=t;t=(...w)=>{const _=x(...w);return A(),_}}let g=y?new Array(e.length).fill(Io):Io;const b=x=>{if(!(!(d.flags&1)||!d.dirty&&!x))if(t){const w=d.run();if(x||n||h||(y?w.some((_,T)=>os(_,g[T])):os(w,g))){p&&p();const _=Tn;Tn=d;try{const T=[w,g===Io?void 0:y&&g[0]===Io?[]:g,m];g=w,r?r(t,3,T):t(...T)}finally{Tn=_}}}else d.run()};return o&&o(b),d=new zl(u),d.scheduler=l?()=>l(b,!1):b,m=x=>ev(x,!1,d),p=d.onStop=()=>{const x=ur.get(d);if(x){if(r)r(x,4);else for(const w of x)w();ur.delete(d)}},t?a?b(!0):g=d.run():l?l(b.bind(null,!0),!0):d.run(),A.pause=d.pause.bind(d),A.resume=d.resume.bind(d),A.stop=A,A}function Za(e,t=1/0,s){if(t<=0||!ht(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,Xt(e))Za(e.value,t,s);else if(Me(e))for(let a=0;a{Za(a,t,s)});else if(Ur(e)){for(const a in e)Za(e[a],t,s);for(const a of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,a)&&Za(e[a],t,s)}return e}/** -* @vue/runtime-core v3.5.38 -* (c) 2018-present Yuxi (Evan) You and Vue contributors -* @license MIT -**/const tv=[];function yx(e){tv.push(e)}function xx(){tv.pop()}function _x(e,t){}const wx={SETUP_FUNCTION:0,0:"SETUP_FUNCTION",RENDER_FUNCTION:1,1:"RENDER_FUNCTION",NATIVE_EVENT_HANDLER:5,5:"NATIVE_EVENT_HANDLER",COMPONENT_EVENT_HANDLER:6,6:"COMPONENT_EVENT_HANDLER",VNODE_HOOK:7,7:"VNODE_HOOK",DIRECTIVE_HOOK:8,8:"DIRECTIVE_HOOK",TRANSITION_HOOK:9,9:"TRANSITION_HOOK",APP_ERROR_HANDLER:10,10:"APP_ERROR_HANDLER",APP_WARN_HANDLER:11,11:"APP_WARN_HANDLER",FUNCTION_REF:12,12:"FUNCTION_REF",ASYNC_COMPONENT_LOADER:13,13:"ASYNC_COMPONENT_LOADER",SCHEDULER:14,14:"SCHEDULER",COMPONENT_UPDATE:15,15:"COMPONENT_UPDATE",APP_UNMOUNT_CLEANUP:16,16:"APP_UNMOUNT_CLEANUP"},kx={sp:"serverPrefetch hook",bc:"beforeCreate hook",c:"created hook",bm:"beforeMount hook",m:"mounted hook",bu:"beforeUpdate hook",u:"updated",bum:"beforeUnmount hook",um:"unmounted hook",a:"activated hook",da:"deactivated hook",ec:"errorCaptured hook",rtc:"renderTracked hook",rtg:"renderTriggered hook",0:"setup function",1:"render function",2:"watcher getter",3:"watcher callback",4:"watcher cleanup function",5:"native event handler",6:"component event handler",7:"vnode hook",8:"directive hook",9:"transition hook",10:"app errorHandler",11:"app warnHandler",12:"ref function",13:"async component loader",14:"scheduler flush",15:"component update",16:"app unmount cleanup function"};function tl(e,t,s,a){try{return a?e(...a):e()}catch(n){ci(n,t,s)}}function Ys(e,t,s,a){if(Ve(e)){const n=tl(e,t,s,a);return n&&Wd(n)&&n.catch(i=>{ci(i,t,s)}),n}if(Me(e)){const n=[];for(let i=0;i>>1,n=Ns[a],i=Vl(n);i=Vl(s)?Ns.push(e):Ns.splice(Cx(t),0,e),e.flags|=1,av()}}function av(){pr||(pr=sv.then(nv))}function ql(e){Me(e)?Pi.push(...e):En&&e.id===-1?En.splice(Ci+1,0,e):e.flags&1||(Pi.push(e),e.flags|=1),av()}function yp(e,t,s=Ra+1){for(;sVl(s)-Vl(a));if(Pi.length=0,En){En.push(...t);return}for(En=t,Ci=0;Cie.id==null?e.flags&2?-1:1/0:e.id;function nv(e){try{for(Ra=0;RaTi.emit(n,...i)),Oo=[]):typeof window<"u"&&window.HTMLElement&&!((a=(s=window.navigator)==null?void 0:s.userAgent)!=null&&a.includes("jsdom"))?((t.__VUE_DEVTOOLS_HOOK_REPLAY__=t.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push(i=>{iv(i,t)}),setTimeout(()=>{Ti||(t.__VUE_DEVTOOLS_HOOK_REPLAY__=null,Oo=[])},3e3)):Oo=[]}let ms=null,Zr=null;function Gl(e){const t=ms;return ms=e,Zr=e&&e.type.__scopeId||null,t}function Tx(e){Zr=e}function Ex(){Zr=null}const Ax=e=>su;function su(e,t=ms,s){if(!t||e._n)return e;const a=(...n)=>{a._d&&Zl(-1);const i=Gl(t);let l;try{l=e(...n)}finally{Gl(i),a._d&&Zl(1)}return l};return a._n=!0,a._c=!0,a._d=!0,a}function Rx(e,t){if(ms===null)return e;const s=vo(ms),a=e.dirs||(e.dirs=[]);for(let n=0;n1)return s&&Ve(t)?t.call(a&&a.proxy):t}}function Ix(){return!!(Ds()||Yn)}const lv=Symbol.for("v-scx"),ov=()=>ra(lv);function Ox(e,t){return po(e,null,t)}function Lx(e,t){return po(e,null,{flush:"post"})}function rv(e,t){return po(e,null,{flush:"sync"})}function Gt(e,t,s){return po(e,t,s)}function po(e,t,s=ct){const{immediate:a,deep:n,flush:i,once:l}=s,o=lt({},s),r=t&&a||!t&&i!=="post";let c;if(ai){if(i==="sync"){const m=ov();c=m.__watcherHandles||(m.__watcherHandles=[])}else if(!r){const m=()=>{};return m.stop=vs,m.resume=vs,m.pause=vs,m}}const d=fs;o.call=(m,h,y)=>Ys(m,d,h,y);let u=!1;i==="post"?o.scheduler=m=>{Qt(m,d&&d.suspense)}:i!=="sync"&&(u=!0,o.scheduler=(m,h)=>{h?m():tu(m)}),o.augmentJob=m=>{t&&(m.flags|=4),u&&(m.flags|=2,d&&(m.id=d.uid,m.i=d))};const p=bx(e,t,o);return ai&&(c?c.push(p):r&&p()),p}function Nx(e,t,s){const a=this.proxy,n=Ze(e)?e.includes(".")?cv(a,e):()=>a[e]:e.bind(a,a);let i;Ve(t)?i=t:(i=t.handler,s=t);const l=sl(this),o=po(n,i.bind(a),s);return l(),o}function cv(e,t){const s=t.split(".");return()=>{let a=e;for(let n=0;ne.__isTeleport,Wn=e=>e&&(e.disabled||e.disabled===""),Mx=e=>e&&(e.defer||e.defer===""),xp=e=>typeof SVGElement<"u"&&e instanceof SVGElement,_p=e=>typeof MathMLElement=="function"&&e instanceof MathMLElement,rd=(e,t)=>{const s=e&&e.to;return Ze(s)?t?t(s):null:s},Dx={name:"Teleport",__isTeleport:!0,process(e,t,s,a,n,i,l,o,r,c){const{mc:d,pc:u,pbc:p,o:{insert:m,querySelector:h,createText:y,createComment:k,parentNode:A}}=c,g=Wn(t.props);let{dynamicChildren:b}=t;const x=(T,L,E)=>{T.shapeFlag&16&&d(T.children,L,E,n,i,l,o,r)},w=(T=t)=>{const L=Wn(T.props),E=T.target=rd(T.props,h),F=cd(E,T,y,m);E&&(l!=="svg"&&xp(E)?l="svg":l!=="mathml"&&_p(E)&&(l="mathml"),n&&n.isCE&&(n.ce._teleportTargets||(n.ce._teleportTargets=new Set)).add(E),L||(x(T,E,F),Tl(T,!1)))},_=T=>{const L=()=>{if(Sn.get(T)===L){if(Sn.delete(T),Wn(T.props)){const E=A(T.el)||s;x(T,E,T.anchor),Tl(T,!0)}w(T)}};Sn.set(T,L),Qt(L,i)};if(e==null){const T=t.el=y(""),L=t.anchor=y("");if(m(T,s,a),m(L,s,a),Mx(t.props)||i&&i.pendingBranch){_(t);return}g&&(x(t,s,L),Tl(t,!0)),w()}else{t.el=e.el;const T=t.anchor=e.anchor,L=Sn.get(e);if(L){L.flags|=8,Sn.delete(e),_(t);return}t.targetStart=e.targetStart;const E=t.target=e.target,F=t.targetAnchor=e.targetAnchor,O=Wn(e.props),I=O?s:E,H=O?T:F;if(l==="svg"||xp(E)?l="svg":(l==="mathml"||_p(E))&&(l="mathml"),b?(p(e.dynamicChildren,b,I,n,i,l,o),fu(e,t,!0)):r||u(e,t,I,H,n,i,l,o,!1),g)O?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):Lo(t,s,T,c,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const te=t.target=rd(t.props,h);te&&Lo(t,te,null,c,0)}else O&&Lo(t,E,F,c,1);Tl(t,g)}},remove(e,t,s,{um:a,o:{remove:n}},i){const{shapeFlag:l,children:o,anchor:r,targetStart:c,targetAnchor:d,target:u,props:p}=e,m=i||!Wn(p),h=Sn.get(e);if(h&&(h.flags|=8,Sn.delete(e)),u&&(n(c),n(d)),i&&n(r),!h&&l&16)for(let y=0;y{e.isMounted=!0}),ec(()=>{e.isUnmounting=!0}),e}const aa=[Function,Array],nu={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:aa,onEnter:aa,onAfterEnter:aa,onEnterCancelled:aa,onBeforeLeave:aa,onLeave:aa,onAfterLeave:aa,onLeaveCancelled:aa,onBeforeAppear:aa,onAppear:aa,onAfterAppear:aa,onAppearCancelled:aa},pv=e=>{const t=e.subTree;return t.component?pv(t.component):t},Fx={name:"BaseTransition",props:nu,setup(e,{slots:t}){const s=Ds(),a=au();return()=>{const n=t.default&&Qr(t.default(),!0),i=n&&n.length?fv(n):s.subTree?Jv():void 0;if(!i)return;const l=ft(e),{mode:o}=l;if(a.isLeaving)return Mc(i);const r=wp(i);if(!r)return Mc(i);let c=ji(r,l,a,s,u=>c=u);r.type!==Zt&&rn(r,c);let d=s.subTree&&wp(s.subTree);if(d&&d.type!==Zt&&!fa(d,r)&&pv(s).type!==Zt){let u=ji(d,l,a,s);if(rn(d,u),o==="out-in"&&r.type!==Zt)return a.isLeaving=!0,u.afterLeave=()=>{a.isLeaving=!1,s.job.flags&8||s.update(),delete u.afterLeave,d=void 0},Mc(i);o==="in-out"&&r.type!==Zt?u.delayLeave=(p,m,h)=>{const y=vv(a,d);y[String(d.key)]=d,p[na]=()=>{m(),p[na]=void 0,delete c.delayedLeave,d=void 0},c.delayedLeave=()=>{h(),delete c.delayedLeave,d=void 0}}:d=void 0}else d&&(d=void 0);return i}}};function fv(e){let t=e[0];if(e.length>1){for(const s of e)if(s.type!==Zt){t=s;break}}return t}const mv=Fx;function vv(e,t){const{leavingVNodes:s}=e;let a=s.get(t.type);return a||(a=Object.create(null),s.set(t.type,a)),a}function ji(e,t,s,a,n){const{appear:i,mode:l,persisted:o=!1,onBeforeEnter:r,onEnter:c,onAfterEnter:d,onEnterCancelled:u,onBeforeLeave:p,onLeave:m,onAfterLeave:h,onLeaveCancelled:y,onBeforeAppear:k,onAppear:A,onAfterAppear:g,onAppearCancelled:b}=t,x=String(e.key),w=vv(s,e),_=(E,F)=>{E&&Ys(E,a,9,F)},T=(E,F)=>{const O=F[1];_(E,F),Me(E)?E.every(I=>I.length<=1)&&O():E.length<=1&&O()},L={mode:l,persisted:o,beforeEnter(E){let F=r;if(!s.isMounted)if(i)F=k||r;else return;E[na]&&E[na](!0);const O=w[x];O&&fa(e,O)&&O.el[na]&&O.el[na](),_(F,[E])},enter(E){if(w[x]===e)return;let F=c,O=d,I=u;if(!s.isMounted)if(i)F=A||c,O=g||d,I=b||u;else return;let H=!1;E[pl]=ee=>{H||(H=!0,ee?_(I,[E]):_(O,[E]),L.delayedLeave&&L.delayedLeave(),E[pl]=void 0)};const te=E[pl].bind(null,!1);F?T(F,[E,te]):te()},leave(E,F){const O=String(e.key);if(E[pl]&&E[pl](!0),s.isUnmounting)return F();_(p,[E]);let I=!1;E[na]=te=>{I||(I=!0,F(),te?_(y,[E]):_(h,[E]),E[na]=void 0,w[O]===e&&delete w[O])};const H=E[na].bind(null,!1);w[O]=e,m?T(m,[E,H]):H()},clone(E){const F=ji(E,t,s,a,n);return n&&n(F),F}};return L}function Mc(e){if(mo(e))return e=Ma(e),e.children=null,e}function wp(e){if(!mo(e))return uv(e.type)&&e.children?fv(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:s}=e;if(s){if(t&16)return s[0];if(t&32&&Ve(s.default))return s.default()}}function rn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,rn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Qr(e,t=!1,s){let a=[],n=0;for(let i=0;i1)for(let i=0;is.value,set:i=>s.value=i})}return s}function kp(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const mr=new WeakMap;function $i(e,t,s,a,n=!1){if(Me(e)){e.forEach((y,k)=>$i(y,t&&(Me(t)?t[k]:t),s,a,n));return}if(sn(a)&&!n){a.shapeFlag&512&&a.type.__asyncResolved&&a.component.subTree.component&&$i(e,t,s,a.component.subTree);return}const i=a.shapeFlag&4?vo(a.component):a.el,l=n?null:i,{i:o,r}=e,c=t&&t.r,d=o.refs===ct?o.refs={}:o.refs,u=o.setupState,p=ft(u),m=u===ct?Ri:y=>kp(d,y)?!1:bt(p,y),h=(y,k)=>!(k&&kp(d,k));if(c!=null&&c!==r){if(Sp(t),Ze(c))d[c]=null,m(c)&&(u[c]=null);else if(Xt(c)){const y=t;h(c,y.k)&&(c.value=null),y.k&&(d[y.k]=null)}}if(Ve(r))tl(r,o,12,[l,d]);else{const y=Ze(r),k=Xt(r);if(y||k){const A=()=>{if(e.f){const g=y?m(r)?u[r]:d[r]:h()||!e.k?r.value:d[e.k];if(n)Me(g)&&Gd(g,i);else if(Me(g))g.includes(i)||g.push(i);else if(y)d[r]=[i],m(r)&&(u[r]=d[r]);else{const b=[i];h(r,e.k)&&(r.value=b),e.k&&(d[e.k]=b)}}else y?(d[r]=l,m(r)&&(u[r]=l)):k&&(h(r,e.k)&&(r.value=l),e.k&&(d[e.k]=l))};if(l){const g=()=>{A(),mr.delete(e)};g.id=-1,mr.set(e,g),Qt(g,s)}else Sp(e),A()}}}function Sp(e){const t=mr.get(e);t&&(t.flags|=8,mr.delete(e))}let Cp=!1;const xi=()=>{Cp||(console.error("Hydration completed but contains mismatches."),Cp=!0)},zx=e=>e.namespaceURI.includes("svg")&&e.tagName!=="foreignObject",Hx=e=>e.namespaceURI.includes("MathML"),No=e=>{if(e.nodeType===1){if(zx(e))return"svg";if(Hx(e))return"mathml"}},Ii=e=>e.nodeType===8;function jx(e){const{mt:t,p:s,o:{patchProp:a,createText:n,nextSibling:i,parentNode:l,remove:o,insert:r,createComment:c}}=e,d=(b,x)=>{if(!x.hasChildNodes()){s(null,b,x),fr(),x._vnode=b;return}u(x.firstChild,b,null,null,null),fr(),x._vnode=b},u=(b,x,w,_,T,L=!1)=>{L=L||!!x.dynamicChildren;const E=Ii(b)&&b.data==="[",F=()=>y(b,x,w,_,T,E),{type:O,ref:I,shapeFlag:H,patchFlag:te}=x;let ee=b.nodeType;x.el=b,te===-2&&(L=!1,x.dynamicChildren=null);let N=null;switch(O){case On:ee!==3?x.children===""?(r(x.el=n(""),l(b),b),N=b):N=F():(b.data!==x.children&&(xi(),b.data=x.children),N=i(b));break;case Zt:g(b)?(N=i(b),A(x.el=b.content.firstChild,b,w)):ee!==8||E?N=F():N=i(b);break;case Xn:if(E&&(b=i(b),ee=b.nodeType),ee===1||ee===3){N=b;const M=!x.children.length;for(let $=0;${L=L||!!x.dynamicChildren;const{type:E,props:F,patchFlag:O,shapeFlag:I,dirs:H,transition:te}=x,ee=E==="input"||E==="option";if(ee||O!==-1){H&&Ia(x,null,w,"created");let N=!1;if(g(b)){N=Uv(null,te)&&w&&w.vnode.props&&w.vnode.props.appear;const $=b.content.firstChild;if(N){const re=$.getAttribute("class");re&&($.$cls=re),te.beforeEnter($)}A($,b,w),x.el=b=$}if(I&16&&!(F&&(F.innerHTML||F.textContent))){let $=m(b.firstChild,x,b,w,_,T,L);for($&&!Mo(b,1)&&xi();$;){const re=$;$=$.nextSibling,o(re)}}else if(I&8){let $=x.children;$[0]===` -`&&(b.tagName==="PRE"||b.tagName==="TEXTAREA")&&($=$.slice(1));const{textContent:re}=b;re!==$&&re!==$.replace(/\r\n|\r/g,` -`)&&(Mo(b,0)||xi(),b.textContent=x.children)}if(F){if(ee||!L||O&48){const $=b.tagName.includes("-");for(const re in F)(ee&&(re.endsWith("value")||re==="indeterminate")||li(re)&&!en(re)||re[0]==="."||$&&!en(re))&&a(b,re,null,F[re],void 0,w)}else if(F.onClick)a(b,"onClick",null,F.onClick,void 0,w);else if(O&4&&tn(F.style))for(const $ in F.style)F.style[$]}let M;(M=F&&F.onVnodeBeforeMount)&&Bs(M,w,x),H&&Ia(x,null,w,"beforeMount"),((M=F&&F.onVnodeMounted)||H||N)&&qv(()=>{M&&Bs(M,w,x),N&&te.enter(b),H&&Ia(x,null,w,"mounted")},_)}return b.nextSibling},m=(b,x,w,_,T,L,E)=>{E=E||!!x.dynamicChildren;const F=x.children,O=F.length;let I=!1;for(let H=0;H{const{slotScopeIds:E}=x;E&&(T=T?T.concat(E):E);const F=l(b),O=m(i(b),x,F,w,_,T,L);return O&&Ii(O)&&O.data==="]"?i(x.anchor=O):(xi(),r(x.anchor=c("]"),F,O),O)},y=(b,x,w,_,T,L)=>{if(Mo(b.parentElement,1)||xi(),x.el=null,L){const O=k(b);for(;;){const I=i(b);if(I&&I!==O)o(I);else break}}const E=i(b),F=l(b);return o(b),s(null,x,F,E,w,_,No(F),T),w&&(w.vnode.el=x.el,sc(w,x.el)),E},k=(b,x="[",w="]")=>{let _=0;for(;b;)if(b=i(b),b&&Ii(b)&&(b.data===x&&_++,b.data===w)){if(_===0)return i(b);_--}return b},A=(b,x,w)=>{const _=x.parentNode;_&&_.replaceChild(b,x);let T=w;for(;T;)T.vnode.el===x&&(T.vnode.el=T.subTree.el=b),T=T.parent},g=b=>b.nodeType===1&&b.tagName==="TEMPLATE";return[d,u]}const Tp="data-allow-mismatch",qx={0:"text",1:"children",2:"class",3:"style",4:"attribute"};function Mo(e,t){if(t===0||t===1)for(;e&&!e.hasAttribute(Tp);)e=e.parentElement;const s=e&&e.getAttribute(Tp);if(s==null)return!1;if(s==="")return!0;{const a=s.split(",");return t===0&&a.includes("children")?!0:a.includes(qx[t])}}const Vx=qr().requestIdleCallback||(e=>setTimeout(e,1)),Gx=qr().cancelIdleCallback||(e=>clearTimeout(e)),Wx=(e=1e4)=>t=>{const s=Vx(t,{timeout:e});return()=>Gx(s)};function Kx(e){const{top:t,left:s,bottom:a,right:n}=e.getBoundingClientRect(),{innerHeight:i,innerWidth:l}=window;return(t>0&&t0&&a0&&s0&&n(t,s)=>{const a=new IntersectionObserver(n=>{for(const i of n)if(i.isIntersecting){a.disconnect(),t();break}},e);return s(n=>{if(n instanceof Element){if(Kx(n))return t(),a.disconnect(),!1;a.observe(n)}}),()=>a.disconnect()},Zx=e=>t=>{if(e){const s=matchMedia(e);if(s.matches)t();else return s.addEventListener("change",t,{once:!0}),()=>s.removeEventListener("change",t)}},Qx=(e=[])=>(t,s)=>{Ze(e)&&(e=[e]);let a=!1;const n=l=>{a||(a=!0,i(),t(),l.target.dispatchEvent(new l.constructor(l.type,l)))},i=()=>{s(l=>{for(const o of e)l.removeEventListener(o,n)})};return s(l=>{for(const o of e)l.addEventListener(o,n,{once:!0})}),i};function Yx(e,t){if(Ii(e)&&e.data==="["){let s=1,a=e.nextSibling;for(;a;){if(a.nodeType===1){if(t(a)===!1)break}else if(Ii(a))if(a.data==="]"){if(--s===0)break}else a.data==="["&&s++;a=a.nextSibling}}else t(e)}const sn=e=>!!e.type.__asyncLoader;function Xx(e){Ve(e)&&(e={loader:e});const{loader:t,loadingComponent:s,errorComponent:a,delay:n=200,hydrate:i,timeout:l,suspensible:o=!0,onError:r}=e;let c=null,d,u=0;const p=()=>(u++,c=null,m()),m=()=>{let h;return c||(h=c=t().catch(y=>{if(y=y instanceof Error?y:new Error(String(y)),r)return new Promise((k,A)=>{r(y,()=>k(p()),()=>A(y),u+1)});throw y}).then(y=>h!==c&&c?c:(y&&(y.__esModule||y[Symbol.toStringTag]==="Module")&&(y=y.default),d=y,y)))};return fo({name:"AsyncComponentWrapper",__asyncLoader:m,__asyncHydrate(h,y,k){let A=!1;(y.bu||(y.bu=[])).push(()=>A=!0);const g=()=>{A||k()},b=i?()=>{const x=i(g,w=>Yx(h,w));x&&(y.bum||(y.bum=[])).push(x)}:g;d?b():m().then(()=>!y.isUnmounted&&b())},get __asyncResolved(){return d},setup(){const h=fs;if(iu(h),d)return()=>Do(d,h);const y=w=>{c=null,ci(w,h,13,!a)};if(o&&h.suspense||ai)return m().then(w=>()=>Do(w,h)).catch(w=>(y(w),()=>a?Bt(a,{error:w}):null));const k=f(!1),A=f(),g=f(!!n);let b,x;return wt(()=>{b!=null&&clearTimeout(b),x!=null&&clearTimeout(x)}),n&&(x=setTimeout(()=>{h.isUnmounted||(g.value=!1)},n)),l!=null&&(b=setTimeout(()=>{if(!h.isUnmounted&&!k.value&&!A.value){const w=new Error(`Async component timed out after ${l}ms.`);y(w),A.value=w}},l)),m().then(()=>{h.isUnmounted||(k.value=!0,h.parent&&mo(h.parent.vnode)&&h.parent.update())}).catch(w=>{if(h.isUnmounted){c=null;return}y(w),A.value=w}),()=>{if(k.value&&d)return Do(d,h);if(A.value&&a)return Bt(a,{error:A.value});if(s&&!g.value)return Do(s,h)}}})}function Do(e,t){const{ref:s,props:a,children:n,ce:i}=t.vnode,l=Bt(e,a,n);return l.ref=s,l.ce=i,delete t.vnode.ce,l}const mo=e=>e.type.__isKeepAlive,e0={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(e,{slots:t}){const s=Ds(),a=s.ctx;if(!a.renderer)return()=>{const g=t.default&&t.default();return g&&g.length===1?g[0]:g};const n=new Map,i=new Set;let l=null;const o=s.suspense,{renderer:{p:r,m:c,um:d,o:{createElement:u}}}=a,p=u("div");a.activate=(g,b,x,w,_)=>{const T=g.component;c(g,b,x,0,o),r(T.vnode,g,b,x,T,o,w,g.slotScopeIds,_),Qt(()=>{T.isDeactivated=!1,T.a&&Di(T.a);const L=g.props&&g.props.onVnodeMounted;L&&Bs(L,T.parent,g)},o)},a.deactivate=g=>{const b=g.component;hr(b.m),hr(b.a),c(g,p,null,1,o),Qt(()=>{b.da&&Di(b.da);const x=g.props&&g.props.onVnodeUnmounted;x&&Bs(x,b.parent,g),b.isDeactivated=!0},o)};function m(g){Dc(g),d(g,s,o,!0)}function h(g){n.forEach((b,x)=>{const w=bd(sn(b)?b.type.__asyncResolved||{}:b.type);w&&!g(w)&&y(x)})}function y(g){const b=n.get(g);b&&(!l||!fa(b,l))?m(b):l&&Dc(l),n.delete(g),i.delete(g)}Gt(()=>[e.include,e.exclude],([g,b])=>{g&&h(x=>El(g,x)),b&&h(x=>!El(b,x))},{flush:"post",deep:!0});let k=null;const A=()=>{k!=null&&(gr(s.subTree.type)?Qt(()=>{n.set(k,Po(s.subTree))},s.subTree.suspense):n.set(k,Po(s.subTree)))};return st(A),Xr(A),ec(()=>{n.forEach(g=>{const{subTree:b,suspense:x}=s,w=Po(b);if(g.type===w.type&&g.key===w.key){Dc(w);const _=w.component.da;_&&Qt(_,x);return}m(g)})}),()=>{if(k=null,!t.default)return l=null;const g=t.default(),b=g[0];if(g.length>1)return l=null,g;if(!cn(b)||!(b.shapeFlag&4)&&!(b.shapeFlag&128))return l=null,b;let x=Po(b);if(x.type===Zt)return l=null,x;const w=x.type,_=bd(sn(x)?x.type.__asyncResolved||{}:w),{include:T,exclude:L,max:E}=e;if(T&&(!_||!El(T,_))||L&&_&&El(L,_))return x.shapeFlag&=-257,l=x,b;const F=x.key==null?w:x.key,O=n.get(F);return x.el&&(x=Ma(x),b.shapeFlag&128&&(b.ssContent=x)),k=F,O?(x.el=O.el,x.component=O.component,x.transition&&rn(x,x.transition),x.shapeFlag|=512,i.delete(F),i.add(F)):(i.add(F),E&&i.size>parseInt(E,10)&&y(i.values().next().value)),x.shapeFlag|=256,l=x,gr(b.type)?b:x}}},t0=e0;function El(e,t){return Me(e)?e.some(s=>El(s,t)):Ze(e)?e.split(",").includes(t):uy(e)?(e.lastIndex=0,e.test(t)):!1}function cs(e,t){hv(e,"a",t)}function es(e,t){hv(e,"da",t)}function hv(e,t,s=fs){const a=e.__wdc||(e.__wdc=()=>{let n=s;for(;n;){if(n.isDeactivated)return;n=n.parent}return e()});if(Yr(t,a,s),s){let n=s.parent;for(;n&&n.parent;)mo(n.parent.vnode)&&s0(a,t,s,n),n=n.parent}}function s0(e,t,s,a){const n=Yr(t,e,a,!0);wt(()=>{Gd(a[t],n)},s)}function Dc(e){e.shapeFlag&=-257,e.shapeFlag&=-513}function Po(e){return e.shapeFlag&128?e.ssContent:e}function Yr(e,t,s=fs,a=!1){if(s){const n=s[e]||(s[e]=[]),i=t.__weh||(t.__weh=(...l)=>{ln();const o=sl(s),r=Ys(t,s,e,l);return o(),on(),r});return a?n.unshift(i):n.push(i),i}}const dn=e=>(t,s=fs)=>{(!ai||e==="sp")&&Yr(e,(...a)=>t(...a),s)},gv=dn("bm"),st=dn("m"),lu=dn("bu"),Xr=dn("u"),ec=dn("bum"),wt=dn("um"),bv=dn("sp"),yv=dn("rtg"),xv=dn("rtc");function _v(e,t=fs){Yr("ec",e,t)}const ou="components",a0="directives";function n0(e,t){return ru(ou,e,!0,t)||e}const wv=Symbol.for("v-ndc");function i0(e){return Ze(e)?ru(ou,e,!1)||e:e||wv}function l0(e){return ru(a0,e)}function ru(e,t,s=!0,a=!1){const n=ms||fs;if(n){const i=n.type;if(e===ou){const o=bd(i,!1);if(o&&(o===t||o===Tt(t)||o===ri(Tt(t))))return i}const l=Ep(n[e]||i[e],t)||Ep(n.appContext[e],t);return!l&&a?i:l}}function Ep(e,t){return e&&(e[t]||e[Tt(t)]||e[ri(Tt(t))])}function o0(e,t,s,a){let n;const i=s&&s[a],l=Me(e);if(l||Ze(e)){const o=l&&tn(e);let r=!1,c=!1;o&&(r=!qs(e),c=Na(e),e=Wr(e)),n=new Array(e.length);for(let d=0,u=e.length;dt(o,r,void 0,i&&i[r]));else{const o=Object.keys(e);n=new Array(o.length);for(let r=0,c=o.length;r{const i=a.fn(...n);return i&&(i.key=a.key),i}:a.fn)}return e}function c0(e,t,s={},a,n){if(ms.ce||ms.parent&&sn(ms.parent)&&ms.parent.ce){const c=Object.keys(s).length>0;return t!=="default"&&(s.name=t),Jl(),br(rs,null,[Bt("slot",s,a&&a())],c?-2:64)}let i=e[t];i&&i._c&&(i._d=!1),Jl();const l=i&&cu(i(s)),o=s.key||l&&l.key,r=br(rs,{key:(o&&!Cs(o)?o:`_${t}`)+(!l&&a?"_fb":"")},l||(a?a():[]),l&&e._===1?64:-2);return!n&&r.scopeId&&(r.slotScopeIds=[r.scopeId+"-s"]),i&&i._c&&(i._d=!0),r}function cu(e){return e.some(t=>cn(t)?!(t.type===Zt||t.type===rs&&!cu(t.children)):!0)?e:null}function d0(e,t){const s={};for(const a in e)s[t&&/[A-Z]/.test(a)?`on:${a}`:Mi(a)]=e[a];return s}const dd=e=>e?Yv(e)?vo(e):dd(e.parent):null,Ml=lt(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>dd(e.parent),$root:e=>dd(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>du(e),$forceUpdate:e=>e.f||(e.f=()=>{tu(e.update)}),$nextTick:e=>e.n||(e.n=Ut.bind(e.proxy)),$watch:e=>Nx.bind(e)}),Pc=(e,t)=>e!==ct&&!e.__isScriptSetup&&bt(e,t),ud={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:a,data:n,props:i,accessCache:l,type:o,appContext:r}=e;if(t[0]!=="$"){const p=l[t];if(p!==void 0)switch(p){case 1:return a[t];case 2:return n[t];case 4:return s[t];case 3:return i[t]}else{if(Pc(a,t))return l[t]=1,a[t];if(n!==ct&&bt(n,t))return l[t]=2,n[t];if(bt(i,t))return l[t]=3,i[t];if(s!==ct&&bt(s,t))return l[t]=4,s[t];pd&&(l[t]=0)}}const c=Ml[t];let d,u;if(c)return t==="$attrs"&&ws(e.attrs,"get",""),c(e);if((d=o.__cssModules)&&(d=d[t]))return d;if(s!==ct&&bt(s,t))return l[t]=4,s[t];if(u=r.config.globalProperties,bt(u,t))return u[t]},set({_:e},t,s){const{data:a,setupState:n,ctx:i}=e;return Pc(n,t)?(n[t]=s,!0):a!==ct&&bt(a,t)?(a[t]=s,!0):bt(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:a,appContext:n,props:i,type:l}},o){let r;return!!(s[o]||e!==ct&&o[0]!=="$"&&bt(e,o)||Pc(t,o)||bt(i,o)||bt(a,o)||bt(Ml,o)||bt(n.config.globalProperties,o)||(r=l.__cssModules)&&r[o])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:bt(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}},u0=lt({},ud,{get(e,t){if(t!==Symbol.unscopables)return ud.get(e,t,e)},has(e,t){return t[0]!=="_"&&!by(t)}});function p0(){return null}function f0(){return null}function m0(e){}function v0(e){}function h0(){return null}function g0(){}function b0(e,t){return null}function y0(){return kv().slots}function x0(){return kv().attrs}function kv(e){const t=Ds();return t.setupContext||(t.setupContext=sh(t))}function Wl(e){return Me(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}function _0(e,t){const s=Wl(e);for(const a in t){if(a.startsWith("__skip"))continue;let n=s[a];n?Me(n)||Ve(n)?n=s[a]={type:n,default:t[a]}:n.default=t[a]:n===null&&(n=s[a]={default:t[a]}),n&&t[`__skip_${a}`]&&(n.skipFactory=!0)}return s}function w0(e,t){return!e||!t?e||t:Me(e)&&Me(t)?e.concat(t):lt({},Wl(e),Wl(t))}function k0(e,t){const s={};for(const a in e)t.includes(a)||Object.defineProperty(s,a,{enumerable:!0,get:()=>e[a]});return s}function S0(e){const t=Ds(),s=ai;let a=e();Ql(),s&&Bi(!1);const n=()=>{sl(t),s&&Bi(!0)},i=()=>{Ds()!==t&&t.scope.off(),Ql(),s&&Bi(!1)};return Wd(a)&&(a=a.catch(l=>{throw n(),Promise.resolve().then(()=>Promise.resolve().then(i)),l})),[a,()=>{n(),Promise.resolve().then(i)}]}let pd=!0;function C0(e){const t=du(e),s=e.proxy,a=e.ctx;pd=!1,t.beforeCreate&&Ap(t.beforeCreate,e,"bc");const{data:n,computed:i,methods:l,watch:o,provide:r,inject:c,created:d,beforeMount:u,mounted:p,beforeUpdate:m,updated:h,activated:y,deactivated:k,beforeDestroy:A,beforeUnmount:g,destroyed:b,unmounted:x,render:w,renderTracked:_,renderTriggered:T,errorCaptured:L,serverPrefetch:E,expose:F,inheritAttrs:O,components:I,directives:H,filters:te}=t;if(c&&T0(c,a,null),l)for(const M in l){const $=l[M];Ve($)&&(a[M]=$.bind(s))}if(n){const M=n.call(s,s);ht(M)&&(e.data=Mn(M))}if(pd=!0,i)for(const M in i){const $=i[M],re=Ve($)?$.bind(s,s):Ve($.get)?$.get.bind(s,s):vs,ce=!Ve($)&&Ve($.set)?$.set.bind(s):vs,Z=B({get:re,set:ce});Object.defineProperty(a,M,{enumerable:!0,configurable:!0,get:()=>Z.value,set:Q=>Z.value=Q})}if(o)for(const M in o)Sv(o[M],a,s,M);if(r){const M=Ve(r)?r.call(s):r;Reflect.ownKeys(M).forEach($=>{Nl($,M[$])})}d&&Ap(d,e,"c");function N(M,$){Me($)?$.forEach(re=>M(re.bind(s))):$&&M($.bind(s))}if(N(gv,u),N(st,p),N(lu,m),N(Xr,h),N(cs,y),N(es,k),N(_v,L),N(xv,_),N(yv,T),N(ec,g),N(wt,x),N(bv,E),Me(F))if(F.length){const M=e.exposed||(e.exposed={});F.forEach($=>{Object.defineProperty(M,$,{get:()=>s[$],set:re=>s[$]=re,enumerable:!0})})}else e.exposed||(e.exposed={});w&&e.render===vs&&(e.render=w),O!=null&&(e.inheritAttrs=O),I&&(e.components=I),H&&(e.directives=H),E&&iu(e)}function T0(e,t,s=vs){Me(e)&&(e=fd(e));for(const a in e){const n=e[a];let i;ht(n)?"default"in n?i=ra(n.from||a,n.default,!0):i=ra(n.from||a):i=ra(n),Xt(i)?Object.defineProperty(t,a,{enumerable:!0,configurable:!0,get:()=>i.value,set:l=>i.value=l}):t[a]=i}}function Ap(e,t,s){Ys(Me(e)?e.map(a=>a.bind(t.proxy)):e.bind(t.proxy),t,s)}function Sv(e,t,s,a){let n=a.includes(".")?cv(s,a):()=>s[a];if(Ze(e)){const i=t[e];Ve(i)&&Gt(n,i)}else if(Ve(e))Gt(n,e.bind(s));else if(ht(e))if(Me(e))e.forEach(i=>Sv(i,t,s,a));else{const i=Ve(e.handler)?e.handler.bind(s):t[e.handler];Ve(i)&&Gt(n,i,e)}}function du(e){const t=e.type,{mixins:s,extends:a}=t,{mixins:n,optionsCache:i,config:{optionMergeStrategies:l}}=e.appContext,o=i.get(t);let r;return o?r=o:!n.length&&!s&&!a?r=t:(r={},n.length&&n.forEach(c=>vr(r,c,l,!0)),vr(r,t,l)),ht(t)&&i.set(t,r),r}function vr(e,t,s,a=!1){const{mixins:n,extends:i}=t;i&&vr(e,i,s,!0),n&&n.forEach(l=>vr(e,l,s,!0));for(const l in t)if(!(a&&l==="expose")){const o=E0[l]||s&&s[l];e[l]=o?o(e[l],t[l]):t[l]}return e}const E0={data:Rp,props:Ip,emits:Ip,methods:Al,computed:Al,beforeCreate:Is,created:Is,beforeMount:Is,mounted:Is,beforeUpdate:Is,updated:Is,beforeDestroy:Is,beforeUnmount:Is,destroyed:Is,unmounted:Is,activated:Is,deactivated:Is,errorCaptured:Is,serverPrefetch:Is,components:Al,directives:Al,watch:R0,provide:Rp,inject:A0};function Rp(e,t){return t?e?function(){return lt(Ve(e)?e.call(this,this):e,Ve(t)?t.call(this,this):t)}:t:e}function A0(e,t){return Al(fd(e),fd(t))}function fd(e){if(Me(e)){const t={};for(let s=0;s{let d,u=ct,p;return rv(()=>{const m=e[n];os(d,m)&&(d=m,c())}),{get(){return r(),s.get?s.get(d):d},set(m){const h=s.set?s.set(m):m;if(!os(h,d)&&!(u!==ct&&os(m,u)))return;const y=a.vnode.props,k=!!(y&&(t in y||n in y||i in y)&&(`onUpdate:${t}`in y||`onUpdate:${n}`in y||`onUpdate:${i}`in y));k||(d=m,c()),a.emit(`update:${t}`,h),os(m,u)&&(os(m,h)&&!os(h,p)||k&&u!==ct&&!os(h,d))&&c(),u=m,p=h}}});return o[Symbol.iterator]=()=>{let r=0;return{next(){return r<2?{value:r++?l||ct:o,done:!1}:{done:!0}}}},o}const Tv=(e,t)=>t==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Tt(t)}Modifiers`]||e[`${Hs(t)}Modifiers`];function N0(e,t,...s){if(e.isUnmounted)return;const a=e.vnode.props||ct;let n=s;const i=t.startsWith("update:"),l=i&&Tv(a,t.slice(7));l&&(l.trim&&(n=s.map(d=>Ze(d)?d.trim():d)),l.number&&(n=s.map(jr)));let o,r=a[o=Mi(t)]||a[o=Mi(Tt(t))];!r&&i&&(r=a[o=Mi(Hs(t))]),r&&Ys(r,e,6,n);const c=a[o+"Once"];if(c){if(!e.emitted)e.emitted={};else if(e.emitted[o])return;e.emitted[o]=!0,Ys(c,e,6,n)}}const M0=new WeakMap;function Ev(e,t,s=!1){const a=s?M0:t.emitsCache,n=a.get(e);if(n!==void 0)return n;const i=e.emits;let l={},o=!1;if(!Ve(e)){const r=c=>{const d=Ev(c,t,!0);d&&(o=!0,lt(l,d))};!s&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}return!i&&!o?(ht(e)&&a.set(e,null),null):(Me(i)?i.forEach(r=>l[r]=null):lt(l,i),ht(e)&&a.set(e,l),l)}function tc(e,t){return!e||!li(t)?!1:(t=t.slice(2).replace(/Once$/,""),bt(e,t[0].toLowerCase()+t.slice(1))||bt(e,Hs(t))||bt(e,t))}function er(e){const{type:t,vnode:s,proxy:a,withProxy:n,propsOptions:[i],slots:l,attrs:o,emit:r,render:c,renderCache:d,props:u,data:p,setupState:m,ctx:h,inheritAttrs:y}=e,k=Gl(e);let A,g;try{if(s.shapeFlag&4){const x=n||a,w=x;A=zs(c.call(w,x,d,u,m,p,h)),g=o}else{const x=t;A=zs(x.length>1?x(u,{attrs:o,slots:l,emit:r}):x(u,null)),g=t.props?o:P0(o)}}catch(x){Dl.length=0,ci(x,e,1),A=Bt(Zt)}let b=A;if(g&&y!==!1){const x=Object.keys(g),{shapeFlag:w}=b;x.length&&w&7&&(i&&x.some(Br)&&(g=$0(g,i)),b=Ma(b,g,!1,!0))}return s.dirs&&(b=Ma(b,null,!1,!0),b.dirs=b.dirs?b.dirs.concat(s.dirs):s.dirs),s.transition&&rn(b,s.transition),A=b,Gl(k),A}function D0(e,t=!0){let s;for(let a=0;a{let t;for(const s in e)(s==="class"||s==="style"||li(s))&&((t||(t={}))[s]=e[s]);return t},$0=(e,t)=>{const s={};for(const a in e)(!Br(a)||!(a.slice(9)in t))&&(s[a]=e[a]);return s};function F0(e,t,s){const{props:a,children:n,component:i}=e,{props:l,children:o,patchFlag:r}=t,c=i.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&r>=0){if(r&1024)return!0;if(r&16)return a?Op(a,l,c):!!l;if(r&8){const d=t.dynamicProps;for(let u=0;uObject.create(Rv),Ov=e=>Object.getPrototypeOf(e)===Rv;function B0(e,t,s,a=!1){const n={},i=Iv();e.propsDefaults=Object.create(null),Lv(e,t,n,i);for(const l in e.propsOptions[0])l in n||(n[l]=void 0);s?e.props=a?n:Yd(n):e.type.props?e.props=n:e.props=i,e.attrs=i}function U0(e,t,s,a){const{props:n,attrs:i,vnode:{patchFlag:l}}=e,o=ft(n),[r]=e.propsOptions;let c=!1;if((a||l>0)&&!(l&16)){if(l&8){const d=e.vnode.dynamicProps;for(let u=0;u{r=!0;const[p,m]=Nv(u,t,!0);lt(l,p),m&&o.push(...m)};!s&&t.mixins.length&&t.mixins.forEach(d),e.extends&&d(e.extends),e.mixins&&e.mixins.forEach(d)}if(!i&&!r)return ht(e)&&a.set(e,Li),Li;if(Me(i))for(let d=0;de==="_"||e==="_ctx"||e==="$stable",pu=e=>Me(e)?e.map(zs):[zs(e)],H0=(e,t,s)=>{if(t._n)return t;const a=su((...n)=>pu(t(...n)),s);return a._c=!1,a},Mv=(e,t,s)=>{const a=e._ctx;for(const n in e){if(uu(n))continue;const i=e[n];if(Ve(i))t[n]=H0(n,i,a);else if(i!=null){const l=pu(i);t[n]=()=>l}}},Dv=(e,t)=>{const s=pu(t);e.slots.default=()=>s},Pv=(e,t,s)=>{for(const a in t)(s||!uu(a))&&(e[a]=t[a])},j0=(e,t,s)=>{const a=e.slots=Iv();if(e.vnode.shapeFlag&32){const n=t._;n?(Pv(a,t,s),s&&Am(a,"_",n,!0)):Mv(t,a)}else t&&Dv(e,t)},q0=(e,t,s)=>{const{vnode:a,slots:n}=e;let i=!0,l=ct;if(a.shapeFlag&32){const o=t._;o?s&&o===1?i=!1:Pv(n,t,s):(i=!t.$stable,Mv(t,n)),l=t}else t&&(Dv(e,t),l={default:1});if(i)for(const o in n)!uu(o)&&l[o]==null&&delete n[o]},Qt=qv;function $v(e){return Bv(e)}function Fv(e){return Bv(e,jx)}function Bv(e,t){const s=qr();s.__VUE__=!0;const{insert:a,remove:n,patchProp:i,createElement:l,createText:o,createComment:r,setText:c,setElementText:d,parentNode:u,nextSibling:p,setScopeId:m=vs,insertStaticContent:h}=e,y=(S,P,G,he=null,fe=null,me=null,we=void 0,J=null,ie=!!P.dynamicChildren)=>{if(S===P)return;S&&!fa(S,P)&&(he=le(S),Q(S,fe,me,!0),S=null),P.patchFlag===-2&&(ie=!1,P.dynamicChildren=null);const{type:X,ref:_e,shapeFlag:U}=P;switch(X){case On:k(S,P,G,he);break;case Zt:A(S,P,G,he);break;case Xn:S==null&&g(P,G,he,we);break;case rs:I(S,P,G,he,fe,me,we,J,ie);break;default:U&1?w(S,P,G,he,fe,me,we,J,ie):U&6?H(S,P,G,he,fe,me,we,J,ie):(U&64||U&128)&&X.process(S,P,G,he,fe,me,we,J,ie,j)}_e!=null&&fe?$i(_e,S&&S.ref,me,P||S,!P):_e==null&&S&&S.ref!=null&&$i(S.ref,null,me,S,!0)},k=(S,P,G,he)=>{if(S==null)a(P.el=o(P.children),G,he);else{const fe=P.el=S.el;P.children!==S.children&&c(fe,P.children)}},A=(S,P,G,he)=>{S==null?a(P.el=r(P.children||""),G,he):P.el=S.el},g=(S,P,G,he)=>{[S.el,S.anchor]=h(S.children,P,G,he,S.el,S.anchor)},b=({el:S,anchor:P},G,he)=>{let fe;for(;S&&S!==P;)fe=p(S),a(S,G,he),S=fe;a(P,G,he)},x=({el:S,anchor:P})=>{let G;for(;S&&S!==P;)G=p(S),n(S),S=G;n(P)},w=(S,P,G,he,fe,me,we,J,ie)=>{if(P.type==="svg"?we="svg":P.type==="math"&&(we="mathml"),S==null)_(P,G,he,fe,me,we,J,ie);else{const X=S.el&&S.el._isVueCE?S.el:null;try{X&&X._beginPatch(),E(S,P,fe,me,we,J,ie)}finally{X&&X._endPatch()}}},_=(S,P,G,he,fe,me,we,J)=>{let ie,X;const{props:_e,shapeFlag:U,transition:de,dirs:ge}=S;if(ie=S.el=l(S.type,me,_e&&_e.is,_e),U&8?d(ie,S.children):U&16&&L(S.children,ie,null,he,fe,$c(S,me),we,J),ge&&Ia(S,null,he,"created"),T(ie,S,S.scopeId,we,he),_e){for(const $e in _e)$e!=="value"&&!en($e)&&i(ie,$e,null,_e[$e],me,he);"value"in _e&&i(ie,"value",null,_e.value,me),(X=_e.onVnodeBeforeMount)&&Bs(X,he,S)}ge&&Ia(S,null,he,"beforeMount");const Ae=Uv(fe,de);Ae&&de.beforeEnter(ie),a(ie,P,G),((X=_e&&_e.onVnodeMounted)||Ae||ge)&&Qt(()=>{try{X&&Bs(X,he,S),Ae&&de.enter(ie),ge&&Ia(S,null,he,"mounted")}finally{}},fe)},T=(S,P,G,he,fe)=>{if(G&&m(S,G),he)for(let me=0;me{for(let X=ie;X{const J=P.el=S.el;let{patchFlag:ie,dynamicChildren:X,dirs:_e}=P;ie|=S.patchFlag&16;const U=S.props||ct,de=P.props||ct;let ge;if(G&&Hn(G,!1),(ge=de.onVnodeBeforeUpdate)&&Bs(ge,G,P,S),_e&&Ia(P,S,G,"beforeUpdate"),G&&Hn(G,!0),(U.innerHTML&&de.innerHTML==null||U.textContent&&de.textContent==null)&&d(J,""),X?F(S.dynamicChildren,X,J,G,he,$c(P,fe),me):we||$(S,P,J,null,G,he,$c(P,fe),me,!1),ie>0){if(ie&16)O(J,U,de,G,fe);else if(ie&2&&U.class!==de.class&&i(J,"class",null,de.class,fe),ie&4&&i(J,"style",U.style,de.style,fe),ie&8){const Ae=P.dynamicProps;for(let $e=0;$e{ge&&Bs(ge,G,P,S),_e&&Ia(P,S,G,"updated")},he)},F=(S,P,G,he,fe,me,we)=>{for(let J=0;J{if(P!==G){if(P!==ct)for(const me in P)!en(me)&&!(me in G)&&i(S,me,P[me],null,fe,he);for(const me in G){if(en(me))continue;const we=G[me],J=P[me];we!==J&&me!=="value"&&i(S,me,J,we,fe,he)}"value"in G&&i(S,"value",P.value,G.value,fe)}},I=(S,P,G,he,fe,me,we,J,ie)=>{const X=P.el=S?S.el:o(""),_e=P.anchor=S?S.anchor:o("");let{patchFlag:U,dynamicChildren:de,slotScopeIds:ge}=P;ge&&(J=J?J.concat(ge):ge),S==null?(a(X,G,he),a(_e,G,he),L(P.children||[],G,_e,fe,me,we,J,ie)):U>0&&U&64&&de&&S.dynamicChildren&&S.dynamicChildren.length===de.length?(F(S.dynamicChildren,de,G,fe,me,we,J),(P.key!=null||fe&&P===fe.subTree)&&fu(S,P,!0)):$(S,P,G,_e,fe,me,we,J,ie)},H=(S,P,G,he,fe,me,we,J,ie)=>{P.slotScopeIds=J,S==null?P.shapeFlag&512?fe.ctx.activate(P,G,he,we,ie):te(P,G,he,fe,me,we,ie):ee(S,P,ie)},te=(S,P,G,he,fe,me,we)=>{const J=S.component=Qv(S,he,fe);if(mo(S)&&(J.ctx.renderer=j),Xv(J,!1,we),J.asyncDep){if(fe&&fe.registerDep(J,N,we),!S.el){const ie=J.subTree=Bt(Zt);A(null,ie,P,G),S.placeholder=ie.el}}else N(J,S,P,G,fe,me,we)},ee=(S,P,G)=>{const he=P.component=S.component;if(F0(S,P,G))if(he.asyncDep&&!he.asyncResolved){M(he,P,G);return}else he.next=P,he.update();else P.el=S.el,he.vnode=P},N=(S,P,G,he,fe,me,we)=>{const J=()=>{if(S.isMounted){let{next:U,bu:de,u:ge,parent:Ae,vnode:$e}=S;{const Qe=zv(S);if(Qe){U&&(U.el=$e.el,M(S,U,we)),Qe.asyncDep.then(()=>{Qt(()=>{S.isUnmounted||X()},fe)});return}}let We=U,je;Hn(S,!1),U?(U.el=$e.el,M(S,U,we)):U=$e,de&&Di(de),(je=U.props&&U.props.onVnodeBeforeUpdate)&&Bs(je,Ae,U,$e),Hn(S,!0);const Xe=er(S),it=S.subTree;S.subTree=Xe,y(it,Xe,u(it.el),le(it),S,fe,me),U.el=Xe.el,We===null&&sc(S,Xe.el),ge&&Qt(ge,fe),(je=U.props&&U.props.onVnodeUpdated)&&Qt(()=>Bs(je,Ae,U,$e),fe)}else{let U;const{el:de,props:ge}=P,{bm:Ae,m:$e,parent:We,root:je,type:Xe}=S,it=sn(P);if(Hn(S,!1),Ae&&Di(Ae),!it&&(U=ge&&ge.onVnodeBeforeMount)&&Bs(U,We,P),Hn(S,!0),de&&xe){const Qe=()=>{S.subTree=er(S),xe(de,S.subTree,S,fe,null)};it&&Xe.__asyncHydrate?Xe.__asyncHydrate(de,S,Qe):Qe()}else{je.ce&&je.ce._hasShadowRoot()&&je.ce._injectChildStyle(Xe,S.parent?S.parent.type:void 0);const Qe=S.subTree=er(S);y(null,Qe,G,he,S,fe,me),P.el=Qe.el}if($e&&Qt($e,fe),!it&&(U=ge&&ge.onVnodeMounted)){const Qe=P;Qt(()=>Bs(U,We,Qe),fe)}(P.shapeFlag&256||We&&sn(We.vnode)&&We.vnode.shapeFlag&256)&&S.a&&Qt(S.a,fe),S.isMounted=!0,P=G=he=null}};S.scope.on();const ie=S.effect=new zl(J);S.scope.off();const X=S.update=ie.run.bind(ie),_e=S.job=ie.runIfDirty.bind(ie);_e.i=S,_e.id=S.uid,ie.scheduler=()=>tu(_e),Hn(S,!0),X()},M=(S,P,G)=>{P.component=S;const he=S.vnode.props;S.vnode=P,S.next=null,U0(S,P.props,he,G),q0(S,P.children,G),ln(),yp(S),on()},$=(S,P,G,he,fe,me,we,J,ie=!1)=>{const X=S&&S.children,_e=S?S.shapeFlag:0,U=P.children,{patchFlag:de,shapeFlag:ge}=P;if(de>0){if(de&128){ce(X,U,G,he,fe,me,we,J,ie);return}else if(de&256){re(X,U,G,he,fe,me,we,J,ie);return}}ge&8?(_e&16&&Oe(X,fe,me),U!==X&&d(G,U)):_e&16?ge&16?ce(X,U,G,he,fe,me,we,J,ie):Oe(X,fe,me,!0):(_e&8&&d(G,""),ge&16&&L(U,G,he,fe,me,we,J,ie))},re=(S,P,G,he,fe,me,we,J,ie)=>{S=S||Li,P=P||Li;const X=S.length,_e=P.length,U=Math.min(X,_e);let de;for(de=0;de_e?Oe(S,fe,me,!0,!1,U):L(P,G,he,fe,me,we,J,ie,U)},ce=(S,P,G,he,fe,me,we,J,ie)=>{let X=0;const _e=P.length;let U=S.length-1,de=_e-1;for(;X<=U&&X<=de;){const ge=S[X],Ae=P[X]=ie?Wa(P[X]):zs(P[X]);if(fa(ge,Ae))y(ge,Ae,G,null,fe,me,we,J,ie);else break;X++}for(;X<=U&&X<=de;){const ge=S[U],Ae=P[de]=ie?Wa(P[de]):zs(P[de]);if(fa(ge,Ae))y(ge,Ae,G,null,fe,me,we,J,ie);else break;U--,de--}if(X>U){if(X<=de){const ge=de+1,Ae=ge<_e?P[ge].el:he;for(;X<=de;)y(null,P[X]=ie?Wa(P[X]):zs(P[X]),G,Ae,fe,me,we,J,ie),X++}}else if(X>de)for(;X<=U;)Q(S[X],fe,me,!0),X++;else{const ge=X,Ae=X,$e=new Map;for(X=Ae;X<=de;X++){const Le=P[X]=ie?Wa(P[X]):zs(P[X]);Le.key!=null&&$e.set(Le.key,X)}let We,je=0;const Xe=de-Ae+1;let it=!1,Qe=0;const se=new Array(Xe);for(X=0;X=Xe){Q(Le,fe,me,!0);continue}let Te;if(Le.key!=null)Te=$e.get(Le.key);else for(We=Ae;We<=de;We++)if(se[We-Ae]===0&&fa(Le,P[We])){Te=We;break}Te===void 0?Q(Le,fe,me,!0):(se[Te-Ae]=X+1,Te>=Qe?Qe=Te:it=!0,y(Le,P[Te],G,null,fe,me,we,J,ie),je++)}const Re=it?V0(se):Li;for(We=Re.length-1,X=Xe-1;X>=0;X--){const Le=Ae+X,Te=P[Le],W=P[Le+1],De=Le+1<_e?W.el||Hv(W):he;se[X]===0?y(null,Te,G,De,fe,me,we,J,ie):it&&(We<0||X!==Re[We]?Z(Te,G,De,2):We--)}}},Z=(S,P,G,he,fe=null)=>{const{el:me,type:we,transition:J,children:ie,shapeFlag:X}=S;if(X&6){Z(S.component.subTree,P,G,he);return}if(X&128){S.suspense.move(P,G,he);return}if(X&64){we.move(S,P,G,j);return}if(we===rs){a(me,P,G);for(let U=0;UJ.enter(me),fe));else{const{leave:U,delayLeave:de,afterLeave:ge}=J,Ae=()=>{S.ctx.isUnmounted?n(me):a(me,P,G)},$e=()=>{const We=me._isLeaving||!!me[na];me._isLeaving&&me[na](!0),J.persisted&&!We?Ae():U(me,()=>{Ae(),ge&&ge()})};de?de(me,Ae,$e):$e()}else a(me,P,G)},Q=(S,P,G,he=!1,fe=!1)=>{const{type:me,props:we,ref:J,children:ie,dynamicChildren:X,shapeFlag:_e,patchFlag:U,dirs:de,cacheIndex:ge,memo:Ae}=S;if(U===-2&&(fe=!1),J!=null&&(ln(),$i(J,null,G,S,!0),on()),ge!=null&&(P.renderCache[ge]=void 0),_e&256){P.ctx.deactivate(S);return}const $e=_e&1&&de,We=!sn(S);let je;if(We&&(je=we&&we.onVnodeBeforeUnmount)&&Bs(je,P,S),_e&6)be(S.component,G,he);else{if(_e&128){S.suspense.unmount(G,he);return}$e&&Ia(S,null,P,"beforeUnmount"),_e&64?S.type.remove(S,P,G,j,he):X&&!X.hasOnce&&(me!==rs||U>0&&U&64)?Oe(X,P,G,!1,!0):(me===rs&&U&384||!fe&&_e&16)&&Oe(ie,P,G),he&&ae(S)}const Xe=Ae!=null&&ge==null;(We&&(je=we&&we.onVnodeUnmounted)||$e||Xe)&&Qt(()=>{je&&Bs(je,P,S),$e&&Ia(S,null,P,"unmounted"),Xe&&(S.el=null)},G)},ae=S=>{const{type:P,el:G,anchor:he,transition:fe}=S;if(P===rs){V(G,he);return}if(P===Xn){x(S);return}const me=()=>{n(G),fe&&!fe.persisted&&fe.afterLeave&&fe.afterLeave()};if(S.shapeFlag&1&&fe&&!fe.persisted){const{leave:we,delayLeave:J}=fe,ie=()=>we(G,me);J?J(S.el,me,ie):ie()}else me()},V=(S,P)=>{let G;for(;S!==P;)G=p(S),n(S),S=G;n(P)},be=(S,P,G)=>{const{bum:he,scope:fe,job:me,subTree:we,um:J,m:ie,a:X}=S;hr(ie),hr(X),he&&Di(he),fe.stop(),me&&(me.flags|=8,Q(we,S,P,G)),J&&Qt(J,P),Qt(()=>{S.isUnmounted=!0},P)},Oe=(S,P,G,he=!1,fe=!1,me=0)=>{for(let we=me;we{if(S.shapeFlag&6)return le(S.component.subTree);if(S.shapeFlag&128)return S.suspense.next();const P=p(S.anchor||S.el),G=P&&P[dv];return G?p(G):P};let oe=!1;const ve=(S,P,G)=>{let he;S==null?P._vnode&&(Q(P._vnode,null,null,!0),he=P._vnode.component):y(P._vnode||null,S,P,null,null,null,G),P._vnode=S,oe||(oe=!0,yp(he),fr(),oe=!1)},j={p:y,um:Q,m:Z,r:ae,mt:te,mc:L,pc:$,pbc:F,n:le,o:e};let Y,xe;return t&&([Y,xe]=t(j)),{render:ve,hydrate:Y,createApp:O0(ve,Y)}}function $c({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function Hn({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Uv(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function fu(e,t,s=!1){const a=e.children,n=t.children;if(Me(a)&&Me(n))for(let i=0;i>1,e[s[o]]0&&(t[a]=s[i-1]),s[i]=a)}}for(i=s.length,l=s[i-1];i-- >0;)s[i]=l,l=t[l];return s}function zv(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:zv(t)}function hr(e){if(e)for(let t=0;te.__isSuspense;let vd=0;const G0={name:"Suspense",__isSuspense:!0,process(e,t,s,a,n,i,l,o,r,c){if(e==null)K0(t,s,a,n,i,l,o,r,c);else{if(i&&i.deps>0&&!e.suspense.isInFallback){t.suspense=e.suspense,t.suspense.vnode=t,t.el=e.el;return}J0(e,t,s,a,n,l,o,r,c)}},hydrate:Z0,normalize:Q0},W0=G0;function Kl(e,t){const s=e.props&&e.props[t];Ve(s)&&s()}function K0(e,t,s,a,n,i,l,o,r){const{p:c,o:{createElement:d}}=r,u=d("div"),p=e.suspense=jv(e,n,a,t,u,s,i,l,o,r);c(null,p.pendingBranch=e.ssContent,u,null,a,p,i,l),p.deps>0?(Kl(e,"onPending"),Kl(e,"onFallback"),c(null,e.ssFallback,t,s,a,null,i,l),Fi(p,e.ssFallback)):p.resolve(!1,!0)}function J0(e,t,s,a,n,i,l,o,{p:r,um:c,o:{createElement:d}}){const u=t.suspense=e.suspense;u.vnode=t,t.el=e.el;const p=t.ssContent,m=t.ssFallback,{activeBranch:h,pendingBranch:y,isInFallback:k,isHydrating:A}=u;if(y)u.pendingBranch=p,fa(y,p)?(r(y,p,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0?u.resolve():k&&(A||(r(h,m,s,a,n,null,i,l,o),Fi(u,m)))):(u.pendingId=vd++,A?(u.isHydrating=!1,u.activeBranch=y):c(y,n,u),u.deps=0,u.effects.length=0,u.hiddenContainer=d("div"),k?(r(null,p,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0?u.resolve():(r(h,m,s,a,n,null,i,l,o),Fi(u,m))):h&&fa(h,p)?(r(h,p,s,a,n,u,i,l,o),u.resolve(!0)):(r(null,p,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0&&u.resolve()));else if(h&&fa(h,p))r(h,p,s,a,n,u,i,l,o),Fi(u,p);else if(Kl(t,"onPending"),u.pendingBranch=p,p.shapeFlag&512?u.pendingId=p.component.suspenseId:u.pendingId=vd++,r(null,p,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0)u.resolve();else{const{timeout:g,pendingId:b}=u;g>0?setTimeout(()=>{u.pendingId===b&&u.fallback(m)},g):g===0&&u.fallback(m)}}function jv(e,t,s,a,n,i,l,o,r,c,d=!1){const{p:u,m:p,um:m,n:h,o:{parentNode:y,remove:k}}=c;let A;const g=Y0(e);g&&t&&t.pendingBranch&&(A=t.pendingId,t.deps++);const b=e.props?rr(e.props.timeout):void 0,x=i,w={vnode:e,parent:t,parentComponent:s,namespace:l,container:a,hiddenContainer:n,deps:0,pendingId:vd++,timeout:typeof b=="number"?b:-1,activeBranch:null,isFallbackMountPending:!1,pendingBranch:null,isInFallback:!d,isHydrating:d,isUnmounted:!1,effects:[],resolve(_=!1,T=!1){const{vnode:L,activeBranch:E,pendingBranch:F,pendingId:O,effects:I,parentComponent:H,container:te,isInFallback:ee}=w;let N=!1;if(w.isHydrating)w.isHydrating=!1;else if(!_){N=E&&F.transition&&F.transition.mode==="out-in";let re=!1;N&&(E.transition.afterLeave=()=>{O===w.pendingId&&(p(F,te,i===x&&!re?h(E):i,0),ql(I),ee&&L.ssFallback&&(L.ssFallback.el=null))}),E&&!w.isFallbackMountPending&&(y(E.el)===te&&(i=h(E),re=!0),m(E,H,w,!0),!N&&ee&&L.ssFallback&&Qt(()=>L.ssFallback.el=null,w)),N||p(F,te,i,0)}w.isFallbackMountPending=!1,Fi(w,F),w.pendingBranch=null,w.isInFallback=!1;let M=w.parent,$=!1;for(;M;){if(M.pendingBranch){M.effects.push(...I),$=!0;break}M=M.parent}!$&&!N&&ql(I),w.effects=[],g&&t&&t.pendingBranch&&A===t.pendingId&&(t.deps--,t.deps===0&&!T&&t.resolve()),Kl(L,"onResolve")},fallback(_){if(!w.pendingBranch)return;const{vnode:T,activeBranch:L,parentComponent:E,container:F,namespace:O}=w;Kl(T,"onFallback");const I=h(L),H=()=>{w.isFallbackMountPending=!1,w.isInFallback&&(u(null,_,F,I,E,null,O,o,r),Fi(w,_))},te=_.transition&&_.transition.mode==="out-in";te&&(w.isFallbackMountPending=!0,L.transition.afterLeave=H),w.isInFallback=!0,m(L,E,null,!0),te||H()},move(_,T,L){w.activeBranch&&p(w.activeBranch,_,T,L),w.container=_},next(){return w.activeBranch&&h(w.activeBranch)},registerDep(_,T,L){const E=!!w.pendingBranch;E&&w.deps++;const F=_.vnode.el;_.asyncDep.catch(O=>{ci(O,_,0)}).then(O=>{if(_.isUnmounted||w.isUnmounted||w.pendingId!==_.suspenseId)return;Ql(),_.asyncResolved=!0;const{vnode:I}=_;hd(_,O,!1),F&&(I.el=F);const H=!F&&_.subTree.el;T(_,I,y(F||_.subTree.el),F?null:h(_.subTree),w,l,L),H&&(I.placeholder=null,k(H)),sc(_,I.el),E&&--w.deps===0&&w.resolve()})},unmount(_,T){w.isUnmounted=!0,w.activeBranch&&m(w.activeBranch,s,_,T),w.pendingBranch&&m(w.pendingBranch,s,_,T)}};return w}function Z0(e,t,s,a,n,i,l,o,r){const c=t.suspense=jv(t,a,s,e.parentNode,document.createElement("div"),null,n,i,l,o,!0),d=r(e,c.pendingBranch=t.ssContent,s,c,i,l);return c.deps===0&&c.resolve(!1,!0),d}function Q0(e){const{shapeFlag:t,children:s}=e,a=t&32;e.ssContent=Np(a?s.default:s),e.ssFallback=a?Np(s.fallback):Bt(Zt)}function Np(e){let t;if(Ve(e)){const s=si&&e._c;s&&(e._d=!1,Jl()),e=e(),s&&(e._d=!0,t=ks,Vv())}return Me(e)&&(e=D0(e)),e=zs(e),t&&!e.dynamicChildren&&(e.dynamicChildren=t.filter(s=>s!==e)),e}function qv(e,t){t&&t.pendingBranch?Me(e)?t.effects.push(...e):t.effects.push(e):ql(e)}function Fi(e,t){e.activeBranch=t;const{vnode:s,parentComponent:a}=e;let n=t.el;for(;!n&&t.component;)t=t.component.subTree,n=t.el;s.el=n,a&&a.subTree===s&&(a.vnode.el=n,sc(a,n))}function Y0(e){const t=e.props&&e.props.suspensible;return t!=null&&t!==!1}const rs=Symbol.for("v-fgt"),On=Symbol.for("v-txt"),Zt=Symbol.for("v-cmt"),Xn=Symbol.for("v-stc"),Dl=[];let ks=null;function Jl(e=!1){Dl.push(ks=e?null:[])}function Vv(){Dl.pop(),ks=Dl[Dl.length-1]||null}let si=1;function Zl(e,t=!1){si+=e,e<0&&ks&&t&&(ks.hasOnce=!0)}function Gv(e){return e.dynamicChildren=si>0?ks||Li:null,Vv(),si>0&&ks&&ks.push(e),e}function X0(e,t,s,a,n,i){return Gv(mu(e,t,s,a,n,i,!0))}function br(e,t,s,a,n){return Gv(Bt(e,t,s,a,n,!0))}function cn(e){return e?e.__v_isVNode===!0:!1}function fa(e,t){return e.type===t.type&&e.key===t.key}function e_(e){}const Wv=({key:e})=>e??null,tr=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?Ze(e)||Xt(e)||Ve(e)?{i:ms,r:e,k:t,f:!!s}:e:null);function mu(e,t=null,s=null,a=0,n=null,i=e===rs?0:1,l=!1,o=!1){const r={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Wv(t),ref:t&&tr(t),scopeId:Zr,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:a,dynamicProps:n,dynamicChildren:null,appContext:null,ctx:ms};return o?(hu(r,s),i&128&&e.normalize(r)):s&&(r.shapeFlag|=Ze(s)?8:16),si>0&&!l&&ks&&(r.patchFlag>0||i&6)&&r.patchFlag!==32&&ks.push(r),r}const Bt=t_;function t_(e,t=null,s=null,a=0,n=null,i=!1){if((!e||e===wv)&&(e=Zt),cn(e)){const o=Ma(e,t,!0);return s&&hu(o,s),si>0&&!i&&ks&&(o.shapeFlag&6?ks[ks.indexOf(e)]=o:ks.push(o)),o.patchFlag=-2,o}if(r_(e)&&(e=e.__vccOpts),t){t=Kv(t);let{class:o,style:r}=t;o&&!Ze(o)&&(t.class=co(o)),ht(r)&&(uo(r)&&!Me(r)&&(r=lt({},r)),t.style=ro(r))}const l=Ze(e)?1:gr(e)?128:uv(e)?64:ht(e)?4:Ve(e)?2:0;return mu(e,t,s,a,n,l,i,!0)}function Kv(e){return e?uo(e)||Ov(e)?lt({},e):e:null}function Ma(e,t,s=!1,a=!1){const{props:n,ref:i,patchFlag:l,children:o,transition:r}=e,c=t?Zv(n||{},t):n,d={__v_isVNode:!0,__v_skip:!0,type:e.type,props:c,key:c&&Wv(c),ref:t&&t.ref?s&&i?Me(i)?i.concat(tr(t)):[i,tr(t)]:tr(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:o,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==rs?l===-1?16:l|16:l,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:r,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Ma(e.ssContent),ssFallback:e.ssFallback&&Ma(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return r&&a&&rn(d,r.clone(d)),d}function vu(e=" ",t=0){return Bt(On,null,e,t)}function s_(e,t){const s=Bt(Xn,null,e);return s.staticCount=t,s}function Jv(e="",t=!1){return t?(Jl(),br(Zt,null,e)):Bt(Zt,null,e)}function zs(e){return e==null||typeof e=="boolean"?Bt(Zt):Me(e)?Bt(rs,null,e.slice()):cn(e)?Wa(e):Bt(On,null,String(e))}function Wa(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Ma(e)}function hu(e,t){let s=0;const{shapeFlag:a}=e;if(t==null)t=null;else if(Me(t))s=16;else if(typeof t=="object")if(a&65){const n=t.default;n&&(n._c&&(n._d=!1),hu(e,n()),n._c&&(n._d=!0));return}else{s=32;const n=t._;!n&&!Ov(t)?t._ctx=ms:n===3&&ms&&(ms.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else Ve(t)?(t={default:t,_ctx:ms},s=32):(t=String(t),a&64?(s=16,t=[vu(t)]):s=8);e.children=t,e.shapeFlag|=s}function Zv(...e){const t={};for(let s=0;sfs||ms;let yr,Bi;{const e=qr(),t=(s,a)=>{let n;return(n=e[s])||(n=e[s]=[]),n.push(a),i=>{n.length>1?n.forEach(l=>l(i)):n[0](i)}};yr=t("__VUE_INSTANCE_SETTERS__",s=>fs=s),Bi=t("__VUE_SSR_SETTERS__",s=>ai=s)}const sl=e=>{const t=fs;return yr(e),e.scope.on(),()=>{e.scope.off(),yr(t)}},Ql=()=>{fs&&fs.scope.off(),yr(null)};function Yv(e){return e.vnode.shapeFlag&4}let ai=!1;function Xv(e,t=!1,s=!1){t&&Bi(t);const{props:a,children:n}=e.vnode,i=Yv(e);B0(e,a,i,t),j0(e,n,s||t);const l=i?i_(e,t):void 0;return t&&Bi(!1),l}function i_(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,ud);const{setup:a}=s;if(a){ln();const n=e.setupContext=a.length>1?sh(e):null,i=sl(e),l=tl(a,e,0,[e.props,n]),o=Wd(l);if(on(),i(),(o||e.sp)&&!sn(e)&&iu(e),o){if(l.then(Ql,Ql),t)return l.then(r=>{hd(e,r,t)}).catch(r=>{ci(r,e,0)});e.asyncDep=l}else hd(e,l,t)}else th(e,t)}function hd(e,t,s){Ve(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ht(t)&&(e.setupState=eu(t)),th(e,s)}let xr,gd;function eh(e){xr=e,gd=t=>{t.render._rc&&(t.withProxy=new Proxy(t.ctx,u0))}}const l_=()=>!xr;function th(e,t,s){const a=e.type;if(!e.render){if(!t&&xr&&!a.render){const n=a.template||du(e).template;if(n){const{isCustomElement:i,compilerOptions:l}=e.appContext.config,{delimiters:o,compilerOptions:r}=a,c=lt(lt({isCustomElement:i,delimiters:o},l),r);a.render=xr(n,c)}}e.render=a.render||vs,gd&&gd(e)}{const n=sl(e);ln();try{C0(e)}finally{on(),n()}}}const o_={get(e,t){return ws(e,"get",""),e[t]}};function sh(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,o_),slots:e.slots,emit:e.emit,expose:t}}function vo(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(eu(Zm(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in Ml)return Ml[s](e)},has(t,s){return s in t||s in Ml}})):e.proxy}function bd(e,t=!0){return Ve(e)?e.displayName||e.name:e.name||t&&e.__name}function r_(e){return Ve(e)&&"__vccOpts"in e}const B=(e,t)=>mx(e,t,ai);function qi(e,t,s){try{Zl(-1);const a=arguments.length;return a===2?ht(t)&&!Me(t)?cn(t)?Bt(e,null,[t]):Bt(e,t):Bt(e,null,t):(a>3?s=Array.prototype.slice.call(arguments,2):a===3&&cn(s)&&(s=[s]),Bt(e,t,s))}finally{Zl(1)}}function c_(){}function d_(e,t,s,a){const n=s[a];if(n&&ah(n,e))return n;const i=t();return i.memo=e.slice(),i.cacheIndex=a,s[a]=i}function ah(e,t){const s=e.memo;if(s.length!=t.length)return!1;for(let a=0;a0&&ks&&ks.push(e),!0}const nh="3.5.38",u_=vs,p_=kx,f_=Ti,m_=iv,v_={createComponentInstance:Qv,setupComponent:Xv,renderComponentRoot:er,setCurrentRenderingInstance:Gl,isVNode:cn,normalizeVNode:zs,getComponentPublicInstance:vo,ensureValidVNode:cu,pushWarningContext:yx,popWarningContext:xx},h_=v_,g_=null,b_=null,y_=null;/** -* @vue/runtime-dom v3.5.38 -* (c) 2018-present Yuxi (Evan) You and Vue contributors -* @license MIT -**/let yd;const Mp=typeof window<"u"&&window.trustedTypes;if(Mp)try{yd=Mp.createPolicy("vue",{createHTML:e=>e})}catch{}const ih=yd?e=>yd.createHTML(e):e=>e,x_="http://www.w3.org/2000/svg",__="http://www.w3.org/1998/Math/MathML",Ga=typeof document<"u"?document:null,Dp=Ga&&Ga.createElement("template"),lh={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,a)=>{const n=t==="svg"?Ga.createElementNS(x_,e):t==="mathml"?Ga.createElementNS(__,e):s?Ga.createElement(e,{is:s}):Ga.createElement(e);return e==="select"&&a&&a.multiple!=null&&n.setAttribute("multiple",a.multiple),n},createText:e=>Ga.createTextNode(e),createComment:e=>Ga.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ga.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,a,n,i){const l=s?s.previousSibling:t.lastChild;if(n&&(n===i||n.nextSibling))for(;t.insertBefore(n.cloneNode(!0),s),!(n===i||!(n=n.nextSibling)););else{Dp.innerHTML=ih(a==="svg"?`${e}`:a==="mathml"?`${e}`:e);const o=Dp.content;if(a==="svg"||a==="mathml"){const r=o.firstChild;for(;r.firstChild;)o.appendChild(r.firstChild);o.removeChild(r)}t.insertBefore(o,s)}return[l?l.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},_n="transition",fl="animation",Vi=Symbol("_vtc"),oh={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},rh=lt({},nu,oh),w_=e=>(e.displayName="Transition",e.props=rh,e),k_=w_((e,{slots:t})=>qi(mv,ch(e),t)),jn=(e,t=[])=>{Me(e)?e.forEach(s=>s(...t)):e&&e(...t)},Pp=e=>e?Me(e)?e.some(t=>t.length>1):e.length>1:!1;function ch(e){const t={};for(const I in e)I in oh||(t[I]=e[I]);if(e.css===!1)return t;const{name:s="v",type:a,duration:n,enterFromClass:i=`${s}-enter-from`,enterActiveClass:l=`${s}-enter-active`,enterToClass:o=`${s}-enter-to`,appearFromClass:r=i,appearActiveClass:c=l,appearToClass:d=o,leaveFromClass:u=`${s}-leave-from`,leaveActiveClass:p=`${s}-leave-active`,leaveToClass:m=`${s}-leave-to`}=e,h=S_(n),y=h&&h[0],k=h&&h[1],{onBeforeEnter:A,onEnter:g,onEnterCancelled:b,onLeave:x,onLeaveCancelled:w,onBeforeAppear:_=A,onAppear:T=g,onAppearCancelled:L=b}=t,E=(I,H,te,ee)=>{I._enterCancelled=ee,Cn(I,H?d:o),Cn(I,H?c:l),te&&te()},F=(I,H)=>{I._isLeaving=!1,Cn(I,u),Cn(I,m),Cn(I,p),H&&H()},O=I=>(H,te)=>{const ee=I?T:g,N=()=>E(H,I,te);jn(ee,[H,N]),$p(()=>{Cn(H,I?r:i),Ta(H,I?d:o),Pp(ee)||Fp(H,a,y,N)})};return lt(t,{onBeforeEnter(I){jn(A,[I]),Ta(I,i),Ta(I,l)},onBeforeAppear(I){jn(_,[I]),Ta(I,r),Ta(I,c)},onEnter:O(!1),onAppear:O(!0),onLeave(I,H){I._isLeaving=!0;const te=()=>F(I,H);Ta(I,u),I._enterCancelled?(Ta(I,p),xd(I)):(xd(I),Ta(I,p)),$p(()=>{I._isLeaving&&(Cn(I,u),Ta(I,m),Pp(x)||Fp(I,a,k,te))}),jn(x,[I,te])},onEnterCancelled(I){E(I,!1,void 0,!0),jn(b,[I])},onAppearCancelled(I){E(I,!0,void 0,!0),jn(L,[I])},onLeaveCancelled(I){F(I),jn(w,[I])}})}function S_(e){if(e==null)return null;if(ht(e))return[Fc(e.enter),Fc(e.leave)];{const t=Fc(e);return[t,t]}}function Fc(e){return rr(e)}function Ta(e,t){t.split(/\s+/).forEach(s=>s&&e.classList.add(s)),(e[Vi]||(e[Vi]=new Set)).add(t)}function Cn(e,t){t.split(/\s+/).forEach(a=>a&&e.classList.remove(a));const s=e[Vi];s&&(s.delete(t),s.size||(e[Vi]=void 0))}function $p(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let C_=0;function Fp(e,t,s,a){const n=e._endId=++C_,i=()=>{n===e._endId&&a()};if(s!=null)return setTimeout(i,s);const{type:l,timeout:o,propCount:r}=dh(e,t);if(!l)return a();const c=l+"end";let d=0;const u=()=>{e.removeEventListener(c,p),i()},p=m=>{m.target===e&&++d>=r&&u()};setTimeout(()=>{d(s[h]||"").split(", "),n=a(`${_n}Delay`),i=a(`${_n}Duration`),l=Bp(n,i),o=a(`${fl}Delay`),r=a(`${fl}Duration`),c=Bp(o,r);let d=null,u=0,p=0;t===_n?l>0&&(d=_n,u=l,p=i.length):t===fl?c>0&&(d=fl,u=c,p=r.length):(u=Math.max(l,c),d=u>0?l>c?_n:fl:null,p=d?d===_n?i.length:r.length:0);const m=d===_n&&/\b(?:transform|all)(?:,|$)/.test(a(`${_n}Property`).toString());return{type:d,timeout:u,propCount:p,hasTransform:m}}function Bp(e,t){for(;e.lengthUp(s)+Up(e[a])))}function Up(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function xd(e){return(e?e.ownerDocument:document).body.offsetHeight}function T_(e,t,s){const a=e[Vi];a&&(t=(t?[t,...a]:[...a]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const _r=Symbol("_vod"),gu=Symbol("_vsh"),uh={name:"show",beforeMount(e,{value:t},{transition:s}){e[_r]=e.style.display==="none"?"":e.style.display,s&&t?s.beforeEnter(e):ml(e,t)},mounted(e,{value:t},{transition:s}){s&&t&&s.enter(e)},updated(e,{value:t,oldValue:s},{transition:a}){!t!=!s&&(a?t?(a.beforeEnter(e),ml(e,!0),a.enter(e)):a.leave(e,()=>{ml(e,!1)}):ml(e,t))},beforeUnmount(e,{value:t}){ml(e,t)}};function ml(e,t){e.style.display=t?e[_r]:"none",e[gu]=!t}function E_(){uh.getSSRProps=({value:e})=>{if(!e)return{style:{display:"none"}}}}const ph=Symbol("");function A_(e){const t=Ds();if(!t)return;const s=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(i=>wr(i,n))},a=()=>{const n=e(t.proxy);t.ce?wr(t.ce,n):_d(t.subTree,n),s(n)};lu(()=>{ql(a)}),st(()=>{Gt(a,vs,{flush:"post"});const n=new MutationObserver(a);n.observe(t.subTree.el.parentNode,{childList:!0}),wt(()=>n.disconnect())})}function _d(e,t){if(e.shapeFlag&128){const s=e.suspense;e=s.activeBranch,s.pendingBranch&&!s.isHydrating&&s.effects.push(()=>{_d(s.activeBranch,t)})}for(;e.component;)e=e.component.subTree;if(e.shapeFlag&1&&e.el)wr(e.el,t);else if(e.type===rs)e.children.forEach(s=>_d(s,t));else if(e.type===Xn){let{el:s,anchor:a}=e;for(;s&&(wr(s,t),s!==a);)s=s.nextSibling}}function wr(e,t){if(e.nodeType===1){const s=e.style;let a="";for(const n in t){const i=My(t[n]);s.setProperty(`--${n}`,i),a+=`--${n}: ${i};`}s[ph]=a}}const R_=/(?:^|;)\s*display\s*:/;function I_(e,t,s){const a=e.style,n=Ze(s);let i=!1;if(s&&!n){if(t)if(Ze(t))for(const l of t.split(";")){const o=l.slice(0,l.indexOf(":")).trim();s[o]==null&&Rl(a,o,"")}else for(const l in t)s[l]==null&&Rl(a,l,"");for(const l in s){l==="display"&&(i=!0);const o=s[l];o!=null?L_(e,l,!Ze(t)&&t?t[l]:void 0,o)||Rl(a,l,o):Rl(a,l,"")}}else if(n){if(t!==s){const l=a[ph];l&&(s+=";"+l),a.cssText=s,i=R_.test(s)}}else t&&e.removeAttribute("style");_r in e&&(e[_r]=i?a.display:"",e[gu]&&(a.display="none"))}const zp=/\s*!important$/;function Rl(e,t,s){if(Me(s))s.forEach(a=>Rl(e,t,a));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const a=O_(e,t);zp.test(s)?e.setProperty(Hs(a),s.replace(zp,""),"important"):e[a]=s}}const Hp=["Webkit","Moz","ms"],Bc={};function O_(e,t){const s=Bc[t];if(s)return s;let a=Tt(t);if(a!=="filter"&&a in e)return Bc[t]=a;a=ri(a);for(let n=0;nUc||(P_.then(()=>Uc=0),Uc=Date.now());function F_(e,t){const s=a=>{if(!a._vts)a._vts=Date.now();else if(a._vts<=s.attached)return;const n=s.value;if(Me(n)){const i=a.stopImmediatePropagation;a.stopImmediatePropagation=()=>{i.call(a),a._stopped=!0};const l=n.slice(),o=[a];for(let r=0;re.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,fh=(e,t,s,a,n,i)=>{const l=n==="svg";t==="class"?T_(e,a,l):t==="style"?I_(e,s,a):li(t)?Br(t)||M_(e,t,s,a,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):B_(e,t,a,l))?(Vp(e,t,a),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&qp(e,t,a,l,i,t!=="value")):e._isVueCE&&(U_(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!Ze(a)))?Vp(e,Tt(t),a,i,t):(t==="true-value"?e._trueValue=a:t==="false-value"&&(e._falseValue=a),qp(e,t,a,l))};function B_(e,t,s,a){if(a)return!!(t==="innerHTML"||t==="textContent"||t in e&&Kp(t)&&Ve(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const n=e.tagName;if(n==="IMG"||n==="VIDEO"||n==="CANVAS"||n==="SOURCE")return!1}return Kp(t)&&Ze(s)?!1:t in e}function U_(e,t){const s=e._def.props;if(!s)return!1;const a=Tt(t);return Array.isArray(s)?s.some(n=>Tt(n)===a):Object.keys(s).some(n=>Tt(n)===a)}const Jp={};function mh(e,t,s){let a=fo(e,t);Ur(a)&&(a=lt({},a,t));class n extends ac{constructor(l){super(a,l,s)}}return n.def=a,n}const z_=((e,t)=>mh(e,t,Eh)),H_=typeof HTMLElement<"u"?HTMLElement:class{};class ac extends H_{constructor(t,s={},a=Cr){super(),this._def=t,this._props=s,this._createApp=a,this._isVueCE=!0,this._instance=null,this._app=null,this._nonce=this._def.nonce,this._connected=!1,this._resolved=!1,this._patching=!1,this._dirty=!1,this._numberProps=null,this._styleChildren=new WeakSet,this._styleAnchors=new WeakMap,this._ob=null,this.shadowRoot&&a!==Cr?this._root=this.shadowRoot:t.shadowRoot!==!1?(this.attachShadow(lt({},t.shadowRootOptions,{mode:"open"})),this._root=this.shadowRoot):this._root=this}connectedCallback(){if(!this.isConnected)return;!this.shadowRoot&&!this._resolved&&this._parseSlots(),this._connected=!0;let t=this;for(;t=t&&(t.assignedSlot||t.parentNode||t.host);)if(t instanceof ac){this._parent=t;break}this._instance||(this._resolved?this._mount(this._def):t&&t._pendingResolve?this._pendingResolve=t._pendingResolve.then(()=>{this._pendingResolve=void 0,this._resolveDef()}):this._resolveDef())}_setParent(t=this._parent){t&&(this._instance.parent=t._instance,this._inheritParentContext(t))}_inheritParentContext(t=this._parent){t&&this._app&&Object.setPrototypeOf(this._app._context.provides,t._instance.provides)}disconnectedCallback(){this._connected=!1,Ut(()=>{this._connected||(this._ob&&(this._ob.disconnect(),this._ob=null),this._app&&this._app.unmount(),this._instance&&(this._instance.ce=void 0),this._app=this._instance=null,this._teleportTargets&&(this._teleportTargets.clear(),this._teleportTargets=void 0))})}_processMutations(t){for(const s of t)this._setAttr(s.attributeName)}_resolveDef(){if(this._pendingResolve)return;for(let a=0;a{this._resolved=!0,this._pendingResolve=void 0;const{props:i,styles:l}=a;let o;if(i&&!Me(i))for(const r in i){const c=i[r];(c===Number||c&&c.type===Number)&&(r in this._props&&(this._props[r]=rr(this._props[r])),(o||(o=Object.create(null)))[Tt(r)]=!0)}this._numberProps=o,this._resolveProps(a),this.shadowRoot&&this._applyStyles(l),this._mount(a)},s=this._def.__asyncLoader;s?this._pendingResolve=s().then(a=>{a.configureApp=this._def.configureApp,t(this._def=a,!0)}):t(this._def)}_mount(t){this._app=this._createApp(t),this._inheritParentContext(),t.configureApp&&t.configureApp(this._app),this._app._ceVNode=this._createVNode(),this._app.mount(this._root);const s=this._instance&&this._instance.exposed;if(s)for(const a in s)bt(this,a)||Object.defineProperty(this,a,{get:()=>La(s[a])})}_resolveProps(t){const{props:s}=t,a=Me(s)?s:Object.keys(s||{});for(const n of Object.keys(this))n[0]!=="_"&&a.includes(n)&&this._setProp(n,this[n]);for(const n of a.map(Tt))Object.defineProperty(this,n,{get(){return this._getProp(n)},set(i){this._setProp(n,i,!0,!this._patching)}})}_setAttr(t){if(t.startsWith("data-v-"))return;const s=this.hasAttribute(t);let a=s?this.getAttribute(t):Jp;const n=Tt(t);s&&this._numberProps&&this._numberProps[n]&&(a=rr(a)),this._setProp(n,a,!1,!0)}_getProp(t){return this._props[t]}_setProp(t,s,a=!0,n=!1){if(s!==this._props[t]&&(this._dirty=!0,s===Jp?delete this._props[t]:(this._props[t]=s,t==="key"&&this._app&&(this._app._ceVNode.key=s)),n&&this._instance&&this._update(),a)){const i=this._ob;i&&(this._processMutations(i.takeRecords()),i.disconnect()),s===!0?this.setAttribute(Hs(t),""):typeof s=="string"||typeof s=="number"?this.setAttribute(Hs(t),s+""):s||this.removeAttribute(Hs(t)),i&&i.observe(this,{attributes:!0})}}_update(){const t=this._createVNode();this._app&&(t.appContext=this._app._context),Th(t,this._root)}_createVNode(){const t={};this.shadowRoot||(t.onVnodeMounted=t.onVnodeUpdated=this._renderSlots.bind(this));const s=Bt(this._def,lt(t,this._props));return this._instance||(s.ce=a=>{this._instance=a,a.ce=this,a.isCE=!0;const n=(i,l)=>{this.dispatchEvent(new CustomEvent(i,Ur(l[0])?lt({detail:l},l[0]):{detail:l}))};a.emit=(i,...l)=>{n(i,l),Hs(i)!==i&&n(Hs(i),l)},this._setParent()}),s}_applyStyles(t,s,a){if(!t)return;if(s){if(s===this._def||this._styleChildren.has(s))return;this._styleChildren.add(s)}const n=this._nonce,i=this.shadowRoot,l=a?this._getStyleAnchor(a)||this._getStyleAnchor(this._def):this._getRootStyleInsertionAnchor(i);let o=null;for(let r=t.length-1;r>=0;r--){const c=document.createElement("style");n&&c.setAttribute("nonce",n),c.textContent=t[r],i.insertBefore(c,o||l),o=c,r===0&&(a||this._styleAnchors.set(this._def,c),s&&this._styleAnchors.set(s,c))}}_getStyleAnchor(t){if(!t)return null;const s=this._styleAnchors.get(t);return s&&s.parentNode===this.shadowRoot?s:(s&&this._styleAnchors.delete(t),null)}_getRootStyleInsertionAnchor(t){for(let s=0;s(delete e.props.mode,e),G_=V_({name:"TransitionGroup",props:lt({},rh,{tag:String,moveClass:String}),setup(e,{slots:t}){const s=Ds(),a=au();let n,i;return Xr(()=>{if(!n.length)return;const l=e.moveClass||`${e.name||"v"}-move`;if(!Q_(n[0].el,s.vnode.el,l)){n=[];return}n.forEach(K_),n.forEach(J_);const o=n.filter(Z_);xd(s.vnode.el),o.forEach(r=>{const c=r.el,d=c.style;Ta(c,l),d.transform=d.webkitTransform=d.transitionDuration="";const u=c[kr]=p=>{p&&p.target!==c||(!p||p.propertyName.endsWith("transform"))&&(c.removeEventListener("transitionend",u),c[kr]=null,Cn(c,l))};c.addEventListener("transitionend",u)}),n=[]}),()=>{const l=ft(e),o=ch(l);let r=l.tag||rs;if(n=[],i)for(let c=0;c{o.split(/\s+/).forEach(r=>r&&a.classList.remove(r))}),s.split(/\s+/).forEach(o=>o&&a.classList.add(o)),a.style.display="none";const i=t.nodeType===1?t:t.parentNode;i.appendChild(a);const{hasTransform:l}=dh(a);return i.removeChild(a),l}const Nn=e=>{const t=e.props["onUpdate:modelValue"]||!1;return Me(t)?s=>Di(t,s):t};function Y_(e){e.target.composing=!0}function Qp(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const ca=Symbol("_assign");function Yp(e,t,s){return t&&(e=e.trim()),s&&(e=jr(e)),e}const Sr={created(e,{modifiers:{lazy:t,trim:s,number:a}},n){e[ca]=Nn(n);const i=a||n.props&&n.props.type==="number";Qa(e,t?"change":"input",l=>{l.target.composing||e[ca](Yp(e.value,s,i))}),(s||i)&&Qa(e,"change",()=>{e.value=Yp(e.value,s,i)}),t||(Qa(e,"compositionstart",Y_),Qa(e,"compositionend",Qp),Qa(e,"change",Qp))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:a,trim:n,number:i}},l){if(e[ca]=Nn(l),e.composing)return;const o=(i||e.type==="number")&&!/^0\d/.test(e.value)?jr(e.value):e.value,r=t??"";if(o===r)return;const c=e.getRootNode();(c instanceof Document||c instanceof ShadowRoot)&&c.activeElement===e&&e.type!=="range"&&(a&&t===s||n&&e.value.trim()===r)||(e.value=r)}},bu={deep:!0,created(e,t,s){e[ca]=Nn(s),Qa(e,"change",()=>{const a=e._modelValue,n=Gi(e),i=e.checked,l=e[ca];if(Me(a)){const o=Vr(a,n),r=o!==-1;if(i&&!r)l(a.concat(n));else if(!i&&r){const c=[...a];c.splice(o,1),l(c)}}else if(oi(a)){const o=new Set(a);i?o.add(n):o.delete(n),l(o)}else l(xh(e,i))})},mounted:Xp,beforeUpdate(e,t,s){e[ca]=Nn(s),Xp(e,t,s)}};function Xp(e,{value:t,oldValue:s},a){e._modelValue=t;let n;if(Me(t))n=Vr(t,a.props.value)>-1;else if(oi(t))n=t.has(a.props.value);else{if(t===s)return;n=nn(t,xh(e,!0))}e.checked!==n&&(e.checked=n)}const yu={created(e,{value:t},s){e.checked=nn(t,s.props.value),e[ca]=Nn(s),Qa(e,"change",()=>{e[ca](Gi(e))})},beforeUpdate(e,{value:t,oldValue:s},a){e[ca]=Nn(a),t!==s&&(e.checked=nn(t,a.props.value))}},yh={deep:!0,created(e,{value:t,modifiers:{number:s}},a){const n=oi(t);Qa(e,"change",()=>{const i=Array.prototype.filter.call(e.options,l=>l.selected).map(l=>s?jr(Gi(l)):Gi(l));e[ca](e.multiple?n?new Set(i):i:i[0]),e._assigning=!0,Ut(()=>{e._assigning=!1})}),e[ca]=Nn(a)},mounted(e,{value:t}){ef(e,t)},beforeUpdate(e,t,s){e[ca]=Nn(s)},updated(e,{value:t}){e._assigning||ef(e,t)}};function ef(e,t){const s=e.multiple,a=Me(t);if(!(s&&!a&&!oi(t))){for(let n=0,i=e.options.length;nString(c)===String(o)):l.selected=Vr(t,o)>-1}else l.selected=t.has(o);else if(nn(Gi(l),t)){e.selectedIndex!==n&&(e.selectedIndex=n);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Gi(e){return"_value"in e?e._value:e.value}function xh(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const _h={created(e,t,s){$o(e,t,s,null,"created")},mounted(e,t,s){$o(e,t,s,null,"mounted")},beforeUpdate(e,t,s,a){$o(e,t,s,a,"beforeUpdate")},updated(e,t,s,a){$o(e,t,s,a,"updated")}};function wh(e,t){switch(e){case"SELECT":return yh;case"TEXTAREA":return Sr;default:switch(t){case"checkbox":return bu;case"radio":return yu;default:return Sr}}}function $o(e,t,s,a,n){const l=wh(e.tagName,s.props&&s.props.type)[n];l&&l(e,t,s,a)}function X_(){Sr.getSSRProps=({value:e})=>({value:e}),yu.getSSRProps=({value:e},t)=>{if(t.props&&nn(t.props.value,e))return{checked:!0}},bu.getSSRProps=({value:e},t)=>{if(Me(e)){if(t.props&&Vr(e,t.props.value)>-1)return{checked:!0}}else if(oi(e)){if(t.props&&e.has(t.props.value))return{checked:!0}}else if(e)return{checked:!0}},_h.getSSRProps=(e,t)=>{if(typeof t.type!="string")return;const s=wh(t.type.toUpperCase(),t.props&&t.props.type);if(s.getSSRProps)return s.getSSRProps(e,t)}}const ew=["ctrl","shift","alt","meta"],tw={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>ew.some(s=>e[`${s}Key`]&&!t.includes(s))},sw=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),a=t.join(".");return s[a]||(s[a]=((n,...i)=>{for(let l=0;l{const s=e._withKeys||(e._withKeys={}),a=t.join(".");return s[a]||(s[a]=(n=>{if(!("key"in n))return;const i=Hs(n.key);if(t.some(l=>l===i||aw[l]===i))return e(n)}))},kh=lt({patchProp:fh},lh);let Pl,tf=!1;function Sh(){return Pl||(Pl=$v(kh))}function Ch(){return Pl=tf?Pl:Fv(kh),tf=!0,Pl}const Th=((...e)=>{Sh().render(...e)}),iw=((...e)=>{Ch().hydrate(...e)}),Cr=((...e)=>{const t=Sh().createApp(...e),{mount:s}=t;return t.mount=a=>{const n=Rh(a);if(!n)return;const i=t._component;!Ve(i)&&!i.render&&!i.template&&(i.template=n.innerHTML),n.nodeType===1&&(n.textContent="");const l=s(n,!1,Ah(n));return n instanceof Element&&(n.removeAttribute("v-cloak"),n.setAttribute("data-v-app","")),l},t}),Eh=((...e)=>{const t=Ch().createApp(...e),{mount:s}=t;return t.mount=a=>{const n=Rh(a);if(n)return s(n,!0,Ah(n))},t});function Ah(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Rh(e){return Ze(e)?document.querySelector(e):e}let sf=!1;const lw=()=>{sf||(sf=!0,X_(),E_())},ow=Object.freeze(Object.defineProperty({__proto__:null,BaseTransition:mv,BaseTransitionPropsValidators:nu,Comment:Zt,DeprecationTypes:y_,EffectScope:Kd,ErrorCodes:wx,ErrorTypeStrings:p_,Fragment:rs,KeepAlive:t0,ReactiveEffect:zl,Static:Xn,Suspense:W0,Teleport:$x,Text:On,TrackOpTypes:vx,Transition:k_,TransitionGroup:W_,TriggerOpTypes:hx,VueElement:ac,assertNumber:_x,callWithAsyncErrorHandling:Ys,callWithErrorHandling:tl,camelize:Tt,capitalize:ri,cloneVNode:Ma,compatUtils:b_,computed:B,createApp:Cr,createBlock:br,createCommentVNode:Jv,createElementBlock:X0,createElementVNode:mu,createHydrationRenderer:Fv,createPropsRestProxy:k0,createRenderer:$v,createSSRApp:Eh,createSlots:r0,createStaticVNode:s_,createTextVNode:vu,createVNode:Bt,customRef:Ym,defineAsyncComponent:Xx,defineComponent:fo,defineCustomElement:mh,defineEmits:f0,defineExpose:m0,defineModel:g0,defineOptions:v0,defineProps:p0,defineSSRCustomElement:z_,defineSlots:h0,devtools:f_,effect:Fy,effectScope:Dy,getCurrentInstance:Ds,getCurrentScope:Mm,getCurrentWatcher:gx,getTransitionRawChildren:Qr,guardReactiveProps:Kv,h:qi,handleError:ci,hasInjectionContext:Ix,hydrate:iw,hydrateOnIdle:Wx,hydrateOnInteraction:Qx,hydrateOnMediaQuery:Zx,hydrateOnVisible:Jx,initCustomFormatter:c_,initDirectivesForSSR:lw,inject:ra,isMemoSame:ah,isProxy:uo,isReactive:tn,isReadonly:Na,isRef:Xt,isRuntimeOnly:l_,isShallow:qs,isVNode:cn,markRaw:Zm,mergeDefaults:_0,mergeModels:w0,mergeProps:Zv,nextTick:Ut,nodeOps:lh,normalizeClass:co,normalizeProps:wy,normalizeStyle:ro,onActivated:cs,onBeforeMount:gv,onBeforeUnmount:ec,onBeforeUpdate:lu,onDeactivated:es,onErrorCaptured:_v,onMounted:st,onRenderTracked:xv,onRenderTriggered:yv,onScopeDispose:Py,onServerPrefetch:bv,onUnmounted:wt,onUpdated:Xr,onWatcherCleanup:ev,openBlock:Jl,patchProp:fh,popScopeId:Ex,provide:Nl,proxyRefs:eu,pushScopeId:Tx,queuePostFlushCb:ql,reactive:Mn,readonly:dr,ref:f,registerRuntimeCompiler:eh,render:Th,renderList:o0,renderSlot:c0,resolveComponent:n0,resolveDirective:l0,resolveDynamicComponent:i0,resolveFilter:g_,resolveTransitionHooks:ji,setBlockTracking:Zl,setDevtoolsHook:m_,setTransitionHooks:rn,shallowReactive:Yd,shallowReadonly:ax,shallowRef:Xd,ssrContextKey:lv,ssrUtils:h_,stop:By,toDisplayString:Lm,toHandlerKey:Mi,toHandlers:d0,toRaw:ft,toRef:px,toRefs:cx,toValue:lx,transformVNodeArgs:e_,triggerRef:ix,unref:La,useAttrs:x0,useCssModule:q_,useCssVars:A_,useHost:vh,useId:Bx,useModel:L0,useSSRContext:ov,useShadowRoot:j_,useSlots:y0,useTemplateRef:Ux,useTransitionState:au,vModelCheckbox:bu,vModelDynamic:_h,vModelRadio:yu,vModelSelect:yh,vModelText:Sr,vShow:uh,version:nh,warn:u_,watch:Gt,watchEffect:Ox,watchPostEffect:Lx,watchSyncEffect:rv,withAsyncContext:S0,withCtx:su,withDefaults:b0,withDirectives:Rx,withKeys:nw,withMemo:d_,withModifiers:sw,withScopeId:Ax},Symbol.toStringTag,{value:"Module"}));/** -* @vue/compiler-core v3.5.38 -* (c) 2018-present Yuxi (Evan) You and Vue contributors -* @license MIT -**/const Yl=Symbol(""),$l=Symbol(""),xu=Symbol(""),Tr=Symbol(""),Ih=Symbol(""),ni=Symbol(""),Oh=Symbol(""),Lh=Symbol(""),_u=Symbol(""),wu=Symbol(""),ho=Symbol(""),ku=Symbol(""),Nh=Symbol(""),Su=Symbol(""),Cu=Symbol(""),Tu=Symbol(""),Eu=Symbol(""),Au=Symbol(""),Ru=Symbol(""),Mh=Symbol(""),Dh=Symbol(""),nc=Symbol(""),Er=Symbol(""),Iu=Symbol(""),Ou=Symbol(""),Xl=Symbol(""),go=Symbol(""),Lu=Symbol(""),wd=Symbol(""),rw=Symbol(""),kd=Symbol(""),Ar=Symbol(""),cw=Symbol(""),dw=Symbol(""),Nu=Symbol(""),uw=Symbol(""),pw=Symbol(""),Mu=Symbol(""),Ph=Symbol(""),Wi={[Yl]:"Fragment",[$l]:"Teleport",[xu]:"Suspense",[Tr]:"KeepAlive",[Ih]:"BaseTransition",[ni]:"openBlock",[Oh]:"createBlock",[Lh]:"createElementBlock",[_u]:"createVNode",[wu]:"createElementVNode",[ho]:"createCommentVNode",[ku]:"createTextVNode",[Nh]:"createStaticVNode",[Su]:"resolveComponent",[Cu]:"resolveDynamicComponent",[Tu]:"resolveDirective",[Eu]:"resolveFilter",[Au]:"withDirectives",[Ru]:"renderList",[Mh]:"renderSlot",[Dh]:"createSlots",[nc]:"toDisplayString",[Er]:"mergeProps",[Iu]:"normalizeClass",[Ou]:"normalizeStyle",[Xl]:"normalizeProps",[go]:"guardReactiveProps",[Lu]:"toHandlers",[wd]:"camelize",[rw]:"capitalize",[kd]:"toHandlerKey",[Ar]:"setBlockTracking",[cw]:"pushScopeId",[dw]:"popScopeId",[Nu]:"withCtx",[uw]:"unref",[pw]:"isRef",[Mu]:"withMemo",[Ph]:"isMemoSame"};function fw(e){Object.getOwnPropertySymbols(e).forEach(t=>{Wi[t]=e[t]})}const ea={start:{line:1,column:1,offset:0},end:{line:1,column:1,offset:0},source:""};function mw(e,t=""){return{type:0,source:t,children:e,helpers:new Set,components:[],directives:[],hoists:[],imports:[],cached:[],temps:0,codegenNode:void 0,loc:ea}}function eo(e,t,s,a,n,i,l,o=!1,r=!1,c=!1,d=ea){return e&&(o?(e.helper(ni),e.helper(Zi(e.inSSR,c))):e.helper(Ji(e.inSSR,c)),l&&e.helper(Au)),{type:13,tag:t,props:s,children:a,patchFlag:n,dynamicProps:i,directives:l,isBlock:o,disableTracking:r,isComponent:c,loc:d}}function ei(e,t=ea){return{type:17,loc:t,elements:e}}function oa(e,t=ea){return{type:15,loc:t,properties:e}}function Yt(e,t){return{type:16,loc:ea,key:Ze(e)?tt(e,!0):e,value:t}}function tt(e,t=!1,s=ea,a=0){return{type:4,loc:s,content:e,isStatic:t,constType:t?3:a}}function va(e,t=ea){return{type:8,loc:t,children:e}}function ns(e,t=[],s=ea){return{type:14,loc:s,callee:e,arguments:t}}function Ki(e,t=void 0,s=!1,a=!1,n=ea){return{type:18,params:e,returns:t,newline:s,isSlot:a,loc:n}}function Sd(e,t,s,a=!0){return{type:19,test:e,consequent:t,alternate:s,newline:a,loc:ea}}function vw(e,t,s=!1,a=!1){return{type:20,index:e,value:t,needPauseTracking:s,inVOnce:a,needArraySpread:!1,loc:ea}}function hw(e){return{type:21,body:e,loc:ea}}function Ji(e,t){return e||t?_u:wu}function Zi(e,t){return e||t?Oh:Lh}function Du(e,{helper:t,removeHelper:s,inSSR:a}){e.isBlock||(e.isBlock=!0,s(Ji(a,e.isComponent)),t(ni),t(Zi(a,e.isComponent)))}const af=new Uint8Array([123,123]),nf=new Uint8Array([125,125]);function lf(e){return e>=97&&e<=122||e>=65&&e<=90}function Zs(e){return e===32||e===10||e===9||e===12||e===13}function wn(e){return e===47||e===62||Zs(e)}function Rr(e){const t=new Uint8Array(e.length);for(let s=0;s100){let l=-1,o=n;for(;l+1>>1;this.newlines[r]=0;l--)if(t>this.newlines[l]){i=l;break}return i>=0&&(s=i+2,a=t-this.newlines[i]),{column:a,line:s,offset:t}}peek(){return this.buffer.charCodeAt(this.index+1)}stateText(t){t===60?(this.index>this.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=5,this.sectionStart=this.index):!this.inVPre&&t===this.delimiterOpen[0]&&(this.state=2,this.delimiterIndex=0,this.stateInterpolationOpen(t))}stateInterpolationOpen(t){if(t===this.delimiterOpen[this.delimiterIndex])if(this.delimiterIndex===this.delimiterOpen.length-1){const s=this.index+1-this.delimiterOpen.length;s>this.sectionStart&&this.cbs.ontext(this.sectionStart,s),this.state=3,this.sectionStart=s}else this.delimiterIndex++;else this.inRCDATA?(this.state=32,this.stateInRCDATA(t)):(this.state=1,this.stateText(t))}stateInterpolation(t){t===this.delimiterClose[0]&&(this.state=4,this.delimiterIndex=0,this.stateInterpolationClose(t))}stateInterpolationClose(t){t===this.delimiterClose[this.delimiterIndex]?this.delimiterIndex===this.delimiterClose.length-1?(this.cbs.oninterpolation(this.sectionStart,this.index+1),this.inRCDATA?this.state=32:this.state=1,this.sectionStart=this.index+1):this.delimiterIndex++:(this.state=3,this.stateInterpolation(t))}stateSpecialStartSequence(t){const s=this.sequenceIndex===this.currentSequence.length;if(!(s?wn(t):(t|32)===this.currentSequence[this.sequenceIndex]))this.inRCDATA=!1;else if(!s){this.sequenceIndex++;return}this.sequenceIndex=0,this.state=6,this.stateInTagName(t)}stateInRCDATA(t){if(this.sequenceIndex===this.currentSequence.length){if(t===62||Zs(t)){const s=this.index-this.currentSequence.length;if(this.sectionStart=t||(this.state===28?this.currentSequence===ys.CdataEnd?this.cbs.oncdata(this.sectionStart,t):this.cbs.oncomment(this.sectionStart,t):this.state===6||this.state===11||this.state===18||this.state===17||this.state===12||this.state===13||this.state===14||this.state===15||this.state===16||this.state===20||this.state===19||this.state===21||this.state===9||this.cbs.ontext(this.sectionStart,t))}emitCodePoint(t,s){}}function of(e,{compatConfig:t}){const s=t&&t[e];return e==="MODE"?s||3:s}function ti(e,t){const s=of("MODE",t),a=of(e,t);return s===3?a===!0:a!==!1}function to(e,t,s,...a){return ti(e,t)}function Pu(e){throw e}function $h(e){}function Mt(e,t,s,a){const n=`https://vuejs.org/error-reference/#compiler-${e}`,i=new SyntaxError(String(n));return i.code=e,i.loc=t,i}const js=e=>e.type===4&&e.isStatic;function Fh(e){switch(e){case"Teleport":case"teleport":return $l;case"Suspense":case"suspense":return xu;case"KeepAlive":case"keep-alive":return Tr;case"BaseTransition":case"base-transition":return Ih}}const bw=/^$|^\d|[^\$\w\xA0-\uFFFF]/,$u=e=>!bw.test(e),Bh=/[A-Za-z_$\xA0-\uFFFF]/,yw=/[\.\?\w$\xA0-\uFFFF]/,xw=/\s+[.[]\s*|\s*[.[]\s+/g,Uh=e=>e.type===4?e.content:e.loc.source,_w=e=>{const t=Uh(e).trim().replace(xw,o=>o.trim());let s=0,a=[],n=0,i=0,l=null;for(let o=0;o|^\s*(?:async\s+)?function(?:\s+[\w$]+)?\s*\(/,kw=e=>ww.test(Uh(e)),Sw=kw;function la(e,t,s=!1){for(let a=0;at.type===7&&t.name==="bind"&&(!t.arg||t.arg.type!==4||!t.arg.isStatic))}function zc(e){return e.type===5||e.type===2}function rf(e){return e.type===7&&e.name==="pre"}function Tw(e){return e.type===7&&e.name==="slot"}function Ir(e){return e.type===1&&e.tagType===3}function Or(e){return e.type===1&&e.tagType===2}const Ew=new Set([Xl,go]);function Hh(e,t=[]){if(e&&!Ze(e)&&e.type===14){const s=e.callee;if(!Ze(s)&&Ew.has(s))return Hh(e.arguments[0],t.concat(e))}return[e,t]}function Lr(e,t,s){let a,n=e.type===13?e.props:e.arguments[2],i=[],l;if(n&&!Ze(n)&&n.type===14){const o=Hh(n);n=o[0],i=o[1],l=i[i.length-1]}if(n==null||Ze(n))a=oa([t]);else if(n.type===14){const o=n.arguments[0];!Ze(o)&&o.type===15?cf(t,o)||o.properties.unshift(t):n.callee===Lu?a=ns(s.helper(Er),[oa([t]),n]):n.arguments.unshift(oa([t])),!a&&(a=n)}else n.type===15?(cf(t,n)||n.properties.unshift(t),a=n):(a=ns(s.helper(Er),[oa([t]),n]),l&&l.callee===go&&(l=i[i.length-2]));e.type===13?l?l.arguments[0]=a:e.props=a:l?l.arguments[0]=a:e.arguments[2]=a}function cf(e,t){let s=!1;if(e.key.type===4){const a=e.key.content;s=t.properties.some(n=>n.key.type===4&&n.key.content===a)}return s}function so(e,t){return`_${t}_${e.replace(/[^\w]/g,(s,a)=>s==="-"?"_":e.charCodeAt(a).toString())}`}function Aw(e){return e.type===14&&e.callee===Mu?e.arguments[1].returns:e}const Rw=/([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/;function jh(e){for(let t=0;t0,isVoidTag:Ri,isPreTag:Ri,isIgnoreNewlineTag:Ri,isCustomElement:Ri,onError:Pu,onWarn:$h,comments:!1,prefixIdentifiers:!1};let vt=Vh,ao=null,an="",_s=null,dt=null,Fs="",Va=-1,Vn=-1,Bu=0,An=!1,Cd=null;const Nt=[],Vt=new gw(Nt,{onerr:Ha,ontext(e,t){Fo(ps(e,t),e,t)},ontextentity(e,t,s){Fo(e,t,s)},oninterpolation(e,t){if(An)return Fo(ps(e,t),e,t);let s=e+Vt.delimiterOpen.length,a=t-Vt.delimiterClose.length;for(;Zs(an.charCodeAt(s));)s++;for(;Zs(an.charCodeAt(a-1));)a--;let n=ps(s,a);n.includes("&")&&(n=vt.decodeEntities(n,!1)),Td({type:5,content:ar(n,!1,Jt(s,a)),loc:Jt(e,t)})},onopentagname(e,t){const s=ps(e,t);_s={type:1,tag:s,ns:vt.getNamespace(s,Nt[0],vt.ns),tagType:0,props:[],children:[],loc:Jt(e-1,t),codegenNode:void 0}},onopentagend(e){uf(e)},onclosetag(e,t){const s=ps(e,t);if(!vt.isVoidTag(s)){let a=!1;for(let n=0;n0&&Ha(24,Nt[0].loc.start.offset);for(let l=0;l<=n;l++){const o=Nt.shift();sr(o,t,l(a.type===7?a.rawName:a.name)===s)&&Ha(2,t)},onattribend(e,t){if(_s&&dt){if(Jn(dt.loc,t),e!==0)if(Fs.includes("&")&&(Fs=vt.decodeEntities(Fs,!0)),dt.type===6)dt.name==="class"&&(Fs=Kh(Fs).trim()),e===1&&!Fs&&Ha(13,t),dt.value={type:2,content:Fs,loc:e===1?Jt(Va,Vn):Jt(Va-1,Vn+1)},Vt.inSFCRoot&&_s.tag==="template"&&dt.name==="lang"&&Fs&&Fs!=="html"&&Vt.enterRCDATA(Rr("n.content==="sync"))>-1&&to("COMPILER_V_BIND_SYNC",vt,dt.loc,dt.arg.loc.source)&&(dt.name="model",dt.modifiers.splice(a,1))}(dt.type!==7||dt.name!=="pre")&&_s.props.push(dt)}Fs="",Va=Vn=-1},oncomment(e,t){vt.comments&&Td({type:3,content:ps(e,t),loc:Jt(e-4,t+3)})},onend(){const e=an.length;for(let t=0;t{const h=t.start.offset+p,y=h+u.length;return ar(u,!1,Jt(h,y),0,m?1:0)},o={source:l(i.trim(),s.indexOf(i,n.length)),value:void 0,key:void 0,index:void 0,finalized:!1};let r=n.trim().replace(Iw,"").trim();const c=n.indexOf(r),d=r.match(df);if(d){r=r.replace(df,"").trim();const u=d[1].trim();let p;if(u&&(p=s.indexOf(u,c+r.length),o.key=l(u,p,!0)),d[2]){const m=d[2].trim();m&&(o.index=l(m,s.indexOf(m,o.key?p+u.length:c+r.length),!0))}}return r&&(o.value=l(r,c,!0)),o}function ps(e,t){return an.slice(e,t)}function uf(e){Vt.inSFCRoot&&(_s.innerLoc=Jt(e+1,e+1)),Td(_s);const{tag:t,ns:s}=_s;s===0&&vt.isPreTag(t)&&Bu++,vt.isVoidTag(t)?sr(_s,e):(Nt.unshift(_s),(s===1||s===2)&&(Vt.inXML=!0)),_s=null}function Fo(e,t,s){{const i=Nt[0]&&Nt[0].tag;i!=="script"&&i!=="style"&&e.includes("&")&&(e=vt.decodeEntities(e,!1))}const a=Nt[0]||ao,n=a.children[a.children.length-1];n&&n.type===2?(n.content+=e,Jn(n.loc,s)):a.children.push({type:2,content:e,loc:Jt(t,s)})}function sr(e,t,s=!1){s?Jn(e.loc,Gh(t,60)):Jn(e.loc,Lw(t,62)+1),Vt.inSFCRoot&&(e.children.length?e.innerLoc.end=lt({},e.children[e.children.length-1].loc.end):e.innerLoc.end=lt({},e.innerLoc.start),e.innerLoc.source=ps(e.innerLoc.start.offset,e.innerLoc.end.offset));const{tag:a,ns:n,children:i}=e;if(An||(a==="slot"?e.tagType=2:pf(e)?e.tagType=3:Mw(e)&&(e.tagType=1)),Vt.inRCDATA||(e.children=Wh(i)),n===0&&vt.isIgnoreNewlineTag(a)){const l=i[0];l&&l.type===2&&(l.content=l.content.replace(/^\r?\n/,""))}n===0&&vt.isPreTag(a)&&Bu--,Cd===e&&(An=Vt.inVPre=!1,Cd=null),Vt.inXML&&(Nt[0]?Nt[0].ns:vt.ns)===0&&(Vt.inXML=!1);{const l=e.props;if(!Vt.inSFCRoot&&ti("COMPILER_NATIVE_TEMPLATE",vt)&&e.tag==="template"&&!pf(e)){const r=Nt[0]||ao,c=r.children.indexOf(e);r.children.splice(c,1,...e.children)}const o=l.find(r=>r.type===6&&r.name==="inline-template");o&&to("COMPILER_INLINE_TEMPLATE",vt,o.loc)&&e.children.length&&(o.value={type:2,content:ps(e.children[0].loc.start.offset,e.children[e.children.length-1].loc.end.offset),loc:o.loc})}}function Lw(e,t){let s=e;for(;an.charCodeAt(s)!==t&&s=0;)s--;return s}const Nw=new Set(["if","else","else-if","for","slot"]);function pf({tag:e,props:t}){if(e==="template"){for(let s=0;s64&&e<91}const Pw=/\r\n/g;function Wh(e){const t=vt.whitespace!=="preserve";let s=!1;for(let a=0;as.type!==3);return t.length===1&&t[0].type===1&&!Or(t[0])?t[0]:null}function nr(e,t,s,a=!1,n=!1){const{children:i}=e,l=[];for(let d=0;d0){if(p>=2){u.codegenNode.patchFlag=-1,l.push(u);continue}}else{const m=u.codegenNode;if(m.type===13){const h=m.patchFlag;if((h===void 0||h===512||h===1)&&Qh(u,s)>=2){const y=Yh(u);y&&(m.props=s.hoist(y))}m.dynamicProps&&(m.dynamicProps=s.hoist(m.dynamicProps))}}}else if(u.type===12&&(a?0:Qs(u,s))>=2){u.codegenNode.type===14&&u.codegenNode.arguments.length>0&&u.codegenNode.arguments.push("-1"),l.push(u);continue}if(u.type===1){const p=u.tagType===1;p&&s.scopes.vSlot++,nr(u,e,s,!1,n),p&&s.scopes.vSlot--}else if(u.type===11)nr(u,e,s,u.children.length===1,!0);else if(u.type===9)for(let p=0;pm.key===u||m.key.content===u);return p&&p.value}}l.length&&s.transformHoist&&s.transformHoist(i,s,e)}function Qs(e,t){const{constantCache:s}=t;switch(e.type){case 1:if(e.tagType!==0)return 0;const a=s.get(e);if(a!==void 0)return a;const n=e.codegenNode;if(n.type!==13||n.isBlock&&e.tag!=="svg"&&e.tag!=="foreignObject"&&e.tag!=="math")return 0;if(n.patchFlag===void 0){let l=3;const o=Qh(e,t);if(o===0)return s.set(e,0),0;o1)for(let r=0;rO&&(L.childIndex--,L.onNodeRemoved()),L.parent.children.splice(O,1)},onNodeRemoved:vs,addIdentifiers(E){},removeIdentifiers(E){},hoist(E){Ze(E)&&(E=tt(E)),L.hoists.push(E);const F=tt(`_hoisted_${L.hoists.length}`,!1,E.loc,2);return F.hoisted=E,F},cache(E,F=!1,O=!1){const I=vw(L.cached.length,E,F,O);return L.cached.push(I),I}};return L.filters=new Set,L}function Vw(e,t){const s=qw(e,t);lc(e,s),t.hoistStatic&&Hw(e,s),t.ssr||Gw(e,s),e.helpers=new Set([...s.helpers.keys()]),e.components=[...s.components],e.directives=[...s.directives],e.imports=s.imports,e.hoists=s.hoists,e.temps=s.temps,e.cached=s.cached,e.transformed=!0,e.filters=[...s.filters]}function Gw(e,t){const{helper:s}=t,{children:a}=e;if(a.length===1){const n=Jh(e);if(n&&n.codegenNode){const i=n.codegenNode;i.type===13&&Du(i,t),e.codegenNode=i}else e.codegenNode=a[0]}else if(a.length>1){let n=64;e.codegenNode=eo(t,s(Yl),void 0,e.children,n,void 0,void 0,!0,void 0,!1)}}function Ww(e,t){let s=0;const a=()=>{s--};for(;sa===e:a=>e.test(a);return(a,n)=>{if(a.type===1){const{props:i}=a;if(a.tagType===3&&i.some(Tw))return;const l=[];for(let o=0;o`${Wi[e]}: _${Wi[e]}`;function Kw(e,{mode:t="function",prefixIdentifiers:s=t==="module",sourceMap:a=!1,filename:n="template.vue.html",scopeId:i=null,optimizeImports:l=!1,runtimeGlobalName:o="Vue",runtimeModuleName:r="vue",ssrRuntimeModuleName:c="vue/server-renderer",ssr:d=!1,isTS:u=!1,inSSR:p=!1}){const m={mode:t,prefixIdentifiers:s,sourceMap:a,filename:n,scopeId:i,optimizeImports:l,runtimeGlobalName:o,runtimeModuleName:r,ssrRuntimeModuleName:c,ssr:d,isTS:u,inSSR:p,source:e.source,code:"",column:1,line:1,offset:0,indentLevel:0,pure:!1,map:void 0,helper(y){return`_${Wi[y]}`},push(y,k=-2,A){m.code+=y},indent(){h(++m.indentLevel)},deindent(y=!1){y?--m.indentLevel:h(--m.indentLevel)},newline(){h(m.indentLevel)}};function h(y){m.push(` -`+" ".repeat(y),0)}return m}function Jw(e,t={}){const s=Kw(e,t);t.onContextCreated&&t.onContextCreated(s);const{mode:a,push:n,prefixIdentifiers:i,indent:l,deindent:o,newline:r,scopeId:c,ssr:d}=s,u=Array.from(e.helpers),p=u.length>0,m=!i&&a!=="module";Zw(e,s);const y=d?"ssrRender":"render",A=(d?["_ctx","_push","_parent","_attrs"]:["_ctx","_cache"]).join(", ");if(n(`function ${y}(${A}) {`),l(),m&&(n("with (_ctx) {"),l(),p&&(n(`const { ${u.map(eg).join(", ")} } = _Vue -`,-1),r())),e.components.length&&(Hc(e.components,"component",s),(e.directives.length||e.temps>0)&&r()),e.directives.length&&(Hc(e.directives,"directive",s),e.temps>0&&r()),e.filters&&e.filters.length&&(r(),Hc(e.filters,"filter",s),r()),e.temps>0){n("let ");for(let g=0;g0?", ":""}_temp${g}`)}return(e.components.length||e.directives.length||e.temps)&&(n(` -`,0),r()),d||n("return "),e.codegenNode?Ss(e.codegenNode,s):n("null"),m&&(o(),n("}")),o(),n("}"),{ast:e,code:s.code,preamble:"",map:s.map?s.map.toJSON():void 0}}function Zw(e,t){const{ssr:s,prefixIdentifiers:a,push:n,newline:i,runtimeModuleName:l,runtimeGlobalName:o,ssrRuntimeModuleName:r}=t,c=o,d=Array.from(e.helpers);if(d.length>0&&(n(`const _Vue = ${c} -`,-1),e.hoists.length)){const u=[_u,wu,ho,ku,Nh].filter(p=>d.includes(p)).map(eg).join(", ");n(`const { ${u} } = _Vue -`,-1)}Qw(e.hoists,t),i(),n("return ")}function Hc(e,t,{helper:s,push:a,newline:n,isTS:i}){const l=s(t==="filter"?Eu:t==="component"?Su:Tu);for(let o=0;o3||!1;t.push("["),s&&t.indent(),bo(e,t,s),s&&t.deindent(),t.push("]")}function bo(e,t,s=!1,a=!0){const{push:n,newline:i}=t;for(let l=0;ls||"null")}function nk(e,t){const{push:s,helper:a,pure:n}=t,i=Ze(e.callee)?e.callee:a(e.callee);n&&s(oc),s(i+"(",-2,e),bo(e.arguments,t),s(")")}function ik(e,t){const{push:s,indent:a,deindent:n,newline:i}=t,{properties:l}=e;if(!l.length){s("{}",-2,e);return}const o=l.length>1||!1;s(o?"{":"{ "),o&&a();for(let r=0;r "),(r||o)&&(s("{"),a()),l?(r&&s("return "),Me(l)?Uu(l,t):Ss(l,t)):o&&Ss(o,t),(r||o)&&(n(),s("}")),c&&(e.isNonScopedSlot&&s(", undefined, true"),s(")"))}function rk(e,t){const{test:s,consequent:a,alternate:n,newline:i}=e,{push:l,indent:o,deindent:r,newline:c}=t;if(s.type===4){const u=!$u(s.content);u&&l("("),tg(s,t),u&&l(")")}else l("("),Ss(s,t),l(")");i&&o(),t.indentLevel++,i||l(" "),l("? "),Ss(a,t),t.indentLevel--,i&&c(),i||l(" "),l(": ");const d=n.type===19;d||t.indentLevel++,Ss(n,t),d||t.indentLevel--,i&&r(!0)}function ck(e,t){const{push:s,helper:a,indent:n,deindent:i,newline:l}=t,{needPauseTracking:o,needArraySpread:r}=e;r&&s("[...("),s(`_cache[${e.index}] || (`),o&&(n(),s(`${a(Ar)}(-1`),e.inVOnce&&s(", true"),s("),"),l(),s("(")),s(`_cache[${e.index}] = `),Ss(e.value,t),o&&(s(`).cacheIndex = ${e.index},`),l(),s(`${a(Ar)}(1),`),l(),s(`_cache[${e.index}]`),i()),s(")"),r&&s(")]")}new RegExp("\\b"+"arguments,await,break,case,catch,class,const,continue,debugger,default,delete,do,else,export,extends,finally,for,function,if,import,let,new,return,super,switch,throw,try,var,void,while,with,yield".split(",").join("\\b|\\b")+"\\b");const dk=Xh(/^(?:if|else|else-if)$/,(e,t,s)=>uk(e,t,s,(a,n,i)=>{const l=s.parent.children;let o=l.indexOf(a),r=0;for(;o-->=0;){const c=l[o];c&&c.type===9&&(r+=c.branches.length)}return()=>{if(i)a.codegenNode=mf(n,r,s);else{const c=pk(a.codegenNode);c.alternate=mf(n,r+a.branches.length-1,s)}}}));function uk(e,t,s,a){if(t.name!=="else"&&(!t.exp||!t.exp.content.trim())){const n=t.exp?t.exp.loc:e.loc;s.onError(Mt(28,t.loc)),t.exp=tt("true",!1,n)}if(t.name==="if"){const n=ff(e,t),i={type:9,loc:Fw(e.loc),branches:[n]};if(s.replaceNode(i),a)return a(i,n,!0)}else{const n=s.parent.children;let i=n.indexOf(e);for(;i-->=-1;){const l=n[i];if(l&&qh(l)){s.removeNode(l);continue}if(l&&l.type===9){(t.name==="else-if"||t.name==="else")&&l.branches[l.branches.length-1].condition===void 0&&s.onError(Mt(30,e.loc)),s.removeNode();const o=ff(e,t);l.branches.push(o);const r=a&&a(l,o,!1);lc(o,s),r&&r(),s.currentNode=null}else s.onError(Mt(30,e.loc));break}}}function ff(e,t){const s=e.tagType===3;return{type:10,loc:e.loc,condition:t.name==="else"?void 0:t.exp,children:s&&!la(e,"for")?e.children:[e],userKey:ic(e,"key"),isTemplateIf:s}}function mf(e,t,s){return e.condition?Sd(e.condition,vf(e,t,s),ns(s.helper(ho),['""',"true"])):vf(e,t,s)}function vf(e,t,s){const{helper:a}=s,n=Yt("key",tt(`${t}`,!1,ea,2)),{children:i}=e,l=i[0];if(i.length!==1||l.type!==1)if(i.length===1&&l.type===11){const r=l.codegenNode;return Lr(r,n,s),r}else return eo(s,a(Yl),oa([n]),i,64,void 0,void 0,!0,!1,!1,e.loc);else{const r=l.codegenNode,c=Aw(r);return c.type===13&&Du(c,s),Lr(c,n,s),r}}function pk(e){for(;;)if(e.type===19)if(e.alternate.type===19)e=e.alternate;else return e;else e.type===20&&(e=e.value)}const fk=Xh("for",(e,t,s)=>{const{helper:a,removeHelper:n}=s;return mk(e,t,s,i=>{const l=ns(a(Ru),[i.source]),o=Ir(e),r=la(e,"memo"),c=ic(e,"key",!1,!0);c&&c.type;let d=c&&(c.type===6?c.value?tt(c.value.content,!0):void 0:c.exp);const u=d?Yt("key",d):null,p=i.source.type===4&&i.source.constType>0,m=p?64:c?128:256;return i.codegenNode=eo(s,a(Yl),void 0,l,m,void 0,void 0,!0,!p,!1,e.loc),()=>{let h;const{children:y}=i,k=y.length!==1||y[0].type!==1,A=Or(e)?e:o&&e.children.length===1&&Or(e.children[0])?e.children[0]:null;if(A?(h=A.codegenNode,o&&u&&Lr(h,u,s)):k?h=eo(s,a(Yl),u?oa([u]):void 0,e.children,64,void 0,void 0,!0,void 0,!1):(h=y[0].codegenNode,o&&u&&Lr(h,u,s),h.isBlock!==!p&&(h.isBlock?(n(ni),n(Zi(s.inSSR,h.isComponent))):n(Ji(s.inSSR,h.isComponent))),h.isBlock=!p,h.isBlock?(a(ni),a(Zi(s.inSSR,h.isComponent))):a(Ji(s.inSSR,h.isComponent))),r){const g=Ki(Ed(i.parseResult,[tt("_cached")]));g.body=hw([va(["const _memo = (",r.exp,")"]),va(["if (_cached && _cached.el",...d?[" && _cached.key === ",d]:[],` && ${s.helperString(Ph)}(_cached, _memo)) return _cached`]),va(["const _item = ",h]),tt("_item.memo = _memo"),tt("return _item")]),l.arguments.push(g,tt("_cache"),tt(String(s.cached.length))),s.cached.push(null)}else l.arguments.push(Ki(Ed(i.parseResult),h,!0))}})});function mk(e,t,s,a){if(!t.exp){s.onError(Mt(31,t.loc));return}const n=t.forParseResult;if(!n){s.onError(Mt(32,t.loc));return}ag(n);const{addIdentifiers:i,removeIdentifiers:l,scopes:o}=s,{source:r,value:c,key:d,index:u}=n,p={type:11,loc:t.loc,source:r,valueAlias:c,keyAlias:d,objectIndexAlias:u,parseResult:n,children:Ir(e)?e.children:[e]};s.replaceNode(p),o.vFor++;const m=a&&a(p);return()=>{o.vFor--,m&&m()}}function ag(e,t){e.finalized||(e.finalized=!0)}function Ed({value:e,key:t,index:s},a=[]){return vk([e,t,s,...a])}function vk(e){let t=e.length;for(;t--&&!e[t];);return e.slice(0,t+1).map((s,a)=>s||tt("_".repeat(a+1),!1))}const hf=tt("undefined",!1),hk=(e,t)=>{if(e.type===1&&(e.tagType===1||e.tagType===3)){const s=la(e,"slot");if(s)return s.exp,t.scopes.vSlot++,()=>{t.scopes.vSlot--}}},gk=(e,t,s,a)=>Ki(e,s,!1,!0,s.length?s[0].loc:a);function bk(e,t,s=gk){t.helper(Nu);const{children:a,loc:n}=e,i=[],l=[];let o=t.scopes.vSlot>0||t.scopes.vFor>0;const r=la(e,"slot",!0);if(r){const{arg:k,exp:A}=r;k&&!js(k)&&(o=!0),i.push(Yt(k||tt("default",!0),s(A,void 0,a,n)))}let c=!1,d=!1;const u=[],p=new Set;let m=0;for(let k=0;k{const b=s(A,void 0,g,n);return t.compatConfig&&(b.isNonScopedSlot=!0),Yt("default",b)};c?u.length&&!u.every(Fu)&&(d?t.onError(Mt(39,u[0].loc)):i.push(k(void 0,u))):i.push(k(void 0,a))}const h=o?2:ir(e.children)?3:1;let y=oa(i.concat(Yt("_",tt(h+"",!1))),n);return l.length&&(y=ns(t.helper(Dh),[y,ei(l)])),{slots:y,hasDynamicSlots:o}}function Bo(e,t,s){const a=[Yt("name",e),Yt("fn",t)];return s!=null&&a.push(Yt("key",tt(String(s),!0))),oa(a)}function ir(e){for(let t=0;tfunction(){if(e=t.currentNode,!(e.type===1&&(e.tagType===0||e.tagType===1)))return;const{tag:a,props:n}=e,i=e.tagType===1;let l=i?xk(e,t):`"${a}"`;const o=ht(l)&&l.callee===Cu;let r,c,d=0,u,p,m,h=o||l===$l||l===xu||!i&&(a==="svg"||a==="foreignObject"||a==="math");if(n.length>0){const y=ig(e,t,void 0,i,o);r=y.props,d=y.patchFlag,p=y.dynamicPropNames;const k=y.directives;m=k&&k.length?ei(k.map(A=>wk(A,t))):void 0,y.shouldUseBlock&&(h=!0)}if(e.children.length>0)if(l===Tr&&(h=!0,d|=1024),i&&l!==$l&&l!==Tr){const{slots:k,hasDynamicSlots:A}=bk(e,t);c=k,A&&(d|=1024)}else if(e.children.length===1&&l!==$l){const k=e.children[0],A=k.type,g=A===5||A===8;g&&Qs(k,t)===0&&(d|=1),g||A===2?c=k:c=e.children}else c=e.children;p&&p.length&&(u=kk(p)),e.codegenNode=eo(t,l,r,c,d===0?void 0:d,u,m,!!h,!1,i,e.loc)};function xk(e,t,s=!1){let{tag:a}=e;const n=Ad(a),i=ic(e,"is",!1,!0);if(i)if(n||ti("COMPILER_IS_ON_ELEMENT",t)){let o;if(i.type===6?o=i.value&&tt(i.value.content,!0):(o=i.exp,o||(o=tt("is",!1,i.arg.loc))),o)return ns(t.helper(Cu),[o])}else i.type===6&&i.value.content.startsWith("vue:")&&(a=i.value.content.slice(4));const l=Fh(a)||t.isBuiltInComponent(a);return l?(s||t.helper(l),l):(t.helper(Su),t.components.add(a),so(a,"component"))}function ig(e,t,s=e.props,a,n,i=!1){const{tag:l,loc:o,children:r}=e;let c=[];const d=[],u=[],p=r.length>0;let m=!1,h=0,y=!1,k=!1,A=!1,g=!1,b=!1,x=!1;const w=[],_=F=>{c.length&&(d.push(oa(gf(c),o)),c=[]),F&&d.push(F)},T=()=>{t.scopes.vFor>0&&c.push(Yt(tt("ref_for",!0),tt("true")))},L=({key:F,value:O})=>{if(js(F)){const I=F.content,H=li(I);if(H&&(!a||n)&&I.toLowerCase()!=="onclick"&&I!=="onUpdate:modelValue"&&!en(I)&&(g=!0),H&&en(I)&&(x=!0),H&&O.type===14&&(O=O.arguments[0]),O.type===20||(O.type===4||O.type===8)&&Qs(O,t)>0)return;I==="ref"?y=!0:I==="class"?k=!0:I==="style"?A=!0:I!=="key"&&!w.includes(I)&&w.push(I),a&&(I==="class"||I==="style")&&!w.includes(I)&&w.push(I)}else b=!0};for(let F=0;Fce.content==="prop")&&(h|=32);const re=t.directiveTransforms[I];if(re){const{props:ce,needRuntime:Z}=re(O,e,t);!i&&ce.forEach(L),$&&H&&!js(H)?_(oa(ce,o)):c.push(...ce),Z&&(u.push(O),Cs(Z)&&ng.set(O,Z))}else fy(I)||(u.push(O),p&&(m=!0))}}let E;if(d.length?(_(),d.length>1?E=ns(t.helper(Er),d,o):E=d[0]):c.length&&(E=oa(gf(c),o)),b?h|=16:(k&&!a&&(h|=2),A&&!a&&(h|=4),w.length&&(h|=8),g&&(h|=32)),!m&&(h===0||h===32)&&(y||x||u.length>0)&&(h|=512),!t.inSSR&&E)switch(E.type){case 15:let F=-1,O=-1,I=!1;for(let ee=0;eeYt(l,i)),n))}return ei(s,e.loc)}function kk(e){let t="[";for(let s=0,a=e.length;s{if(Or(e)){const{children:s,loc:a}=e,{slotName:n,slotProps:i}=Ck(e,t),l=[t.prefixIdentifiers?"_ctx.$slots":"$slots",n,"{}","undefined","true"];let o=2;i&&(l[2]=i,o=3),s.length&&(l[3]=Ki([],s,!1,!1,a),o=4),t.scopeId&&!t.slotted&&(o=5),l.splice(o),e.codegenNode=ns(t.helper(Mh),l,a)}};function Ck(e,t){let s='"default"',a;const n=[];for(let i=0;i0){const{props:i,directives:l}=ig(e,t,n,!1,!1);a=i,l.length&&t.onError(Mt(36,l[0].loc))}return{slotName:s,slotProps:a}}const lg=(e,t,s,a)=>{const{loc:n,modifiers:i,arg:l}=e;!e.exp&&!i.length&&s.onError(Mt(35,n));let o;if(l.type===4)if(l.isStatic){let u=l.content;u.startsWith("vue:")&&(u=`vnode-${u.slice(4)}`);const p=t.tagType!==0||u.startsWith("vnode")||!/[A-Z]/.test(u)?Mi(Tt(u)):`on:${u}`;o=tt(p,!0,l.loc)}else o=va([`${s.helperString(kd)}(`,l,")"]);else o=l,o.children.unshift(`${s.helperString(kd)}(`),o.children.push(")");let r=e.exp;r&&!r.content.trim()&&(r=void 0);let c=s.cacheHandlers&&!r&&!s.inVOnce;if(r){const u=zh(r),p=!(u||Sw(r)),m=r.content.includes(";");(p||c&&u)&&(r=va([`${p?"$event":"(...args)"} => ${m?"{":"("}`,r,m?"}":")"]))}let d={props:[Yt(o,r||tt("() => {}",!1,n))]};return a&&(d=a(d)),c&&(d.props[0].value=s.cache(d.props[0].value)),d.props.forEach(u=>u.key.isHandlerKey=!0),d},Tk=(e,t,s)=>{const{modifiers:a,loc:n}=e,i=e.arg;let{exp:l}=e;return l&&l.type===4&&!l.content.trim()&&(l=void 0),i.type!==4?(i.children.unshift("("),i.children.push(') || ""')):i.isStatic||(i.content=i.content?`${i.content} || ""`:'""'),a.some(o=>o.content==="camel")&&(i.type===4?i.isStatic?i.content=Tt(i.content):i.content=`${s.helperString(wd)}(${i.content})`:(i.children.unshift(`${s.helperString(wd)}(`),i.children.push(")"))),s.inSSR||(a.some(o=>o.content==="prop")&&bf(i,"."),a.some(o=>o.content==="attr")&&bf(i,"^")),{props:[Yt(i,l)]}},bf=(e,t)=>{e.type===4?e.isStatic?e.content=t+e.content:e.content=`\`${t}\${${e.content}}\``:(e.children.unshift(`'${t}' + (`),e.children.push(")"))},Ek=(e,t)=>{if(e.type===0||e.type===1||e.type===11||e.type===10)return()=>{const s=e.children;let a,n=!1;for(let i=0;ii.type===7&&!t.directiveTransforms[i.name])&&e.tag!=="template")))for(let i=0;i{if(e.type===1&&la(e,"once",!0))return yf.has(e)||t.inVOnce||t.inSSR?void 0:(yf.add(e),t.inVOnce=!0,t.helper(Ar),()=>{t.inVOnce=!1;const s=t.currentNode;s.codegenNode&&(s.codegenNode=t.cache(s.codegenNode,!0,!0))})},og=(e,t,s)=>{const{exp:a,arg:n}=e;if(!a)return s.onError(Mt(41,e.loc)),vl();const i=a.loc.source.trim(),l=a.type===4?a.content:i,o=s.bindingMetadata[i];if(o==="props"||o==="props-aliased")return s.onError(Mt(44,a.loc)),vl();if(o==="literal-const"||o==="setup-const")return s.onError(Mt(45,a.loc)),vl();if(!l.trim()||!zh(a))return s.onError(Mt(42,a.loc)),vl();const r=n||tt("modelValue",!0),c=n?js(n)?`onUpdate:${Tt(n.content)}`:va(['"onUpdate:" + ',n]):"onUpdate:modelValue";let d;const u=s.isTS?"($event: any)":"$event";d=va([`${u} => ((`,a,") = $event)"]);const p=[Yt(r,e.exp),Yt(c,d)];if(e.modifiers.length&&t.tagType===1){const m=e.modifiers.map(y=>y.content).map(y=>($u(y)?y:JSON.stringify(y))+": true").join(", "),h=n?js(n)?`${n.content}Modifiers`:va([n,' + "Modifiers"']):"modelModifiers";p.push(Yt(h,tt(`{ ${m} }`,!1,e.loc,2)))}return vl(p)};function vl(e=[]){return{props:e}}const Rk=/[\w).+\-_$\]]/,Ik=(e,t)=>{ti("COMPILER_FILTERS",t)&&(e.type===5?Nr(e.content,t):e.type===1&&e.props.forEach(s=>{s.type===7&&s.name!=="for"&&s.exp&&Nr(s.exp,t)}))};function Nr(e,t){if(e.type===4)xf(e,t);else for(let s=0;s=0&&(g=s.charAt(A),g===" ");A--);(!g||!Rk.test(g))&&(l=!0)}}h===void 0?h=s.slice(0,m).trim():d!==0&&k();function k(){y.push(s.slice(d,m).trim()),d=m+1}if(y.length){for(m=0;m{if(e.type===1){const s=la(e,"memo");return!s||_f.has(e)||t.inSSR?void 0:(_f.add(e),()=>{const a=e.codegenNode||t.currentNode.codegenNode;a&&a.type===13&&(e.tagType!==1&&Du(a,t),e.codegenNode=ns(t.helper(Mu),[s.exp,Ki(void 0,a),"_cache",String(t.cached.length)]),t.cached.push(null))})}},Nk=(e,t)=>{if(e.type===1){for(const s of e.props)if(s.type===7&&s.name==="bind"&&(!s.exp||s.exp.type===4&&!s.exp.content.trim())&&s.arg){const a=s.arg;if(a.type!==4||!a.isStatic)t.onError(Mt(53,a.loc)),s.exp=tt("",!0,a.loc);else{const n=Tt(a.content);(Bh.test(n[0])||n[0]==="-")&&(s.exp=tt(n,!1,a.loc))}}}};function Mk(e){return[[Nk,Ak,dk,Lk,fk,Ik,Sk,yk,hk,Ek],{on:lg,bind:Tk,model:og}]}function Dk(e,t={}){const s=t.onError||Pu,a=t.mode==="module";t.prefixIdentifiers===!0?s(Mt(48)):a&&s(Mt(49));const n=!1;t.cacheHandlers&&s(Mt(50)),t.scopeId&&!a&&s(Mt(51));const i=lt({},t,{prefixIdentifiers:n}),l=Ze(e)?zw(e,i):e,[o,r]=Mk();return Vw(l,lt({},i,{nodeTransforms:[...o,...t.nodeTransforms||[]],directiveTransforms:lt({},r,t.directiveTransforms||{})})),Jw(l,i)}const Pk=()=>({props:[]});/** -* @vue/compiler-dom v3.5.38 -* (c) 2018-present Yuxi (Evan) You and Vue contributors -* @license MIT -**/const rg=Symbol(""),cg=Symbol(""),dg=Symbol(""),ug=Symbol(""),Rd=Symbol(""),pg=Symbol(""),fg=Symbol(""),mg=Symbol(""),vg=Symbol(""),hg=Symbol("");fw({[rg]:"vModelRadio",[cg]:"vModelCheckbox",[dg]:"vModelText",[ug]:"vModelSelect",[Rd]:"vModelDynamic",[pg]:"withModifiers",[fg]:"withKeys",[mg]:"vShow",[vg]:"Transition",[hg]:"TransitionGroup"});let _i;function $k(e,t=!1){return _i||(_i=document.createElement("div")),t?(_i.innerHTML=`
`,_i.children[0].getAttribute("foo")):(_i.innerHTML=e,_i.textContent)}const Fk={parseMode:"html",isVoidTag:Iy,isNativeTag:e=>Ey(e)||Ay(e)||Ry(e),isPreTag:e=>e==="pre",isIgnoreNewlineTag:e=>e==="pre"||e==="textarea",decodeEntities:$k,isBuiltInComponent:e=>{if(e==="Transition"||e==="transition")return vg;if(e==="TransitionGroup"||e==="transition-group")return hg},getNamespace(e,t,s){let a=t?t.ns:s;if(t&&a===2)if(t.tag==="annotation-xml"){if(e==="svg")return 1;t.props.some(n=>n.type===6&&n.name==="encoding"&&n.value!=null&&(n.value.content==="text/html"||n.value.content==="application/xhtml+xml"))&&(a=0)}else/^m(?:[ions]|text)$/.test(t.tag)&&e!=="mglyph"&&e!=="malignmark"&&(a=0);else t&&a===1&&(t.tag==="foreignObject"||t.tag==="desc"||t.tag==="title")&&(a=0);if(a===0){if(e==="svg")return 1;if(e==="math")return 2}return a}},Bk=e=>{e.type===1&&e.props.forEach((t,s)=>{t.type===6&&t.name==="style"&&t.value&&(e.props[s]={type:7,name:"bind",arg:tt("style",!0,t.loc),exp:Uk(t.value.content,t.loc),modifiers:[],loc:t.loc})})},Uk=(e,t)=>{const s=Rm(e);return tt(JSON.stringify(s),!1,t,3)};function Ln(e,t){return Mt(e,t)}const zk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Ln(54,n)),t.children.length&&(s.onError(Ln(55,n)),t.children.length=0),{props:[Yt(tt("innerHTML",!0,n),a||tt("",!0))]}},Hk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Ln(56,n)),t.children.length&&(s.onError(Ln(57,n)),t.children.length=0),{props:[Yt(tt("textContent",!0),a?Qs(a,s)>0?a:ns(s.helperString(nc),[a],n):tt("",!0))]}},jk=(e,t,s)=>{const a=og(e,t,s);if(!a.props.length||t.tagType===1)return a;e.arg&&s.onError(Ln(59,e.arg.loc));const{tag:n}=t,i=s.isCustomElement(n);if(n==="input"||n==="textarea"||n==="select"||i){let l=dg,o=!1;if(n==="input"||i){const r=ic(t,"type");if(r){if(r.type===7)l=Rd;else if(r.value)switch(r.value.content){case"radio":l=rg;break;case"checkbox":l=cg;break;case"file":o=!0,s.onError(Ln(60,e.loc));break}}else Cw(t)&&(l=Rd)}else n==="select"&&(l=ug);o||(a.needRuntime=s.helper(l))}else s.onError(Ln(58,e.loc));return a.props=a.props.filter(l=>!(l.key.type===4&&l.key.content==="modelValue")),a},qk=Xs("passive,once,capture"),Vk=Xs("stop,prevent,self,ctrl,shift,alt,meta,exact,middle"),Gk=Xs("left,right"),gg=Xs("onkeyup,onkeydown,onkeypress"),Wk=(e,t,s,a)=>{const n=[],i=[],l=[];for(let o=0;ojs(e)&&e.content.toLowerCase()==="onclick"?tt(t,!0):e.type!==4?va(["(",e,`) === "onClick" ? "${t}" : (`,e,")"]):e,Kk=(e,t,s)=>lg(e,t,s,a=>{const{modifiers:n}=e;if(!n.length)return a;let{key:i,value:l}=a.props[0];const{keyModifiers:o,nonKeyModifiers:r,eventOptionModifiers:c}=Wk(i,n,s,e.loc);if(r.includes("right")&&(i=wf(i,"onContextmenu")),r.includes("middle")&&(i=wf(i,"onMouseup")),r.length&&(l=ns(s.helper(pg),[l,JSON.stringify(r)])),o.length&&(!js(i)||gg(i.content.toLowerCase()))&&(l=ns(s.helper(fg),[l,JSON.stringify(o)])),c.length){const d=c.map(ri).join("");i=js(i)?tt(`${i.content}${d}`,!0):va(["(",i,`) + "${d}"`])}return{props:[Yt(i,l)]}}),Jk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Ln(62,n)),{props:[],needRuntime:s.helper(mg)}},Zk=(e,t)=>{e.type===1&&e.tagType===0&&(e.tag==="script"||e.tag==="style")&&t.removeNode()},Qk=[Bk],Yk={cloak:Pk,html:zk,text:Hk,model:jk,on:Kk,show:Jk};function Xk(e,t={}){return Dk(e,lt({},Fk,t,{nodeTransforms:[Zk,...Qk,...t.nodeTransforms||[]],directiveTransforms:lt({},Yk,t.directiveTransforms||{}),transformHoist:null}))}/** -* vue v3.5.38 -* (c) 2018-present Yuxi (Evan) You and Vue contributors -* @license MIT -**/const kf=Object.create(null);function eS(e,t){if(!Ze(e))if(e.nodeType)e=e.innerHTML;else return vs;const s=hy(e,t),a=kf[s];if(a)return a;if(e[0]==="#"){const o=document.querySelector(e);e=o?o.innerHTML:""}const n=lt({hoistStatic:!0,onError:void 0,onWarn:vs},t);!n.isCustomElement&&typeof customElements<"u"&&(n.isCustomElement=o=>!!customElements.get(o));const{code:i}=Xk(e,n),l=new Function("Vue",i)(ow);return l._rc=!0,kf[s]=l}eh(eS);const Mr=Mn({items:[]});let tS=1;function rc(e,t="info",s=3e3){const a=tS++;return Mr.items.push({id:a,message:String(e),type:t}),s>0&&setTimeout(()=>zu(a),s),a}function zu(e){const t=Mr.items.findIndex(s=>s.id===e);t>=0&&Mr.items.splice(t,1)}function Se(e,t="info",s=3e3){return rc(e,t,s)}Se.success=(e,t=3e3)=>rc(e,"success",t);Se.error=(e,t=5e3)=>rc(e,"error",t);Se.info=(e,t=3e3)=>rc(e,"info",t);Se.dismiss=zu;const sS={setup(){return{state:Mr,dismiss:zu}},template:` -
- -
- - {{ t.message }} -
-
-
- `},Ka=Mn({open:!1,title:"Confirm",message:"",confirmLabel:"Confirm",cancelLabel:"Cancel",danger:!1});let Ui=null;function is({title:e="Confirm",message:t="",confirmLabel:s="Confirm",cancelLabel:a="Cancel",danger:n=!1}={}){return Ui&&Ui(!1),Ka.title=e,Ka.message=t,Ka.confirmLabel=s,Ka.cancelLabel=a,Ka.danger=n,Ka.open=!0,new Promise(i=>{Ui=i})}function Sf(e){Ka.open=!1,Ui&&(Ui(e),Ui=null)}const aS={setup(){function e(t){Ka.open&&t.key==="Escape"&&(t.stopPropagation(),Sf(!1))}return st(()=>document.addEventListener("keydown",e,!0)),wt(()=>document.removeEventListener("keydown",e,!0)),{state:Ka,settle:Sf}},template:` - - - - `};/*! - * vue-router v4.6.4 - * (c) 2025 Eduardo San Martin Morote - * @license MIT - */const Ei=typeof document<"u";function bg(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function nS(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&bg(e.default)}const xt=Object.assign;function jc(e,t){const s={};for(const a in t){const n=t[a];s[a]=ga(n)?n.map(e):e(n)}return s}const Fl=()=>{},ga=Array.isArray;function Cf(e,t){const s={};for(const a in e)s[a]=a in t?t[a]:e[a];return s}const yg=/#/g,iS=/&/g,lS=/\//g,oS=/=/g,rS=/\?/g,xg=/\+/g,cS=/%5B/g,dS=/%5D/g,_g=/%5E/g,uS=/%60/g,wg=/%7B/g,pS=/%7C/g,kg=/%7D/g,fS=/%20/g;function Hu(e){return e==null?"":encodeURI(""+e).replace(pS,"|").replace(cS,"[").replace(dS,"]")}function mS(e){return Hu(e).replace(wg,"{").replace(kg,"}").replace(_g,"^")}function Id(e){return Hu(e).replace(xg,"%2B").replace(fS,"+").replace(yg,"%23").replace(iS,"%26").replace(uS,"`").replace(wg,"{").replace(kg,"}").replace(_g,"^")}function vS(e){return Id(e).replace(oS,"%3D")}function hS(e){return Hu(e).replace(yg,"%23").replace(rS,"%3F")}function gS(e){return hS(e).replace(lS,"%2F")}function no(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}const bS=/\/$/,yS=e=>e.replace(bS,"");function qc(e,t,s="/"){let a,n={},i="",l="";const o=t.indexOf("#");let r=t.indexOf("?");return r=o>=0&&r>o?-1:r,r>=0&&(a=t.slice(0,r),i=t.slice(r,o>0?o:t.length),n=e(i.slice(1))),o>=0&&(a=a||t.slice(0,o),l=t.slice(o,t.length)),a=kS(a??t,s),{fullPath:a+i+l,path:a,query:n,hash:no(l)}}function xS(e,t){const s=t.query?e(t.query):"";return t.path+(s&&"?")+s+(t.hash||"")}function Tf(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function _S(e,t,s){const a=t.matched.length-1,n=s.matched.length-1;return a>-1&&a===n&&Qi(t.matched[a],s.matched[n])&&Sg(t.params,s.params)&&e(t.query)===e(s.query)&&t.hash===s.hash}function Qi(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function Sg(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(var s in e)if(!wS(e[s],t[s]))return!1;return!0}function wS(e,t){return ga(e)?Ef(e,t):ga(t)?Ef(t,e):(e==null?void 0:e.valueOf())===(t==null?void 0:t.valueOf())}function Ef(e,t){return ga(t)?e.length===t.length&&e.every((s,a)=>s===t[a]):e.length===1&&e[0]===t}function kS(e,t){if(e.startsWith("/"))return e;if(!e)return t;const s=t.split("/"),a=e.split("/"),n=a[a.length-1];(n===".."||n===".")&&a.push("");let i=s.length-1,l,o;for(l=0;l1&&i--;else break;return s.slice(0,i).join("/")+"/"+a.slice(l).join("/")}const kn={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let Od=(function(e){return e.pop="pop",e.push="push",e})({}),Vc=(function(e){return e.back="back",e.forward="forward",e.unknown="",e})({});function SS(e){if(!e)if(Ei){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),yS(e)}const CS=/^[^#]+#/;function TS(e,t){return e.replace(CS,"#")+t}function ES(e,t){const s=document.documentElement.getBoundingClientRect(),a=e.getBoundingClientRect();return{behavior:t.behavior,left:a.left-s.left-(t.left||0),top:a.top-s.top-(t.top||0)}}const cc=()=>({left:window.scrollX,top:window.scrollY});function AS(e){let t;if("el"in e){const s=e.el,a=typeof s=="string"&&s.startsWith("#"),n=typeof s=="string"?a?document.getElementById(s.slice(1)):document.querySelector(s):s;if(!n)return;t=ES(n,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function Af(e,t){return(history.state?history.state.position-t:-1)+e}const Ld=new Map;function RS(e,t){Ld.set(e,t)}function IS(e){const t=Ld.get(e);return Ld.delete(e),t}function OS(e){return typeof e=="string"||e&&typeof e=="object"}function Cg(e){return typeof e=="string"||typeof e=="symbol"}let qt=(function(e){return e[e.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",e[e.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",e[e.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",e[e.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",e[e.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",e})({});const Tg=Symbol("");qt.MATCHER_NOT_FOUND+"",qt.NAVIGATION_GUARD_REDIRECT+"",qt.NAVIGATION_ABORTED+"",qt.NAVIGATION_CANCELLED+"",qt.NAVIGATION_DUPLICATED+"";function Yi(e,t){return xt(new Error,{type:e,[Tg]:!0},t)}function ja(e,t){return e instanceof Error&&Tg in e&&(t==null||!!(e.type&t))}const LS=["params","query","hash"];function NS(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;const t={};for(const s of LS)s in e&&(t[s]=e[s]);return JSON.stringify(t,null,2)}function MS(e){const t={};if(e===""||e==="?")return t;const s=(e[0]==="?"?e.slice(1):e).split("&");for(let a=0;an&&Id(n)):[a&&Id(a)]).forEach(n=>{n!==void 0&&(t+=(t.length?"&":"")+s,n!=null&&(t+="="+n))})}return t}function DS(e){const t={};for(const s in e){const a=e[s];a!==void 0&&(t[s]=ga(a)?a.map(n=>n==null?null:""+n):a==null?a:""+a)}return t}const PS=Symbol(""),If=Symbol(""),dc=Symbol(""),ju=Symbol(""),Nd=Symbol("");function hl(){let e=[];function t(a){return e.push(a),()=>{const n=e.indexOf(a);n>-1&&e.splice(n,1)}}function s(){e=[]}return{add:t,list:()=>e.slice(),reset:s}}function Rn(e,t,s,a,n,i=l=>l()){const l=a&&(a.enterCallbacks[n]=a.enterCallbacks[n]||[]);return()=>new Promise((o,r)=>{const c=p=>{p===!1?r(Yi(qt.NAVIGATION_ABORTED,{from:s,to:t})):p instanceof Error?r(p):OS(p)?r(Yi(qt.NAVIGATION_GUARD_REDIRECT,{from:t,to:p})):(l&&a.enterCallbacks[n]===l&&typeof p=="function"&&l.push(p),o())},d=i(()=>e.call(a&&a.instances[n],t,s,c));let u=Promise.resolve(d);e.length<3&&(u=u.then(c)),u.catch(p=>r(p))})}function Gc(e,t,s,a,n=i=>i()){const i=[];for(const l of e)for(const o in l.components){let r=l.components[o];if(!(t!=="beforeRouteEnter"&&!l.instances[o]))if(bg(r)){const c=(r.__vccOpts||r)[t];c&&i.push(Rn(c,s,a,l,o,n))}else{let c=r();i.push(()=>c.then(d=>{if(!d)throw new Error(`Couldn't resolve component "${o}" at "${l.path}"`);const u=nS(d)?d.default:d;l.mods[o]=d,l.components[o]=u;const p=(u.__vccOpts||u)[t];return p&&Rn(p,s,a,l,o,n)()}))}}return i}function $S(e,t){const s=[],a=[],n=[],i=Math.max(t.matched.length,e.matched.length);for(let l=0;lQi(c,o))?a.push(o):s.push(o));const r=e.matched[l];r&&(t.matched.find(c=>Qi(c,r))||n.push(r))}return[s,a,n]}/*! - * vue-router v4.6.4 - * (c) 2025 Eduardo San Martin Morote - * @license MIT - */let FS=()=>location.protocol+"//"+location.host;function Eg(e,t){const{pathname:s,search:a,hash:n}=t,i=e.indexOf("#");if(i>-1){let l=n.includes(e.slice(i))?e.slice(i).length:1,o=n.slice(l);return o[0]!=="/"&&(o="/"+o),Tf(o,"")}return Tf(s,e)+a+n}function BS(e,t,s,a){let n=[],i=[],l=null;const o=({state:p})=>{const m=Eg(e,location),h=s.value,y=t.value;let k=0;if(p){if(s.value=m,t.value=p,l&&l===h){l=null;return}k=y?p.position-y.position:0}else a(m);n.forEach(A=>{A(s.value,h,{delta:k,type:Od.pop,direction:k?k>0?Vc.forward:Vc.back:Vc.unknown})})};function r(){l=s.value}function c(p){n.push(p);const m=()=>{const h=n.indexOf(p);h>-1&&n.splice(h,1)};return i.push(m),m}function d(){if(document.visibilityState==="hidden"){const{history:p}=window;if(!p.state)return;p.replaceState(xt({},p.state,{scroll:cc()}),"")}}function u(){for(const p of i)p();i=[],window.removeEventListener("popstate",o),window.removeEventListener("pagehide",d),document.removeEventListener("visibilitychange",d)}return window.addEventListener("popstate",o),window.addEventListener("pagehide",d),document.addEventListener("visibilitychange",d),{pauseListeners:r,listen:c,destroy:u}}function Of(e,t,s,a=!1,n=!1){return{back:e,current:t,forward:s,replaced:a,position:window.history.length,scroll:n?cc():null}}function US(e){const{history:t,location:s}=window,a={value:Eg(e,s)},n={value:t.state};n.value||i(a.value,{back:null,current:a.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function i(r,c,d){const u=e.indexOf("#"),p=u>-1?(s.host&&document.querySelector("base")?e:e.slice(u))+r:FS()+e+r;try{t[d?"replaceState":"pushState"](c,"",p),n.value=c}catch(m){console.error(m),s[d?"replace":"assign"](p)}}function l(r,c){i(r,xt({},t.state,Of(n.value.back,r,n.value.forward,!0),c,{position:n.value.position}),!0),a.value=r}function o(r,c){const d=xt({},n.value,t.state,{forward:r,scroll:cc()});i(d.current,d,!0),i(r,xt({},Of(a.value,r,null),{position:d.position+1},c),!1),a.value=r}return{location:a,state:n,push:o,replace:l}}function zS(e){e=SS(e);const t=US(e),s=BS(e,t.state,t.location,t.replace);function a(i,l=!0){l||s.pauseListeners(),history.go(i)}const n=xt({location:"",base:e,go:a,createHref:TS.bind(null,e)},t,s);return Object.defineProperty(n,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(n,"state",{enumerable:!0,get:()=>t.state.value}),n}function HS(e){return e=location.host?e||location.pathname+location.search:"",e.includes("#")||(e+="#"),zS(e)}let Zn=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.Group=2]="Group",e})({});var ss=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.ParamRegExp=2]="ParamRegExp",e[e.ParamRegExpEnd=3]="ParamRegExpEnd",e[e.EscapeNext=4]="EscapeNext",e})(ss||{});const jS={type:Zn.Static,value:""},qS=/[a-zA-Z0-9_]/;function VS(e){if(!e)return[[]];if(e==="/")return[[jS]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(m){throw new Error(`ERR (${s})/"${c}": ${m}`)}let s=ss.Static,a=s;const n=[];let i;function l(){i&&n.push(i),i=[]}let o=0,r,c="",d="";function u(){c&&(s===ss.Static?i.push({type:Zn.Static,value:c}):s===ss.Param||s===ss.ParamRegExp||s===ss.ParamRegExpEnd?(i.length>1&&(r==="*"||r==="+")&&t(`A repeatable param (${c}) must be alone in its segment. eg: '/:ids+.`),i.push({type:Zn.Param,value:c,regexp:d,repeatable:r==="*"||r==="+",optional:r==="*"||r==="?"})):t("Invalid state to consume buffer"),c="")}function p(){c+=r}for(;ot.length?t.length===1&&t[0]===Ls.Static+Ls.Segment?1:-1:0}function Ag(e,t){let s=0;const a=e.score,n=t.score;for(;s0&&t[t.length-1]<0}const ZS={strict:!1,end:!0,sensitive:!1};function QS(e,t,s){const a=KS(VS(e.path),s),n=xt(a,{record:e,parent:t,children:[],alias:[]});return t&&!n.record.aliasOf==!t.record.aliasOf&&t.children.push(n),n}function YS(e,t){const s=[],a=new Map;t=Cf(ZS,t);function n(u){return a.get(u)}function i(u,p,m){const h=!m,y=Df(u);y.aliasOf=m&&m.record;const k=Cf(t,u),A=[y];if("alias"in u){const x=typeof u.alias=="string"?[u.alias]:u.alias;for(const w of x)A.push(Df(xt({},y,{components:m?m.record.components:y.components,path:w,aliasOf:m?m.record:y})))}let g,b;for(const x of A){const{path:w}=x;if(p&&w[0]!=="/"){const _=p.record.path,T=_[_.length-1]==="/"?"":"/";x.path=p.record.path+(w&&T+w)}if(g=QS(x,p,k),m?m.alias.push(g):(b=b||g,b!==g&&b.alias.push(g),h&&u.name&&!Pf(g)&&l(u.name)),Rg(g)&&r(g),y.children){const _=y.children;for(let T=0;T<_.length;T++)i(_[T],g,m&&m.children[T])}m=m||g}return b?()=>{l(b)}:Fl}function l(u){if(Cg(u)){const p=a.get(u);p&&(a.delete(u),s.splice(s.indexOf(p),1),p.children.forEach(l),p.alias.forEach(l))}else{const p=s.indexOf(u);p>-1&&(s.splice(p,1),u.record.name&&a.delete(u.record.name),u.children.forEach(l),u.alias.forEach(l))}}function o(){return s}function r(u){const p=t1(u,s);s.splice(p,0,u),u.record.name&&!Pf(u)&&a.set(u.record.name,u)}function c(u,p){let m,h={},y,k;if("name"in u&&u.name){if(m=a.get(u.name),!m)throw Yi(qt.MATCHER_NOT_FOUND,{location:u});k=m.record.name,h=xt(Mf(p.params,m.keys.filter(b=>!b.optional).concat(m.parent?m.parent.keys.filter(b=>b.optional):[]).map(b=>b.name)),u.params&&Mf(u.params,m.keys.map(b=>b.name))),y=m.stringify(h)}else if(u.path!=null)y=u.path,m=s.find(b=>b.re.test(y)),m&&(h=m.parse(y),k=m.record.name);else{if(m=p.name?a.get(p.name):s.find(b=>b.re.test(p.path)),!m)throw Yi(qt.MATCHER_NOT_FOUND,{location:u,currentLocation:p});k=m.record.name,h=xt({},p.params,u.params),y=m.stringify(h)}const A=[];let g=m;for(;g;)A.unshift(g.record),g=g.parent;return{name:k,path:y,params:h,matched:A,meta:e1(A)}}e.forEach(u=>i(u));function d(){s.length=0,a.clear()}return{addRoute:i,resolve:c,removeRoute:l,clearRoutes:d,getRoutes:o,getRecordMatcher:n}}function Mf(e,t){const s={};for(const a of t)a in e&&(s[a]=e[a]);return s}function Df(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:XS(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function XS(e){const t={},s=e.props||!1;if("component"in e)t.default=s;else for(const a in e.components)t[a]=typeof s=="object"?s[a]:s;return t}function Pf(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function e1(e){return e.reduce((t,s)=>xt(t,s.meta),{})}function t1(e,t){let s=0,a=t.length;for(;s!==a;){const i=s+a>>1;Ag(e,t[i])<0?a=i:s=i+1}const n=s1(e);return n&&(a=t.lastIndexOf(n,a-1)),a}function s1(e){let t=e;for(;t=t.parent;)if(Rg(t)&&Ag(e,t)===0)return t}function Rg({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function $f(e){const t=ra(dc),s=ra(ju),a=B(()=>{const r=La(e.to);return t.resolve(r)}),n=B(()=>{const{matched:r}=a.value,{length:c}=r,d=r[c-1],u=s.matched;if(!d||!u.length)return-1;const p=u.findIndex(Qi.bind(null,d));if(p>-1)return p;const m=Ff(r[c-2]);return c>1&&Ff(d)===m&&u[u.length-1].path!==m?u.findIndex(Qi.bind(null,r[c-2])):p}),i=B(()=>n.value>-1&&o1(s.params,a.value.params)),l=B(()=>n.value>-1&&n.value===s.matched.length-1&&Sg(s.params,a.value.params));function o(r={}){if(l1(r)){const c=t[La(e.replace)?"replace":"push"](La(e.to)).catch(Fl);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>c),c}return Promise.resolve()}return{route:a,href:B(()=>a.value.href),isActive:i,isExactActive:l,navigate:o}}function a1(e){return e.length===1?e[0]:e}const n1=fo({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:$f,setup(e,{slots:t}){const s=Mn($f(e)),{options:a}=ra(dc),n=B(()=>({[Bf(e.activeClass,a.linkActiveClass,"router-link-active")]:s.isActive,[Bf(e.exactActiveClass,a.linkExactActiveClass,"router-link-exact-active")]:s.isExactActive}));return()=>{const i=t.default&&a1(t.default(s));return e.custom?i:qi("a",{"aria-current":s.isExactActive?e.ariaCurrentValue:null,href:s.href,onClick:s.navigate,class:n.value},i)}}}),i1=n1;function l1(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function o1(e,t){for(const s in t){const a=t[s],n=e[s];if(typeof a=="string"){if(a!==n)return!1}else if(!ga(n)||n.length!==a.length||a.some((i,l)=>i.valueOf()!==n[l].valueOf()))return!1}return!0}function Ff(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const Bf=(e,t,s)=>e??t??s,r1=fo({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:s}){const a=ra(Nd),n=B(()=>e.route||a.value),i=ra(If,0),l=B(()=>{let c=La(i);const{matched:d}=n.value;let u;for(;(u=d[c])&&!u.components;)c++;return c}),o=B(()=>n.value.matched[l.value]);Nl(If,B(()=>l.value+1)),Nl(PS,o),Nl(Nd,n);const r=f();return Gt(()=>[r.value,o.value,e.name],([c,d,u],[p,m,h])=>{d&&(d.instances[u]=c,m&&m!==d&&c&&c===p&&(d.leaveGuards.size||(d.leaveGuards=m.leaveGuards),d.updateGuards.size||(d.updateGuards=m.updateGuards))),c&&d&&(!m||!Qi(d,m)||!p)&&(d.enterCallbacks[u]||[]).forEach(y=>y(c))},{flush:"post"}),()=>{const c=n.value,d=e.name,u=o.value,p=u&&u.components[d];if(!p)return Uf(s.default,{Component:p,route:c});const m=u.props[d],h=m?m===!0?c.params:typeof m=="function"?m(c):m:null,k=qi(p,xt({},h,t,{onVnodeUnmounted:A=>{A.component.isUnmounted&&(u.instances[d]=null)},ref:r}));return Uf(s.default,{Component:k,route:c})||k}}});function Uf(e,t){if(!e)return null;const s=e(t);return s.length===1?s[0]:s}const c1=r1;function d1(e){const t=YS(e.routes,e),s=e.parseQuery||MS,a=e.stringifyQuery||Rf,n=e.history,i=hl(),l=hl(),o=hl(),r=Xd(kn);let c=kn;Ei&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const d=jc.bind(null,le=>""+le),u=jc.bind(null,gS),p=jc.bind(null,no);function m(le,oe){let ve,j;return Cg(le)?(ve=t.getRecordMatcher(le),j=oe):j=le,t.addRoute(j,ve)}function h(le){const oe=t.getRecordMatcher(le);oe&&t.removeRoute(oe)}function y(){return t.getRoutes().map(le=>le.record)}function k(le){return!!t.getRecordMatcher(le)}function A(le,oe){if(oe=xt({},oe||r.value),typeof le=="string"){const P=qc(s,le,oe.path),G=t.resolve({path:P.path},oe),he=n.createHref(P.fullPath);return xt(P,G,{params:p(G.params),hash:no(P.hash),redirectedFrom:void 0,href:he})}let ve;if(le.path!=null)ve=xt({},le,{path:qc(s,le.path,oe.path).path});else{const P=xt({},le.params);for(const G in P)P[G]==null&&delete P[G];ve=xt({},le,{params:u(P)}),oe.params=u(oe.params)}const j=t.resolve(ve,oe),Y=le.hash||"";j.params=d(p(j.params));const xe=xS(a,xt({},le,{hash:mS(Y),path:j.path})),S=n.createHref(xe);return xt({fullPath:xe,hash:Y,query:a===Rf?DS(le.query):le.query||{}},j,{redirectedFrom:void 0,href:S})}function g(le){return typeof le=="string"?qc(s,le,r.value.path):xt({},le)}function b(le,oe){if(c!==le)return Yi(qt.NAVIGATION_CANCELLED,{from:oe,to:le})}function x(le){return T(le)}function w(le){return x(xt(g(le),{replace:!0}))}function _(le,oe){const ve=le.matched[le.matched.length-1];if(ve&&ve.redirect){const{redirect:j}=ve;let Y=typeof j=="function"?j(le,oe):j;return typeof Y=="string"&&(Y=Y.includes("?")||Y.includes("#")?Y=g(Y):{path:Y},Y.params={}),xt({query:le.query,hash:le.hash,params:Y.path!=null?{}:le.params},Y)}}function T(le,oe){const ve=c=A(le),j=r.value,Y=le.state,xe=le.force,S=le.replace===!0,P=_(ve,j);if(P)return T(xt(g(P),{state:typeof P=="object"?xt({},Y,P.state):Y,force:xe,replace:S}),oe||ve);const G=ve;G.redirectedFrom=oe;let he;return!xe&&_S(a,j,ve)&&(he=Yi(qt.NAVIGATION_DUPLICATED,{to:G,from:j}),Z(j,j,!0,!1)),(he?Promise.resolve(he):F(G,j)).catch(fe=>ja(fe)?ja(fe,qt.NAVIGATION_GUARD_REDIRECT)?fe:ce(fe):$(fe,G,j)).then(fe=>{if(fe){if(ja(fe,qt.NAVIGATION_GUARD_REDIRECT))return T(xt({replace:S},g(fe.to),{state:typeof fe.to=="object"?xt({},Y,fe.to.state):Y,force:xe}),oe||G)}else fe=I(G,j,!0,S,Y);return O(G,j,fe),fe})}function L(le,oe){const ve=b(le,oe);return ve?Promise.reject(ve):Promise.resolve()}function E(le){const oe=V.values().next().value;return oe&&typeof oe.runWithContext=="function"?oe.runWithContext(le):le()}function F(le,oe){let ve;const[j,Y,xe]=$S(le,oe);ve=Gc(j.reverse(),"beforeRouteLeave",le,oe);for(const P of j)P.leaveGuards.forEach(G=>{ve.push(Rn(G,le,oe))});const S=L.bind(null,le,oe);return ve.push(S),Oe(ve).then(()=>{ve=[];for(const P of i.list())ve.push(Rn(P,le,oe));return ve.push(S),Oe(ve)}).then(()=>{ve=Gc(Y,"beforeRouteUpdate",le,oe);for(const P of Y)P.updateGuards.forEach(G=>{ve.push(Rn(G,le,oe))});return ve.push(S),Oe(ve)}).then(()=>{ve=[];for(const P of xe)if(P.beforeEnter)if(ga(P.beforeEnter))for(const G of P.beforeEnter)ve.push(Rn(G,le,oe));else ve.push(Rn(P.beforeEnter,le,oe));return ve.push(S),Oe(ve)}).then(()=>(le.matched.forEach(P=>P.enterCallbacks={}),ve=Gc(xe,"beforeRouteEnter",le,oe,E),ve.push(S),Oe(ve))).then(()=>{ve=[];for(const P of l.list())ve.push(Rn(P,le,oe));return ve.push(S),Oe(ve)}).catch(P=>ja(P,qt.NAVIGATION_CANCELLED)?P:Promise.reject(P))}function O(le,oe,ve){o.list().forEach(j=>E(()=>j(le,oe,ve)))}function I(le,oe,ve,j,Y){const xe=b(le,oe);if(xe)return xe;const S=oe===kn,P=Ei?history.state:{};ve&&(j||S?n.replace(le.fullPath,xt({scroll:S&&P&&P.scroll},Y)):n.push(le.fullPath,Y)),r.value=le,Z(le,oe,ve,S),ce()}let H;function te(){H||(H=n.listen((le,oe,ve)=>{if(!be.listening)return;const j=A(le),Y=_(j,be.currentRoute.value);if(Y){T(xt(Y,{replace:!0,force:!0}),j).catch(Fl);return}c=j;const xe=r.value;Ei&&RS(Af(xe.fullPath,ve.delta),cc()),F(j,xe).catch(S=>ja(S,qt.NAVIGATION_ABORTED|qt.NAVIGATION_CANCELLED)?S:ja(S,qt.NAVIGATION_GUARD_REDIRECT)?(T(xt(g(S.to),{force:!0}),j).then(P=>{ja(P,qt.NAVIGATION_ABORTED|qt.NAVIGATION_DUPLICATED)&&!ve.delta&&ve.type===Od.pop&&n.go(-1,!1)}).catch(Fl),Promise.reject()):(ve.delta&&n.go(-ve.delta,!1),$(S,j,xe))).then(S=>{S=S||I(j,xe,!1),S&&(ve.delta&&!ja(S,qt.NAVIGATION_CANCELLED)?n.go(-ve.delta,!1):ve.type===Od.pop&&ja(S,qt.NAVIGATION_ABORTED|qt.NAVIGATION_DUPLICATED)&&n.go(-1,!1)),O(j,xe,S)}).catch(Fl)}))}let ee=hl(),N=hl(),M;function $(le,oe,ve){ce(le);const j=N.list();return j.length?j.forEach(Y=>Y(le,oe,ve)):console.error(le),Promise.reject(le)}function re(){return M&&r.value!==kn?Promise.resolve():new Promise((le,oe)=>{ee.add([le,oe])})}function ce(le){return M||(M=!le,te(),ee.list().forEach(([oe,ve])=>le?ve(le):oe()),ee.reset()),le}function Z(le,oe,ve,j){const{scrollBehavior:Y}=e;if(!Ei||!Y)return Promise.resolve();const xe=!ve&&IS(Af(le.fullPath,0))||(j||!ve)&&history.state&&history.state.scroll||null;return Ut().then(()=>Y(le,oe,xe)).then(S=>S&&AS(S)).catch(S=>$(S,le,oe))}const Q=le=>n.go(le);let ae;const V=new Set,be={currentRoute:r,listening:!0,addRoute:m,removeRoute:h,clearRoutes:t.clearRoutes,hasRoute:k,getRoutes:y,resolve:A,options:e,push:x,replace:w,go:Q,back:()=>Q(-1),forward:()=>Q(1),beforeEach:i.add,beforeResolve:l.add,afterEach:o.add,onError:N.add,isReady:re,install(le){le.component("RouterLink",i1),le.component("RouterView",c1),le.config.globalProperties.$router=be,Object.defineProperty(le.config.globalProperties,"$route",{enumerable:!0,get:()=>La(r)}),Ei&&!ae&&r.value===kn&&(ae=!0,x(n.location).catch(j=>{}));const oe={};for(const j in kn)Object.defineProperty(oe,j,{get:()=>r.value[j],enumerable:!0});le.provide(dc,be),le.provide(ju,Yd(oe)),le.provide(Nd,r);const ve=le.unmount;V.add(le),le.unmount=function(){V.delete(le),V.size<1&&(c=kn,H&&H(),H=null,r.value=kn,ae=!1,M=!1),ve()}}};function Oe(le){return le.reduce((oe,ve)=>oe.then(()=>E(ve)),Promise.resolve())}return be}function Ig(){return ra(dc)}function u1(e){return ra(ju)}const uc={props:{tabs:{type:Array,required:!0},defaultTab:{type:String,default:""},groupLabel:{type:String,default:""}},setup(e){const t=u1(),s=Ig(),a=B({get(){var r;const o=t.query.tab;return o&&e.tabs.some(c=>c.id===o)?o:e.defaultTab||((r=e.tabs[0])==null?void 0:r.id)||""},set(o){s.replace({query:{...t.query,tab:o}})}}),n=B(()=>{var o;return((o=e.tabs.find(r=>r.id===a.value))==null?void 0:o.component)||null}),i=B(()=>{var o;return((o=e.tabs.find(r=>r.id===a.value))==null?void 0:o.label)||""});Gt(i,o=>{e.groupLabel&&o&&(document.title=`Odin — ${e.groupLabel} › ${o}`)},{immediate:!0});function l(o,r){if(!["ArrowLeft","ArrowRight","Home","End"].includes(o.key))return;o.preventDefault();let c=r;o.key==="ArrowRight"&&(c=(r+1)%e.tabs.length),o.key==="ArrowLeft"&&(c=(r-1+e.tabs.length)%e.tabs.length),o.key==="Home"&&(c=0),o.key==="End"&&(c=e.tabs.length-1),a.value=e.tabs[c].id,requestAnimationFrame(()=>{var d;return(d=document.getElementById("tab-"+e.tabs[c].id))==null?void 0:d.focus()})}return{activeTab:a,activeComponent:n,activeLabel:i,onTabKeydown:l}},template:` -
-
-
- -
-
-
- -
-
- `},io=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),Da=e=>Number.isSafeInteger(e)&&e>=0,p1=e=>e===null||typeof e=="string",Dr=(e,t)=>Da(e)&&Da(t)&&t>=e,qu=e=>io(e)&&typeof e.status=="string"&&typeof e.truncated=="boolean"&&p1(e.cursor);function f1(e){return!qu(e)||e.kind!=="tool_output"?!1:e.retention==="failed"?typeof e.error=="string"&&typeof e.head=="string"&&io(e.tail)&&typeof e.tail.text=="string"&&e.cursor===null&&e.truncated:e.retention!=="retained"||typeof e.result_id!="string"||!Da(e.total_chars)||!Da(e.total_bytes)||e.offset_unit!=="unicode_code_points"||!Dr(e.start,e.end)||e.end>e.total_chars?!1:"head"in e?typeof e.head=="string"&&io(e.tail)&&typeof e.tail.text=="string"&&Dr(e.tail.start,e.tail.end)&&e.tail.end<=e.total_chars&&e.tail_is_context_only===!0:typeof e.text=="string"&&!("tail"in e)}function zf(e){return qu(e)&&e.kind==="process_output"&&Da(e.pid)&&typeof e.generation=="string"&&(e.exit_code===null||Number.isInteger(e.exit_code))&&["emitted_bytes","retained_bytes","shown_bytes","capture_limit_loss_bytes","not_retained_bytes"].every(t=>Da(e[t]))&&e.retained_bytes<=e.emitted_bytes&&Array.isArray(e.shown_intervals)&&e.shown_intervals.every(t=>Array.isArray(t)&&t.length===2&&Dr(...t)&&t[1]<=("text"in e?e.retained_bytes:e.emitted_bytes))}function m1(e){return qu(e)&&!("kind"in e)&&typeof e.id=="string"&&typeof e.label=="string"&&typeof e.preview=="string"&&["original_bytes","result_bytes","error_bytes","source_original_bytes"].every(t=>Da(e[t]))&&Dr(e.offset,e.end)&&e.end<=e.original_bytes&&e.result_bytes+e.error_bytes===e.original_bytes&&Array.isArray(e.tools_used)&&e.tools_used.every(t=>typeof t=="string")&&Da(e.tools_omitted)}function Md(e){try{return JSON.parse(e)}catch{return}}const Dd=e=>JSON.stringify(e,null,2),v1=e=>{const t=Md(e);return t===void 0?e:Dd(t)},Uo=(e,t,s)=>`[${e}, ${t}) ${s}`;function Og(e,{prettyPrint:t=!0}={}){var o,r;const s=typeof e=="string"?e:Dd(e)??"";let a=typeof e=="string"?Md(e):e,n=null;if(typeof e=="string"&&a===void 0){const c=` -[output retention] `,d=e.lastIndexOf(c),u=e.indexOf(` -`);if(d>u&&u>0){const p=Md(e.slice(d+c.length)),m=e.slice(0,u);zf(p)&&!("text"in p)&&m.startsWith(`[PID ${p.pid}] status=${p.status} `)&&(a=p,n=e.slice(u+1,d))}}const i={raw:s,kind:"text",header:[],sections:[],metadata:null},l=(c,d)=>({label:c,text:t?v1(d):d});if(io(a)&&a.kind==="audit_preview"&&a.audit_clipped===!0&&(!("original_chars"in a)||Da(a.original_chars))&&(!("preview"in a)||typeof a.preview=="string")){if(i.kind="audit_preview",i.header=["audit clipped: yes",...Da(a.original_chars)?[`original ${a.original_chars} code points`]:[]],io(a.source))for(const c of["kind","status","retention","truncated","capture_loss","capture_limit_loss_bytes","not_retained_bytes","cursor_present","total_bytes","total_chars","retained_bytes","emitted_bytes","shown_bytes","offset_unit","capture_error","pid","exit_code","start","end","tail_status","original_bytes","result_bytes","error_bytes","offset","source_original_bytes","id","capture_lost_bytes","dropped_bytes","output_lost","capture_truncated","retention_seconds_after_exit"])["string","number","boolean"].includes(typeof a.source[c])&&i.header.push(`source ${c}: ${a.source[c]}`);return i.sections.push({label:"Audit preview — incomplete source; raw shows stored wrapper",text:a.preview??(t?"(no preview retained in audit)":"")}),i.metadata=a,i}if(f1(a))i.kind="tool_output",i.header=[a.status,`retention: ${a.retention}`],a.retention==="retained"?(i.header.push(`${a.total_bytes} UTF-8 bytes`,`${a.total_chars} code points`),i.sections.push(l(`${"head"in a?"Head":"Page"} ${Uo(a.start,a.end,"code points")}`,a.head??a.text)),(o=a.tail)!=null&&o.text&&i.sections.push(l(`Tail context only ${Uo(a.tail.start,a.tail.end,"code points")} — not a continuation`,a.tail.text))):(i.header.push(a.error),i.sections.push(l("Head — retention failed",a.head),l("Tail context only — may overlap head",a.tail.text))),typeof((r=a.matches)==null?void 0:r.summary)=="string"&&i.header.push(a.matches.summary);else if(zf(a)&&(typeof a.text=="string"||n!==null)){i.kind="process_output",i.header=[a.status,`PID ${a.pid}`,...a.exit_code!==null?[`exit ${a.exit_code}`]:[],`emitted ${a.emitted_bytes} B`,`retained ${a.retained_bytes} B`,`shown ${a.shown_bytes} B`,`capture-limit loss ${a.capture_limit_loss_bytes} B`,`not retained ${a.not_retained_bytes} B`],a.capture_error&&i.header.push(`capture error: ${a.capture_error}`),a.tail_status&&i.header.push(`recent output: ${a.tail_status}`);const c=a.shown_intervals.map(d=>Uo(...d,"UTF-8 bytes")).join(", ");i.sections.push(l(`${n!==null?"Recent preview — retrieval starts at byte 0":"Page"}${c?" · "+c:""}`,n??a.text))}else if(m1(a))i.kind="agent_result",i.header=[a.status,`agent ${a.id}`,a.label,`original ${a.original_bytes} B`,`result ${a.result_bytes} B`,`error ${a.error_bytes} B`,`source ${a.source_original_bytes} B`,`tools ${a.tools_used.length} shown / ${a.tools_omitted} omitted`],i.sections.push(l(`Result + error page ${Uo(a.offset,a.end,"UTF-8 bytes")}`,a.preview));else return i.kind=a===void 0?"text":"json",i.sections.push({label:"",text:a===void 0||!t&&typeof e=="string"?s:t?Dd(a):JSON.stringify(a)}),i;if(i.metadata=a,i.header.push(`source truncated: ${a.truncated?"yes":"no"}`,`cursor: ${a.cursor?"present":"none"}`),typeof a.expires_at=="string"&&i.header.push(`expires: ${a.expires_at}`),typeof a.expires_at=="number"){const c=new Date(a.expires_at*1e3);Number.isNaN(c.valueOf())||i.header.push(`expires: ${c.toISOString()}`)}return i}function Lg(e,t=30,s=6e3){let a=1,n=0,i=0;if(t>0&&s>0)for(const l of e){if(n>=s||l===` -`&&a>=t)break;l===` -`&&a++,n++,i+=l.length}return{text:e.slice(0,i),folded:ie!==null&&typeof e=="object",g1=new Set(["_hmac","_prev_hmac"]),Pd=e=>e.replace(/\r\n?/g,` -`);function lo(e){const t=typeof e=="string"?e:JSON.stringify(e)??"";try{let s=!1;const a=JSON.parse(t,(n,i)=>{if(g1.has(n)){s=!0;return}return i});return Pd(s?JSON.stringify(a):t)}catch{return Pd(t)}}function b1(e){const t=e.metadata;if(!t)return[];const s=[],a=e.kind==="audit_preview"&&h1(t.source)?t.source:t;return e.kind==="audit_preview"&&s.push("audit clipped"),a.truncated===!0&&s.push("source truncated"),a.retention==="failed"&&s.push("retention unavailable"),a.capture_error&&s.push(`capture unavailable: ${a.capture_error}`),a.capture_limit_loss_bytes>0&&s.push(`capture loss ${a.capture_limit_loss_bytes} B`),a.not_retained_bytes>0&&s.push(`not retained ${a.not_retained_bytes} B`),a.capture_lost_bytes>0&&s.push(`capture lost ${a.capture_lost_bytes} B`),a.dropped_bytes>0&&s.push(`dropped ${a.dropped_bytes} B`),(a.capture_loss===!0||a.output_lost===!0||a.capture_truncated===!0)&&s.push("capture loss"),Number.isInteger(a.exit_code)&&a.exit_code!==0&&s.push(`process exit ${a.exit_code}`),["failed","error","cancelled","timed_out"].includes(a.status)&&s.push(`source ${a.status}`),e.kind==="audit_preview"&&!t.preview&&s.push("audit body unavailable"),s}function y1(e){var m;const t=Og(typeof e=="string"?Pd(e):e,{prettyPrint:!1}),s=t.sections.map(h=>({...h,text:lo(h.text)})),a=s.map(h=>h.text).filter(Boolean).join(` -`),n=a.replace(/\n$/,""),i=[...a].length,l=n?n.split(` -`).length:0,o=!["text","json"].includes(t.kind),r=l>=2||i>Wc.inlineChars||o&&n.length>0,c=s.filter(h=>h.text).map(h=>{let y=h.text;try{y=JSON.stringify(JSON.parse(y),null,2)}catch{}return h.label?`${h.label} -${y}`:y}).join(` - -`).replace(/\n$/,""),d=Lg(c,Wc.previewLines,Wc.previewChars),u=t.kind==="audit_preview"?(m=t.metadata)==null?void 0:m.source:t.metadata,p=u&&(t.kind==="process_output"||u.kind==="process_output")?`PID ${u.pid??"?"} ${u.status??""}${Number.isInteger(u.exit_code)?` exit ${u.exit_code}`:""}`.trim():t.kind==="agent_result"&&(u!=null&&u.status)?`agent ${u.status}`:"";return{promoted:r,chars:i,lines:l,envelope:o,kind:t.kind,formatted:c,preview:d,outcome:p,header:t.header,summary:n.replace(/\n/g," "),warnings:b1(t)}}const x1={name:"CompactOutput",props:{value:{default:""},rawValue:{default:void 0},label:{type:String,default:"Output"},hasContext:{type:Boolean,default:!1},recordId:{default:null}},setup(e){const t=f(!1),s=f(!0),a=f(!1),n=f(""),i=f(null),l=f(null),o=f(!1),r=B(()=>y1(e.value)),c=B(()=>{const x=e.rawValue===void 0?e.value:e.rawValue;return typeof x=="string"?x:JSON.stringify(x,null,2)??""}),d=B(()=>a.value?c.value:r.value.formatted),u=B(()=>r.value.promoted&&r.value.preview.folded||o.value),p=B(()=>t.value?!!d.value:r.value.promoted),m=B(()=>p.value?"":r.value.summary);let h;function y(){if(t.value)return;const x=r.value.promoted?i.value:l.value;o.value=!!(x&&(x.scrollHeight>x.clientHeight+1||x.scrollWidth>x.clientWidth+1))}function k(){h==null||h.disconnect();for(const x of[i.value,l.value])x&&(h==null||h.observe(x));y()}function A(){t.value=!t.value,t.value||(a.value=!1)}function g(){a.value=!a.value,t.value=!0,n.value=""}async function b(){const x=e.value;try{await navigator.clipboard.writeText(d.value),x===e.value&&(n.value="Copied")}catch{x===e.value&&(n.value="Copy unavailable — select text manually")}}return Gt([()=>e.value,()=>e.rawValue,()=>e.recordId],(x,w)=>{(e.recordId===null||x[2]!==w[2])&&(t.value=!1,a.value=!1),n.value=""}),Gt([i,l,t,s,r],()=>Ut(k),{flush:"post"}),st(()=>{h=new ResizeObserver(y),k()}),wt(()=>h==null?void 0:h.disconnect()),{expanded:t,wrapped:s,rawMode:a,copyStatus:n,previewElement:i,summaryElement:l,model:r,body:d,canExpand:u,showBody:p,headerSummary:m,toggleExpanded:A,toggleRaw:g,copyOutput:b}},template:` -
-
-
- -
-
- - - -
- -
- -
- - - - {{ model.outcome }} - {{ headerSummary }} - -
-
{{ expanded ? body : model.preview.text }}
-
-
{{ model.warnings.join('; ') }}
-
{{ model.header.join(' · ') }}
- -
- {{ copyStatus }} -
`},pc={name:"ToolOutput",components:{CompactOutput:x1},props:{value:{default:""},label:{type:String,default:"Output"},presentation:{type:String,default:"inspector"},rawValue:{default:void 0},hasContext:{type:Boolean,default:!1},recordId:{default:null}},setup(e){const t=f(!1),s=f(!0),a=f(!1),n=f(""),i=B(()=>Og(e.value)),l=B(()=>a.value?[{label:"Raw received value",text:i.value.raw}]:i.value.sections),o=B(()=>{let u=30,p=6e3;return l.value.map(m=>{const h=Lg(m.text,u,p);return u=Math.max(0,u-h.lines),p=Math.max(0,p-h.chars),{...m,display:t.value?m.text:h.text,folded:h.folded}})}),r=B(()=>o.value.some(u=>u.folded)),c=B(()=>a.value?i.value.raw:l.value.map(u=>u.label?`${u.label} -${u.text}`:u.text).join(` - -`));async function d(){const u=e.value;try{await navigator.clipboard.writeText(c.value),e.value===u&&(n.value="Copied")}catch{e.value===u&&(n.value="Copy unavailable — select text manually")}}return Gt(()=>e.value,()=>{t.value=!1,n.value=""}),Gt(a,()=>{t.value=!1,n.value=""}),{expanded:t,wrapped:s,rawMode:a,copyStatus:n,model:i,foldedSections:o,canExpand:r,copyOutput:d}},template:` - - - - - -
-
- {{ model.kind }} - {{ item }} -
-
- - - - {{ copyStatus }} -
-
- -
{{ section.display }}
- More received text hidden locally. -
- -
- `},_1={components:{ToolOutput:pc},setup(){const e=f([]),t=f([]),s=f({}),a=50;function n(p){var y,k,A,g,b,x,w,_,T,L,E;const m=p.payload||p,h=m.type||p.type;if(!(["loop_tool_start","loop_tool"].includes(h)&&!(m.agent_id||(y=m.metadata)!=null&&y.agent_id))&&!(["loop_tool_start","loop_tool"].includes(h)&&!(m.call_id||(k=m.metadata)!=null&&k.call_id))){if(h==="tool_start"||h==="loop_tool_start"){const F=m.call_id||((A=m.metadata)==null?void 0:A.call_id)||null,O=m.agent_id||((g=m.metadata)==null?void 0:g.agent_id)||"",I={callId:F,agentId:O,agentLabel:m.agent_label||((b=m.metadata)==null?void 0:b.agent_label)||"",toolInput:m.tool_input,id:F?`${O}:${F}`:`${m.action}-${Date.now()}`,tool:m.action,actor:m.actor||"",channel:m.channel_id||"",iteration:m.iteration??((x=m.metadata)==null?void 0:x.iteration)??0,startTime:Date.now(),elapsed:0,status:"running",output:"",result:""};e.value.unshift(I);return}if(h==="tool_end"||h==="loop_tool"){const F=m.call_id||((w=m.metadata)==null?void 0:w.call_id)||null,O=m.agent_id||((_=m.metadata)==null?void 0:_.agent_id)||"";let I=-1;if(F&&(I=e.value.findIndex(H=>H.callId===F&&H.agentId===O&&H.status==="running")),I<0&&!F)for(let H=e.value.length-1;H>=0;H--){const te=e.value[H];if(te.tool===m.action&&te.agentId===O&&te.status==="running"){I=H;break}}if(I>=0){const H=e.value[I];H.status=m.error||(T=m.metadata)!=null&&T.error||["error","failed","cancelled","denied","outcome_unknown"].includes(m.status||((L=m.metadata)==null?void 0:L.status))?"error":"success",H.elapsed=m.execution_time_ms??m.duration_ms??((E=m.metadata)==null?void 0:E.elapsed_ms)??Date.now()-H.startTime,H.result=m.result_summary??m.detail??"",H.fadingOut=!0,setTimeout(()=>{const te=e.value.indexOf(H);te>=0&&e.value.splice(te,1),t.value.unshift(H),t.value.length>a&&t.value.pop()},5e3)}return}if(h==="tool_stream"){const F=m.call_id||m.tool_name||"unknown";if(m.finished){const O={...s.value};delete O[F],s.value=O}else{const I=((s.value[F]||"")+(m.chunk||"")).split(` -`);s.value={...s.value,[F]:I.slice(-30).join(` -`)}}return}}}let i=null;function l(){const p=Date.now();e.value.forEach(m=>{m.status==="running"&&(m.elapsed=p-m.startTime)})}let o=!1;function r(){o||(o=!0,mt.on("events",n),i||(i=setInterval(l,500)))}function c(){o&&(o=!1,mt.off("events",n),i&&(clearInterval(i),i=null))}st(r),cs(r),es(c),wt(c);function d(p){return p<1e3?`${p}ms`:`${(p/1e3).toFixed(1)}s`}function u(p){return p==="running"?"clock":p==="success"?"success":p==="error"?"error":"info"}return{activeTasks:e,recentHistory:t,streamOutput:s,formatMs:d,statusIcon:u}},template:` -
-

- Execution Viewer -

- - -
-

Active

-
- No active tool executions -
-
-
-
- - - {{ task.tool }} - iter {{ task.iteration }} - agent {{ task.agentLabel || task.agentId }} -
- {{ formatMs(task.elapsed) }} -
- -
Arguments
- - -
-
- - -
-

Live Output

-
-
{{ tool }}
- - -
-
- - -
-

- Recent ({{ recentHistory.length }}) -

-
- No recent executions -
-
- - {{ task.tool }} - agent {{ task.agentLabel || task.agentId }} - {{ formatMs(task.elapsed) }} -
Arguments
- -
-
-
- `};function Vu(e){if(e instanceof Date)return e;if(typeof e=="string"){const t=new Date(e);return isNaN(t.getTime())?null:t}return typeof e=="number"&&isFinite(e)?new Date(e<1e12?e*1e3:e):null}function di(e){const t=Vu(e);return t?t.toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}):"—"}function w1(e){const t=Vu(e);return t?t.toLocaleTimeString():"—"}function Ng(e){const t=Vu(e);if(!t)return"—";const s=Math.max(0,Math.floor((Date.now()-t.getTime())/1e3));return s<60?`${s}s ago`:s<3600?`${Math.floor(s/60)}m ago`:s<86400?`${Math.floor(s/3600)}h ago`:`${Math.floor(s/86400)}d ago`}function k1(e){if(e==null||!isFinite(e))return"—";const t=Math.max(0,Math.floor(Number(e)));return t<60?"less than 1 min ago":t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)} hr ago`:`${Math.floor(t/86400)} day ago`}function Xi(e){if(e==null||!isFinite(e))return"—";const t=Math.max(0,Math.round(e));if(t<60)return`${t}s`;if(t<3600){const n=Math.floor(t/60),i=t%60;return i?`${n}m ${i}s`:`${n}m`}const s=Math.floor(t/3600),a=Math.floor(t%3600/60);return a?`${s}h ${a}m`:`${s}h`}function Gu(e,t=200){const s=String(e??"");return s.length>t?s.slice(0,t)+"…":s}function Mg(e,t=5e3){const s=String(e??"");return s.length>t?s.slice(0,t)+` -... (truncated)`:s}function Hf(e){return String(e??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function Wu(e){return e==null||!isFinite(e)?"—":Number(e).toLocaleString()}function Dg(e){return e==null||!isFinite(e)?"—":e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}const Pg=Symbol("agent-detail-cancelled"),S1=15e3;function C1(e,{timeoutMs:t,timeoutLabel:s,scheduleTimeout:a,cancelTimeout:n}){const i=typeof AbortController=="function"?new AbortController:null;let l=null,o=!1,r,c;const d=new Promise((m,h)=>{r=m,c=h});function u(m,h){o||(o=!0,l!==null&&n(l),l=null,(m?r:c)(h))}let p;try{p=e(i==null?void 0:i.signal)}catch(m){u(!1,m)}return o||Promise.resolve(p).then(m=>u(!0,m),m=>u(!1,m)),!o&&Number.isFinite(t)&&t>0&&(l=a(()=>{const m=Math.max(1,Math.round(t/1e3));u(!1,new Error(`${s} request timed out after ${m}s`)),i==null||i.abort()},t)),{promise:d,cancel(){u(!0,Pg),i==null||i.abort()}}}function $g({state:e,requestDetail:t,timeoutMs:s=S1,detailLabel:a="Agent detail",scheduleTimeout:n=globalThis.setTimeout.bind(globalThis),cancelTimeout:i=globalThis.clearTimeout.bind(globalThis)}){if(!e||typeof e!="object")throw new TypeError("agent detail state is required");if(typeof t!="function")throw new TypeError("requestDetail must be a function");let l=null;function o(){const p=l;l=null,p==null||p.cancel()}function r(p,{initial:m,coalesce:h}){if(!p)return Promise.resolve();if(h&&l&&l.agentId===p&&e.detailId===p)return l.promise;o();const y={agentId:p,cancel:null,promise:null};l=y,m?(e.detail=null,e.detailError=null,e.detailLoading=!0):e.detail===null&&e.detailError===null&&(e.detailLoading=!0);const k=C1(A=>t(p,{signal:A}),{timeoutMs:s,timeoutLabel:a,scheduleTimeout:n,cancelTimeout:i});return y.cancel=k.cancel,y.promise=(async()=>{let A=null,g=null;try{A=await k.promise}catch(b){g=b}A!==Pg&&(l!==y||e.detailId!==p||(l=null,!g&&(A===null||typeof A!="object")&&(g=new Error(`${a} response was empty or invalid`)),g?e.detail===null&&(e.detailError=(g==null?void 0:g.message)||`Failed to load ${a.toLowerCase()}`):(e.detail=A,e.detailError=null),e.detailLoading=!1))})(),y.promise}function c(p){return e.detailId=p,r(p,{initial:!0,coalesce:!1})}function d(){const p=e.detailId;return p?r(p,{initial:!1,coalesce:!0}):Promise.resolve()}function u(){o(),e.detailId=null,e.detail=null,e.detailError=null,e.detailLoading=!1}return{open:c,refresh:d,close:u,hasInFlight:()=>l!==null}}function T1({isEnabled:e,refreshList:t,hasOpenDetail:s,refreshDetail:a,intervalMs:n=5e3,scheduleInterval:i=globalThis.setInterval.bind(globalThis),cancelInterval:l=globalThis.clearInterval.bind(globalThis)}){let o=null;function r(){e()&&(t(),s()&&a())}function c(){o!==null&&(l(o),o=null)}function d(){c(),e()&&(o=i(r,n))}function u(){e()?d():c()}return{start:d,stop:c,sync:u,isRunning:()=>o!==null}}const E1={components:{ToolOutput:pc},template:` -
-
-

Agents

-
- - -
-
- - -
-
- {{ agents.length }} - Total -
-
- {{ runningCount }} - Running -
-
- {{ completedCount }} - Completed -
-
- {{ failedCount }} - Failed -
-
- - - - - -
-
-
- -
- - No agents - Agents are spawned via Discord commands or the chat interface -
- - -
- -
-
- -
-
- - {{ agent.label }} - {{ agent.id }} -
- {{ agent.status }} -
- - - -
- {{ displayModelText(agent) }} - {{ displayEffortText(agent) }} -
- - -
{{ agent.goal }}
- - -
-
-
- - -
-
- Iterations - {{ agent.iteration_count }} -
-
- Runtime - {{ formatDuration(agent.runtime_seconds) }} -
-
- Tools - {{ agent.tools_used_count ?? 0 }} -
-
- - -
- - by {{ agent.requester_name }} - - - {{ formatTs(agent.created_at) }} - -
- - -
-
Result
-
{{ agent.result }}
-
-
-
Error
-
{{ agent.error }}
-
- -
- - -
- -
-
-
- - - -
`,setup(){const e=f([]),t=f(!0),s=f(null),a=f(null),n=f(!0),i=f("all");let l=!1;const o=B(()=>e.value.filter($=>$.status==="running").length),r=B(()=>e.value.filter($=>$.status==="completed").length),c=B(()=>e.value.filter($=>["failed","timeout","killed"].includes($.status)).length),d=B(()=>[{value:"all",label:"All",count:e.value.length},{value:"running",label:"Running",count:o.value},{value:"completed",label:"Completed",count:r.value},{value:"failed",label:"Failed",count:c.value}]),u=B(()=>i.value==="all"?e.value:i.value==="failed"?e.value.filter($=>["failed","timeout","killed"].includes($.status)):e.value.filter($=>$.status===i.value));function p($){const re=Number($.max_iterations)||0;return re<=0?0:Math.min(100,Math.round($.iteration_count/re*100))}function m($){return(Number($.max_iterations)||0)>0}function h($,re){return $?$==="N/A"?"N/A":re==="current_inheritance"?`inherit (currently ${$})`:$:"unknown"}function y($){return h($.display_model,$.display_model_source||$.display_source)}function k($){return h($.display_reasoning_effort,$.display_reasoning_effort_source||$.display_source)}function A($){return{last_execution:"last executed",current_inheritance:"inherited from current config — not yet executed",spawn_override_pending:"requested at spawn — not yet executed",unknown:"no execution data"}[$]||""}const g=f(null),b=f(null),x=f(!1),w=f(null),_=f(""),L=$g({state:{get detail(){return g.value},set detail($){g.value=$},get detailId(){return b.value},set detailId($){b.value=$},get detailLoading(){return x.value},set detailLoading($){x.value=$},get detailError(){return w.value},set detailError($){w.value=$}},requestDetail:($,{signal:re})=>z.get(`/api/agents/${encodeURIComponent($)}`,{signal:re})});async function E($){_.value="",await L.open($.id)}function F(){L.close(),_.value=""}async function O(){await L.refresh()}async function I($,re){try{await navigator.clipboard.writeText(re||""),_.value=$,setTimeout(()=>{_.value===$&&(_.value="")},1500)}catch{Se.error("Copy failed")}}async function H($=!1){$=$===!0,$||(t.value=!0);try{const re=await z.get("/api/agents");e.value=Array.isArray(re)?re:[],s.value=null}catch(re){$||(s.value=re.message)}$||(t.value=!1)}async function te($){const re=e.value.find(Z=>Z.id===$);if(await is({title:"Kill agent",message:`Kill agent "${(re==null?void 0:re.label)||$}"? Its current work will be lost.`,confirmLabel:"Kill",danger:!0})){a.value=$;try{await z.del(`/api/agents/${encodeURIComponent($)}`),Se.success("Agent killed"),await H()}catch(Z){Se.error(Z.message||"Failed to kill agent")}a.value=null}}const ee=T1({isEnabled:()=>n.value&&l,refreshList:()=>H(!0),hasOpenDetail:()=>!!b.value,refreshDetail:O});function N(){ee.start()}function M(){ee.stop()}return Gt(n,()=>ee.sync()),st(()=>{l=!0,H(),N()}),cs(()=>{l=!0,H(!0),N()}),es(()=>{l=!1,M()}),wt(()=>{l=!1,M(),L.close()}),{agents:e,loading:t,error:s,killing:a,autoRefresh:n,statusFilter:i,runningCount:o,completedCount:r,failedCount:c,statusFilters:d,filteredAgents:u,formatTs:di,formatDuration:Xi,progressPercent:p,hasProgress:m,displayModelText:y,displayEffortText:k,displaySourceLabel:A,detail:g,detailId:b,detailLoading:x,detailError:w,copied:_,openDetail:E,closeDetail:F,copyText:I,fetchAgents:H,killAgent:te,startAutoRefresh:N,stopAutoRefresh:M}}},A1={template:` -
-
-

Autonomous Loops

-
- - -
-
- - -
-

Start New Loop

- -
- -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
{{ createError }}
- - -
- - -
-
-
- -
- - No active loops - Click "Start Loop" to create an autonomous recurring task -
-
- -
-
-
{{ loops.length }}
-
Total Loops
-
-
-
{{ runningCount }}
-
Running
-
-
-
{{ totalIterations }}
-
Total Iterations
-
-
- - -
-
-
-
-
- - {{ loop.status || 'running' }} - {{ loop.mode }} - {{ loop.id }} -
-
- -
{{ loop.goal }}
- -
-
- Interval: - {{ formatDuration(loop.interval_seconds) }} -
-
- Iterations: - {{ loop.iteration_count }} / {{ loop.max_iterations }} -
-
-
-
-
- Last trigger: - {{ loop.last_trigger_age_seconds == null ? 'pending' : formatDuration(loop.last_trigger_age_seconds) + ' ago' }} -
-
- Created: - {{ formatAge(loop.created_at) }} -
-
- -
- Stop when: {{ loop.stop_condition }} -
- -
- Started by {{ loop.requester_name }} -
- -
- Latest context preview - {{ loop.iteration_history[loop.iteration_history.length - 1] }} -
-
-
- - -
-
-
-
- - - -
`,setup(){const e=f([]),t=f(!0),s=f(null),a=f(!1),n=f({goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""}),i=f(!1),l=f(null),o=f(null),r=f(null),c=f(null),d=f(null),u=f(!1),p=f(null),m=f("");let h=!1;const k=$g({state:{get detail(){return c.value},set detail(M){c.value=M},get detailId(){return d.value},set detailId(M){d.value=M},get detailLoading(){return u.value},set detailLoading(M){u.value=M},get detailError(){return p.value},set detailError(M){p.value=M}},detailLabel:"Loop detail",requestDetail:(M,{signal:$})=>z.get(`/api/loops/${encodeURIComponent(M)}?limit=100`,{signal:$})});async function A(M){m.value="",await k.open(M.id)}function g(){k.close(),m.value=""}async function b(M,$){try{await navigator.clipboard.writeText($||""),m.value=M,setTimeout(()=>{m.value===M&&(m.value="")},1500)}catch{Se.error("Copy failed")}}const x=B(()=>e.value.reduce((M,$)=>M+($.iteration_count||0),0)),w=B(()=>e.value.filter(M=>M.status==="running").length);function _(M){return M==="running"?"loop-status-running":M==="error"?"loop-status-error":"loop-status-stopped"}function T(M){return M==="running"?"badge-success":M==="error"?"badge-danger":M==="completed"?"badge-info":"badge-warning"}function L(M){return M==="act"?"badge-warning":M==="silent"?"badge-info":"badge-success"}async function E(M=!1){M=M===!0,M||(t.value=!0);try{const $=await z.get("/api/loops");e.value=Array.isArray($)?$:[],s.value=null}catch($){M||(s.value=$.message)}M||(t.value=!1)}async function F(){l.value=null;const M=n.value;if(!M.goal.trim()){l.value="Goal is required";return}if(!M.channel_id.trim()){l.value="Channel ID is required";return}const $={goal:M.goal.trim(),channel_id:M.channel_id.trim(),interval_seconds:M.interval_seconds||60,mode:M.mode,max_iterations:M.max_iterations||50};M.stop_condition.trim()&&($.stop_condition=M.stop_condition.trim()),i.value=!0;try{const re=await z.post("/api/loops",$);Se.success(`Loop started: ${re.loop_id}`),n.value={goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""},a.value=!1,await E()}catch(re){l.value=re.message}i.value=!1}async function O(M){if(await is({title:"Stop loop",message:`Stop loop ${M}? The current iteration will finish before stopping.`,confirmLabel:"Stop Loop",danger:!0})){o.value=M;try{await z.del(`/api/loops/${encodeURIComponent(M)}`),Se.success("Loop stopped"),await E()}catch(re){Se.error(re.message||"Failed to stop loop")}o.value=null}}async function I(M){r.value=M;try{await z.post(`/api/loops/${encodeURIComponent(M)}/restart`),Se.success("Loop restarted"),await E()}catch($){Se.error($.message||"Failed to restart loop")}r.value=null}function H(M){h&&M.payload&&(M.payload.loop_id||M.payload.type==="loop")&&(E(!0),d.value&&k.refresh())}let te=null;function ee(){te!==null&&clearInterval(te),te=null}function N(){ee(),h&&(te=setInterval(()=>{E(!0),d.value&&k.refresh()},5e3))}return st(()=>{h=!0,E(),mt.subscribe("events",H),N()}),cs(()=>{h=!0,E(!0),N()}),es(()=>{h=!1,ee()}),wt(()=>{h=!1,mt.unsubscribe("events",H),ee(),k.close()}),{loops:e,loading:t,error:s,showCreate:a,form:n,creating:i,createError:l,stoppingId:o,restartingId:r,detail:c,detailId:d,detailLoading:u,detailError:p,copied:m,totalIterations:x,runningCount:w,statusDotClass:_,statusBadge:T,modeBadge:L,formatAge:Ng,formatDuration:Xi,formatTs:di,formatTokens:Dg,openDetail:A,closeDetail:g,copyText:b,fetchLoops:E,doCreate:F,doStop:O,doRestart:I}}},R1={template:` -
-
-
-

Processes

-

Inspect managed command lifecycles, output, and exit state.

-
-
- - -
-
- -
-
-
- -
- - No background processes - Processes appear when Odin runs long-running commands -
-
- -
-
-
{{ processes.length }}
-
Total
-
-
-
{{ runningCount }}
-
Running
-
-
-
{{ completedCount }}
-
Completed
-
-
- - -
-
-
-
- - PID {{ p.pid }} - {{ p.status }} - (exit {{ p.exit_code }}) -
-
- {{ formatDuration(p.uptime_seconds) }} - -
-
- -
- {{ p.command }} -
- -
- Host: {{ p.host || 'local' }} -
- - -
-
Recent output:
-
{{ p.output_preview.join('\\n') }}
-
-
-
-
- -
`,setup(){const e=f([]),t=f(!0),s=f(null),a=f(!0);let n=null;const i=f(null),l=B(()=>e.value.filter(g=>g.status==="running").length),o=B(()=>e.value.filter(g=>g.status!=="running").length);function r(g){return g==="running"?"loop-status-running":g==="failed"||g==="error"?"loop-status-error":"loop-status-stopped"}function c(g){return g==="running"?"badge-success":g==="completed"||g==="exited"?"badge-info":g==="killed"||g==="error"||g==="failed"?"badge-danger":"badge-warning"}async function d(g=!1){g=g===!0,g||(t.value=!0);try{e.value=await z.get("/api/processes"),s.value=null}catch(b){g||(s.value=b.message)}g||(t.value=!1)}function u(){p(),a.value&&(n=setInterval(()=>{t.value||d(!0)},5e3))}function p(){n&&(clearInterval(n),n=null)}Gt(a,g=>{g?u():p()});async function m(g){if(await is({title:"Kill process",message:`Kill process ${g}?`,confirmLabel:"Kill",danger:!0})){i.value=g;try{await z.del(`/api/processes/${g}`),Se.success(`Process ${g} killed`),await d()}catch(x){Se.error(x.message||"Failed to kill process")}i.value=null}}function h(g){g.payload&&(g.payload.pid||g.payload.type==="process")&&d(!0)}let y=!1;function k(){y||(y=!0,d(),mt.subscribe("events",h),u())}function A(){y&&(y=!1,mt.unsubscribe("events",h),p())}return st(k),cs(k),es(A),wt(A),{processes:e,loading:t,error:s,autoRefresh:a,killingPid:i,runningCount:l,completedCount:o,procStatusDot:r,statusBadge:c,formatDuration:Xi,fetchProcesses:d,doKill:m}}},I1=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;function jf(e,t){return t==="cron"&&String(e.cron||"").trim()?e.run_at="":t==="run_at"&&String(e.run_at||"").trim()&&(e.cron=""),e}function O1(e,t=!1){const s=n=>String(n).padStart(2,"0"),a=`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())}T${s(e.getHours())}:${s(e.getMinutes())}`;return t?`${a}:${s(e.getSeconds())}`:a}function L1(e){const t=-e.getTimezoneOffset(),s=t>=0?"+":"-",a=Math.abs(t),n=Math.floor(a/60),i=a%60;return`UTC${s}${n}${i?`:${String(i).padStart(2,"0")}`:""}`}function qf(e){const t=String(e||"").trim();if(!t)return{state:"empty"};const s=I1.exec(t);if(!s)return{state:"invalid",typed:t};const[,a,n,i,l,o]=s.slice(0,6).map(Number),r=s[6]===void 0?0:Number(s[6]);if(r>59)return{state:"invalid",typed:t};const c=s[6]!==void 0,d=c?t.slice(0,19):t.slice(0,16),u=Date.UTC(a,n-1,i,l,o,r),p=new Date(u-864e5).getTimezoneOffset(),m=new Date(u+864e5).getTimezoneOffset(),h=[];for(const k of new Set([p,m])){const A=new Date(u+k*6e4);O1(A,c)===d&&(h.some(g=>g.getTime()===A.getTime())||h.push(A))}if(h.sort((k,A)=>k.getTime()-A.getTime()),h.length===0)return{state:"nonexistent",typed:t};if(h.length>1)return{state:"ambiguous",typed:t,options:h.map(k=>({instant:k,offset:L1(k),iso:k.toISOString()}))};const y=h[0];return{state:"ok",typed:t,instant:y,iso:y.toISOString()}}const N1=5e3;function lr(e){const t=(e==null?void 0:e.available)===!0,s=typeof(e==null?void 0:e.reason)=="string"?e.reason:"provider_error";return{available:t,reason:s,epoch:Number.isInteger(e==null?void 0:e.epoch)?e.epoch:null}}function zo(e){if(e.available)return"";switch(e.reason){case"unavailable":return"Scheduling is not configured.";case"connecting":return"Scheduling is connecting.";case"disconnected":return"Scheduling is disconnected.";case"provider_error":return"Scheduling status provider failed.";default:return"Scheduling is unavailable."}}function Vf(e){var t;return(e==null?void 0:e.status)!==503||!((t=e==null?void 0:e.data)!=null&&t.connection)?null:lr(e.data.connection)}function M1(e){return e!=="webhook"}function Gf(e,t){return!M1(t)||(e==null?void 0:e.available)===!0}function D1(e,t=null){if((e==null?void 0:e.state)==="ok")return e.instant.toISOString();if((e==null?void 0:e.state)==="ambiguous"){if(t==null||t==="")throw new Error("Choose which occurrence of this repeated local time to use.");const s=e.options[Number(t)];if(!(s!=null&&s.instant))throw new Error("Choose a valid occurrence of this local time.");return s.instant.toISOString()}throw(e==null?void 0:e.state)==="nonexistent"?new Error("That local time does not exist because clocks skip it. Choose another time."):new Error("Enter a valid new one-time run time.")}const P1={template:` -
-
-
-

Schedules

-

Create, inspect, and run recurring or one-time automation.

-
-
- - -
-
- -
- {{ schedulingAvailabilityMessage }} Discord-delivered actions cannot be created, run, or resumed. Outbound HTTP webhooks remain available. -
- - -
-

Create Schedule

- -
- -
- -
-
- -
-
- -
-
- -

Choose one timing mode. Entering a Cron expression clears One-Time, and entering a One-Time value clears Cron.

-
-
- Cron Expression -
- - -
- -
-
- Valid. Next runs: -
- {{ formatTs(run) }} ({{ formatFuture(run) }}) -
-
-
{{ cronResult.error }}
-
- -
- -
-
-
- - -
- - -
- -

- Fires at {{ runAtUtcPreview }} -

-
-
- -
- -
- -
-
- -
-
- -
-
- -

- Requires the command to emit the generic paginated JSON contract. -

-
-
- -
-
-
- -
-
- -
-
-
-
- -
-
- -
-
-
- -
-
- -
{{ createError }}
- - -
- - -
-
-
- -
- - No scheduled tasks - Click "New Schedule" to set up automated checks or reminders -
-
- -
-
-
{{ schedules.length }}
-
Total
-
-
-
{{ cronCount }}
-
Recurring
-
-
-
{{ oneTimeCount }}
-
One-Time
-
-
-
{{ webhookCount }}
-
Webhook
-
-
-
{{ pausedCount }}
-
Paused
-
-
-
{{ failingCount }}
-
Failing
-
-
- -
- - - - - - - - - - - - - - - -
DescriptionTypeScheduleLast RunStatusActions
-
-
- -
`,setup(){const e=f([]),t=f(!0),s=f(null),a=f(lr(null)),n=B(()=>a.value.available),i=B(()=>zo(a.value));let l=null;const o=f(!1),r=f({description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""}),c=f(!1),d=f(null),u=B(()=>Gf(a.value,r.value.action));function p(U){return Gf(a.value,U)}const m=f(null),h=B(()=>qf(r.value.run_at));Gt(()=>r.value.run_at,()=>{m.value=null});const y=B(()=>{var de;const U=h.value;return U.state==="ok"?U.instant:U.state==="ambiguous"&&m.value!==null&&((de=U.options[m.value])==null?void 0:de.instant)||null}),k=B(()=>{const U=y.value;return U?`${U.toLocaleString()} local — ${U.toISOString()} UTC`:""}),A=f(null),g=f(!1),b=[{label:"Every hour",expr:"0 * * * *"},{label:"Every 6h",expr:"0 */6 * * *"},{label:"Daily 9am",expr:"0 9 * * *"},{label:"Weekly Mon",expr:"0 9 * * 1"},{label:"Every 30m",expr:"*/30 * * * *"}],x=f(null),w=f(null),_=f(null),T=f(null),L=f(null),E=f(null),F=f({}),O=f({}),I=f(null),H=f([]),te=f(!1),ee=f("");let N=0;const M=B(()=>e.value.filter(U=>U.cron&&!U.one_time).length),$=B(()=>e.value.filter(U=>U.one_time).length),re=B(()=>e.value.filter(U=>U.trigger).length),ce=B(()=>e.value.filter(U=>U.paused).length),Z=B(()=>e.value.filter(U=>U.consecutive_failures>0).length);function Q(U){if(!U)return"-";const de=Date.now(),Ae=(new Date(U).getTime()-de)/1e3;if(Ae<0)return"overdue";if(Ae<60)return"in < 1 min";if(Ae<3600)return`in ${Math.floor(Ae/60)} min`;if(Ae<86400){const We=Math.floor(Ae/3600),je=Math.floor(Ae%3600/60);return je>0?`in ${We}h ${je}m`:`in ${We}h`}const $e=Math.floor(Ae/86400);return`in ${$e} day${$e!==1?"s":""}`}function ae(U){return U==null?"-":U<1e3?`${U}ms`:U<6e4?`${(U/1e3).toFixed(1)}s`:Xi(U/1e3)}function V(U=r.value.cron){r.value.cron=U,jf(r.value,"cron"),A.value=null}function be(U=r.value.run_at){r.value.run_at=U,jf(r.value,"run_at"),A.value=null}function Oe(U){return qf(F.value[U.id]||"")}function le(U,de){F.value[U]=de,delete O.value[U]}function oe(U,de){O.value[U]=de===""?null:Number(de)}async function ve(U){let de;try{de=D1(Oe(U),O.value[U.id])}catch(ge){Se.error(ge.message);return}E.value=U.id;try{const ge=await z.put(`/api/schedules/${encodeURIComponent(U.id)}`,{run_at:de});ge!=null&&ge.inert_reason||ge!=null&&ge.paused?Se.error(`Schedule remains paused and inert: ${ge.inert_reason||"the server kept it paused"}`):(Se.success("Schedule re-armed for the new run time"),delete F.value[U.id],delete O.value[U.id]),await Y()}catch(ge){S(ge),Se.error(ge.message||"Failed to re-arm schedule")}finally{E.value=null}}async function j(){const U=r.value.cron.trim();if(U){g.value=!0;try{A.value=await z.post("/api/schedules/validate-cron",{expression:U})}catch(de){A.value={valid:!1,error:de.message}}g.value=!1}}async function Y(){t.value=!0,s.value=null;try{e.value=await z.get("/api/schedules")}catch(U){s.value=U.message}t.value=!1}async function xe(){try{a.value=lr(await z.get("/api/schedules/status"))}catch(U){a.value=Vf(U)||lr(null)}}function S(U){const de=Vf(U);de&&(a.value=de)}async function P(U){if(I.value===U){I.value=null,H.value=[];return}I.value=U,te.value=!0,H.value=[];const de=++N;try{const ge=await z.get(`/api/schedules/${encodeURIComponent(U)}/history?limit=10`);if(de!==N||I.value!==U)return;H.value=ge,ee.value=""}catch(ge){if(de!==N||I.value!==U)return;H.value=[],ee.value=ge.message||"Failed to load execution history"}de===N&&(te.value=!1)}async function G(){if(d.value=null,!p(r.value.action)){d.value=zo(a.value);return}const U=r.value;if(!U.description.trim()){d.value="Description is required";return}if(U.action!=="webhook"&&!U.channel_id.trim()){d.value="Channel ID is required";return}if(!U.cron.trim()&&!U.run_at.trim()){d.value="Cron expression or run_at time is required";return}if(U.cron.trim()&&U.run_at.trim()){d.value="Choose either Cron or One-Time, not both";return}const de={description:U.description.trim(),action:U.action,channel_id:U.channel_id.trim()};if(U.cron.trim()&&(de.cron=U.cron.trim()),U.run_at.trim()){const ge=h.value;if(ge.state==="nonexistent"){d.value="That local time does not exist (daylight saving gap)";return}if(ge.state==="invalid"){d.value="One-time run time is not a valid date";return}const Ae=y.value;if(ge.state==="ambiguous"&&m.value===null){d.value="That local time happens twice — choose which occurrence to use";return}if(!Ae){d.value="One-time run time could not be resolved";return}de.run_at=Ae.toISOString()}if(U.action==="reminder"&&U.message.trim()&&(de.message=U.message.trim()),U.action==="check"&&(U.tool_name.trim()&&(de.tool_name=U.tool_name.trim()),U.report_format&&(de.report_format=U.report_format),U.tool_input_str.trim()))try{de.tool_input=JSON.parse(U.tool_input_str.trim())}catch{d.value="Tool input must be valid JSON";return}if(U.action==="webhook"){if(!U.webhook_url.trim()){d.value="Webhook URL is required";return}const ge={url:U.webhook_url.trim(),method:U.webhook_method};if(U.webhook_headers_str.trim())try{const Ae=JSON.parse(U.webhook_headers_str.trim());if(!Ae||Array.isArray(Ae)||typeof Ae!="object")throw new Error("not an object");ge.headers=Ae}catch{d.value="Webhook headers must be a valid JSON object";return}if(U.webhook_body&&(ge.body=U.webhook_body),U.webhook_expected_status_str.trim()){const Ae=U.webhook_expected_status_str.split(",").map($e=>Number($e.trim()));if(Ae.some($e=>!Number.isInteger($e)||$e<100||$e>599)){d.value="Expected status codes must be comma-separated HTTP codes";return}ge.expected_status_codes=Ae}de.webhook_config=ge}c.value=!0;try{await z.post("/api/schedules",de),Se.success("Schedule created"),r.value={description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""},A.value=null,o.value=!1,await Y()}catch(ge){S(ge),d.value=ge.message}c.value=!1}async function he(U){if(!p(U.action)){Se.error(zo(a.value));return}const de=U.id;x.value=de;try{const ge=await z.post(`/api/schedules/${encodeURIComponent(de)}/run`);if(ge.status==="failure")Se.error(`Execution failed: ${ge.error||"unknown error"}`);else{const Ae=ge.warning?`Executed (${ge.warning})`:"Executed successfully";Se.success(Ae)}await Y()}catch(ge){S(ge),Se.error(ge.message||"Failed to trigger")}x.value=null}async function fe(U){if(U.paused&&!p(U.action)){Se.error(zo(a.value));return}_.value=U.id;const de=!U.paused;try{const ge=await z.put(`/api/schedules/${encodeURIComponent(U.id)}`,{paused:de});!de&&(ge!=null&&ge.inert_reason)?Se.error(`Schedule remains paused and inert: ${ge.inert_reason}`):Se.success(de?"Schedule paused":"Schedule resumed"),await Y()}catch(ge){S(ge),Se.error(ge.message||"Failed to update schedule")}_.value=null}const me=new Map;function we(U,de){const ge=me.get(U.id);ge&&clearTimeout(ge.timer);const Ae={run:()=>J(U,de),timer:null};Ae.timer=setTimeout(()=>{me.delete(U.id),Ae.run()},500),me.set(U.id,Ae)}async function J(U,de){L.value=U.id;try{await z.put(`/api/schedules/${encodeURIComponent(U.id)}`,{report_format:de}),Se.success(de?"Structured report enabled":"Plain-text report enabled")}catch(ge){Se.error(`Update failed: ${ge.message}`)}finally{await Y(),L.value=null}}function ie(){for(const[U,de]of[...me])clearTimeout(de.timer),me.delete(U),de.run()}async function X(U){T.value=U;try{await z.post(`/api/schedules/${encodeURIComponent(U)}/reset-failures`),Se.success("Failure counters reset"),await Y()}catch(de){Se.error(de.message||"Failed to reset")}T.value=null}async function _e(U){const de=e.value.find(Ae=>Ae.id===U);if(await is({title:"Delete schedule",message:`Delete "${(de==null?void 0:de.description)||U}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){w.value=U;try{await z.del(`/api/schedules/${encodeURIComponent(U)}`),Se.success("Schedule deleted"),await Y()}catch(Ae){Se.error(Ae.message||"Failed to delete schedule")}w.value=null}}return st(()=>{Y(),xe(),l=setInterval(xe,N1)}),wt(()=>{ie(),l&&clearInterval(l)}),{schedules:e,loading:t,error:s,schedulingAvailable:n,schedulingAvailabilityMessage:i,selectedActionAvailable:u,actionAvailable:p,showCreate:o,form:r,creating:c,createError:d,runAtUtcPreview:k,runAtAnalysis:h,runAtOccurrence:m,cronResult:A,validatingCron:g,cronPresets:b,runningId:x,deletingId:w,togglingId:_,resettingId:T,reportUpdatingId:L,flushReportFormatTimers:ie,rearmingId:E,rearmRunAt:F,rearmOccurrences:O,rearmAnalysis:Oe,expandedId:I,history:H,historyLoading:te,historyError:ee,cronCount:M,oneTimeCount:$,webhookCount:re,pausedCount:ce,failingCount:Z,formatTs:di,formatAge:Ng,formatFuture:Q,formatMs:ae,formatDuration:Xi,onCronInput:V,onRunAtInput:be,validateCron:j,toggleExpand:P,fetchSchedules:Y,fetchSchedulingAvailability:xe,doCreate:G,doRunNow:he,doTogglePause:fe,doUpdateReportFormat:we,doResetFailures:X,doDelete:_e,setRearmRunAt:le,setRearmOccurrence:oe,doRearmSchedule:ve}}},Fg=[{id:"live",label:"Live",component:_1},{id:"agents",label:"Agents",component:E1},{id:"loops",label:"Loops",component:A1},{id:"processes",label:"Processes",component:R1},{id:"schedules",label:"Schedules",component:P1}],$1={components:{TabbedPage:uc},setup(){return{tabs:Fg}},template:''},$d=/^\d{15,25}$/,Ho=new Map,jo=new Map;let qo=null,Vo=null;async function F1(){return qo||(Vo||(Vo=z.get("/api/discord/members").then(e=>(qo=Array.isArray(e)?e:[],qo)).catch(()=>[]).finally(()=>{Vo=null})),Vo)}async function B1(e){if(!$d.test(String(e)))return null;const t=(await F1()).find(s=>String(s.id)===String(e));return t?(Ho.set(String(e),t),t):Ho.has(String(e))?Ho.get(String(e)):(jo.has(String(e))||jo.set(String(e),z.get(`/api/discord/users/${encodeURIComponent(e)}`).then(s=>(s==null?void 0:s.user)||null).catch(()=>null).then(s=>(s&&Ho.set(String(e),s),jo.delete(String(e)),s))),jo.get(String(e)))}const Dn={props:{userId:{type:[String,Number],required:!0},members:{type:Array,default:()=>[]},showId:{type:Boolean,default:!0},fallbackLabel:{type:String,default:""}},template:` - - - {{ displayFallback }} - {{ userId }} - - `,setup(e){const t=f(null),s=B(()=>(e.members||[]).find(l=>String(l.id)===String(e.userId))||null),a=B(()=>s.value||t.value),n=B(()=>$d.test(String(e.userId))?e.userId:e.fallbackLabel||e.userId);async function i(){t.value=null,$d.test(String(e.userId))&&!s.value&&(t.value=await B1(e.userId))}return Gt(()=>[e.userId,e.members],i,{deep:!0}),st(i),{identity:a,displayFallback:n}}},U1={components:{DiscordIdentity:Dn},template:` -
-
-

Audit Log

-
- - -
-
- - -
-

Verification failed: {{ verifyError }}

-
-
-

Tamper-evidence is not enabled — no signing key is configured, so the chain cannot be verified.

-
-
-

- Chain valid — {{ verifyResult.verified }} signed entr{{ verifyResult.verified === 1 ? 'y' : 'ies' }} verified across {{ (verifyResult.segments || []).length }} retained file{{ (verifyResult.segments || []).length === 1 ? '' : 's' }}. -

-

- Chain INVALID — problem in {{ verifyResult.first_bad_file }}{{ verifyResult.first_bad ? ' at line ' + verifyResult.first_bad : '' }}. -

-

- {{ verifyResult.unsigned_prefix.toLocaleString() }} older entries predate signing and are permanently unsigned — expected, not tampering. -

-
    -
  • - {{ seg.file }}{{ seg.position === 0 ? ' (current)' : '' }}: {{ segmentText(seg) }} -
  • -
-

- Each file is verified on its own from its first entry. Files already removed by rotation, or deleted by hand from the oldest end, cannot be detected. -

-
- - -
-
-
- -
-
- -
-
- -
-
- -
-
-
- - -
-
- -
-
-
- - -
-
-
- -
- - No audit entries found - Try adjusting your filters or wait for tool executions to appear -
-
-
Showing {{ entries.length }} entries
-
- - - - - - - - - - - - - - -
TimestampToolUserHostDurationStatus
-
- -
-
`,setup(){const e=f([]),t=f(!0),s=f(null),a=f(null),n=f({tool:"",user:"",keyword:"",date:"",limit:50});function i(k){if(!k)return"";if(typeof k=="string")return k;try{return JSON.stringify(k,null,2)}catch{return String(k)}}function l(k){a.value=a.value===k?null:k}function o(){n.value={tool:"",user:"",keyword:"",date:"",limit:50},y()}let r=0;const c=f(!1),d=f(null),u=f(null);async function p(){c.value=!0,u.value=null;try{d.value=await z.get("/api/audit/verify")}catch(k){k.status===409&&k.data&&typeof k.data=="object"?d.value=k.data.availability==="not_enabled"?{...k.data,not_enabled:!0}:k.data:(d.value=null,u.value=k.message||"verification request failed")}c.value=!1}function m(k){return k.status==="verified"||k.status==="unsigned"}function h(k){const A=b=>Number(b||0).toLocaleString();if(k.status==="verified"){if(!k.total)return"no entries yet";const b=k.unsigned_prefix>0?`; ${A(k.unsigned_prefix)} older entries predate signing`:"";return`verified — ${A(k.verified)} signed entries${b}`}if(k.status==="unsigned")return"no signatures — written before signing was enabled, so it cannot be verified";if(k.status==="unreadable")return`could not be read (${k.error||"error"})`;if(k.status==="missing")return"missing — newer and older files exist, but this one does not";const g=`${A(k.verified)} entries verified before it; later lines not checked`;return k.reason==="hmac_mismatch"?`break at line ${k.first_bad} — entry altered, reordered, or signed with a different key; ${g}`:k.reason==="signing_gap"?"no signatures although older files are signed — signing was switched off for this period, or signatures were removed":k.reason==="unsigned_after_signed"?`break at line ${k.first_bad} — unsigned entry after signed ones (signing was switched off, or a line was inserted); ${g}`:`break at line ${k.first_bad} — not a valid audit entry; ${g}`}async function y(){const k=++r;t.value=!0,s.value=null,a.value=null;try{const A=new URLSearchParams;n.value.tool&&A.set("tool",n.value.tool),n.value.user&&A.set("user",n.value.user),n.value.keyword&&A.set("q",n.value.keyword),n.value.date&&A.set("date",n.value.date),A.set("limit",String(n.value.limit));const g=A.toString(),b=await z.get(`/api/audit${g?"?"+g:""}`);if(k!==r)return;e.value=Array.isArray(b)?b:[]}catch(A){if(k!==r)return;s.value=A.message}k===r&&(t.value=!1)}return st(()=>{y()}),{entries:e,loading:t,error:s,expandedIdx:a,filters:n,formatTs:di,formatDetail:i,truncateBlock:Mg,toggleExpand:l,clearFilters:o,fetchAudit:y,verifying:c,verifyResult:d,verifyError:u,verifyIntegrity:p,segmentOk:m,segmentText:h}}},Wf=[{id:"all",name:"All Sessions",icon:"list",filters:{}},{id:"active",name:"Recently Active",icon:"activity",filters:{minAge:0,maxAge:3600}},{id:"discord",name:"Discord Only",icon:"message",filters:{source:"discord"}},{id:"web",name:"Web Only",icon:"globe",filters:{source:"web"}},{id:"long",name:"Long Conversations",icon:"book",filters:{minMessages:10}},{id:"compacted",name:"Compacted",icon:"archive",filters:{hasCompaction:!0}}],z1=[{value:"last_active",label:"Last Active"},{value:"created_at",label:"Created"},{value:"message_count",label:"Message Count"}],H1={components:{DiscordIdentity:Dn},template:` -
- -
-
-

Sessions

-

- {{ sessions.length }} session{{ sessions.length !== 1 ? 's' : '' }} - - · {{ filteredSessions.length }} shown - -

-
-
- - -
-
- - -
-
- -
-
- - - - - -
- -
- - -
- -
-
- - -
-
-
- - -
-
- Search History - Full-text search across all sessions and archives -
-
-
- -
- - - - -
- -
-
Searching... -
- -
-
Previous results — not current for these filters.
-
No results found
-
-
{{ ftsResults.length }} result{{ ftsResults.length !== 1 ? 's' : '' }}
-
-
-
- {{ r.type }} - {{ r.channel_id }} - - {{ r.author }} - - {{ formatTimestamp(r.timestamp) }} - - - score: {{ Math.abs(r.rank).toFixed(1) }} - -
-
-
-
-
-
-
- - -
-
-
- -
- - No active sessions - Sessions appear when users interact with Odin via Discord or the chat interface -
-
- - No sessions match the current filter - -
-
- -
- -
- -
-
- -
- -
- -
-
-
- {{ s.channel_id }} - {{ s.message_count }} msg - compacted -
-
- Active {{ formatAge(s.last_active) }} · Created {{ formatAge(s.created_at) }} - · -
-
-
- - - - -
-
- - -
-
- - {{ p.role === 'user' ? 'USER' : 'ODIN' }}: - - {{ p.content || '(empty)' }} -
-
- - -
-
-
Loading... -
-
- -
-
Compacted Summary
-
{{ detail.summary }}
-
- - -
- - - - {{ detail.messages.length }} message{{ detail.messages.length !== 1 ? 's' : '' }} - - · {{ threads.length }} thread{{ threads.length !== 1 ? 's' : '' }} - - -
- - -
-
-
- #{{ ti + 1 }} - {{ threadSummary(thread) }} - {{ thread.length }} msg - {{ formatTimestamp(thread[0].timestamp) }} - -
-
-
-
- {{ m.role }} - - - {{ formatTimestamp(m.timestamp) }} - -
-
{{ truncateContent(m.content) }}
-
-
-
- -
No messages in this session
-
- - -
-
-
- - {{ m.role }} - - - {{ formatTimestamp(m.timestamp) }} - -
-
{{ truncateContent(m.content) }}
-
- -
No messages in this session
-
-
-
-
-
-
- - - - - - -
`,setup(){const e=f([]),t=f(!0),s=f(null),a=f(null),n=f(null),i=f(!1);let l=0;const o=f(null),r=f(!1),c=f(new Set),d=f(!1),u=f("all"),p=f(""),m=f("last_active"),h=f(!1),y=Wf,k=z1,A=f([]),g=f(!1),b=f(""),x=f("flat"),w=f(new Set),_=f(""),T=f(""),L=f(""),E=f(null),F=f(!1),O=f(""),I=f(!1);let H=0;Gt([_,T,L],()=>{H++,F.value=!1,O.value="",I.value=E.value!==null},{flush:"sync"});function te(){try{const W=localStorage.getItem("odin-session-presets");W&&(A.value=JSON.parse(W))}catch{}}function ee(){try{localStorage.setItem("odin-session-presets",JSON.stringify(A.value))}catch{}}const N=B(()=>p.value.trim()!==""||u.value!=="all"),M=B(()=>{let W=[...e.value];const De=Wf.find(et=>et.id===u.value),ze=De?De.filters:{};if(ze.source&&(W=W.filter(et=>et.source===ze.source)),ze.minMessages&&(W=W.filter(et=>et.message_count>=ze.minMessages)),ze.hasCompaction&&(W=W.filter(et=>et.has_summary)),ze.maxAge!=null){const et=Date.now()/1e3;W=W.filter(Rt=>Rt.last_active&&et-Rt.last_active<=ze.maxAge)}if(p.value.trim()){const et=p.value.toLowerCase().trim();W=W.filter(Rt=>(Rt.channel_id||"").toLowerCase().includes(et)||(Rt.last_user_id||"").toLowerCase().includes(et)||(Rt.source||"").toLowerCase().includes(et))}const Ye=m.value,Dt=h.value?1:-1;return W.sort((et,Rt)=>{const Pt=et[Ye]||0,Ts=Rt[Ye]||0;return(Pt-Ts)*Dt}),W}),$=B(()=>{if(!n.value||!n.value.messages)return[];const W=n.value.messages;if(W.length===0)return[];const De=[];let ze=[];for(const Ye of W)Ye.role==="user"&&ze.length>0&&(De.push(ze),ze=[]),ze.push(Ye);return ze.length>0&&De.push(ze),De}),re=B(()=>M.value.length>0&&c.value.size===M.value.length);function ce(W){const De=W.find(ze=>ze.role==="user");if(De&&De.content){const ze=De.content.slice(0,120);return ze.lengthDe.id!==W),ee(),u.value===W&&(u.value="all")}function Oe(){u.value="all",p.value="",m.value="last_active",h.value=!1}function le(W){if(!W)return"—";const De=Date.now()/1e3-W;if(De<60)return"just now";if(De<3600){const Ye=Math.floor(De/60);return`${Ye} minute${Ye!==1?"s":""} ago`}if(De<86400){const Ye=Math.floor(De/3600);return`${Ye} hour${Ye!==1?"s":""} ago`}const ze=Math.floor(De/86400);return`${ze} day${ze!==1?"s":""} ago`}function oe(W){if(!W)return"";try{return new Date(W*1e3).toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return""}}function ve(W){if(!W)return"";try{return new Date(W*1e3).toLocaleString()}catch{return""}}function j(W){return W==="user"?"bg-gray-900/50 border border-gray-800":W==="assistant"?"bg-indigo-950/30 border border-indigo-900/30":"bg-gray-900/30 border border-gray-800/50"}function Y(W){return W==="user"?"sess-msg-user":W==="assistant"?"sess-msg-assistant":"sess-msg-system"}function xe(W){return W==="user"?"badge-info":W==="assistant"?"badge-success":"badge-warning"}function S(W){return W==="user"?"sess-dot-user":W==="assistant"?"sess-dot-assistant":"sess-dot-system"}function P(W){return W==="user"?"text-cyan-400":W==="assistant"?"text-indigo-400":"text-gray-500"}function G(W){return W?W.length>2e3?W.slice(0,2e3)+` -... (truncated)`:W:""}async function he(){const W=_.value.trim();if(!W)return;const De=++H;F.value=!0,O.value="",I.value=E.value!==null;try{let ze=`/api/sessions/search?q=${encodeURIComponent(W)}&limit=50`;T.value.trim()&&(ze+=`&channel_id=${encodeURIComponent(T.value.trim())}`),L.value.trim()&&(ze+=`&user_id=${encodeURIComponent(L.value.trim())}`);const Ye=await z.get(ze);if(De!==H)return;E.value=Ye.results||[],I.value=!1}catch(ze){if(De!==H)return;O.value=ze.message||"Search failed. Please retry."}finally{De===H&&(F.value=!1)}}function fe(){H++,_.value="",T.value="",L.value="",E.value=null,O.value="",I.value=!1,F.value=!1}function me(W){return W?W.replace(/&/g,"&").replace(//g,">").replace(/>>>/g,'').replace(/<<</g,""):""}function we(W){return W==="user"?"fts-result-user":W==="assistant"?"fts-result-assistant":W==="summary"?"fts-result-summary":W==="fts"?"fts-result-fts":W==="channel"?"fts-result-channel":"fts-result-default"}function J(W){return W==="user"?"badge-info":W==="assistant"?"badge-success":W==="summary"?"badge-warning":W==="fts"?"badge-success":"badge-info"}let ie=0;async function X(){const W=++ie;t.value=!0,s.value=null;try{const De=await z.get("/api/sessions");if(W!==ie)return;e.value=De}catch(De){if(W!==ie)return;s.value=De.message}W===ie&&(t.value=!1)}function _e(){s.value=null,X()}async function U(W){if(a.value===W){a.value=null,n.value=null,w.value=new Set;return}a.value=W,n.value=null,i.value=!0,w.value=new Set;const De=++l;try{const ze=await z.get(`/api/sessions/${encodeURIComponent(W)}`);De===l&&a.value===W&&(n.value=ze)}catch(ze){De===l&&a.value===W&&(n.value={messages:[],summary:"",error:ze.message||"Failed to load session"})}finally{De===l&&(i.value=!1)}}function de(W){const De=new Set(c.value);De.has(W)?De.delete(W):De.add(W),c.value=De}function ge(){re.value?c.value=new Set:c.value=new Set(M.value.map(W=>W.channel_id))}function Ae(W){o.value=W}async function $e(){if(o.value){r.value=!0;try{await z.del(`/api/sessions/${encodeURIComponent(o.value)}`),a.value===o.value&&(a.value=null,n.value=null),c.value.delete(o.value),await X()}catch(W){s.value=W.message||"Failed to clear session"}r.value=!1,o.value=null}}function We(){d.value=!0}async function je(){if(c.value.size!==0){r.value=!0;try{await z.post("/api/sessions/clear-bulk",{channel_ids:[...c.value]}),c.value.has(a.value)&&(a.value=null,n.value=null),c.value=new Set,await X()}catch(W){s.value=W.message||"Failed to clear sessions"}r.value=!1,d.value=!1}}async function Xe(W,De){const ze=`/api/sessions/${encodeURIComponent(W)}/export?format=${De}`;try{const Ye=await z.getBlob(ze),Dt=URL.createObjectURL(Ye),et=document.createElement("a");et.href=Dt,et.download=`session-${W}.${De==="text"?"txt":"json"}`,et.click(),URL.revokeObjectURL(Dt)}catch(Ye){s.value=Ye.message||"Failed to export session"}}let it=null;function Qe(W){W.payload&&W.payload.channel_id&&(clearTimeout(it),it=setTimeout(()=>{if(X(),a.value&&W.payload.channel_id===a.value){const De=a.value,ze=l;z.get(`/api/sessions/${encodeURIComponent(De)}`).then(Ye=>{ze!==l||a.value!==De||(n.value=Ye)}).catch(()=>{})}},2e3))}let se=!1,Re=null;function Le(){se||(se=!0,X(),mt.subscribe("events",Qe),Re=mt.onReconnected(()=>X()))}st(()=>{te(),Le()}),cs(()=>{Le()});function Te(){se&&(se=!1,mt.unsubscribe("events",Qe),Re&&(Re(),Re=null),clearTimeout(it))}return es(Te),wt(Te),{sessions:e,loading:t,error:s,expandedId:a,detail:n,detailLoading:i,clearTarget:o,clearing:r,selected:c,allSelected:re,bulkClearing:d,activePreset:u,searchQuery:p,sortBy:m,sortAsc:h,filterPresets:y,sortOptions:k,filteredSessions:M,hasActiveFilters:N,customPresets:A,showSavePreset:g,newPresetName:b,threadView:x,threads:$,collapsedThreads:w,ftsQuery:_,ftsChannelId:T,ftsUserId:L,ftsResults:E,ftsSearching:F,ftsError:O,ftsStale:I,formatAge:le,formatTimestamp:oe,formatFullTimestamp:ve,messageClass:j,threadMsgClass:Y,roleBadge:xe,roleDotClass:S,roleLabelClass:P,truncateContent:G,threadSummary:ce,fetchSessions:X,retry:_e,toggleSession:U,toggleSelect:de,toggleSelectAll:ge,confirmClear:Ae,clearSession:$e,confirmBulkClear:We,doBulkClear:je,exportSession:Xe,applyPreset:Q,applyCustomPreset:ae,saveCustomPreset:V,removeCustomPreset:be,resetFilters:Oe,toggleThread:Z,runFtsSearch:he,clearFtsSearch:fe,highlightSnippet:me,ftsResultClass:we,ftsTypeBadge:J}}},j1={props:["trace"],template:` - -
-
Context Assembly
-
-
- System tokens - {{ formatTokens(trace.summary?.system_tokens) }} -
-
- History tokens - {{ formatTokens(trace.summary?.history_used_tokens) }} -
-
- Learned injected - {{ trace.summary?.learned_injected ?? '—' }} - ({{ trace.learned?.mode || '?' }}) - -
-
- Continuity - {{ trace.continuity_source || '—' }} -
-
- - - - - - - - - - - - -
SectionTokens
{{ s.section }}{{ formatTokens(s.tokens) }}
history ({{ trace.history.kept_recent }} recent + {{ trace.history.kept_relevant }} relevant of {{ trace.history.candidates }}){{ formatTokens(trace.history.used) }}
-
- {{ w.code }} -
-
- trace truncated ({{ trace.truncation_reason }}) -
-
- `,setup(){return{formatTokens:Dg}}},q1={components:{ContextAssemblyPanel:j1,DiscordIdentity:Dn},template:` -
-
-

Trace Viewer

-
- -
-
- - -
-
-
- -
-
- -
-
- -
-
- Filters -
- - -
-
-
-
-
- -
-
- -
-
- -
-
- -
-
-
- - -
-
- Trace for message {{ singleTrace.message_id }} - -
-
-
-
- User - {{ !singleTrace.user_id ? (singleTrace.user_name || '—') : '' }} -
-
- Channel - {{ singleTrace.channel_id || '—' }} -
-
- Time - {{ formatTs(singleTrace.timestamp) }} -
-
- Status - error - handoff - ok -
-
- - -
-
- Iterations - {{ singleTrace.iteration_count || 0 }} -
-
- Tools Used - {{ (singleTrace.tools_used || []).length }} -
-
- Duration - {{ formatDuration(singleTrace.total_duration_ms) }} -
-
- Tokens - {{ formatTokens(singleTrace.total_input_tokens + singleTrace.total_output_tokens) }} -
-
- - -
-
User Message
-
{{ singleTrace.user_content }}
-
- - -
-
Tool Chain
-
-
- -
-
- #{{ it.iteration + 1 }} -
- - {{ tc.name || 'unknown' }} - - no tool calls -
-
-
- {{ formatDuration(it.duration_ms) }} - {{ it.input_tokens + it.output_tokens }} tok - -
-
- - -
- -
-
-
-
-
{{ Math.round((it.duration_ms / singleTrace.total_duration_ms) * 100) }}% of total
-
- - -
-
LLM Text
-
{{ it.llm_text }}
-
- - -
-
- Call: {{ tc.name }} -
-
{{ formatJSON(tc.input) }}
-
- - -
-
- Result: {{ tr.name || 'result ' + tri }} -
-
{{ truncateBlock(formatJSON(tr.output || tr.result || tr.error || tr), 5000) }}
-
-
-
-
-
- - -
-
Final Response
-
{{ truncateBlock(singleTrace.final_response, 5000) }}
-
- - - - -
- Tools: - {{ t }} -
-
-
- - -
-
-
-
- -
- - No traces found - Traces appear when the bot processes messages with tool calls -
-
-
- Showing {{ entries.length }} traces - ({{ totalSaved }} total saved) -
- - -
- - - - - - - - - - - - - - - -
TimestampUserMessageToolsDurationTokensStatus
-
- -
-
-
`,setup(){const e=f([]),t=f([]),s=f(!0),a=f(null),n=f(null),i=f(null),l=f(""),o=f(""),r=f(0),c=f({}),d=f({channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50});function u(T){if(!T)return"—";try{const L=new Date(T);return isNaN(L.getTime())?T:L.toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return T}}function p(T){return!T&&T!==0?"—":T<1e3?T+"ms":(T/1e3).toFixed(1)+"s"}function m(T){return!T&&T!==0?"—":T>=1e3?(T/1e3).toFixed(1)+"k":String(T)}function h(T){if(!T)return"";if(typeof T=="string")return T;try{return JSON.stringify(T,null,2)}catch{return String(T)}}function y(T){n.value===T?n.value=null:(n.value=T,c.value={})}function k(T,L){const E=T+"-"+L;c.value={...c.value,[E]:!c.value[E]}}function A(T,L){return!!c.value[T+"-"+L]}function g(){d.value={channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50},o.value="",l.value="",i.value=null,w()}async function b(){try{const T=await z.get("/api/trajectories");e.value=T.files||[],r.value=T.count||0}catch{}}let x=0;async function w(){const T=++x;s.value=!0,a.value=null,n.value=null,i.value=null,c.value={};try{if(o.value){const L=await z.get(`/api/trajectories/${encodeURIComponent(o.value)}?limit=${d.value.limit}`);if(T!==x)return;let E=L.entries||[];d.value.tool_name&&(E=E.filter(F=>(F.tools_used||[]).includes(d.value.tool_name))),d.value.errors_only&&(E=E.filter(F=>F.is_error)),d.value.channel_id&&(E=E.filter(F=>F.channel_id===d.value.channel_id)),d.value.user_id&&(E=E.filter(F=>F.user_id===d.value.user_id)),t.value=E}else{const L=new URLSearchParams;d.value.channel_id&&L.set("channel_id",d.value.channel_id),d.value.user_id&&L.set("user_id",d.value.user_id),d.value.tool_name&&L.set("tool_name",d.value.tool_name),d.value.errors_only&&L.set("errors_only","true"),L.set("limit",String(d.value.limit));const E=L.toString(),F=await z.get(`/api/trajectories/search/query?${E}`);if(T!==x)return;t.value=F.results||[]}}catch(L){if(T!==x)return;a.value=L.message}T===x&&(s.value=!1)}async function _(){if(!l.value.trim())return;const T=++x;s.value=!0,a.value=null,c.value={};try{const L=await z.get(`/api/trajectories/message/${encodeURIComponent(l.value.trim())}`);if(T!==x)return;i.value=L.entry||null,i.value||(a.value="No trace found for this message ID")}catch(L){if(T!==x)return;L.status===404?(i.value=null,a.value="No trace found for message ID: "+l.value):a.value=L.message}T===x&&(s.value=!1)}return st(async()=>{await b(),await w()}),{files:e,entries:t,loading:s,error:a,expandedIdx:n,singleTrace:i,messageIdQuery:l,selectedFile:o,totalSaved:r,filters:d,expandedIterations:c,formatTs:u,formatDuration:p,formatTokens:m,formatJSON:h,truncateBlock:Mg,toggleExpand:y,toggleIteration:k,isIterationExpanded:A,clearFilters:g,fetchFiles:b,fetchTraces:w,lookupMessage:_}}};function V1(e){const t=Number(e);return!Number.isFinite(t)||t<=0?"—":t<1e3?`${Math.round(t)} ms`:t<6e4?`${(t/1e3).toFixed(1)} s`:t<36e5?`${(t/6e4).toFixed(1)} min`:`${(t/36e5).toFixed(1)} h`}function G1(e){return e?`${e.approximate?"~":""}${Wu(e.total||0)}`:"0"}const W1={template:` -
-
-
-

Usage & Activity

-

Persistent statistics from settled trajectories and the read-only audit index.

-
-
- -
-
- -
-
-
-
-
- - - -
-
-

Last refresh failed: {{ error }} — showing the last successful response.

-
-
-

Historical indexing is still running. Recent data appears first; all-time totals are incomplete.

-

{{ data.coverage.sources_complete }} / {{ data.coverage.sources_indexed }} sources complete · {{ data.coverage.malformed_rows_skipped }} malformed rows skipped

-
-
- Statistics are stale. Last successful receipt was more than 30 seconds ago. -
- -
-

How much work happened

-
-
{{ fmtNum(work.settled_turns) }}
Settled turns
-
{{ fmtNum(work.accepted_generations) }}
Accepted generations
-
{{ tokenLabel(work.input_tokens) }}
Input processed
-
{{ tokenLabel(work.output_tokens) }}
Output generated
-
{{ fmtDuration(work.recorded_processing_ms) }}
Recorded processing
-
-
- Input provenance: {{ fmtNum(work.input_tokens.provider_reported) }} provider-reported · - {{ fmtNum(work.input_tokens.estimated) }} current estimates · - {{ fmtNum(work.input_tokens.legacy_estimated) }} legacy estimates · - {{ work.input_tokens.provider_reported_percent }}% reported coverage. - Recorded processing is summed operation time, not wall-clock uptime. A dash means timing was not recorded; unavailable samples are excluded. -
-
- -
- No settled usage history in this range. -
- -
-

Activity over time

-
- -
-

Daily settled work units by recorded surface. Hover a bar for its value.

-
- -
-
-

What kind of work

-
- - - - -
SurfaceOutcomeTurnsProcessing
{{ row.surface }}{{ row.outcome }}{{ fmtNum(row.count) }}{{ fmtDuration(row.duration_ms) }}
No activity yet
-
- -
-

What served it

-
- - - - -
Provider / modelEffortGenerationsInputOutputProcessing
{{ row.provider }}
{{ row.model }}
{{ row.effort || 'n/a' }}{{ fmtNum(row.generations) }}{{ fmtNum(row.input_tokens) }}{{ fmtNum(row.output_tokens) }}{{ fmtDuration(row.duration_ms) }}
No generations yet
-
-
- -
-

What tools Odin used

-
- - - - -
ToolExecutionsErrorsError rateAverage time
{{ row.tool_name }}{{ fmtNum(row.executions) }}{{ fmtNum(row.errors) }}{{ row.error_rate_percent }}%{{ fmtDuration(row.avg_duration_ms) }}
No audited tool executions yet
-
- -
-

Automation

-
{{ row.state }}: {{ fmtNum(row.count) }} · recoveries {{ fmtNum(row.recovery_attempts) }}No agent outcomes yet
-
- -
-

Provider-reported actual cost

-

{{ formatActualCost(data.cost?.actual_spend_usd) }}

-

{{ data.cost?.note || 'No provider-reported cost in this range.' }}

-
-
- {{ row.model }} via {{ row.upstream_provider }} · {{ row.cached_percent }}% cached · {{ fmtNum(row.samples) }} measured generations -
-
-
-
-
- `,setup(){const e=f(!0),t=f(null),s=f(!1),a=f({available:!0,coverage:{},work:{},activity:[],serving:[],tools:[],automation:[]}),n=f("7d"),i=f(0),l=f(Date.now());let o=null,r=null,c=!1,d=0;const u=[{key:"24h",label:"24 hours"},{key:"7d",label:"7 days"},{key:"30d",label:"30 days"},{key:"all",label:"All time"}],p=B(()=>a.value.work||{}),m=T=>T==null?"Not reported":`$${Number(T).toFixed(6)}`,h=B(()=>Math.max(1,...(a.value.activity_over_time||[]).map(T=>Number(T.count||0)))),y=B(()=>({minWidth:`max(100%, ${(a.value.activity_over_time||[]).length*5}px)`})),k=T=>({height:`${Math.max(4,Math.round(Number(T||0)/h.value*100))}%`}),A=B(()=>s.value&&l.value-i.value>3e4);async function g(){const T=++d,L=n.value;try{const E=await z.get(`/api/usage?range=${encodeURIComponent(L)}`);if(T!==d||L!==n.value)return;a.value=E,i.value=Date.now(),l.value=i.value,s.value=!0,t.value=null}catch(E){T===d&&(t.value=E.message)}finally{T===d&&(e.value=!1)}}function b(T){n.value=T,e.value=!s.value,g()}function x(){e.value=!0,g()}function w(){c||(c=!0,g(),o=setInterval(g,15e3),r=setInterval(()=>{l.value=Date.now()},1e3))}function _(){c&&(c=!1,d+=1,o&&clearInterval(o),r&&clearInterval(r),o=null,r=null)}return st(w),cs(w),es(_),wt(_),{data:a,work:p,loading:e,error:t,hasData:s,range:n,ranges:u,isStale:A,fmtNum:Wu,fmtDuration:V1,tokenLabel:G1,formatActualCost:m,activityTrackStyle:y,activityBar:k,selectRange:b,retry:x}}},Bg=[{id:"audit",label:"Audit",component:U1},{id:"sessions",label:"Sessions",component:H1},{id:"traces",label:"Traces",component:q1},{id:"usage",label:"Usage & Activity",component:W1}],K1={components:{TabbedPage:uc},setup(){return{tabs:Bg}},template:''},Kc=[{id:"system",label:"System & Commands",icon:"terminal",match:e=>/^(run_command|run_script|read_file|apply_patch|list_directory|search_files|manage_process|file_|post_file)/.test(e)},{id:"devops",label:"DevOps & Infrastructure",icon:"server",match:e=>/^(http_probe)/.test(e)},{id:"agents",label:"Agents & Orchestration",icon:"bot",match:e=>/^(spawn_agent|send_to_agent|wait_for_agents|get_agent_results|kill_agent|list_agents)/.test(e)},{id:"workflow",label:"Workflows & Tasks",icon:"workflow",match:e=>/^(delegate_task|cancel_task|list_tasks|schedule_|start_loop|stop_loop|list_loops|delete_schedule|list_schedules|update_schedule|parse_time)/.test(e)},{id:"network",label:"Network & Web",icon:"globe",match:e=>/^(web_|browser_|search_web|fetch_url|http_)/.test(e)},{id:"knowledge",label:"Knowledge & Search",icon:"book",match:e=>/^(search_knowledge|ingest_|knowledge_|search_history|search_audit|bulk_ingest|delete_knowledge|list_knowledge)/.test(e)},{id:"discord",label:"Discord & Admin",icon:"message",match:e=>/^(send_|add_reaction|create_poll|purge_|discord_|embed_|read_channel|set_permission)/.test(e)},{id:"skills",label:"Skills",icon:"puzzle",match:e=>/^(create_skill|edit_skill|delete_skill|enable_skill|disable_skill|install_skill|export_skill|skill_status|invoke_skill|list_skills)/.test(e)},{id:"memory",label:"Memory & State",icon:"brain",match:e=>/^(memory_manage|list_manage)/.test(e)},{id:"ai",label:"AI & Generation",icon:"sparkles",match:e=>/^(generate_|analyze_|vision_)/.test(e)},{id:"integrations",label:"Integrations",icon:"link",match:e=>/^(grafana_|mcp_)/.test(e)},{id:"other",label:"Other Tools",icon:"wrench",match:()=>!0}],J1={template:` -
-
-
-

Tools

-

Disabling a tool removes it from future model requests and causes stored jobs that call it to fail. Already-running calls are not cancelled.

-
-
- - -
-
- - -
-
-
-
-
-
-
-
-
-
-
- - - - -
- -
-
-
{{ tools.length }}
-
Total Tools
-
-
-
{{ coreCount }}
-
Core Tools
-
-
-
{{ skillCount }}
-
Skill Tools
-
-
-
{{ totalUsage.toLocaleString() }}
-
Total Executions
-
-
- - -
- - -
- - -
-
-
- - {{ group.label }} - {{ group.tools.length }} -
-
-
-
- {{ t.name }} - {{ stateBadge(t) }} -
-
{{ truncate(t.description, 80) }}
- - -
-
{{ t.description }}
-
Core capability. Disabling it may cause automation, recovery, or stored workflows that depend on it to fail.
-
-
Parameters
-
- {{ pname }} - {{ prop.type }} - required -
-
-
-
-
-
-
- - -
-
-
- - {{ group.label }} - {{ group.tools.length }} -
-
- - - - - - - - - - - - -
NameDescriptionUsesEnabled for model
-
-
-
- - -
- - No tools match "{{ search }}" - Try a different search term -
-
-
`,setup(){const e=f([]),t=f(!0),s=f(null),a=f(""),n=f({}),i=f({}),l=f("cards"),o=f(null),r=f(null),c=f(!1),d=f(new Set),u={disabled:"Disabled by operator",unavailable:"Unavailable — required backend is not configured",global_disabled:"Global tools disabled"};function p(E){return E.source!=="builtin"?"":u[E.state]||""}function m(E,F){const O=E&&Array.isArray(E.tools)?E.tools:null;if(c.value=!!O,r.value=O?!!E.global_enabled:null,!O){e.value=F.map(te=>({...te,source:"unknown",enabled:void 0,state:null}));return}const I=new Set(O.map(te=>te.name)),H=F.filter(te=>!I.has(te.name)).map(te=>({...te,source:te.name.startsWith("mcp_")?"mcp":"skill",enabled:!0,state:null}));e.value=[...O.map(te=>({...te,source:"builtin"})),...H]}async function h(E,F){if(d.value.has(E.name))return;const O=!!F.target.checked,I=new Set(d.value);I.add(E.name),d.value=I;try{const H=await z.post(`/api/tools/builtins/${encodeURIComponent(E.name)}/enabled`,{enabled:O});m(H,e.value),s.value=null;try{const te=await z.get("/api/tools");m(H,te)}catch(te){console.warn("Built-in toggle committed; visible catalog refresh failed",te)}}catch(H){F.target.checked=!!E.enabled,s.value=H.message||`Failed to toggle ${E.name}`}finally{const H=new Set(d.value);H.delete(E.name),d.value=H}}const y=B(()=>e.value.filter(E=>E.source==="builtin"&&E.is_core).length),k=B(()=>e.value.filter(E=>E.source==="skill").length),A=B(()=>Object.values(n.value).reduce((E,F)=>E+F,0));function g(E){for(const F of Kc)if(F.id!=="other"&&F.match(E))return F.id;return"other"}const b=B(()=>{let E=e.value;if(a.value){const F=a.value.toLowerCase();E=E.filter(O=>O.name.toLowerCase().includes(F)||(O.description||"").toLowerCase().includes(F))}return o.value&&(E=E.filter(F=>g(F.name)===o.value)),E}),x=B(()=>{const E=new Set;for(const F of e.value)E.add(g(F.name));return Kc.filter(F=>E.has(F.id))}),w=B(()=>{const E=b.value,F={};for(const I of E){const H=g(I.name);F[H]||(F[H]=[]),F[H].push(I)}const O=[];for(const I of Kc)F[I.id]&&F[I.id].length>0&&O.push({label:I.label,icon:I.icon,tools:F[I.id].sort((H,te)=>H.name.localeCompare(te.name))});return O});function _(E){i.value={...i.value,[E]:!i.value[E]}}async function T(){t.value=!0,s.value=null;try{const[E,F,O]=await Promise.all([z.get("/api/tools"),z.get("/api/tools/stats").catch(()=>({})),z.get("/api/tools/builtins").catch(()=>null)]);m(O,E),n.value=F||{}}catch(E){s.value=E.message}t.value=!1}function L(){T()}return st(()=>{T()}),{tools:e,loading:t,error:s,search:a,stats:n,expanded:i,viewMode:l,activeCategory:o,globalEnabled:r,inventoryAvailable:c,togglePending:d,coreCount:y,skillCount:k,totalUsage:A,filteredTools:b,groupedTools:w,usedCategories:x,stateBadge:p,applyInventory:m,toggleBuiltinTool:h,truncate:Gu,toggleExpand:_,refresh:L}}};function Z1(e){if(!e)return"";let t=e.replace(/&/g,"&").replace(//g,">");t=t.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,'$1'),t=t.replace(/(#[^\n]*)/g,'$1');const s="\\b(def|class|return|if|elif|else|for|while|import|from|as|try|except|finally|raise|with|async|await|yield|pass|break|continue|and|or|not|in|is|None|True|False|self|lambda)\\b";t=t.replace(new RegExp(s,"g"),'$1');const a="\\b(print|len|range|str|int|float|list|dict|set|tuple|type|isinstance|hasattr|getattr|setattr|super|property|staticmethod|classmethod|enumerate|zip|map|filter|sorted|reversed|any|all|min|max|sum|abs|round|open|format)\\b";return t=t.replace(new RegExp(a,"g"),'$1'),t=t.replace(/(@\w+)/g,'$1'),t=t.replace(/\b(\d+\.?\d*)\b/g,'$1'),t}function Q1(e){if(!e)return"1";const t=e.split(` -`).length;return Array.from({length:t},(s,a)=>a+1).join(` -`)}const Y1={template:` -
-
-

Skills

-
- - -
-
- - -
-
-
{{ skills.length }}
-
Total Skills
-
-
-
{{ enabledCount }}
-
Active Skills
-
-
-
{{ totalExecutions.toLocaleString() }}
-
Total Runs
-
-
-
{{ totalLines.toLocaleString() }}
-
Lines of Code
-
-
- - -
- -
- - -
-
-
-
-
-
-
-
-
- - - - - -
- - No skills loaded - Click "New Skill" to create a custom tool -
- - -
-
-
- -
-
- - {{ s.name }} - {{ s.execution_count.toLocaleString() }} runs -
-
- - - - -
-
- - -
-
{{ s.description || 'No description' }}
-
- Loaded: {{ formatTs(s.loaded_at) }} - {{ countLines(s.code) }} lines -
-
- - -
-
- {{ testResults[s.name].is_error ? 'Test failed' : 'Test passed' }} -
-
{{ truncate(testResults[s.name].result, 500) }}
-
- - -
-
- {{ s.name }}.py - -
-
-
{{ getLineNumbers(s.code) }}
-
-
-
-
-
- - -
- - No skills match "{{ search }}" - Try a different search term -
-
- - -
-
-

- {{ editMode === 'create' ? 'Create Skill' : 'Edit Skill: ' + editName }} -

- -
- -
- -
Lowercase, alphanumeric + underscores, starts with letter
-
- -
- Code -
-
{{ editorLineNums }}
- -
-
- {{ editLineCount }} lines - {{ editCode.length.toLocaleString() }} chars -
-
- - -
- {{ editValidation.valid ? 'Valid Python structure' : editValidation.message }} -
- -
-
Error
-
{{ editError }}
-
-
{{ editSuccess }}
- -
- - -
-
- - - -
`,setup(){const e=f([]),t=f(!0),s=f(null),a=f({}),n=f({}),i=f(null),l=f(""),o=f(null),r=f(!1),c=f("create"),d=f(""),u=f(""),p=f(null),m=f(null),h=f(!1),y=f(null),k=f(null),A=f(!1),g=B(()=>e.value.length),b=B(()=>e.value.reduce((V,be)=>V+(be.execution_count||0),0)),x=B(()=>e.value.reduce((V,be)=>V+F(be.code),0)),w=B(()=>{if(!l.value)return e.value;const V=l.value.toLowerCase();return e.value.filter(be=>be.name.toLowerCase().includes(V)||(be.description||"").toLowerCase().includes(V))}),_=B(()=>u.value?u.value.split(` -`).length:0),T=B(()=>{const V=Math.max(_.value,1);return Array.from({length:V},(be,Oe)=>Oe+1).join(` -`)}),L=B(()=>{const V=u.value.trim();return V?V.includes("SKILL_DEFINITION")?V.includes("async def execute")?{valid:!0,message:""}:{valid:!1,message:"Missing async def execute function"}:{valid:!1,message:"Missing SKILL_DEFINITION dict"}:null});function E(V){return Z1(V)}function F(V){return V?V.split(` -`).length:0}function O(V){return Q1(V)}function I(V){a.value={...a.value,[V]:!a.value[V]}}async function H(V){try{await navigator.clipboard.writeText(V);const be=e.value.find(Oe=>Oe.code===V);be&&(o.value=be.name,setTimeout(()=>{o.value=null},2e3))}catch{}}function te(V){if(V.key==="Tab"){V.preventDefault();const be=V.target,Oe=be.selectionStart,le=be.selectionEnd;u.value=u.value.substring(0,Oe)+" "+u.value.substring(le),Ut(()=>{be.selectionStart=be.selectionEnd=Oe+4})}}function ee(V){const be=V.target.previousElementSibling;be&&(be.scrollTop=V.target.scrollTop)}async function N(){t.value=!0,s.value=null;try{e.value=await z.get("/api/skills")}catch(V){s.value=V.message}t.value=!1}async function M(V){i.value=V,delete n.value[V],n.value={...n.value};try{const be=await z.post(`/api/skills/${encodeURIComponent(V)}/test`);n.value={...n.value,[V]:be}}catch(be){n.value={...n.value,[V]:{result:be.message,is_error:!0}}}i.value=null}function $(){r.value=!0,c.value="create",d.value="",u.value="",p.value=null,m.value=null}function re(V){r.value=!0,c.value="edit",d.value=V.name,u.value=V.code||"",p.value=null,m.value=null}function ce(){r.value=!1,p.value=null,m.value=null}async function Z(){p.value=null,m.value=null;const V=d.value.trim(),be=u.value.trim();if(!V){p.value="Name is required";return}if(!be){p.value="Code is required";return}h.value=!0;try{c.value==="create"?(await z.post("/api/skills",{name:V,code:be}),m.value="Skill created successfully"):(await z.put(`/api/skills/${encodeURIComponent(V)}`,{code:be}),m.value="Skill updated successfully"),await N(),setTimeout(()=>{r.value=!1},800)}catch(Oe){p.value=Oe.message}h.value=!1}function Q(V){k.value=V}async function ae(){if(k.value){A.value=!0;try{await z.del(`/api/skills/${encodeURIComponent(k.value)}`),await N()}catch(V){Se.error(`Failed to delete skill: ${V.message||"unknown error"}`)}A.value=!1,k.value=null}}return st(()=>{N()}),{skills:e,loading:t,error:s,showCode:a,testResults:n,testing:i,search:l,copied:o,editing:r,editMode:c,editName:d,editCode:u,editError:p,editSuccess:m,saving:h,editorRef:y,deleteTarget:k,deleting:A,enabledCount:g,totalExecutions:b,totalLines:x,displayedSkills:w,editLineCount:_,editorLineNums:T,editValidation:L,highlight:E,truncate:Gu,formatTs:di,countLines:F,getLineNumbers:O,toggleCode:I,copyCode:H,handleEditorKey:te,syncScroll:ee,fetchSkills:N,testSkill:M,showCreate:$,editSkill:re,cancelEdit:ce,saveSkill:Z,confirmDelete:Q,doDelete:ae}}};class ia extends Error{constructor(t,s=""){super(t),this.name="MCPFormError",this.field=s}}const X1=/^[A-Za-z_][A-Za-z0-9_]*$/;function Kf(e){return String(e||"").split(/\r?\n/).map(t=>t.trim()).filter(Boolean)}function Jf(e,t,s){const a={},n=[...new Set((t||[]).map(l=>String(l)))],i=new Set(n);for(const l of e||[]){const o=String((l==null?void 0:l.key)||"").trim(),r=String((l==null?void 0:l.value)??"");if(!(!o&&!r)){if(!o)throw new ia(`${s} key is required when a value is entered.`,"authentication");if(/[\r\n\0]/.test(o))throw new ia(`${s} keys cannot contain line breaks or NUL bytes.`,"authentication");if(Object.hasOwn(a,o))throw new ia(`${s} key “${o}” appears more than once.`,"authentication");if(i.has(o))throw new ia(`${s} key “${o}” cannot be replaced and removed in the same save.`,"authentication");a[o]=r}}return{set:a,remove:n}}function eC(e){try{const t=new URL(e);return(t.protocol==="http:"||t.protocol==="https:")&&!!t.hostname}catch{return!1}}function tC(e,{mode:t="add",originalTransport:s=""}={}){const a=t==="add",n=String(e.name||"").trim();if(!n)throw new ia("Server name is required.","name");if(n.length>128||!X1.test(n))throw new ia("Use at most 128 letters, digits, or underscores, with no leading digit.","name");const i=e.transport==="http"?"http":"stdio",l=!a&&!!s&&i!==s,o={enabled:!!e.enabled,transport:i};if(a&&(o.name=n),i==="stdio"){const d=String(e.command||"").trim();if((a||l)&&!d)throw new ia("An executable path is required for a new stdio connection.","command");if(d&&(o.command=d),(a||e.replaceArgs)&&(o.args=Kf(e.argsText)),a||e.replaceCwd){const u=String(e.cwd||"").trim();if(u&&(!u.startsWith("/")||u.includes("\0")))throw new ia("Working directory must be an absolute path.","cwd");o.cwd=u}}else{const d=String(e.url||"").trim();if((a||l)&&!d)throw new ia("An HTTP endpoint is required for this connection.","url");if(d&&!eC(d))throw new ia("Endpoint must be a valid http:// or https:// URL.","url");d&&(o.url=d)}if(a||e.replaceTimeout){const d=Number(e.timeoutSeconds);if(!Number.isInteger(d)||d<1||d>3600)throw new ia("Timeout must be a whole number from 1 to 3600 seconds.","timeout");o.timeout_seconds=d}(a||e.replaceAllowlist)&&(o.tool_allowlist=Kf(e.allowlistText));const r=Jf(e.headerRows,e.headersRemove,"Header"),c=Jf(e.envRows,e.envRemove,"Environment variable");return Object.keys(r.set).length&&(o.headers_set=r.set),r.remove.length&&(o.headers_remove=r.remove),Object.keys(c.set).length&&(o.env_set=c.set),c.remove.length&&(o.env_remove=c.remove),o}function sC(e,t){return t?e.transport!==t.transport||!!e.enabled!=!!t.enabled?!0:Object.keys(e).some(s=>!["enabled","transport"].includes(s)):!1}function aC(e){const t=String(e||"").toLowerCase();return["disabled","connecting","connected","stale","error","blocked"].includes(t)?t:"error"}function nC(e,t){const s=String(t||"").trim().toLowerCase();return s?[e==null?void 0:e.original_name,e==null?void 0:e.published_name,e==null?void 0:e.description,e==null?void 0:e.exclusion_reason].filter(Boolean).some(a=>String(a).toLowerCase().includes(s)):!0}const iC=Object.freeze([{id:"identity",label:"Identity"},{id:"transport",label:"Transport"},{id:"authentication",label:"Authentication"},{id:"limits",label:"Limits"}]);function lC(e,{root:t=document,reducedMotion:s=typeof window<"u"&&(a=>(a=window.matchMedia)==null?void 0:a.call(window,"(prefers-reduced-motion: reduce)").matches)()}={}){var l;const n=t.querySelector(".mcp-editor-groups"),i=n==null?void 0:n.querySelector(`#mcp-form-${e}`);return i?(i.scrollIntoView({behavior:s?"auto":"smooth",block:"start",inline:"nearest"}),(l=i.querySelector("[data-mcp-form-heading]"))==null||l.focus({preventScroll:!0}),!0):!1}const oC=1e4,rC=Object.freeze({disabled:"Disabled",connecting:"Connecting",connected:"Connected",stale:"Stale",error:"Error",blocked:"Blocked"});function Jc(){return{name:"",enabled:!0,transport:"stdio",command:"",argsText:"",cwd:"",url:"",timeoutSeconds:120,allowlistText:"",replaceArgs:!1,replaceCwd:!1,replaceTimeout:!1,replaceAllowlist:!1,headerRows:[],envRows:[],headersRemove:[],envRemove:[]}}function cC(e){if(e==null)return"Never";const t=Math.max(0,Number(e)||0);return t<60?`${Math.round(t)}s ago`:t<3600?`${Math.round(t/60)}m ago`:t<86400?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`}const dC={template:` -
-
-
-
Model Context Protocol
-

MCP Servers

-

Connect tool providers over stdio or Streamable HTTP. Only current, connected, validated tools enter the model catalog.

-
-
- - -
-
- -
-
-
-
- - - - - - -
- `,setup(){const e=f(null),t=f(!1),s=f(!1),a=f(!1),n=f(""),i=f({max_published_tools_per_server:"",max_published_tools_global:""}),l=f(new Set),o=B(()=>Object.keys(i.value).every(se=>{var Re;return Number.isInteger((Re=e.value)==null?void 0:Re[se])})),r=f(""),c=f(new Set),d=f(new Set),u=f({}),p=f({}),m=f({}),h=f(new Set),y=f(!1),k=f("add"),A=f(""),g=f(null),b=f(Jc()),x=f(""),w=f(!1);let _=null,T=0,L=!1,E=!1;const F=iC,O=B(()=>{var se;return((se=e.value)==null?void 0:se.servers)||[]}),I=B(()=>{var se;return!!((se=e.value)!=null&&se.enabled)}),H=B(()=>{var se,Re,Le,Te;return{serverCount:((se=e.value)==null?void 0:se.server_count)||0,enabledCount:((Re=e.value)==null?void 0:Re.enabled_server_count)||0,connectedCount:((Le=e.value)==null?void 0:Le.connected_count)||0,toolCount:((Te=e.value)==null?void 0:Te.published_tool_count)||0}}),te=B(()=>{var se;return((se=g.value)==null?void 0:se.header_keys)||[]}),ee=B(()=>{var se;return((se=g.value)==null?void 0:se.env_keys)||[]}),N=B(()=>{var se;return k.value==="edit"&&((se=g.value)==null?void 0:se.transport)==="http"}),M=B(()=>k.value==="add"||!N.value),$=B(()=>N.value?"Replace endpoint URL":"Endpoint URL"),re=B(()=>N.value?"Leave blank to keep the saved endpoint":"https://mcp.example.com/mcp");function ce(){Z(),_=window.setInterval(()=>Q({quiet:!0}),oC)}function Z(){_&&window.clearInterval(_),_=null}async function Q({quiet:se=!1}={}){if(a.value)return;const Re=++T;se||(t.value=!0);try{const Le=await z.get("/api/mcp/status");if(Re!==T||!L)return;e.value=Le;for(const W of Object.keys(i.value))!l.value.has(W)&&Number.isInteger(Le[W])&&(i.value[W]=String(Le[W]));r.value="";const Te=new Set((Le.servers||[]).map(W=>W.name));d.value=new Set([...d.value].filter(W=>Te.has(W)))}catch(Le){Re===T&&L&&(r.value=Le.message||"Failed to load MCP status")}finally{Re===T&&(t.value=!1)}}function ae(se){return s.value||c.value.has(se)}function V(se,Re){const Le=new Set(c.value);Re?Le.add(se):Le.delete(se),c.value=Le}function be(se){return aC(se.state)}function Oe(se){if(be(se)==="disabled"){if(!se.enabled)return"Disabled — server switch off";if(!I.value)return"Disabled — global MCP is off"}return rC[be(se)]}function le(se){return se.transport==="http"?"Streamable HTTP":"stdio"}function oe(se){return se.negotiated_version?`${se.era?`${String(se.era).charAt(0).toUpperCase()}${String(se.era).slice(1)}`:"Protocol"} · ${se.negotiated_version}`:"Not negotiated"}function ve(se){return se.discovered_count?`${se.published_count||0} published · ${se.excluded_count||0} excluded`:"No tools discovered"}const j=f(new Set);async function Y(se,Re){if(j.value.has(se.name))return;const Le=!!Re.target.checked,Te=new Set(j.value);Te.add(se.name),j.value=Te;try{const W=await z.post(`/api/mcp/servers/${encodeURIComponent(se.name)}/enabled`,{enabled:Le});W&&Array.isArray(W.servers)?e.value=W:await Q({quiet:!0})}catch(W){Re.target.checked=!!se.enabled,Se.error(W.message||`Failed to toggle ${se.name}`)}finally{const W=new Set(j.value);W.delete(se.name),j.value=W}}function xe(se,Re){var Te;i.value[se]=Re;const Le=new Set(l.value);Re===String((Te=e.value)==null?void 0:Te[se])?Le.delete(se):Le.add(se),l.value=Le,n.value=""}async function S(){if(s.value||!o.value||!l.value.size)return;const se={};for(const Re of l.value){const Le=Number(i.value[Re]),Te=Re==="max_published_tools_per_server"?128:256;if(!Number.isInteger(Le)||Le<1||Le>Te){n.value=`Enter a whole number between 1 and ${Te}.`;return}se[Re]=Le}a.value=!0,s.value=!0,n.value="",++T,t.value=!1;try{const Re=await z.post("/api/mcp/limits",se);e.value=Re;for(const Le of Object.keys(i.value))Number.isInteger(Re[Le])&&(i.value[Le]=String(Re[Le]));l.value=new Set,Se.success("MCP limits saved. Applied at the next publication or tools refresh.")}catch(Re){n.value=Re.message||"Failed to save MCP publication limits"}finally{a.value=!1,s.value=!1,await Q({quiet:!0})}}async function P(se){if(se!==I.value&&!(!se&&!await is({title:"Disable MCP tool publication",message:"Disable MCP globally? All MCP tools will be unpublished immediately and active transports will be stopped. Saved server configuration remains.",confirmLabel:"Disable MCP",danger:!0}))){s.value=!0;try{await z.post("/api/mcp/enabled",{enabled:se}),Se.success(se?"MCP enabled":"MCP disabled"),await Q({quiet:!0})}catch(Re){Se.error(Re.message||"Failed to update MCP state"),await Q({quiet:!0})}finally{s.value=!1}}}async function G(se){V(se.name,!0);try{await z.post(`/api/mcp/servers/${encodeURIComponent(se.name)}/reconnect`,{}),Se.success(`Reconnected ${se.name}`)}catch(Re){Se.error(Re.message||`Failed to reconnect ${se.name}`)}finally{V(se.name,!1),await Q({quiet:!0})}}async function he(se){V(se.name,!0);try{await z.post(`/api/mcp/servers/${encodeURIComponent(se.name)}/refresh-tools`,{}),Se.success(`Refreshed tools from ${se.name}`),await we(se.name,!0)}catch(Re){Se.error(Re.message||`Failed to refresh ${se.name}`)}finally{V(se.name,!1),await Q({quiet:!0})}}async function fe(se){if(await is({title:`Remove ${se.name}`,message:`Remove this saved MCP server? Its ${se.published_count||0} published tool${se.published_count===1?"":"s"} will disappear immediately and configured authentication keys will be deleted. This cannot be undone.`,confirmLabel:"Remove server",danger:!0})){V(se.name,!0);try{await z.del(`/api/mcp/servers/${encodeURIComponent(se.name)}`),Se.success(`Removed ${se.name}`),delete p.value[se.name]}catch(Le){Se.error(Le.message||`Failed to remove ${se.name}`)}finally{V(se.name,!1),await Q({quiet:!0})}}}async function me(se){const Re=new Set(d.value);if(Re.has(se.name)){Re.delete(se.name),d.value=Re;return}Re.add(se.name),d.value=Re,Object.hasOwn(p.value,se.name)||await we(se.name)}async function we(se,Re=!1){if(!Re&&Object.hasOwn(p.value,se))return;const Le=new Set(h.value);Le.add(se),h.value=Le,m.value={...m.value,[se]:""};try{const Te=await z.get(`/api/mcp/servers/${encodeURIComponent(se)}/tools`);p.value={...p.value,[se]:Te.tools||[]}}catch(Te){m.value={...m.value,[se]:Te.message||"Failed to load tools"}}finally{const Te=new Set(h.value);Te.delete(se),h.value=Te}}function J(se){return(p.value[se]||[]).filter(Re=>nC(Re,u.value[se]))}function ie(se,Re){u.value={...u.value,[se]:Re}}function X(){k.value="add",A.value="",g.value=null,b.value=Jc(),x.value="",y.value=!0}function _e(se){k.value="edit",A.value=se.name,g.value=se,b.value={...Jc(),name:se.name,enabled:!!se.enabled,transport:se.transport||"stdio"},x.value="",y.value=!0}function U(){w.value||(y.value=!1)}function de(se){y.value&&lC(se)}function ge(se){const Re=se==="headers"?"headerRows":"envRows";b.value[Re].push({key:"",value:""})}function Ae(se,Re){const Le=se==="headers"?"headerRows":"envRows";b.value[Le].splice(Re,1)}function $e(se,Re){const Le=se==="headers"?"headersRemove":"envRemove",Te=b.value[Le];b.value[Le]=Te.includes(Re)?Te.filter(W=>W!==Re):[...Te,Re]}async function We(){var Re,Le;x.value="";let se;try{se=tC(b.value,{mode:k.value,originalTransport:((Re=g.value)==null?void 0:Re.transport)||""})}catch(Te){x.value=Te instanceof ia?Te.message:"Invalid MCP server configuration",await Ut(),(Le=document.querySelector(".mcp-editor"))==null||Le.scrollTo({top:0,behavior:"smooth"});return}if(!(k.value==="edit"&&sC(se,g.value)&&!await is({title:`Change ${A.value} connection`,message:"Saving this configuration replaces the server runtime. Any current connection will be retired and its tools unpublished; enabled servers reconnect after the change.",confirmLabel:"Save and reconnect",danger:!0}))){w.value=!0;try{k.value==="add"?await z.post("/api/mcp/servers",se):await z.put(`/api/mcp/servers/${encodeURIComponent(A.value)}`,se),Se.success(k.value==="add"?`Saved ${se.name}`:`Updated ${A.value}`),y.value=!1,await Q({quiet:!0})}catch(Te){x.value=Te.message||"Failed to save MCP server"}finally{w.value=!1}}}let je=null;function Xe(se){`${(se==null?void 0:se.event)||""} ${(se==null?void 0:se.type)||""} ${(se==null?void 0:se.tool)||""} ${(se==null?void 0:se.message)||""}`.toLowerCase().includes("mcp")&&(je&&window.clearTimeout(je),je=window.setTimeout(()=>Q({quiet:!0}),200))}function it(){L||(L=!0,E||(mt.subscribe("events",Xe),E=!0),Q(),ce())}function Qe(){L=!1,Z(),je&&window.clearTimeout(je),je=null,E&&(mt.unsubscribe("events",Xe),E=!1)}return st(it),cs(it),es(Qe),wt(Qe),{status:e,loading:t,mutating:s,pageError:r,servers:O,masterEnabled:I,aggregate:H,limitsSaving:a,limitsError:n,limitsAvailable:o,limitDraft:i,limitDirty:l,editLimit:xe,saveLimits:S,expandedServers:d,toolQueries:u,toolErrors:m,toolsLoading:h,editorOpen:y,editorMode:k,editingName:A,editingServer:g,form:b,formError:x,saving:w,editorGroups:F,configuredHeaderKeys:te,configuredEnvKeys:ee,savedHttpEndpoint:N,endpointRequired:M,endpointFieldLabel:$,endpointPlaceholder:re,refreshAll:Q,busy:ae,serverState:be,stateLabel:Oe,transportLabel:le,protocolLabel:oe,toolSummary:ve,formatAge:cC,setMasterEnabled:P,togglePending:j,toggleServerEnabled:Y,reconnect:G,refreshTools:he,removeServer:fe,toggleTools:me,filteredTools:J,setToolQuery:ie,openAdd:X,openEdit:_e,closeEditor:U,jumpToEditorGroup:de,addSecretRow:ge,removeSecretRow:Ae,toggleSecretRemoval:$e,saveServer:We}}};function uC(e,t){if(!e||!t)return Hf(e);const s=Hf(e),a=t.trim().split(/\s+/).filter(Boolean);if(!a.length)return s;const n=a.map(i=>i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");try{return s.replace(new RegExp(`(${n})`,"gi"),'$1')}catch{return s}}const pC={template:` -
-
-

Knowledge

-
- - -
-
- - -
-
- {{ sources.length }} - Sources -
-
- {{ totalChunks }} - Chunks -
-
- {{ uploaderCount }} - Uploaders -
-
- - -
- - - -
- - -
-
- Search Results {{ searchResults.length }} - for "{{ lastQuery }}" -
-
-

Search error: {{ searchError }}

-
-
- - No results for "{{ lastQuery }}" - Try different search terms or ingest more documents -
-
-
-
- {{ r.source || 'unknown' }} - {{ r.score.toFixed(3) }} - chunk #{{ r.chunk_index }} -
-
-
-
-
- - -
-

Ingest Document

-
- -
-
- -
-
{{ ingestError }}
-
- {{ ingestSuccess }} -
- -
- - -
-
-
- -
- - No documents ingested - Click "Ingest Document" to add knowledge for Odin to reference -
-
-
- Sources {{ sources.length }} -
-
-
- -
- - - {{ s.source || s.name || s }} - {{ s.chunks || 0 }} chunks - {{ s.uploader }} -
- - -
-
- - -
- Ingested: {{ formatTs(s.ingested_at) }} -
-
{{ s.preview }}
- - -
- {{ reingestResult.message }} -
- - -
-
-
Loading chunks... -
-
-
- {{ sourceChunks[s.source || s.name || s].length }} chunks - Ingested: {{ formatTs(s.ingested_at) }} -
-
-
- #{{ chunk.chunk_index }} - {{ chunk.char_count }} chars -
-
-
-
-
{{ chunk.content }}
-
{{ truncate(chunk.content, 120) }}
-
-
-
Couldn't load chunks — {{ chunkErrors[s.source || s.name || s] }}. Collapse and expand to retry.
-
No chunks found
-
-
-
-
- - - -
`,setup(){const e=f([]),t=f(!0),s=f(null),a=f(""),n=f(null),i=f(!1),l=f(""),o=f(null),r=f(!1),c=f(""),d=f(""),u=f(null),p=f(null),m=f("success"),h=f(!1),y=f(null),k=f(null);let A=null;const g=f(null),b=f(!1),x=f({}),w=f({}),_=f({}),T=f({}),L=new Map,E=f(null),F=B(()=>e.value.reduce((Q,ae)=>Q+(ae.chunks||0),0)),O=B(()=>new Set(e.value.map(ae=>ae.uploader).filter(Boolean)).size);function I(Q,ae){const V=w.value[ae];if(!V||V.length===0)return 0;const be=Math.max(...V.map(Oe=>Oe.char_count||0));return be===0?0:Math.round(Q.char_count/be*100)}async function H(){t.value=!0,s.value=null;try{const Q=await z.get("/api/knowledge");e.value=Array.isArray(Q)?Q:[]}catch(Q){s.value=Q.message}t.value=!1}async function te(Q){if(x.value[Q]){x.value[Q]=!1,E.value=null;return}if(x.value[Q]=!0,Object.prototype.hasOwnProperty.call(w.value,Q))return;if(L.has(Q))return L.get(Q);const ae={...T.value,[Q]:!0};T.value=ae;const V={..._.value};delete V[Q],_.value=V;const be=z.get(`/api/knowledge/${encodeURIComponent(Q)}/chunks`).then(Oe=>{w.value={...w.value,[Q]:Array.isArray(Oe)?Oe:[]}}).catch(Oe=>{_.value={..._.value,[Q]:Oe.message||"load failed"}}).finally(()=>{if(L.get(Q)!==be)return;L.delete(Q);const Oe={...T.value};delete Oe[Q],T.value=Oe});return L.set(Q,be),be}let ee=0;async function N(){const Q=a.value.trim();if(!Q)return;const ae=++ee;i.value=!0,o.value=null,l.value=Q;try{const V=await z.get(`/api/knowledge/search?q=${encodeURIComponent(Q)}`);if(ae!==ee)return;n.value=Array.isArray(V)?V:[]}catch(V){if(ae!==ee)return;n.value=[],o.value=V.message||"Search failed"}ae===ee&&(i.value=!1)}function M(){ee+=1,i.value=!1,n.value=null,a.value="",o.value=null}async function $(){u.value=null,p.value=null,m.value="success";const Q=c.value.trim(),ae=d.value.trim();if(!Q){u.value="Source name is required";return}if(!ae){u.value="Content is required";return}h.value=!0;try{const V=await z.post("/api/knowledge",{source:Q,content:ae}),be=V.outcome==="duplicate"||V.outcome==="conflict";V.outcome==="unchanged"?(p.value=`Already stored unchanged: "${Q}" (${V.chunks} chunks)`,c.value="",d.value=""):be?(m.value="warning",p.value=V.message||V.status):Number.isInteger(V.chunks)&&V.chunks>0?(p.value=`Ingested ${V.chunks} chunks from "${Q}"`,c.value="",d.value=""):(m.value="warning",p.value="Ingestion returned no confirmed stored content; check the source before retrying."),w.value={},await H(),!be&&m.value==="success"&&setTimeout(()=>{r.value=!1,p.value=null},1500)}catch(V){u.value=V.message}h.value=!1}async function re(Q){y.value=Q,k.value=null,A&&(clearTimeout(A),A=null);try{const ae=await z.post(`/api/knowledge/${encodeURIComponent(Q)}/reingest`),V=ae.outcome==="duplicate"||ae.outcome==="conflict";k.value={source:Q,error:!1,warning:V,message:V?ae.message||ae.status:ae.outcome==="unchanged"?`Already stored unchanged (${ae.chunks} chunks)`:`Re-ingested ${ae.chunks||0} chunks`},V||(delete w.value[Q],await H()),A=setTimeout(()=>{k.value=null,A=null},3e3)}catch(ae){k.value={source:Q,error:!0,message:ae.message}}y.value=null}function ce(Q){g.value=Q}async function Z(){if(g.value){b.value=!0;try{await z.del(`/api/knowledge/${encodeURIComponent(g.value)}`),delete w.value[g.value],await H()}catch(Q){Se.error(`Failed to delete source: ${Q.message||"unknown error"}`)}b.value=!1,g.value=null}}return st(()=>{H()}),{sources:e,loading:t,error:s,searchQuery:a,searchResults:n,searching:i,lastQuery:l,searchError:o,showIngest:r,ingestSource:c,ingestContent:d,ingestError:u,ingestSuccess:p,ingestNoticeType:m,ingesting:h,reingesting:y,reingestResult:k,deleteTarget:g,deleting:b,expanded:x,sourceChunks:w,chunkErrors:_,loadingChunks:T,selectedChunk:E,totalChunks:F,uploaderCount:O,truncate:Gu,formatTs:di,highlightTerms:uC,chunkBarWidth:I,fetchSources:H,toggleSource:te,doSearch:N,clearSearch:M,doIngest:$,doReingest:re,confirmDelete:ce,doDelete:Z}}},fC={template:` -
-
-

Memory

-
- - -
-
- - -
-
- {{ totalEntries }} - Entries -
-
- {{ scopes.length }} - Scopes -
-
- {{ selectedCount }} - Selected -
-
- - Select entries to delete -
-
- - -
- -
- - -
-

Add Memory Entry

-
-
- -
-
- -
-
-
- -
-
{{ addError }}
-
{{ addSuccess }}
- -
- - -
-
-

{{ actionError }}

- -
-
- - -
-
-
- -
- - No memory entries - Click "Add Entry" or let Odin learn preferences through conversations -
- - -
-
- -
- - - {{ scope.name }} - - {{ scope.count }} keys - -
- - -
-
-
Loading... -
-
- {{ filterQuery ? 'No matching keys' : 'No entries' }} -
-
-
-
- - {{ entry.key }} -
- - - -
-
-
- -
- - -
-
- -
{{ entry.value }}
-
-
-
-
-
- - - - - - -
`,setup(){const e=f([]),t=f({}),s=f(!0),a=f(null),n=f({}),i=f(null),l=f(""),o=f(!1),r=f({scope:"global",key:"",value:""}),c=f(!1),d=f(null),u=f(null),p=f(null),m=f(""),h=f(!1),y=f(null),k=f(null),A=f(new Set),g=f(null),b=f(!1),x=f(!1),w=B(()=>e.value.reduce((Q,ae)=>Q+ae.count,0)),_=B(()=>A.value.size);function T(Q){const ae=t.value[Q];if(!ae)return[];if(!l.value.trim())return ae;const V=l.value.trim().toLowerCase();return ae.filter(be=>be.key.toLowerCase().includes(V)||be.value&&be.value.toLowerCase().includes(V))}function L(Q,ae){return A.value.has(Q+"/"+ae)}function E(Q,ae){const V=Q+"/"+ae,be=new Set(A.value);be.has(V)?be.delete(V):be.add(V),A.value=be}function F(Q){const ae=t.value[Q];return!ae||ae.length===0?!1:ae.every(V=>A.value.has(Q+"/"+V.key))}function O(Q,ae){const V=t.value[Q];if(!V)return;const be=new Set(A.value);for(const Oe of V){const le=Q+"/"+Oe.key;ae?be.add(le):be.delete(le)}A.value=be}async function I(){s.value=!0,a.value=null;try{const Q=await z.get("/api/memory");e.value=Object.entries(Q).map(([ae,V])=>({name:ae,keys:V.keys||[],count:V.count||0}))}catch(Q){a.value=Q.message}s.value=!1}async function H(Q){if(n.value[Q]){n.value[Q]=!1;return}n.value[Q]=!0;const ae=e.value.find(be=>be.name===Q);if(!ae||t.value[Q]||i.value===Q)return;i.value=Q;let V;try{const Oe=(await z.get(`/api/memory/${encodeURIComponent(Q)}`)).entries||{};V=ae.keys.map(le=>Object.prototype.hasOwnProperty.call(Oe,le)?{key:le,value:Oe[le]||"",failed:!1}:{key:le,value:"",failed:!0,error:"Not found in scope"})}catch(be){V=ae.keys.map(Oe=>({key:Oe,value:"",failed:!0,error:be.message||"Failed to load"}))}t.value[Q]=V,i.value=null}function te(Q,ae,V){p.value=Q+"/"+ae,m.value=V}async function ee(Q,ae){h.value=!0,y.value=null;try{await z.put(`/api/memory/${encodeURIComponent(Q)}/${encodeURIComponent(ae)}`,{value:m.value});const V=t.value[Q];if(V){const be=V.find(Oe=>Oe.key===ae);be&&(be.value=m.value)}p.value=null}catch(V){y.value=`Failed to save: ${V.message||"unknown error"}`}h.value=!1}async function N(Q,ae){try{await navigator.clipboard.writeText(ae.value),k.value=Q+"/"+ae.key,setTimeout(()=>{k.value=null},1500)}catch{}}async function M(){d.value=null,u.value=null;const Q=r.value.scope.trim(),ae=r.value.key.trim(),V=r.value.value.trim();if(!Q){d.value="Scope is required";return}if(!ae){d.value="Key is required";return}if(!V){d.value="Value is required";return}c.value=!0;try{await z.put(`/api/memory/${encodeURIComponent(Q)}/${encodeURIComponent(ae)}`,{value:V}),u.value="Entry saved",r.value={scope:"global",key:"",value:""},t.value={},await I(),setTimeout(()=>{o.value=!1,u.value=null},800)}catch(be){d.value=be.message}c.value=!1}function $(Q,ae){g.value={scope:Q,key:ae}}async function re(){if(!g.value)return;b.value=!0,y.value=null;const{scope:Q,key:ae}=g.value;try{await z.del(`/api/memory/${encodeURIComponent(Q)}/${encodeURIComponent(ae)}`);const V=t.value[Q];V&&(t.value[Q]=V.filter(le=>le.key!==ae));const be=e.value.find(le=>le.name===Q);be&&(be.count--,be.keys=be.keys.filter(le=>le!==ae));const Oe=new Set(A.value);Oe.delete(Q+"/"+ae),A.value=Oe}catch(V){y.value=`Failed to delete: ${V.message||"unknown error"}`}b.value=!1,g.value=null}function ce(){x.value=!0}async function Z(){b.value=!0,y.value=null;const Q=[];for(const ae of A.value){const V=ae.indexOf("/");Q.push({scope:ae.slice(0,V),key:ae.slice(V+1)})}try{await z.post("/api/memory/bulk-delete",{entries:Q}),A.value=new Set,t.value={},await I()}catch(ae){y.value=`Bulk delete failed: ${ae.message||"unknown error"}`}b.value=!1,x.value=!1}return st(()=>{I()}),{scopes:e,scopeEntries:t,loading:s,error:a,expanded:n,loadingScope:i,filterQuery:l,showAdd:o,addForm:r,adding:c,addError:d,addSuccess:u,editingKey:p,editValue:m,saving:h,actionError:y,copied:k,selected:A,selectedCount:_,totalEntries:w,deleteTarget:g,deleting:b,showBulkDelete:x,fetchMemory:I,toggleScope:H,startEdit:te,doEdit:ee,copyValue:N,doAdd:M,confirmDelete:$,doDelete:re,confirmBulkDelete:ce,doBulkDelete:Z,isSelected:L,toggleSelect:E,isScopeAllSelected:F,toggleSelectAll:O,filteredEntries:T}}},mC={components:{DiscordIdentity:Dn},template:` -
-
-
-

Learned Context

-

- {{ entries.length }} entries | Last reflection: {{ formatTs(meta.last_reflection) }} -

-
- -
- -
-
-
-

Automatic learning

-

When off, Odin neither creates automatic lessons nor adds stored learned entries to model context. Existing entries are retained.

-

Current state: {{ learningEnabled ? 'On' : 'Off' }}

- -

Checking administrator configuration…

-
- -
-
- -
-
-
-
-

{{ error }}

- -
-
- - No learned entries yet - Existing entries remain available whether automatic learning is on or off -
- -
- -
- -
- - -
-
-
-
- {{ entry.key }} - {{ entry.category }} - - user: - -
-
- -
- - -
-
-

{{ entry.content }}

-
- Created: {{ formatTs(entry.created_at) }} - | Updated: {{ formatTs(entry.updated_at) }} -
-
-
- - -
-
-
-
-
- `,setup(){const e=f([]),t=f(null),s=f(!0),a=f(null),n=f(null),i=f(null),l=f(""),o=f(!1),r=f(!1),c=f(null),d=f(!1),u=B(()=>[...new Set(e.value.map(_=>_.category))].sort()),p=B(()=>{const w={};return e.value.forEach(_=>{w[_.category]=(w[_.category]||0)+1}),w}),m=B(()=>n.value?e.value.filter(w=>w.category===n.value):e.value);function h(w){return w==="correction"?"badge-warning":w==="operational"?"badge-info":w==="preference"?"badge-success":"badge-info"}function y(w){i.value=w.key,l.value=w.content}async function k(w){try{await z.put("/api/learned/"+encodeURIComponent(w),{content:l.value}),i.value=null,Se.success("Entry updated"),await g()}catch(_){Se.error(_.message||"Failed to save entry")}}async function A(w){if(await is({title:"Delete learned entry",message:`Delete "${w}"? Odin will no longer apply this learned context.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/learned/"+encodeURIComponent(w)),Se.success("Entry deleted"),await g()}catch(T){Se.error(T.message||"Failed to delete entry")}}async function g(){s.value=!0,a.value=null;try{const w=await z.get("/api/learned");e.value=w.entries||[],t.value={last_reflection:w.last_reflection,count:w.count}}catch(w){a.value=w.message}s.value=!1}async function b(){var w;r.value=!1,c.value=null;try{const _=await z.get("/api/config");o.value=((w=_.learning)==null?void 0:w.enabled)===!0,r.value=!0}catch(_){c.value=_.status===403?"Administrator access is required to change automatic learning.":_.message||"Automatic learning state is unavailable."}}async function x(w){if(!(!r.value||d.value)){d.value=!0,c.value=null;try{if(await z.put("/api/config",{learning:{enabled:w}}),await b(),!r.value)return;Se.success(`Automatic learning ${o.value?"enabled":"disabled"}`)}catch(_){r.value=!1,c.value=_.status===403?"Administrator access is required to change automatic learning.":_.message||"Failed to change automatic learning."}finally{d.value=!1}}}return st(()=>{g(),b()}),{entries:e,meta:t,loading:s,error:a,filterCat:n,editing:i,editContent:l,categories:u,catCounts:p,filtered:m,learningEnabled:o,configReady:r,configError:c,savingConfig:d,catBadge:h,formatTs:di,startEdit:y,saveEdit:k,deleteEntry:A,fetchEntries:g,fetchLearningConfig:b,setLearningEnabled:x}}},Ug=[{id:"tools",label:"Tools",component:J1},{id:"skills",label:"Skills",component:Y1},{id:"mcp-servers",label:"MCP Servers",component:dC},{id:"knowledge",label:"Knowledge",component:pC},{id:"memory",label:"Memory",component:fC},{id:"learned",label:"Learned",component:mC}],vC={components:{TabbedPage:uc},setup(){return{tabs:Ug}},template:''},hC={ok:"text-green-400",degraded:"text-yellow-400",down:"text-red-400",unconfigured:"text-gray-500"},gC={ok:"success",degraded:"warning",down:"error",unconfigured:"minus"},bC={healthy:"text-green-400",degraded:"text-yellow-400",unhealthy:"text-red-400"},yC={template:` -
- -
-
-
-
-
-
-
-
-
-
-
-
- - - - - -
-
-

Last refresh failed: {{ error }} — showing the most recent data.

-
- -
-
-
- -
-
{{ overallLabel }}
-
- {{ data.healthy_count }} healthy, {{ data.degraded_count }} degraded, {{ data.down_count }} down, {{ data.unconfigured_count }} unconfigured -
-
-
-
- Updated {{ formatTime(data.checked_at) }} - -
-
-
- - -
-
-
- - {{ formatName(c.name) }} - {{ c.status }} -
-
{{ c.detail }}
- - -
-
Configured hosts:
-
- - {{ h.alias }} - {{ h.ssh_user }}@{{ h.address }} - ({{ h.os }}) -
-
- Pool: {{ c.metadata.active_connections || 0 }} active, - {{ c.metadata.total_reused || 0 }} reused, - {{ c.metadata.total_opened || 0 }} opened -
-
- - -
-
- Model: - {{ c.metadata.model || 'unknown' }} -
-
- Circuit: - {{ c.metadata.circuit_breaker }} -
-
- Pool: - {{ c.metadata.http_pool_active_connections || 0 }}/{{ c.metadata.http_pool_max_connections || 0 }} connections -
-
- Requests: - {{ c.metadata.http_pool_total_requests || 0 }} total -
-
- - -
-
- Chunks: - {{ c.metadata.chunks || 0 }} -
-
- Vector search: - - {{ c.metadata.vector_search ? 'enabled' : 'FTS only' }} - -
-
- - -
-
- Active: - {{ c.metadata.count || 0 }} session(s) -
-
- Tokens: - {{ formatNumber(c.metadata.total_tokens || 0) }} -
-
- {{ c.metadata.over_budget }} over budget -
-
- - -
-
- {{ key }}: - {{ val }} -
-
-
-
-
-
`,setup(){const e=f({}),t=f(!0),s=f(null),a=f(!1),n=f(!1),i=B(()=>e.value.components||[]),l=B(()=>bC[e.value.overall]||"text-gray-400"),o=B(()=>e.value.overall==="healthy"?"success":e.value.overall==="degraded"?"warning":e.value.overall==="unhealthy"?"error":"minus"),r=B(()=>{const _=e.value.overall;return _==="healthy"?"All Systems Healthy":_==="degraded"?"Some Systems Degraded":_==="unhealthy"?"System Issues Detected":"Unknown"});function c(_){return hC[_]||"text-gray-400"}function d(_){return gC[_]||"info"}function u(_){return _==="ok"?"badge-success":_==="degraded"?"badge-warning":_==="down"?"badge-danger":"badge-info"}function p(_){return _==="closed"?"text-green-400":_==="half_open"?"text-yellow-400":_==="open"?"text-red-400":"text-gray-400"}function m(_){return _.replace(/_/g," ").replace(/\b\w/g,T=>T.toUpperCase())}function h(_){if(!_)return"—";try{return new Date(_).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return _}}function y(_){return _>=1e6?(_/1e6).toFixed(1)+"M":_>=1e3?(_/1e3).toFixed(1)+"K":String(_)}async function k(){n.value=!0;try{e.value=await z.get("/api/health/components"),s.value=null,a.value=!0}catch(_){s.value=_.message}finally{t.value=!1,n.value=!1}}function A(){t.value=!0,s.value=null,k()}let g=null,b=!1;function x(){b||(b=!0,k(),g||(g=setInterval(k,3e4)))}function w(){b&&(b=!1,g&&(clearInterval(g),g=null))}return st(x),cs(x),es(w),wt(w),{data:e,hasData:a,loading:t,error:s,refreshing:n,components:i,overallColor:l,overallIcon:o,overallLabel:r,statusColor:c,statusIcon:d,badgeClass:u,circuitColor:p,formatName:m,formatTime:h,formatNumber:y,fetchHealth:k,retry:A}}},xC={template:` -
- -
-
-
-
-
-
-
-
-
- - - - - -
-
-

Last refresh failed: {{ error }} — showing the most recent data.

-
- -
-

Resource Usage

-
- {{ collectedAt }} - -
-
- - -
-
-
{{ data.sessions.active_count }}
-
Active Sessions
-
-
-
{{ fmtNum(data.knowledge.chunk_count) }}
-
Knowledge Chunks
-
-
-
{{ fmtNum(data.trajectories.total_count) }}
-
Trajectories Saved
-
-
-
{{ data.storage_total_mb }} MB
-
Total Storage
-
-
- - -
- -
- - -
-
-
-
{{ fmtNum(data.sessions.total_tokens) }}
-
Total Tokens
-
-
-
{{ fmtNum(data.sessions.total_messages) }}
-
Total Messages
-
-
-
- {{ data.sessions.over_budget_count }} -
-
Over Budget
-
-
-
{{ data.sessions.persist_dir.total_mb }} MB
-
Persist Storage
-
-
- -
-

Per-Session Breakdown

-
- - - - - - - - - - - - - - - -
ChannelTokensMessagesSummary
{{ s.channel_id }}{{ fmtNum(s.tokens) }}{{ s.messages }} - - {{ s.has_summary ? 'Yes' : 'No' }} - -
-
-
-
- No active sessions -
-
- - -
-
-
-
{{ data.knowledge.source_count }}
-
Sources
-
-
-
{{ fmtNum(data.knowledge.chunk_count) }}
-
Chunks
-
-
-
- {{ data.knowledge.vector_search ? 'Vector + FTS' : 'FTS Only' }} -
-
Search Mode
-
-
-
{{ data.knowledge.db_file.total_mb }} MB
-
DB Size
-
-
- -
-

Ingested Sources

-
- - - - - - - - - - - - - -
SourceChunksUploader
{{ s.source }}{{ s.chunks }}{{ s.uploader }}
-
-
-
- {{ data.knowledge.available ? 'No documents ingested' : 'Knowledge store unavailable' }} -
-
- - -
-
-
-
{{ fmtNum(data.trajectories.message_count) }}
-
Message Turns
-
-
-
{{ fmtNum(data.trajectories.agent_count) }}
-
Agent Turns
-
-
-
{{ data.trajectories.message_dir.file_count }}
-
Message Files
-
-
-
{{ data.trajectories.combined_mb }} MB
-
Total Volume
-
-
- -
- -
-

- Message Trajectory Files - ({{ data.trajectories.message_dir.total_mb }} MB) -

-
-
- {{ f }} -
-
-
No trajectory files yet
-
- - -
-

- Agent Trajectory Files - ({{ data.trajectories.agent_dir.total_mb }} MB) -

-
-
- {{ f }} -
-
-
No agent trajectory files yet
-
-
-
- - -
-
-

Storage Breakdown

-
-
-
- {{ item.label }} - {{ item.mb }} MB ({{ item.files }} files) -
-
-
-
-
-
-
-
-
-
- `,setup(){const e=f(!0),t=f(null),s=f(!1),a=f(!1),n=f("sessions"),i=f(null);let l=null;const o=[{key:"sessions",label:"Sessions"},{key:"knowledge",label:"Knowledge"},{key:"trajectories",label:"Trajectories"},{key:"storage",label:"Storage"}],r=B(()=>{if(!i.value||!i.value.collected_at)return"";try{return new Date(i.value.collected_at).toLocaleTimeString()}catch{return""}}),c=B(()=>{if(!i.value)return[];const k=i.value,A=k.storage_total_bytes||1;return[{label:"Session Persistence",mb:k.sessions.persist_dir.total_mb,bytes:k.sessions.persist_dir.total_bytes,files:k.sessions.persist_dir.file_count,pct:Math.min(100,Math.round(k.sessions.persist_dir.total_bytes/A*100)),color:"res-bar-blue"},{label:"Knowledge Database",mb:k.knowledge.db_file.total_mb,bytes:k.knowledge.db_file.total_bytes,files:k.knowledge.db_file.file_count,pct:Math.min(100,Math.round(k.knowledge.db_file.total_bytes/A*100)),color:"res-bar-purple"},{label:"Message Trajectories",mb:k.trajectories.message_dir.total_mb,bytes:k.trajectories.message_dir.total_bytes,files:k.trajectories.message_dir.file_count,pct:Math.min(100,Math.round(k.trajectories.message_dir.total_bytes/A*100)),color:"res-bar-emerald"},{label:"Agent Trajectories",mb:k.trajectories.agent_dir.total_mb,bytes:k.trajectories.agent_dir.total_bytes,files:k.trajectories.agent_dir.file_count,pct:Math.min(100,Math.round(k.trajectories.agent_dir.total_bytes/A*100)),color:"res-bar-amber"}]});async function d(){try{const k=await z.get("/api/resource-usage");i.value=k,t.value=null,s.value=!0}catch(k){t.value=k.message||"Failed to load resource usage"}finally{e.value=!1,a.value=!1}}async function u(){a.value=!0,await d()}function p(){e.value=!0,t.value=null,d()}let m=!1;function h(){m||(m=!0,d(),l||(l=setInterval(d,3e4)))}function y(){m&&(m=!1,l&&(clearInterval(l),l=null))}return st(h),cs(h),es(y),wt(y),{hasData:s,loading:e,error:t,refreshing:a,data:i,activeTab:n,tabs:o,collectedAt:r,storageItems:c,fmtNum:Wu,refresh:u,retry:p}}},_C=new Set(["timestamp","type","level","tool_name","action","method","path","status","success","execution_time_ms","duration_ms","metadata","audit_metadata","turn","tool_input","audit_observer","_hmac","_prev_hmac","agent_id","agent_label","parent_agent_id","root_agent_id","originating_turn_id","turn_id","iteration","call_id","result_summary","detail","message","diff","error"]),wC=new Set(["agent_id","agent_label","label","parent_agent_id","root_agent_id","originating_turn_id","turn_id","iteration","call_id","status","duration_ms","tool_input_keys","audit_observer","_hmac","_prev_hmac"]);function kC(e){const t={};for(const s of["metadata","audit_metadata"]){if(!(e!=null&&e[s])||typeof e[s]!="object")continue;const a=Object.fromEntries(Object.entries(e[s]).filter(([n])=>!wC.has(n)));Object.keys(a).length&&(t[s]=a)}return Object.keys(t).length?lo(t):""}function SC(e){var n,i,l,o;const t=e.record;if(!t)return{body:e.text,action:"",status:"",duration:null};let s;for(const r of["result_summary","detail","message","diff","error"])if(t[r]!==void 0&&t[r]!==null&&t[r]!==""){s=t[r];break}if(s===void 0&&((n=t.metadata)!=null&&n.error)&&(s=t.metadata.error),s===void 0){const r=Object.fromEntries(Object.entries(t).filter(([c])=>!_C.has(c)));s=Object.keys(r).length?lo(r):""}const a=t.status??((i=t.metadata)==null?void 0:i.status)??(t.error||t.success===!1?"failed":t.success===!0?"success":["tool_start","loop_tool_start"].includes(t.type)?"started":"");return{body:s,action:t.tool_name||t.action||(t.method?`${t.method} ${t.path||""}`.trim():t.type||""),status:a,duration:t.execution_time_ms??t.duration_ms??((l=t.metadata)==null?void 0:l.duration_ms)??((o=t.metadata)==null?void 0:o.elapsed_ms)??null}}const In=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),oo=e=>typeof e=="string"||typeof e=="number"?String(e):"";function CC(e){const t=In(e)?e:{},s=In(t.metadata)?t.metadata:{},a=In(t.audit_metadata)?t.audit_metadata:{},n=In(t.turn)?t.turn:{},i=l=>oo(t[l]??s[l]??a[l]??n[l]);return{agentId:i("agent_id"),label:i("agent_label")||i("label"),parentId:i("parent_agent_id"),rootId:i("root_agent_id"),turnId:i("originating_turn_id")||i("turn_id"),iteration:i("iteration"),callId:i("call_id")}}function Zf(e){return e.record?JSON.stringify(zg(e),null,2):e.text}function zg(e){var t;return((t=e.events)==null?void 0:t.length)>1?e.events:e.record}function Fd(e){return e!=null&&e.tool_name?["tool_start","loop_tool_start"].includes(e.type)?"start":["tool_end","loop_tool"].includes(e.type)?"end":(!e.type||e.type==="execution")&&"result_summary"in e?"execution":"":""}function Qf(e){if(!Fd(e.record))return"";const t=e.attribution,s=e.record;return!t.callId||!t.turnId&&!t.agentId?"":JSON.stringify([t.turnId,t.agentId,t.iteration,t.callId,e.tool,oo(s.channel_id),oo(s.user_id??s.actor)])}function TC(e,t,s=2e3){var i,l,o;const a=Qf(t),n=a?e.findIndex(r=>Qf(r)===a):-1;if(n<0)e.push(t);else{const r=e[n],c=[...r.events||[r.record]],d=JSON.stringify(t.record);if(c.some(g=>JSON.stringify(g)===d))return;c.push(t.record);const u=g=>"result_summary"in g?2:Fd(g)==="end"?1:0,p=[...c].sort((g,b)=>u(g)-u(b)),m=Object.assign({},...p);m.type=p[p.length-1].type||"execution";for(const g of["metadata","audit_metadata","turn"]){const b=p.filter(x=>In(x[g])).map(x=>x[g]);b.length&&(m[g]=Object.assign({},...b))}const h=c.some(g=>Fd(g)!=="start"),y=c.find(g=>Bd(g,0).level==="ERROR"),k=(y==null?void 0:y.status)||((i=y==null?void 0:y.metadata)==null?void 0:i.status);m.status=y?["failed","error","cancelled","denied","outcome_unknown"].includes(k)?k:"failed":h?m.status||((l=m.metadata)==null?void 0:l.status)||"succeeded":"started",h&&m.status==="started"&&(m.status="succeeded"),y&&(m.error=y.error||((o=y.metadata)==null?void 0:o.error)||m.error);const A=Bd(m,r.id,r._time);Object.assign(A,{events:c,ts:r.ts,_time:r._time,searchText:c.map(g=>JSON.stringify(g)).join(` -`)}),e.splice(n,1,A)}e.length>s&&e.splice(0,e.length-s)}function Bd(e,t,s=new Date){var u,p;let a=e;if(In(e)&&e.type==="log"&&"line"in e?a=e.line:In(e)&&"payload"in e&&(a=e.payload),typeof a=="string")try{a=JSON.parse(a)}catch{}const n=In(a)?a:null,i=n!=null&&n.timestamp?new Date(n.timestamp):s,l=Number.isNaN(i.getTime())?s:i,o=n?n.result_summary??n.detail??n.message??JSON.stringify(n):typeof a=="string"?a:JSON.stringify(a)??"",c=(n==null?void 0:n.error)||((u=n==null?void 0:n.metadata)==null?void 0:u.error)||(n==null?void 0:n.success)===!1||[n==null?void 0:n.status,(p=n==null?void 0:n.metadata)==null?void 0:p.status].some(m=>["failed","error","cancelled","denied","outcome_unknown"].includes(m))?"ERROR":oo(n==null?void 0:n.level).toUpperCase()||"INFO",d={id:t,record:n,ts:l.toLocaleTimeString(),_time:l,level:c,text:typeof o=="string"?o:JSON.stringify(o),tool:oo(n==null?void 0:n.tool_name),raw:n?null:o,attribution:CC(n)};return d.searchText=n?JSON.stringify(n):d.text,d}function EC(e){const t=new Map;for(const s of e){const{turnId:a,agentId:n,rootId:i,label:l,parentId:o}=s.attribution,r=a?`turn:${a}`:n?`root:${i||n}`:"unattributed";t.has(r)||t.set(r,{key:r,title:a?`Turn ${a}`:n?`Agent root ${i||n} (turn unavailable)`:"Unattributed / legacy records",count:0,sections:[],_sections:new Map});const c=t.get(r),d=n?`agent:${n}`:"main";if(!c._sections.has(d)){const u={key:d,agentId:n,label:l,parentId:o,rootId:i,title:n?`${l||"Agent"} (${n})`:"Main thread / turn events",entries:[]};c._sections.set(d,u),c.sections.push(u)}c._sections.get(d).entries.push(s),c.count++}return[...t.values()].map(({_sections:s,...a})=>a)}const AC={components:{ToolOutput:pc},props:{entry:{type:Object,required:!0}},emits:["copy"],setup(e){const t=B(()=>SC(e.entry)),s=B(()=>{var o;return lo(((o=e.entry.record)==null?void 0:o.tool_input)??"")}),a=B(()=>{var o,r,c;return lo(((o=e.entry.record)==null?void 0:o.error)||((c=(r=e.entry.record)==null?void 0:r.metadata)==null?void 0:c.error)||"")}),n=B(()=>kC(e.entry.record)),i=B(()=>zg(e.entry)),l=B(()=>(e.entry.events||[e.entry.record]).filter(Boolean).map(o=>{var r;return{type:o.type||"execution",timestamp:o.timestamp||"Time not recorded",status:o.status||((r=o.metadata)==null?void 0:r.status)||""}}));return{display:t,argumentsText:s,errorText:a,metadataText:n,rawRecord:i,lifecycle:l}},template:` -
- - - - - -
`},RC=["INFO","WARNING","ERROR"],Yf="WARNING+",IC=[{id:"all",name:"All Logs",icon:"list",filters:{}},{id:"errors",name:"Errors Only",icon:"error",filters:{level:"ERROR"}},{id:"warnings",name:"Warnings+",icon:"warning",filters:{levels:["WARNING","ERROR"]}},{id:"tools",name:"Tool Activity",icon:"wrench",filters:{hasToolName:!0}},{id:"recent-errors",name:"Recent Errors",icon:"flame",filters:{level:"ERROR",timeRange:"last_1h"}}],Zc=[{value:"",label:"All Time"},{value:"last_5m",label:"Last 5 min",seconds:300},{value:"last_15m",label:"Last 15 min",seconds:900},{value:"last_1h",label:"Last 1 hour",seconds:3600},{value:"last_4h",label:"Last 4 hours",seconds:14400},{value:"last_24h",label:"Last 24 hours",seconds:86400}],OC=[50,100,200,500],LC={components:{ToolOutput:pc,LogRecord:AC,DiscordIdentity:Dn},template:` -
-
-
-

Logs

-

- {{ filteredLogs.length.toLocaleString() }} / {{ logs.length.toLocaleString() }} entries -

-

- {{ searchResults.length.toLocaleString() }} results -

-
-
- -
- - -
- - -
-
- - - - - - -
`,setup(){const e=f("live"),t=f([]);let s=0;const a=f(!1),n=f(!1),i=f(!0),l=f(""),o=f(""),r=f(!1),c=f(!1),d=f(mt.state||"disconnected"),u=B(()=>{switch(d.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}}),p=f(null),m=f(!1),h=f(null),y=2e3,k=RC,A=IC,g=Zc,b=f("all"),x=f(""),w=f(!1),_=f([]),T=f(!1),L=f(""),E=f([]);function F(){try{const ue=localStorage.getItem("odin-log-presets");ue&&(_.value=JSON.parse(ue))}catch{}}function O(){try{localStorage.setItem("odin-log-presets",JSON.stringify(_.value))}catch{}}const I=B(()=>l.value!==""||o.value.trim()!==""||x.value!==""||w.value),H=B(()=>{const ue=Zc.find(Ce=>Ce.value===x.value);return ue?ue.label:""}),te=B(()=>{if(!r.value||!o.value)return null;try{return new RegExp(o.value,"i"),null}catch(ue){return ue.message}}),ee=24,N=B(()=>{if(ae.value.length===0)return[];const ue=[],Ce=new Date,qe=3600*1e3;for(let Ge=ee-1;Ge>=0;Ge--){const It=new Date(Ce.getTime()-(Ge+1)*qe),ot=new Date(Ce.getTime()-Ge*qe);ue.push({start:It,end:ot,label:ce(It,ot),shortLabel:ot.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}),total:0,info:0,warnings:0,errors:0})}for(const Ge of ae.value){if(!Ge._time)continue;const It=Ge._time.getTime();for(const ot of ue)if(It>=ot.start.getTime()&&It{let ue=1;for(const Ce of N.value)Ce.total>ue&&(ue=Ce.total);return ue}),$=B(()=>{if(N.value.length===0)return"";const ue=ae.value.map(Ge=>Ge._time&&Ge._time.getTime()).filter(Boolean);if(ue.length===0)return"";const Ce=new Date(Math.min(...ue));return`${ae.value.length} shown, oldest ${Ce.toLocaleTimeString()}`}),re=B(()=>Math.ceil(ee/8));function ce(ue,Ce){const qe={hour:"2-digit",minute:"2-digit"};return ue.toLocaleTimeString([],qe)+" - "+Ce.toLocaleTimeString([],qe)}function Z(ue,Ce){return!Ce||!ue?"0px":Math.max(2,ue/Ce*100)+"%"}function Q(ue){const Ce=ae.value.findIndex(qe=>qe._time&&qe._time.getTime()>=ue.start.getTime()&&qe._time.getTime()=0&&p.value){const qe=p.value.querySelector('[data-log-id="'+ae.value[Ce].id+'"]');qe&&(qe.scrollIntoView({behavior:"smooth",block:"center"}),i.value=!1)}}const ae=B(()=>{let ue=t.value;if(w.value&&(ue=ue.filter(Ce=>!!Ce.tool)),l.value)if(l.value===Yf){const Ce={INFO:0,WARNING:1,ERROR:2,CRITICAL:3};ue=ue.filter(qe=>Ce[qe.level||"INFO"]>=Ce.WARNING)}else ue=ue.filter(Ce=>(Ce.level||"INFO")===l.value);if(x.value){const Ce=Zc.find(qe=>qe.value===x.value);if(Ce&&Ce.seconds){const qe=new Date(Date.now()-Ce.seconds*1e3);ue=ue.filter(Ge=>Ge._time&&Ge._time>=qe)}}if(o.value&&!te.value)if(r.value)try{const Ce=new RegExp(o.value,"i");ue=ue.filter(qe=>{const Ge=qe.searchText,It=qe.tool||"";return Ce.test(Ge)||Ce.test(It)})}catch{}else{const Ce=o.value.toLowerCase();ue=ue.filter(qe=>{const Ge=qe.searchText.toLowerCase(),It=(qe.tool||"").toLowerCase();return Ge.includes(Ce)||It.includes(Ce)})}return ue}),V=B(()=>EC(ae.value));function be(ue){const Ce=Bd(ue,++s);if(n.value){E.value.push(Ce);return}Oe(Ce)}function Oe(ue){TC(t.value,ue,y),i.value&&Ut(()=>le())}function le(ue=!1){const Ce=p.value;Ce&&Ce.scrollTo({top:Ce.scrollHeight,behavior:ue?"smooth":"instant"})}function oe(){i.value=!0,m.value=!1,Ut(()=>le(!0))}const ve=new Set(["PageUp","PageDown","ArrowUp","ArrowDown","Home","End"," "]);function j(){const ue=p.value;if(!ue)return;const Ce=ue.scrollHeight-ue.scrollTop-ue.clientHeight<40;m.value=!i.value&&!Ce&&t.value.length>0,P.value&&Y()}function Y(){const ue=p.value;!ue||!i.value||ue.scrollHeight-ue.scrollTop-ue.clientHeight>=40&&(i.value=!1,m.value=t.value.length>0)}function xe(){i.value&&requestAnimationFrame(Y)}function S(ue){ve.has(ue.key)&&xe()}const P=f(!1);function G(){i.value&&(P.value=!0,requestAnimationFrame(Y))}function he(){P.value&&(P.value=!1,Y())}function fe(){i.value&&(m.value=!1,Ut(()=>le()))}function me(){if(n.value=!n.value,!n.value&&E.value.length>0){for(const ue of E.value)Oe(ue);E.value=[]}}function we(){t.value=[],E.value=[],m.value=!1}function J(){let ue;e.value==="search"?ue=Ye.value.map(It=>{const ot=It.error?"ERROR":"INFO",pn=It.tool_name?`[${It.tool_name}] `:"";return`${It.timestamp||""} ${ot} ${pn}${It.result_summary||It.message||""}`}).join(` -`):ue=ae.value.map(Zf).join(` - -`);const Ce=new Blob([ue],{type:"text/plain"}),qe=URL.createObjectURL(Ce),Ge=document.createElement("a");Ge.href=qe,Ge.download=`odin-logs-${new Date().toISOString().slice(0,19).replace(/:/g,"-")}.txt`,Ge.click(),URL.revokeObjectURL(qe)}function ie(ue){const Ce=Zf(ue);navigator.clipboard.writeText(Ce).then(()=>{h.value=ue.id,setTimeout(()=>{h.value=null},1500)}).catch(()=>{})}function X(ue){l.value=l.value===ue?"":ue,b.value="all",w.value=!1}function _e(ue){return ue.level==="ERROR"?"log-line-error":ue.level==="WARNING"?"log-line-warning":"text-gray-300"}function U(ue){return ue==="ERROR"?"text-red-500 font-semibold":ue==="WARNING"?"text-yellow-500":"text-blue-500"}function de(ue){return ue==="ERROR"?"log-chip-error":ue==="WARNING"?"log-chip-warning":"log-chip-info"}function ge(ue){b.value=ue.id;const Ce=ue.filters;l.value=Ce.level||"",x.value=Ce.timeRange||"",o.value=Ce.text||"",w.value=!!Ce.hasToolName,Ce.levels&&(l.value=Ce.levels.includes("WARNING")&&Ce.levels.includes("ERROR")?Yf:Ce.levels[0]||"")}function Ae(ue){b.value=ue.id,l.value=ue.filters.level||"",x.value=ue.filters.timeRange||"",o.value=ue.filters.text||"",w.value=!!ue.filters.hasToolName}function $e(){if(!L.value.trim())return;const ue={id:"custom-"+Date.now(),name:L.value.trim(),filters:{level:l.value,timeRange:x.value,text:o.value,hasToolName:w.value||void 0}};_.value=[..._.value,ue],O(),T.value=!1,L.value=""}function We(ue){_.value=_.value.filter(Ce=>Ce.id!==ue),O(),b.value===ue&&(b.value="all",x.value="",w.value=!1,l.value="",o.value="")}const je=f("all"),Xe=f(""),it=f(""),Qe=f(""),se=f(""),Re=f(""),Le=f(100),Te=OC,W=f(!1),De=f(!1),ze=f(""),Ye=f([]),Dt=f(null),et=f(null);function Rt(){e.value="search",Dt.value||Pt()}async function Pt(){try{Dt.value=await z.get("/api/logs/stats")}catch{}}function Ts(){const ue=Re.value;if(!ue){Qe.value="",se.value="";return}const qe={last_5m:300,last_15m:900,last_1h:3600,last_4h:14400,last_24h:86400,last_7d:604800}[ue];if(qe){const Ge=new Date(Date.now()-qe*1e3);Qe.value=Vs(Ge),se.value=""}}function Vs(ue){const Ce=qe=>String(qe).padStart(2,"0");return`${ue.getFullYear()}-${Ce(ue.getMonth()+1)}-${Ce(ue.getDate())}T${Ce(ue.getHours())}:${Ce(ue.getMinutes())}`}function Gs(ue){if(!ue)return"";const Ce=new Date(ue);return isNaN(Ce.getTime())?"":Ce.toISOString()}let zt=0;async function ta(){if(W.value)return;const ue=++zt;W.value=!0,ze.value="",De.value=!0,et.value=null;try{const Ce=new URLSearchParams;je.value&&je.value!=="all"&&Ce.set("level",je.value),Xe.value&&Ce.set("tool",Xe.value),it.value&&Ce.set("q",it.value);const qe=Gs(Qe.value),Ge=Gs(se.value);qe&&Ce.set("start",qe),Ge&&Ce.set("end",Ge),Ce.set("limit",String(Le.value));const It=await z.get(`/api/logs/search?${Ce.toString()}`);if(ue!==zt)return;Ye.value=It.entries||[]}catch(Ce){if(ue!==zt)return;ze.value=Ce.message||"Search failed",Ye.value=[]}finally{ue===zt&&(W.value=!1)}}function Ws(){zt++,je.value="all",Xe.value="",it.value="",Qe.value="",se.value="",Re.value="",Le.value=100,Ye.value=[],De.value=!1,ze.value="",et.value=null,W.value=!1}function Pa(ue){et.value=et.value===ue?null:ue}function hs(ue){if(!ue.timestamp)return"";try{return new Date(ue.timestamp).toLocaleString()}catch{return ue.timestamp}}function $a(ue){return ue.type==="web_action"?`${ue.status||""} (${ue.execution_time_ms||0}ms)`:(ue.result_summary||"").slice(0,200)}function Es(ue){return ue.error?"log-line-error":"text-gray-300"}function Ct(ue){try{return JSON.stringify(ue,null,2)}catch{return String(ue)}}let ls=null,sa=!1;function ba(){sa||(sa=!0,mt.subscribe("logs",be),c.value=mt.connected,d.value=mt.state||"disconnected",ls=mt.onState(ue=>{d.value=ue,c.value=ue==="connected"}))}function Fe(){sa&&(sa=!1,mt.unsubscribe("logs",be),ls&&(ls(),ls=null))}return st(()=>{F(),window.addEventListener("pointerup",he),window.addEventListener("pointercancel",he)}),cs(ba),es(Fe),wt(()=>{Fe(),window.removeEventListener("pointerup",he),window.removeEventListener("pointercancel",he)}),{mode:e,logs:t,paused:n,autoScroll:i,levelFilter:l,textFilter:o,useRegex:r,groupByTurn:a,groupedLogs:V,subscribed:c,wsState:d,wsStateLabel:u,logContainer:p,filteredLogs:ae,pauseBuffer:E,showJumpBottom:m,copiedIndex:h,regexError:te,levels:k,logPresets:A,timeRanges:g,timeRange:x,toolOnly:w,activeLogPreset:b,customLogPresets:_,showSaveLogPreset:T,newLogPresetName:L,hasActiveLogFilters:I,timeRangeLabel:H,timelineBuckets:N,timelineMax:M,timelineSpanLabel:$,timelineLabelSkip:re,togglePause:me,clearLogs:we,exportLogs:J,logLineClass:_e,levelClass:U,levelChipClass:de,toggleLevel:X,copyLine:ie,jumpToBottom:oe,onScroll:j,onUserScrollIntent:xe,onUserScrollKey:S,onAutoScrollToggle:fe,onPointerDown:G,applyLogPreset:ge,applyCustomLogPreset:Ae,saveLogCustomPreset:$e,removeLogCustomPreset:We,segmentHeight:Z,jumpToTimelineBucket:Q,searchLevel:je,searchTool:Xe,searchKeyword:it,searchStart:Qe,searchEnd:se,searchTimePreset:Re,searchLimit:Le,searchLimits:Te,searching:W,searchRan:De,searchError:ze,searchResults:Ye,searchStats:Dt,expandedSearch:et,switchToSearch:Rt,runSearch:ta,clearSearchFilters:Ws,toggleSearchExpand:Pa,formatSearchTs:hs,searchEntryText:$a,searchLogLineClass:Es,formatJson:Ct,applySearchTimePreset:Ts}}};function Go(e=[]){const t=[],s=new Set;function a(n){const i=[n.kind,n.label,n.apply_mode||"",n.code||"",n.text||""].join("\0");s.has(i)||(s.add(i),t.push({...n,key:i}))}for(const n of e)for(const i of(n==null?void 0:n.consumers)||[])a({kind:"consumer",label:i.name,apply_mode:i.apply_mode,text:i.detail});for(const n of e)n!=null&&n.apply_handler&&a({kind:"handler",label:"Apply handler",code:n.apply_handler});for(const n of e)n!=null&&n.restart_reason&&a({kind:"restart",label:"Why a restart is required",text:n.restart_reason});for(const n of e)n!=null&&n.activation_policy&&a({kind:"activation",label:"Activation policy",text:n.activation_policy});return t}const NC=Object.freeze([{key:"all",label:"All fields",short:"All",icon:"grid"},{key:"applied",label:"Applied",short:"Applied",icon:"success"},{key:"pending_restart",label:"Pending restart",short:"Restart",icon:"refresh"},{key:"dormant",label:"Saved, not active",short:"Saved only",icon:"pause"},{key:"invalid",label:"Invalid",short:"Invalid",icon:"error"},{key:"drift",label:"Drift",short:"Drift",icon:"warning"},{key:"unknown",label:"Effective state unknown",short:"Unknown",icon:"info"}]);function MC(e,t={}){var n,i;const s=t.getStyle||(l=>globalThis.getComputedStyle(l)),a=Object.hasOwn(t,"fallback")?t.fallback:(n=globalThis.document)==null?void 0:n.scrollingElement;for(let l=e;l;l=l.parentElement){const o=((i=s(l))==null?void 0:i.overflowY)||"";if(/^(auto|scroll|overlay)$/.test(o)&&l.scrollHeight>l.clientHeight)return l}return a&&a.scrollHeight>a.clientHeight?a:e||a||null}const zi=[{key:"core",label:"Core",icon:"sliders",sections:["timezone","logging","permissions","graceful_degradation"]},{key:"models",label:"Models & AI",icon:"brain",sections:["image","llm_recovery"]},{key:"runtime",label:"Runtime",icon:"activity",sections:["context","sessions","agents","turn_state"]},{key:"data",label:"Data & Storage",icon:"database",sections:["learning","search","usage","audit","attachments"]},{key:"services",label:"Services",icon:"link",sections:["webhook","observability","email","browser","mcp"]},{key:"automation",label:"Automation",icon:"workflow",sections:["grafana_alerts","outbound_webhooks"]},{key:"infrastructure",label:"Infrastructure",icon:"server",sections:["tools","web"]}],DC={live_read:"Applies immediately",live_apply:"Dedicated live apply",live_for_new_work:"Applies to new work",restart:"Restart required",activation_required:"Saved only — see activation note",legacy_control:"Controlled elsewhere",dormant:"Saved for future support"},Wo=new Set(["llm_provider","openai_codex","ollama","openai_compatible","kimi","personality","discord","computer"]),PC=Object.freeze(["web.api_tokens","outbound_webhooks.targets"]);function Xf(e){return PC.some(t=>e===t||e.startsWith(`${t}.`))}const Hg="odin_config_center_expanded_v1",jg="odin_config_center_category_v1",$C=50,FC=650,gl=()=>z.get("/api/config/meta");function Gn(e){return e===void 0?void 0:JSON.parse(JSON.stringify(e))}function Oi(e,t){return JSON.stringify(e)===JSON.stringify(t)}function wi(e){return String(e).replace(/[_-]+/g," ").replace(/\b\w/g,t=>t.toUpperCase())}function BC(e){return e===void 0?"unset":e===null?"null":typeof e=="boolean"?e?"Enabled":"Disabled":Array.isArray(e)?e.length?`${e.length} item${e.length===1?"":"s"}`:"Empty list":typeof e=="object"?Object.keys(e).length?`${Object.keys(e).length} field${Object.keys(e).length===1?"":"s"}`:"Empty object":e===""?"Empty":String(e)}function UC(e){if(e===void 0)return"unset";if(e===null)return"null";if(typeof e=="object")try{return JSON.stringify(e,null,2)}catch{return String(e)}return String(e)}function qg(e,t){if(Oi(e,t))return;if(!(e&&t&&typeof e=="object"&&typeof t=="object"&&!Array.isArray(e)&&!Array.isArray(t)))return Gn(t);const a={};for(const[n,i]of Object.entries(t)){const l=qg(e[n],i);l!==void 0&&(a[n]=l)}return Object.keys(a).length?a:void 0}function zC(e,t){const s={};for(const[a,n]of Object.entries(t||{})){const i=qg(e==null?void 0:e[a],n);i!==void 0&&(s[a]=i)}return s}function Vg(e,t,s,a){if(Oi(e,t))return;if(e&&t&&typeof e=="object"&&typeof t=="object"&&!Array.isArray(e)&&!Array.isArray(t)){const i=new Set([...Object.keys(e),...Object.keys(t)]);for(const l of i)Vg(e[l],t[l],s?`${s}.${l}`:l,a);return}a.push({path:s,oldVal:e,newVal:t})}function HC(){try{const e=JSON.parse(localStorage.getItem(Hg)||"{}");return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}}function jC(){try{const e=localStorage.getItem(jg);return zi.some(t=>t.key===e)?e:zi[0].key}catch{return zi[0].key}}const qC={template:` -
-
-
-
System settings
-

Configuration center

-

- {{ sectionCount }} sections · {{ fieldCount }} settings · revision {{ meta?.revision || 'unavailable' }} -

-
-
- - - - -
-
- -
- {{ toast.message }} -
- -
-
-
-
-
-
-
-
- - - - -
- `,setup(){const e=f(null),t=f(null),s=f(!0),a=f(null),n=f(!1),i=f(null),l=f(!1),o=f(""),r=f(!1),c=f(""),d=f(""),u=f("");function p(C){i.value=C||null,C&&typeof C.authorized=="boolean"&&(l.value=C.authorized)}async function m(){try{const C=await z.get("/api/setup/status");p(C.listener),u.value=""}catch(C){p(null),u.value=`Listener status could not be loaded: ${C.message||"Unknown error"}`}}async function h(){if(!(!he.value||!o.value.trim()||r.value||G.value)){r.value=!0,c.value="",d.value="";try{const C=z.setListenerExposure(o.value.trim(),l.value);o.value="";const K=await C;c.value=K.message,p(K.listener)}catch(C){d.value=C.message||"Listener consent could not be saved."}finally{o.value="",r.value=!1}}}const y=f(null),k=["image_model","outer_model"],A=f(null),g=f(null),b=f(null),x=f(!1),w=f(!1),_=f(null),T=f(""),L=f("all"),E=f(jC()),F=f(HC()),O=f({}),I=f({}),H=f(""),te=f({}),ee=f({}),N=f([]),M=f([]),$=f(!1),re=f(!1),ce=f(!1);let Z=null,Q=null,ae={path:null,at:0},V=0;const be=B(()=>{var C;return(((C=t.value)==null?void 0:C.fields)||[]).filter(K=>!Wo.has(K.path.split(".")[0])&&!Xf(K.path))}),Oe=B(()=>new Map(be.value.map(C=>[C.path,C]))),le=B(()=>xe.value.reduce((C,K)=>C+K.sections.length,0)),oe=B(()=>be.value.length),ve=B(()=>NC),j=B(()=>N.value.length>0),Y=B(()=>M.value.length>0),xe=B(()=>{if(!e.value)return[];const C=new Set(zi.flatMap(Ee=>Ee.sections)),K=zi.map(Ee=>({...Ee,sections:Ee.sections.filter(Je=>Object.hasOwn(e.value,Je)&&!Wo.has(Je))})).filter(Ee=>Ee.sections.length),ne=Object.keys(e.value).filter(Ee=>!C.has(Ee)&&!Wo.has(Ee));return ne.length&&K.push({key:"other",label:"Other",icon:"folder",sections:ne}),K}),S=B(()=>e.value?{...e.value,...O.value}:null),P=B(()=>{if(!e.value)return[];const C=[];for(const[K,ne]of Object.entries(O.value))Vg(e.value[K],ne,K,C);return C.filter(K=>!Oi(K.oldVal,K.newVal)).map(K=>{const ne=it(K.path);return{...K,label:(ne==null?void 0:ne.label)||wi(K.path.split(".").at(-1)),apply_mode:(ne==null?void 0:ne.apply_mode)||De(K.path.split(".")[0])}})}),G=B(()=>P.value.length>0),he=B(()=>!!i.value&&l.value!==i.value.authorized),fe=B(()=>{var K;const C=(K=i.value)==null?void 0:K.state;return C==="active"||C==="authorized_loopback"?"active":["pending_widening","pending_narrowing","active_rebind_pending"].includes(C)?"pending":C==="restricted"?"restricted":"unknown"}),me=B(()=>{var C;return{active:"Exposure active",authorized_loopback:"Authorized · loopback host",pending_widening:"Authorized · restart pending",pending_narrowing:"Restriction saved · restart pending",active_rebind_pending:"Exposed · restart pending",restricted:"Loopback only",unknown:"Runtime state unavailable"}[(C=i.value)==null?void 0:C.state]||"Loading listener state"}),we=B(()=>i.value?i.value.authorized?i.value.authorization_source==="explicit"?"Beyond-loopback access is explicitly authorized":"Beyond-loopback access is retained from this installation":"Beyond-loopback access is not authorized":"Unavailable"),J=B(()=>{var C;return{explicit:"saved explicitly in config.yml",default:"schema default; no web.host key is saved",unknown:"source could not be verified"}[(C=i.value)==null?void 0:C.configured_host_source]||"source unavailable"}),ie=B(()=>{const C=i.value;return!C||C.running_scope==="unavailable"?"Actual bound address unavailable":`${(C.listening_hosts||[]).map((ne,Ee)=>{var St;const Je=(St=C.listening_ports)==null?void 0:St[Ee];return Je?`${ne}:${Je}`:ne}).join(", ")} · ${C.running_scope==="loopback"?"loopback only":"accepting beyond loopback"}`}),X=B(()=>P.value.length),_e=B(()=>new Set(P.value.map(C=>C.path.split(".")[0])).size),U=B(()=>!!T.value||L.value!=="all"),de=B(()=>{const C={...ee.value};for(const K of P.value){const ne=it(K.path),Ee=pe(ne,K.newVal);Ee&&(C[K.path]=Ee)}return C}),ge=B(()=>Object.keys(de.value).length>0),Ae=B(()=>e.value?(U.value?xe.value:xe.value.filter(K=>K.key===E.value)).map(K=>({...K,sections:K.sections.filter(ne=>Es(ne))})).filter(K=>K.sections.length):[]),$e=B(()=>{const C=["live_read","live_apply","live_for_new_work","restart","activation_required","legacy_control","dormant"],K=new Map(C.map(ne=>[ne,[]]));for(const ne of P.value){const Ee=K.has(ne.apply_mode)?ne.apply_mode:"restart";K.get(Ee).push(ne)}return C.filter(ne=>K.get(ne).length).map(ne=>({key:ne,label:ds(ne),entries:K.get(ne)}))}),We=B(()=>P.value.filter(C=>C.apply_mode==="restart").length),je=B(()=>be.value.filter(C=>C.pending_restart)),Xe=B(()=>je.value.length);function it(C){const K=Oe.value.get(C);return K?{...K,apply_details:Go([K])}:null}function Qe(C){const K=`${C}.`;return be.value.filter(ne=>ne.path===C||ne.path.startsWith(K))}function se(){return be.value.some(C=>C.path==="tools.hosts"||C.path.startsWith("tools.hosts."))}function Re(){var ne,Ee;const C=((Ee=(ne=e.value)==null?void 0:ne.tools)==null?void 0:Ee.hosts)||{},K=Object.keys(C).length;return`${K} host${K===1?"":"s"} configured.`}function Le(C){return Qe(C).length}function Te(C){return wi(C)}function W(C){const K=Qe(C);if(!K.length)return`${wi(C)} configuration.`;const ne=K.find(St=>St.sensitivity==="public"&&St.description)||K.find(St=>St.description),Ee=(ne==null?void 0:ne.description)||"";return Ee.match(/setting for (.+)\.$/i)?`${wi(C)} settings and runtime behaviour.`:Ee}function De(C){const K=[...new Set(Qe(C).map(ne=>ne.apply_mode))];return K.length===1?K[0]:K.includes("restart")?"restart":K.includes("activation_required")?"activation_required":K[0]||"restart"}function ze(C){const K=[...new Set(Qe(C).map(ne=>ds(ne.apply_mode)))];return K.length?K.length===1?K[0]:`Mixed apply behaviour: ${K.join(" · ")}`:""}function Ye(C){return Go(Qe(C))}function Dt(C){var K;return Object.hasOwn(O.value,C)?O.value[C]:(K=e.value)==null?void 0:K[C]}function et(){const C=Dt("mcp")||{},K=Object.keys(C.servers||{}).length;return`${C.enabled?"Globally enabled":"Globally disabled"} · ${K} configured server${K===1?"":"s"}.`}function Rt(C,K){return K.split(".").reduce((ne,Ee)=>ne==null?void 0:ne[Ee],C)}function Pt(C){const K=S.value;return Qe(C).filter(ne=>Xf(ne.path)?!1:ne.path.split(".").length<=2?!0:!ne.path.includes(".*")).map(ne=>({...ne,key:ne.path.split(".").at(-1),value:Rt(K,ne.path),apply_details:Go([ne]),editor:ne.path==="agents.final_warning_iterations"?"warning-chips":null}))}function Ts(C){const K=C.path.split(".");return K.length>2?K.slice(0,2).join("."):null}function Vs(C){const K=new Map;for(const ne of Pt(C)){const Ee=Ts(ne),Je=Ee||`${C}.__root`;K.has(Je)||K.set(Je,{key:Je,path:Ee,entries:[]}),K.get(Je).entries.push(ne)}return[...K.values()].map(ne=>{const Ee=ne.entries.find(Je=>Je.group_description);return{...ne,label:ne.path?wi(ne.path.split(".").at(-1)):null,description:(Ee==null?void 0:Ee.group_description)||null,apply_details:Go(ne.entries),runtime_summaries:zt(ne.entries)}})}function Gs(C){return{save:C.save_effect||(C.apply_mode==="dormant"?"Saving records this value in config.yml.":"Saving records this value and validates the section."),runtime:C.runtime_effect||{live_read:"Odin reads the saved value during current work.",live_apply:"Odin reloads this setting without a restart.",live_for_new_work:"New work uses the saved value; existing work keeps its snapshot.",restart:"Odin keeps using its startup value until a clean restart.",activation_required:"Odin keeps the current behavior until you enable this feature separately.",legacy_control:"Odin keeps the existing compatibility behavior until you apply this choice.",dormant:"This version of Odin does not use the saved value. Restarting will not activate it."}[C.apply_mode]||"Effective runtime state is not currently observable."}}function zt(C){const K=new Map;for(const ne of C){const Ee=Gs(ne),Je=`${ne.apply_mode}|${Ee.save}|${Ee.runtime}`;K.has(Je)||K.set(Je,{key:Je,label:ds(ne.apply_mode),save:Ee.save,runtime:Ee.runtime})}return[...K.values()]}function ta(C){if(Ws(C))return C.runtime_effect||C.activation_policy||"";if(C.apply_mode==="activation_required"){const K=C.activation_policy||C.runtime_effect;return K?`Not active after saving. No activation control exists in this release. ${K}`:"Not active after saving; no activation control exists in this release."}return""}function Ws(C){return C.action_available===!0&&!!(C.action_label&&C.action_endpoint)}async function Pa(C){if(Ws(C))try{if(Fe(C.path))throw new Error("Save this setting before applying its action.");const K=String(C.action_method||"POST").toLowerCase(),ne={post:z.post.bind(z),put:z.put.bind(z),delete:z.del.bind(z)}[K];if(!ne)throw new Error("Unsupported configuration action");await ne(C.action_endpoint,C.action_body||void 0),await gn(),As("success",`${C.action_label} completed.`)}catch(K){As("error",K.message||`${C.action_label} failed`)}}function hs(C,K){return[C.label,C.path,C.description,...C.aliases||[]].filter(Boolean).join(" ").toLowerCase().includes(K)}function $a(C){const K=T.value.trim().toLowerCase();return K?Qe(C).filter(ne=>hs(ne,K)):[]}function Es(C){const K=Qe(C);if(L.value!=="all"&&!K.some(Ee=>Ee.apply_state===L.value))return!1;const ne=T.value.trim().toLowerCase();return!ne||`${Te(C)} ${C}`.toLowerCase().includes(ne)?!0:K.some(Ee=>hs(Ee,ne))}function Ct(C,K){return Qe(C).filter(ne=>ne.apply_state===K).length}function ls(C){return C==="all"?oe.value:be.value.filter(K=>K.apply_state===C).length}function sa(C){const K=C.sections.flatMap(ne=>Qe(ne));return{fields:K.length,modified:P.value.filter(ne=>C.sections.includes(ne.path.split(".")[0])).length,pending_restart:K.filter(ne=>ne.apply_state==="pending_restart").length,invalid:K.filter(ne=>ne.apply_state==="invalid").length,dormant:K.filter(ne=>ne.apply_state==="dormant").length}}function ba(C){var K;return Object.hasOwn(O.value,C)&&!Oi((K=e.value)==null?void 0:K[C],O.value[C])}function Fe(C){return P.value.some(K=>K.path===C||K.path.startsWith(`${C}.`))}function ue(C){E.value=C,T.value="",L.value="all";try{localStorage.setItem(jg,C)}catch{}}function Ce(C){L.value=C}function qe(){T.value="",L.value="all"}function Ge(C){var K;return((K=xe.value.find(ne=>ne.sections.includes(C)))==null?void 0:K.sections)||[]}function It(C){const K=Ge(C),ne=K.find(Ee=>F.value[Ee]===!0);return ne||K.find(Ee=>F.value[Ee]!==!1)||null}function ot(C){return T.value&&!ce.value&&Es(C)?!0:ce.value?It(C)===C:Object.hasOwn(F.value,C)?F.value[C]===!0:!0}function pn(C){const K=!ot(C);if(ce.value){const ne={...F.value};for(const Ee of Ge(C))ne[Ee]===!0&&(ne[Ee]=!1);ne[C]=K,F.value=ne;return}F.value={...F.value,[C]:K}}function pi(){N.value.push(Gn(O.value)),N.value.length>$C&&N.value.shift(),M.value=[]}function Fa(){n.value||G.value&&(pi(),O.value={},ee.value={},$.value=!1)}function fi(C,K=!1){const ne=Date.now();if(K&&ae.path===C&&ne-ae.atJe-Ee);H.value="",kt(C,ne)}function $n(C,K){kt(C,(C.value||[]).filter(ne=>ne!==K))}function Ua(C){return C.apply_mode==="live_read"?"Odin reads the saved file value on next use.":C.apply_mode==="live_for_new_work"?"New work uses the saved file value.":C.apply_mode==="live_apply"?C.apply_handler?`Apply the saved value through ${C.apply_handler}.`:"Apply it through its dedicated owner page or endpoint.":C.apply_mode==="restart"?"Restart Odin for the saved collection to take effect.":C.apply_mode==="activation_required"?"Saving does not enable it. No activation control exists in this release.":C.apply_mode==="dormant"?"This release does not use the saved collection.":"Follow the runtime details shown for this setting."}function vn(C){return C.type==="array"&&Array.isArray(C.value)&&!C.structured_container&&!C.structured_container_child&&C.sensitivity==="public"&&C.value.every(K=>["string","number","boolean"].includes(typeof K))}function Ie(C){const K=String(te.value[C.path]??"").trim();if(!K)return;const ne=[...new Set([...C.value||[],K])];te.value={...te.value,[C.path]:""},kt(C,ne)}function D(C,K){kt(C,(C.value||[]).filter(ne=>ne!==K))}function pe(C,K){var Ee;if(!C)return null;if((Ee=C.enum)!=null&&Ee.length&&!C.enum.includes(K))return`Choose one of: ${C.enum.join(", ")}`;if(C.path==="agents.final_warning_iterations"&&(!Array.isArray(K)||!K.length))return"Add at least one warning threshold.";const ne=C.constraints||{};if((C.type==="integer"||C.type==="number")&&typeof K=="number"){if(ne.minimum!==void 0&&Kne.maximum)return`Must be at most ${ne.maximum}${C.unit?` ${C.unit}`:""}`}return null}function ke(C){return de.value[C.path]||null}function Be(C){const K=`${C}.`;return Object.keys(de.value).some(ne=>ne===C||ne.startsWith(K))}function Ue(){n.value||N.value.length&&(M.value.push(Gn(O.value)),O.value=N.value.pop(),ee.value={},I.value={},ae={path:null,at:0})}function Ke(){n.value||M.value.length&&(N.value.push(Gn(O.value)),O.value=M.value.pop(),ee.value={},I.value={},ae={path:null,at:0})}function $t(){!G.value||ge.value||($.value=!0,re.value=!1)}function gt(){$.value=!1}function Ot(){Fa()}function ds(C){return DC[C]||wi(C||"unknown")}function ut(C){return`apply-${String(C||"unknown").replaceAll("_","-")}`}function ua(C){return`cfgc-field-${C.replace(/[^a-zA-Z0-9_-]/g,"-")}`}function hn(C){return`${ua(C)}-input`}function _o(C){const K=document.getElementById(ua(C))||document.getElementById(ua(C.split(".").slice(0,2).join(".")));K==null||K.scrollIntoView({behavior:"smooth",block:"center"})}function As(C,K){g.value={type:C,message:K},window.setTimeout(()=>{var ne;((ne=g.value)==null?void 0:ne.message)===K&&(g.value=null)},3500)}function vc(){x.value=!1,L.value="pending_restart",T.value="";const C=MC(a.value);C&&(C.scrollTop=0)}function hc(){x.value=!1}function wo(C=1800){Q&&window.clearTimeout(Q),Q=window.setTimeout(gc,C)}async function gc(){if(w.value){if(V+=1,V>45){w.value=!1,_.value="Odin did not return with the new startup settings within 90 seconds.";return}try{if(t.value=await gl(),Xe.value===0){w.value=!1,_.value=null,As("success","Odin restarted and the saved startup settings are active.");return}}catch{}wo(2e3)}}async function bc(){if(!w.value){_.value=null;try{await z.post("/api/restart",{}),w.value=!0,V=0,x.value=!1,wo()}catch(C){_.value=C.message||"Odin could not schedule a restart."}}}async function ko(){if(!(!G.value||ge.value||n.value)){n.value=!0;try{const C=zC(e.value,O.value),K=await z.put("/api/config",C);e.value=K,O.value={},N.value=[],M.value=[],ee.value={},$.value=!1;try{t.value=await gl(),b.value=null,x.value=Xe.value>0,As("success",Xe.value?`Configuration saved. ${Xe.value} setting${Xe.value===1?"":"s"} still use startup values.`:"Configuration saved. Apply status has been refreshed.")}catch(ne){b.value=ne.message||"Unknown metadata error.",As("error",`Configuration saved, but apply status could not be refreshed: ${b.value}`)}}catch(C){As("error",C.message||"Configuration could not be saved")}finally{n.value=!1}}}async function yc(){if(!n.value){n.value=!0,y.value=null;try{t.value=await gl(),b.value=null}catch(C){y.value=`Image model status could not be refreshed: ${C.message||"Unknown error"}`}finally{n.value=!1}}}async function xc(C,K){if(n.value||!["follow","pin"].includes(K)||!C.length||C.some(Ee=>{var Je,St;return!k.includes(Ee)||!((St=(Je=t.value)==null?void 0:Je.image_model_defaults)!=null&&St[Ee])}))return;n.value=!0,y.value=null;let ne=!1;try{const Ee=await z.post("/api/config/image-models",{operations:Object.fromEntries(C.map(Je=>[Je,K])),expected_revision:t.value.image_model_revision});ne=!0;for(const Je of C){const St=`image.openai.${Je}`,Ks=Rt(e.value,St),gs=Rt(Ee.config,St),yn=Rs=>!Object.hasOwn(Rs,"image")||!Oi(Rt(Rs,St),Ks)?Rs:Ba(Rs,St.split("."),gs);O.value=yn(O.value),N.value=N.value.map(yn),M.value=M.value.map(yn),e.value=Ba(e.value,St.split("."),gs)}t.value={...t.value,image_model_defaults:Ee.image_model_defaults,image_model_revision:Ee.image_model_revision},As("success","Image model defaults saved. Unrelated drafts were not saved.")}catch(Ee){y.value=`Image model operation failed: ${Ee.message||"Unknown error"}. Refresh status before retrying if the outcome is uncertain.`}try{t.value=await gl(),b.value=null}catch(Ee){const Je=`Image model status could not be refreshed: ${Ee.message||"Unknown error"}`;b.value=Je,y.value=ne?`Image model defaults were saved, but ${Je}`:`${y.value} ${Je}`}finally{n.value=!1}}async function gn(){var C,K;if(!(G.value||n.value)){s.value=!0,A.value=null;try{const ne=await z.get("/api/config"),Ee=await gl();await m(),e.value=ne,t.value=Ee,b.value=null;const Je=xe.value;if(Je.some(St=>St.key===E.value)||(E.value=((C=Je[0])==null?void 0:C.key)||zi[0].key),ce.value){const Ks=(((K=Je.find(gs=>gs.key===E.value))==null?void 0:K.sections)||[]).find(gs=>F.value[gs]===!0);F.value=Ks?{...F.value,[Ks]:!0}:{}}}catch(ne){A.value=ne.message||"Unknown configuration error"}finally{s.value=!1}}}function bn(C){if($.value||!(C.ctrlKey||C.metaKey))return;const K=C.target;K instanceof HTMLElement&&(K.matches("input, textarea, select")||K.isContentEditable)||(!C.shiftKey&&C.key.toLowerCase()==="z"?(C.preventDefault(),Ue()):(C.key.toLowerCase()==="y"||C.shiftKey&&C.key.toLowerCase()==="z")&&(C.preventDefault(),Ke()))}function mi(C){ce.value=C.matches}Gt(F,C=>{try{localStorage.setItem(Hg,JSON.stringify(C))}catch{}},{deep:!0});let Fn=!1;function vi(){Fn||(Fn=!0,document.addEventListener("keydown",bn))}function al(){Fn&&(Fn=!1,document.removeEventListener("keydown",bn))}return st(()=>{var C;gn(),vi(),Z=window.matchMedia("(max-width: 760px)"),mi(Z),(C=Z.addEventListener)==null||C.call(Z,"change",mi)}),cs(vi),es(al),es(()=>{o.value=""}),wt(()=>{var C;o.value="",al(),(C=Z==null?void 0:Z.removeEventListener)==null||C.call(Z,"change",mi),Q&&window.clearTimeout(Q)}),{listenerState:i,listenerConsent:l,listenerCredential:o,listenerSaving:r,listenerMessage:c,listenerError:d,listenerStatusError:u,listenerChoiceChanged:he,listenerStatusTone:fe,listenerStatusLabel:me,listenerAuthorizationCopy:we,listenerConfiguredSourceCopy:J,listenerRunningCopy:ie,saveListenerConsent:h,armKeydown:vi,disarmKeydown:al,handleKeydown:bn,config:e,meta:t,loading:s,saving:n,error:A,toast:g,metaRefreshError:b,restartPromptOpen:x,restartScheduled:w,restartError:_,configMain:a,imageModelError:y,imageModelLeaves:k,setImageModelDefaults:xc,refreshImageModelMetadata:yc,searchQuery:T,healthFilter:L,activeCategory:E,reviewOpen:$,mobileOverflowOpen:re,warningThresholdInput:H,arrayInputs:te,healthFilters:ve,visibleCategories:xe,displayGroups:Ae,reviewGroups:$e,sectionCount:le,fieldCount:oe,hasChanges:G,changeCount:X,changedSectionCount:_e,hasDraftErrors:ge,canUndo:j,canRedo:Y,globalFilterActive:U,reviewRestartCount:We,pendingRestartCount:Xe,pendingRestartFields:je,healthCount:ls,categoryStats:sa,selectCategory:ue,selectHealthFilter:Ce,clearFilters:qe,sectionLabel:Te,sectionDescription:W,sectionFieldCount:Le,sectionHealthCount:Ct,sectionApplySummary:ze,sectionApplyDetails:Ye,sectionEntries:Pt,fieldGroups:Vs,sectionSearchHits:$a,mcpConfigSummary:et,fieldRuntimeCopy:Gs,fieldSpecificRuntimeNote:ta,hasHonestAction:Ws,runFieldAction:Pa,hasHostsCollection:se,hostsConfigSummary:Re,sectionChanged:ba,fieldChanged:Fe,isSectionExpanded:ot,toggleSection:pn,discardAllDrafts:Fa,setFieldValue:kt,setNumberFieldValue:Pn,numberInputValue:xa,beginInputEdit:$s,endTextInputEdit:Wt,endInputEdit:ya,addWarningThreshold:mn,removeWarningThreshold:$n,isScalarArray:vn,addScalarArrayItem:Ie,removeScalarArrayItem:D,fieldError:ke,sectionHasErrors:Be,undo:Ue,redo:Ke,openReview:$t,closeReview:gt,mobileCancel:Ot,applyModeLabel:ds,applyClass:ut,compactValue:BC,formatValue:UC,structuredApplyCopy:Ua,fieldId:ua,fieldInputId:hn,focusField:_o,fetchConfig:gn,saveConfig:ko,restartOdin:bc,restartLater:hc,reviewPendingRestart:vc}}},VC=/^\d{15,25}$/;function Gg(e){return String((e==null?void 0:e.display_name)||(e==null?void 0:e.username)||(e==null?void 0:e.id)||"Unknown user")}const Wg={props:{members:{type:Array,default:()=>[]},excludedIds:{type:Array,default:()=>[]},placeholder:{type:String,default:"Search Discord users…"},ariaLabel:{type:String,default:"Search Discord users"},optionsId:{type:String,required:!0},autofocus:{type:Boolean,default:!1},showAddButton:{type:Boolean,default:!1}},emits:["select"],template:` -
- - -
- - -
-
- `,setup(e,{emit:t}){const s=f(""),a=f(!1),n=f(0),i=f(null),l=B(()=>new Set((e.excludedIds||[]).map(String))),o=B(()=>{const _=s.value.toLowerCase().trim();return(e.members||[]).filter(T=>l.value.has(String(T.id))?!1:_?p(T).toLowerCase().includes(_)||String(T.username||"").toLowerCase().includes(_)||String(T.id).includes(_):!0)}),r=B(()=>{const _=s.value.trim();return o.value.length===0&&VC.test(_)&&!l.value.has(_)?_:""}),c=B(()=>o.value.length+(r.value?1:0)),d=B(()=>!!s.value.trim()&&!!(o.value[n.value]||r.value&&n.value===o.value.length)),u=B(()=>{if(a.value){if(o.value[n.value])return`${e.optionsId}-${n.value}`;if(r.value&&n.value===o.value.length)return`${e.optionsId}-raw`}});function p(_){return Gg(_)}function m(){a.value=!0,n.value=0}function h(){m()}function y(){const _=Math.max(c.value-1,0);n.value=Math.min(n.value+1,_)}function k(){n.value=Math.max(n.value-1,0)}function A(){const _=o.value[n.value];_?g(_):r.value&&n.value===o.value.length&&b(r.value)}function g(_){b(String(_.id))}function b(_){t("select",_),s.value="",a.value=!1,n.value=0}function x(){a.value=!1}function w(){setTimeout(x,150)}return st(()=>{e.autofocus&&Ut(()=>{var _;return(_=i.value)==null?void 0:_.focus()})}),{query:s,open:a,highlightedIndex:n,input:i,filteredMembers:o,rawId:r,activeOptionId:u,selectableValue:d,memberName:p,openOptions:m,onInput:h,highlightNext:y,highlightPrevious:k,selectHighlighted:A,selectMember:g,selectId:b,closeOptions:x,onBlur:w}}};function em(e,t,s){var a;return((a=e==null?void 0:e.config)==null?void 0:a[t])!=null?e.config[t]:s==null?void 0:s[t]}const GC={components:{DiscordUserCombobox:Wg},template:` -
-
-

Discord Channels

- -
-
-
-
-

Gateway connection

- {{ connectionState.label }} -
-

{{ connectionState.detail }}

-
- Configured - No usable credential - {{ connection.credential_preferred_storage ? 'Stored in the preferred environment format' : (connection.credential_usable ? 'Legacy storage; replace to migrate' : 'Not stored') }} -
-
-
-
- - -
-
- - -
-
- -
-

- For ordinary conversational intake, allowed users and channels are absolute global gates; guild and channel settings cannot readmit a blocked message. - Explicitly allowed test webhooks bypass the user gate. Require-mention and bot-response behavior - resolve channel → guild → global. An explicit mention bypasses the ignored-bot list, but the effective respond-to-bots policy still applies. - Changes take effect immediately. -

- -
-
-
-
-

{{ error }}

- -
- -
-
-
-
-

Global defaults

-

Allowed users and channels are absolute. Require-mention and bot-response values are defaults that guild or channel settings may override.

-
-
- -
- - -
-
-
-
{{ editor.label }} -
- ? -

{{ editor.description }}

-
-
-
- {{ globalItemLabel(editor, item) }} - - - No entries -
-
- -
-
- - -
-
-
- -
- -
- -
-
- -
- {{ guild.name.charAt(0) }} -
-
- {{ guild.name }} - {{ guild.member_count }} members -
-
-
- - - - -
-
- - -
-
- - - - - - - - - - - - - - - - - - - - - -
ChannelCategoryEnabledRequire @mentionRespond to botsOverride
#{{ ch.name }}{{ ch.category || '—' }} - - - - - - - - inherit -
-
-
-
-
-
- `,setup(){const e=f([]),t=f({persisted:!1,credential_usable:!1,credential_preferred_storage:!1,connection:{state:"unavailable",detail:"Connection status unavailable"}}),s=f(""),a=f(!1),n=f(!1),i=f(null);let l=null;const o=f(!0),r=f(null),c=f({}),d=f(null),u=f(null),p=f(!1),m=f(null),h=f({}),y=f([]);let k=0;const A=Object.freeze([{key:"allowed_users",label:"Allowed users",description:"Absolute gate for ordinary conversational intake. Guild/channel settings cannot readmit blocked users; explicitly allowed test webhooks bypass this gate.",placeholder:"Search users",userAutocomplete:!0},{key:"channels",label:"Allowed channels",description:"Absolute gate for ordinary conversational intake. Guild/channel settings cannot readmit blocked channels.",placeholder:"Search channels"},{key:"ignore_bot_ids",label:"Ignored bot IDs",description:"Ignored unless the bot explicitly mentions Odin; the effective respond-to-bots policy still applies.",placeholder:"Bot ID",userAutocomplete:!0}]),g=B(()=>{var Y,xe,S,P;const oe=String(((xe=(Y=t.value)==null?void 0:Y.connection)==null?void 0:xe.state)||"").toLowerCase();return{...{connected:{key:"connected",label:"Connected",badgeClass:"badge-success"},connecting:{key:"connecting",label:"Connecting",badgeClass:"badge-warning"},disconnected:{key:"disconnected",label:"Disconnected",badgeClass:""},detached:{key:"disconnected",label:"Disconnected",badgeClass:""},detaching:{key:"disconnected",label:"Disconnected",badgeClass:""},stopped:{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"},failed:{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"}}[oe]||{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"},detail:((P=(S=t.value)==null?void 0:S.connection)==null?void 0:P.detail)||"Connection status unavailable"}}),b=B(()=>JSON.stringify(d.value)!==JSON.stringify(u.value)),x=B(()=>new Map(y.value.map(oe=>[String(oe.id),oe])));function w(oe){return oe.config&&oe.config.enabled!==void 0?oe.config.enabled:!0}function _(oe){return em(oe,"require_mention",d.value)}function T(oe){return em(oe,"respond_to_bots",d.value)}function L(oe){return oe.config&&Object.keys(oe.config).length>0}function E(oe){c.value[oe]=!c.value[oe]}function F(oe){const ve=oe.discord||{};return{allowed_users:[...ve.allowed_users||[]],channels:[...ve.channels||[]],respond_to_bots:!!ve.respond_to_bots,require_mention:!!ve.require_mention,ignore_bot_ids:[...ve.ignore_bot_ids||[]]}}async function O({showLoading:oe=!0}={}){const ve=++k;oe&&(o.value=!0),r.value=null;try{const j=await z.get("/api/discord/guilds");ve===k&&(e.value=j)}catch(j){ve===k&&(r.value=j.message)}finally{oe&&ve===k&&(o.value=!1)}}async function I(){try{t.value=await z.get("/api/discord/connection"),i.value=null}catch(oe){i.value=oe.message}}async function H(oe,ve=null){if(!n.value){n.value=!0,i.value=null;try{const j={operation:oe};ve!==null&&(j.token=ve),t.value=await z.post("/api/discord/connection",j),oe==="credentials"&&(s.value="",a.value=!1)}catch(j){i.value=j.message||"Connection update failed."}finally{n.value=!1}}}function te(){if(!(!a.value||!s.value))return H("credentials",s.value)}function ee(){return H("connect")}function N(){return H("detach")}async function M(){o.value=!0,r.value=null;try{const[oe,ve,j]=await Promise.all([z.get("/api/discord/guilds"),z.get("/api/discord/members").catch(()=>[]),z.get("/api/config")]),Y=F(j),xe=b.value;d.value=Y,xe||(u.value=JSON.parse(JSON.stringify(Y))),y.value=ve,e.value=oe,m.value=null}catch(oe){r.value=oe.message}finally{o.value=!1}}let $=Promise.resolve();const re=f(new Set);function ce(oe,ve){const j=new Set(re.value);j.add(oe),re.value=j;const Y=$.then(ve);return $=Y.catch(()=>{}),Y.finally(()=>{const xe=new Set(re.value);xe.delete(oe),re.value=xe})}function Z(oe,ve,j,Y){const xe=(Y==null?void 0:Y.target)??null;return ce(`guild:${oe}:${ve}`,async()=>{try{await z.put("/api/discord/guild/"+oe+"/config",{[ve]:j}),await O({showLoading:!1})}catch(S){r.value=S.message,xe&&typeof j=="boolean"&&(xe.checked=!j)}})}function Q(oe,ve,j,Y,xe){const S=(xe==null?void 0:xe.target)??null;return ce(`channel:${oe}:${j}`,async()=>{try{await z.put("/api/discord/channel/"+oe+"/config",{[j]:Y}),await O({showLoading:!1})}catch(P){r.value=P.message,S&&typeof Y=="boolean"&&(S.checked=!Y)}})}function ae(oe,ve){return ce(`channel:${oe}:clear`,async()=>{try{await z.put("/api/discord/channel/"+oe+"/config",{clear:!0}),await O({showLoading:!1})}catch(j){r.value=j.message}})}function V(oe,ve){const j=String(ve);if(!oe.userAutocomplete)return j;const Y=x.value.get(j);return Y?Gg(Y):j}function be(oe,ve=null){const j=String(ve??h.value[oe]??"").trim();!j||u.value[oe].includes(j)||(u.value[oe]=[...u.value[oe],j],h.value={...h.value,[oe]:""})}function Oe(oe,ve){u.value[oe]=u.value[oe].filter(j=>j!==ve)}async function le(){if(!(!b.value||p.value)){p.value=!0,m.value=null;try{const ve=(await z.put("/api/config",{discord:u.value})).discord||u.value;d.value={allowed_users:[...ve.allowed_users||[]],channels:[...ve.channels||[]],respond_to_bots:!!ve.respond_to_bots,require_mention:!!ve.require_mention,ignore_bot_ids:[...ve.ignore_bot_ids||[]]},u.value=JSON.parse(JSON.stringify(d.value))}catch(oe){m.value=oe.message||"Global defaults could not be saved."}finally{p.value=!1}}}return st(()=>{M(),I(),l=window.setInterval(I,5e3)}),wt(()=>{l!==null&&window.clearInterval(l),l=null}),{guilds:e,loading:o,error:r,expanded:c,globalDraft:u,globalSaving:p,globalError:m,globalArrayInputs:h,globalMembers:y,globalListEditors:A,globalChanged:b,guildEnabled:w,guildMention:_,guildBots:T,hasOverride:L,toggleGuild:E,fetchAll:M,fetchGuilds:O,setGuildConfig:Z,setChannelConfig:Q,clearOverride:ae,mutationPending:re,globalItemLabel:V,addGlobalItem:be,removeGlobalItem:Oe,saveGlobalDefaults:le,connection:t,connectionState:g,connectionToken:s,connectionTokenDirty:a,connectionBusy:n,connectionError:i,saveDiscordCredentials:te,connectDiscord:ee,detachDiscord:N}}},Js=e=>e==null?e:JSON.parse(JSON.stringify(e));function WC({applyDefault:e,applyUser:t,applyDelete:s,onDefaultConfirmed:a=()=>{},onDefaultRollback:n=()=>{},onUserConfirmed:i=()=>{},onUserRollback:l=()=>{},onUserDeleted:o=()=>{},onError:r=()=>{}}){let c=Promise.resolve(),d=0,u=0;const p=new Map;let m=null;const h=new Map;function y(_){d+=1;const T=c.then(_,_);return c=T.catch(()=>{}),T}function k(_,T){m=Js(_),h.clear();for(const[L,E]of Object.entries(T||{}))h.set(L,Js(E))}function A(_){const T=Js(_),L=++u;return y(async()=>{try{await e(Js(T)),m=Js(T),L===u&&a(Js(T))}catch(E){L===u&&(n(Js(m)),r(E,{kind:"default"}))}})}function g(_,T){const L=Js(T),E=(p.get(_)||0)+1;return p.set(_,E),y(async()=>{try{await t(_,Js(L)),h.set(_,Js(L)),E===p.get(_)&&i(_,Js(L))}catch(F){E===p.get(_)&&(l(_,Js(h.get(_)??null)),r(F,{kind:"user",uid:_}))}})}function b(_){const T=(p.get(_)||0)+1;return p.set(_,T),y(async()=>{try{await s(_),h.delete(_),T===p.get(_)&&o(_)}catch(L){T===p.get(_)&&(l(_,Js(h.get(_)??null)),r(L,{kind:"delete",uid:_}))}})}async function x(){for(;;){const _=c;if(await _,_===c)return d}}async function w(_){for(;;){const T=await x(),L=await _();if(T===d)return L}}return{seed:k,saveDefault:A,saveUser:g,deleteUser:b,whenIdle:x,readSnapshot:w,get revision(){return d}}}const KC={components:{DiscordUserCombobox:Wg,DiscordIdentity:Dn},template:` -
-
-

Host Access Control

- -
-

- Control which hosts each user can execute commands on and set per-user defaults. - Users without an explicit entry fall back to the default policy. -

- -
-
-
-
-

{{ error }}

- -
- -
- -
-

Default Policy

-

Applied to users without an explicit host access entry.

-
{{ chip }}
- -

Default host: {{ defaultPolicy.default_host || 'None' }}

-
- - -
-
-

User Overrides

- - -
- - -
-
- - -
-
- - -
- - - - - - - - - - - - - - - - - - -
UserTierHostsDefault HostRemove
- -
Invalid tier: {{ permissions.invalid_overrides[uid] }}
{{ chip }}
- {{ (users[uid] || defaultPolicy).default_host || 'None' }} - - -
-
-

No user overrides configured. All users follow the default policy.

-
-
- - - -
-

Edit hosts

{{ editor === 'default' ? 'Default Policy' : (getMember(editor)?.display_name || editor) }}

-
- - -
- -

No matching hosts.

-
- - -

{{ defaultHostError }}

- -
-
-
-
-
-
- `,setup(){const e=f(!0),t=f(""),s=f(null),a=f([]),n=f({}),i=f({allowed_hosts:[],default_host:""}),l=f({}),o=f([]),r=f(!1),c=f([]),d=f({overrides:{},config_tiers:{},invalid_overrides:{}}),u=["admin","user","guest"],p=f({}),m=f(""),h=f(null),y=f(null),k=f(!1),A=f(""),g=f(!1),b=f(""),x=f(""),w=B(()=>a.value.filter(j=>`${j} ${n.value[j]||""}`.toLowerCase().includes(b.value.toLowerCase()))),_=B(()=>a.value.filter(j=>{var Y,xe;return((Y=y.value)==null?void 0:Y.allow_all)||((xe=y.value)==null?void 0:xe.allowed_hosts.includes(j))})),T=B(()=>y.value?y.value.default_host&&!_.value.includes(y.value.default_host)?_.value.length?"Choose a new default from the allowed hosts before saving.":"No hosts are allowed. Select None for the default before saving.":g.value&&_.value.length&&!y.value.default_host?"Choose a new default from the allowed hosts before saving.":"":""),L=B(()=>[...new Set([...Object.keys(l.value),...Object.keys(d.value.overrides||{}),...Object.keys(d.value.config_tiers||{}),...Object.keys(d.value.invalid_overrides||{})])]),E=B(()=>L.value.filter(j=>{var Y,xe;return`${((Y=O(j))==null?void 0:Y.display_name)||""} ${((xe=O(j))==null?void 0:xe.username)||""} ${j}`.toLowerCase().includes(x.value.toLowerCase())})),F=B(()=>{const j={};for(const Y of c.value)j[Y.id]=Y;return j});function O(j){return F.value[j]||null}function I(j){if(!j||j.allow_all)return"All hosts";const Y=j.allowed_hosts||[];return Y.length>3?`${Y.slice(0,3).join(", ")} +${Y.length-3}`:Y.join(", ")||"No hosts"}function H(j){if(j.allow_all)return["All hosts"];const Y=j.allowed_hosts||[];return[...Y.slice(0,2),...Y.length>2?[`+${Y.length-2}`]:[],...Y.length?[]:["None"]]}function te(j){return o.value.includes(j)?H(l.value[j]):["Inherit policy"]}async function ee(j){var xe,S;if(k.value)return;const Y=j==="default"?i.value:l.value[j]||i.value;m.value=j,y.value={...Y,allowed_hosts:[...Y.allowed_hosts]},g.value=!!Y.default_host,b.value="",A.value="",await Ut(),y.value&&!((xe=h.value)!=null&&xe.open)&&((S=h.value)==null||S.showModal())}function N(){var j;k.value||((j=h.value)==null||j.close(),y.value=null,m.value="")}function M(j){if(j.key!=="Tab")return;const Y=[...h.value.querySelectorAll("button, input, select")].filter(P=>!P.matches(":disabled")),xe=Y[0],S=Y.at(-1);if(!xe){j.preventDefault();return}j.shiftKey&&document.activeElement===xe?(j.preventDefault(),S.focus()):!j.shiftKey&&document.activeElement===S&&(j.preventDefault(),xe.focus())}async function $(){var xe;if(!y.value||k.value||T.value)return;const j=m.value,Y={...y.value,allowed_hosts:[...y.value.allowed_hosts]};k.value=!0,A.value="",await Ut(),(xe=h.value)!=null&&xe.open&&h.value.focus();try{j==="default"?(i.value=Y,await ae.saveDefault(Y)):(l.value[j]=Y,o.value.includes(j)||o.value.push(j),await ae.saveUser(j,Y))}finally{k.value=!1}A.value||N()}function re(j){var Y,xe;return((Y=d.value.overrides)==null?void 0:Y[j])||((xe=d.value.config_tiers)==null?void 0:xe[j])||"default"}async function ce(j,Y){var xe;try{if(Y==="default"){if(!((xe=d.value.overrides)!=null&&xe[j]))return;await z.del(`/api/permissions/user/${encodeURIComponent(j)}`)}else await z.put(`/api/permissions/user/${encodeURIComponent(j)}`,{tier:Y});Y==="default"?delete d.value.overrides[j]:d.value.overrides[j]=Y,Se.success("Permission tier updated")}catch(S){Se.error(S.message||"Failed to update tier"),await be()}}async function Z(j){const Y=p.value[j];if(Y)try{await z.post(`/api/permissions/user/${encodeURIComponent(j)}/repair`,{tier:Y}),delete d.value.invalid_overrides[j],d.value.overrides[j]=Y}catch(xe){Se.error(xe.message||"Failed to repair tier")}}function Q(j,Y){return j?j.allowed_hosts===null||j.allowed_hosts===void 0?{allowed_hosts:[...Y],default_host:j.default_host||"",allow_all:!0}:{allowed_hosts:j.allowed_hosts,default_host:j.default_host||"",allow_all:!1}:{allowed_hosts:[...Y],default_host:Y[0]||"",allow_all:!0}}const ae=WC({applyDefault:async j=>{const Y=j.allow_all?null:j.allowed_hosts;await z.put("/api/host-access/default-policy",{allowed_hosts:Y,default_host:j.default_host})},applyUser:async(j,Y)=>{const xe=Y.allow_all?null:Y.allowed_hosts;await z.put(`/api/host-access/user/${j}`,{allowed_hosts:xe,default_host:Y.default_host})},applyDelete:j=>z.del(`/api/host-access/user/${j}`),onDefaultConfirmed:()=>Se.success("Default policy updated"),onDefaultRollback:j=>{j&&(i.value=j)},onUserConfirmed:j=>{const Y=O(j);Se.success(`Updated access for ${Y?Y.display_name:j}`)},onUserRollback:(j,Y)=>{const xe={...l.value};Y?xe[j]=Y:delete xe[j],l.value=xe,Y||(o.value=o.value.filter(S=>S!==j))},onUserDeleted:j=>{const Y={...l.value};delete Y[j],l.value=Y,o.value=o.value.filter(xe=>xe!==j)},onError:(j,Y)=>{var S;k.value&&(A.value=j.message||"Failed to save. Your draft is retained; try again.");const xe=Y.uid?` ${((S=O(Y.uid))==null?void 0:S.display_name)||Y.uid}`:"";Se.error(`${j.message||"Failed to save"} — reverted${xe}`)}});let V=0;async function be(){const j=++V;e.value=!0,t.value="";try{const Y=await ae.readSnapshot(()=>z.get("/api/host-access"));if(j!==V)return;s.value=Y,a.value=Y.available_hosts||[],n.value=Y.host_descriptions||{},i.value=Q(Y.default_policy,a.value);const xe=Y.users||{};o.value=Object.keys(xe);const S={};for(const[P,G]of Object.entries(xe))S[P]=Q(G,a.value);l.value=S,ae.seed(i.value,S),d.value=await z.get("/api/permissions/tiers")}catch(Y){j===V&&(t.value=Y.message||"Failed to fetch host access data")}finally{j===V&&(e.value=!1)}try{const Y=await z.get("/api/discord/members")||[];j===V&&(c.value=Y)}catch{j===V&&(c.value=[])}}function Oe(){r.value=!0}function le(j){!/^\d{15,25}$/.test(j)||l.value[j]||(l.value[j]={allowed_hosts:[...a.value],default_host:a.value[0]||"",allow_all:!1},o.value.includes(j)||o.value.push(j),ae.saveUser(j,l.value[j]),r.value=!1)}async function oe(j){const Y=O(j);await is({title:"Remove user override",message:`Remove the host access override for ${Y?Y.display_name:j}? They will fall back to the default policy.`,confirmLabel:"Remove",danger:!0})&&(await ae.deleteUser(j),l.value[j]||Se.success(`Removed override for ${Y?Y.display_name:j}`))}st(be);function ve(){var j;(j=h.value)==null||j.close(),y.value=null,m.value=""}return es(ve),wt(ve),{loading:e,error:t,data:s,availableHosts:a,hostDescriptions:n,defaultPolicy:i,users:l,showAddUser:r,members:c,hostOverrideIds:o,fetchData:be,getMember:O,openAddUser:Oe,addUserById:le,deleteUser:oe,permissions:d,validTiers:u,repairTiers:p,repairTier:Z,tierValue:re,setTier:ce,editor:m,hostQuery:b,userQuery:x,filteredHosts:w,allUserIds:L,visibleUserIds:E,hostSummary:I,hostChips:H,visibleHostChips:te,hostDialog:h,draft:y,saving:k,saveError:A,requiresDefault:g,defaultHostOptions:_,defaultHostError:T,openEditor:ee,closeEditor:N,saveEditor:$,trapEditorFocus:M}}},JC={template:` -
-
-

Managed Hosts

Live inventory, pinned SSH trust, and connection health.

-
-
-
{{ error }}
-
-
Deletion is blocked by these references:
-
  • {{ item.kind }} · {{ item.location }}
-
-
- - - -
-
- - - - - - - - -
AliasEndpointTrustStateLast testActions
{{ host.alias }}
{{ host.description || 'No description' }}
{{ host.ssh_user }}@{{ host.address }}:{{ host.port }}
{{ host.os }}
{{ host.trust_state }}{{ host.targetable ? 'Targetable' : 'Disabled' }} · draining{{ host.last_test?.detail || 'Not tested this process' }}
-
-
No managed hosts configured.
- -
-

{{ editing ? 'Edit host' : 'Add host' }} · Step {{ step }} of 5

-
- - - - - - - - -
-

Install Odin's public key for {{ form.ssh_user }}@{{ form.address }}. Odin never accepts passwords or private keys here.

{{ keyInfo.public_key }}
{{ keyInfo.authorized_keys_command }}

Fingerprint: {{ keyInfo.fingerprint }}. {{ keyInfo.permissions }}

-

Verify the host key out of band. Run ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the target and paste its SHA256 fingerprint.

Observed: {{ observed.join(', ') }}
-

Test non-interactive authentication and platform identity before activation.

{{ JSON.stringify(testResult,null,2) }}
-

Activation is live. Users with allowed_hosts: null gain this host automatically. Review grants on Host Access after saving.

Open Host Access
-
-
-
`,setup(){const e=f([]),t=f(!1),s=f(""),a=f([]),n=f(!1),i=f(!1),l=f(1),o=f(""),r=f(!1),c=f(null),d=f(""),u=f([]),p=f(!1),m=f(null),h=f(""),y=()=>({alias:"",address:"",port:22,ssh_user:"root",os:"linux",description:"",trust_mode:"pinned",enabled:!0,confirm_local:!1,confirm_tofu:!1}),k=f(y()),A=B(()=>["127.0.0.1","localhost","::1"].includes(k.value.address));async function g(){t.value=!0,s.value="";try{const ee=await z.get("/api/hosts");e.value=ee.hosts||[],o.value=ee.default_host||"",r.value=!!ee.tofu_enabled}catch(ee){s.value=ee.message}finally{t.value=!1}}async function b(){try{await z.post("/api/hosts/settings",{default_host:o.value,allow_host_tofu:r.value}),Se.success("Host settings saved and published live"),await g()}catch(ee){Se.error(ee.message)}}function x(){d.value="",u.value=[],p.value=!1,m.value=null,c.value=null,h.value="",l.value=1,n.value=!0}function w(){i.value=!1,k.value=y(),x()}function _(ee){i.value=!0,k.value={...y(),...ee},x()}async function T(){try{c.value=await z.get("/api/hosts/public-key")}catch(ee){Se.error(ee.message)}}async function L(ee){try{const N=await z.post("/api/hosts/"+encodeURIComponent(ee.alias)+"/import-legacy",{});i.value=!0,k.value={...y(),...ee,trust_mode:"pinned"},x(),d.value=N.candidate_token,u.value=N.fingerprints||[],h.value=u.value.join(` -`),l.value=4,Se.info("Imported existing known_hosts trust. Test before activation.")}catch(N){Se.error(N.message)}}async function E(){try{const ee=h.value.split(/\s+/).filter(Boolean),N={...k.value,expected_fingerprints:ee,candidate_fingerprints:u.value},M=await z.post("/api/hosts/candidates",N);if(d.value=M.candidate_token,u.value=M.fingerprints||[],k.value.trust_mode==="tofu"&&N.candidate_fingerprints.length===0){k.value.confirm_tofu=!1,Se.info("Fingerprint scanned. Review it, tick confirmation, then scan again.");return}l.value=4}catch(ee){Se.error(ee.message)}}async function F(){var ee,N;p.value=!1,m.value=null;try{const M=await z.post("/api/hosts/candidates/"+d.value+"/test",{});p.value=!!M.tested,m.value=M.last_test,p.value&&(l.value=5)}catch(M){const $=(ee=M.data)==null?void 0:ee.last_test;$&&typeof $=="object"&&!Array.isArray($)&&(m.value=$);const re=(N=m.value)==null?void 0:N.detail;Se.error(typeof re=="string"&&re.trim()?re:M.message)}}async function O(){try{await z.post("/api/hosts/candidates/"+d.value+"/commit",{}),Se.success("Host saved and published live"),n.value=!1,await g()}catch(ee){Se.error(ee.message)}}async function I(ee){try{await z.post("/api/hosts/"+encodeURIComponent(ee.alias)+"/enabled",{enabled:!ee.enabled}),await g()}catch(N){Se.error(N.message)}}async function H(ee){var N;if(await is("Delete host "+ee.alias+"? Dependencies will block deletion.")){a.value=[];try{await z.del("/api/hosts/"+encodeURIComponent(ee.alias)),await g()}catch(M){a.value=Array.isArray((N=M.data)==null?void 0:N.pending_references)?M.data.pending_references:[],Se.error(M.message)}}}async function te(ee){if(await is("Force revoke "+ee.alias+"? Remote outcomes may be unknown."))try{await z.post("/api/hosts/"+encodeURIComponent(ee.alias)+"/force-revoke",{}),await g()}catch(N){Se.error(N.message)}}return st(g),{hosts:e,loading:t,error:s,pendingReferences:a,wizard:n,editing:i,step:l,defaultHost:o,tofuEnabled:r,form:k,isLocal:A,keyInfo:c,candidate:d,observed:u,tested:p,testResult:m,fingerprintsText:h,load:g,saveSettings:b,beginAdd:w,beginEdit:_,loadKey:T,importLegacy:L,prepare:E,testConnection:F,commit:O,toggle:I,remove:H,forceRevoke:te}}},ZC={components:{DiscordIdentity:Dn},template:` -
-
-

API Tokens

- -
-

- Manage API tokens for programmatic access, orchestrators, and web-chat identity. - Each token has its own user identity, permission tier, and host access scope. -

- -
-
-
-
-

{{ error }}

- -
- -
- - - -
-
- Token Created - -
-

Copy this token now. It will not be shown again.

-
- {{ newToken }} - -
-
- - -
-
-

Create Token

- -
-
-
-
- -
-
- -
-
- -
-
- -
-
-
- -
- -
-
-
- -

Used when API requests don't specify a host.

-
-
- -
-
- -
-
-
- - -
-

Active Tokens

-
- No API tokens configured. -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
User IDLabelTierHostsDefaultToolsSourceActions
{{ t.label || '—' }} - {{ t.tier }} - - {{ t.allowed_hosts === null || t.allowed_hosts === undefined ? 'default policy' : t.allowed_hosts.length === 0 ? 'no host access' : t.allowed_hosts.join(', ') }} - - {{ t.default_host || 'policy' }} - - {{ t.allowed_tools && t.allowed_tools.length ? t.allowed_tools.length + ' tools' : 'tier default' }} - - - {{ t.source === 'config' ? 'config.yml' : 'dynamic' }} - - - - - - read-only
-
-
- - - -
- -
- `,setup(){const e=f(!0),t=f(""),s=f(null),a=f([]),n=f([]),i=f("missing"),l=f(!1),o=f(!1),r=f(null),c=f(null),d=f(!1);async function u(O){var I;try{await z._request("DELETE","/api/tokens/unusable/"+O.index,{reason:O.reason,...O.user_id===void 0?{}:{user_id:O.user_id}}),Se.success("Unusable token entry removed"),await A()}catch(H){Se.error(((I=H.data)==null?void 0:I.error)||H.message||"Failed to remove unusable token")}}const p=f({user_id:"",username:"",tier:"admin",label:"",host_mode:"default",allowed_hosts:[],default_host:"",allowed_tools_str:""}),m=f({username:"",tier:"admin",label:"",host_mode:"default",allowed_hosts:[],default_host:"",allowed_tools_str:""}),h=B(()=>p.value.host_mode==="select"?p.value.allowed_hosts:p.value.host_mode==="none"?[]:a.value),y=B(()=>m.value.host_mode==="select"?m.value.allowed_hosts:m.value.host_mode==="none"?[]:a.value);function k(O){return O==="admin"?"text-xs px-1.5 py-0.5 rounded bg-red-900/50 text-red-400":O==="user"?"text-xs px-1.5 py-0.5 rounded bg-blue-900/50 text-blue-400":"text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400"}async function A(){e.value=!0,t.value="";try{const O=await z.get("/api/tokens");s.value=O.tokens||[],a.value=O.available_hosts||[],n.value=O.invalid_entries||[],i.value=O.store_status||"missing"}catch(O){t.value=O.message||"Failed to load tokens"}finally{e.value=!1}}function g(O){return!O||!O.trim()?[]:O.split(",").map(I=>I.trim()).filter(Boolean)}function b(O,I){const H=p.value.allowed_hosts;if(I&&!H.includes(O)&&H.push(O),!I){const te=H.indexOf(O);te>=0&&H.splice(te,1)}}function x(O,I){const H=m.value.allowed_hosts;if(I&&!H.includes(O)&&H.push(O),!I){const te=H.indexOf(O);te>=0&&H.splice(te,1)}}async function w(){var O;o.value=!0;try{const I=g(p.value.allowed_tools_str),H=p.value.host_mode,te=H==="none"?[]:H==="select"?p.value.allowed_hosts:null,ee={user_id:p.value.user_id.trim(),username:p.value.username.trim()||"API",tier:p.value.tier,label:p.value.label.trim(),allowed_tools:I.length?I:[]};te!==null&&(ee.allowed_hosts=te),ee.default_host=p.value.default_host||"";const N=await z.post("/api/tokens",ee);r.value=N.token,p.value={user_id:"",username:"",tier:"admin",label:"",host_mode:"default",allowed_hosts:[],default_host:"",allowed_tools_str:""},l.value=!1,Se.success("Token created"),await A()}catch(I){Se.error(((O=I.data)==null?void 0:O.error)||I.message||"Failed to create token")}finally{o.value=!1}}function _(O){c.value=O;const I=O.allowed_hosts;let H="default";I==null?H="default":Array.isArray(I)&&I.length===0?H="none":Array.isArray(I)&&(H="select"),m.value={username:O.username||"",tier:O.tier||"admin",label:O.label||"",host_mode:H,allowed_hosts:Array.isArray(I)?[...I]:[],default_host:O.default_host||"",allowed_tools_str:(O.allowed_tools||[]).join(", ")}}async function T(){var O;if(c.value){d.value=!0;try{const I=g(m.value.allowed_tools_str),H=m.value.host_mode,te={username:m.value.username,tier:m.value.tier,label:m.value.label,allowed_tools:I};H==="none"?te.allowed_hosts=[]:H==="select"?te.allowed_hosts=m.value.allowed_hosts:te.allowed_hosts=null,te.default_host=m.value.default_host||"",await z.put("/api/tokens/"+encodeURIComponent(c.value.user_id),te),c.value=null,Se.success("Token updated"),await A()}catch(I){Se.error(((O=I.data)==null?void 0:O.error)||I.message||"Failed to update")}finally{d.value=!1}}}async function L(O){var H;if(await is({title:"Regenerate token",message:`Regenerate token for ${O.username||O.user_id}? The old token will stop working immediately.`,confirmLabel:"Regenerate",danger:!0}))try{const te=await z.post("/api/tokens/"+encodeURIComponent(O.user_id)+"/regenerate");r.value=te.token,Se.success("Token regenerated")}catch(te){Se.error(((H=te.data)==null?void 0:H.error)||te.message||"Failed to regenerate")}}async function E(O){var H;if(await is({title:"Delete token",message:`Delete token for ${O.username||O.user_id}? This cannot be undone.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/tokens/"+encodeURIComponent(O.user_id)),Se.success("Token deleted"),await A()}catch(te){Se.error(((H=te.data)==null?void 0:H.error)||te.message||"Failed to delete")}}async function F(){if(r.value)try{await navigator.clipboard.writeText(r.value),Se.success("Copied to clipboard")}catch{Se.error("Copy failed — select and copy manually")}}return st(A),{loading:e,error:t,tokens:s,availableHosts:a,invalidEntries:n,storeStatus:i,showCreate:l,creating:o,newToken:r,editing:c,saving:d,createForm:p,editForm:m,removeUnusable:u,createDefaultHostOptions:h,editDefaultHostOptions:y,fetchData:A,tierBadge:k,toggleCreateHost:b,toggleEditHost:x,createToken:w,startEdit:_,saveEdit:T,confirmRegenerate:L,confirmDelete:E,copyToken:F}}};function QC(e,t=()=>"unknown"){const s=e==null?void 0:e.quota;return!s||typeof s!="object"?[]:["primary","secondary"].flatMap(a=>{const n=s[a],i=Number(n==null?void 0:n.used_percent),l=Number(n==null?void 0:n.window_minutes);if(!n||!Number.isFinite(i)||i<0||!Number.isFinite(l)||l<=0)return[];let o;return l===300?o="5-hour usage limit":l===10080?o="Weekly usage limit":Number.isFinite(l)&&l>0?o=l<60?`${l}-minute usage limit`:l<1440?`${Math.round(l/60)}-hour usage limit`:`${Math.round(l/1440)}-day usage limit`:o=a==="primary"?"Primary usage limit":"Secondary usage limit",[{key:a,label:o,remaining:Math.round(Math.max(0,Math.min(100,100-i))),limitReached:i>=100,statusLabel:i>=100?"Limit reached":"",resetLabel:n.resets_at==null?"unknown":t(n.resets_at)}]})}function YC(e){return!!(e!=null&&e.quota_check_failed)}const XC=Object.freeze(["enabled","model","reasoning_effort","agent_reasoning_effort"]),eT=Object.freeze(["request_timeout_seconds","stream_stall_timeout_seconds","retry","connection_pool","context_compression","context_budget_overrides","context_utilization"]),tT=Object.freeze(["enabled","base_url","model","max_tokens","num_ctx"]),sT=Object.freeze(["enabled","base_url","model","reasoning_effort"]);function yo(e,t){return Object.fromEntries(t.map(s=>[s,e[s]]))}function bl(e,t={}){const s=yo(e,sT);return t.includeApiKey&&(s.api_key=e.api_key),s}function Ko(e){return yo(e,["request_timeout_seconds","stream_stall_timeout_seconds","preset","model_profiles","context_utilization","openrouter"])}function yl(e){return yo(e,XC)}function xl(e){return yo(e,eT)}function _l(e,{includeApiKey:t=!1}={}){const s=yo(e,tT);return t&&(s.api_key=e.api_key),s}function Jo(e){return{timeout:e.timeout}}function Zo(e,t=500){let s=null;const a=(...n)=>{s&&clearTimeout(s),s=setTimeout(()=>{s=null,e(...n)},t)};return a.pending=()=>s!==null,a.cancel=()=>{s&&(clearTimeout(s),s=null)},a}const aT={template:` -
-
-
-

LLM Configuration

-

Provider routing, model selection, credentials, and Codex accounts.

-

Serving through Codex

-

Serving through OpenAI-compatible

-

Serving through Ollama

-

Selected model is unavailable

-
- -
- -
-
-
- -
- - -
-

Model Selection

-

Enabled providers supply model choices. Unavailable saved selections are retained until you choose another model.

-
-
- -
-
- -

Main model is configured on a disabled provider. Enable that provider below or choose another model.

- - -
-
- -

Agent model is configured on a disabled provider. Enable that provider below or choose another model.

- - - -
-

- Per-spawn reasoning: none / low / medium / high / xhigh / max. Each choice maps to the selected model's native capability; omission uses its allowlist default. -

- -

{{ allowlistSummary }} · Used when Agent model is Auto

-
- - - -
-
- -

Auxiliary model is configured on a disabled provider. Enable that provider below or choose another model.

-

Used for compaction, reflection, consolidation, and background follow-up. Its output feeds sessions and memory, so choose deliberately.

-
-
-
- - -
-
-

Codex (OpenAI)

-
-
- Connected -
- -
-
- -
- {{ auxData.unavailable_reason }} -
-
- - Advanced Settings - Transport, retries, and model-aware context policy - -
-
-
TransportRequest lifecycle limits
- - -
-
-
Retry policyTransient request failures
- - - -
-
-
Connection poolShared Codex HTTP transport
-

- Saved values need a restart. This process still uses {{ llmStatus.codex.effective_connection_pool?.max_connections }} connections with {{ llmStatus.codex.effective_connection_pool?.keepalive_timeout }}s keepalive. -

-

- Saved values match this process. Future changes take effect after restart. -

-

Future changes take effect after restart; current process values are unavailable.

- - -
-
-
Context compressionLong-conversation compaction
-

- Saved values need a restart. This process still uses compression {{ llmStatus.codex.effective_context_compression?.enabled ? 'on' : 'off' }}, {{ formatContextCeiling(llmStatus.codex.effective_context_compression?.max_context_chars) }}, and {{ llmStatus.codex.effective_context_compression?.keep_recent_iterations }} recent iterations. -

-

- Saved values match this process. Future changes take effect after restart. -

-

Future changes take effect after restart; current process values are unavailable.

- - - -
-
-
-
- Context budgets - Capability, working-set policy, and temporary evidence -
- -
-

- Overrides describe usable input capability. Utilization is the working-set policy applied to larger models; budgets at or below 272,000 tokens keep legacy behavior. Learned clamps are temporary evidence from successful overflow recovery, not operator policy. -

-
- Loading context budgets… -
- - -
- -
-
-
-

Authentication

-

- OAuth credentials for ChatGPT subscription. Supports automatic refresh and pool rotation. -

- -
-
-
-
- {{ codexError }} - -
- -
- -
- No Codex credentials configured. Use the device login below or run - python scripts/codex_login.py -
-
- {{ codexData.account_count }} account{{ codexData.account_count !== 1 ? 's' : '' }} configured, - active: #{{ codexData.current_index + 1 }} -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
#LabelEmailPlanQuotaStatusActiveActions
{{ a.index + 1 }} - - - - - - - {{ a.email || '—' }} - - {{ a.plan_type }} - - — - -
-
-
- {{ block.label }} - {{ block.remaining }}% remaining -
-
-
-
- - {{ block.statusLabel ? block.statusLabel + ' · ' : '' }}Resets {{ block.resetLabel }} - -
-
- Quota check failed - — - - checked {{ quotaAge(a.quota.observed_at) }} ago - -
- Error - Expired - Rate limited - Active - - Current - - - - -
-
-
- - -
-
-

Add Account (Device Login)

- -
-
-
-
-

1. Open: {{ deviceInfo.verify_url }}

-

2. Enter code: {{ deviceInfo.user_code }}

-
-
-
Waiting...
- -
-
-
-

Authenticated as {{ deviceResult.email }}.

- -
-
-

{{ deviceError }}

- -
-
-
-
-
- -
-

Additional providers

Connect compatible endpoints or Ollama. Their models appear above automatically.

- -
-
-

OpenAI-compatible endpoint

-
-
Status unavailable
-
- Connected - Unreachable -
- -
-
-
-
- -

{{ compatibleCatalogueStatus }}

-
-
- API Key -
- Configured - -
-
-
-
-
-
- Advanced SettingsStreaming timeouts -
-
- - -
-
- -
-
-
OpenRouter provider preferencesOrder uses lowercase endpoint tags, never display names. A per-model pin is preferred; disable fallbacks below to make it a hard pin.
- - - - - - -

require_parameters is always sent when tools or reasoning are present. Measured cached-token ratios appear in the selected model list after real calls.

-
- -
-
-
- {{ compatibleStatus.health.error }} -
-
- - -
-
-

Ollama (Local/Remote)

-
-
Status unavailable
-
- Connected - Unreachable -
- -
-
-
-
- -
-
- -
-
- -

Prompt tokens sent as Ollama num_ctx. Higher values require more host memory.

-
-
- -
-
- -
-
-
- Advanced SettingsProvider request timeout -
-
- -
- -
-
-
- {{ ollamaStatus.health.error }} -
-
-
-
- -
- `,setup(){const e=f(!0),t=f(null),s=15e3;let a=null,n=!1,i=null,l=null,o=!1;const r={codexBasic:null,codexAdvanced:null,ollamaBasic:null,ollamaAdvanced:null,compatibleBasic:null,compatibleAdvanced:null,auxiliary:null,mainModel:null,agents:null},c=v=>JSON.stringify(v),d=(v,R)=>{r[v]=c(R)},u=(v,R)=>r[v]!==null&&r[v]!==c(R),p=f(null),m=f(!1),h=f({main:"",main_capability:"medium",agent_capability:"adaptive"}),y=f(""),k=["none","low","medium","high","xhigh","max"],A=["none","low","medium","high","xhigh","max"],g=f({enabled:!1,model:"gpt-6.1-sol",reasoning_effort:"xhigh",agent_reasoning_effort:"auto",request_timeout_seconds:3600,stream_stall_timeout_seconds:180,retry:{max_retries:3,base_delay:1,max_delay:30},connection_pool:{max_connections:10,keepalive_timeout:30},context_compression:{enabled:!0,max_context_chars:null,keep_recent_iterations:30},context_budget_overrides:{},context_utilization:60}),b=["gpt-6-astra","gpt-6.1-sol","gpt-6-sol","gpt-6-luna","gpt-5.6-sol","gpt-5.6-terra","gpt-5.6-luna"],x=v=>!v||v==="auto"?null:v.startsWith("compat:")?"compat":v.startsWith("ollama:")?"ollama":v.includes(":")?null:"codex",w=v=>{var R,q;return((q=(R=p.value)==null?void 0:R[v==="compat"?"openai_compatible":v])==null?void 0:q.enabled)===!0},_=B(()=>w("codex")&&!w("compat")&&!w("ollama")),T=v=>{const R=v==="main"?h.value.main:v==="agent"?Fe.value.model:X.value.enabled?X.value.model:"";return L(R)},L=v=>{var q,ye;const R=x(v);return!!(R&&((ye=(q=p.value)==null?void 0:q[R==="compat"?"openai_compatible":R])==null?void 0:ye.enabled)===!1)},E=v=>{var q;const R=v==="main"?h.value.main:v==="agent"?Fe.value.model:X.value.model;return`Configured: ${((q=I.value.find(ye=>ye.ref===R))==null?void 0:q.name)||R.replace(/^(compat|ollama):/,"")} (provider disabled)`},F=v=>{var Ne,rt;const R=[],q=x(((Ne=p.value)==null?void 0:Ne.main_model)||h.value.main);q===v&&R.push("Main"),(Fe.value.model===""?q:x(Fe.value.model))===v&&R.push("Agent");const ye=((rt=p.value)==null?void 0:rt.auxiliary)||X.value;return ye.enabled&&x(ye.model)===v&&R.push("Auxiliary"),Fe.value.model==="auto"&&ot.value.filter(Lt=>x(Lt)!==v).map(Lt=>I.value.find(wa=>wa.ref===Lt)).filter(Lt=>Lt&&N(Lt)&&!(Lt.provider==="compat"&&ut.value&&!Lt.ref.slice(7).includes("/"))).length===0&&R.push("Agent Auto (zero effective choices)"),R};async function O(v,R){if(R!==!1||!w(v))return!0;const q=F(v);return is({title:`Disable ${v==="compat"?"OpenAI-compatible":v==="codex"?"Codex":"Ollama"}?`,message:`${q.length?`This provider is used by ${q.join(", ")}. `:""}Saved selections and ranked allowlist entries remain configured, not replaced. Continue?`,confirmLabel:"Disable provider",danger:!0})}const I=B(()=>{var wa,xn,at,Kt;const v=p.value||{},R=v.model_catalogue||v.model_catalog||{},q=Pe=>{var Ft,pt,ka;return((Ft=W.value.model_profiles)==null?void 0:Ft[Pe])||((ka=(pt=W.value.openrouter)==null?void 0:pt.catalogue_profiles)==null?void 0:ka[Pe])||null},ye=(Pe,Ft,pt)=>Ft.map(ka=>{var fp,mp;const Ac=typeof ka=="string"?ka:ka.name,Sa=Pe==="compat"?q(Ac):null;return{ref:Pe==="codex"?ka:`${Pe}:${Ac}`,name:Ac,provider:Pe,available:!!(pt!=null&&pt.enabled&&(Pe==="codex"?pt.configured:(fp=pt.health)!=null&&fp.healthy)),unavailable_reason:pt!=null&&pt.enabled?pt!=null&&pt.configured?!((mp=pt==null?void 0:pt.health)!=null&&mp.healthy)&&Pe!=="codex"?"unreachable":"":"not configured":"disabled",capability:Pe==="codex"||Sa!=null&&Sa.supports_reasoning?"reasoning":Sa!=null&&Sa.supports_thinking_mode?"thinking":"none",efforts:Sa==null?void 0:Sa.supported_efforts,profile:Sa}}),Ne=[...R.codex||ye("codex",b,v.codex),...R.compat||R.openai_compatible||[],...R.ollama||ye("ollama",zt.value,v.ollama)].map(Pe=>typeof Pe=="string"?{ref:Pe,name:Pe,provider:"codex",available:w("codex"),capability:"reasoning"}:{...Pe,available:w(Pe.provider)&&Pe.available,unavailable_reason:w(Pe.provider)?Pe.unavailable_reason:"disabled"}),rt=Pe=>{const Ft=Ne.findIndex(pt=>pt.ref===Pe.ref);if(Ft===-1)Ne.push(Pe);else{const pt=Ne[Ft];Ne[Ft]={...pt,...Pe,available:pt.available,unavailable_reason:pt.unavailable_reason,agent_available:pt.agent_available}}};for(const Pe of ye("compat",Es.value,v.openai_compatible))rt(Pe);if(As.value&&xa.value.length)for(const Pe of xa.value){const Ft=`compat:${Pe.id}`;rt({ref:Ft,name:Pe.name||Pe.id,provider:"compat",available:((wa=Ne.find(pt=>pt.ref===Ft))==null?void 0:wa.available)??!1,unavailable_reason:(xn=Ne.find(pt=>pt.ref===Ft))!=null&&xn.available?"":((at=Ne.find(pt=>pt.ref===Ft))==null?void 0:at.unavailable_reason)||"unavailable",capability:Pe.supports_reasoning?"reasoning":"none",efforts:k.filter(pt=>{var ka;return(ka=Pe.supported_efforts)==null?void 0:ka.includes(pt)}),agent_available:!!(Pe.agent_eligible&&Pe.profile&&((Kt=Ne.find(pt=>pt.ref===Ft))==null?void 0:Kt.agent_available)===!0),agent_unavailable_reason:Pe.agent_unavailable_reason||""})}const Lt=new Set(Ne.map(Pe=>Pe.ref));for(const Pe of[h.value.main,Fe.value.model,...X.value.enabled?[X.value.model]:[],...ot.value])if(Pe&&Pe!=="auto"&&!Lt.has(Pe)&&x(Pe)){const Ft=x(Pe);Ne.unshift({ref:Pe,name:Pe.replace(/^(compat|ollama):/,""),provider:Ft,available:!1,unavailable_reason:w(Ft)?"unavailable":"disabled",capability:Ft==="codex"?"reasoning":"none"}),Lt.add(Pe)}return Ne.filter(Pe=>!ut.value||Pe.provider!=="compat"||Pe.ref.slice(7).includes("/")||[h.value.main,Fe.value.model,X.value.enabled&&X.value.model,...ot.value].includes(Pe.ref)).map(Pe=>Pe.efforts?{...Pe,efforts:k.filter(Ft=>Pe.efforts.includes(Ft)&&!G(Pe.ref,Ft))}:Pe)}),H=()=>[h.value.main,Fe.value.model,X.value.enabled&&X.value.model],te=B(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].filter(([v])=>w(v)).map(([v,R])=>({id:v,label:R,models:I.value.filter(q=>{if(q.provider!==v||L(q.ref)&&H().includes(q.ref))return!1;const ye=_.value?"":y.value.trim().toLowerCase();return!ye||`${q.name} ${q.ref}`.toLowerCase().includes(ye)})})).filter(v=>v.models.length)),ee=B(()=>{var v;return((v=te.value.find(R=>R.id==="codex"))==null?void 0:v.models)||[]}),N=v=>w(v.provider)&&v.available&&v.agent_available!==!1,M=v=>v.available?`${v.name}${v.agent_available===!1?` (${v.agent_unavailable_reason||"not agent-eligible"})`:""}`:Y(v),$=B(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].filter(([v])=>w(v)).map(([v,R])=>({id:v,label:R,models:I.value.filter(q=>q.provider===v&&!(v==="compat"&&ut.value)&&`${q.ref} ${q.name}`.toLowerCase().includes(Pn.value.trim().toLowerCase()))})).filter(v=>v.models.length)),re=B(()=>I.value.find(v=>v.ref===h.value.main)),ce=B(()=>I.value.find(v=>v.ref===Fe.value.model)),Z=v=>I.value.find(R=>R.ref===v),Q=v=>{const R=Z(v);return(R==null?void 0:R.provider)==="codex"?k.filter(q=>!G(v,q)):(R==null?void 0:R.efforts)||[]},ae=v=>Ge.value.find(R=>qe(R)===v),V=(v,R)=>{const q=ae(v);return typeof q=="string"?"":(q==null?void 0:q[R])||""},be=B(()=>ot.value.map(v=>Z(v)).filter(v=>v&&N(v)&&!(v.provider==="compat"&&ut.value&&!v.ref.slice(7).includes("/")))),Oe=B(()=>[...new Set(be.value.map(v=>v.capability!=="reasoning"?v.capability||"none":v.provider==="codex"?"codex_reasoning":"compatible_reasoning"))]),le=B(()=>Oe.value.length>1),oe=B(()=>{var R;if(Fe.value.model!=="auto")return((R=ce.value)==null?void 0:R.capability)||"none";if(le.value)return"mixed";const v=Oe.value[0]||"none";return v.endsWith("_reasoning")?"reasoning":v}),ve=B(()=>{var v;return Fe.value.model==="auto"?k:((v=ce.value)==null?void 0:v.efforts)||k}),j=B(()=>oe.value==="thinking"?Fe.value.thinking_mode??g.value.agent_reasoning_effort??"":g.value.agent_reasoning_effort??""),Y=v=>`${v.name}${v.available?"":` (${v.unavailable_reason||"unavailable"})`}`,xe=B(()=>{const v=g.value.model;return v&&!b.includes(v)?[v,...b]:b}),S=B(()=>{const v=Fe.value.model;return v&&v!=="auto"&&!b.includes(v)?[v,...b]:b}),P={"gpt-5.4":["max"],"gpt-5.4-mini":["max"],"gpt-6-astra":["none"],"gpt-6.1-sol":["none"]},G=(v,R)=>!!v&&!!R&&(P[v]||[]).includes(R),he=B(()=>{const v=Fe.value.model;return v&&!v.includes(":")?v:null}),fe=v=>!G(g.value.model,v)&&!(g.value.agent_reasoning_effort===""&&G(he.value,v)),me=v=>{const R=Fe.value.model;return R==="auto"?!0:!G(R||g.value.model,v)},we=B(()=>{const v=g.value.agent_reasoning_effort;return v==="auto"?null:v||g.value.reasoning_effort}),J=v=>G(v,g.value.reasoning_effort)||Fe.value.model===""&&G(v,we.value),ie=v=>G(v,we.value),X=f({enabled:!1,model:"gpt-6-luna"}),_e=f({unavailable_reason:null}),U=B(()=>{const v=X.value.model;return v&&!b.includes(v)?[v,...b]:b});function de(v){const R=v.target.value;X.value.enabled=R!=="",R!==""&&(X.value.model=R),R.startsWith("compat:")&&ut.value?nl(il(R),Co(R)).then(()=>Un()).catch(q=>He(q.message||"Failed to prepare OpenRouter model","error")):Un()}const ge=f(!1),Ae=f({codex:!1,ollama:!1,compatible:!1}),$e=f(null),We=f(!1),je=f(""),Xe=f(null),it=f(!1);let Qe=0;const se=B(()=>{var v;return Object.entries(((v=$e.value)==null?void 0:v.models)||{}).map(([R,q])=>{var ye,Ne,rt;return{model:R,floor:q.floor,override:q.override,effectiveBudget:(ye=q.effective)==null?void 0:ye.effective_budget,configuredPrimaryChars:(Ne=q.configured)==null?void 0:Ne.primary_chars,primaryChars:(rt=q.effective)==null?void 0:rt.primary_chars,provenance:q.provenance,clampExpiresAt:q.clamp_expires_at,densityPriorMilli:q.density_prior_milli,densityScope:q.density_scope,workloadCalibration:q.workload_calibration}})}),Re=B(()=>{var v;return((v=$e.value)==null?void 0:v.clamps)||[]}),Le=B(()=>{var v,R;return((R=(v=$e.value)==null?void 0:v.models)==null?void 0:R[g.value.model])||null}),Te=f({enabled:!1,base_url:"",model:"",api_key:"",max_tokens:4096,num_ctx:32768,timeout:300}),W=f({enabled:!1,base_url:"https://api.deepseek.com/v1",api_key:"",model:"deepseek-v4-flash",request_timeout_seconds:3600,stream_stall_timeout_seconds:180,preset:"deepseek",reasoning_effort:"medium",model_profiles:{},context_utilization:75,openrouter:{order:[],allow_fallbacks:!0,quantizations:[],sort:null,data_collection:null,reasoning_effort:"medium",model_pins:{},catalogue_profiles:{}}}),De=f(!1),ze=f(!1),Ye=f(!1),Dt=f(!1),et=f(!1),Rt=f(!1);function Pt(){var R,q,ye;return!!(((R=t.value)==null?void 0:R.contains(document.activeElement))&&((ye=(q=document.activeElement)==null?void 0:q.matches)==null?void 0:ye.call(q,'input, textarea, select, [contenteditable="true"]'))||ue.value||To.value!==null||it.value||De.value||ze.value||Ce.value||Ye.value||Dt.value||et.value||ge.value||gi.pending()||bi.pending()||zn.pending()||Un.pending()||u("codexBasic",yl(g.value))||u("codexAdvanced",xl(g.value))||u("ollamaBasic",_l(Te.value))||u("ollamaAdvanced",Jo(Te.value))||u("compatibleBasic",bl(W.value))||u("compatibleAdvanced",Ko(W.value))||u("auxiliary",X.value)||u("mainModel",h.value.main)||u("agents",Fe.value))}function Ts(){r.codexBasic=c(yl(g.value)),r.codexAdvanced=c(xl(g.value)),r.ollamaBasic=c(_l(Te.value)),r.ollamaAdvanced=c(Jo(Te.value)),r.compatibleBasic=c(bl(W.value)),r.compatibleAdvanced=c(Ko(W.value)),r.auxiliary=c(X.value),r.mainModel=c(h.value.main)}const Vs=f({configured:null}),Gs=f(!1),zt=f([]),ta=f(""),Ws=f(!1),Pa=f(!1),hs=f({configured:null}),$a=f(!1),Es=f([]),Ct=B(()=>ut.value?xa.value.map(v=>v.id):Es.value),ls=f(""),sa=f(!1),ba=f(!1),Fe=f({model:"auto",thinking_mode:null,auto_model_allowlist:[]}),ue=f(!1),Ce=f(!1),qe=v=>typeof v=="string"?v:v==null?void 0:v.model,Ge=B(()=>(Fe.value.auto_model_allowlist||[]).map(v=>typeof v=="string"?v:{...v}).filter(v=>qe(v))),It=B(()=>{var v,R,q,ye;if(!w("codex")&&w("compat")){const Ne=(R=(v=p.value)==null?void 0:v.openai_compatible)==null?void 0:R.model;return Ne?[`compat:${Ne}`]:[]}if(!w("codex")&&!w("compat")&&w("ollama")){const Ne=(ye=(q=p.value)==null?void 0:q.ollama)==null?void 0:ye.model;return Ne?[`ollama:${Ne}`]:[]}return w("codex")?b:[]}),ot=B(()=>Ge.value.length?Ge.value.map(qe):It.value),pn=B(()=>be.value.length),pi=B(()=>{var v;return`${(v=Fe.value.auto_model_allowlist)!=null&&v.length?"Allowlist":"Default"}: ${ot.value.length} configured, ${pn.value} effective models`}),Fa=new Map;function fi(){ue.value=!1,So()}const Ba=v=>{const R=x(v);if(R&&!w(R))return"provider disabled";if(ut.value&&v.startsWith("compat:")&&!v.slice(7).includes("/"))return"OpenRouter requires a namespaced vendor/model ID; this is a direct-endpoint profile.";const q=I.value.find(ye=>ye.ref===v);return q?N(q)&&!(q.provider==="compat"&&ut.value&&!v.slice(7).includes("/"))?"":q.agent_unavailable_reason||q.unavailable_reason||"Not agent-eligible":"Model is absent from the current endpoint catalogue."},fn=v=>{if(gn.value.has(v))return Fn(v);const R=I.value.find(ye=>ye.ref===v),q=(R==null?void 0:R.hint_metadata)||{};return[q.hint||q.hint_derived,q.as_of&&`as of ${q.as_of}`,q.scope_note,q.evidence&&`Evidence: ${q.evidence}`,R&&ap(R)].filter(Boolean).join(" · ")||"No catalogue hint. Add an operator hint below."},kt=f(null),$s=f(!1),Wt=f("");let ya=0;const xa=B(()=>{var v;return(((v=kt.value)==null?void 0:v.models)||[]).map(R=>{var ye,Ne,rt;const q=((ye=W.value.model_profiles)==null?void 0:ye[R.id])||R.profile||((rt=(Ne=W.value.openrouter)==null?void 0:Ne.catalogue_profiles)==null?void 0:rt[R.id])||(R.context_length>0&&R.max_completion_tokens>0?{total_window_tokens:R.context_length,max_output_tokens:R.max_completion_tokens}:null);return{...R,profile:q}})}),Pn=f(""),mn=f(""),$n=f(!0),Ua=f(!0),vn=f(!0),Ie=f(!1),D=f(null),pe=f(""),ke=f(null),Be=f(""),Ue=f([]),Ke=f(!1),$t=f("throughput"),gt=(v,R,q=null)=>{const ye=v[R],Ne=q&&ye&&typeof ye=="object"?ye[q]:ye,rt=Number(Ne);return Number.isFinite(rt)?rt:null},Ot=B(()=>{const v=[...Ue.value],R=$t.value;return v.sort((q,ye)=>{var Pe,Ft;if(R==="quantization")return String(q.quantization).localeCompare(String(ye.quantization));const[Ne,rt,Lt]=R==="throughput"?["throughput_last_30m","p50",!0]:R==="latency_p99"?["latency_last_30m","p99",!1]:R==="cache_price"?["cache_read_per_token",null,!1]:["prompt_per_token",null,!1],wa=(Pe=q.pricing)==null?void 0:Pe[Ne],xn=(Ft=ye.pricing)==null?void 0:Ft[Ne],at=rt?gt(q,Ne,rt):wa==null?null:Number(wa),Kt=rt?gt(ye,Ne,rt):xn==null?null:Number(xn);return Number.isFinite(at)?Number.isFinite(Kt)?Lt?Kt-at:at-Kt:-1:1})}),ds=B(()=>{try{return new URL(W.value.base_url).hostname}catch{return""}}),ut=B(()=>/(^|\.)openrouter\.ai$/i.test(ds.value)),ua=B(()=>{var v,R;return((R=(v=p.value)==null?void 0:v.openai_compatible)==null?void 0:R.base_url)||""}),hn=B(()=>{try{return new URL(ua.value).href}catch{return""}}),_o=B(()=>{try{return new URL(W.value.base_url).href}catch{return""}}),As=B(()=>w("compat")&&ut.value&&!!hn.value&&hn.value===_o.value);Gt(()=>[As.value,_o.value,hn.value],()=>{++ya,kt.value=null,Wt.value="",$s.value=!1,So()},{flush:"sync"});const vc=B(()=>{var v,R;return ut.value?$s.value?"OpenRouter recognized · fetching catalogue…":Wt.value?`OpenRouter recognized · catalogue failed: ${Wt.value}`:`OpenRouter recognized · ${((R=(v=kt.value)==null?void 0:v.models)==null?void 0:R.length)||0} catalogue models loaded`:Es.value.length?`${Es.value.length} endpoint models loaded`:"Catalogue not loaded"}),hc=B(()=>Wt.value?"text-red-400":"text-gray-500"),wo=B(()=>{var v;return[...new Set((((v=kt.value)==null?void 0:v.models)||[]).map(R=>R.vendor))].sort()}),gc=B(()=>{var v;return[...new Set((((v=kt.value)==null?void 0:v.models)||[]).flatMap(R=>(R.endpoints||[]).map(q=>q.quantization)).filter(Boolean))].sort()}),bc=v=>{var R;return(((R=kt.value)==null?void 0:R.measured_cache)||[]).some(q=>q.model===v.id&&q.samples>0&&q.cached_percent>0)},ko=B(()=>{const v=Pn.value.trim().toLowerCase();return xa.value.filter(R=>{var q;return!(v&&!`${R.id} ${R.name} ${R.vendor}`.toLowerCase().includes(v)||mn.value&&R.vendor!==mn.value||$n.value&&!R.supports_tools||Ua.value&&!R.agent_eligible||vn.value&&R.variant!=="standard"||Ie.value&&!bc(R)||D.value!=null&&Number((q=R.pricing)==null?void 0:q.prompt_per_token)*1e6>Number(D.value)||pe.value&&!(R.endpoints||[]).some(ye=>ye.quantization===pe.value))})}),yc=B(()=>ko.value.slice(0,100)),xc=B(()=>ko.value.length),gn=B(()=>new Map((As.value?xa.value:[]).map(v=>[`compat:${v.id}`,v]))),bn=v=>v==null?"n/a":`$${(Number(v)*1e6).toFixed(3)}/M`,mi=v=>{var R,q,ye,Ne;return[v.vendor,v.context_length?`${Number(v.context_length).toLocaleString()} ctx`:"context unknown",`${bn((R=v.pricing)==null?void 0:R.prompt_per_token)} in`,`${bn((q=v.pricing)==null?void 0:q.completion_per_token)} out`,`${bn((ye=v.pricing)==null?void 0:ye.cache_read_per_token)} cache read`,`${bn((Ne=v.pricing)==null?void 0:Ne.cache_write_per_token)} cache write`,v.supports_tools?"tools":"no tools",v.supports_reasoning?"reasoning":"no reasoning",v.variant!=="standard"?v.variant:null].filter(Boolean).join(" · ")},Fn=v=>{var Ne;const R=gn.value.get(v);if(!R)return"Catalogue facts unavailable";const q=(((Ne=kt.value)==null?void 0:Ne.measured_cache)||[]).filter(rt=>rt.model===R.id),ye=q.length?q.map(rt=>`${rt.upstream_provider}: ${rt.cached_percent}% cached`).join(" · "):"No measured cache evidence yet";return`${mi(R)} · ${ye}${R.profile_conflict?" · operator profile conflicts with catalogue":""}`},vi=B(()=>Es.value.map(v=>typeof v=="string"?v:v.name).filter(Boolean)),al=async()=>{var R,q,ye;const v=(ye=(q=(R=p.value)==null?void 0:R.openai_compatible)==null?void 0:q.preset_catalogue)==null?void 0:ye[W.value.preset];v&&(W.value.base_url=v.base_url),zn.cancel(),await dl()},C=(v,R)=>{W.value.openrouter[v]=R.split(",").map(q=>q.trim()).filter(Boolean)},K=B(()=>zt.value||[]),ne=B(()=>{const v=[...b,...vi.value.map(R=>`compat:${R}`),...K.value.map(R=>`ollama:${R.name}`)];for(const R of[Fe.value.model,...ot.value])R&&R!=="auto"&&!v.includes(R)&&v.unshift(R);return v}),Ee=v=>v==="codex-auto-review"?"codex-auto-review (Codex alias → gpt-5.6-luna)":v;async function Je({poll:v=!1}={}){try{const R=await z.get("/api/agents/model");if(v&&Pt()||ue.value||Ce.value||u("agents",Fe.value))return;Fe.value={...Fe.value,...R},r.agents=c(Fe.value)}catch{}}async function St(){const v=ds.value,R=ua.value,q=++ya;if(!As.value){kt.value=null,Wt.value="",$s.value=!1;return}$s.value=!0;try{const ye=await z.get("/api/openrouter/catalogue");if(q!==ya||!As.value||v!==ds.value||R!==ua.value)return;kt.value=ye,Wt.value=""}catch(ye){if(q!==ya||!As.value)return;Wt.value=ye.message||"Failed to load OpenRouter catalogue"}finally{q===ya&&($s.value=!1)}}async function Ks(){var v;try{(v=Fe.value.model)!=null&&v.startsWith("compat:")&&ut.value&&await nl(il(Fe.value.model),Co(Fe.value.model));const R=await z.put("/api/agents/model",{model:Fe.value.model||null});Fe.value={...Fe.value,...R},r.agents=c(Fe.value),He("Agent model policy saved")}catch(R){He(R.message||"Failed to save agent model policy","error")}}const gs=()=>[...Ge.value.length?Ge.value:ot.value];async function yn(v,R){const q=gs(),ye=q.findIndex(Lt=>qe(Lt)===v);let Ne=null;if(R.target.checked&&ye<0&&q.push(Fa.get(v)||v),!R.target.checked&&ye>=0&&([Ne]=q.splice(ye,1)),!q.length){R.target.checked=!0,He("Keep one model selected. An empty list restores the provider default.","error");return}const rt=await Rs(q,"Agent Auto allowlist saved");rt&&R.target.checked&&Fa.delete(v),rt&&!R.target.checked&&typeof Ne=="object"&&Fa.set(v,Ne),rt||(R.target.checked=ot.value.includes(v))}async function Rs(v,R){if(Ce.value)return!1;Ce.value=!0;try{const q=await z.put("/api/agents/model",{auto_model_allowlist:v});return Fe.value={...Fe.value,...q},r.agents=c(Fe.value),He(R),!0}catch(q){return He(q.message||"Failed to save agent allowlist","error"),!1}finally{Ce.value=!1}}const db=()=>Rs([],"Provider default restored");async function ub(v,R,q){const ye=ot.value.map(Ne=>{const rt=ae(Ne);if(Ne!==v)return rt||Ne;const Lt=typeof rt=="string"?{model:Ne}:{...rt||{model:Ne}};return delete Lt.reasoning_effort,delete Lt.thinking_mode,q&&(Lt[R]=q),Object.keys(Lt).length===1?Lt.model:Lt});await Rs(ye,"Model default saved")}async function nl(v,R=""){const[q,...ye]=v.split("/");if(!q||!ye.length)throw new Error("OpenRouter model id is not namespaced");return z.post(`/api/openrouter/models/${encodeURIComponent(q)}/${encodeURIComponent(ye.join("/"))}/select`,{provider_tag:R})}const pb=(v,R)=>{var ye;const q=(((ye=kt.value)==null?void 0:ye.measured_cache)||[]).find(Ne=>Ne.model===v&&Ne.upstream_provider===R);return q?`${q.cached_percent}% cached over ${q.samples} calls`:"no measured cache evidence"},fb=v=>v==null?"n/a":`$${(Number(v)*1e6).toFixed(4)}/M`,mb=(v,R)=>{if(v==null)return"n/a";if(typeof v=="number")return Number(v).toLocaleString();const q=v[R];return q==null?"n/a":Number(q).toLocaleString()},vb=v=>{var Ne;const R=[];v.quantization==="fp4"&&R.push("fp4 quantization may change quality");const q=typeof v.latency_last_30m=="object"?Number((Ne=v.latency_last_30m)==null?void 0:Ne.p99):null,ye=Number(Fe.value.iteration_timeout_seconds||0)*1e3;return q&&ye&&q>ye&&R.push("p99 exceeds the agent iteration budget"),R.join("; ")};async function hb(v){var R;ke.value=v,Be.value=((R=W.value.openrouter.model_pins)==null?void 0:R[v.id])||"",Ke.value=!0;try{const[q,...ye]=v.id.split("/"),Ne=await z.get(`/api/openrouter/models/${encodeURIComponent(q)}/${encodeURIComponent(ye.join("/"))}/endpoints`);Ue.value=Ne.endpoints||[]}catch(q){Ue.value=[],He(q.message||"Failed to load OpenRouter provider routes","error")}finally{Ke.value=!1}}function So(){ke.value=null,Be.value="",Ue.value=[]}async function gb(v,R=""){try{await nl(v.id,R);const q=gs(),ye=`compat:${v.id}`;if(q.some(rt=>qe(rt)===ye)||q.push(ye),!await Rs(q,R?"OpenRouter model added and provider pinned.":"OpenRouter model added unpinned."))return;So(),await _a()}catch(q){He(q.message||"Failed to add OpenRouter model","error")}}const il=v=>v.startsWith("compat:")?v.slice(7):v,Co=v=>{var R;return((R=W.value.openrouter.model_pins)==null?void 0:R[il(v)])||""},bb=v=>ot.value.length>1&&Rs(gs().filter(R=>qe(R)!==v),"Model removed from allowlist");async function yb(){var v,R;try{const q=gs();for(const ye of((v=kt.value)==null?void 0:v.quick_add)||[])(R=gn.value.get(ye))!=null&&R.agent_eligible&&(await nl(il(ye),""),q.some(Ne=>qe(Ne)===ye)||q.push(ye));await Rs(q,"Curated OpenRouter models added"),await _a()}catch(q){He(q.message||"Failed to add curated OpenRouter models","error")}}function ap(v){const R=v.hint_metadata||{},q=[];return R.context_tokens&&q.push(`context ${Number(R.context_tokens).toLocaleString()}`),R.max_output_tokens&&q.push(`max output ${Number(R.max_output_tokens).toLocaleString()}`),R.structural_source&&q.push(`source ${R.structural_source}`),q.join("; ")}async function xb(v,R){const q={...Fe.value.model_selection_hints||{}},ye=R.trim();ye?q[v]=ye:delete q[v];try{const Ne=await z.put("/api/agents/model",{model_selection_hints:q});Fe.value={...Fe.value,...Ne},r.agents=c(Fe.value),He("Model hint saved")}catch(Ne){He(Ne.message||"Failed to save model hint","error")}}function np(v,R){const q=ot.value.indexOf(v);return!Ce.value&&q>=0&&q+R>=0&&q+Rqe(Ne)===v);ye<0||!np(v,R)||([q[ye],q[ye+R]]=[q[ye+R],q[ye]],await Rs(q,"Agent Auto allowlist order saved"))}const _c=f(!0),wc=f(""),ip=f({configured:null,accounts:[]}),kc=f(null),To=f(null),Sc=f(""),ll=f(null),Cc=f(!1),Tc=f(null),lp=f(null),op=f("");let hi=null;function He(v,R="success"){Se(v,R==="error"?"error":"success")}function wb(v){if(!v)return"?";const R=v/(1024*1024*1024);return R>=1?R.toFixed(1)+" GB":(v/(1024*1024)).toFixed(0)+" MB"}function kb(v){return Number.isFinite(Number(v))?Number(v).toLocaleString():"—"}function Sb(v){return v==null?"automatic (model-derived)":Number(v).toLocaleString()+" characters"}function Cb(v){const R=new Date(v);return Number.isNaN(R.getTime())?"unknown":R.toLocaleString([],{dateStyle:"medium",timeStyle:"short"})}function Tb(v){const R=new Date(Number(v)*1e3);return Number.isNaN(R.getTime())?"unknown":R.toLocaleString([],{dateStyle:"medium",timeStyle:"short"})}function Eb(v){const R=Number(v);if(!Number.isFinite(R)||R<=0)return"unknown";const q=Math.max(0,Math.floor((Date.now()/1e3-R)/60));return q<1?"<1 min":`${q} min`}function Ab(v){return typeof v=="string"&&v.length>12?v.slice(0,8)+"…"+v.slice(-4):v}function Rb(v){return typeof v!="number"||!Number.isFinite(v)?"—":(v/1e3).toFixed(2)}function Ib(v){return v==="temporary learned clamp"?"is-clamp":v==="override"?"is-override":"is-built-in"}function Ob(v){const R=g.value.context_budget_overrides[v.model];return v.floor!=null&&Number.isFinite(Number(R))&&Number(R)>v.floor}function Lb(v,R){const q={...g.value.context_budget_overrides};R.target.value===""?delete q[v]:q[v]=Number(R.target.value),g.value.context_budget_overrides=q,it.value=!0}function Nb(v){g.value.context_utilization=v.target.value===""?"":Number(v.target.value),it.value=!0}function Mb(v){const R={...g.value.context_budget_overrides};delete R[v],g.value.context_budget_overrides=R,it.value=!0}async function _a({quiet:v=!1,poll:R=!1}={}){if(!(R&&Pt())){if(l&&await l,i)return i;v||(e.value=!0),i=(async()=>{await Promise.all([bs({poll:R}),ol(),rl(),Je({poll:R}),pa(),Bn({poll:R})]),await St(),Pt()||Ts()})();try{await i}finally{i=null,v||(e.value=!1)}}}async function Db(){if(!(Pt()||i)){if(l)return l;l=Promise.all([bs({poll:!0}),ol({refreshModels:!1}),rl({refreshModels:!1}),Je({poll:!0}),pa(),Bn({poll:!0})]).then(()=>{Pt()||Ts()});try{await l}finally{l=null}}}async function bs({preserveBasic:v=!1,preserveAdvanced:R=!1,poll:q=!1}={}){var ye,Ne,rt,Lt,wa,xn;try{const at=await z.get("/api/llm/status");p.value=at,m.value=!1,!o&&!u("mainModel",h.value.main)&&(h.value.main=at.main_model||at.active_model||(at.active_provider==="compat"?`compat:${((ye=at.openai_compatible)==null?void 0:ye.model)||""}`:at.active_provider==="ollama"?`ollama:${((Ne=at.ollama)==null?void 0:Ne.model)||""}`:((rt=at.codex)==null?void 0:rt.model)||"gpt-6.1-sol")),at.codex&&!gi.pending()&&(!v&&!Ye.value&&!u("codexBasic",yl(g.value))&&(g.value.enabled=at.codex.enabled,g.value.model=at.codex.model||"gpt-6.1-sol",g.value.reasoning_effort=at.codex.reasoning_effort||"medium",g.value.agent_reasoning_effort=at.codex.agent_reasoning_effort||""),!R&&!Ye.value&&!u("codexAdvanced",xl(g.value))&&(g.value.request_timeout_seconds=at.codex.request_timeout_seconds??g.value.request_timeout_seconds,g.value.stream_stall_timeout_seconds=at.codex.stream_stall_timeout_seconds??g.value.stream_stall_timeout_seconds,g.value.retry={...g.value.retry,...at.codex.retry||{}},g.value.connection_pool={...g.value.connection_pool,...at.codex.connection_pool||{}},g.value.context_compression={...g.value.context_compression,...at.codex.context_compression||{}},!it.value&&!Ye.value&&(g.value.context_budget_overrides={...at.codex.context_budget_overrides||{}},g.value.context_utilization=at.codex.context_utilization??g.value.context_utilization))),at.ollama&&!bi.pending()&&(!v&&!Dt.value&&!u("ollamaBasic",_l(Te.value))&&(Te.value.enabled=at.ollama.enabled,Te.value.base_url=at.ollama.base_url||"",Te.value.model=at.ollama.model||"",Te.value.max_tokens=at.ollama.max_tokens||4096,Te.value.num_ctx=at.ollama.num_ctx||32768),!R&&!Dt.value&&!u("ollamaAdvanced",Jo(Te.value))&&(Te.value.timeout=at.ollama.timeout??Te.value.timeout));const Kt=at.openai_compatible;Kt&&!zn.pending()&&(!v&&!et.value&&!u("compatibleBasic",bl(W.value))&&(W.value.enabled=Kt.enabled,W.value.base_url=Kt.base_url||W.value.base_url,W.value.model=Kt.model||W.value.model,W.value.preset=Kt.preset||W.value.preset,W.value.reasoning_effort=Kt.reasoning_effort||"medium"),!R&&!et.value&&!u("compatibleAdvanced",Ko(W.value))&&(W.value.request_timeout_seconds=Kt.request_timeout_seconds??W.value.request_timeout_seconds,W.value.stream_stall_timeout_seconds=Kt.stream_stall_timeout_seconds??W.value.stream_stall_timeout_seconds,W.value.model_profiles=Kt.model_profiles||W.value.model_profiles,W.value.context_utilization=Kt.context_utilization??W.value.context_utilization,W.value.openrouter={...W.value.openrouter,...Kt.openrouter||{}})),!o&&!u("mainModel",h.value.main)&&((Lt=h.value.main)!=null&&Lt.startsWith("compat:")?h.value.main_capability=(Kt==null?void 0:Kt.reasoning_effort)||"medium":(wa=h.value.main)!=null&&wa.startsWith("ollama:")||(h.value.main_capability=((xn=at.codex)==null?void 0:xn.reasoning_effort)||"medium")),at.auxiliary&&(_e.value=at.auxiliary,!Un.pending()&&!ge.value&&!u("auxiliary",X.value)&&(X.value.enabled=at.auxiliary.enabled,X.value.model=at.auxiliary.model||"gpt-6-luna"))}catch{p.value||(p.value={active_provider:"",codex:{configured:null},ollama:{configured:null},openai_compatible:{configured:null}}),m.value=!0}}async function Bn({poll:v=!1}={}){const R=++Qe;We.value=!0,je.value="";try{const q=await z.get("/api/context/windows");if(R!==Qe)return;$e.value=q,!(v&&Pt())&&!Ye.value&&!it.value&&(g.value.context_budget_overrides=Object.fromEntries(Object.entries(q.models||{}).filter(([,ye])=>ye.override!=null).map(([ye,Ne])=>[ye,Ne.override])),g.value.context_utilization=q.utilization??g.value.context_utilization)}catch(q){R===Qe&&(je.value=q.message||"Failed to load context budgets")}finally{R===Qe&&(We.value=!1)}}async function ol({refreshModels:v=!0}={}){try{if(Vs.value=await z.get("/api/ollama/status"),Gs.value=!1,Vs.value.model&&(ta.value=Vs.value.model),v&&Vs.value.configured)try{const R=await z.get("/api/ollama/models");zt.value=R.models||[]}catch{zt.value=[]}else if(v&&Te.value.base_url)try{const R=await z.post("/api/ollama/probe-models",{base_url:Te.value.base_url});zt.value=R.models||[]}catch{zt.value=[]}}catch{Gs.value=!0}}async function pa(){_c.value=!0,wc.value="";try{ip.value=await z.get("/api/codex/status")}catch(v){wc.value=v.message||"Failed to fetch Codex status"}finally{_c.value=!1}}async function Pb(){if(o)return;o=!0;const v=h.value.main;try{v.startsWith("compat:")&&ut.value&&await nl(il(v),Co(v));try{await z.put("/api/llm/main-model",{model:v})}catch(R){if(!/404|not found/i.test(R.message||""))throw R;await z.post("/api/llm/switch",{model:v})}d("mainModel",v),He("Main model saved"),await _a()}catch(R){He(R.message||"Failed to save main model","error"),await bs()}finally{o=!1}}async function $b(v){h.value.main_capability=v;const R=re.value;R&&(R.provider==="compat"&&R.capability!=="none"?(W.value.reasoning_effort=v,await dl()):R.capability==="reasoning"?(g.value.reasoning_effort=v,await cl()):R.capability==="thinking"&&(await z.put("/api/openai-compatible/config",{thinking_mode:v}),He("Thinking mode saved")))}async function Fb(v){h.value.agent_capability=v;const R=ce.value,q=(R==null?void 0:R.capability)||oe.value;if(q!=="none"){if(v===""||v==="auto"||q==="reasoning"||q==="mixed"){if(g.value.agent_reasoning_effort=v,q==="thinking"){const ye=await z.put("/api/agents/model",{thinking_mode:null});Fe.value={...Fe.value,...ye}}await cl()}else if(q==="thinking"){const ye=await z.put("/api/agents/model",{thinking_mode:v});Fe.value={...Fe.value,...ye},He("Agent thinking mode saved")}}}async function Bb(){Ws.value=!0;try{const v=await z.post("/api/ollama/reload");He(v.configured?"Ollama reloaded":v.reason||"Ollama not configured",v.configured?"success":"error"),await _a()}catch(v){He(v.message||"Reload failed","error")}finally{Ws.value=!1}}async function Ub(){Pa.value=!0;try{await z.post("/api/ollama/model",{model:ta.value}),He("Model set to "+ta.value),await _a()}catch(v){He(v.message||"Failed","error")}finally{Pa.value=!1}}async function zb(){const v=Te.value.base_url;if(!v){He("Enter a base URL first","error");return}Rt.value=!0;try{const R=await z.post("/api/ollama/probe-models",{base_url:v});zt.value=R.models||[],zt.value.length?(He(zt.value.length+" model(s) found"),!Te.value.model&&zt.value.length&&(Te.value.model=zt.value[0].name)):He("No models found at "+v,"error")}catch(R){He(R.message||"Could not reach Ollama","error")}finally{Rt.value=!1}}async function rl({refreshModels:v=!0}={}){try{if(hs.value=await z.get("/api/openai-compatible/status"),$a.value=!1,hs.value.model&&(ls.value=hs.value.model),v&&hs.value.configured)try{const R=await z.get("/api/openai-compatible/models");Es.value=R.models||[]}catch{Es.value=[]}}catch{$a.value=!0}}async function Hb(){sa.value=!0;try{const v=await z.post("/api/openai-compatible/reload");He(v.configured?"OpenAI-compatible reloaded":v.reason||"OpenAI-compatible not configured",v.configured?"success":"error"),await _a()}catch(v){He(v.message||"Reload failed","error")}finally{sa.value=!1}}async function jb(){ba.value=!0;try{await z.post("/api/openai-compatible/model",{model:ls.value}),He("Model set to "+ls.value),await _a()}catch(v){He(v.message||"Failed","error")}finally{ba.value=!1}}async function cl(){var R,q;if(Ye.value){gi();return}Ye.value=!0;const v=yl(g.value);try{if(!await O("codex",v.enabled)){g.value.enabled=((q=(R=p.value)==null?void 0:R.codex)==null?void 0:q.enabled)===!0;return}await z.put("/api/llm/codex/config",v),d("codexBasic",v),He("Codex config saved"),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),pa()])}catch(ye){He(ye.message||"Failed","error");const Ne=JSON.stringify(yl(g.value))!==JSON.stringify(v);await Promise.all([bs({preserveBasic:Ne,preserveAdvanced:!0}),pa()])}finally{Ye.value=!1}}async function rp(){if(Ye.value)return;Ye.value=!0;const v=xl(g.value);try{await z.put("/api/llm/codex/config",v),d("codexAdvanced",v),JSON.stringify({context_budget_overrides:g.value.context_budget_overrides,context_utilization:g.value.context_utilization})===JSON.stringify({context_budget_overrides:v.context_budget_overrides,context_utilization:v.context_utilization})&&(it.value=!1),He("Codex advanced settings saved"),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),pa(),Bn()])}catch(R){He(R.message||"Failed","error");const q=JSON.stringify(xl(g.value))!==JSON.stringify(v);await Promise.all([bs({preserveBasic:!0,preserveAdvanced:q}),pa(),Bn()])}finally{Ye.value=!1}}async function Ec(){var v,R;if(Dt.value){bi();return}Dt.value=!0;try{const q=De.value?Te.value.api_key:null,ye=_l(Te.value,{includeApiKey:q!==null});if(!await O("ollama",ye.enabled)){Te.value.enabled=((R=(v=p.value)==null?void 0:v.ollama)==null?void 0:R.enabled)===!0;return}await z.put("/api/llm/ollama/config",ye),He("Ollama config saved"),q!==null&&Te.value.api_key===q&&(Te.value.api_key="",De.value=!1),d("ollamaBasic",_l(Te.value)),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),ol()])}catch(q){He(q.message||"Failed","error"),await bs({preserveBasic:!0,preserveAdvanced:!0})}finally{Dt.value=!1}}async function cp(){if(!Dt.value){Dt.value=!0;try{const v=Jo(Te.value);await z.put("/api/llm/ollama/config",v),d("ollamaAdvanced",v),He("Ollama timeout saved"),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),ol()])}catch(v){He(v.message||"Failed","error")}finally{Dt.value=!1}}}async function dl(){var v,R;if(et.value){zn();return}et.value=!0;try{const q=ze.value?W.value.api_key:null,ye=bl(W.value,{includeApiKey:q!==null});if(!await O("compat",ye.enabled)){W.value.enabled=((R=(v=p.value)==null?void 0:v.openai_compatible)==null?void 0:R.enabled)===!0;return}await z.put("/api/openai-compatible/config",ye),He("OpenAI-compatible config saved"),q!==null&&W.value.api_key===q&&(W.value.api_key="",ze.value=!1),d("compatibleBasic",bl(W.value)),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),rl()]),await St()}catch(q){He(q.message||"Failed","error"),await bs({preserveBasic:!0,preserveAdvanced:!0})}finally{et.value=!1}}async function dp(){if(!et.value){et.value=!0;try{const v=Ko(W.value);await z.put("/api/openai-compatible/config",v),d("compatibleAdvanced",v),He("OpenAI-compatible endpoint settings saved"),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),rl()]),await St()}catch(v){He(v.message||"Failed","error")}finally{et.value=!1}}}async function qb(){if(ge.value){Un();return}ge.value=!0;try{const v={...X.value};await z.put("/api/llm/auxiliary/config",v),d("auxiliary",v),He("Auxiliary config saved"),await bs()}catch(v){He(v.message||"Failed","error"),await bs()}finally{ge.value=!1}}const gi=Zo(cl),Un=Zo(qb),bi=Zo(Ec),zn=Zo(dl),Vb=()=>(gi.cancel(),cl()),Gb=()=>(bi.cancel(),Ec()),Wb=()=>(zn.cancel(),dl()),Kb=()=>rp(),Jb=()=>cp(),Zb=()=>dp();async function Qb(v){const R=v.account_key+":"+v.model;Xe.value=R;try{const q=await z.post("/api/context/windows/clear",{account_key:v.account_key,model:v.model});He(q.cleared?"Temporary clamp cleared":"Clamp was already inactive"),await Bn()}catch(q){He(q.message||"Failed to clear clamp","error"),await Bn()}finally{Xe.value=null}}async function Yb(v){try{await z.post("/api/codex/account/"+v+"/activate"),He("Active account switched"),await pa()}catch(R){He(R.message||"Failed","error")}}async function Xb(v){kc.value=v;try{await z.post("/api/codex/account/"+v+"/refresh"),He("Token refreshed"),await pa()}catch(R){He(R.message||"Refresh failed","error")}finally{kc.value=null}}function ey(v,R){To.value=v,Sc.value=R||""}async function ty(v){try{await z.put("/api/codex/account/"+v+"/label",{label:Sc.value}),He("Label updated"),To.value=null,await pa()}catch(R){He(R.message||"Failed","error")}}async function sy(v,R){if(await is({title:"Delete Codex account",message:`Delete ${R||"account #"+(v+1)}? The pool will reload without it.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/codex/account/"+v),He("Deleted. Pool reloaded."),await pa()}catch(ye){He(ye.message||"Failed","error")}}async function ay(){Cc.value=!0;try{const v=await z.post("/api/codex/device-code");Tc.value=v,ll.value="pending",ny(v)}catch(v){He(v.message||"Failed","error")}finally{Cc.value=!1}}async function ny(v){hi={cancelled:!1};const R=hi;try{const q=await z.post("/api/codex/device-poll",{device_auth_id:v.device_auth_id,user_code:v.user_code,interval:v.interval});if(R.cancelled)return;lp.value=q,ll.value="success",await _a()}catch(q){if(R.cancelled)return;op.value=q.message||"Device login failed",ll.value="error"}}function iy(){hi&&(hi.cancelled=!0),ll.value=null,Tc.value=null}function up(){n||(n=!0,_a(),a=window.setInterval(Db,s))}function pp(){n&&(n=!1,a!==null&&window.clearInterval(a),a=null)}return st(up),cs(up),es(pp),wt(()=>{pp(),hi&&(hi.cancelled=!0),gi.cancel(),Un.cancel(),bi.cancel(),zn.cancel()}),{pageRoot:t,savedProviderEnabled:w,codexOnly:_,codexChoices:ee,configuredDisabledSelection:T,configuredDisabledLabel:E,disableWarningRoles:F,effectiveAutoAllowlistCount:pn,allowlistModalOpen:ue,closeAllowlistModal:fi,allowlistSaving:Ce,effectiveAllowlist:ot,allowlistSummary:pi,resetAgentAllowlist:db,selectedUnavailableReason:Ba,selectedModelFacts:fn,loading:e,llmStatus:p,llmStatusLoadFailed:m,modelSelection:h,modelSelectorSearch:y,reasoningEfforts:k,neutralReasoningLevels:A,modelCatalog:I,modelGroups:te,selectedMainModel:re,selectedAgentModel:ce,selectedAgentCapabilityValue:j,agentCapabilityKind:oe,agentCapabilityEfforts:ve,autoAllowlistModels:be,allowlistModel:Z,allowlistModelEfforts:Q,allowlistEntryCapabilityValue:V,modelOptionLabel:Y,agentModelAvailable:N,agentModelOptionLabel:M,advancedOpen:Ae,codexForm:g,codexModelOptions:xe,codexAgentModelOptions:S,mainEffortAllowed:fe,agentEffortAllowed:me,mainModelOptionDisabled:J,agentModelOptionDisabled:ie,auxForm:X,auxData:_e,auxModelOptions:U,onAuxModelChange:de,savingAux:ge,saveAuxConfigDebounced:Un,ollamaForm:Te,compatibleForm:W,savingCodex:Ye,savingOllama:Dt,savingCompatible:et,probingOllama:Rt,ollamaKeyDirty:De,compatibleKeyDirty:ze,fetchCodexStatus:pa,ollamaStatus:Vs,ollamaStatusLoadFailed:Gs,ollamaModels:zt,ollamaSelectedModel:ta,reloading:Ws,settingModel:Pa,compatibleStatus:hs,compatibleStatusLoadFailed:$a,compatibleModels:Es,visibleCompatibleModels:Ct,compatibleSelectedModel:ls,reloadingCompatible:sa,settingCompatibleModel:ba,applyCompatiblePreset:al,setOpenRouterList:C,agentsConfig:Fe,compatibleAgentModels:vi,ollamaAgentModels:K,knownAgentModelRefs:ne,agentModelLabel:Ee,saveAgentsModel:Ks,toggleAgentAutoAllowlist:yn,saveAllowlistEntryCapability:ub,autoAllowlistGroups:$,structuralFacts:ap,saveModelHint:xb,canMoveAllowlist:np,moveAgentAutoAllowlist:_b,openRouterCatalogue:kt,openRouterCatalogueLoading:$s,openRouterCatalogueError:Wt,openRouterRecognized:ut,openRouterCatalogueActive:As,compatibleCatalogueStatus:vc,compatibleCatalogueStatusClass:hc,openRouterSearch:Pn,openRouterVendor:mn,openRouterVendors:wo,openRouterToolsOnly:$n,openRouterEligibleOnly:Ua,openRouterStandardOnly:vn,openRouterMeasuredCacheOnly:Ie,openRouterMaxPromptPrice:D,openRouterQuantization:pe,openRouterQuantizations:gc,openRouterResults:yc,openRouterMatchCount:xc,openRouterInlineFacts:mi,openRouterSelectedFacts:Fn,prepareOpenRouterModel:hb,addOpenRouterModel:gb,removeOpenRouterModel:bb,quickAddOpenRouter:yb,openRouterModelMap:gn,openRouterPin:Co,openRouterPendingModel:ke,openRouterPendingTag:Be,openRouterPendingEndpoints:Ue,openRouterPendingLoading:Ke,openRouterEndpointSort:$t,openRouterSortedPendingEndpoints:Ot,openRouterEndpointCacheFact:pb,openRouterRate:fb,openRouterMetric:mb,openRouterRouteWarning:vb,cancelOpenRouterPending:So,codexLoading:_c,codexError:wc,codexData:ip,refreshing:kc,editingLabel:To,labelValue:Sc,contextWindows:$e,contextWindowsLoading:We,contextWindowsError:je,contextBudgetRows:se,activeClampRows:Re,activeContextBudget:Le,clearingClamp:Xe,contextPolicyDirty:it,deviceState:ll,deviceLoading:Cc,deviceInfo:Tc,deviceResult:lp,deviceError:op,fetchAll:_a,fetchLLMStatus:bs,fetchOllamaStatus:ol,fetchCompatibleStatus:rl,saveMainModel:Pb,saveMainCapability:$b,saveAgentCapability:Fb,reloadOllama:Bb,setOllamaModel:Ub,reloadCompatible:Hb,setCompatibleModel:jb,probeOllamaModels:zb,saveCodexConfig:cl,saveOllamaConfig:Ec,saveCompatibleConfig:dl,saveCodexAdvancedConfig:rp,saveOllamaAdvancedConfig:cp,saveCompatibleAdvancedConfig:dp,saveCodexConfigDebounced:gi,saveOllamaConfigDebounced:bi,saveCompatibleConfigDebounced:zn,saveCodexConfigNow:Vb,saveOllamaConfigNow:Gb,saveCompatibleConfigNow:Wb,saveCodexAdvancedConfigNow:Kb,saveOllamaAdvancedConfigNow:Jb,saveCompatibleAdvancedConfigNow:Zb,activateAccount:Yb,refreshAccount:Xb,startEditLabel:ey,saveLabel:ty,deleteAccount:sy,startDeviceLogin:ay,cancelDeviceLogin:iy,formatSize:wb,fetchContextWindows:Bn,clearContextClamp:Qb,setContextOverride:Lb,setContextUtilization:Nb,resetContextOverride:Mb,overrideAboveFloor:Ob,formatCount:kb,formatContextCeiling:Sb,formatExpiry:Cb,shortAccountKey:Ab,provenanceClass:Ib,formatDensity:Rb,quotaBlocks:QC,quotaFailureVisible:YC,quotaAge:Eb,formatQuotaDate:Tb}}},tm={ok:"text-green-400",pass:"text-green-400",degraded:"text-yellow-400",warn:"text-yellow-400",down:"text-red-400",fail:"text-red-400",unconfigured:"text-gray-500",skipped:"text-gray-500"};function nT(e){return tm[e]||tm[(e||"").toLowerCase()]||"text-gray-400"}const iT={template:` -
-
-
-
- - - -
-
-

- {{ failedCount }} of {{ endpoints.length }} internal endpoints failed to load: - {{ failedEndpointSummary }}. -

-
- - -
-

Startup Diagnostics

-
-
- {{ startup.passed_count || 0 }}/{{ startup.total_checks || 0 }} passed - ({{ startup.duration_ms }}ms) -
-
- - - - {{ d.name }} - {{ d.detail || '' }} -
-
-

No diagnostics available

-
- - -
-

Subsystem Guard

-
-
-
- - - - {{ s.name }} -
-
- {{ s.total_successes || 0 }} ok / {{ s.total_failures || 0 }} fail - — last fail: {{ formatAgeSeconds(s.last_failure_age_seconds) }} -
-
-
-

No subsystems registered

-
- - -
-

Connection Pools

-
-
-

SSH Pool

-
-
Active connections: {{ sshPool.active_connections || 0 }}
-
Active hosts: {{ sshPool.active_hosts?.length ? sshPool.active_hosts.join(', ') : 'None' }}
-
Opened: {{ sshPool.total_opened || 0 }}
-
Reused: {{ sshPool.total_reused || 0 }}
-
-

No SSH pool data

-
-
-

HTTP Pools

-
-
- {{ provider }} - - -
-
-

No HTTP pool data

-
-
-
- - -
-

Command Governor

-
-
- Blocked: {{ governorStats.blocked || 0 }} - High-risk allowed: {{ governorStats.allowed_high_risk || 0 }} -
-
-
Recent blocks:
-
- [{{ b.risk }}] {{ b.reason }} — {{ b.command }} -
-
-
-

No governor data

-
- - -
- - -
-

Risk Classifier

-
-
Total assessed: {{ riskTotal }}
-
Critical: {{ riskStats.totals?.critical || 0 }}
-
High risk: {{ riskStats.totals?.high || 0 }}
-
Medium: {{ riskStats.totals?.medium || 0 }}
-
Low: {{ riskStats.totals?.low || 0 }}
-
-

No risk data

-
- - -
-

Recovery

-
-
Attempts: {{ recoveryStats.totals?.attempts || 0 }}
-
Recovered: {{ recoveryStats.totals?.successes || 0 }}
-
Failed: {{ recoveryStats.totals?.failures || 0 }}
-
-

Recovery disabled or no data

-
- - -
-

Context Compression

-
-
Compressions: {{ compressionStats.compressions || 0 }}
-
Iterations compressed: {{ compressionStats.iterations_compressed || 0 }}
-
Chars saved: {{ (compressionStats.chars_saved || 0).toLocaleString() }}
-
Prefix cache hit rate: {{ ((compressionStats.prefix_hit_rate || 0) * 100).toFixed(0) }}%
-
-

No compression data

-
- -
- - -
-

Branch Freshness

-
-
Checks: {{ freshnessStats.total_checks || 0 }}
-
Stale detected: {{ freshnessStats.stale_found || 0 }}
-
Fetch failures: {{ freshnessStats.fetch_failures || 0 }}
-
-

Freshness checking disabled or no data

-
- -
-
- `,setup(){const e=f(!0),t=f({}),s=f([]),a=f({}),n=f({}),i=f(null),l=f(null),o=f(null),r=f(null),c=f(null),d=B(()=>{var _;return Object.values(((_=i.value)==null?void 0:_.totals)||{}).reduce((T,L)=>T+Number(L||0),0)}),u=f(""),p=f(0),m=f([]),h=B(()=>m.value.map(_=>`${_.label} (${_.path}${_.reason?`: ${_.reason}`:""})`).join("; ")),y=Object.freeze([{key:"startup",label:"Startup diagnostics",path:"/api/startup/diagnostics"},{key:"subsystems",label:"Subsystem status",path:"/api/subsystems/status"},{key:"sshPool",label:"SSH pool",path:"/api/pools/ssh"},{key:"httpPool",label:"HTTP pool",path:"/api/pools/http"},{key:"riskStats",label:"Risk stats",path:"/api/risk/stats"},{key:"recoveryStats",label:"Recovery stats",path:"/api/recovery/stats"},{key:"compressionStats",label:"Compression stats",path:"/api/compression/stats"},{key:"freshnessStats",label:"Freshness stats",path:"/api/freshness/stats"},{key:"governorStats",label:"Governor stats",path:"/api/governor/stats"}]);let k=null;async function A(){var F;const _=await Promise.allSettled(y.map(O=>z.get(O.path))),T=O=>_[O].status==="fulfilled"?_[O].value:null;t.value=T(0)||{};const L=T(1);s.value=Array.isArray(L)?L:L&&L.subsystems||[],a.value=T(2)||{},n.value=T(3)||{},i.value=T(4),l.value=T(5),o.value=T(6),r.value=T(7),c.value=T(8);const E=_.filter(O=>O.status==="rejected");if(m.value=_.flatMap((O,I)=>{var H;return O.status==="rejected"?[{...y[I],reason:((H=O.reason)==null?void 0:H.message)||"request failed"}]:[]}),p.value=m.value.length,E.length===_.length){const O=(F=E[0])==null?void 0:F.reason;u.value=(O==null?void 0:O.message)||"Failed to load internals"}else u.value="";e.value=!1}function g(){e.value=!0,u.value="",A()}let b=!1;function x(){b||(b=!0,A(),k||(k=setInterval(A,3e4)))}function w(){b&&(b=!1,k&&(clearInterval(k),k=null))}return st(x),cs(x),es(w),wt(w),{loading:e,error:u,failedCount:p,failedEndpoints:m,failedEndpointSummary:h,endpoints:y,retry:g,startup:t,subsystems:s,sshPool:a,httpPool:n,riskStats:i,riskTotal:d,recoveryStats:l,compressionStats:o,freshnessStats:r,governorStats:c,statusColor:nT,formatAgeSeconds:k1}}},lT=1e4,sm=3e4;function wl(e,t){return Math.max(0,e-t)}function Qc(e,t){return new Set((e.operations||[]).map(a=>a.state)).has("MANUAL_RESOLUTION_REQUIRED")?0:e.expired_lease||e.status==="ACTIVE"&&(!e.lease_expires_at||e.lease_expires_at -
-

Turn State

-
- Data stale - -
-
-

- Read-only current recovery posture. Historical interrupted-effect evidence - remains available below as diagnostics, not operator work. -

- -
-
-
-

Current posture

-

Active work, suspended recovery, expired leases, and effects requiring a human.

-
- stale — last success {{ turnsAgeSeconds }}s ago -
- -
- Turn durability is not enabled in this deployment. -
-
-

{{ turnsError }}

- -
- -
-
-
-
- -
-
-

Model capacity breakers

-
- process lifetime - stale — last success {{ breakersAgeSeconds }}s ago -
-
- -
- Breaker registry is not constructed in this deployment. -
-
-

{{ breakersError }}

- -
- -
-
-
-
-
- `,setup(){const e=f(null),t=f(""),s=f(null),a=f(!1),n=f(0),i=f(null),l=f(""),o=f(null),r=f(!1),c=f(0),d=f(Date.now());let u=null,p=0,m=0;async function h(){const M=++p;a.value=!0;try{const $=await z.get("/api/turn-state/turns?limit=100");if(M!==p)return;t.value=$.availability,e.value=$.availability==="available"?$.data:null,s.value=null,n.value=Date.now()}catch($){if(M!==p)return;s.value=$.message||"Turn-state read failed",$.status===503&&(t.value="unavailable")}M===p&&(a.value=!1)}async function y(){const M=++m;r.value=!0;try{const $=await z.get("/api/turn-state/capacity-breakers");if(M!==m)return;l.value=$.availability,i.value=$.availability==="available"?$.data:null,o.value=null,c.value=Date.now()}catch($){if(M!==m)return;o.value=$.message||"Breaker read failed",$.status===503&&(l.value="unavailable")}M===m&&(r.value=!1)}function k(){h(),y()}const A=B(()=>e.value!==null&&wl(d.value,n.value)>sm),g=B(()=>i.value!==null&&wl(d.value,c.value)>sm),b=B(()=>A.value||g.value),x=B(()=>Math.round(wl(d.value,n.value)/1e3)),w=B(()=>Math.round(wl(d.value,c.value)/1e3));function _(M){return Qc(M,d.value/1e3)}function T(M){return oT[_(M)]}const L=B(()=>{var re;const M=[...((re=e.value)==null?void 0:re.turns)||[]],$=d.value/1e3;return M.sort((ce,Z)=>Qc(ce,$)-Qc(Z,$)||(Z.last_progress_at||0)-(ce.last_progress_at||0))});function E(M){return M.state==="closed"?"badge-success":M.state==="probing"?"badge-warning":"badge-danger"}function F(M){if(M.state==="closed")return"—";const $=wl(d.value,c.value)/1e3,re=Math.max(0,(M.cooldown_remaining_seconds||0)-$);return re>0?`${Math.ceil(re)}s`:M.state==="probing"?"probe in flight":"probe eligible"}function O(M){if(!M)return"";const $=Math.max(0,Math.round(d.value/1e3-M));if($<90)return`${$}s ago`;const re=Math.round($/60);return re<90?`${re}m ago`:`${Math.round(re/60)}h ago`}let I=null,H=null,te=!1;function ee(){te||(te=!0,k(),I=setInterval(k,lT),u=setInterval(()=>{d.value=Date.now()},1e3),H=mt.onReconnected(k))}function N(){te&&(te=!1,I&&(clearInterval(I),I=null),u&&(clearInterval(u),u=null),H&&(H(),H=null))}return st(ee),cs(ee),es(N),wt(N),{turnsData:e,turnsAvailability:t,turnsError:s,turnsLoading:a,breakersData:i,breakersAvailability:l,breakersError:o,breakersLoading:r,turnsStale:A,breakersStale:g,anyStale:b,turnsAgeSeconds:x,breakersAgeSeconds:w,sortedTurns:L,priorityOf:_,priorityBadge:T,breakerBadge:E,cooldownLabel:F,ageLabel:O,fetchTurns:h,fetchBreakers:y,refreshAll:k,arm:ee,disarm:N}}},cT={setup(){const e=f(""),t=f(""),s=f(!1),a=f(""),n=f(!1),i=f(!1),l=f(!1),o=f(null),r=f(!1);async function c(){n.value=!0,o.value=null,r.value=!1;try{const u=await z.get("/api/update/check");e.value=u.current||"",t.value=u.latest||"",s.value=u.update_available||!1,a.value=u.changelog||"",u.error&&(o.value=u.error),r.value=!0}catch(u){o.value=u.message}finally{n.value=!1}}async function d(){if(await is({title:"Update & restart",message:"Update Odin and restart? Active tasks will be interrupted.",confirmLabel:"Update & Restart",danger:!0})){i.value=!0,o.value=null;try{await z.post("/api/update/apply",{version:"latest"}),l.value=!0,setTimeout(()=>location.reload(),8e3)}catch(p){o.value=p.message}finally{i.value=!1}}}return st(c),{current:e,latest:t,updateAvailable:s,changelog:a,checking:n,applying:i,applied:l,error:o,checkDone:r,checkUpdate:c,applyUpdate:d}},template:` -
-
-

Updates

-

Check for new Odin releases and apply updates.

-
- - -
-
-
- Current version -

{{ current || '...' }}

-
- -
-
- - -
-
- - Update available: {{ latest }} -
-
{{ changelog }}
- -
- - -
-
- - You're running the latest version. -
-
- - -
-
- - Update applied. Restarting... This page will reload automatically. -
-
- - -
-

{{ error }}

-
-
- `},am=e=>JSON.parse(JSON.stringify(e)),dT=(e,t)=>JSON.stringify(e)===JSON.stringify(t),uT={emits:["saved"],template:` -
-
-
-

Computer provisioning

-

Edit desired target, storage and launcher policy. All settings below require an Odin restart. Startup values remain pinned, even across disable/enable cycles.

-
- Restart required -
-

Saving does not install dependencies, create storage, grant OS permissions, attach to a desktop or restart Odin. Enable/disable and Stop session are separate lifecycle controls above.

-

Hyprland only: best-effort input. A hard guardian SIGKILL can leave input held. Releasing Odin's button can clobber the physical user's simultaneous same-button hold. Use Release owned input for recovery, then obtain a fresh observation. Native explicit-output capture only; there is no portal fallback. The operator must load the ABI-matched scope plugin once at setup. Saving or enabling never loads it or edits hyprland.conf.

-

{{ message }}

- -

Loading desired configuration and restart metadata…

-

{{ pending.length ? 'Saved settings still pending restart: ' + pending.join(', ') : 'No pending provisioning restart reported.' }}

-

Some startup values are unknown; absence of a pending flag does not prove those settings are active.

-
-
- Desired computer settings -
- -

{{ field.description }}

- - - - - -
-
-
- - -
-
-
-

Review provisioning changes

-
-
-
{{ change.label }}
-
Saved: {{ format(original[change.key]) }}
-
Desired: {{ format(change.value) }}
-
-
-

Only these changed provisioning fields will be saved. Running sessions keep startup settings. Nothing is enabled, stopped or restarted.

-
- - -
-
- -

Drafts are local to this view and discarded when leaving it. Backend validation remains authoritative.

-
`,setup(e,{emit:t}){const s=f([]),a=f({}),n=f({}),i=f(!1),l=f(!1),o=f(!1),r=f(!1),c=f(!1),d=f(""),u=f("");let p=!1,m=0,h=null;const y=(N,M)=>p&&m===N&&z.token===M,k=N=>"computer-provisioning-"+N.key,A=N=>N===null?"Unset":N===""?"Empty":JSON.stringify(N),g=N=>{const M=n.value[N.key];return N.type==="array"?String(M||"").split(/\r?\n/).map($=>$.trim()).filter(Boolean):["integer","number"].includes(N.type)?M===""||M==null?null:Number(M):M},b=B(()=>s.value.map(N=>({...N,value:g(N)})).filter(N=>!dT(N.value,a.value[N.key]))),x=B(()=>s.value.filter(N=>N.pending_restart).map(N=>N.label)),w=B(()=>s.value.some(N=>N.apply_state==="unknown")),_=B(()=>{const N={};for(const M of s.value){const $=g(M),re=M.constraints||{};["integer","number"].includes(M.type)&&($===null&&!M.nullable?N[M.key]="A number is required.":$!==null&&(!Number.isFinite($)||M.type==="integer"&&!Number.isInteger($)||re.minimum!=null&&$re.maximum)&&(N[M.key]="Enter a number within the allowed range.")),M.key==="monitor_names"&&($.length>16||new Set($).size!==$.length||$.some(ce=>!/^[A-Za-z0-9_.-]{1,64}$/.test(ce)))&&(N[M.key]="Use up to 16 unique monitor names, one per line (letters, digits, dot, underscore or hyphen).")}return N}),T=B(()=>Object.keys(_.value).length>0);function L(N,M){n.value[N.key]=M,u.value=""}function E(){n.value=Object.fromEntries(s.value.map(N=>[N.key,N.type==="array"?a.value[N.key].join(` -`):a.value[N.key]])),r.value=!1}async function F(N,M){const[$,re]=await Promise.all([z.get("/api/config"),z.get("/api/config/meta")]);if(!y(N,M))return!1;const ce=(re.fields||[]).filter(Z=>/^computer\.[^.]+$/.test(Z.path)&&Z.path!=="computer.enabled"&&Z.sensitivity==="public"&&Z.apply_mode==="restart");if(!$.computer||!ce.length)throw new Error("Provisioning metadata is unavailable. Reload before editing.");return s.value=ce.map(Z=>({...Z,key:Z.path.split(".")[1]})),a.value=Object.fromEntries(s.value.map(Z=>[Z.key,am($.computer[Z.key])])),E(),h=M,i.value=!0,c.value=!1,!0}async function O(){if(!p||l.value||o.value)return;const N=++m,M=z.token;l.value=!0,i.value=!1,d.value="",u.value="",r.value=!1;try{await F(N,M)}catch($){y(N,M)&&(c.value=!0,d.value=$.message||"Could not load provisioning. No changes were sent.")}finally{y(N,M)&&(l.value=!1)}}function I(){i.value&&!l.value&&!o.value&&!c.value&&b.value.length&&!T.value&&(r.value=!0)}async function H(){if(!p||!i.value||!r.value||o.value||l.value||c.value||T.value||!b.value.length)return;if(h!==z.token){ee(),te();return}const N={computer:Object.fromEntries(b.value.map(ce=>[ce.key,am(ce.value)]))},M=m,$=z.token;o.value=!0,d.value="",u.value="";let re=!1;try{if(await z.put("/api/config",N),re=!0,!y(M,$))return;await F(M,$)&&(u.value="Provisioning saved and configuration reloaded. Startup settings remain pinned until Odin restarts; no lifecycle action was requested.",t("saved"))}catch(ce){y(M,$)&&(c.value=!0,r.value=!1,d.value=re?"Provisioning was saved, but configuration refresh failed. Reload saved values before editing again.":`Save failed or its outcome is unknown${ce.status===400?": "+ce.message:"."} Reload saved values before editing or retrying. No request was replayed.`)}finally{y(M,$)&&(o.value=!1)}}function te(){p||(p=!0,O())}function ee(){p=!1,m++,i.value=!1,l.value=!1,o.value=!1,r.value=!1,h=null,s.value=[],a.value={},n.value={},d.value="",u.value=""}return st(te),cs(te),es(ee),wt(ee),{fields:s,original:a,draft:n,ready:i,loading:l,saving:o,reviewing:r,uncertain:c,error:d,message:u,pending:x,effectiveUnknown:w,changes:b,validation:_,invalid:T,fieldId:k,format:A,edit:L,discard:E,load:O,openReview:I,save:H}}},pT={components:{ComputerProvisioning:uT,DiscordIdentity:Dn},template:` -
- - -
-
-
{{ adminReady ? 'Current session' : 'Last-known session (not current)' }}
-
{{ status.state || 'unknown' }}
-
- {{ loading ? 'Checking status' : adminReady ? 'Session status' : 'Status not current' }} -
-

{{ checkedAt ? 'Last successful status check: ' + new Date(checkedAt).toISOString() + '. ' : 'No successful status check. ' }}Preserved details are read-only history, not current readiness, consent or session authority. Refresh status independently before further operations. Emergency Pause / Stop only request revocation and remain independently authenticated by the server.

-
-
-
-
-

Administrator lifecycle controls

-

Configuration and runtime state are separate. Changes here never restart Odin.

-
-
-
-
Configured
{{ enabledLabel(status.configured_enabled ?? status.enabled) }}
-
Runtime lifecycle
{{ enabledLabel(status.runtime_enabled) }}
-
Runtime generation
{{ status.generation ?? 'Unknown' }}
-
Backend platform / environment
{{ status.backend?.platform || 'Unknown' }} / {{ status.backend?.environment || 'Unknown' }}
-
Restart-required settings
{{ restartSettings }}
-
-
- - -
-

Lifecycle controls require a successful authenticated administrator status check.

-

Applying lifecycle change and checking status…

-

Enabling does not prove backend readiness or start a session. Session startup checks capabilities. Unavailable input remains unavailable.

-

Input unavailable: this backend cannot act. Enabling computer use does not grant mouse or keyboard control.

-

Input capability is unknown. Do not assume this backend can act.

-

Backend reports input support; session authorization and startup checks still apply.

-

Readiness: {{ status.backend.readiness }}. Input blocker: {{ status.backend.input_blocker }}.

-

Display capture access is not application inspection access. Provision the worker under the desktop user's identity, or explicitly configure runtime_sudo and its restricted worker permissions. This page does not elevate privileges.

-

Runtime settings are generation-pinned. Pending restart-required settings are not live; this page does not restart Odin.

-
- -
-

Hyprland native input: best-effort

-

A hard guardian SIGKILL can leave owned input held. Releasing Odin's button may clobber the physical user's simultaneous same-button hold. Cooperative release acknowledgments are not receiver-side proof. These accepted residuals apply only to Hyprland.

-

Native capture is limited to the explicitly consented output. There is no portal fallback. Recovery revokes input and capture, leaves the session paused, and requires renewed consent and a fresh observation.

- -

{{ status.owned_input_recovery.released ? 'Cooperative release acknowledged; receiver-side release is not verified.' : 'Release not confirmed. Inspect the desktop and use documented operator recovery; do not resume input.' }}

-
-
-
-

Desktop accessibility (AT-SPI)

- {{ accessibilityLabel }} -
-

AT-SPI is Linux's accessibility interface. Enabling it lets Odin target UI elements by identity and replace field contents reliably when the application exposes editable elements.

-

Without accessible elements, Odin can use pixels and coordinates. Field replacement uses the explicit pixel action (replace_field_pixels): click an observed field, select all, then type. It is less reliable and needs visual verification; identity-based replace_field never silently switches paths.

-

Enabled is a session setting, not proof that a particular application exposes editable elements or that input is authorized. Fresh observations, application safety checks and consent still apply.

-

Read-only org.a11y.Status IsEnabled check for the configured runtime operator session, refreshed with status about every 5 seconds while this page is open. {{ accessibilityDetail }}

-

This page does not enable or disable accessibility. Change it explicitly in your desktop's accessibility settings; that affects your whole user session, not just Odin.

-
-
-
-

Input eligibility evidence

- {{ status.input_admission.state }} -
-
-

{{ status.input_admission.state }}: {{ status.input_admission.code }}

-

Compositor: {{ status.input_admission.compositor.name }} {{ status.input_admission.compositor.version }} ({{ status.input_admission.compositor.backend }}). Build: {{ status.input_admission.compositor.build_id }}.

-

{{ status.input_admission.reason }}

-

Operator action: {{ status.input_admission.remedy }}

-

Probe scope: {{ status.input_admission.probe_scope }}.

-
-

Behavior was tested in a separate disposable compositor with the matched stack, not by abandoning held input on your desktop.

-

Eligibility evidence does not replace current backend consent, source mapping or application checks. Opening this page runs no input probe.

-
-
-
-

Application profiles

-
-
    -
  • - {{ profile.label }}: {{ profile.input === 'supported' ? 'Input eligible' : 'Capture only' }} - (application provenance unavailable) -

    {{ profile.task_scope }}

    -
  • -
-

No application profiles reported for this backend.

-

Profiles describe supported scope, not installation, focus, permission or task success. Input readiness is checked against a fresh observation.

-
-
-
-

Attached application

-
-

Focus the application you want help with, then ask in ordinary chat. There is no application allowlist. Normal dialogs, file pickers, menus and document open/new/close/reopen are ordinary use. Session Stop only detaches input; applications stay open.

-

Denied classes remain blocked: terminals, shells, authentication and password prompts, polkit, keyring, sudo and Odin control-plane windows. Session authorization, current target checks and input-release checks still apply.

-
-
Observed executable
{{ status.application_provenance?.exe_basename || 'Not observed' }}
-
Observed WM_CLASS
{{ status.application_provenance?.wm_class || 'Not observed' }}
-
Observed PID
{{ status.application_provenance?.pid ?? 'Not observed' }}
-
Trusted executable metadata
{{ status.application_provenance?.trusted_executable === true ? 'Yes' : status.application_provenance?.trusted_executable === false ? 'No' : 'Unknown' }}
-
Script identity evidence
{{ scriptIdentity }}
-
Pointer
{{ status.backend?.pointer || 'unknown' }}
-
Keyboard focus
{{ status.backend?.keyboard_focus || 'unknown' }}
-
Widget focus
{{ status.backend?.widget_focus || 'unknown' }}
-
-

Provenance describes the last observed target, not application approval or task success. Untrusted executable metadata is evidence, not an application refusal. Focus within one window may be shared even with an independent pointer. Unknown capabilities are not proof of independence.

-

Per-call input bounds: {{ inputLimits }}. These limits are not application restrictions.

-
-
Disabled or unavailable. Check configured state, lifecycle state and backend prerequisites separately.
-
{{ adminReady ? 'Agent input is revoked.' : 'Last-known session was paused; this is not current evidence of revocation.' }} This inspector does not provide remote mouse or keyboard control. Resume requires a renewed generation and fresh evidence.
-
Outcome is unknown. Refresh status; do not replay the last action.
-
-
-

Recovery evidence

- {{ status.recovery.complete ? 'Verified' : 'Review required' }} -
-

{{ status.recovery.status }}: {{ status.recovery.reason }}. Cleanup {{ status.recovery.complete ? 'verified' : 'not verified' }}.

-

Reconciliation only inspects the recorded workload. It never sends input, terminates applications or replays actions.

- -
-

Manual reconciliation is an operator attestation, not verified cleanup. Independently inspect the desktop: no held input, owned master devices or workers may remain. Known running workers also block this request. The failed action remains unknown and is never replayed.

- - - -
-
-
-
-

Session details

-
-
-
Owner
—
-
Session
{{ status.session_id || '—' }}
-
Application
{{ status.app || '—' }}
-
Last action / verification
{{ status.last_action || '—' }} / {{ status.last_verification || 'unavailable' }}
-
-
- -
-
-
-

Visual evidence

-

Frames are captured only on request. Opening or refreshing this view never captures or posts an image.

-
-
- - - -
-
-
-
{{ freshness }} · Captured {{ frame.captured_at }} · Private evidence expires {{ frame.expires_at }}
- Requested frame from the authorized application; read-only -
-

Frame expired. Observe again for current evidence.

-
- -
-
-
-
-

Export a saved workspace file

-

Choose one exact filename, not a host path. Downloads are never executed.

-
-
- -
- - -
-
-
- - Expires {{ artifact.expires_at }} -
-
-
-
`,setup(){const e=f({state:"unknown",available:!1}),t=f(!1),s=f(!1),a=f(!1),n=f(!1),i=f(!1),l=f(!1),o=f(""),r=f(!1),c=f(!1),d=f(!1),u=f(0),p=f(""),m=f(""),h=f(null),y=f(""),k=f(!1),A=f(Date.now()),g=f(""),b=f(null);let x=0,w=null,_=!1,T=z.token,L=0,E=null,F=null,O=!1;const I=J=>J===!0?"Enabled":J===!1?"Disabled":"Unknown",H=B(()=>{var J;return((J=e.value.backend)==null?void 0:J.environment)==="existing_session"}),te=B(()=>{var ie;const J=Date.parse(((ie=e.value.accessibility)==null?void 0:ie.checked_at)||"");return c.value&&Number.isFinite(J)&&A.value-J<15e3&&A.value>=J-5e3}),ee=B(()=>{var J;return te.value?I((J=e.value.accessibility)==null?void 0:J.enabled):"Unknown / not current"}),N=B(()=>{var J;return te.value?((J=e.value.accessibility)==null?void 0:J.reason)==="property_read"?"Last checked: "+e.value.accessibility.checked_at:"Property unavailable for this target. Isolated sessions, an unbound operator identity, an inactive accessibility service or an inaccessible session bus may prevent this read. Unknown does not mean disabled.":"No current property read; unknown does not mean disabled."}),M=B(()=>Object.entries(e.value.input_limits||{}).filter(([,J])=>typeof J=="number"&&Number.isFinite(J)).map(([J,ie])=>`${J}: ${ie}`).join(", ")),$=B(()=>{var ie;const J=(ie=e.value.application_provenance)==null?void 0:ie.script_identity;return typeof J=="string"?J:!J||typeof J!="object"?"Not observed":`${J.interpreter_basename||"Unknown interpreter"}; argv digest ${J.argv_digest||"not recorded"}; ${J.verified===!0?"verified":"not verified"}`}),re=B(()=>Array.isArray(e.value.application_profiles)?e.value.application_profiles.filter(J=>J&&typeof J.id=="string"&&typeof J.label=="string"&&["supported","capture_only"].includes(J.input)).slice(0,16):[]),ce=B(()=>{const J=e.value.restart_required;return Array.isArray(J)?J.length?J.join(", "):"None reported":J===!0?"Pending; restart required":J===!1?"None reported":"Unknown"}),Z=B(()=>{var ie,X;const J=Date.parse(((ie=h.value)==null?void 0:ie.captured_at)||"");return Number.isFinite(J)&&A.value2097152||!Number.isFinite(Date.parse(_e.frame.expires_at))||Date.parse(_e.frame.expires_at)<=Date.now())throw new Error("Invalid evidence");h.value=_e.frame,y.value=URL.createObjectURL(U),o.value=""}catch(_e){V(J,ie)&&Oe(_e)}finally{J===x&&(s.value=!1)}}async function he(){if(!be()||i.value||!e.value.available)return;b.value=null;const J=x,ie=z.token;i.value=!0;try{const X=await z.post("/api/computer/export",{name:g.value});V(J,ie)&&(b.value=X,o.value="")}catch(X){V(J,ie)&&Oe(X)}finally{J===x&&(i.value=!1)}}async function fe(){if(!be()||l.value||!b.value)return;const J=x,ie=z.token,X=b.value;l.value=!0;try{if(!/^[A-Za-z0-9_-]{8,128}$/.test((X==null?void 0:X.artifact_id)||""))throw new Error("Invalid export");const _e=await z.getBlob("/api/computer/download/"+X.artifact_id);if(!V(J,ie))return;const U=URL.createObjectURL(_e),de=document.createElement("a");de.href=U,de.download=X.name,de.click(),setTimeout(()=>URL.revokeObjectURL(U),1e3)}catch(_e){V(J,ie)&&Oe(_e)}finally{J===x&&(l.value=!1)}}function me(){_||(T!==z.token&&(T=z.token,ae(),u.value=0,F=null,e.value={state:"unknown",available:!1},o.value="",p.value=""),_=!0,le(),w=setInterval(()=>{A.value=Date.now(),T!==z.token&&(T=z.token,ae(),u.value=0,F=null,o.value="",p.value="",e.value={state:"unknown",available:!1}),c.value&&A.value-u.value>=15e3&&ae(),h.value&&Date.parse(h.value.expires_at)<=A.value&&(Q(),k.value=!0),b.value&&Date.parse(b.value.expires_at)<=A.value&&(b.value=null),!O&&A.value-L>=5e3&&le()},500))}function we(){_=!1,clearInterval(w),w=null,ae(),c.value=!1}return st(me),cs(me),es(we),wt(we),{status:e,checkedAt:u,remedy:p,loading:t,observing:s,stopping:a,pausing:n,exporting:i,downloading:l,error:o,frame:h,frameUrl:y,frameExpired:k,freshness:Z,name:g,artifact:b,refresh:le,control:j,observe:G,clearFrame:Q,exportFile:he,download:fe,toggling:r,adminReady:c,enabledLabel:I,restartSettings:ce,setEnabled:ve,recovering:d,recover:xe,reconcile:S,releaseOwnedInput:Y,reconciliationAck:m,applicationProfiles:re,attached:H,scriptIdentity:$,inputLimits:M,accessibilityLabel:ee,accessibilityDetail:N}}},Kg=[{id:"health",label:"Health",component:yC},{id:"resources",label:"Resources",component:xC},{id:"logs",label:"Logs",component:LC},{id:"config",label:"Config",component:qC},{id:"discord",label:"Discord",component:GC},{id:"hosts",label:"Hosts",component:JC},{id:"host-access",label:"Host Access",component:KC},{id:"api-tokens",label:"API Tokens",component:ZC},{id:"llm",label:"LLM Config",component:aT},{id:"internals",label:"Internals",component:iT},{id:"turn-state",label:"Turn State",component:rT},{id:"computer",label:"Computer",component:pT},{id:"update",label:"Update",component:cT}],fT={components:{TabbedPage:uc},setup(){return{tabs:Kg}},template:''},Qo=(e,t,s,a)=>a.map(({id:n,label:i})=>({group:e,label:i,icon:t,to:{path:s,query:{tab:n}}})),mT=[{group:"Workspace",label:"Dashboard",icon:"dashboard",to:{path:"/dashboard"}},{group:"Workspace",label:"Chat",icon:"chat",to:{path:"/chat"}},...Qo("Operations","operations","/operations",Fg),...Qo("History","history","/history",Bg),...Qo("Capabilities","capabilities","/capabilities",Ug),{group:"Manage",label:"Personality",icon:"personality",to:{path:"/personality"}},...Qo("System","system","/system",Kg)],Us=Mn({open:!1,query:"",selected:0});function nm(){Us.query="",Us.selected=0,Us.open=!0}function Yc(){Us.open=!1}function vT(e,t){const s=e.label.toLowerCase(),a=`${e.group} ${e.label}`.toLowerCase();return t?s.startsWith(t)?100:a.startsWith(t)?80:s.includes(t)?60:a.includes(t)?40:0:1}const hT={setup(){const e=Ig(),t=f(null),s=B(()=>{const i=Us.query.trim().toLowerCase();return mT.map(l=>({...l,_score:vT(l,i)})).filter(l=>l._score>0).sort((l,o)=>o._score-l._score)});Gt(()=>Us.open,async i=>{var l;i&&(await Ut(),(l=t.value)==null||l.focus())}),Gt(()=>Us.query,()=>{Us.selected=0});function a(i){Yc(),e.push(i.to)}function n(i){if(i.key==="Escape"){i.preventDefault(),Yc();return}if(i.key==="ArrowDown")i.preventDefault(),Us.selected=Math.min(Us.selected+1,s.value.length-1);else if(i.key==="ArrowUp")i.preventDefault(),Us.selected=Math.max(Us.selected-1,0);else if(i.key==="Enter"){i.preventDefault();const l=s.value[Us.selected];l&&a(l)}}return{state:Us,results:s,inputEl:t,go:a,onKeydown:n,closePalette:Yc}},template:` - - - - `},Ud={brand:"M12 3 4.5 8v8L12 21l7.5-5V8L12 3Zm0 4.2 4.6 3.1L12 16.8l-4.6-6.5L12 7.2Zm0 3.3v3.7",dashboard:"M4 13h6V4H4v9Zm0 7h6v-4H4v4Zm10 0h6v-9h-6v9Zm0-16v4h6V4h-6Z",chat:"M20 15a3 3 0 0 1-3 3H9l-5 3v-6a3 3 0 0 1-1-2.2V7a3 3 0 0 1 3-3h11a3 3 0 0 1 3 3v8Z",operations:"M5 12h3l2-6 4 12 2-6h3M4 4v16h16",history:"M4 12a8 8 0 1 0 2.3-5.7L4 8.5M4 4v4.5h4.5M12 7v5l3 2",home:"M3 11.5 12 4l9 7.5M5.5 10v10h13V10M9 20v-6h6v6",users:"M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2m7-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.9m-2-11.8a4 4 0 0 1 0 7.7",capabilities:"M14.7 6.3a4 4 0 0 0-5.6 5.6L4 17v3h3v-2h2v-2h2l1.1-1.1a4 4 0 0 0 5.6-5.6l-3 3-3-3 3-3Z",personality:"M12 3a8 8 0 0 0-8 8c0 4 3 7 7 7v3h3v-3c3 0 6-3 6-7a8 8 0 0 0-8-8ZM8.5 10h.01M15.5 10h.01M9 14c1.7 1.2 4.3 1.2 6 0",system:"M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4",menu:"M4 7h16M4 12h16M4 17h16",panelLeft:"M9 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4V4Zm0 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H9M6 8h.01M6 12h.01",chevronLeft:"m15 18-6-6 6-6",chevronRight:"m9 18 6-6-6-6",chevronDown:"m6 9 6 6 6-6",chevronUp:"m18 15-6-6-6 6",search:"m21 21-4.3-4.3M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z",logout:"M10 17l5-5-5-5m5 5H3m10-8h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5",success:"m5 12 4 4L19 6",warning:"M12 3 2.8 20h18.4L12 3Zm0 6v4m0 3h.01",info:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-8v4m0-8h.01",error:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm-3-12 6 6m0-6-6 6",edit:"M4 20h4l11-11-4-4L4 16v4Zm9-13 4 4",trash:"M4 7h16m-10 4v5m4-5v5M9 4h6l1 3H8l1-3Zm-3 3 1 13h10l1-13",brain:"M9 5a3 3 0 0 0-5 2.2A3.5 3.5 0 0 0 4 14a3 3 0 0 0 5 2.2V5Zm6 0a3 3 0 0 1 5 2.2 3.5 3.5 0 0 1 0 6.8 3 3 0 0 1-5 2.2V5ZM9 9H7m2 4H6m9-4h2m-2 4h3M12 4v16",refresh:"M20 6v5h-5M4 18v-5h5M18.5 10A7 7 0 0 0 6 7.5L4 11m16 2-2 3.5A7 7 0 0 1 5.5 14",close:"M6 6l12 12M18 6 6 18",command:"M7 8a3 3 0 1 1-3-3h3v14a3 3 0 1 1-3-3h13a3 3 0 1 1-3 3V5a3 3 0 1 1 3 3H7Z",external:"M14 4h6v6m0-6-9 9M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6",activity:"M4 12h4l2-5 4 10 2-5h4",shield:"M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z",database:"M20 6c0 1.7-3.6 3-8 3S4 7.7 4 6s3.6-3 8-3 8 1.3 8 3Zm0 0v6c0 1.7-3.6 3-8 3s-8-1.3-8-3V6m16 6v6c0 1.7-3.6 3-8 3s-8-1.3-8-3v-6",server:"M4 4h16v6H4V4Zm0 10h16v6H4v-6Zm3-7h.01M7 17h.01",terminal:"M5 7l4 4-4 4m6 1h8M3 4h18v16H3V4Z",wrench:"M14.7 6.3a4 4 0 0 0-5.6 5.6L4 17v3h3v-2h2v-2h2l1.1-1.1a4 4 0 0 0 5.6-5.6l-3 3-3-3 3-3Z",bot:"M8 4h8m-4-2v2M5 8h14a2 2 0 0 1 2 2v8H3v-8a2 2 0 0 1 2-2Zm3 4h.01M16 12h.01M8 16h8M3 13H1m22 0h-2",workflow:"M5 5h5v5H5V5Zm9 9h5v5h-5v-5ZM10 7.5h4a3 3 0 0 1 3 3V14M7.5 10v4a3 3 0 0 0 3 3H14",globe:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-18c2.2 2.5 3.3 5.5 3.3 9S14.2 18.5 12 21m0-18C9.8 5.5 8.7 8.5 8.7 12s1.1 6.5 3.3 9M3 12h18",book:"M4 5a3 3 0 0 1 3-2h5v17H7a3 3 0 0 0-3 1V5Zm16 0a3 3 0 0 0-3-2h-5v17h5a3 3 0 0 1 3 1V5Z",message:"M4 4h16v13H8l-4 4V4Zm4 5h8m-8 4h5",puzzle:"M9 4h3a2 2 0 1 1 4 0h4v5a2 2 0 1 0 0 4v7h-7a2 2 0 1 1-4 0H4v-7a2 2 0 1 0 0-4V4h5",sparkles:"m12 3 1.2 3.8L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.2L12 3Zm6 10 .8 2.2L21 16l-2.2.8L18 19l-.8-2.2L15 16l2.2-.8L18 13ZM5 14l1 2.8L9 18l-3 1.2L5 22l-1-2.8L1 18l3-1.2L5 14Z",link:"M9.5 14.5 14.5 9m-7 8H6a4 4 0 0 1 0-8h3m6 0h3a4 4 0 0 1 0 8h-3",file:"M6 3h8l4 4v14H6V3Zm8 0v5h5M9 13h6m-6 4h6",folder:"M3 6h7l2 2h9v11H3V6Z",image:"M4 4h16v16H4V4Zm3 12 4-4 3 3 2-2 4 4M9 9h.01",attachment:"m8 12 5-5a3 3 0 1 1 4 4l-7 7a5 5 0 0 1-7-7l7-7",clock:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v5l3 2",calendar:"M5 5h14v15H5V5Zm3-2v4m8-4v4M5 10h14",chart:"M4 20V10m5 10V4m5 16v-7m5 7V7M2 20h20",sliders:"M4 7h10m4 0h2M4 17h2m4 0h10M16 4v6M8 14v6",code:"m9 6-6 6 6 6m6-12 6 6-6 6",copy:"M8 8h11v12H8V8Zm-3 8H4V4h11v1",play:"m8 5 11 7-11 7V5Z",grid:"M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h6v6h-6v-6Z",list:"M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",target:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-5a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0-4h.01",rotate:"M20 6v5h-5M4 18v-5h5M18.5 10A7 7 0 0 0 6 7.5L4 11m16 2-2 3.5A7 7 0 0 1 5.5 14",archive:"M4 8h16v12H4V8Zm-1-4h18v4H3V4Zm6 8h6",flame:"M12 22c4 0 7-3 7-7 0-5-4-7-4-11-3 2-5 5-5 8-1-1-2-3-1-5-3 2-5 5-5 8 0 4 3 7 8 7Z",eye:"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z",upload:"M12 16V4m-5 5 5-5 5 5M5 20h14",download:"M12 4v12m-5-5 5 5 5-5M5 20h14",undo:"M9 7 4 12l5 5m-5-5h10a6 6 0 0 1 6 6",redo:"m15 7 5 5-5 5m5-5H10a6 6 0 0 0-6 6",minus:"M5 12h14",plus:"M12 5v14M5 12h14",network:"M12 3v4m0 10v4M3 12h4m10 0h4M7.8 7.8l2.1 2.1m4.2 4.2 2.1 2.1m0-8.4-2.1 2.1m-4.2 4.2-2.1 2.1M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",more:"M6 12h.01M12 12h.01M18 12h.01",pause:"M9 5v14m6-14v14",sort:"M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"};Object.freeze(Object.keys(Ud));const gT={name:"OdinIcon",props:{name:{type:String,required:!0},size:{type:[Number,String],default:18},strokeWidth:{type:[Number,String],default:1.8}},setup(e,{attrs:t}){return()=>qi("svg",{...t,class:["odin-icon",t.class],width:e.size,height:e.size,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":e.strokeWidth,"stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":t["aria-label"]?void 0:"true",focusable:"false"},[qi("path",{d:Ud[e.name]||Ud.info})])}},bT=["a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])'].join(",");function im(e){return[...e.querySelectorAll(bT)].filter(t=>!t.hasAttribute("hidden")&&t.getAttribute("aria-hidden")!=="true")}const yT={mounted(e){const t=document.activeElement,s=a=>{if(a.key!=="Tab")return;const n=im(e);if(!n.length){a.preventDefault(),e.focus();return}const i=n[0],l=n[n.length-1];a.shiftKey&&document.activeElement===i?(a.preventDefault(),l.focus()):!a.shiftKey&&document.activeElement===l&&(a.preventDefault(),i.focus())};e.__odinModalFocus={previous:t,onKeydown:s},e.addEventListener("keydown",s),requestAnimationFrame(()=>{(e.querySelector("[autofocus]")||im(e)[0]||e).focus()})},unmounted(e){var s;const t=e.__odinModalFocus;t&&(e.removeEventListener("keydown",t.onKeydown),(s=t.previous)!=null&&s.isConnected&&typeof t.previous.focus=="function"&&requestAnimationFrame(()=>t.previous.focus()),delete e.__odinModalFocus)}},xT={template:` -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - -
- -
-
-
- - - - - -
-
-
Odin
-
- - {{ status.status === 'online' ? 'Online' : 'Starting' }} - · - {{ uptime }} -
-
-
-
- - - -
-
- - -
-
-
- - {{ s.label }} -
-
{{ s.value }}
-
{{ s.sub }}
-
-
- - -
-
System Health
-
-
- - {{ h.label }} - {{ h.detail }} -
-
-
- - -
- - -
-
- Active Agents - {{ agents.length }} -
-
- - No active agents -
-
-
-
- - {{ a.label }} - {{ a.iteration_count }} iters -
-
{{ a.goal }}
-
- {{ formatDuration(a.runtime_seconds) }} - {{ a.tools_used_count }} tools -
-
-
-
- - -
-
- - Recent Activity - +{{ newEventCount }} - - -
-
Loading...
-
- - No recent activity -
-
-
- - {{ a.tool_name }} - {{ formatTime(a.timestamp) }} -
-
-
- - -
- -
-
- Guilds -
-
- - No guilds -
-
-
- - {{ g.name }} - {{ g.member_count }} -
-
-
- - -
-
- Recent Errors - {{ errors.length }} -
-
- - Couldn't load recent errors -
-
- - All clear -
-
-
Refresh failed — showing known errors
-
-
- - {{ e.tool_name }} - {{ formatTime(e.timestamp) }} -
-
{{ e.error }}
-
-
-
-
-
-
-
`,setup(){const e=f({}),t=f(!0),s=f(null),a=f([]),n=f(!1),i=f([]),l=f(!1),o=f(!1),r=f([]),c=f(0),d=f(null),u=f({reload:!1,clearSessions:!1,stopLoops:!1});let p=0;const m=B(()=>{const ce=e.value.uptime_seconds||0,Z=Math.floor(ce/86400),Q=Math.floor(ce%86400/3600),ae=Math.floor(ce%3600/60),V=[];return Z>0&&V.push(`${Z}d`),Q>0&&V.push(`${Q}h`),(V.length===0||Z===0&&Q===0)&&V.push(`${ae}m`),V.join(" ")}),h=B(()=>{const ce=e.value.uptime_seconds||0;return 125.66*(1-Math.min(ce/86400,1))}),y=B(()=>{const ce=e.value;return[{label:"Guilds",value:ce.guild_count??0,icon:"home",iconColor:"text-blue-400"},{label:"Sessions",value:ce.session_count??0,icon:"message",iconColor:"text-yellow-400"},{label:"Tools",value:ce.tool_count??0,icon:"wrench",iconColor:"text-purple-400",sub:`${ce.skill_count??0} skills`,subColor:"text-gray-500"},{label:"Loops",value:ce.loop_count??0,icon:"rotate",iconColor:"text-green-400",color:ce.loop_count>0?"text-green-400":"",highlight:ce.loop_count>0},{label:"Agents",value:ce.agent_running??0,icon:"bot",iconColor:"text-cyan-400",sub:ce.agent_count>0?`${ce.agent_count} total`:"",subColor:"text-gray-500",highlight:(ce.agent_running??0)>0},{label:"Processes",value:ce.process_running??0,icon:"sliders",iconColor:"text-orange-400",sub:ce.process_count>0?`${ce.process_count} total`:"",subColor:"text-gray-500",highlight:(ce.process_running??0)>0},{label:"Schedules",value:ce.schedule_count??0,icon:"clock",iconColor:"text-amber-400",sub:(ce.schedule_failing>0?`${ce.schedule_failing} failing`:"")+(ce.schedule_failing>0&&ce.schedule_paused>0?", ":"")+(ce.schedule_paused>0?`${ce.schedule_paused} paused`:"")||void 0,subColor:ce.schedule_failing>0?"text-red-400":"text-yellow-400",color:ce.schedule_failing>0?"text-red-400":"",highlight:ce.schedule_failing>0},{label:"Users",value:ce.user_count??0,icon:"users",iconColor:"text-indigo-400"},...d.value!==null?[{label:"Knowledge",value:d.value,icon:"book",iconColor:"text-teal-400",sub:"chunks",subColor:"text-gray-500"}]:[]]}),k=B(()=>{const ce=e.value,Z=[];return Z.push({label:"Bot",status:ce.status==="online"?"ok":"warn",detail:ce.status==="online"?"Online":"Starting"}),(ce.schedule_failing||0)>0?Z.push({label:"Schedules",status:"error",detail:`${ce.schedule_failing} failing`}):(ce.schedule_count||0)>0&&Z.push({label:"Schedules",status:"ok",detail:`${ce.schedule_count} configured`}),(ce.loop_count||0)>0&&Z.push({label:"Loops",status:"ok",detail:`${ce.loop_count} active`}),(ce.agent_running||0)>0&&Z.push({label:"Agents",status:"ok",detail:`${ce.agent_running} running`}),(ce.process_running||0)>0&&Z.push({label:"Processes",status:"ok",detail:`${ce.process_running} running`}),Z});async function A(){try{e.value=await z.get("/api/status"),s.value=null}catch(ce){s.value=ce.message}finally{t.value=!1}}let g=0,b=0,x=0,w=0;function _(ce,Z){const Q=new Set;return[...Z,...ce].filter(ae=>{const V=ae._hmac||JSON.stringify([ae.timestamp,ae.tool_name,ae.user_id,ae.result_summary,ae.error]);return Q.has(V)?!1:(Q.add(V),!0)})}async function T(){const ce=++g,Z=x;n.value=!0;try{const Q=await z.get("/api/audit?limit=10");if(ce!==g)return;const ae=Z===x?[]:a.value.filter(V=>(V._liveEpoch||0)>Z);a.value=_(Q,ae).slice(0,10),c.value=ae.length}catch{}ce===g&&(n.value=!1)}async function L(){const ce=++b,Z=w;l.value=!0;try{const Q=await z.get("/api/audit?error_only=1&limit=5");if(ce!==b)return;const ae=Z===w?[]:i.value.filter(V=>(V._liveErrorEpoch||0)>Z);i.value=_(Q,ae).slice(0,5),o.value=!1}catch{if(ce!==b)return;o.value=Z===w||i.value.length===0}ce===b&&(l.value=!1)}async function E(){try{const ce=await z.get("/api/knowledge");d.value=(Array.isArray(ce)?ce:[]).reduce((Z,Q)=>Z+(Q.chunks||0),0)}catch{d.value=null}}async function F(){try{const ce=await z.get("/api/agents");r.value=ce.filter(Z=>Z.status==="running")}catch{}}async function O(){u.value={...u.value,reload:!0};try{await z.post("/api/reload"),Se.success("Config reloaded")}catch(ce){Se.error(ce.message)}u.value={...u.value,reload:!1}}async function I(){if(!await is({title:"Clear all sessions",message:"Clear all conversation sessions? This cannot be undone.",confirmLabel:"Clear All",danger:!0}))return;u.value={...u.value,clearSessions:!0};const Z=e.value.session_count;e.value={...e.value,session_count:0};try{const Q=await z.post("/api/sessions/clear-all");Se.success(`Cleared ${Q.count} session${Q.count!==1?"s":""}`),await A()}catch(Q){e.value={...e.value,session_count:Z},Se.error(Q.message)}u.value={...u.value,clearSessions:!1}}async function H(){if(!await is({title:"Stop all loops",message:"Stop all running loops?",confirmLabel:"Stop Loops",danger:!0}))return;u.value={...u.value,stopLoops:!0};const Z=e.value.loop_count;e.value={...e.value,loop_count:0};try{const Q=await z.post("/api/loops/stop-all");Se.success(Q.result),await A()}catch(Q){e.value={...e.value,loop_count:Z},Se.error(Q.message)}u.value={...u.value,stopLoops:!1}}function te(){t.value=!0,s.value=null,A(),T(),L(),F()}let ee=null,N=null,M=null;function $(ce){if(ce.payload&&ce.payload.tool_name){x+=1;const Z={...ce.payload,_isNew:!0,_key:++p,_liveEpoch:x};a.value.unshift(Z),a.value.length>10&&a.value.pop(),c.value++,Z.error&&(w+=1,Z._liveErrorEpoch=w,o.value=!1,i.value.unshift(Z),i.value.length>5&&i.value.pop()),setTimeout(()=>{Z._isNew=!1},1500),clearTimeout(M),M=setTimeout(()=>{c.value=0},1e4)}}let re=null;return st(async()=>{await Promise.all([A(),T(),L(),F(),E()]),ee=setInterval(A,15e3),N=setInterval(F,1e4),mt.subscribe("events",$),re=mt.onReconnected(()=>{T(),L()})}),wt(()=>{ee&&clearInterval(ee),N&&clearInterval(N),clearTimeout(M),mt.unsubscribe("events",$),re&&(re(),re=null)}),{status:e,loading:t,error:s,uptime:m,uptimeRingOffset:h,stats:y,healthIndicators:k,activity:a,activityLoading:n,newEventCount:c,errors:i,errorsLoading:l,errorsError:o,agents:r,actionLoading:u,fetchActivity:T,fetchErrors:L,fetchStatus:A,onEvent:$,formatTime:w1,formatDuration:Xi,retry:te,reloadConfig:O,clearSessions:I,stopAllLoops:H}}};/*! @license DOMPurify 3.4.9 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.9/LICENSE */function lm(e,t){(t==null||t>e.length)&&(t=e.length);for(var s=0,a=Array(t);s2?a-2:0),i=2;i1?s-1:0),n=1;n"u"?null:ts(BigInt.prototype.toString),pm=typeof Symbol>"u"?null:ts(Symbol.prototype.toString),jt=ts(Object.prototype.hasOwnProperty),kl=ts(Object.prototype.toString),us=ts(RegExp.prototype.test),qn=MT(TypeError);function ts(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);for(var s=arguments.length,a=new Array(s>1?s-1:0),n=1;n2&&arguments[2]!==void 0?arguments[2]:Il;if(om&&om(e,null),!Os(t))return e;let a=t.length;for(;a--;){let n=t[a];if(typeof n=="string"){const i=s(n);i!==n&&(TT(t)||(t[a]=i),n=i)}e[n]=!0}return e}function DT(e){for(let t=0;t/g),HT=da(/\${[\w\W]*/g),jT=da(/^data-[\-\w.\u00B7-\uFFFF]+$/),qT=da(/^aria-[\-\w]+$/),gm=da(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),VT=da(/^(?:\w+script|data):/i),GT=da(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),WT=da(/^html$/i),KT=da(/^[a-z][.\w]*(-[.\w]+)+$/i),Ca={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},JT=function(){return typeof window>"u"?null:window},ZT=function(t,s){if(typeof t!="object"||typeof t.createPolicy!="function")return null;let a=null;const n="data-tt-policy-suffix";s&&s.hasAttribute(n)&&(a=s.getAttribute(n));const i="dompurify"+(a?"#"+a:"");try{return t.createPolicy(i,{createHTML(l){return l},createScriptURL(l){return l}})}catch{return console.warn("TrustedTypes policy "+i+" could not be created."),null}},bm=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}};function Qg(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:JT();const t=Ie=>Qg(Ie);if(t.version="3.4.9",t.removed=[],!e||!e.document||e.document.nodeType!==Ca.document||!e.Element)return t.isSupported=!1,t;let s=e.document;const a=s,n=a.currentScript;e.DocumentFragment;const i=e.HTMLTemplateElement,l=e.Node,o=e.Element,r=e.NodeFilter,c=e.NamedNodeMap;c===void 0&&(e.NamedNodeMap||e.MozNamedAttrMap),e.HTMLFormElement;const d=e.DOMParser,u=e.trustedTypes,p=o.prototype,m=Ea(p,"cloneNode"),h=Ea(p,"remove"),y=Ea(p,"nextSibling"),k=Ea(p,"childNodes"),A=Ea(p,"parentNode"),g=Ea(p,"shadowRoot"),b=Ea(p,"attributes"),x=l&&l.prototype?Ea(l.prototype,"nodeType"):null,w=l&&l.prototype?Ea(l.prototype,"nodeName"):null;if(typeof i=="function"){const Ie=s.createElement("template");Ie.content&&Ie.content.ownerDocument&&(s=Ie.content.ownerDocument)}let _,T="",L,E=!1,F=0;const O=function(){if(F>0)throw qn('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},I=function(D){O(),F++;try{return _.createHTML(D)}finally{F--}},H=function(D){O(),F++;try{return _.createScriptURL(D)}finally{F--}},te=function(){return E||(L=ZT(u,n),E=!0),L},ee=s,N=ee.implementation,M=ee.createNodeIterator,$=ee.createDocumentFragment,re=ee.getElementsByTagName,ce=a.importNode;let Z=bm();t.isSupported=typeof Jg=="function"&&typeof A=="function"&&N&&N.createHTMLDocument!==void 0;const Q=UT,ae=zT,V=HT,be=jT,Oe=qT,le=VT,oe=GT,ve=KT;let j=gm,Y=null;const xe=nt({},[...fm,...ed,...td,...sd,...mm]);let S=null;const P=nt({},[...vm,...ad,...hm,...Yo]);let G=Object.seal(Ai(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),he=null,fe=null;const me=Object.seal(Ai(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let we=!0,J=!0,ie=!1,X=!0,_e=!1,U=!0,de=!1,ge=!1,Ae=!1,$e=!1,We=!1,je=!1,Xe=!0,it=!1;const Qe="user-content-";let se=!0,Re=!1,Le={},Te=null;const W=nt({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let De=null;const ze=nt({},["audio","video","img","source","image","track"]);let Ye=null;const Dt=nt({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",Rt="http://www.w3.org/2000/svg",Pt="http://www.w3.org/1999/xhtml";let Ts=Pt,Vs=!1,Gs=null;const zt=nt({},[et,Rt,Pt],Xc);let ta=nt({},["mi","mo","mn","ms","mtext"]),Ws=nt({},["annotation-xml"]);const Pa=nt({},["title","style","font","a","script"]);let hs=null;const $a=["application/xhtml+xml","text/html"],Es="text/html";let Ct=null,ls=null;const sa=s.createElement("form"),ba=function(D){return D instanceof RegExp||D instanceof Function},Fe=function(){let D=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(ls&&ls===D)return;(!D||typeof D!="object")&&(D={}),D=xs(D),hs=$a.indexOf(D.PARSER_MEDIA_TYPE)===-1?Es:D.PARSER_MEDIA_TYPE,Ct=hs==="application/xhtml+xml"?Xc:Il,Y=jt(D,"ALLOWED_TAGS")&&Os(D.ALLOWED_TAGS)?nt({},D.ALLOWED_TAGS,Ct):xe,S=jt(D,"ALLOWED_ATTR")&&Os(D.ALLOWED_ATTR)?nt({},D.ALLOWED_ATTR,Ct):P,Gs=jt(D,"ALLOWED_NAMESPACES")&&Os(D.ALLOWED_NAMESPACES)?nt({},D.ALLOWED_NAMESPACES,Xc):zt,Ye=jt(D,"ADD_URI_SAFE_ATTR")&&Os(D.ADD_URI_SAFE_ATTR)?nt(xs(Dt),D.ADD_URI_SAFE_ATTR,Ct):Dt,De=jt(D,"ADD_DATA_URI_TAGS")&&Os(D.ADD_DATA_URI_TAGS)?nt(xs(ze),D.ADD_DATA_URI_TAGS,Ct):ze,Te=jt(D,"FORBID_CONTENTS")&&Os(D.FORBID_CONTENTS)?nt({},D.FORBID_CONTENTS,Ct):W,he=jt(D,"FORBID_TAGS")&&Os(D.FORBID_TAGS)?nt({},D.FORBID_TAGS,Ct):xs({}),fe=jt(D,"FORBID_ATTR")&&Os(D.FORBID_ATTR)?nt({},D.FORBID_ATTR,Ct):xs({}),Le=jt(D,"USE_PROFILES")?D.USE_PROFILES&&typeof D.USE_PROFILES=="object"?xs(D.USE_PROFILES):D.USE_PROFILES:!1,we=D.ALLOW_ARIA_ATTR!==!1,J=D.ALLOW_DATA_ATTR!==!1,ie=D.ALLOW_UNKNOWN_PROTOCOLS||!1,X=D.ALLOW_SELF_CLOSE_IN_ATTR!==!1,_e=D.SAFE_FOR_TEMPLATES||!1,U=D.SAFE_FOR_XML!==!1,de=D.WHOLE_DOCUMENT||!1,$e=D.RETURN_DOM||!1,We=D.RETURN_DOM_FRAGMENT||!1,je=D.RETURN_TRUSTED_TYPE||!1,Ae=D.FORCE_BODY||!1,Xe=D.SANITIZE_DOM!==!1,it=D.SANITIZE_NAMED_PROPS||!1,se=D.KEEP_CONTENT!==!1,Re=D.IN_PLACE||!1,j=$T(D.ALLOWED_URI_REGEXP)?D.ALLOWED_URI_REGEXP:gm,Ts=typeof D.NAMESPACE=="string"?D.NAMESPACE:Pt,ta=jt(D,"MATHML_TEXT_INTEGRATION_POINTS")&&D.MATHML_TEXT_INTEGRATION_POINTS&&typeof D.MATHML_TEXT_INTEGRATION_POINTS=="object"?xs(D.MATHML_TEXT_INTEGRATION_POINTS):nt({},["mi","mo","mn","ms","mtext"]),Ws=jt(D,"HTML_INTEGRATION_POINTS")&&D.HTML_INTEGRATION_POINTS&&typeof D.HTML_INTEGRATION_POINTS=="object"?xs(D.HTML_INTEGRATION_POINTS):nt({},["annotation-xml"]);const pe=jt(D,"CUSTOM_ELEMENT_HANDLING")&&D.CUSTOM_ELEMENT_HANDLING&&typeof D.CUSTOM_ELEMENT_HANDLING=="object"?xs(D.CUSTOM_ELEMENT_HANDLING):Ai(null);if(G=Ai(null),jt(pe,"tagNameCheck")&&ba(pe.tagNameCheck)&&(G.tagNameCheck=pe.tagNameCheck),jt(pe,"attributeNameCheck")&&ba(pe.attributeNameCheck)&&(G.attributeNameCheck=pe.attributeNameCheck),jt(pe,"allowCustomizedBuiltInElements")&&typeof pe.allowCustomizedBuiltInElements=="boolean"&&(G.allowCustomizedBuiltInElements=pe.allowCustomizedBuiltInElements),_e&&(J=!1),We&&($e=!0),Le&&(Y=nt({},mm),S=Ai(null),Le.html===!0&&(nt(Y,fm),nt(S,vm)),Le.svg===!0&&(nt(Y,ed),nt(S,ad),nt(S,Yo)),Le.svgFilters===!0&&(nt(Y,td),nt(S,ad),nt(S,Yo)),Le.mathMl===!0&&(nt(Y,sd),nt(S,hm),nt(S,Yo))),me.tagCheck=null,me.attributeCheck=null,jt(D,"ADD_TAGS")&&(typeof D.ADD_TAGS=="function"?me.tagCheck=D.ADD_TAGS:Os(D.ADD_TAGS)&&(Y===xe&&(Y=xs(Y)),nt(Y,D.ADD_TAGS,Ct))),jt(D,"ADD_ATTR")&&(typeof D.ADD_ATTR=="function"?me.attributeCheck=D.ADD_ATTR:Os(D.ADD_ATTR)&&(S===P&&(S=xs(S)),nt(S,D.ADD_ATTR,Ct))),jt(D,"ADD_URI_SAFE_ATTR")&&Os(D.ADD_URI_SAFE_ATTR)&&nt(Ye,D.ADD_URI_SAFE_ATTR,Ct),jt(D,"FORBID_CONTENTS")&&Os(D.FORBID_CONTENTS)&&(Te===W&&(Te=xs(Te)),nt(Te,D.FORBID_CONTENTS,Ct)),jt(D,"ADD_FORBID_CONTENTS")&&Os(D.ADD_FORBID_CONTENTS)&&(Te===W&&(Te=xs(Te)),nt(Te,D.ADD_FORBID_CONTENTS,Ct)),se&&(Y["#text"]=!0),de&&nt(Y,["html","head","body"]),Y.table&&(nt(Y,["tbody"]),delete he.tbody),D.TRUSTED_TYPES_POLICY){if(typeof D.TRUSTED_TYPES_POLICY.createHTML!="function")throw qn('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof D.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw qn('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const ke=_;_=D.TRUSTED_TYPES_POLICY;try{T=I("")}catch(Be){throw _=ke,Be}}else D.TRUSTED_TYPES_POLICY===null?(_=void 0,T=""):(_===void 0&&(_=te()),_&&typeof T=="string"&&(T=I("")));(Z.uponSanitizeElement.length>0||Z.uponSanitizeAttribute.length>0)&&Y===xe&&(Y=xs(Y)),Z.uponSanitizeAttribute.length>0&&S===P&&(S=xs(S)),Ps&&Ps(D),ls=D},ue=nt({},[...ed,...td,...FT]),Ce=nt({},[...sd,...BT]),qe=function(D){let pe=A(D);(!pe||!pe.tagName)&&(pe={namespaceURI:Ts,tagName:"template"});const ke=Il(D.tagName),Be=Il(pe.tagName);return Gs[D.namespaceURI]?D.namespaceURI===Rt?pe.namespaceURI===Pt?ke==="svg":pe.namespaceURI===et?ke==="svg"&&(Be==="annotation-xml"||ta[Be]):!!ue[ke]:D.namespaceURI===et?pe.namespaceURI===Pt?ke==="math":pe.namespaceURI===Rt?ke==="math"&&Ws[Be]:!!Ce[ke]:D.namespaceURI===Pt?pe.namespaceURI===Rt&&!Ws[Be]||pe.namespaceURI===et&&!ta[Be]?!1:!Ce[ke]&&(Pa[ke]||!ue[ke]):!!(hs==="application/xhtml+xml"&&Gs[D.namespaceURI]):!1},Ge=function(D){ki(t.removed,{element:D});try{A(D).removeChild(D)}catch{if(h(D),!A(D))throw qn("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},It=function(D){const pe=k?k(D):D.childNodes;if(pe){const Be=[];qa(pe,Ue=>{ki(Be,Ue)}),qa(Be,Ue=>{try{h(Ue)}catch{}})}const ke=b?b(D):null;if(ke)for(let Be=ke.length-1;Be>=0;--Be){const Ue=ke[Be],Ke=Ue&&Ue.name;if(typeof Ke=="string")try{D.removeAttribute(Ke)}catch{}}},ot=function(D,pe){try{ki(t.removed,{attribute:pe.getAttributeNode(D),from:pe})}catch{ki(t.removed,{attribute:null,from:pe})}if(pe.removeAttribute(D),D==="is")if($e||We)try{Ge(pe)}catch{}else try{pe.setAttribute(D,"")}catch{}},pn=function(D){const pe=b?b(D):D.attributes;if(pe)for(let ke=pe.length-1;ke>=0;--ke){const Be=pe[ke],Ue=Be&&Be.name;if(!(typeof Ue!="string"||S[Ct(Ue)]))try{D.removeAttribute(Ue)}catch{}}},pi=function(D){const pe=[D];for(;pe.length>0;){const ke=pe.pop();(x?x(ke):ke.nodeType)===Ca.element&&pn(ke);const Ue=k?k(ke):ke.childNodes;if(Ue)for(let Ke=Ue.length-1;Ke>=0;--Ke)pe.push(Ue[Ke])}},Fa=function(D){let pe=null,ke=null;if(Ae)D=""+D;else{const Ke=cm(D,/^[\r\n\t ]+/);ke=Ke&&Ke[0]}hs==="application/xhtml+xml"&&Ts===Pt&&(D=''+D+"");const Be=_?I(D):D;if(Ts===Pt)try{pe=new d().parseFromString(Be,hs)}catch{}if(!pe||!pe.documentElement){pe=N.createDocument(Ts,"template",null);try{pe.documentElement.innerHTML=Vs?T:Be}catch{}}const Ue=pe.body||pe.documentElement;return D&&ke&&Ue.insertBefore(s.createTextNode(ke),Ue.childNodes[0]||null),Ts===Pt?re.call(pe,de?"html":"body")[0]:de?pe.documentElement:Ue},fi=function(D){return M.call(D.ownerDocument||D,D,r.SHOW_ELEMENT|r.SHOW_COMMENT|r.SHOW_TEXT|r.SHOW_PROCESSING_INSTRUCTION|r.SHOW_CDATA_SECTION,null)},Ba=function(D){var pe,ke;D.normalize();const Be=M.call(D.ownerDocument||D,D,r.SHOW_TEXT|r.SHOW_COMMENT|r.SHOW_CDATA_SECTION|r.SHOW_PROCESSING_INSTRUCTION,null);let Ue=Be.nextNode();for(;Ue;){let $t=Ue.data;qa([Q,ae,V],gt=>{$t=Si($t,gt," ")}),Ue.data=$t,Ue=Be.nextNode()}const Ke=(pe=(ke=D.querySelectorAll)===null||ke===void 0?void 0:ke.call(D,"template"))!==null&&pe!==void 0?pe:[];qa(Array.from(Ke),$t=>{kt($t.content)&&Ba($t.content)})},fn=function(D){const pe=w?w(D):null;return typeof pe!="string"||Ct(pe)!=="form"?!1:typeof D.nodeName!="string"||typeof D.textContent!="string"||typeof D.removeChild!="function"||D.attributes!==b(D)||typeof D.removeAttribute!="function"||typeof D.setAttribute!="function"||typeof D.namespaceURI!="string"||typeof D.insertBefore!="function"||typeof D.hasChildNodes!="function"||D.nodeType!==x(D)||D.childNodes!==k(D)},kt=function(D){if(!x||typeof D!="object"||D===null)return!1;try{return x(D)===Ca.documentFragment}catch{return!1}},$s=function(D){if(!x||typeof D!="object"||D===null)return!1;try{return typeof x(D)=="number"}catch{return!1}};function Wt(Ie,D,pe){qa(Ie,ke=>{ke.call(t,D,pe,ls)})}const ya=function(D){let pe=null;if(Wt(Z.beforeSanitizeElements,D,null),fn(D))return Ge(D),!0;const ke=Ct(w?w(D):D.nodeName);if(Wt(Z.uponSanitizeElement,D,{tagName:ke,allowedTags:Y}),U&&D.hasChildNodes()&&!$s(D.firstElementChild)&&us(/<[/\w!]/g,D.innerHTML)&&us(/<[/\w!]/g,D.textContent)||U&&D.namespaceURI===Pt&&ke==="style"&&$s(D.firstElementChild)||D.nodeType===Ca.progressingInstruction||U&&D.nodeType===Ca.comment&&us(/<[/\w]/g,D.data))return Ge(D),!0;if(he[ke]||!(me.tagCheck instanceof Function&&me.tagCheck(ke))&&!Y[ke]){if(!he[ke]&&mn(ke)&&(G.tagNameCheck instanceof RegExp&&us(G.tagNameCheck,ke)||G.tagNameCheck instanceof Function&&G.tagNameCheck(ke)))return!1;if(se&&!Te[ke]){const Ue=A(D),Ke=k(D);if(Ke&&Ue){const $t=Ke.length;for(let gt=$t-1;gt>=0;--gt){const Ot=Re?Ke[gt]:m(Ke[gt],!0);Ue.insertBefore(Ot,y(D))}}}return Ge(D),!0}return(x?x(D):D.nodeType)===Ca.element&&!qe(D)||(ke==="noscript"||ke==="noembed"||ke==="noframes")&&us(/<\/no(script|embed|frames)/i,D.innerHTML)?(Ge(D),!0):(_e&&D.nodeType===Ca.text&&(pe=D.textContent,qa([Q,ae,V],Ue=>{pe=Si(pe,Ue," ")}),D.textContent!==pe&&(ki(t.removed,{element:D.cloneNode()}),D.textContent=pe)),Wt(Z.afterSanitizeElements,D,null),!1)},xa=function(D,pe,ke){if(fe[pe]||Xe&&(pe==="id"||pe==="name")&&(ke in s||ke in sa))return!1;const Be=S[pe]||me.attributeCheck instanceof Function&&me.attributeCheck(pe,D);if(!(J&&!fe[pe]&&us(be,pe))){if(!(we&&us(Oe,pe))){if(!Be||fe[pe]){if(!(mn(D)&&(G.tagNameCheck instanceof RegExp&&us(G.tagNameCheck,D)||G.tagNameCheck instanceof Function&&G.tagNameCheck(D))&&(G.attributeNameCheck instanceof RegExp&&us(G.attributeNameCheck,pe)||G.attributeNameCheck instanceof Function&&G.attributeNameCheck(pe,D))||pe==="is"&&G.allowCustomizedBuiltInElements&&(G.tagNameCheck instanceof RegExp&&us(G.tagNameCheck,ke)||G.tagNameCheck instanceof Function&&G.tagNameCheck(ke))))return!1}else if(!Ye[pe]){if(!us(j,Si(ke,oe,""))){if(!((pe==="src"||pe==="xlink:href"||pe==="href")&&D!=="script"&&dm(ke,"data:")===0&&De[D])){if(!(ie&&!us(le,Si(ke,oe,"")))){if(ke)return!1}}}}}}return!0},Pn=nt({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),mn=function(D){return!Pn[Il(D)]&&us(ve,D)},$n=function(D){Wt(Z.beforeSanitizeAttributes,D,null);const pe=D.attributes;if(!pe||fn(D))return;const ke={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:S,forceKeepAttr:void 0};let Be=pe.length;for(;Be--;){const Ue=pe[Be],Ke=Ue.name,$t=Ue.namespaceURI,gt=Ue.value,Ot=Ct(Ke),ds=gt;let ut=Ke==="value"?ds:OT(ds);if(ke.attrName=Ot,ke.attrValue=ut,ke.keepAttr=!0,ke.forceKeepAttr=void 0,Wt(Z.uponSanitizeAttribute,D,ke),ut=ke.attrValue,it&&(Ot==="id"||Ot==="name")&&dm(ut,Qe)!==0&&(ot(Ke,D),ut=Qe+ut),U&&us(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,ut)){ot(Ke,D);continue}if(Ot==="attributename"&&cm(ut,"href")){ot(Ke,D);continue}if(ke.forceKeepAttr)continue;if(!ke.keepAttr){ot(Ke,D);continue}if(!X&&us(/\/>/i,ut)){ot(Ke,D);continue}_e&&qa([Q,ae,V],hn=>{ut=Si(ut,hn," ")});const ua=Ct(D.nodeName);if(!xa(ua,Ot,ut)){ot(Ke,D);continue}if(_&&typeof u=="object"&&typeof u.getAttributeType=="function"&&!$t)switch(u.getAttributeType(ua,Ot)){case"TrustedHTML":{ut=I(ut);break}case"TrustedScriptURL":{ut=H(ut);break}}if(ut!==ds)try{$t?D.setAttributeNS($t,Ke,ut):D.setAttribute(Ke,ut),fn(D)?Ge(D):rm(t.removed)}catch{ot(Ke,D)}}Wt(Z.afterSanitizeAttributes,D,null)},Ua=function(D){let pe=null;const ke=fi(D);for(Wt(Z.beforeSanitizeShadowDOM,D,null);pe=ke.nextNode();)if(Wt(Z.uponSanitizeShadowNode,pe,null),ya(pe),$n(pe),kt(pe.content)&&Ua(pe.content),(x?x(pe):pe.nodeType)===Ca.element){const Ue=g?g(pe):pe.shadowRoot;kt(Ue)&&(vn(Ue),Ua(Ue))}Wt(Z.afterSanitizeShadowDOM,D,null)},vn=function(D){const pe=[{node:D,shadow:null}];for(;pe.length>0;){const ke=pe.pop();if(ke.shadow){Ua(ke.shadow);continue}const Be=ke.node,Ke=(x?x(Be):Be.nodeType)===Ca.element,$t=k?k(Be):Be.childNodes;if($t)for(let gt=$t.length-1;gt>=0;--gt)pe.push({node:$t[gt],shadow:null});if(Ke){const gt=w?w(Be):null;if(typeof gt=="string"&&Ct(gt)==="template"){const Ot=Be.content;kt(Ot)&&pe.push({node:Ot,shadow:null})}}if(Ke){const gt=g?g(Be):Be.shadowRoot;kt(gt)&&pe.push({node:null,shadow:gt},{node:gt,shadow:null})}}};return t.sanitize=function(Ie){let D=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},pe=null,ke=null,Be=null,Ue=null;if(Vs=!Ie,Vs&&(Ie=""),typeof Ie!="string"&&!$s(Ie)&&(Ie=PT(Ie),typeof Ie!="string"))throw qn("dirty is not a string, aborting");if(!t.isSupported)return Ie;ge||Fe(D),t.removed=[];const Ke=Re&&typeof Ie!="string"&&$s(Ie);if(Ke){const Ot=w?w(Ie):Ie.nodeName;if(typeof Ot=="string"){const ds=Ct(Ot);if(!Y[ds]||he[ds])throw qn("root node is forbidden and cannot be sanitized in-place")}if(fn(Ie))throw qn("root node is clobbered and cannot be sanitized in-place");try{vn(Ie)}catch(ds){throw It(Ie),ds}}else if($s(Ie))pe=Fa(""),ke=pe.ownerDocument.importNode(Ie,!0),ke.nodeType===Ca.element&&ke.nodeName==="BODY"||ke.nodeName==="HTML"?pe=ke:pe.appendChild(ke),vn(ke);else{if(!$e&&!_e&&!de&&Ie.indexOf("<")===-1)return _&&je?I(Ie):Ie;if(pe=Fa(Ie),!pe)return $e?null:je?T:""}pe&&Ae&&Ge(pe.firstChild);const $t=fi(Ke?Ie:pe);try{for(;Be=$t.nextNode();)ya(Be),$n(Be),kt(Be.content)&&Ua(Be.content)}catch(Ot){throw Ke&&It(Ie),Ot}if(Ke)return qa(t.removed,Ot=>{Ot.element&&pi(Ot.element)}),_e&&Ba(Ie),Ie;if($e){if(_e&&Ba(pe),We)for(Ue=$.call(pe.ownerDocument);pe.firstChild;)Ue.appendChild(pe.firstChild);else Ue=pe;return(S.shadowroot||S.shadowrootmode)&&(Ue=ce.call(a,Ue,!0)),Ue}let gt=de?pe.outerHTML:pe.innerHTML;return de&&Y["!doctype"]&&pe.ownerDocument&&pe.ownerDocument.doctype&&pe.ownerDocument.doctype.name&&us(WT,pe.ownerDocument.doctype.name)&&(gt=" -`+gt),_e&&qa([Q,ae,V],Ot=>{gt=Si(gt,Ot," ")}),_&&je?I(gt):gt},t.setConfig=function(){let Ie=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};Fe(Ie),ge=!0},t.clearConfig=function(){ls=null,ge=!1,_=L,T=""},t.isValidAttribute=function(Ie,D,pe){ls||Fe({});const ke=Ct(Ie),Be=Ct(D);return xa(ke,Be,pe)},t.addHook=function(Ie,D){typeof D=="function"&&ki(Z[Ie],D)},t.removeHook=function(Ie,D){if(D!==void 0){const pe=RT(Z[Ie],D);return pe===-1?void 0:IT(Z[Ie],pe,1)[0]}return rm(Z[Ie])},t.removeHooks=function(Ie){Z[Ie]=[]},t.removeAllHooks=function(){Z=bm()},t}var ym=Qg();function Ku(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var ui=Ku();function Yg(e){ui=e}var Bl={exec:()=>null};function _t(e,t=""){let s=typeof e=="string"?e:e.source;const a={replace:(n,i)=>{let l=typeof i=="string"?i:i.source;return l=l.replace(Ms.caret,"$1"),s=s.replace(n,l),a},getRegex:()=>new RegExp(s,t)};return a}var Ms={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},QT=/^(?:[ \t]*(?:\n|$))+/,YT=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,XT=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,xo=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,eE=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Ju=/(?:[*+-]|\d{1,9}[.)])/,Xg=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,eb=_t(Xg).replace(/bull/g,Ju).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),tE=_t(Xg).replace(/bull/g,Ju).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Zu=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,sE=/^[^\n]+/,Qu=/(?!\s*\])(?:\\.|[^\[\]\\])+/,aE=_t(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",Qu).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),nE=_t(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Ju).getRegex(),fc="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",Yu=/|$))/,iE=_t("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",Yu).replace("tag",fc).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),tb=_t(Zu).replace("hr",xo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",fc).getRegex(),lE=_t(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",tb).getRegex(),Xu={blockquote:lE,code:YT,def:aE,fences:XT,heading:eE,hr:xo,html:iE,lheading:eb,list:nE,newline:QT,paragraph:tb,table:Bl,text:sE},xm=_t("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",xo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",fc).getRegex(),oE={...Xu,lheading:tE,table:xm,paragraph:_t(Zu).replace("hr",xo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",xm).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",fc).getRegex()},rE={...Xu,html:_t(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",Yu).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:Bl,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:_t(Zu).replace("hr",xo).replace("heading",` *#{1,6} *[^ -]`).replace("lheading",eb).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},cE=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,dE=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,sb=/^( {2,}|\\)\n(?!\s*$)/,uE=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ib=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,hE=_t(ib,"u").replace(/punct/g,mc).getRegex(),gE=_t(ib,"u").replace(/punct/g,nb).getRegex(),lb="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",bE=_t(lb,"gu").replace(/notPunctSpace/g,ab).replace(/punctSpace/g,ep).replace(/punct/g,mc).getRegex(),yE=_t(lb,"gu").replace(/notPunctSpace/g,mE).replace(/punctSpace/g,fE).replace(/punct/g,nb).getRegex(),xE=_t("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ab).replace(/punctSpace/g,ep).replace(/punct/g,mc).getRegex(),_E=_t(/\\(punct)/,"gu").replace(/punct/g,mc).getRegex(),wE=_t(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),kE=_t(Yu).replace("(?:-->|$)","-->").getRegex(),SE=_t("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",kE).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Pr=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,CE=_t(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",Pr).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ob=_t(/^!?\[(label)\]\[(ref)\]/).replace("label",Pr).replace("ref",Qu).getRegex(),rb=_t(/^!?\[(ref)\](?:\[\])?/).replace("ref",Qu).getRegex(),TE=_t("reflink|nolink(?!\\()","g").replace("reflink",ob).replace("nolink",rb).getRegex(),tp={_backpedal:Bl,anyPunctuation:_E,autolink:wE,blockSkip:vE,br:sb,code:dE,del:Bl,emStrongLDelim:hE,emStrongRDelimAst:bE,emStrongRDelimUnd:xE,escape:cE,link:CE,nolink:rb,punctuation:pE,reflink:ob,reflinkSearch:TE,tag:SE,text:uE,url:Bl},EE={...tp,link:_t(/^!?\[(label)\]\((.*?)\)/).replace("label",Pr).getRegex(),reflink:_t(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Pr).getRegex()},jd={...tp,emStrongRDelimAst:yE,emStrongLDelim:gE,url:_t(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},_m=e=>RE[e];function Aa(e,t){if(t){if(Ms.escapeTest.test(e))return e.replace(Ms.escapeReplace,_m)}else if(Ms.escapeTestNoEncode.test(e))return e.replace(Ms.escapeReplaceNoEncode,_m);return e}function wm(e){try{e=encodeURI(e).replace(Ms.percentDecode,"%")}catch{return null}return e}function km(e,t){var i;const s=e.replace(Ms.findPipe,(l,o,r)=>{let c=!1,d=o;for(;--d>=0&&r[d]==="\\";)c=!c;return c?"|":" |"}),a=s.split(Ms.splitPipe);let n=0;if(a[0].trim()||a.shift(),a.length>0&&!((i=a.at(-1))!=null&&i.trim())&&a.pop(),t)if(a.length>t)a.splice(t);else for(;a.length0?-2:-1}function Sm(e,t,s,a,n){const i=t.href,l=t.title||null,o=e[1].replace(n.other.outputLinkReplace,"$1");a.state.inLink=!0;const r={type:e[0].charAt(0)==="!"?"image":"link",raw:s,href:i,title:l,text:o,tokens:a.inlineTokens(o)};return a.state.inLink=!1,r}function OE(e,t,s){const a=e.match(s.other.indentCodeCompensation);if(a===null)return t;const n=a[1];return t.split(` -`).map(i=>{const l=i.match(s.other.beginningSpace);if(l===null)return i;const[o]=l;return o.length>=n.length?i.slice(n.length):i}).join(` -`)}var $r=class{constructor(e){Et(this,"options");Et(this,"rules");Et(this,"lexer");this.options=e||ui}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const s=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?s:Cl(s,` -`)}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const s=t[0],a=OE(s,t[3]||"",this.rules);return{type:"code",raw:s,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:a}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let s=t[2].trim();if(this.rules.other.endingHash.test(s)){const a=Cl(s,"#");(this.options.pedantic||!a||this.rules.other.endingSpaceChar.test(a))&&(s=a.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:s,tokens:this.lexer.inline(s)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:Cl(t[0],` -`)}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let s=Cl(t[0],` -`).split(` -`),a="",n="";const i=[];for(;s.length>0;){let l=!1;const o=[];let r;for(r=0;r1,n={type:"list",raw:"",ordered:a,start:a?+s.slice(0,-1):"",loose:!1,items:[]};s=a?`\\d{1,9}\\${s.slice(-1)}`:`\\${s}`,this.options.pedantic&&(s=a?s:"[*+-]");const i=this.rules.other.listItemRegex(s);let l=!1;for(;e;){let r=!1,c="",d="";if(!(t=i.exec(e))||this.rules.block.hr.test(e))break;c=t[0],e=e.substring(c.length);let u=t[2].split(` -`,1)[0].replace(this.rules.other.listReplaceTabs,A=>" ".repeat(3*A.length)),p=e.split(` -`,1)[0],m=!u.trim(),h=0;if(this.options.pedantic?(h=2,d=u.trimStart()):m?h=t[1].length+1:(h=t[2].search(this.rules.other.nonSpaceChar),h=h>4?1:h,d=u.slice(h),h+=t[1].length),m&&this.rules.other.blankLine.test(p)&&(c+=p+` -`,e=e.substring(p.length+1),r=!0),!r){const A=this.rules.other.nextBulletRegex(h),g=this.rules.other.hrRegex(h),b=this.rules.other.fencesBeginRegex(h),x=this.rules.other.headingBeginRegex(h),w=this.rules.other.htmlBeginRegex(h);for(;e;){const _=e.split(` -`,1)[0];let T;if(p=_,this.options.pedantic?(p=p.replace(this.rules.other.listReplaceNesting," "),T=p):T=p.replace(this.rules.other.tabCharGlobal," "),b.test(p)||x.test(p)||w.test(p)||A.test(p)||g.test(p))break;if(T.search(this.rules.other.nonSpaceChar)>=h||!p.trim())d+=` -`+T.slice(h);else{if(m||u.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||b.test(u)||x.test(u)||g.test(u))break;d+=` -`+p}!m&&!p.trim()&&(m=!0),c+=_+` -`,e=e.substring(_.length+1),u=T.slice(h)}}n.loose||(l?n.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(l=!0));let y=null,k;this.options.gfm&&(y=this.rules.other.listIsTask.exec(d),y&&(k=y[0]!=="[ ] ",d=d.replace(this.rules.other.listReplaceTask,""))),n.items.push({type:"list_item",raw:c,task:!!y,checked:k,loose:!1,text:d,tokens:[]}),n.raw+=c}const o=n.items.at(-1);if(o)o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd();else return;n.raw=n.raw.trimEnd();for(let r=0;ru.type==="space"),d=c.length>0&&c.some(u=>this.rules.other.anyLine.test(u.raw));n.loose=d}if(n.loose)for(let r=0;r({text:r,tokens:this.lexer.inline(r),header:!1,align:i.align[c]})));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const s=t[1].charAt(t[1].length-1)===` -`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:s,tokens:this.lexer.inline(s)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const s=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(s)){if(!this.rules.other.endAngleBracket.test(s))return;const i=Cl(s.slice(0,-1),"\\");if((s.length-i.length)%2===0)return}else{const i=IE(t[2],"()");if(i===-2)return;if(i>-1){const o=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,o).trim(),t[3]=""}}let a=t[2],n="";if(this.options.pedantic){const i=this.rules.other.pedanticHrefTitle.exec(a);i&&(a=i[1],n=i[3])}else n=t[3]?t[3].slice(1,-1):"";return a=a.trim(),this.rules.other.startAngleBracket.test(a)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(s)?a=a.slice(1):a=a.slice(1,-1)),Sm(t,{href:a&&a.replace(this.rules.inline.anyPunctuation,"$1"),title:n&&n.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let s;if((s=this.rules.inline.reflink.exec(e))||(s=this.rules.inline.nolink.exec(e))){const a=(s[2]||s[1]).replace(this.rules.other.multipleSpaceGlobal," "),n=t[a.toLowerCase()];if(!n){const i=s[0].charAt(0);return{type:"text",raw:i,text:i}}return Sm(s,n,s[0],this.lexer,this.rules)}}emStrong(e,t,s=""){let a=this.rules.inline.emStrongLDelim.exec(e);if(!a||a[3]&&s.match(this.rules.other.unicodeAlphaNumeric))return;if(!(a[1]||a[2]||"")||!s||this.rules.inline.punctuation.exec(s)){const i=[...a[0]].length-1;let l,o,r=i,c=0;const d=a[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(d.lastIndex=0,t=t.slice(-1*e.length+i);(a=d.exec(t))!=null;){if(l=a[1]||a[2]||a[3]||a[4]||a[5]||a[6],!l)continue;if(o=[...l].length,a[3]||a[4]){r+=o;continue}else if((a[5]||a[6])&&i%3&&!((i+o)%3)){c+=o;continue}if(r-=o,r>0)continue;o=Math.min(o,o+r+c);const u=[...a[0]][0].length,p=e.slice(0,i+a.index+u+o);if(Math.min(i,o)%2){const h=p.slice(1,-1);return{type:"em",raw:p,text:h,tokens:this.lexer.inlineTokens(h)}}const m=p.slice(2,-2);return{type:"strong",raw:p,text:m,tokens:this.lexer.inlineTokens(m)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let s=t[2].replace(this.rules.other.newLineCharGlobal," ");const a=this.rules.other.nonSpaceChar.test(s),n=this.rules.other.startingSpaceChar.test(s)&&this.rules.other.endingSpaceChar.test(s);return a&&n&&(s=s.substring(1,s.length-1)),{type:"codespan",raw:t[0],text:s}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let s,a;return t[2]==="@"?(s=t[1],a="mailto:"+s):(s=t[1],a=s),{type:"link",raw:t[0],text:s,href:a,tokens:[{type:"text",raw:s,text:s}]}}}url(e){var s;let t;if(t=this.rules.inline.url.exec(e)){let a,n;if(t[2]==="@")a=t[0],n="mailto:"+a;else{let i;do i=t[0],t[0]=((s=this.rules.inline._backpedal.exec(t[0]))==null?void 0:s[0])??"";while(i!==t[0]);a=t[0],t[1]==="www."?n="http://"+t[0]:n=t[0]}return{type:"link",raw:t[0],text:a,href:n,tokens:[{type:"text",raw:a,text:a}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const s=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:s}}}},Ya=class qd{constructor(t){Et(this,"tokens");Et(this,"options");Et(this,"state");Et(this,"tokenizer");Et(this,"inlineQueue");this.tokens=[],this.tokens.links=Object.create(null),this.options=t||ui,this.options.tokenizer=this.options.tokenizer||new $r,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const s={other:Ms,block:Xo.normal,inline:Sl.normal};this.options.pedantic?(s.block=Xo.pedantic,s.inline=Sl.pedantic):this.options.gfm&&(s.block=Xo.gfm,this.options.breaks?s.inline=Sl.breaks:s.inline=Sl.gfm),this.tokenizer.rules=s}static get rules(){return{block:Xo,inline:Sl}}static lex(t,s){return new qd(s).lex(t)}static lexInline(t,s){return new qd(s).inlineTokens(t)}lex(t){t=t.replace(Ms.carriageReturn,` -`),this.blockTokens(t,this.tokens);for(let s=0;s(o=c.call({lexer:this},t,s))?(t=t.substring(o.raw.length),s.push(o),!0):!1))continue;if(o=this.tokenizer.space(t)){t=t.substring(o.raw.length);const c=s.at(-1);o.raw.length===1&&c!==void 0?c.raw+=` -`:s.push(o);continue}if(o=this.tokenizer.code(t)){t=t.substring(o.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="paragraph"||(c==null?void 0:c.type)==="text"?(c.raw+=` -`+o.raw,c.text+=` -`+o.text,this.inlineQueue.at(-1).src=c.text):s.push(o);continue}if(o=this.tokenizer.fences(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.heading(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.hr(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.blockquote(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.list(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.html(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.def(t)){t=t.substring(o.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="paragraph"||(c==null?void 0:c.type)==="text"?(c.raw+=` -`+o.raw,c.text+=` -`+o.raw,this.inlineQueue.at(-1).src=c.text):this.tokens.links[o.tag]||(this.tokens.links[o.tag]={href:o.href,title:o.title});continue}if(o=this.tokenizer.table(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.lheading(t)){t=t.substring(o.raw.length),s.push(o);continue}let r=t;if((l=this.options.extensions)!=null&&l.startBlock){let c=1/0;const d=t.slice(1);let u;this.options.extensions.startBlock.forEach(p=>{u=p.call({lexer:this},d),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(r=t.substring(0,c+1))}if(this.state.top&&(o=this.tokenizer.paragraph(r))){const c=s.at(-1);a&&(c==null?void 0:c.type)==="paragraph"?(c.raw+=` -`+o.raw,c.text+=` -`+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=c.text):s.push(o),a=r.length!==t.length,t=t.substring(o.raw.length);continue}if(o=this.tokenizer.text(t)){t=t.substring(o.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="text"?(c.raw+=` -`+o.raw,c.text+=` -`+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=c.text):s.push(o);continue}if(t){const c="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return this.state.top=!0,s}inline(t,s=[]){return this.inlineQueue.push({src:t,tokens:s}),s}inlineTokens(t,s=[]){var o,r,c;let a=t,n=null;if(this.tokens.links){const d=Object.keys(this.tokens.links);if(d.length>0)for(;(n=this.tokenizer.rules.inline.reflinkSearch.exec(a))!=null;)d.includes(n[0].slice(n[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,n.index)+"["+"a".repeat(n[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(n=this.tokenizer.rules.inline.anyPunctuation.exec(a))!=null;)a=a.slice(0,n.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(n=this.tokenizer.rules.inline.blockSkip.exec(a))!=null;)a=a.slice(0,n.index)+"["+"a".repeat(n[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,l="";for(;t;){i||(l=""),i=!1;let d;if((r=(o=this.options.extensions)==null?void 0:o.inline)!=null&&r.some(p=>(d=p.call({lexer:this},t,s))?(t=t.substring(d.raw.length),s.push(d),!0):!1))continue;if(d=this.tokenizer.escape(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.tag(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.link(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(d.raw.length);const p=s.at(-1);d.type==="text"&&(p==null?void 0:p.type)==="text"?(p.raw+=d.raw,p.text+=d.text):s.push(d);continue}if(d=this.tokenizer.emStrong(t,a,l)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.codespan(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.br(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.del(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.autolink(t)){t=t.substring(d.raw.length),s.push(d);continue}if(!this.state.inLink&&(d=this.tokenizer.url(t))){t=t.substring(d.raw.length),s.push(d);continue}let u=t;if((c=this.options.extensions)!=null&&c.startInline){let p=1/0;const m=t.slice(1);let h;this.options.extensions.startInline.forEach(y=>{h=y.call({lexer:this},m),typeof h=="number"&&h>=0&&(p=Math.min(p,h))}),p<1/0&&p>=0&&(u=t.substring(0,p+1))}if(d=this.tokenizer.inlineText(u)){t=t.substring(d.raw.length),d.raw.slice(-1)!=="_"&&(l=d.raw.slice(-1)),i=!0;const p=s.at(-1);(p==null?void 0:p.type)==="text"?(p.raw+=d.raw,p.text+=d.text):s.push(d);continue}if(t){const p="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(p);break}else throw new Error(p)}}return s}},Fr=class{constructor(e){Et(this,"options");Et(this,"parser");this.options=e||ui}space(e){return""}code({text:e,lang:t,escaped:s}){var i;const a=(i=(t||"").match(Ms.notSpaceStart))==null?void 0:i[0],n=e.replace(Ms.endingNewline,"")+` -`;return a?'
'+(s?n:Aa(n,!0))+`
-`:"
"+(s?n:Aa(n,!0))+`
-`}blockquote({tokens:e}){return`
-${this.parser.parse(e)}
-`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} -`}hr(e){return`
-`}list(e){const t=e.ordered,s=e.start;let a="";for(let l=0;l -`+a+" -`}listitem(e){var s;let t="";if(e.task){const a=this.checkbox({checked:!!e.checked});e.loose?((s=e.tokens[0])==null?void 0:s.type)==="paragraph"?(e.tokens[0].text=a+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=a+" "+Aa(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:a+" ",text:a+" ",escaped:!0}):t+=a+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • -`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    -`}table(e){let t="",s="";for(let n=0;n${a}`),` - -`+t+` -`+a+`
    -`}tablerow({text:e}){return` -${e} -`}tablecell(e){const t=this.parser.parseInline(e.tokens),s=e.header?"th":"td";return(e.align?`<${s} align="${e.align}">`:`<${s}>`)+t+` -`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${Aa(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:s}){const a=this.parser.parseInline(s),n=wm(e);if(n===null)return a;e=n;let i='
    ",i}image({href:e,title:t,text:s,tokens:a}){a&&(s=this.parser.parseInline(a,this.parser.textRenderer));const n=wm(e);if(n===null)return Aa(s);e=n;let i=`${s}{const r=l[o].flat(1/0);s=s.concat(this.walkTokens(r,t))}):l.tokens&&(s=s.concat(this.walkTokens(l.tokens,t)))}}return s}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(s=>{const a={...s};if(a.async=this.defaults.async||a.async||!1,s.extensions&&(s.extensions.forEach(n=>{if(!n.name)throw new Error("extension name required");if("renderer"in n){const i=t.renderers[n.name];i?t.renderers[n.name]=function(...l){let o=n.renderer.apply(this,l);return o===!1&&(o=i.apply(this,l)),o}:t.renderers[n.name]=n.renderer}if("tokenizer"in n){if(!n.level||n.level!=="block"&&n.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=t[n.level];i?i.unshift(n.tokenizer):t[n.level]=[n.tokenizer],n.start&&(n.level==="block"?t.startBlock?t.startBlock.push(n.start):t.startBlock=[n.start]:n.level==="inline"&&(t.startInline?t.startInline.push(n.start):t.startInline=[n.start]))}"childTokens"in n&&n.childTokens&&(t.childTokens[n.name]=n.childTokens)}),a.extensions=t),s.renderer){const n=this.defaults.renderer||new Fr(this.defaults);for(const i in s.renderer){if(!(i in n))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const l=i,o=s.renderer[l],r=n[l];n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d||""}}a.renderer=n}if(s.tokenizer){const n=this.defaults.tokenizer||new $r(this.defaults);for(const i in s.tokenizer){if(!(i in n))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const l=i,o=s.tokenizer[l],r=n[l];n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d}}a.tokenizer=n}if(s.hooks){const n=this.defaults.hooks||new or;for(const i in s.hooks){if(!(i in n))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const l=i,o=s.hooks[l],r=n[l];or.passThroughHooks.has(i)?n[l]=c=>{if(this.defaults.async)return Promise.resolve(o.call(n,c)).then(u=>r.call(n,u));const d=o.call(n,c);return r.call(n,d)}:n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d}}a.hooks=n}if(s.walkTokens){const n=this.defaults.walkTokens,i=s.walkTokens;a.walkTokens=function(l){let o=[];return o.push(i.call(this,l)),n&&(o=o.concat(n.call(this,l))),o}}this.defaults={...this.defaults,...a}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return Ya.lex(e,t??this.defaults)}parser(e,t){return Xa.parse(e,t??this.defaults)}parseMarkdown(e){return(s,a)=>{const n={...a},i={...this.defaults,...n},l=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&n.async===!1)return l(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof s>"u"||s===null)return l(new Error("marked(): input parameter is undefined or null"));if(typeof s!="string")return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(s)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=e);const o=i.hooks?i.hooks.provideLexer():e?Ya.lex:Ya.lexInline,r=i.hooks?i.hooks.provideParser():e?Xa.parse:Xa.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(s):s).then(c=>o(c,i)).then(c=>i.hooks?i.hooks.processAllTokens(c):c).then(c=>i.walkTokens?Promise.all(this.walkTokens(c,i.walkTokens)).then(()=>c):c).then(c=>r(c,i)).then(c=>i.hooks?i.hooks.postprocess(c):c).catch(l);try{i.hooks&&(s=i.hooks.preprocess(s));let c=o(s,i);i.hooks&&(c=i.hooks.processAllTokens(c)),i.walkTokens&&this.walkTokens(c,i.walkTokens);let d=r(c,i);return i.hooks&&(d=i.hooks.postprocess(d)),d}catch(c){return l(c)}}}onError(e,t){return s=>{if(s.message+=` -Please report this to https://github.com/markedjs/marked.`,e){const a="

    An error occurred:

    "+Aa(s.message+"",!0)+"
    ";return t?Promise.resolve(a):a}if(t)return Promise.reject(s);throw s}}},ii=new LE;function yt(e,t){return ii.parse(e,t)}yt.options=yt.setOptions=function(e){return ii.setOptions(e),yt.defaults=ii.defaults,Yg(yt.defaults),yt};yt.getDefaults=Ku;yt.defaults=ui;yt.use=function(...e){return ii.use(...e),yt.defaults=ii.defaults,Yg(yt.defaults),yt};yt.walkTokens=function(e,t){return ii.walkTokens(e,t)};yt.parseInline=ii.parseInline;yt.Parser=Xa;yt.parser=Xa.parse;yt.Renderer=Fr;yt.TextRenderer=sp;yt.Lexer=Ya;yt.lexer=Ya.lex;yt.Tokenizer=$r;yt.Hooks=or;yt.parse=yt;yt.options;yt.setOptions;yt.use;yt.walkTokens;yt.parseInline;Xa.parse;Ya.lex;const NE={breaks:!0,gfm:!0};function Cm(e){if(!e)return"";try{if(typeof yt<"u"&&yt.parse){const t=yt.parse(e,NE);return typeof ym<"u"?ym.sanitize(t):t}}catch{}return e.replace(/&/g,"&").replace(//g,">").replace(/\n/g,"
    ")}function ME(e){const t=new Date(e),s=t.getHours().toString().padStart(2,"0"),a=t.getMinutes().toString().padStart(2,"0");return`${s}:${a}`}const DE={run_command:"terminal",ssh_command:"terminal",run_script:"terminal",read_file:"file",apply_patch:"edit",list_directory:"folder",search_knowledge:"search",ingest_document:"book",generate_image:"image",analyze_image:"eye",analyze_pdf:"file",browser_screenshot:"globe",manage_process:"sliders"};function PE(e){return DE[e]||"wrench"}const $E=/https?:\/\/\S+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?\S*)?/gi;function Tm(e){if(!e)return[];const t=e.match($E);return t?[...new Set(t)]:[]}const FE={template:` -
    - - -
    - -
    -
    -
    - - - - - -
    -
    Odin is watching
    -
    Ask anything. Run commands. Manage infrastructure.
    -
    - -
    -
    -
    - - - - - -
    -
    - - - -
    -
    -
    - - {{ typingText }} -
    -
    -
    -
    - - -
    -
    - - - -
    -
    - Enter to send · Shift+Enter for newline - - - {{ wsStatus }} - -
    -
    -
    `,setup(){const e=f([]),t=f(""),s=f(!1),a=f(""),n=f(null),i=f(null),l=f(0),o=f("");let r=null,c=0;const d=["Check system health","List running services","Show disk usage","What can you do?"],u=B(()=>t.value.trim().length>0&&!s.value),p=f(mt.state||"disconnected");let m=null;const h=B(()=>{const N=p.value;return N==="connected"?"Connected":N==="reconnecting"?"Reconnecting…":N==="connecting"?"Connecting…":"REST fallback"}),y=["Watching across all realms...","Processing...","Consulting the bifrost...","Observing..."],k=B(()=>{const N=Math.floor(l.value/4)%y.length,M=l.value;return M>3?`${y[N]} (${M}s)`:y[0]});function A(){Ut(()=>{n.value&&(n.value.scrollTop=n.value.scrollHeight)})}function g(){if(!i.value)return;const N=i.value;N.style.height="auto",N.style.height=Math.min(N.scrollHeight,120)+"px"}function b(N,M,$={}){const re={id:++c,role:N,content:M,timestamp:Date.now(),html:N==="bot"?Cm(M):"",tools_used:$.tools_used||[],is_error:$.is_error||!1,images:N==="bot"?Tm(M):[],files:$.files||[],_showTools:!1};return e.value.push(re),A(),N==="bot"&&Ut(()=>x()),re}function x(){if(!n.value)return;n.value.querySelectorAll(".chat-markdown pre:not([data-copy])").forEach(M=>{M.setAttribute("data-copy","true"),M.style.position="relative";const $=document.createElement("button");$.className="chat-code-copy",$.textContent="Copy",$.addEventListener("click",()=>{const re=M.querySelector("code"),ce=re?re.textContent:M.textContent;navigator.clipboard.writeText(ce).then(()=>{$.textContent="Copied!",setTimeout(()=>{$.textContent="Copy"},1500)}).catch(()=>{})}),M.appendChild($)})}function w(N){if(N===0)return!0;const M=e.value[N-1],$=e.value[N],re=new Date(M.timestamp).toDateString(),ce=new Date($.timestamp).toDateString();return re!==ce}function _(N){const M=new Date(N),$=new Date;if(M.toDateString()===$.toDateString())return"Today";const re=new Date($);return re.setDate(re.getDate()-1),M.toDateString()===re.toDateString()?"Yesterday":M.toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}function T(N){t.value=N,Ut(()=>te())}function L(N){window.open(N,"_blank","noopener")}function E(N){N.target.style.display="none"}function F(){l.value=0,r=setInterval(()=>{l.value++},1e3)}function O(){r&&(clearInterval(r),r=null),l.value=0}function I(N){s.value&&(s.value=!1,O(),N.type==="chat_response"?b("bot",N.content,{tools_used:N.tools_used||[],is_error:N.is_error||!1,files:N.files||[]}):N.type==="chat_error"&&b("bot",N.error||"Unknown error",{is_error:!0}),Ut(()=>{var M;return(M=i.value)==null?void 0:M.focus()}))}async function H(N){try{const M=await z.post("/api/chat",{content:N,channel_id:o.value});b("bot",M.response,{tools_used:M.tools_used||[],is_error:M.is_error||!1,files:M.files||[]})}catch(M){b("bot",M.message||"Failed to send message",{is_error:!0})}}async function te(){const N=t.value.trim();if(!N||s.value)return;b("user",N),t.value="",s.value=!0,F(),i.value&&(i.value.style.height="auto"),mt.connected&&mt.sendChat(N,{channelId:o.value})||(await H(N),s.value=!1,O()),Ut(()=>{var $;return($=i.value)==null?void 0:$.focus()})}async function ee(){a.value="";try{if(!o.value){const M=await z.get("/api/auth/session");o.value=M.channel_id||M.user_id||"web-user"}const N=await z.get("/api/sessions/"+encodeURIComponent(o.value));if(N&&N.messages&&N.messages.length>0){for(const M of N.messages){const $=M.role==="user"?"user":"bot";let re=M.content||"";if($==="user"){const Z=re.match(/^\[.*?\]:\s*/);Z&&(re=re.slice(Z[0].length))}if(!re.trim())continue;const ce={id:++c,role:$,content:re,timestamp:M.timestamp?M.timestamp*1e3:Date.now(),html:$==="bot"?Cm(re):"",tools_used:[],is_error:!1,images:$==="bot"?Tm(re):[],files:[],_showTools:!1};e.value.push(ce)}Ut(()=>{A(),x()})}}catch(N){N&&N.status!==404&&(a.value="Couldn't load chat history — earlier messages may be missing. Refresh to retry.",Se.error(a.value))}}return st(()=>{mt.subscribe("chat",I),p.value=mt.state||"disconnected",m=mt.onState(N=>{p.value=N}),ee(),Ut(()=>{var N;return(N=i.value)==null?void 0:N.focus()})}),wt(()=>{mt.unsubscribe("chat",I),m&&(m(),m=null),O()}),{messages:e,input:t,sending:s,historyError:a,messagesEl:n,inputEl:i,canSend:u,wsStatus:h,typingText:k,suggestions:d,send:te,autoResize:g,formatTime:ME,formatDate:_,showDateSeparator:w,useSuggestion:T,openImage:L,onImageError:E,getToolIcon:PE,loadHistory:ee}}},BE={setup(){const e=f("odin"),t=f(""),s=f(""),a=f(""),n=f({}),i=f([]),l=f([]),o=f(!1),r=f(!1),c=f(null),d=f(!0),u=f(""),p=f(!1),m=f(!1),h=B(()=>e.value==="custom"),y=B(()=>[...i.value,...l.value]),k=B(()=>l.value.includes(e.value)),A=B(()=>{var L;return h.value?t.value||"Odin":((L=n.value[e.value])==null?void 0:L.name)||e.value}),g=B(()=>{var L;return h.value?s.value||"(empty — will use Odin default)":((L=n.value[e.value])==null?void 0:L.identity)||""}),b=B(()=>{var L;return h.value?a.value||"(empty — will use Odin default)":((L=n.value[e.value])==null?void 0:L.voice)||""});async function x(){d.value=!0;try{const L=await z.get("/api/personality");e.value=L.preset||"odin",t.value=L.custom_name||"",s.value=L.custom_identity||"",a.value=L.custom_voice||"",n.value=L.presets||{},i.value=L.builtin_presets||[],l.value=L.user_presets||[]}catch(L){c.value=L.message}finally{d.value=!1}}async function w(){o.value=!0,c.value=null,r.value=!1;try{await z.put("/api/personality",{preset:e.value,custom_name:t.value,custom_identity:s.value,custom_voice:a.value}),r.value=!0,setTimeout(()=>r.value=!1,3e3)}catch(L){c.value=L.message}finally{o.value=!1}}async function _(){const L=u.value.trim();if(L){m.value=!0,c.value=null;try{await z.post("/api/personality/presets",{name:L,display_name:A.value,identity:g.value,voice:b.value}),p.value=!1,u.value="",await x(),e.value=L.toLowerCase().replace(/ /g,"_")}catch(E){c.value=E.message}finally{m.value=!1}}}async function T(){if(await is({title:"Delete preset",message:`Delete preset "${e.value}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){c.value=null;try{await z.del(`/api/personality/presets/${encodeURIComponent(e.value)}`),await x(),e.value="odin"}catch(E){c.value=E.message}}}return st(x),{preset:e,customName:t,customIdentity:s,customVoice:a,presets:n,presetNames:y,isCustom:h,isUserPreset:k,previewName:A,previewIdentity:g,previewVoice:b,saving:o,saved:r,error:c,loading:d,save:w,showSavePreset:p,newPresetName:u,savingPreset:m,saveAsPreset:_,deletePreset:T,builtinPresets:i,userPresets:l}},template:` -
    -
    -

    Personality

    -

    Configure how Odin presents itself. Changes apply immediately — no restart needed.

    -
    - -
    - Loading... -
    - - -
    - `},UE={props:["onComplete"],template:` -
    - -
    `,setup(e){const t=f(""),s=f(""),a=f(!1),n=f(!1),i=f(""),l=f("Initialization pending."),o=f(!1),r=f(""),c=f(null),d=f({}),u=f("");let p=0,m=null;function h(){return p+=1,m==null||m.abort(),m=null,p}function y(w,_,T){return typeof z.postWithOptions=="function"?z.postWithOptions(w,_,{signal:T}):z.post(w,_)}async function k(){var T,L,E;a.value=!0,i.value="",l.value="Saving setup…";const w={},_=!!s.value.trim();t.value.trim()&&(w.web_api_token=t.value.trim()),s.value.trim()&&(w.discord_token=s.value.trim());try{const F=z.post("/api/setup/complete",w);t.value="",s.value="";const O=await F,I=((T=O.discord)==null?void 0:T.state)||O.discord_status;if(I==="failed"?(i.value=((L=O.discord)==null?void 0:L.error)||"Discord attachment failed. Setup was saved.",l.value="Saved. Discord attachment failed."):I==="connecting"?l.value="Saved. Connecting Discord…":I==="ready"?l.value="Saved. Discord ready.":_?l.value="Saved. Discord token stored; attachment status is pending.":l.value=O.message||"Setup saved. Ready to sign in.",(E=O.restart_required)!=null&&E.length){const H=O.message||`Restart Odin to apply: ${O.restart_required.join(", ")}`;l.value.includes(H)||(l.value+=` ${H}`)}n.value=!0}catch(F){i.value=F.message||"Setup could not be saved.",l.value="Initialization pending."}finally{a.value=!1}}async function A(){const w=h(),_=typeof AbortController=="function"?new AbortController:null;m=_,o.value=!0,u.value="";try{const T=await y("/api/codex/device-code",void 0,_==null?void 0:_.signal);if(w!==p)return;c.value=T,r.value="pending";const L=await y("/api/codex/device-poll",{device_auth_id:T.device_auth_id,user_code:T.user_code,interval:T.interval},_==null?void 0:_.signal);if(w!==p)return;d.value=L||{},r.value="ready"}catch(T){w===p&&(T==null?void 0:T.name)!=="AbortError"&&(u.value=T.message||"Device sign-in failed.",r.value="failed")}finally{w===p&&(o.value=!1,m=null)}}function g(){h(),o.value=!1,x()}function b(){var w;(w=e.onComplete)==null||w.call(e)}function x(){r.value="",c.value=null,d.value={},u.value=""}return wt(()=>{h()}),{webApiToken:t,discordToken:s,saving:a,completed:n,error:i,statusMessage:l,save:k,onComplete:b,deviceLoading:o,deviceState:r,deviceInfo:c,deviceResult:d,deviceError:u,startDeviceLogin:A,cancelDeviceLogin:g,clearDeviceState:x}}},Ht=(e,t)=>s=>({path:e,query:{...s.query,tab:t}}),cb=[{path:"/",redirect:"/dashboard"},{path:"/dashboard",component:xT,meta:{label:"Dashboard",icon:"dashboard",section:"Workspace",description:"System posture and recent activity"}},{path:"/chat",component:FE,meta:{label:"Chat",icon:"chat",section:"Workspace",description:"Direct operator conversation"}},{path:"/operations",component:$1,meta:{label:"Operations",icon:"operations",section:"Operate",description:"Execution, agents, loops, processes, and schedules"}},{path:"/history",component:K1,meta:{label:"History",icon:"history",section:"Observe",description:"Audit trail, sessions, traces, and usage"}},{path:"/capabilities",component:vC,meta:{label:"Capabilities",icon:"capabilities",section:"Manage",description:"Tools, skills, knowledge, and memory"}},{path:"/personality",component:BE,meta:{label:"Personality",icon:"personality",section:"Manage",description:"Behavior and response profile"}},{path:"/system",component:fT,meta:{label:"System",icon:"system",section:"Manage",description:"Health, configuration, access, and updates"}},{path:"/execution",redirect:Ht("/operations","live")},{path:"/agents",redirect:Ht("/operations","agents")},{path:"/loops",redirect:Ht("/operations","loops")},{path:"/processes",redirect:Ht("/operations","processes")},{path:"/schedules",redirect:Ht("/operations","schedules")},{path:"/audit",redirect:Ht("/history","audit")},{path:"/sessions",redirect:Ht("/history","sessions")},{path:"/traces",redirect:Ht("/history","traces")},{path:"/usage",redirect:Ht("/history","usage")},{path:"/tools",redirect:Ht("/capabilities","tools")},{path:"/skills",redirect:Ht("/capabilities","skills")},{path:"/mcp",redirect:Ht("/capabilities","mcp-servers")},{path:"/knowledge",redirect:Ht("/capabilities","knowledge")},{path:"/memory",redirect:Ht("/capabilities","memory")},{path:"/learned",redirect:Ht("/capabilities","learned")},{path:"/health",redirect:Ht("/system","health")},{path:"/resources",redirect:Ht("/system","resources")},{path:"/logs",redirect:Ht("/system","logs")},{path:"/config",redirect:Ht("/system","config")},{path:"/host-access",redirect:Ht("/system","host-access")},{path:"/hosts",redirect:Ht("/system","hosts")},{path:"/internals",redirect:Ht("/system","internals")}],Ul=d1({history:HS(),routes:cb});Ul.afterEach(e=>{var s;const t=(s=e.meta)==null?void 0:s.label;document.title=t?`Odin — ${t}`:"Odin — Management"});const zE={template:` - `,props:["onLogin","sessionExpired"],setup(e){const t=f(""),s=f(null),a=f(!1),n=f(!1);async function i(){a.value=!0,s.value=null;try{z.setPersist(n.value),await z.login(t.value),e.onLogin()}catch(l){s.value=l.message||"Login failed"}finally{a.value=!1}}return{token:t,error:s,busy:a,persist:n,login:i}}},HE={template:` -
    -
    - Loading application... -
    - - -
    - - - - -
    - {{ wsToast.text }} -
    -
    - - - -
    - -
    -
    -
    - - - `,setup(){const e=f("checking"),t=f(!1),s=f(!1),a=f(!1),n=f(null),i=f(null),l=f(!1);let o=null,r=null;const c=f(!1),d=f("disconnected"),u=f(-1),p=f(null);let m=null;const h=f("starting"),y=f(""),k=cb.filter(Z=>Z.meta),A=B(()=>["Workspace","Operate","Observe","Manage"].map(Z=>({name:Z,routes:k.filter(Q=>Q.meta.section===Z)})).filter(Z=>Z.routes.length)),g=B(()=>{var Z;return((Z=Ul.currentRoute.value.meta)==null?void 0:Z.label)||"Odin"}),b=B(()=>{var Z;return((Z=Ul.currentRoute.value.meta)==null?void 0:Z.section)||"Management"}),x=B(()=>{var Z;return((Z=Ul.currentRoute.value.meta)==null?void 0:Z.description)||"Management console"});function w(){mt.disconnect(),N&&(clearInterval(N),N=null)}z.onSessionExpired=()=>{t.value=!0,w(),z.setToken(""),e.value="login"};function _(Z){var Q;if((Z.ctrlKey||Z.metaKey)&&Z.key.toLowerCase()==="k"){e.value==="ready"&&(Z.preventDefault(),nm());return}if(a.value&&Z.key==="Tab"){const ae=[...((Q=n.value)==null?void 0:Q.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'))||[]];if(ae.length){const V=ae[0],be=ae[ae.length-1];if(Z.shiftKey&&(document.activeElement===V||!n.value.contains(document.activeElement))){Z.preventDefault(),be.focus();return}if(!Z.shiftKey&&(document.activeElement===be||!n.value.contains(document.activeElement))){Z.preventDefault(),V.focus();return}}}if(Z.key==="Escape"&&a.value){a.value=!1,Z.preventDefault();return}if(Z.key==="/"&&!["INPUT","TEXTAREA","SELECT"].includes(Z.target.tagName)){Z.preventDefault();const ae=document.querySelector('.hm-main input[type="text"], .hm-main .hm-input:not(textarea):not(select)');ae&&ae.focus()}}function T(){l.value=!!(o!=null&&o.matches),l.value||(a.value=!1)}async function L(){try{const Z=await z.get("/api/setup/status");if(Z.mode==="pending"||Z.needed===!0)return w(),e.value="setup",!0}catch(Z){Z==null||Z.name}return!1}st(async()=>{if(document.addEventListener("keydown",_),o=window.matchMedia("(max-width: 900px)"),T(),o.addEventListener("change",T),await L())return;const Z=await z.check();Z.ok?(e.value="ready",re()):Z.needsAuth?e.value="login":(e.value="ready",re())});function E(){t.value=!1,e.value="ready",re()}async function F(){if(await L())return;const Z=await z.check();Z.ok?(e.value="ready",re()):Z.needsAuth?e.value="login":(e.value="ready",re())}async function O(){w(),e.value="login",await z.logout()}function I(){s.value=!s.value}function H(){a.value=!a.value}Gt(a,async Z=>{var Q,ae;if(Z)r=document.activeElement,await Ut(),(ae=(Q=n.value)==null?void 0:Q.querySelector(".nav-item"))==null||ae.focus();else if(r!=null&&r.isConnected){const V=r;r=null,requestAnimationFrame(()=>V.focus())}});const te=B(()=>{switch(d.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}});function ee(Z,Q="info",ae=3e3){p.value={text:Z,level:Q},clearTimeout(m),m=setTimeout(()=>{p.value=null},ae)}let N=null,M=!1,$=[];function re(){for(const Z of $)Z();$=[mt.onStatus(Z=>{c.value=Z}),mt.onLatencyChange(Z=>{u.value=Z}),mt.onState((Z,Q)=>{d.value=Z,Z==="connected"?(M&&ee("Connection restored","success"),M=!0):Z==="reconnecting"&&Q.attempt===1&&ee("Connection lost — reconnecting…","warn")})],mt.connect(),ce(),N&&clearInterval(N),N=setInterval(ce,15e3)}async function ce(){try{const Z=await z.get("/api/status");h.value=Z.status==="online"?"online":"starting";const Q=Z.uptime_seconds||0,ae=Math.floor(Q/3600),V=Math.floor(Q%3600/60);y.value=`${ae}h ${V}m uptime`}catch{h.value="offline",y.value=""}}return wt(()=>{N&&clearInterval(N);for(const Z of $)Z();$=[],mt.disconnect(),document.removeEventListener("keydown",_),o==null||o.removeEventListener("change",T)}),{authState:e,sessionExpired:t,sidebarCollapsed:s,mobileOpen:a,wsConnected:c,wsState:d,wsLatency:u,wsLabel:te,wsToast:p,botStatus:h,botUptime:y,navRoutes:k,navGroups:A,currentPage:g,currentSection:b,currentDescription:x,sidebarEl:n,mobileMenuButton:i,isMobileViewport:l,onLogin:E,onSetupComplete:F,logout:O,toggleSidebar:I,toggleMobileNavigation:H,openPalette:nm}}},un=Cr(HE);un.component("odin-icon",gT);un.component("login-screen",zE);un.component("setup-page",UE);un.component("toast-container",sS);un.component("confirm-host",aS);un.component("command-palette",hT);un.directive("modal-focus",yT);un.use(Ul);un.mount("#app"); diff --git a/ui/dist/index.html b/ui/dist/index.html index be382e56c..d60e40d5d 100644 --- a/ui/dist/index.html +++ b/ui/dist/index.html @@ -9,7 +9,7 @@ Odin — Management - + diff --git a/ui/js/config-meta-fixture.js b/ui/js/config-meta-fixture.js index ef8df686c..a46258932 100644 --- a/ui/js/config-meta-fixture.js +++ b/ui/js/config-meta-fixture.js @@ -36,7 +36,6 @@ const SECTION_DEFAULTS = { mcp: { apply_mode: 'activation_required', owner: 'mcp', description: 'Model Context Protocol servers and tool publication.' }, audit: { apply_mode: 'restart', description: 'Audit signing, verification, and retention.' }, agents: { apply_mode: 'live_for_new_work', description: 'Spawned-agent budgets, inheritance, and tree limits.' }, - grafana_alerts: { apply_mode: 'restart', owner: 'grafana_alerts', description: 'Grafana alert routing and remediation policy.', restart_reason: 'The GrafanaAlertHandler is constructed from these values at startup; saving does not rebuild it.' }, outbound_webhooks: { apply_mode: 'live_apply', owner: 'outbound_webhooks', description: 'Outbound event targets, delivery, and safety policy.' }, graceful_degradation: { apply_mode: 'restart', description: 'Always-on subsystem request guarding and its failure thresholds.', restart_reason: 'The SubsystemGuard is constructed with these thresholds at startup; saving does not rebuild it.' }, llm_recovery: { apply_mode: 'restart', description: 'Provider recovery, breaker, and retry policy.' }, diff --git a/ui/js/pages/config.js b/ui/js/pages/config.js index df233c086..1698b509f 100644 --- a/ui/js/pages/config.js +++ b/ui/js/pages/config.js @@ -17,7 +17,7 @@ const CATEGORY_GROUPS = [ { key: 'runtime', label: 'Runtime', icon: 'activity', sections: ['context', 'sessions', 'agents', 'turn_state'] }, { key: 'data', label: 'Data & Storage', icon: 'database', sections: ['learning', 'search', 'usage', 'audit', 'attachments'] }, { key: 'services', label: 'Services', icon: 'link', sections: ['webhook', 'observability', 'email', 'browser', 'mcp'] }, - { key: 'automation', label: 'Automation', icon: 'workflow', sections: ['grafana_alerts', 'outbound_webhooks'] }, + { key: 'automation', label: 'Automation', icon: 'workflow', sections: ['outbound_webhooks'] }, { key: 'infrastructure', label: 'Infrastructure', icon: 'server', sections: ['tools', 'web'] }, ]; diff --git a/ui/js/pages/tools.js b/ui/js/pages/tools.js index 0fe2c4ce9..573023f99 100644 --- a/ui/js/pages/tools.js +++ b/ui/js/pages/tools.js @@ -19,7 +19,7 @@ const TOOL_CATEGORIES = [ { id: 'skills', label: 'Skills', icon: 'puzzle', match: n => /^(create_skill|edit_skill|delete_skill|enable_skill|disable_skill|install_skill|export_skill|skill_status|invoke_skill|list_skills)/.test(n) }, { id: 'memory', label: 'Memory & State', icon: 'brain', match: n => /^(memory_manage|list_manage)/.test(n) }, { id: 'ai', label: 'AI & Generation', icon: 'sparkles', match: n => /^(generate_|analyze_|vision_)/.test(n) }, - { id: 'integrations', label: 'Integrations', icon: 'link', match: n => /^(grafana_|mcp_)/.test(n) }, + { id: 'integrations', label: 'Integrations', icon: 'link', match: n => /^mcp_/.test(n) }, { id: 'other', label: 'Other Tools', icon: 'wrench', match: () => true }, ]; From 5630a28c6a8a0f544490c872af3f4dcc865d641b Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:44:15 -0400 Subject: [PATCH 002/162] fix(skills): preserve activation and loaded artifact identity --- src/tools/skill_manager.py | 35 ++++++++++---- tests/test_campaign_governor_skills.py | 67 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 tests/test_campaign_governor_skills.py diff --git a/src/tools/skill_manager.py b/src/tools/skill_manager.py index dab52aa3b..55eba06e6 100644 --- a/src/tools/skill_manager.py +++ b/src/tools/skill_manager.py @@ -4,9 +4,11 @@ import asyncio import importlib.util import json +import os import re import subprocess import sys +import tempfile import threading import time import urllib.parse @@ -658,9 +660,20 @@ def _load_disabled_set(self) -> set[str]: pass return set() - def _save_disabled_set(self) -> None: + def _save_disabled_set(self, disabled: set[str] | None = None) -> None: """Persist the disabled skill names to disk.""" - self._disabled_path.write_text(json.dumps(sorted(self._disabled))) + candidate = self._disabled if disabled is None else disabled + # Persist before publishing activation; a failed write cannot leave + # a changed live catalog or a partially written durable ledger. + fd, filename = tempfile.mkstemp(dir=self._disabled_path.parent) + try: + with os.fdopen(fd, "w") as stream: + stream.write(json.dumps(sorted(candidate))) + stream.flush() + os.fsync(stream.fileno()) + os.replace(filename, self._disabled_path) + finally: + Path(filename).unlink(missing_ok=True) def set_services( self, @@ -834,7 +847,7 @@ def edit_skill(self, name: str, code: str) -> str: if name not in self._skills: return f"Skill '{name}' not found." - path = self.skills_dir / f"{name}.py" + path = self._skills[name].file_path old_code = path.read_text() if path.exists() else "" try: @@ -860,6 +873,8 @@ def edit_skill(self, name: str, code: str) -> str: f"('{name}'). They must be identical. Reverted to previous version." ) + if name in self._disabled or previous_skill.status == SkillStatus.DISABLED: + skill.status = SkillStatus.DISABLED self._skills[name] = skill return f"Skill '{name}' updated and reloaded successfully." @@ -868,9 +883,9 @@ def delete_skill(self, name: str) -> str: if name not in self._skills: return f"Skill '{name}' not found." - path = self.skills_dir / f"{name}.py" - self._unload_skill(name) + path = self._skills[name].file_path path.unlink(missing_ok=True) + self._unload_skill(name) # Clean up config file and disabled state config_path = self._config_dir / f"{name}.json" config_path.unlink(missing_ok=True) @@ -886,9 +901,10 @@ def enable_skill(self, name: str) -> str: skill = self._skills[name] if skill.status != SkillStatus.DISABLED: return f"Skill '{name}' is already enabled." + candidate = self._disabled - {name} + self._save_disabled_set(candidate) + self._disabled = candidate skill.status = SkillStatus.LOADED - self._disabled.discard(name) - self._save_disabled_set() return f"Skill '{name}' enabled." def disable_skill(self, name: str) -> str: @@ -898,9 +914,10 @@ def disable_skill(self, name: str) -> str: skill = self._skills[name] if skill.status == SkillStatus.DISABLED: return f"Skill '{name}' is already disabled." + candidate = self._disabled | {name} + self._save_disabled_set(candidate) + self._disabled = candidate skill.status = SkillStatus.DISABLED - self._disabled.add(name) - self._save_disabled_set() return f"Skill '{name}' disabled. Use enable_skill to re-activate it." def is_enabled(self, name: str) -> bool: diff --git a/tests/test_campaign_governor_skills.py b/tests/test_campaign_governor_skills.py new file mode 100644 index 000000000..f1723fcbc --- /dev/null +++ b/tests/test_campaign_governor_skills.py @@ -0,0 +1,67 @@ +"""Campaign regressions using real managers/executors and inert transports.""" +from __future__ import annotations + +import json +from unittest.mock import AsyncMock + +import pytest + +from src.tools.skill_manager import SkillManager, SkillStatus + + +def skill_code(name="demo", result="ok"): + definition = {"name": name, "description": "demo", "input_schema": { + "type": "object", "properties": {}}} + return (f"SKILL_DEFINITION = {definition!r}\n" + f"async def execute(inp, context):\n return {result!r}\n") + + +def manager(tmp_path): + return SkillManager(str(tmp_path / "skills"), AsyncMock()) + + +async def test_disabled_edit_keeps_ledger_catalog_and_dispatch(tmp_path): + mgr = manager(tmp_path) + mgr.create_skill("demo", skill_code()) + mgr.disable_skill("demo") + assert "updated" in mgr.edit_skill("demo", skill_code(result="new")) + assert mgr._skills["demo"].status == SkillStatus.DISABLED + assert json.loads(mgr._disabled_path.read_text()) == ["demo"] + assert mgr.get_tool_definitions() == [] + assert "disabled" in await mgr.execute("demo", {}) + assert not manager(tmp_path).is_enabled("demo") + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_failed_activation_persistence_does_not_publish(tmp_path, monkeypatch, enabled): + mgr = manager(tmp_path) + mgr.create_skill("demo", skill_code()) + if not enabled: + mgr.disable_skill("demo") + old_status = mgr._skills["demo"].status + old_disabled = mgr._disabled.copy() + old_catalog = mgr.get_tool_definitions() + def fail(*args): + raise PermissionError("storage refused") + monkeypatch.setattr("src.tools.skill_manager.os.replace", fail) + with pytest.raises(PermissionError): + (mgr.disable_skill if enabled else mgr.enable_skill)("demo") + assert mgr._skills["demo"].status == old_status + assert mgr._disabled == old_disabled + assert mgr.get_tool_definitions() == old_catalog + assert manager(tmp_path).is_enabled("demo") == enabled + + +def test_loaded_artifact_identity_survives_edit_rollback_delete_restart(tmp_path): + mgr = manager(tmp_path) + artifact = mgr.skills_dir / "custom_filename.py" + artifact.write_text(skill_code()) + mgr = manager(tmp_path) + assert "updated" in mgr.edit_skill("demo", skill_code(result="new")) + assert not (mgr.skills_dir / "demo.py").exists() + assert "Reverted" in mgr.edit_skill("demo", skill_code(name="other")) + assert artifact.read_text() == skill_code(result="new") + assert manager(tmp_path)._skills["demo"].file_path == artifact + assert "deleted" in mgr.delete_skill("demo") + assert not artifact.exists() + assert not manager(tmp_path).has_skill("demo") From e21d4b87ddf257b267f3baee1697a695fb617522 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:00:23 -0400 Subject: [PATCH 003/162] fix(governor): admit skill helpers and selected skills through inherited authority --- pyproject.toml | 1 + src/discord/background_task.py | 7 + src/discord/native_tools/skills_tools.py | 8 + src/tools/defs/memory_skills.py | 7 +- src/tools/handlers/validation.py | 8 +- src/tools/risk_classifier.py | 65 +++++ src/tools/skill_context.py | 35 +-- src/tools/skill_manager.py | 71 ++++-- tests/test_campaign_governor_skills.py | 306 +++++++++++++++++++++++ 9 files changed, 457 insertions(+), 51 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b957ec6e0..6b5c09fb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "ruamel.yaml>=0.18", "pydantic>=2.0", "jsonschema>=4.23,<5", + "packaging>=23.2", "croniter>=2.0.0", "fastembed>=0.8.0", "sqlite-vec>=0.1.0", diff --git a/src/discord/background_task.py b/src/discord/background_task.py index 264df412a..8cdf69b1b 100644 --- a/src/discord/background_task.py +++ b/src/discord/background_task.py @@ -642,6 +642,13 @@ async def _execute_tool_captured( target_name = tool_input.get("name") if not target_name: return "Error: invoke_skill requires 'name'." + from ..tools.output_authorization import tool_scope_allows + + if not tool_scope_allows(target_name): + return ToolResult( + output="Permission denied: selected skill scope revoked or unavailable.", + ok=False, error="permission_denied", tool_name=tool_name, + ) if not skill_manager.has_skill(target_name): return f"Error: skill '{target_name}' not found or disabled." skill_input = tool_input.get("input") or {} diff --git a/src/discord/native_tools/skills_tools.py b/src/discord/native_tools/skills_tools.py index 38ffbd9a7..32c89d005 100644 --- a/src/discord/native_tools/skills_tools.py +++ b/src/discord/native_tools/skills_tools.py @@ -138,6 +138,14 @@ async def dispatch( target_name = tool_input.get("name") if not target_name: return "Error: invoke_skill requires 'name'.", effects + from ...tools.output_authorization import tool_scope_allows + from ...tools.result_validator import ToolResult + + if not tool_scope_allows(target_name): + return ToolResult( + output="Permission denied: selected skill scope revoked or unavailable.", + ok=False, error="permission_denied", tool_name=tool_name, + ), effects if not self.skill_manager.has_skill(target_name): return ( f"Error: skill '{target_name}' not found or disabled. " diff --git a/src/tools/defs/memory_skills.py b/src/tools/defs/memory_skills.py index b2ef8e866..16357670e 100644 --- a/src/tools/defs/memory_skills.py +++ b/src/tools/defs/memory_skills.py @@ -126,12 +126,15 @@ "description": ( "Creates a skill (custom tool) from Python code. Available immediately.\n" "Define: async def execute(inp: dict, context: SkillContext) -> str\n\n" - "SkillContext methods (all async):\n" + "SkillContext async methods (await these):\n" "- run_on_host(alias, cmd), read_file(host, path)\n" "- execute_tool(name, input), http_get(url), http_post(url, json=)\n" "- post_message(text), post_file(data, filename, caption)\n" "- search_knowledge(query), ingest_document(content, source), search_history(query)\n" - "- remember(key, value), recall(key), schedule_task(...), get_hosts(), log(msg)\n" + "- schedule_task(...)\n" + "SkillContext synchronous methods (do not await):\n" + "- remember(key, value) saves memory and returns None; recall(key) reads memory\n" + "- get_hosts(), log(msg) (log writes a message and returns None)\n" "See data/skills/*.template." ), "input_schema": { diff --git a/src/tools/handlers/validation.py b/src/tools/handlers/validation.py index 96e81158c..883d14932 100644 --- a/src/tools/handlers/validation.py +++ b/src/tools/handlers/validation.py @@ -56,7 +56,9 @@ async def _exec( # directly by the SSH/local primitives without touching self. if governor is not None: try: - decision = governor.check(command) + # Use the shared run_command admission path: it carries + # the task-local requester tier and exact effective alias. + allowed, denial, _note = self._govern_command(command, _address) except Exception as ge: # Fail-closed on governor exceptions: we advertise # command-type checks as going through the governor; @@ -66,8 +68,8 @@ async def _exec( # operator sees it, and treat the check as errored. log.exception("governor check raised for validation command") return 1, f"validate_action: governor check raised {type(ge).__name__}: {ge}" - if not decision.allowed: - return 1, f"governor-blocked: {decision.denial_message()}" + if not allowed: + return 1, f"governor-blocked: {denial}" # Forwarded per check from run_bundle: True only for type=command # (user-supplied text, a raw command route like run_command — # round 10); fixed-shape probes must keep pre-PR cwd semantics so diff --git a/src/tools/risk_classifier.py b/src/tools/risk_classifier.py index ec2ce1cfc..feac44df7 100644 --- a/src/tools/risk_classifier.py +++ b/src/tools/risk_classifier.py @@ -459,6 +459,64 @@ def _contains_literal_git_push(command: str) -> bool: } +def _systemctl_action(command: str) -> str | None: + """Read standard global options before a lifecycle verb, without a shell. + + Bound the scan; do not interpret expansion, substitutions or arbitrary + options as shell syntax. Existing pattern checks remain as a fallback. + """ + import shlex + + flags = {"--user", "--system", "--global", "--no-block", "--quiet", + "--no-pager", "--no-legend", "--no-ask-password", "--force", + "--full", "--all", "--runtime", "--wait", "--no-wall", + "--recursive", "--plain", "--show-types", "--value", + "--marked", "--dry-run"} + values = {"--host", "--machine", "--root", "--image", "--job-mode", + "--type", "--state", "--property", "--signal", "--kill-whom", + "--preset-mode", "--output", "--lines", "--timestamp"} + actions = {"stop", "disable", "restart", "mask", "start", "enable", "reload"} + try: + lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|()") + lexer.whitespace_split = True + lexer.commenters = "" + tokens = list(lexer) + except ValueError: + return None + found = None + for pos, token in enumerate(tokens): + if token.rsplit("/", 1)[-1] != "systemctl": + continue + cursor = pos + 1 + for _ in range(64): + if cursor >= len(tokens): + break + option = tokens[cursor] + if option == "--": + cursor += 1 + if cursor < len(tokens): + option = tokens[cursor] + else: + break + if option in actions: + if option in {"stop", "disable", "restart", "mask"}: + return "service lifecycle change" + found = "service start/enable" + break + if option in flags or (option.startswith("-") and not option.startswith("--") + and len(option) > 1 and set(option[1:]) <= set("qalfr")): + cursor += 1 + elif option in values or option in {"-H", "-M", "-t", "-p", "-o", "-n"}: + cursor += 2 + elif (option.partition("=")[0] in values and "=" in option + or option[:2] in {"-H", "-M", "-t", "-p", "-o", "-n"} + and len(option) > 2): + cursor += 1 + else: + break + return found + + def classify_command(command: str) -> RiskAssessment: """Classify a shell command string by risk level. @@ -479,6 +537,10 @@ def classify_command(command: str) -> RiskAssessment: f"unconditional git force push ({force_form})", ) + systemctl_action = _systemctl_action(command) + if systemctl_action == "service lifecycle change": + return RiskAssessment(RiskLevel.HIGH, systemctl_action) + for pattern, reason in _HIGH_PATTERNS: if pattern.search(command): return RiskAssessment(RiskLevel.HIGH, reason) @@ -486,6 +548,9 @@ def classify_command(command: str) -> RiskAssessment: if _contains_literal_git_push(command): return RiskAssessment(RiskLevel.MEDIUM, "git push") + if systemctl_action: + return RiskAssessment(RiskLevel.MEDIUM, systemctl_action) + for pattern, reason in _MEDIUM_PATTERNS: if pattern.search(command): return RiskAssessment(RiskLevel.MEDIUM, reason) diff --git a/src/tools/skill_context.py b/src/tools/skill_context.py index a18748454..106cd4afd 100644 --- a/src/tools/skill_context.py +++ b/src/tools/skill_context.py @@ -102,19 +102,10 @@ def is_url_blocked(url: str) -> bool: SKILL_SAFE_TOOLS: frozenset[str] = frozenset( { "read_file", - "search_history", - "search_audit", - "search_knowledge", - "list_knowledge", - "list_schedules", - "list_skills", - "list_tasks", "memory_manage", - "parse_time", "web_search", "fetch_url", "http_probe", - "browser_screenshot", "browser_read_page", "browser_read_table", } @@ -163,13 +154,9 @@ def __init__( async def run_on_host(self, alias: str, command: str) -> str: """Run a shell command on a managed host via SSH. Returns output string.""" - # Preserve the direct-executor seam used by older embedders/tests; - # real ToolExecutor execution takes the generation lease below. - from .executor import ToolExecutor - - if not isinstance(self._executor, ToolExecutor) and hasattr( - self._executor, "execute" - ): + # The public executor owns admission, governance and the generation + # lease. Never take the private transport shortcut on a real executor. + if hasattr(self._executor, "execute"): return str( await self._executor.execute( "run_command", @@ -177,17 +164,8 @@ async def run_on_host(self, alias: str, command: str) -> str: user_id=getattr(self, "_requester_id", None), ) ) - if isinstance(self._executor, ToolExecutor): - raw = await self._executor._run_on_host( - alias, - command, - use_workspace=True, - user_id=getattr(self, "_requester_id", None), - ) - else: - raw = await self._executor._run_on_host( - alias, command, use_workspace=True - ) + # Legacy transport-only embedders do not expose ToolExecutor admission. + raw = await self._executor._run_on_host(alias, command, use_workspace=True) if isinstance(raw, tuple): return raw[0] return raw @@ -450,7 +428,8 @@ async def delete_schedule(self, schedule_id: str) -> bool: async def execute_tool(self, tool_name: str, tool_input: dict | None = None) -> str: """Execute a safe built-in tool by name. Returns the tool's output string. - Only tools listed in SKILL_SAFE_TOOLS are allowed. Destructive + Only executor-routable tools listed in SKILL_SAFE_TOOLS are allowed. + Native-only tools require their dedicated context helper. Destructive tools (run_command, apply_patch, etc.) are blocked from skill context. """ if tool_name not in SKILL_SAFE_TOOLS: diff --git a/src/tools/skill_manager.py b/src/tools/skill_manager.py index 55eba06e6..6e06cd9cb 100644 --- a/src/tools/skill_manager.py +++ b/src/tools/skill_manager.py @@ -21,6 +21,9 @@ from typing import Any from jsonschema.validators import validator_for +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion from ..odin_log import get_logger from .executor import ToolExecutor @@ -200,10 +203,6 @@ def _parse_package_name(spec: str) -> str: return m.group(1) if m else "" -# Version-specifier operators allowed after a package name/extras. -_DEP_VERSION_RE = re.compile(r"^(===|==|~=|!=|<=|>=|<|>)?\s*[A-Za-z0-9_.*+!-]*$") - - def is_safe_dependency_spec(spec: str) -> bool: """Reject pip specs that let a skill run arbitrary code at install time. @@ -225,23 +224,45 @@ def is_safe_dependency_spec(spec: str) -> bool: or "/" in s or "\\" in s or any(lowered.startswith(v + "+") for v in ("git", "hg", "svn", "bzr")) - or any(c.isspace() for c in s) + or any(c in s for c in "\r\n\x00") ): return False - name = _parse_package_name(s) - if not name: + try: + requirement = Requirement(s) + except InvalidRequirement: return False - # Whatever follows the name[extras] must be a plain version specifier. - remainder = s.split("]", 1)[1] if "[" in s and "]" in s else s[len(name) :] - return bool(_DEP_VERSION_RE.match(remainder.strip())) + return requirement.url is None and requirement.marker is None -def _is_package_installed(name: str) -> bool: - """Check if a pip package is installed via importlib.metadata.""" +def _is_package_installed(spec: str, _seen: set[str] | None = None) -> bool: + """Check the installed version and dependencies required by extras.""" try: - distribution(name) + requirement = Requirement(spec) + if requirement.url is not None: + return False + installed = distribution(requirement.name) + if requirement.specifier and not requirement.specifier.contains(installed.version): + return False + if requirement.extras: + declared = {canonicalize_name(extra) for extra in + installed.metadata.get_all("Provides-Extra", [])} + if not {canonicalize_name(extra) for extra in requirement.extras} <= declared: + return False + seen = set() if _seen is None else _seen.copy() + if str(requirement) in seen: + return True + seen.add(str(requirement)) + for dependency in installed.requires or []: + child = Requirement(dependency) + if child.marker is None or any( + child.marker.evaluate({"extra": extra}) + for extra in ["", *requirement.extras] + ): + child.marker = None + if not _is_package_installed(str(child), seen): + return False return True - except PackageNotFoundError: + except (PackageNotFoundError, InvalidRequirement, InvalidVersion): return False @@ -351,7 +372,7 @@ def resolve_dependencies(deps: list[str]) -> tuple[list[str], list[str], list[Sk if not name: diagnostics.append(SkillDiagnostic("warn", f"Invalid dependency spec: {spec!r}")) continue - if _is_package_installed(name): + if _is_package_installed(spec): already_installed.append(spec) else: to_install.append(spec) @@ -1009,7 +1030,8 @@ def check_dependencies(self, name: str) -> dict: { "spec": spec, "package": pkg_name, - "installed": _is_package_installed(pkg_name) if pkg_name else False, + "installed": (is_safe_dependency_spec(spec) + and _is_package_installed(spec)) if pkg_name else False, } ) return { @@ -1180,6 +1202,16 @@ async def execute( requester_id: str | None = None, ) -> str: """Execute a user-created skill with timeout and sandboxing.""" + from .output_authorization import tool_scope_allows + + # invoke_skill is only a wrapper, not authority for its selected skill. + # This boundary also covers legacy/background and direct manager calls. + if not tool_scope_allows(tool_name): + return "Permission denied: selected skill scope revoked or unavailable." + if isinstance(self._executor, ToolExecutor): + denial = self._executor.check_permission(tool_name, requester_id) + if denial: + return denial skill = self._skills.get(tool_name) if not skill: return f"Skill '{tool_name}' not found." @@ -1217,8 +1249,11 @@ async def execute( ) if not isinstance(result, str): result = str(result) - # Enforce output limit - if len(result) > MAX_SKILL_OUTPUT_CHARS: + # Delivery owners retain the full bounded output before preview. + # Direct/internal callers keep the historical safety limit. + from .result_capture import capture_active + + if not capture_active() and len(result) > MAX_SKILL_OUTPUT_CHARS: result = ( result[:MAX_SKILL_OUTPUT_CHARS] + f"\n... [truncated at {MAX_SKILL_OUTPUT_CHARS} chars]" diff --git a/tests/test_campaign_governor_skills.py b/tests/test_campaign_governor_skills.py index f1723fcbc..592fb27af 100644 --- a/tests/test_campaign_governor_skills.py +++ b/tests/test_campaign_governor_skills.py @@ -1,14 +1,41 @@ """Campaign regressions using real managers/executors and inert transports.""" from __future__ import annotations +import inspect import json +from email.message import Message +from types import SimpleNamespace from unittest.mock import AsyncMock import pytest +from src.config.schema import ToolsConfig +from src.tools.executor import ToolExecutor +from src.tools.output_authorization import request_scope_authorizer, request_tool_scope +from src.tools.risk_classifier import CommandGovernor, RiskLevel, classify_command +from src.tools.skill_context import SKILL_SAFE_TOOLS, SkillContext from src.tools.skill_manager import SkillManager, SkillStatus +class Permissions: + def __init__(self, tier="admin"): + self.tier = tier + + def get_tier(self, user): + return self.tier + + def allowed_tool_names(self, user): + return None if self.tier == "admin" else set() + + +def executor(tmp_path, permissions=None): + config = ToolsConfig(hosts={"lab": {"address": "127.0.0.1", "ssh_user": "root"}}, + default_host="lab", audit_log_path=str(tmp_path / "audit.jsonl")) + ex = ToolExecutor(config, permission_manager=permissions) + ex._exec_command = AsyncMock(return_value=(0, "transport output")) + return ex + + def skill_code(name="demo", result="ok"): definition = {"name": name, "description": "demo", "input_schema": { "type": "object", "properties": {}}} @@ -65,3 +92,282 @@ def test_loaded_artifact_identity_survives_edit_rollback_delete_restart(tmp_path assert "deleted" in mgr.delete_skill("demo") assert not artifact.exists() assert not manager(tmp_path).has_skill("demo") + + +@pytest.mark.parametrize("denial", ["scope", "live_scope", "disabled", "demoted", "strict", + "host", "host_access"]) +async def test_real_executor_skill_shell_helper_preserves_admission(tmp_path, denial): + permissions = Permissions() + ex = executor(tmp_path, permissions) + ctx = SkillContext(ex, "demo", requester_id="admin") + scope = request_tool_scope.set({"demo"} if denial == "scope" else None) + live = request_scope_authorizer.set((lambda: {"demo"}) if denial == "live_scope" else None) + command = "systemctl restart example.service" if denial == "strict" else "printf safe" + alias = "missing" if denial == "host" else "lab" + if denial == "demoted": + permissions.tier = "guest" + if denial == "disabled": + ex.set_builtin_policy(SimpleNamespace(is_disabled=lambda tool: tool == "run_command")) + if denial == "strict": + ex.command_governor = CommandGovernor(host_overrides={"lab": "strict"}) + if denial == "host_access": + ex._host_access = SimpleNamespace(is_host_allowed=lambda user, host: False) + try: + output = await ctx.run_on_host(alias, command) + assert "output" not in output + ex._exec_command.assert_not_awaited() + finally: + request_scope_authorizer.reset(live) + request_tool_scope.reset(scope) + + +async def test_skill_shell_helper_preserves_admin_override_and_generation_lease(tmp_path): + ex = executor(tmp_path, Permissions()) + ctx = SkillContext(ex, "demo", requester_id="admin") + from src.tools.executor import _host_lease_ctx + + async def inert_transport(*args, **kwargs): + lease = _host_lease_ctx.get() + assert lease is not None and not lease.revoked + assert lease.target.alias == "lab" + return 0, "transport output" + + ex._exec_command = AsyncMock(side_effect=inert_transport) + output = await ctx.run_on_host("lab", "reboot") + # Inert transport proves the configured override is unchanged, no reboot. + assert "transport output" in output + ex._exec_command.assert_awaited_once() + assert ex._exec_command.call_args.kwargs["use_workspace"] is True + ex._exec_command.reset_mock() + ex.command_governor = CommandGovernor(admin_can_override=False) + assert "Blocked" in await ctx.run_on_host("lab", "reboot") + ex._exec_command.assert_not_awaited() + + +@pytest.mark.parametrize("scope_kind", ["snapshot", "live"]) +async def test_manager_selected_skill_scope_and_revocation(tmp_path, scope_kind): + mgr = manager(tmp_path) + mgr.create_skill("demo", skill_code()) + execute_fn = AsyncMock(return_value="executed") + mgr._skills["demo"].execute_fn = execute_fn + var = request_tool_scope if scope_kind == "snapshot" else request_scope_authorizer + scope = {"invoke_skill"} + token = var.set(scope if scope_kind == "snapshot" else lambda: scope) + try: + assert "Permission denied" in await mgr.execute("demo", {}, requester_id="admin") + execute_fn.assert_not_awaited() + scope.add("demo") + assert await mgr.execute("demo", {}, requester_id="admin") == "executed" + scope.remove("demo") + assert "Permission denied" in await mgr.execute("demo", {}, requester_id="admin") + assert execute_fn.await_count == 1 + finally: + var.reset(token) + + +async def test_manager_selected_skill_rbac_demoted_requester(tmp_path): + ex = executor(tmp_path, Permissions("guest")) + mgr = SkillManager(str(tmp_path / "skills"), ex) + mgr.create_skill("demo", skill_code()) + execute_fn = AsyncMock(return_value="executed") + mgr._skills["demo"].execute_fn = execute_fn + assert "Permission denied" in await mgr.execute("demo", {}, requester_id="admin") + execute_fn.assert_not_awaited() + + +async def test_native_invoke_checks_selected_skill_before_manager(tmp_path): + from src.discord.native_tools.registry import NativeToolDispatcher + mgr = manager(tmp_path) + mgr.create_skill("demo", skill_code()) + mgr.execute = AsyncMock(return_value="executed") + dispatcher = NativeToolDispatcher(owners={}, skill_manager=mgr, + tool_catalog=SimpleNamespace(), prompt_builder=SimpleNamespace(), + channel_state=SimpleNamespace(pending_files={})) + token = request_tool_scope.set({"invoke_skill"}) + try: + result, _ = await dispatcher.dispatch("invoke_skill", {"name": "demo"}, + message=SimpleNamespace(channel=SimpleNamespace(id=1)), user_id="admin", + skill_file_delivery="stage") + assert not result.ok + assert result.error == "permission_denied" + mgr.execute.assert_not_awaited() + finally: + request_tool_scope.reset(token) + + +async def test_legacy_deferred_invoke_checks_selected_skill(tmp_path): + from src.discord.background_task import _execute_tool_captured + + mgr = manager(tmp_path) + mgr.create_skill("demo", skill_code()) + mgr.execute = AsyncMock(return_value="executed") + token = request_tool_scope.set({"invoke_skill"}) + try: + result = await _execute_tool_captured("invoke_skill", {"name": "demo"}, + executor(tmp_path), mgr, None, None, "admin", requester_id="admin") + assert not result.ok + assert result.error == "permission_denied" + mgr.execute.assert_not_awaited() + finally: + request_tool_scope.reset(token) + + +@pytest.mark.parametrize("explicit_host", [True, False]) +async def test_validation_carries_effective_host_and_tier(tmp_path, explicit_host): + ex = executor(tmp_path, Permissions()) + ex.command_governor = CommandGovernor(host_overrides={"lab": "strict"}) + check = {"type": "command", "target": "systemctl restart example.service"} + if explicit_host: + check["host"] = "lab" + result = await ex.execute("validate_action", {"checks": [check]}, user_id="admin") + assert "strict-mode" in str(result) + ex._exec_command.assert_not_awaited() + ex.command_governor = CommandGovernor() + result = await ex.execute("validate_action", {"checks": [dict(check, target="reboot")]}, + user_id="admin") + ex._exec_command.assert_awaited_once() + assert "governor-blocked" not in str(result) + + +@pytest.mark.parametrize("options", ["--user", "--no-block", "--user --no-block", + "--host lab", "--machine=box", "--root /tmp", "-q --system", "-Hlab", "--"]) +def test_systemctl_global_options_cannot_hide_lifecycle(options): + command = f"systemctl {options} restart example.service" + assert classify_command(command).level == RiskLevel.HIGH + assert not CommandGovernor(host_overrides={"lab": "strict"}).check(command, host="lab").allowed + assert classify_command(f"systemctl {options} reload example.service").level == RiskLevel.MEDIUM + assert classify_command(f"systemctl {options} status example.service").level == RiskLevel.LOW + + +@pytest.mark.parametrize("command", ["systemctl --user restart; printf safe", + "printf safe && systemctl --no-block restart example.service", + "systemctl --user reload example.service; systemctl --system stop other.service"]) +def test_systemctl_option_scan_preserves_shell_boundaries_and_highest_risk(command): + assert classify_command(command).level == RiskLevel.HIGH + + +@pytest.mark.parametrize("name", ["browser_screenshot", "list_knowledge", "list_schedules", + "list_skills", "list_tasks", "parse_time", "search_audit", "search_history", + "search_knowledge"]) +async def test_native_only_names_are_not_advertised_by_generic_skill_api(tmp_path, name): + ex = executor(tmp_path) + ctx = SkillContext(ex, "demo", requester_id="admin") + assert name not in SKILL_SAFE_TOOLS + assert "not allowed" in await ctx.execute_tool(name) + ex._exec_command.assert_not_awaited() + + +def test_all_allowed_skill_generic_tools_resolve(tmp_path): + ex = executor(tmp_path) + assert all(ex._resolve_handler(tool) is not None for tool in SKILL_SAFE_TOOLS) + + +def test_create_skill_description_matches_method_kinds(): + from src.tools.registry import TOOLS + description = next(t["description"] for t in TOOLS if t["name"] == "create_skill") + async_part, sync_part = description.split("SkillContext synchronous methods") + for name in ["run_on_host", "read_file", "execute_tool", "http_get", "http_post", + "post_message", "post_file", "search_knowledge", "ingest_document", + "search_history", "schedule_task"]: + assert inspect.iscoroutinefunction(getattr(SkillContext, name)) + assert name + "(" in async_part + for name in ["remember", "recall", "get_hosts", "log"]: + assert not inspect.iscoroutinefunction(getattr(SkillContext, name)) + assert name + "(" in sync_part + + +@pytest.mark.parametrize("spec", ["requests>=2,<3", "demo[feature]>=1.2,!=1.3,<2", + "demo==1.*", "demo~=1.2", "requests >=2, <3"]) +def test_compound_requirements_are_safe(spec): + from src.tools.skill_manager import is_safe_dependency_spec + assert is_safe_dependency_spec(spec) + + +@pytest.mark.parametrize("spec", ["demo[]garbage", "demo>=", "demo>=1,,<2", + "demo @ https://example.com/archive.whl", "demo; python_version > '3'", + "demo\n--index-url=https://example.com", "demo>=1 --extra-index-url=example.com"]) +def test_requirement_parser_preserves_index_only_safety(spec): + from src.tools.skill_manager import is_safe_dependency_spec + + assert not is_safe_dependency_spec(spec) + + +def fake_distribution(version="1.5", extras=(), requires=()): + metadata = Message() + for extra in extras: + metadata["Provides-Extra"] = extra + return SimpleNamespace(version=version, metadata=metadata, requires=list(requires)) + + +def test_dependencies_check_versions_and_extra_dependencies(tmp_path, monkeypatch): + from importlib.metadata import PackageNotFoundError + + from src.tools import skill_manager as sm + installed = {"demo": fake_distribution(extras=["feature"], + requires=['child>=2; extra == "feature"']), + "child": fake_distribution("1")} + def distribution(name): + if name not in installed: + raise PackageNotFoundError(name) + return installed[name] + monkeypatch.setattr(sm, "distribution", distribution) + assert sm._is_package_installed("demo>=1,<2") + assert not sm._is_package_installed("demo==2") + assert not sm._is_package_installed("demo>=999") + assert not sm._is_package_installed("demo[missing]") + assert not sm._is_package_installed("demo[feature]") + installed["child"].version = "2" + assert sm._is_package_installed("demo[feature]") + installed.pop("child") + assert not sm._is_package_installed("demo[feature]") + mgr = manager(tmp_path) + mgr.create_skill("demo", skill_code()) + mgr._skills["demo"].metadata.dependencies = ["demo>=999", "demo[feature]"] + assert not mgr.check_dependencies("demo")["all_satisfied"] + installs = [] + monkeypatch.setattr(sm, "_install_packages", lambda specs: (installs.extend(specs) or True, "")) + already, newly, diagnostics = sm.resolve_dependencies(["demo>=999", "demo>=1,<2"]) + assert already == ["demo>=1,<2"] + assert newly == installs == ["demo>=999"] + assert not any(d.level == "error" for d in diagnostics) + + +async def test_long_skill_tail_is_retained_before_preview(tmp_path): + from src.tools.runtime_delivery import deliver_runtime_output, execution_delivery_scope + mgr = manager(tmp_path) + tail = "UNIQUE_FULL_TAIL" + output = "x" * 60000 + tail + mgr.create_skill("demo", skill_code(result=output)) + ex = executor(tmp_path) + with execution_delivery_scope("admin", "channel"): + result = await mgr.execute("demo", {}, requester_id="admin") + assert result.endswith(tail) + delivered = deliver_runtime_output(ex, result, tool_name="demo", tool_input={}, + user_id="admin", channel_id="channel", budget=1500) + envelope = json.loads(delivered) + snapshot, _ = ex._ensure_output_store().read(envelope["cursor"], owner="admin", + channel="channel", authorize=lambda tool, hosts: tool == "demo") + assert snapshot.text == output + assert snapshot.text.endswith(tail) + assert not mgr._skills["demo"].last_execution.truncated + assert "truncated at" in await mgr.execute("demo", {}) + + +async def test_long_skill_retention_quota_failure_is_explicit(tmp_path, monkeypatch): + from src.tools.output_retention import RetentionError + from src.tools.runtime_delivery import deliver_runtime_output, execution_delivery_scope + + mgr = manager(tmp_path) + output = "x" * 60000 + "TAIL" + mgr.create_skill("demo", skill_code(result=output)) + ex = executor(tmp_path) + def refuse(*args, **kwargs): + raise RetentionError("Result exceeds quota") + monkeypatch.setattr(ex._ensure_output_store(), "retain", refuse) + with execution_delivery_scope("admin", "channel"): + full = await mgr.execute("demo", {}, requester_id="admin") + assert full == output + delivered = deliver_runtime_output(ex, full, tool_name="demo", tool_input={}, + user_id="admin", channel_id="channel", budget=1500) + assert "quota" in delivered + assert "no continuation" in delivered.lower() From b3325345c93fea9e6956d847602c02d6edd3f743 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:04:40 -0400 Subject: [PATCH 004/162] docs: correct provider, auth, restart and inventory guidance --- README.md | 28 +++--- docs/computer-use/HYPRLAND-OPERATOR-R32.md | 11 +- docs/configuration.md | 35 ++++--- docs/security.md | 8 +- results/docs.md | 24 +++++ tests/test_docs_campaign_contracts.py | 112 +++++++++++++++++++++ 6 files changed, 188 insertions(+), 30 deletions(-) create mode 100644 results/docs.md create mode 100644 tests/test_docs_campaign_contracts.py diff --git a/README.md b/README.md index 26a17ec30..58f0b52d3 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ It is built for people who run real infrastructure and want an agent that **exec | | | |---|---| -| **67 built-in tools** | shell and SSH, files and patches, background processes, browser automation, web, scheduling, sub-agents, knowledge base, and memory | -| **Three model backends** | OpenAI Codex over a ChatGPT subscription (GPT-6 and GPT-5.x, multi-account), Kimi, or local Ollama — switch at runtime | -| **Management WebUI** | live execution viewer, agents, loops, processes, schedules, audit, sessions, usage, tools, skills, knowledge, hosts, config, turn state — 211 REST routes behind it | +| **Built-in tools** | shell and SSH, files and patches, background processes, browser automation, web, scheduling, sub-agents, knowledge base, and memory | +| **Model providers** | OpenAI Codex over a ChatGPT subscription, OpenAI-compatible endpoints (including Kimi and compatible hosted or self-hosted services), or Ollama — switch at runtime | +| **Management WebUI** | live execution viewer, agents, loops, processes, schedules, audit, sessions, usage, tools, skills, knowledge, hosts, config, and turn state; routes and tools are defined by the shipped registries | | **Bounded autonomy** | iteration, lifetime, and nesting limits; durable turn state that survives model-capacity outages without replaying side effects | | **Tested** | 11,712 tests across 346 files, characterization pins on the tool catalog, API routes, and tool loop | @@ -125,7 +125,7 @@ The web interface provides grouped views for: affecting deliberate memory); - health, resources, logs, configuration, permissions, host access, and updates. -The API exposes 211 REST routes (pinned in order by a characterization test) plus health, metrics, webhook, WebSocket, and static-interface routes. +The API exposes REST routes plus health, webhook, WebSocket, and static-interface routes. The authoritative route characterization test and generated API reference track the shipped inventory. The static built-in tool catalog is distinct from dynamically configured tools, including computer-use tools. Packaged computer-use operator handoffs are listed in `packaging/nfpm.yml`. ## Execution model @@ -133,7 +133,7 @@ The API exposes 211 REST routes (pinned in order by a characterization test) plu flowchart TD A["Discord · Web API · CLI"] --> B["Request admission & identity
    channel / user / mention / bot / permission policy"] B --> C["Context assembly
    session history · context files · memory · tools for this requester"] - C --> D["LLM provider
    Codex (GPT-6 / GPT-5.x) · Kimi · Ollama"] + C --> D["LLM provider
    Codex · OpenAI-compatible endpoints · Ollama"] D --> E{"Tool loop"} E -->|built-in tools| F["Managed hosts & services
    shell · SSH · files · processes · browser · web"] E -->|native handlers| G["Discord, agents, schedules, loops"] @@ -154,19 +154,19 @@ For a normal Discord request: 6. After a recognized mutation, the tool loop asks the model to run `validate_action` before the final response, retrying the request a bounded number of times; it does not hold finalization indefinitely. 7. The final response and turn trajectory are persisted and delivered to Discord or the API caller. -Odin supports three model backends: +Odin supports Codex, OpenAI-compatible providers, and Ollama: | Provider | Authentication | Notes | |---|---|---| | OpenAI Codex | OAuth device or browser flow | Primary provider path; supports account rotation and separate spawned-agent model settings | -| Kimi | Moonshot API key | Alternative hosted provider | +| OpenAI-compatible endpoints | Provider-specific credentials | Presets include hosted providers such as DeepSeek and OpenRouter; compatible local endpoints include vLLM, llama.cpp, and LM Studio. See [compatible-provider configuration](docs/configuration.md#agents). Kimi is also available through this compatible lane. | | Ollama | Local or remote endpoint; optional bearer token | Self-hosted provider path | The provider can be changed through configuration or the web interface. The Codex Advanced panel uses an explicit save action. Codex connection-pool and context-compression changes are saved immediately but require an Odin restart. Provider support does not imply that every model has equivalent tool-calling behavior or context limits. ## Built-in tools -The current release registers 67 built-in tools, 20 of them core tools. The registry is assembled from ordered definition modules under `src/tools/defs/`; tests pin the catalog order and prevent duplicate names. +The current release's static built-in tool registry is assembled from ordered definition modules under `src/tools/defs/`; characterization tests pin the catalog order and prevent duplicate names. Runtime visibility can additionally include configured dynamic tools. | Area | Tools | |---|---| @@ -268,7 +268,7 @@ For supervised use on your own screen, start with the [operator guide](docs/computer-use/OPERATOR.md) and [recovery guide](docs/computer-use/RECOVERY.md). The R11 general attached-app/action and compositor expansion is an implementation contract pending its own validation, -not a new qualification claim. Only the three operator handoffs ship in the package; +not a new qualification claim. The operator handoffs listed in `packaging/nfpm.yml` ship in the package; engineering history stays in the repository. Remote worker transport is assessment only and is not implemented. @@ -354,10 +354,12 @@ policy, and `tools.default_host` is the explicit fallback for omitted-host system work; mapping order is never treated as policy. The Codex model selectors include `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-sol`, and `gpt-6-luna` -for entitled accounts, followed by the 5.6 family. Sol and Luna accept all six -reasoning efforts (`none` through `max`) and have measured input-budget floors -of 921,799 tokens. Astra accepts `low` through `max` reasoning effort but rejects -`none`; Odin validates that pair for main, fixed-agent, and per-spawn settings. +for entitled accounts, followed by the 5.6 family. GPT-6 Sol and Luna accept +all six reasoning efforts (`none` through `max`) and have measured input-budget +floors of 921,799 tokens. GPT-6.1 Sol accepts `low` through `max`, rejects +`none`, and has a measured floor of 921,849 tokens; Astra also rejects `none`. +Odin validates incompatible model/effort pairs where model settings enter the +system. Remote `manage_process` jobs are supervised on the target with a one-hour deadline. Odin shutdown/restart attempts to terminate every tracked remote job; diff --git a/docs/computer-use/HYPRLAND-OPERATOR-R32.md b/docs/computer-use/HYPRLAND-OPERATOR-R32.md index e8f4b82ee..e78d5fbf5 100644 --- a/docs/computer-use/HYPRLAND-OPERATOR-R32.md +++ b/docs/computer-use/HYPRLAND-OPERATOR-R32.md @@ -263,9 +263,14 @@ Replaying an action ID returns the receipt only, never new input or new pixels. Set the explicit target fields above in the **installed runtime's configuration** before qualification and use its supported config-protected restart flow. With no `computer` section the existing default is still disabled isolated X11; a -successful separately configured driver does not configure normal turns. Neither -session start nor turn dispatch discovers an ambient target, loads a plugin, -changes compositor settings or falls back to a different desktop. +successful separately configured driver does not configure normal turns. For a +configured native Hyprland route, first inventory or session start may discover +the approved compositor identity and managed activation may load the approved +plugin when the default managed-activation setting is enabled. Discovery and +plugin activation are bounded by the configured/approved target and build +identity; they do not grant task consent, select an ambient desktop, or authorize +fault injection. Explicit manual-mode opt-outs remain manual. Odin does not +change compositor configuration or fall back to a different desktop. Backend identity is retained even if native startup fails. A clean failed startup releases the singleton; missing acknowledgement remains quarantined and retains diff --git a/docs/configuration.md b/docs/configuration.md index f72a295bf..5e7c352d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,10 +32,15 @@ tools: address: 203.0.113.10 ssh_user: deploy os: linux - max_tool_iterations_chat: 30 # Tool calls per Discord message - max_tool_iterations_loop: 100 # Tool calls per autonomous loop + max_tool_iterations_chat: 30 # Model/tool-batch iterations in one Discord message + max_tool_iterations_loop: 100 # Model/tool-batch iterations per autonomous loop cycle ``` +These caps count model/tool-batch iterations, not individual tool calls: one +iteration can dispatch a batch containing multiple tool calls. The loop cap is +per autonomous loop cycle, not the loop's lifetime; `start_loop.max_iterations` +is the separate total-cycle limit. + ### SSH Configuration ```yaml @@ -135,13 +140,13 @@ installs' model selections. Reasoning effort `max` is served by both families. GPT-6 Sol and Luna accept all six efforts (`none`, `low`, `medium`, `high`, `xhigh`, `max`) and each has a measured input-budget floor of 921,799 tokens (2026-09-22). -GPT-6 Astra rejects `none`. -GPT-6.1 Sol accepts `low`, `medium`, `high`, `xhigh`, and `max`, rejects -`none`, and has a measured input-budget floor of 921,849 tokens (2026-09-29). +GPT-6 Astra rejects `none`. GPT-6.1 Sol accepts `low`, `medium`, `high`, +`xhigh`, and `max`, rejects `none`, and has a measured input-budget floor of +921,849 tokens (2026-09-29). The retired `gpt-5.5` is no longer selectable. On configuration-file load, explicit main, fixed-agent, and auxiliary selections migrate in memory to -`gpt-5.6-terra`, with a warning. Existing effort selections are preserved. +`gpt-6-sol`, with a warning. Existing effort selections are preserved. Its context-budget overrides are discarded rather than transferred to a different model; any existing Terra override remains unchanged. YAML and environment placeholders are not rewritten. Live configuration updates and @@ -167,7 +172,11 @@ the Web UI (Auxiliary Model dropdown, "Off" to run those jobs on the primary). Generate credentials: `python3 scripts/codex_login.py` -Tokens expire weekly — re-run the script and copy `data/codex_auth.json` to the deployment. +Codex access tokens are refreshed automatically when needed, with refresh +serialized per account and refreshed credentials persisted. Re-run the login +flow only when refresh or authorization fails and Odin reports that the account +needs reauthorization; do not schedule periodic credential replacement. No +fixed refresh-token lifetime is promised here. ## Agents @@ -315,7 +324,7 @@ Runtime overrides persist in `data/permissions.json` and take precedence. | Tier | Access | |------|--------| -| admin | All 67 built-in tools | +| admin | All enabled built-in tools | | user | Eleven tools: get_tool_output, search_history, search_knowledge, web_search, fetch_url, list_schedules, list_tasks, list_skills, list_knowledge, manage_list, parse_time (no shell; manage_list can change list state) | | guest | Conversation only, no tools | @@ -371,10 +380,12 @@ The WebUI self-updater (Updates page) requires a **git-clone install** — `.deb` installs have no repository and should upgrade via `apt` instead (the endpoint answers 409 with the same hint). -After a successful update — and after the first-boot setup wizard saves its -config — Odin restarts **in place** by re-executing itself once graceful -shutdown completes. Recovery therefore does not depend on the service -unit's `Restart=` policy, Docker restart policy, or any supervisor at all. +After a successful update, Odin restarts **in place** by re-executing itself +once graceful shutdown completes. The first-boot setup wizard does not schedule +or perform that re-exec: it saves settings and reports when an operator restart +is required. Startup-owned settings may remain unapplied until that restart. +Update recovery therefore does not depend on the service unit's `Restart=` +policy, Docker restart policy, or any supervisor at all. `Restart=always` (what the packaged unit ships) is still recommended so the service also recovers from crashes and reboots. diff --git a/docs/security.md b/docs/security.md index 31659a2d3..80013fd4d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -89,7 +89,7 @@ Three tiers control tool access: | Tier | Tools | Use Case | |------|-------|----------| -| `admin` | All 67 built-in tools | Operators | +| `admin` | All enabled built-in tools | Operators | | `user` | 11 tools: ten read-only plus list management | Team members | | `guest` | None (chat only) | Restricted | @@ -113,7 +113,11 @@ Patterns detected: Discord tokens, API keys, Bearer tokens, SSH private keys, AW - All `/api/*` endpoints require Bearer token auth when `web.api_token` is configured - Session-based auth via `/api/auth/login` - Web chat uses server-side identity — caller cannot spoof `user_id` -- WebSocket connections authenticate via query parameter token +- WebSocket connections authenticate without placing credentials in the URL. + Browser clients offer `odin.bearer.` as a + WebSocket subprotocol; non-browser clients may send an `Authorization: + Bearer ` header. `/api/ws?token=...` is explicitly rejected (close + code 4001). Do not put credentials in query strings or logs. ## Skill Sandboxing diff --git a/results/docs.md b/results/docs.md new file mode 100644 index 000000000..abd3d59a9 --- /dev/null +++ b/results/docs.md @@ -0,0 +1,24 @@ +# Documentation campaign: issues #434–#442 + +Worktree: `/home/odin/campaign-v410-worktrees/docs` (`work/v410-docs`). + +| Issue | Change / verified behavior | Commit | Tests | +|---|---|---|---| +| #434 | Clarified update self-re-exec versus setup wizard save; wizard reports operator restart requirement and does not schedule re-exec. | pending | `tests/test_docs_campaign_contracts.py::test_setup_restart_docs_distinguish_update_from_wizard` | +| #435 | Replaced rejected WebSocket query-token guidance with browser bearer subprotocol, Authorization header option and query-token rejection. | pending | `...::test_websocket_auth_docs_describe_supported_carriers_and_reject_query_tokens` | +| #436 | Corrected Hyprland first-use description with bounded identity discovery and managed plugin activation conditions. | pending | `...::test_hyprland_operator_guide_covers_bounded_managed_first_use` | +| #437 | Documented Sol/Luna versus GPT-6.1 Sol effort and input-budget differences. | pending | `...::test_documented_codex_effort_limits_match_authoritative_defaults` | +| #438 | Removed weekly manual Codex login claim; documented automatic refresh and reauthorization on genuine failure without fixed lifetime claims. | pending | covered by docs review; test file covers core numeric/contract claims | +| #439 | Expanded provider overview to Codex, generic OpenAI-compatible endpoints (including Kimi) and Ollama. | pending | `...::test_provider_overview_includes_generic_compatible_lane` | +| #440 | Removed stale overview counts and obsolete metrics/Grafana claims. Overview refers to source registries/characterization tests and package manifest rather than guessed literals. Parent owns final generated reference and final route/tool/handoff inventory counts after integration. | pending | `...::test_route_inventory_claim_is_registry_backed_not_a_stale_literal`, `...::test_static_tools_and_packaged_handoffs_use_authoritative_inventory_contracts`, `...::test_docs_do_not_claim_removed_native_grafana_or_prometheus_features` | +| #441 | Clarified tool iteration caps count model/tool batches, with the loop cap per cycle and `start_loop.max_iterations` as lifetime cycle count. | pending | `...::test_tool_iteration_explanation_distinguishes_batch_and_cycle_budget` | +| #442 | Corrected retired-model successor to `gpt-6-sol`; verified migration source of truth. | pending | `...::test_retired_model_documentation_matches_migration_and_successor` | + +Validation run from this worktree: + +```text +/home/odin/odin-dev/.venv/bin/python -m pytest tests/test_docs_campaign_contracts.py -q +10 passed, 1 warning (third-party audioop deprecation) +``` + +Generated `docs/reference/*` and `CHANGELOG.md` intentionally untouched; parent will regenerate references/counts during integration. No live calls, deployment, service changes, or destructive commands. diff --git a/tests/test_docs_campaign_contracts.py b/tests/test_docs_campaign_contracts.py new file mode 100644 index 000000000..7f8c3e0a2 --- /dev/null +++ b/tests/test_docs_campaign_contracts.py @@ -0,0 +1,112 @@ +"""Regression checks for current operator-facing documentation claims.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_route_inventory_claim_is_registry_backed_not_a_stale_literal(): + readme = (ROOT / "README.md").read_text() + parity = (ROOT / "tests/characterization/test_api_route_parity.py").read_text() + assert "211 REST routes" not in readme + assert "237" in parity and "authoritative route characterization test" in readme + + +def test_static_tools_and_packaged_handoffs_use_authoritative_inventory_contracts(): + from src.tools.registry import TOOLS + + package = (ROOT / "packaging/nfpm.yml").read_text() + readme = (ROOT / "README.md").read_text() + static_names = {tool["name"] for tool in TOOLS} + core_names = {tool["name"] for tool in TOOLS if tool.get("is_core")} + import yaml + + package_config = yaml.safe_load(package) + shipped_handoffs = { + Path(row["dst"]).name for row in package_config["contents"] + if row.get("dst", "").startswith("/usr/share/doc/odin/computer-use/") + } + assert static_names and core_names and shipped_handoffs + assert "registers 67 built-in tools" not in readme + assert "dynamic" in readme.lower() and "static built-in tool catalog" in readme + assert "operator handoffs listed in `packaging/nfpm.yml` ship in the package" in readme + # Keep these values computed from the runtime/package definitions rather + # than copying guessed totals into overview prose. + assert len(static_names) == len(TOOLS) + assert len(shipped_handoffs) > 0 + + +def test_retired_model_documentation_matches_migration_and_successor(): + from src.config.model_defaults import RETIRED_MODEL_SUCCESSOR + + guide = (ROOT / "docs/configuration.md").read_text() + assert RETIRED_MODEL_SUCCESSOR == "gpt-6-sol" + assert "explicit main, fixed-agent, and auxiliary selections migrate in memory to\n`gpt-6-sol`" in guide + assert "`gpt-5.6-terra`, with a warning. Existing effort selections are preserved." not in guide + + +def test_documented_codex_effort_limits_match_authoritative_defaults(): + from src.config.schema import CODEX_MODEL_UNSUPPORTED_EFFORTS, CODEX_MODEL_INPUT_BUDGETS + + guide = (ROOT / "docs/configuration.md").read_text() + assert "none" in CODEX_MODEL_UNSUPPORTED_EFFORTS["gpt-6.1-sol"] + assert CODEX_MODEL_INPUT_BUDGETS["gpt-6.1-sol"] == 921_849 + assert "GPT-6.1 Sol accepts `low`, `medium`, `high`," in guide + assert "921,849 tokens" in guide + + +def test_websocket_auth_docs_describe_supported_carriers_and_reject_query_tokens(): + from src.web.websocket import BEARER_SUBPROTOCOL_PREFIX + + guide = (ROOT / "docs/security.md").read_text() + handler = (ROOT / "src/web/websocket.py").read_text() + assert BEARER_SUBPROTOCOL_PREFIX == "odin.bearer." + assert "Authorization:" in guide and "Bearer " in guide + assert "odin.bearer." in guide + assert "`/api/ws?token=...` is explicitly rejected (close" in guide + assert "token in URL is not accepted" in handler + + +def test_setup_restart_docs_distinguish_update_from_wizard(): + guide = (ROOT / "docs/configuration.md").read_text() + onboarding = (ROOT / "src/web/onboarding.py").read_text() + assert "does not schedule\nor perform that re-exec" in guide + assert "restart_required" in onboarding + + +def test_hyprland_operator_guide_covers_bounded_managed_first_use(): + guide = (ROOT / "docs/computer-use/HYPRLAND-OPERATOR-R32.md").read_text() + assert "first inventory or session start may discover" in guide + assert "managed activation may load the approved" in guide + assert "Neither\nsession start nor turn dispatch discovers an ambient target" not in guide + + +def test_provider_overview_includes_generic_compatible_lane(): + from src.config.schema import OpenAICompatibleConfig + + readme = (ROOT / "README.md").read_text() + guide = (ROOT / "docs/configuration.md").read_text() + assert OpenAICompatibleConfig is not None + assert "OpenAI-compatible endpoints" in readme + for name in ("DeepSeek", "OpenRouter", "vLLM", "llama.cpp", "LM Studio"): + assert name in guide + assert "Kimi is also available through this compatible lane" in readme + + +def test_tool_iteration_explanation_distinguishes_batch_and_cycle_budget(): + guide = (ROOT / "docs/configuration.md").read_text() + loop = (ROOT / "src/discord/tool_loop.py").read_text() + assert "not individual tool calls" in guide + assert "per autonomous loop cycle" in guide + assert "start_loop.max_iterations" in guide + assert "max_tool_iterations_loop" in loop and "loop_cap" in loop + + +def test_docs_do_not_claim_removed_native_grafana_or_prometheus_features(): + readme = (ROOT / "README.md").read_text().lower() + scheduling = (ROOT / "docs/scheduling.md").read_text().lower() + assert "/metrics" not in readme + assert "grafana alerts" not in readme + assert "/webhook/grafana" not in scheduling + assert "grafana remediation" not in scheduling + From a5a59ee6109355eb53d53ea7f96526bfd64d716f Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:05:47 -0400 Subject: [PATCH 005/162] docs: record documentation campaign results --- results/docs.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/results/docs.md b/results/docs.md index abd3d59a9..256fc6611 100644 --- a/results/docs.md +++ b/results/docs.md @@ -4,15 +4,15 @@ Worktree: `/home/odin/campaign-v410-worktrees/docs` (`work/v410-docs`). | Issue | Change / verified behavior | Commit | Tests | |---|---|---|---| -| #434 | Clarified update self-re-exec versus setup wizard save; wizard reports operator restart requirement and does not schedule re-exec. | pending | `tests/test_docs_campaign_contracts.py::test_setup_restart_docs_distinguish_update_from_wizard` | -| #435 | Replaced rejected WebSocket query-token guidance with browser bearer subprotocol, Authorization header option and query-token rejection. | pending | `...::test_websocket_auth_docs_describe_supported_carriers_and_reject_query_tokens` | -| #436 | Corrected Hyprland first-use description with bounded identity discovery and managed plugin activation conditions. | pending | `...::test_hyprland_operator_guide_covers_bounded_managed_first_use` | -| #437 | Documented Sol/Luna versus GPT-6.1 Sol effort and input-budget differences. | pending | `...::test_documented_codex_effort_limits_match_authoritative_defaults` | -| #438 | Removed weekly manual Codex login claim; documented automatic refresh and reauthorization on genuine failure without fixed lifetime claims. | pending | covered by docs review; test file covers core numeric/contract claims | -| #439 | Expanded provider overview to Codex, generic OpenAI-compatible endpoints (including Kimi) and Ollama. | pending | `...::test_provider_overview_includes_generic_compatible_lane` | -| #440 | Removed stale overview counts and obsolete metrics/Grafana claims. Overview refers to source registries/characterization tests and package manifest rather than guessed literals. Parent owns final generated reference and final route/tool/handoff inventory counts after integration. | pending | `...::test_route_inventory_claim_is_registry_backed_not_a_stale_literal`, `...::test_static_tools_and_packaged_handoffs_use_authoritative_inventory_contracts`, `...::test_docs_do_not_claim_removed_native_grafana_or_prometheus_features` | -| #441 | Clarified tool iteration caps count model/tool batches, with the loop cap per cycle and `start_loop.max_iterations` as lifetime cycle count. | pending | `...::test_tool_iteration_explanation_distinguishes_batch_and_cycle_budget` | -| #442 | Corrected retired-model successor to `gpt-6-sol`; verified migration source of truth. | pending | `...::test_retired_model_documentation_matches_migration_and_successor` | +| #434 | Clarified update self-re-exec versus setup wizard save; wizard reports operator restart requirement and does not schedule re-exec. | `150e491` | `tests/test_docs_campaign_contracts.py::test_setup_restart_docs_distinguish_update_from_wizard` | +| #435 | Replaced rejected WebSocket query-token guidance with browser bearer subprotocol, Authorization header option and query-token rejection. | `150e491` | `...::test_websocket_auth_docs_describe_supported_carriers_and_reject_query_tokens` | +| #436 | Corrected Hyprland first-use description with bounded identity discovery and managed plugin activation conditions. | `150e491` | `...::test_hyprland_operator_guide_covers_bounded_managed_first_use` | +| #437 | Documented Sol/Luna versus GPT-6.1 Sol effort and input-budget differences. | `150e491` | `...::test_documented_codex_effort_limits_match_authoritative_defaults` | +| #438 | Removed weekly manual Codex login claim; documented automatic refresh and reauthorization on genuine failure without fixed lifetime claims. | `150e491` | Test file verifies docs contract and key claims; no independent refresh-flow assertion. | +| #439 | Expanded provider overview to Codex, generic OpenAI-compatible endpoints (including Kimi) and Ollama. | `150e491` | `...::test_provider_overview_includes_generic_compatible_lane` | +| #440 | Removed stale overview counts and obsolete metrics/Grafana claims. Overview refers to source registries/characterization tests and package manifest rather than guessed literals. Parent owns final generated reference and final route/tool/handoff inventory counts after integration. | `150e491` | `...::test_route_inventory_claim_is_registry_backed_not_a_stale_literal`, `...::test_static_tools_and_packaged_handoffs_use_authoritative_inventory_contracts`, `...::test_docs_do_not_claim_removed_native_grafana_or_prometheus_features` | +| #441 | Clarified tool iteration caps count model/tool batches, with the loop cap per cycle and `start_loop.max_iterations` as lifetime cycle count. | `150e491` | `...::test_tool_iteration_explanation_distinguishes_batch_and_cycle_budget` | +| #442 | Corrected retired-model successor to `gpt-6-sol`; verified migration source of truth. | `150e491` | `...::test_retired_model_documentation_matches_migration_and-successor` | Validation run from this worktree: From 781600a723ceb4b6e23c4119712bdc2b9dfc8058 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:13:34 -0400 Subject: [PATCH 006/162] Include rebuilt WebUI asset for monitoring removal --- ui/dist/assets/index-Bs48I-Ro.js | 7595 ++++++++++++++++++++++++++++++ 1 file changed, 7595 insertions(+) create mode 100644 ui/dist/assets/index-Bs48I-Ro.js diff --git a/ui/dist/assets/index-Bs48I-Ro.js b/ui/dist/assets/index-Bs48I-Ro.js new file mode 100644 index 000000000..1d4acbc25 --- /dev/null +++ b/ui/dist/assets/index-Bs48I-Ro.js @@ -0,0 +1,7595 @@ +var ly=Object.defineProperty;var oy=(e,t,s)=>t in e?ly(e,t,{enumerable:!0,configurable:!0,writable:!0,value:s}):e[t]=s;var Et=(e,t,s)=>oy(e,typeof t!="symbol"?t+"":t,s);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))a(n);new MutationObserver(n=>{for(const i of n)if(i.type==="childList")for(const l of i.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&a(l)}).observe(document,{childList:!0,subtree:!0});function s(n){const i={};return n.integrity&&(i.integrity=n.integrity),n.referrerPolicy&&(i.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?i.credentials="include":n.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function a(n){if(n.ep)return;n.ep=!0;const i=s(n);fetch(n.href,i)}})();class ry{constructor(){this._persist=localStorage.getItem("odin_persist")==="1",this._token=this._persist?localStorage.getItem("odin_token")||"":sessionStorage.getItem("odin_token")||"";const t=this._persist?localStorage:sessionStorage;this._sessionTimeout=parseInt(t.getItem("odin_session_timeout")||"0",10),this._lastActivity=Date.now(),this._activityTimer=null,this.onSessionExpired=null,this._token&&this._sessionTimeout>0&&this._startActivityMonitor()}get token(){return this._token}get sessionTimeout(){return this._sessionTimeout}setToken(t,s=0){this._token=t,this._sessionTimeout=s,this._lastActivity=Date.now(),t?(this._persist?(localStorage.setItem("odin_token",t),s>0?localStorage.setItem("odin_session_timeout",String(s)):localStorage.removeItem("odin_session_timeout"),localStorage.setItem("odin_persist","1"),sessionStorage.removeItem("odin_token"),sessionStorage.removeItem("odin_session_timeout")):(localStorage.removeItem("odin_persist"),localStorage.removeItem("odin_token"),localStorage.removeItem("odin_session_timeout"),sessionStorage.setItem("odin_token",t),s>0?sessionStorage.setItem("odin_session_timeout",String(s)):sessionStorage.removeItem("odin_session_timeout")),this._startActivityMonitor()):(localStorage.removeItem("odin_persist"),sessionStorage.removeItem("odin_token"),sessionStorage.removeItem("odin_session_timeout"),localStorage.removeItem("odin_token"),localStorage.removeItem("odin_session_timeout"),this._stopActivityMonitor())}setPersist(t){this._persist=t}_startActivityMonitor(){this._stopActivityMonitor(),!(this._sessionTimeout<=0)&&(this._activityTimer=setInterval(()=>{(Date.now()-this._lastActivity)/1e3>=this._sessionTimeout&&(this._stopActivityMonitor(),this.onSessionExpired&&this.onSessionExpired())},1e4))}_stopActivityMonitor(){this._activityTimer&&(clearInterval(this._activityTimer),this._activityTimer=null)}_headers(t={}){const s={"Content-Type":"application/json",...t};return this._token&&(s.Authorization=`Bearer ${this._token}`),s}async _request(t,s,a=null,{signal:n}={}){this._lastActivity=Date.now();const i={method:t,headers:this._headers(),signal:n};a!==null&&(i.body=JSON.stringify(a));const l=await fetch(s,i);if(l.status===401)throw new Eo("Unauthorized");const o=await l.json().catch(()=>null);if(!l.ok){const r=(o==null?void 0:o.error)||`HTTP ${l.status}`;throw new Rc(r,l.status,o)}return o}get(t,s={}){return this._request("GET",t,null,s)}async getBlob(t){this._lastActivity=Date.now();const s=await fetch(t,{method:"GET",headers:this._headers()});if(s.status===401)throw new Eo("Unauthorized");if(!s.ok){const a=await s.json().catch(()=>null);throw new Rc((a==null?void 0:a.error)||`HTTP ${s.status}`,s.status,a)}return s.blob()}post(t,s){return this._request("POST",t,s)}async setListenerExposure(t,s){const a=await fetch("/api/setup/listener",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${t}`},body:JSON.stringify({expose_beyond_loopback:s})}),n=await a.json().catch(()=>null);if(!a.ok)throw new Rc((n==null?void 0:n.error)||"Listener reauthentication failed",a.status,n);return n}put(t,s){return this._request("PUT",t,s)}del(t){return this._request("DELETE",t)}async login(t){const s=await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:t})}),a=await s.json().catch(()=>null);if(!s.ok)throw new Eo((a==null?void 0:a.error)||"Login failed");return this.setToken(a.session_id,a.timeout_seconds||0),a}async logout(){const t=this.post("/api/auth/logout",{});this.setToken("");try{await t}catch{}}async check(){try{return await this.get("/api/status"),{ok:!0,needsAuth:!1}}catch(t){return t instanceof Eo?{ok:!1,needsAuth:!0}:{ok:!1,needsAuth:!1,error:t.message}}}}class Eo extends Error{constructor(t){super(t),this.name="AuthError"}}class Rc extends Error{constructor(t,s,a){super(t),this.name="ApiError",this.status=s,this.data=a}}class cy{constructor(t){this._api=t,this._ws=null,this._handlers={logs:[],events:[],chat:[]},this._reconnectDelay=1e3,this._maxReconnectDelay=3e4,this._shouldConnect=!1,this._subscriptions=new Set,this._reconnectAttempt=0,this._reconnectTimer=null,this._lastPongTime=0,this._pingInterval=null,this._forcedRetireTimer=null,this._subscriptionAckTimer=null,this._pendingReconnect=null,this._latency=-1,this._chatPending=!1,this._state="disconnected",this._lifecycle={status:new Set,state:new Set,latency:new Set,reconnected:new Set},this._everConnected=!1,this._reconnectEpoch=0}onStatus(t){return this._addLifecycle("status",t)}onState(t){return this._addLifecycle("state",t)}onLatencyChange(t){return this._addLifecycle("latency",t)}onReconnected(t){return this._addLifecycle("reconnected",t)}_addLifecycle(t,s){return this._lifecycle[t].add(s),()=>{this._lifecycle[t].delete(s)}}_emitLifecycle(t,...s){for(const a of[...this._lifecycle[t]])try{a(...s)}catch{}}get connected(){var t;return((t=this._ws)==null?void 0:t.readyState)===WebSocket.OPEN}get state(){return this._state}get reconnectAttempt(){return this._reconnectAttempt}get latency(){return this._latency}get reconnectEpoch(){return this._reconnectEpoch}_resetLatency(){this._latency=-1,this._emitLifecycle("latency",-1)}connect(){this._shouldConnect=!0,this._setState("connecting"),this._open()}disconnect(){this._shouldConnect=!1,this._everConnected=!1,this._reconnectTimer&&(clearTimeout(this._reconnectTimer),this._reconnectTimer=null),this._forcedRetireTimer&&(clearTimeout(this._forcedRetireTimer),this._forcedRetireTimer=null),this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._pendingReconnect=null,this._reconnectAttempt=0,this._resetLatency(),this._stopPing(),this._ws&&(this._ws.close(),this._ws=null),this._setState("disconnected")}_setState(t){this._state!==t&&(this._state=t,this._emitLifecycle("state",t,{attempt:this._reconnectAttempt,latency:this._latency}))}_startPing(t){this._stopPing(),this._lastPongTime=Date.now(),this._pingInterval=setInterval(()=>{if(!(this._ws!==t||t.readyState!==WebSocket.OPEN)){if(this._lastPongTime&&Date.now()-this._lastPongTime>47e3){this._beginForcedRetirement(t,"pong timeout");return}try{t.send(JSON.stringify({type:"ping",ts:Date.now()}))}catch{}}},15e3)}_beginForcedRetirement(t,s){if(!(this._ws!==t||this._forcedRetireTimer)){this._stopPing(),this._reconnectAttempt++,this._setState("reconnecting"),this._emitLifecycle("status",!1),this._forcedRetireTimer=setTimeout(()=>{this._forcedRetireTimer=null,this._retireSocket(t,!0,!0)},1e3);try{t.close(4e3,s)}catch{}}}_scheduleReconnect(t=!0){!this._shouldConnect||this._reconnectTimer||(t&&this._reconnectAttempt++,this._setState("reconnecting"),this._reconnectTimer=setTimeout(()=>{this._reconnectTimer=null,this._open()},this._reconnectDelay),this._reconnectDelay=Math.min(this._reconnectDelay*2,this._maxReconnectDelay))}_retireSocket(t,s=!1,a=!1){if(this._ws===t){if(this._forcedRetireTimer&&(clearTimeout(this._forcedRetireTimer),this._forcedRetireTimer=null),this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._pendingReconnect=null,this._ws=null,this._stopPing(),this._resetLatency(),this._chatPending){this._chatPending=!1;const n={type:"chat_error",error:"Connection lost — the response may still complete; check session history."};for(const i of this._handlers.chat||[])i(n)}s||this._emitLifecycle("status",!1),this._shouldConnect?this._scheduleReconnect(!a):this._setState("disconnected")}}_beginReconnectBarrier(t,s){if(!s)return;const a=new Set(this._subscriptions);if(a.size===0){this._reconnectEpoch+=1,this._emitLifecycle("reconnected",this._reconnectEpoch);return}this._pendingReconnect={socket:t,channels:a},this._subscriptionAckTimer=setTimeout(()=>{var n;((n=this._pendingReconnect)==null?void 0:n.socket)===t&&this._beginForcedRetirement(t,"subscription acknowledgement timeout")},5e3)}_ackSubscription(t,s){const a=this._pendingReconnect;!a||a.socket!==t||!a.channels.has(s)||(a.channels.delete(s),!(a.channels.size>0)&&(this._pendingReconnect=null,this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._reconnectEpoch+=1,this._emitLifecycle("reconnected",this._reconnectEpoch)))}_stopPing(){this._pingInterval&&(clearInterval(this._pingInterval),this._pingInterval=null)}subscribe(t,s){var a;if(this._handlers[t]||(this._handlers[t]=[]),this._handlers[t].push(s),t!=="chat"&&(this._subscriptions.add(t),this.connected)){const n=this._ws;((a=this._pendingReconnect)==null?void 0:a.socket)===n&&this._pendingReconnect.channels.add(t),n.send(JSON.stringify({subscribe:t}))}}unsubscribe(t,s){const a=this._handlers[t];if(a){const n=a.indexOf(s);if(n>=0&&a.splice(n,1),a.length===0&&t!=="chat"&&(this._subscriptions.delete(t),this.connected)){const i=this._ws;i.send(JSON.stringify({unsubscribe:t})),this._ackSubscription(i,t)}}}on(t,s){return this.subscribe(t,s)}off(t,s){return this.unsubscribe(t,s)}sendChat(t,{channelId:s,userId:a,username:n}={}){return this.connected?(this._ws.send(JSON.stringify({type:"chat",content:t,channel_id:s||"web-default",user_id:a||void 0,username:n||void 0})),this._chatPending=!0,!0):!1}_open(){if(this._ws||!this._shouldConnect)return;const s=`${location.protocol==="https:"?"wss:":"ws:"}//${location.host}/api/ws`,a=this._api.token?["odin.bearer."+btoa(this._api.token).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")]:void 0,n=a?new WebSocket(s,a):new WebSocket(s);this._ws=n;const i=()=>this._ws===n;n.onopen=()=>{if(!i())return;const l=this._everConnected;this._everConnected=!0,this._reconnectDelay=1e3,this._reconnectAttempt=0;for(const o of this._subscriptions)n.send(JSON.stringify({subscribe:o}));this._startPing(n),this._setState("connected"),this._emitLifecycle("status",!0),this._beginReconnectBarrier(n,l)},n.onmessage=l=>{if(!i())return;let o;try{o=JSON.parse(l.data)}catch{return}const r=o.type;if(r==="pong"){o.ts&&(this._latency=Date.now()-o.ts,this._lastPongTime=Date.now(),this._emitLifecycle("latency",this._latency));return}if(r==="subscribed"){this._ackSubscription(n,o.channel);return}if(r==="log")for(const c of this._handlers.logs||[])c(o);else if(r==="event")for(const c of this._handlers.events||[])c(o);else if(r==="chat_response"||r==="chat_error"){this._chatPending=!1;for(const c of this._handlers.chat||[])c(o)}},n.onclose=()=>{const l=!!this._forcedRetireTimer;this._retireSocket(n,l,l)},n.onerror=()=>{}}}const z=new ry,mt=new cy(z);/** +* @vue/shared v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Xs(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const ct={},Li=[],vs=()=>{},Ri=()=>!1,li=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Br=e=>e.startsWith("onUpdate:"),lt=Object.assign,Gd=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},dy=Object.prototype.hasOwnProperty,bt=(e,t)=>dy.call(e,t),Me=Array.isArray,Ni=e=>el(e)==="[object Map]",oi=e=>el(e)==="[object Set]",vp=e=>el(e)==="[object Date]",uy=e=>el(e)==="[object RegExp]",Ve=e=>typeof e=="function",Ze=e=>typeof e=="string",Cs=e=>typeof e=="symbol",ht=e=>e!==null&&typeof e=="object",Wd=e=>(ht(e)||Ve(e))&&Ve(e.then)&&Ve(e.catch),Em=Object.prototype.toString,el=e=>Em.call(e),py=e=>el(e).slice(8,-1),Ur=e=>el(e)==="[object Object]",zr=e=>Ze(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,en=Xs(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),fy=Xs("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),Hr=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},my=/-\w/g,Tt=Hr(e=>e.replace(my,t=>t.slice(1).toUpperCase())),vy=/\B([A-Z])/g,Hs=Hr(e=>e.replace(vy,"-$1").toLowerCase()),ri=Hr(e=>e.charAt(0).toUpperCase()+e.slice(1)),Mi=Hr(e=>e?`on${ri(e)}`:""),os=(e,t)=>!Object.is(e,t),Di=(e,...t)=>{for(let s=0;s{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:a,value:s})},jr=e=>{const t=parseFloat(e);return isNaN(t)?e:t},rr=e=>{const t=Ze(e)?Number(e):NaN;return isNaN(t)?e:t};let hp;const qr=()=>hp||(hp=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function hy(e,t){return e+JSON.stringify(t,(s,a)=>typeof a=="function"?a.toString():a)}const gy="Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol",by=Xs(gy);function ro(e){if(Me(e)){const t={};for(let s=0;s{if(s){const a=s.split(xy);a.length>1&&(t[a[0].trim()]=a[1].trim())}}),t}function co(e){let t="";if(Ze(e))t=e;else if(Me(e))for(let s=0;snn(s,t))}const Om=e=>!!(e&&e.__v_isRef===!0),Lm=e=>Ze(e)?e:e==null?"":Me(e)||ht(e)&&(e.toString===Em||!Ve(e.toString))?Om(e)?Lm(e.value):JSON.stringify(e,Nm,2):String(e),Nm=(e,t)=>Om(t)?Nm(e,t.value):Ni(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[a,n],i)=>(s[Ic(a,i)+" =>"]=n,s),{})}:oi(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ic(s))}:Cs(t)?Ic(t):ht(t)&&!Me(t)&&!Ur(t)?String(t):t,Ic=(e,t="")=>{var s;return Cs(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};function My(e){return e==null?"initial":typeof e=="string"?e===""?" ":e:String(e)}/** +* @vue/reactivity v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let as;class Kd{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&as&&(as.active?(this.parent=as,this.index=(as.scopes||(as.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t0&&--this._on===0){if(as===this)as=this.prevScope;else{let t=as;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,a;for(s=0,a=this.effects.length;s0)return;if(Ll){let t=Ll;for(Ll=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Ol;){let t=Ol;for(Ol=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(a){e||(e=a)}t=s}}if(e)throw e}function $m(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Fm(e){let t,s=e.depsTail,a=s;for(;a;){const n=a.prevDep;a.version===-1?(a===s&&(s=n),Qd(a),$y(a)):t=a,a.dep.activeLink=a.prevActiveLink,a.prevActiveLink=void 0,a=n}e.deps=t,e.depsTail=s}function id(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Bm(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Bm(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Hl)||(e.globalVersion=Hl,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!id(e))))return;e.flags|=2;const t=e.dep,s=At,a=ma;At=e,ma=!0;try{$m(e);const n=e.fn(e._value);(t.version===0||os(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(n){throw t.version++,n}finally{At=s,ma=a,Fm(e),e.flags&=-3}}function Qd(e,t=!1){const{dep:s,prevSub:a,nextSub:n}=e;if(a&&(a.nextSub=n,e.prevSub=void 0),n&&(n.prevSub=a,e.nextSub=void 0),s.subs===e&&(s.subs=a,!a&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)Qd(i,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function $y(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}function Fy(e,t){e.effect instanceof zl&&(e=e.effect.fn);const s=new zl(e);t&<(s,t);try{s.run()}catch(n){throw s.stop(),n}const a=s.run.bind(s);return a.effect=s,a}function By(e){e.effect.stop()}let ma=!0;const Um=[];function ln(){Um.push(ma),ma=!1}function on(){const e=Um.pop();ma=e===void 0?!0:e}function gp(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=At;At=void 0;try{t()}finally{At=s}}}let Hl=0;class Uy{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Gr{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!At||!ma||At===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==At)s=this.activeLink=new Uy(At,this),At.deps?(s.prevDep=At.depsTail,At.depsTail.nextDep=s,At.depsTail=s):At.deps=At.depsTail=s,zm(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const a=s.nextDep;a.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=a),s.prevDep=At.depsTail,s.nextDep=void 0,At.depsTail.nextDep=s,At.depsTail=s,At.deps===s&&(At.deps=a)}return s}trigger(t){this.version++,Hl++,this.notify(t)}notify(t){Jd();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{Zd()}}}function zm(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let a=t.deps;a;a=a.nextDep)zm(a)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const cr=new WeakMap,Qn=Symbol(""),ld=Symbol(""),jl=Symbol("");function ws(e,t,s){if(ma&&At){let a=cr.get(e);a||cr.set(e,a=new Map);let n=a.get(s);n||(a.set(s,n=new Gr),n.map=a,n.key=s),n.track()}}function Ja(e,t,s,a,n,i){const l=cr.get(e);if(!l){Hl++;return}const o=r=>{r&&r.trigger()};if(Jd(),t==="clear")l.forEach(o);else{const r=Me(e),c=r&&zr(s);if(r&&s==="length"){const d=Number(a);l.forEach((u,p)=>{(p==="length"||p===jl||!Cs(p)&&p>=d)&&o(u)})}else switch((s!==void 0||l.has(void 0))&&o(l.get(s)),c&&o(l.get(jl)),t){case"add":r?c&&o(l.get("length")):(o(l.get(Qn)),Ni(e)&&o(l.get(ld)));break;case"delete":r||(o(l.get(Qn)),Ni(e)&&o(l.get(ld)));break;case"set":Ni(e)&&o(l.get(Qn));break}}Zd()}function zy(e,t){const s=cr.get(e);return s&&s.get(t)}function yi(e){const t=ft(e);return t===e?t:(ws(t,"iterate",jl),qs(e)?t:t.map(ha))}function Wr(e){return ws(e=ft(e),"iterate",jl),e}function Oa(e,t){return Na(e)?Hi(tn(e)?ha(t):t):ha(t)}const Hy={__proto__:null,[Symbol.iterator](){return Lc(this,Symbol.iterator,e=>Oa(this,e))},concat(...e){return yi(this).concat(...e.map(t=>Me(t)?yi(t):t))},entries(){return Lc(this,"entries",e=>(e[1]=Oa(this,e[1]),e))},every(e,t){return za(this,"every",e,t,void 0,arguments)},filter(e,t){return za(this,"filter",e,t,s=>s.map(a=>Oa(this,a)),arguments)},find(e,t){return za(this,"find",e,t,s=>Oa(this,s),arguments)},findIndex(e,t){return za(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return za(this,"findLast",e,t,s=>Oa(this,s),arguments)},findLastIndex(e,t){return za(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return za(this,"forEach",e,t,void 0,arguments)},includes(...e){return Nc(this,"includes",e)},indexOf(...e){return Nc(this,"indexOf",e)},join(e){return yi(this).join(e)},lastIndexOf(...e){return Nc(this,"lastIndexOf",e)},map(e,t){return za(this,"map",e,t,void 0,arguments)},pop(){return ul(this,"pop")},push(...e){return ul(this,"push",e)},reduce(e,...t){return bp(this,"reduce",e,t)},reduceRight(e,...t){return bp(this,"reduceRight",e,t)},shift(){return ul(this,"shift")},some(e,t){return za(this,"some",e,t,void 0,arguments)},splice(...e){return ul(this,"splice",e)},toReversed(){return yi(this).toReversed()},toSorted(e){return yi(this).toSorted(e)},toSpliced(...e){return yi(this).toSpliced(...e)},unshift(...e){return ul(this,"unshift",e)},values(){return Lc(this,"values",e=>Oa(this,e))}};function Lc(e,t,s){const a=Wr(e),n=a[t]();return a!==e&&!qs(e)&&(n._next=n.next,n.next=()=>{const i=n._next();return i.done||(i.value=s(i.value)),i}),n}const jy=Array.prototype;function za(e,t,s,a,n,i){const l=Wr(e),o=l!==e&&!qs(e),r=l[t];if(r!==jy[t]){const u=r.apply(e,i);return o?ha(u):u}let c=s;l!==e&&(o?c=function(u,p){return s.call(this,Oa(e,u),p,e)}:s.length>2&&(c=function(u,p){return s.call(this,u,p,e)}));const d=r.call(l,c,a);return o&&n?n(d):d}function bp(e,t,s,a){const n=Wr(e),i=n!==e&&!qs(e);let l=s,o=!1;n!==e&&(i?(o=a.length===0,l=function(c,d,u){return o&&(o=!1,c=Oa(e,c)),s.call(this,c,Oa(e,d),u,e)}):s.length>3&&(l=function(c,d,u){return s.call(this,c,d,u,e)}));const r=n[t](l,...a);return o?Oa(e,r):r}function Nc(e,t,s){const a=ft(e);ws(a,"iterate",jl);const n=a[t](...s);return(n===-1||n===!1)&&uo(s[0])?(s[0]=ft(s[0]),a[t](...s)):n}function ul(e,t,s=[]){ln(),Jd();const a=ft(e)[t].apply(e,s);return Zd(),on(),a}const qy=Xs("__proto__,__v_isRef,__isVue"),Hm=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Cs));function Vy(e){Cs(e)||(e=String(e));const t=ft(this);return ws(t,"has",e),t.hasOwnProperty(e)}class jm{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,a){if(s==="__v_skip")return t.__v_skip;const n=this._isReadonly,i=this._isShallow;if(s==="__v_isReactive")return!n;if(s==="__v_isReadonly")return n;if(s==="__v_isShallow")return i;if(s==="__v_raw")return a===(n?i?Jm:Km:i?Wm:Gm).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(a)?t:void 0;const l=Me(t);if(!n){let r;if(l&&(r=Hy[s]))return r;if(s==="hasOwnProperty")return Vy}const o=Reflect.get(t,s,Xt(t)?t:a);if((Cs(s)?Hm.has(s):qy(s))||(n||ws(t,"get",s),i))return o;if(Xt(o)){const r=l&&zr(s)?o:o.value;return n&&ht(r)?dr(r):r}return ht(o)?n?dr(o):Mn(o):o}}class qm extends jm{constructor(t=!1){super(!1,t)}set(t,s,a,n){let i=t[s];const l=Me(t)&&zr(s);if(!this._isShallow){const c=Na(i);if(!qs(a)&&!Na(a)&&(i=ft(i),a=ft(a)),!l&&Xt(i)&&!Xt(a))return c||(i.value=a),!0}const o=l?Number(s)e,Ao=e=>Reflect.getPrototypeOf(e);function Zy(e,t,s){return function(...a){const n=this.__v_raw,i=ft(n),l=Ni(i),o=e==="entries"||e===Symbol.iterator&&l,r=e==="keys"&&l,c=n[e](...a),d=s?od:t?Hi:ha;return!t&&ws(i,"iterate",r?ld:Qn),lt(Object.create(c),{next(){const{value:u,done:p}=c.next();return p?{value:u,done:p}:{value:o?[d(u[0]),d(u[1])]:d(u),done:p}}})}}function Ro(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Qy(e,t){const s={get(n){const i=this.__v_raw,l=ft(i),o=ft(n);e||(os(n,o)&&ws(l,"get",n),ws(l,"get",o));const{has:r}=Ao(l),c=t?od:e?Hi:ha;if(r.call(l,n))return c(i.get(n));if(r.call(l,o))return c(i.get(o));i!==l&&i.get(n)},get size(){const n=this.__v_raw;return!e&&ws(ft(n),"iterate",Qn),n.size},has(n){const i=this.__v_raw,l=ft(i),o=ft(n);return e||(os(n,o)&&ws(l,"has",n),ws(l,"has",o)),n===o?i.has(n):i.has(n)||i.has(o)},forEach(n,i){const l=this,o=l.__v_raw,r=ft(o),c=t?od:e?Hi:ha;return!e&&ws(r,"iterate",Qn),o.forEach((d,u)=>n.call(i,c(d),c(u),l))}};return lt(s,e?{add:Ro("add"),set:Ro("set"),delete:Ro("delete"),clear:Ro("clear")}:{add(n){const i=ft(this),l=Ao(i),o=ft(n),r=!t&&!qs(n)&&!Na(n)?o:n;return l.has.call(i,r)||os(n,r)&&l.has.call(i,n)||os(o,r)&&l.has.call(i,o)||(i.add(r),Ja(i,"add",r,r)),this},set(n,i){!t&&!qs(i)&&!Na(i)&&(i=ft(i));const l=ft(this),{has:o,get:r}=Ao(l);let c=o.call(l,n);c||(n=ft(n),c=o.call(l,n));const d=r.call(l,n);return l.set(n,i),c?os(i,d)&&Ja(l,"set",n,i):Ja(l,"add",n,i),this},delete(n){const i=ft(this),{has:l,get:o}=Ao(i);let r=l.call(i,n);r||(n=ft(n),r=l.call(i,n)),o&&o.call(i,n);const c=i.delete(n);return r&&Ja(i,"delete",n,void 0),c},clear(){const n=ft(this),i=n.size!==0,l=n.clear();return i&&Ja(n,"clear",void 0,void 0),l}}),["keys","values","entries",Symbol.iterator].forEach(n=>{s[n]=Zy(n,e,t)}),s}function Kr(e,t){const s=Qy(e,t);return(a,n,i)=>n==="__v_isReactive"?!e:n==="__v_isReadonly"?e:n==="__v_raw"?a:Reflect.get(bt(s,n)&&n in a?s:a,n,i)}const Yy={get:Kr(!1,!1)},Xy={get:Kr(!1,!0)},ex={get:Kr(!0,!1)},tx={get:Kr(!0,!0)},Gm=new WeakMap,Wm=new WeakMap,Km=new WeakMap,Jm=new WeakMap;function sx(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Mn(e){return Na(e)?e:Jr(e,!1,Gy,Yy,Gm)}function Yd(e){return Jr(e,!1,Ky,Xy,Wm)}function dr(e){return Jr(e,!0,Wy,ex,Km)}function ax(e){return Jr(e,!0,Jy,tx,Jm)}function Jr(e,t,s,a,n){if(!ht(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=n.get(e);if(i)return i;const l=sx(py(e));if(l===0)return e;const o=new Proxy(e,l===2?a:s);return n.set(e,o),o}function tn(e){return Na(e)?tn(e.__v_raw):!!(e&&e.__v_isReactive)}function Na(e){return!!(e&&e.__v_isReadonly)}function qs(e){return!!(e&&e.__v_isShallow)}function uo(e){return e?!!e.__v_raw:!1}function ft(e){const t=e&&e.__v_raw;return t?ft(t):e}function Zm(e){return!bt(e,"__v_skip")&&Object.isExtensible(e)&&Am(e,"__v_skip",!0),e}const ha=e=>ht(e)?Mn(e):e,Hi=e=>ht(e)?dr(e):e;function Xt(e){return e?e.__v_isRef===!0:!1}function f(e){return Qm(e,!1)}function Xd(e){return Qm(e,!0)}function Qm(e,t){return Xt(e)?e:new nx(e,t)}class nx{constructor(t,s){this.dep=new Gr,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:ft(t),this._value=s?t:ha(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,a=this.__v_isShallow||qs(t)||Na(t);t=a?t:ft(t),os(t,s)&&(this._rawValue=t,this._value=a?t:ha(t),this.dep.trigger())}}function ix(e){e.dep&&e.dep.trigger()}function La(e){return Xt(e)?e.value:e}function lx(e){return Ve(e)?e():La(e)}const ox={get:(e,t,s)=>t==="__v_raw"?e:La(Reflect.get(e,t,s)),set:(e,t,s,a)=>{const n=e[t];return Xt(n)&&!Xt(s)?(n.value=s,!0):Reflect.set(e,t,s,a)}};function eu(e){return tn(e)?e:new Proxy(e,ox)}class rx{constructor(t){this.__v_isRef=!0,this._value=void 0;const s=this.dep=new Gr,{get:a,set:n}=t(s.track.bind(s),s.trigger.bind(s));this._get=a,this._set=n}get value(){return this._value=this._get()}set value(t){this._set(t)}}function Ym(e){return new rx(e)}function cx(e){const t=Me(e)?new Array(e.length):{};for(const s in e)t[s]=Xm(e,s);return t}class dx{constructor(t,s,a){this._object=t,this._defaultValue=a,this.__v_isRef=!0,this._value=void 0,this._key=Cs(s)?s:String(s),this._raw=ft(t);let n=!0,i=t;if(!Me(t)||Cs(this._key)||!zr(this._key))do n=!uo(i)||qs(i);while(n&&(i=i.__v_raw));this._shallow=n}get value(){let t=this._object[this._key];return this._shallow&&(t=La(t)),this._value=t===void 0?this._defaultValue:t}set value(t){if(this._shallow&&Xt(this._raw[this._key])){const s=this._object[this._key];if(Xt(s)){s.value=t;return}}this._object[this._key]=t}get dep(){return zy(this._raw,this._key)}}class ux{constructor(t){this._getter=t,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}}function px(e,t,s){return Xt(e)?e:Ve(e)?new ux(e):ht(e)&&arguments.length>1?Xm(e,t,s):f(e)}function Xm(e,t,s){return new dx(e,t,s)}class fx{constructor(t,s,a){this.fn=t,this.setter=s,this._value=void 0,this.dep=new Gr(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Hl-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=a}notify(){if(this.flags|=16,!(this.flags&8)&&At!==this)return Pm(this,!0),!0}get value(){const t=this.dep.track();return Bm(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function mx(e,t,s=!1){let a,n;return Ve(e)?a=e:(a=e.get,n=e.set),new fx(a,n,s)}const vx={GET:"get",HAS:"has",ITERATE:"iterate"},hx={SET:"set",ADD:"add",DELETE:"delete",CLEAR:"clear"},Io={},ur=new WeakMap;let Tn;function gx(){return Tn}function ev(e,t=!1,s=Tn){if(s){let a=ur.get(s);a||ur.set(s,a=[]),a.push(e)}}function bx(e,t,s=ct){const{immediate:a,deep:n,once:i,scheduler:l,augmentJob:o,call:r}=s,c=x=>n?x:qs(x)||n===!1||n===0?Za(x,1):Za(x);let d,u,p,m,h=!1,y=!1;if(Xt(e)?(u=()=>e.value,h=qs(e)):tn(e)?(u=()=>c(e),h=!0):Me(e)?(y=!0,h=e.some(x=>tn(x)||qs(x)),u=()=>e.map(x=>{if(Xt(x))return x.value;if(tn(x))return c(x);if(Ve(x))return r?r(x,2):x()})):Ve(e)?t?u=r?()=>r(e,2):e:u=()=>{if(p){ln();try{p()}finally{on()}}const x=Tn;Tn=d;try{return r?r(e,3,[m]):e(m)}finally{Tn=x}}:u=vs,t&&n){const x=u,w=n===!0?1/0:n;u=()=>Za(x(),w)}const k=Mm(),A=()=>{d.stop(),k&&k.active&&Gd(k.effects,d)};if(i&&t){const x=t;t=(...w)=>{const _=x(...w);return A(),_}}let g=y?new Array(e.length).fill(Io):Io;const b=x=>{if(!(!(d.flags&1)||!d.dirty&&!x))if(t){const w=d.run();if(x||n||h||(y?w.some((_,T)=>os(_,g[T])):os(w,g))){p&&p();const _=Tn;Tn=d;try{const T=[w,g===Io?void 0:y&&g[0]===Io?[]:g,m];g=w,r?r(t,3,T):t(...T)}finally{Tn=_}}}else d.run()};return o&&o(b),d=new zl(u),d.scheduler=l?()=>l(b,!1):b,m=x=>ev(x,!1,d),p=d.onStop=()=>{const x=ur.get(d);if(x){if(r)r(x,4);else for(const w of x)w();ur.delete(d)}},t?a?b(!0):g=d.run():l?l(b.bind(null,!0),!0):d.run(),A.pause=d.pause.bind(d),A.resume=d.resume.bind(d),A.stop=A,A}function Za(e,t=1/0,s){if(t<=0||!ht(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,Xt(e))Za(e.value,t,s);else if(Me(e))for(let a=0;a{Za(a,t,s)});else if(Ur(e)){for(const a in e)Za(e[a],t,s);for(const a of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,a)&&Za(e[a],t,s)}return e}/** +* @vue/runtime-core v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const tv=[];function yx(e){tv.push(e)}function xx(){tv.pop()}function _x(e,t){}const wx={SETUP_FUNCTION:0,0:"SETUP_FUNCTION",RENDER_FUNCTION:1,1:"RENDER_FUNCTION",NATIVE_EVENT_HANDLER:5,5:"NATIVE_EVENT_HANDLER",COMPONENT_EVENT_HANDLER:6,6:"COMPONENT_EVENT_HANDLER",VNODE_HOOK:7,7:"VNODE_HOOK",DIRECTIVE_HOOK:8,8:"DIRECTIVE_HOOK",TRANSITION_HOOK:9,9:"TRANSITION_HOOK",APP_ERROR_HANDLER:10,10:"APP_ERROR_HANDLER",APP_WARN_HANDLER:11,11:"APP_WARN_HANDLER",FUNCTION_REF:12,12:"FUNCTION_REF",ASYNC_COMPONENT_LOADER:13,13:"ASYNC_COMPONENT_LOADER",SCHEDULER:14,14:"SCHEDULER",COMPONENT_UPDATE:15,15:"COMPONENT_UPDATE",APP_UNMOUNT_CLEANUP:16,16:"APP_UNMOUNT_CLEANUP"},kx={sp:"serverPrefetch hook",bc:"beforeCreate hook",c:"created hook",bm:"beforeMount hook",m:"mounted hook",bu:"beforeUpdate hook",u:"updated",bum:"beforeUnmount hook",um:"unmounted hook",a:"activated hook",da:"deactivated hook",ec:"errorCaptured hook",rtc:"renderTracked hook",rtg:"renderTriggered hook",0:"setup function",1:"render function",2:"watcher getter",3:"watcher callback",4:"watcher cleanup function",5:"native event handler",6:"component event handler",7:"vnode hook",8:"directive hook",9:"transition hook",10:"app errorHandler",11:"app warnHandler",12:"ref function",13:"async component loader",14:"scheduler flush",15:"component update",16:"app unmount cleanup function"};function tl(e,t,s,a){try{return a?e(...a):e()}catch(n){ci(n,t,s)}}function Ys(e,t,s,a){if(Ve(e)){const n=tl(e,t,s,a);return n&&Wd(n)&&n.catch(i=>{ci(i,t,s)}),n}if(Me(e)){const n=[];for(let i=0;i>>1,n=Ns[a],i=Vl(n);i=Vl(s)?Ns.push(e):Ns.splice(Cx(t),0,e),e.flags|=1,av()}}function av(){pr||(pr=sv.then(nv))}function ql(e){Me(e)?Pi.push(...e):En&&e.id===-1?En.splice(Ci+1,0,e):e.flags&1||(Pi.push(e),e.flags|=1),av()}function yp(e,t,s=Ra+1){for(;sVl(s)-Vl(a));if(Pi.length=0,En){En.push(...t);return}for(En=t,Ci=0;Cie.id==null?e.flags&2?-1:1/0:e.id;function nv(e){try{for(Ra=0;RaTi.emit(n,...i)),Oo=[]):typeof window<"u"&&window.HTMLElement&&!((a=(s=window.navigator)==null?void 0:s.userAgent)!=null&&a.includes("jsdom"))?((t.__VUE_DEVTOOLS_HOOK_REPLAY__=t.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push(i=>{iv(i,t)}),setTimeout(()=>{Ti||(t.__VUE_DEVTOOLS_HOOK_REPLAY__=null,Oo=[])},3e3)):Oo=[]}let ms=null,Zr=null;function Gl(e){const t=ms;return ms=e,Zr=e&&e.type.__scopeId||null,t}function Tx(e){Zr=e}function Ex(){Zr=null}const Ax=e=>su;function su(e,t=ms,s){if(!t||e._n)return e;const a=(...n)=>{a._d&&Zl(-1);const i=Gl(t);let l;try{l=e(...n)}finally{Gl(i),a._d&&Zl(1)}return l};return a._n=!0,a._c=!0,a._d=!0,a}function Rx(e,t){if(ms===null)return e;const s=vo(ms),a=e.dirs||(e.dirs=[]);for(let n=0;n1)return s&&Ve(t)?t.call(a&&a.proxy):t}}function Ix(){return!!(Ds()||Yn)}const lv=Symbol.for("v-scx"),ov=()=>ra(lv);function Ox(e,t){return po(e,null,t)}function Lx(e,t){return po(e,null,{flush:"post"})}function rv(e,t){return po(e,null,{flush:"sync"})}function Gt(e,t,s){return po(e,t,s)}function po(e,t,s=ct){const{immediate:a,deep:n,flush:i,once:l}=s,o=lt({},s),r=t&&a||!t&&i!=="post";let c;if(ai){if(i==="sync"){const m=ov();c=m.__watcherHandles||(m.__watcherHandles=[])}else if(!r){const m=()=>{};return m.stop=vs,m.resume=vs,m.pause=vs,m}}const d=fs;o.call=(m,h,y)=>Ys(m,d,h,y);let u=!1;i==="post"?o.scheduler=m=>{Qt(m,d&&d.suspense)}:i!=="sync"&&(u=!0,o.scheduler=(m,h)=>{h?m():tu(m)}),o.augmentJob=m=>{t&&(m.flags|=4),u&&(m.flags|=2,d&&(m.id=d.uid,m.i=d))};const p=bx(e,t,o);return ai&&(c?c.push(p):r&&p()),p}function Nx(e,t,s){const a=this.proxy,n=Ze(e)?e.includes(".")?cv(a,e):()=>a[e]:e.bind(a,a);let i;Ve(t)?i=t:(i=t.handler,s=t);const l=sl(this),o=po(n,i.bind(a),s);return l(),o}function cv(e,t){const s=t.split(".");return()=>{let a=e;for(let n=0;ne.__isTeleport,Wn=e=>e&&(e.disabled||e.disabled===""),Mx=e=>e&&(e.defer||e.defer===""),xp=e=>typeof SVGElement<"u"&&e instanceof SVGElement,_p=e=>typeof MathMLElement=="function"&&e instanceof MathMLElement,rd=(e,t)=>{const s=e&&e.to;return Ze(s)?t?t(s):null:s},Dx={name:"Teleport",__isTeleport:!0,process(e,t,s,a,n,i,l,o,r,c){const{mc:d,pc:u,pbc:p,o:{insert:m,querySelector:h,createText:y,createComment:k,parentNode:A}}=c,g=Wn(t.props);let{dynamicChildren:b}=t;const x=(T,L,E)=>{T.shapeFlag&16&&d(T.children,L,E,n,i,l,o,r)},w=(T=t)=>{const L=Wn(T.props),E=T.target=rd(T.props,h),F=cd(E,T,y,m);E&&(l!=="svg"&&xp(E)?l="svg":l!=="mathml"&&_p(E)&&(l="mathml"),n&&n.isCE&&(n.ce._teleportTargets||(n.ce._teleportTargets=new Set)).add(E),L||(x(T,E,F),Tl(T,!1)))},_=T=>{const L=()=>{if(Sn.get(T)===L){if(Sn.delete(T),Wn(T.props)){const E=A(T.el)||s;x(T,E,T.anchor),Tl(T,!0)}w(T)}};Sn.set(T,L),Qt(L,i)};if(e==null){const T=t.el=y(""),L=t.anchor=y("");if(m(T,s,a),m(L,s,a),Mx(t.props)||i&&i.pendingBranch){_(t);return}g&&(x(t,s,L),Tl(t,!0)),w()}else{t.el=e.el;const T=t.anchor=e.anchor,L=Sn.get(e);if(L){L.flags|=8,Sn.delete(e),_(t);return}t.targetStart=e.targetStart;const E=t.target=e.target,F=t.targetAnchor=e.targetAnchor,O=Wn(e.props),I=O?s:E,H=O?T:F;if(l==="svg"||xp(E)?l="svg":(l==="mathml"||_p(E))&&(l="mathml"),b?(p(e.dynamicChildren,b,I,n,i,l,o),fu(e,t,!0)):r||u(e,t,I,H,n,i,l,o,!1),g)O?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):Lo(t,s,T,c,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const te=t.target=rd(t.props,h);te&&Lo(t,te,null,c,0)}else O&&Lo(t,E,F,c,1);Tl(t,g)}},remove(e,t,s,{um:a,o:{remove:n}},i){const{shapeFlag:l,children:o,anchor:r,targetStart:c,targetAnchor:d,target:u,props:p}=e,m=i||!Wn(p),h=Sn.get(e);if(h&&(h.flags|=8,Sn.delete(e)),u&&(n(c),n(d)),i&&n(r),!h&&l&16)for(let y=0;y{e.isMounted=!0}),ec(()=>{e.isUnmounting=!0}),e}const aa=[Function,Array],nu={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:aa,onEnter:aa,onAfterEnter:aa,onEnterCancelled:aa,onBeforeLeave:aa,onLeave:aa,onAfterLeave:aa,onLeaveCancelled:aa,onBeforeAppear:aa,onAppear:aa,onAfterAppear:aa,onAppearCancelled:aa},pv=e=>{const t=e.subTree;return t.component?pv(t.component):t},Fx={name:"BaseTransition",props:nu,setup(e,{slots:t}){const s=Ds(),a=au();return()=>{const n=t.default&&Qr(t.default(),!0),i=n&&n.length?fv(n):s.subTree?Jv():void 0;if(!i)return;const l=ft(e),{mode:o}=l;if(a.isLeaving)return Mc(i);const r=wp(i);if(!r)return Mc(i);let c=ji(r,l,a,s,u=>c=u);r.type!==Zt&&rn(r,c);let d=s.subTree&&wp(s.subTree);if(d&&d.type!==Zt&&!fa(d,r)&&pv(s).type!==Zt){let u=ji(d,l,a,s);if(rn(d,u),o==="out-in"&&r.type!==Zt)return a.isLeaving=!0,u.afterLeave=()=>{a.isLeaving=!1,s.job.flags&8||s.update(),delete u.afterLeave,d=void 0},Mc(i);o==="in-out"&&r.type!==Zt?u.delayLeave=(p,m,h)=>{const y=vv(a,d);y[String(d.key)]=d,p[na]=()=>{m(),p[na]=void 0,delete c.delayedLeave,d=void 0},c.delayedLeave=()=>{h(),delete c.delayedLeave,d=void 0}}:d=void 0}else d&&(d=void 0);return i}}};function fv(e){let t=e[0];if(e.length>1){for(const s of e)if(s.type!==Zt){t=s;break}}return t}const mv=Fx;function vv(e,t){const{leavingVNodes:s}=e;let a=s.get(t.type);return a||(a=Object.create(null),s.set(t.type,a)),a}function ji(e,t,s,a,n){const{appear:i,mode:l,persisted:o=!1,onBeforeEnter:r,onEnter:c,onAfterEnter:d,onEnterCancelled:u,onBeforeLeave:p,onLeave:m,onAfterLeave:h,onLeaveCancelled:y,onBeforeAppear:k,onAppear:A,onAfterAppear:g,onAppearCancelled:b}=t,x=String(e.key),w=vv(s,e),_=(E,F)=>{E&&Ys(E,a,9,F)},T=(E,F)=>{const O=F[1];_(E,F),Me(E)?E.every(I=>I.length<=1)&&O():E.length<=1&&O()},L={mode:l,persisted:o,beforeEnter(E){let F=r;if(!s.isMounted)if(i)F=k||r;else return;E[na]&&E[na](!0);const O=w[x];O&&fa(e,O)&&O.el[na]&&O.el[na](),_(F,[E])},enter(E){if(w[x]===e)return;let F=c,O=d,I=u;if(!s.isMounted)if(i)F=A||c,O=g||d,I=b||u;else return;let H=!1;E[pl]=ee=>{H||(H=!0,ee?_(I,[E]):_(O,[E]),L.delayedLeave&&L.delayedLeave(),E[pl]=void 0)};const te=E[pl].bind(null,!1);F?T(F,[E,te]):te()},leave(E,F){const O=String(e.key);if(E[pl]&&E[pl](!0),s.isUnmounting)return F();_(p,[E]);let I=!1;E[na]=te=>{I||(I=!0,F(),te?_(y,[E]):_(h,[E]),E[na]=void 0,w[O]===e&&delete w[O])};const H=E[na].bind(null,!1);w[O]=e,m?T(m,[E,H]):H()},clone(E){const F=ji(E,t,s,a,n);return n&&n(F),F}};return L}function Mc(e){if(mo(e))return e=Ma(e),e.children=null,e}function wp(e){if(!mo(e))return uv(e.type)&&e.children?fv(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:s}=e;if(s){if(t&16)return s[0];if(t&32&&Ve(s.default))return s.default()}}function rn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,rn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Qr(e,t=!1,s){let a=[],n=0;for(let i=0;i1)for(let i=0;is.value,set:i=>s.value=i})}return s}function kp(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const mr=new WeakMap;function $i(e,t,s,a,n=!1){if(Me(e)){e.forEach((y,k)=>$i(y,t&&(Me(t)?t[k]:t),s,a,n));return}if(sn(a)&&!n){a.shapeFlag&512&&a.type.__asyncResolved&&a.component.subTree.component&&$i(e,t,s,a.component.subTree);return}const i=a.shapeFlag&4?vo(a.component):a.el,l=n?null:i,{i:o,r}=e,c=t&&t.r,d=o.refs===ct?o.refs={}:o.refs,u=o.setupState,p=ft(u),m=u===ct?Ri:y=>kp(d,y)?!1:bt(p,y),h=(y,k)=>!(k&&kp(d,k));if(c!=null&&c!==r){if(Sp(t),Ze(c))d[c]=null,m(c)&&(u[c]=null);else if(Xt(c)){const y=t;h(c,y.k)&&(c.value=null),y.k&&(d[y.k]=null)}}if(Ve(r))tl(r,o,12,[l,d]);else{const y=Ze(r),k=Xt(r);if(y||k){const A=()=>{if(e.f){const g=y?m(r)?u[r]:d[r]:h()||!e.k?r.value:d[e.k];if(n)Me(g)&&Gd(g,i);else if(Me(g))g.includes(i)||g.push(i);else if(y)d[r]=[i],m(r)&&(u[r]=d[r]);else{const b=[i];h(r,e.k)&&(r.value=b),e.k&&(d[e.k]=b)}}else y?(d[r]=l,m(r)&&(u[r]=l)):k&&(h(r,e.k)&&(r.value=l),e.k&&(d[e.k]=l))};if(l){const g=()=>{A(),mr.delete(e)};g.id=-1,mr.set(e,g),Qt(g,s)}else Sp(e),A()}}}function Sp(e){const t=mr.get(e);t&&(t.flags|=8,mr.delete(e))}let Cp=!1;const xi=()=>{Cp||(console.error("Hydration completed but contains mismatches."),Cp=!0)},zx=e=>e.namespaceURI.includes("svg")&&e.tagName!=="foreignObject",Hx=e=>e.namespaceURI.includes("MathML"),No=e=>{if(e.nodeType===1){if(zx(e))return"svg";if(Hx(e))return"mathml"}},Ii=e=>e.nodeType===8;function jx(e){const{mt:t,p:s,o:{patchProp:a,createText:n,nextSibling:i,parentNode:l,remove:o,insert:r,createComment:c}}=e,d=(b,x)=>{if(!x.hasChildNodes()){s(null,b,x),fr(),x._vnode=b;return}u(x.firstChild,b,null,null,null),fr(),x._vnode=b},u=(b,x,w,_,T,L=!1)=>{L=L||!!x.dynamicChildren;const E=Ii(b)&&b.data==="[",F=()=>y(b,x,w,_,T,E),{type:O,ref:I,shapeFlag:H,patchFlag:te}=x;let ee=b.nodeType;x.el=b,te===-2&&(L=!1,x.dynamicChildren=null);let N=null;switch(O){case On:ee!==3?x.children===""?(r(x.el=n(""),l(b),b),N=b):N=F():(b.data!==x.children&&(xi(),b.data=x.children),N=i(b));break;case Zt:g(b)?(N=i(b),A(x.el=b.content.firstChild,b,w)):ee!==8||E?N=F():N=i(b);break;case Xn:if(E&&(b=i(b),ee=b.nodeType),ee===1||ee===3){N=b;const M=!x.children.length;for(let $=0;${L=L||!!x.dynamicChildren;const{type:E,props:F,patchFlag:O,shapeFlag:I,dirs:H,transition:te}=x,ee=E==="input"||E==="option";if(ee||O!==-1){H&&Ia(x,null,w,"created");let N=!1;if(g(b)){N=Uv(null,te)&&w&&w.vnode.props&&w.vnode.props.appear;const $=b.content.firstChild;if(N){const re=$.getAttribute("class");re&&($.$cls=re),te.beforeEnter($)}A($,b,w),x.el=b=$}if(I&16&&!(F&&(F.innerHTML||F.textContent))){let $=m(b.firstChild,x,b,w,_,T,L);for($&&!Mo(b,1)&&xi();$;){const re=$;$=$.nextSibling,o(re)}}else if(I&8){let $=x.children;$[0]===` +`&&(b.tagName==="PRE"||b.tagName==="TEXTAREA")&&($=$.slice(1));const{textContent:re}=b;re!==$&&re!==$.replace(/\r\n|\r/g,` +`)&&(Mo(b,0)||xi(),b.textContent=x.children)}if(F){if(ee||!L||O&48){const $=b.tagName.includes("-");for(const re in F)(ee&&(re.endsWith("value")||re==="indeterminate")||li(re)&&!en(re)||re[0]==="."||$&&!en(re))&&a(b,re,null,F[re],void 0,w)}else if(F.onClick)a(b,"onClick",null,F.onClick,void 0,w);else if(O&4&&tn(F.style))for(const $ in F.style)F.style[$]}let M;(M=F&&F.onVnodeBeforeMount)&&Bs(M,w,x),H&&Ia(x,null,w,"beforeMount"),((M=F&&F.onVnodeMounted)||H||N)&&qv(()=>{M&&Bs(M,w,x),N&&te.enter(b),H&&Ia(x,null,w,"mounted")},_)}return b.nextSibling},m=(b,x,w,_,T,L,E)=>{E=E||!!x.dynamicChildren;const F=x.children,O=F.length;let I=!1;for(let H=0;H{const{slotScopeIds:E}=x;E&&(T=T?T.concat(E):E);const F=l(b),O=m(i(b),x,F,w,_,T,L);return O&&Ii(O)&&O.data==="]"?i(x.anchor=O):(xi(),r(x.anchor=c("]"),F,O),O)},y=(b,x,w,_,T,L)=>{if(Mo(b.parentElement,1)||xi(),x.el=null,L){const O=k(b);for(;;){const I=i(b);if(I&&I!==O)o(I);else break}}const E=i(b),F=l(b);return o(b),s(null,x,F,E,w,_,No(F),T),w&&(w.vnode.el=x.el,sc(w,x.el)),E},k=(b,x="[",w="]")=>{let _=0;for(;b;)if(b=i(b),b&&Ii(b)&&(b.data===x&&_++,b.data===w)){if(_===0)return i(b);_--}return b},A=(b,x,w)=>{const _=x.parentNode;_&&_.replaceChild(b,x);let T=w;for(;T;)T.vnode.el===x&&(T.vnode.el=T.subTree.el=b),T=T.parent},g=b=>b.nodeType===1&&b.tagName==="TEMPLATE";return[d,u]}const Tp="data-allow-mismatch",qx={0:"text",1:"children",2:"class",3:"style",4:"attribute"};function Mo(e,t){if(t===0||t===1)for(;e&&!e.hasAttribute(Tp);)e=e.parentElement;const s=e&&e.getAttribute(Tp);if(s==null)return!1;if(s==="")return!0;{const a=s.split(",");return t===0&&a.includes("children")?!0:a.includes(qx[t])}}const Vx=qr().requestIdleCallback||(e=>setTimeout(e,1)),Gx=qr().cancelIdleCallback||(e=>clearTimeout(e)),Wx=(e=1e4)=>t=>{const s=Vx(t,{timeout:e});return()=>Gx(s)};function Kx(e){const{top:t,left:s,bottom:a,right:n}=e.getBoundingClientRect(),{innerHeight:i,innerWidth:l}=window;return(t>0&&t0&&a0&&s0&&n(t,s)=>{const a=new IntersectionObserver(n=>{for(const i of n)if(i.isIntersecting){a.disconnect(),t();break}},e);return s(n=>{if(n instanceof Element){if(Kx(n))return t(),a.disconnect(),!1;a.observe(n)}}),()=>a.disconnect()},Zx=e=>t=>{if(e){const s=matchMedia(e);if(s.matches)t();else return s.addEventListener("change",t,{once:!0}),()=>s.removeEventListener("change",t)}},Qx=(e=[])=>(t,s)=>{Ze(e)&&(e=[e]);let a=!1;const n=l=>{a||(a=!0,i(),t(),l.target.dispatchEvent(new l.constructor(l.type,l)))},i=()=>{s(l=>{for(const o of e)l.removeEventListener(o,n)})};return s(l=>{for(const o of e)l.addEventListener(o,n,{once:!0})}),i};function Yx(e,t){if(Ii(e)&&e.data==="["){let s=1,a=e.nextSibling;for(;a;){if(a.nodeType===1){if(t(a)===!1)break}else if(Ii(a))if(a.data==="]"){if(--s===0)break}else a.data==="["&&s++;a=a.nextSibling}}else t(e)}const sn=e=>!!e.type.__asyncLoader;function Xx(e){Ve(e)&&(e={loader:e});const{loader:t,loadingComponent:s,errorComponent:a,delay:n=200,hydrate:i,timeout:l,suspensible:o=!0,onError:r}=e;let c=null,d,u=0;const p=()=>(u++,c=null,m()),m=()=>{let h;return c||(h=c=t().catch(y=>{if(y=y instanceof Error?y:new Error(String(y)),r)return new Promise((k,A)=>{r(y,()=>k(p()),()=>A(y),u+1)});throw y}).then(y=>h!==c&&c?c:(y&&(y.__esModule||y[Symbol.toStringTag]==="Module")&&(y=y.default),d=y,y)))};return fo({name:"AsyncComponentWrapper",__asyncLoader:m,__asyncHydrate(h,y,k){let A=!1;(y.bu||(y.bu=[])).push(()=>A=!0);const g=()=>{A||k()},b=i?()=>{const x=i(g,w=>Yx(h,w));x&&(y.bum||(y.bum=[])).push(x)}:g;d?b():m().then(()=>!y.isUnmounted&&b())},get __asyncResolved(){return d},setup(){const h=fs;if(iu(h),d)return()=>Do(d,h);const y=w=>{c=null,ci(w,h,13,!a)};if(o&&h.suspense||ai)return m().then(w=>()=>Do(w,h)).catch(w=>(y(w),()=>a?Bt(a,{error:w}):null));const k=f(!1),A=f(),g=f(!!n);let b,x;return wt(()=>{b!=null&&clearTimeout(b),x!=null&&clearTimeout(x)}),n&&(x=setTimeout(()=>{h.isUnmounted||(g.value=!1)},n)),l!=null&&(b=setTimeout(()=>{if(!h.isUnmounted&&!k.value&&!A.value){const w=new Error(`Async component timed out after ${l}ms.`);y(w),A.value=w}},l)),m().then(()=>{h.isUnmounted||(k.value=!0,h.parent&&mo(h.parent.vnode)&&h.parent.update())}).catch(w=>{if(h.isUnmounted){c=null;return}y(w),A.value=w}),()=>{if(k.value&&d)return Do(d,h);if(A.value&&a)return Bt(a,{error:A.value});if(s&&!g.value)return Do(s,h)}}})}function Do(e,t){const{ref:s,props:a,children:n,ce:i}=t.vnode,l=Bt(e,a,n);return l.ref=s,l.ce=i,delete t.vnode.ce,l}const mo=e=>e.type.__isKeepAlive,e0={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(e,{slots:t}){const s=Ds(),a=s.ctx;if(!a.renderer)return()=>{const g=t.default&&t.default();return g&&g.length===1?g[0]:g};const n=new Map,i=new Set;let l=null;const o=s.suspense,{renderer:{p:r,m:c,um:d,o:{createElement:u}}}=a,p=u("div");a.activate=(g,b,x,w,_)=>{const T=g.component;c(g,b,x,0,o),r(T.vnode,g,b,x,T,o,w,g.slotScopeIds,_),Qt(()=>{T.isDeactivated=!1,T.a&&Di(T.a);const L=g.props&&g.props.onVnodeMounted;L&&Bs(L,T.parent,g)},o)},a.deactivate=g=>{const b=g.component;hr(b.m),hr(b.a),c(g,p,null,1,o),Qt(()=>{b.da&&Di(b.da);const x=g.props&&g.props.onVnodeUnmounted;x&&Bs(x,b.parent,g),b.isDeactivated=!0},o)};function m(g){Dc(g),d(g,s,o,!0)}function h(g){n.forEach((b,x)=>{const w=bd(sn(b)?b.type.__asyncResolved||{}:b.type);w&&!g(w)&&y(x)})}function y(g){const b=n.get(g);b&&(!l||!fa(b,l))?m(b):l&&Dc(l),n.delete(g),i.delete(g)}Gt(()=>[e.include,e.exclude],([g,b])=>{g&&h(x=>El(g,x)),b&&h(x=>!El(b,x))},{flush:"post",deep:!0});let k=null;const A=()=>{k!=null&&(gr(s.subTree.type)?Qt(()=>{n.set(k,Po(s.subTree))},s.subTree.suspense):n.set(k,Po(s.subTree)))};return st(A),Xr(A),ec(()=>{n.forEach(g=>{const{subTree:b,suspense:x}=s,w=Po(b);if(g.type===w.type&&g.key===w.key){Dc(w);const _=w.component.da;_&&Qt(_,x);return}m(g)})}),()=>{if(k=null,!t.default)return l=null;const g=t.default(),b=g[0];if(g.length>1)return l=null,g;if(!cn(b)||!(b.shapeFlag&4)&&!(b.shapeFlag&128))return l=null,b;let x=Po(b);if(x.type===Zt)return l=null,x;const w=x.type,_=bd(sn(x)?x.type.__asyncResolved||{}:w),{include:T,exclude:L,max:E}=e;if(T&&(!_||!El(T,_))||L&&_&&El(L,_))return x.shapeFlag&=-257,l=x,b;const F=x.key==null?w:x.key,O=n.get(F);return x.el&&(x=Ma(x),b.shapeFlag&128&&(b.ssContent=x)),k=F,O?(x.el=O.el,x.component=O.component,x.transition&&rn(x,x.transition),x.shapeFlag|=512,i.delete(F),i.add(F)):(i.add(F),E&&i.size>parseInt(E,10)&&y(i.values().next().value)),x.shapeFlag|=256,l=x,gr(b.type)?b:x}}},t0=e0;function El(e,t){return Me(e)?e.some(s=>El(s,t)):Ze(e)?e.split(",").includes(t):uy(e)?(e.lastIndex=0,e.test(t)):!1}function cs(e,t){hv(e,"a",t)}function es(e,t){hv(e,"da",t)}function hv(e,t,s=fs){const a=e.__wdc||(e.__wdc=()=>{let n=s;for(;n;){if(n.isDeactivated)return;n=n.parent}return e()});if(Yr(t,a,s),s){let n=s.parent;for(;n&&n.parent;)mo(n.parent.vnode)&&s0(a,t,s,n),n=n.parent}}function s0(e,t,s,a){const n=Yr(t,e,a,!0);wt(()=>{Gd(a[t],n)},s)}function Dc(e){e.shapeFlag&=-257,e.shapeFlag&=-513}function Po(e){return e.shapeFlag&128?e.ssContent:e}function Yr(e,t,s=fs,a=!1){if(s){const n=s[e]||(s[e]=[]),i=t.__weh||(t.__weh=(...l)=>{ln();const o=sl(s),r=Ys(t,s,e,l);return o(),on(),r});return a?n.unshift(i):n.push(i),i}}const dn=e=>(t,s=fs)=>{(!ai||e==="sp")&&Yr(e,(...a)=>t(...a),s)},gv=dn("bm"),st=dn("m"),lu=dn("bu"),Xr=dn("u"),ec=dn("bum"),wt=dn("um"),bv=dn("sp"),yv=dn("rtg"),xv=dn("rtc");function _v(e,t=fs){Yr("ec",e,t)}const ou="components",a0="directives";function n0(e,t){return ru(ou,e,!0,t)||e}const wv=Symbol.for("v-ndc");function i0(e){return Ze(e)?ru(ou,e,!1)||e:e||wv}function l0(e){return ru(a0,e)}function ru(e,t,s=!0,a=!1){const n=ms||fs;if(n){const i=n.type;if(e===ou){const o=bd(i,!1);if(o&&(o===t||o===Tt(t)||o===ri(Tt(t))))return i}const l=Ep(n[e]||i[e],t)||Ep(n.appContext[e],t);return!l&&a?i:l}}function Ep(e,t){return e&&(e[t]||e[Tt(t)]||e[ri(Tt(t))])}function o0(e,t,s,a){let n;const i=s&&s[a],l=Me(e);if(l||Ze(e)){const o=l&&tn(e);let r=!1,c=!1;o&&(r=!qs(e),c=Na(e),e=Wr(e)),n=new Array(e.length);for(let d=0,u=e.length;dt(o,r,void 0,i&&i[r]));else{const o=Object.keys(e);n=new Array(o.length);for(let r=0,c=o.length;r{const i=a.fn(...n);return i&&(i.key=a.key),i}:a.fn)}return e}function c0(e,t,s={},a,n){if(ms.ce||ms.parent&&sn(ms.parent)&&ms.parent.ce){const c=Object.keys(s).length>0;return t!=="default"&&(s.name=t),Jl(),br(rs,null,[Bt("slot",s,a&&a())],c?-2:64)}let i=e[t];i&&i._c&&(i._d=!1),Jl();const l=i&&cu(i(s)),o=s.key||l&&l.key,r=br(rs,{key:(o&&!Cs(o)?o:`_${t}`)+(!l&&a?"_fb":"")},l||(a?a():[]),l&&e._===1?64:-2);return!n&&r.scopeId&&(r.slotScopeIds=[r.scopeId+"-s"]),i&&i._c&&(i._d=!0),r}function cu(e){return e.some(t=>cn(t)?!(t.type===Zt||t.type===rs&&!cu(t.children)):!0)?e:null}function d0(e,t){const s={};for(const a in e)s[t&&/[A-Z]/.test(a)?`on:${a}`:Mi(a)]=e[a];return s}const dd=e=>e?Yv(e)?vo(e):dd(e.parent):null,Ml=lt(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>dd(e.parent),$root:e=>dd(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>du(e),$forceUpdate:e=>e.f||(e.f=()=>{tu(e.update)}),$nextTick:e=>e.n||(e.n=Ut.bind(e.proxy)),$watch:e=>Nx.bind(e)}),Pc=(e,t)=>e!==ct&&!e.__isScriptSetup&&bt(e,t),ud={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:a,data:n,props:i,accessCache:l,type:o,appContext:r}=e;if(t[0]!=="$"){const p=l[t];if(p!==void 0)switch(p){case 1:return a[t];case 2:return n[t];case 4:return s[t];case 3:return i[t]}else{if(Pc(a,t))return l[t]=1,a[t];if(n!==ct&&bt(n,t))return l[t]=2,n[t];if(bt(i,t))return l[t]=3,i[t];if(s!==ct&&bt(s,t))return l[t]=4,s[t];pd&&(l[t]=0)}}const c=Ml[t];let d,u;if(c)return t==="$attrs"&&ws(e.attrs,"get",""),c(e);if((d=o.__cssModules)&&(d=d[t]))return d;if(s!==ct&&bt(s,t))return l[t]=4,s[t];if(u=r.config.globalProperties,bt(u,t))return u[t]},set({_:e},t,s){const{data:a,setupState:n,ctx:i}=e;return Pc(n,t)?(n[t]=s,!0):a!==ct&&bt(a,t)?(a[t]=s,!0):bt(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:a,appContext:n,props:i,type:l}},o){let r;return!!(s[o]||e!==ct&&o[0]!=="$"&&bt(e,o)||Pc(t,o)||bt(i,o)||bt(a,o)||bt(Ml,o)||bt(n.config.globalProperties,o)||(r=l.__cssModules)&&r[o])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:bt(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}},u0=lt({},ud,{get(e,t){if(t!==Symbol.unscopables)return ud.get(e,t,e)},has(e,t){return t[0]!=="_"&&!by(t)}});function p0(){return null}function f0(){return null}function m0(e){}function v0(e){}function h0(){return null}function g0(){}function b0(e,t){return null}function y0(){return kv().slots}function x0(){return kv().attrs}function kv(e){const t=Ds();return t.setupContext||(t.setupContext=sh(t))}function Wl(e){return Me(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}function _0(e,t){const s=Wl(e);for(const a in t){if(a.startsWith("__skip"))continue;let n=s[a];n?Me(n)||Ve(n)?n=s[a]={type:n,default:t[a]}:n.default=t[a]:n===null&&(n=s[a]={default:t[a]}),n&&t[`__skip_${a}`]&&(n.skipFactory=!0)}return s}function w0(e,t){return!e||!t?e||t:Me(e)&&Me(t)?e.concat(t):lt({},Wl(e),Wl(t))}function k0(e,t){const s={};for(const a in e)t.includes(a)||Object.defineProperty(s,a,{enumerable:!0,get:()=>e[a]});return s}function S0(e){const t=Ds(),s=ai;let a=e();Ql(),s&&Bi(!1);const n=()=>{sl(t),s&&Bi(!0)},i=()=>{Ds()!==t&&t.scope.off(),Ql(),s&&Bi(!1)};return Wd(a)&&(a=a.catch(l=>{throw n(),Promise.resolve().then(()=>Promise.resolve().then(i)),l})),[a,()=>{n(),Promise.resolve().then(i)}]}let pd=!0;function C0(e){const t=du(e),s=e.proxy,a=e.ctx;pd=!1,t.beforeCreate&&Ap(t.beforeCreate,e,"bc");const{data:n,computed:i,methods:l,watch:o,provide:r,inject:c,created:d,beforeMount:u,mounted:p,beforeUpdate:m,updated:h,activated:y,deactivated:k,beforeDestroy:A,beforeUnmount:g,destroyed:b,unmounted:x,render:w,renderTracked:_,renderTriggered:T,errorCaptured:L,serverPrefetch:E,expose:F,inheritAttrs:O,components:I,directives:H,filters:te}=t;if(c&&T0(c,a,null),l)for(const M in l){const $=l[M];Ve($)&&(a[M]=$.bind(s))}if(n){const M=n.call(s,s);ht(M)&&(e.data=Mn(M))}if(pd=!0,i)for(const M in i){const $=i[M],re=Ve($)?$.bind(s,s):Ve($.get)?$.get.bind(s,s):vs,ce=!Ve($)&&Ve($.set)?$.set.bind(s):vs,Z=B({get:re,set:ce});Object.defineProperty(a,M,{enumerable:!0,configurable:!0,get:()=>Z.value,set:Q=>Z.value=Q})}if(o)for(const M in o)Sv(o[M],a,s,M);if(r){const M=Ve(r)?r.call(s):r;Reflect.ownKeys(M).forEach($=>{Nl($,M[$])})}d&&Ap(d,e,"c");function N(M,$){Me($)?$.forEach(re=>M(re.bind(s))):$&&M($.bind(s))}if(N(gv,u),N(st,p),N(lu,m),N(Xr,h),N(cs,y),N(es,k),N(_v,L),N(xv,_),N(yv,T),N(ec,g),N(wt,x),N(bv,E),Me(F))if(F.length){const M=e.exposed||(e.exposed={});F.forEach($=>{Object.defineProperty(M,$,{get:()=>s[$],set:re=>s[$]=re,enumerable:!0})})}else e.exposed||(e.exposed={});w&&e.render===vs&&(e.render=w),O!=null&&(e.inheritAttrs=O),I&&(e.components=I),H&&(e.directives=H),E&&iu(e)}function T0(e,t,s=vs){Me(e)&&(e=fd(e));for(const a in e){const n=e[a];let i;ht(n)?"default"in n?i=ra(n.from||a,n.default,!0):i=ra(n.from||a):i=ra(n),Xt(i)?Object.defineProperty(t,a,{enumerable:!0,configurable:!0,get:()=>i.value,set:l=>i.value=l}):t[a]=i}}function Ap(e,t,s){Ys(Me(e)?e.map(a=>a.bind(t.proxy)):e.bind(t.proxy),t,s)}function Sv(e,t,s,a){let n=a.includes(".")?cv(s,a):()=>s[a];if(Ze(e)){const i=t[e];Ve(i)&&Gt(n,i)}else if(Ve(e))Gt(n,e.bind(s));else if(ht(e))if(Me(e))e.forEach(i=>Sv(i,t,s,a));else{const i=Ve(e.handler)?e.handler.bind(s):t[e.handler];Ve(i)&&Gt(n,i,e)}}function du(e){const t=e.type,{mixins:s,extends:a}=t,{mixins:n,optionsCache:i,config:{optionMergeStrategies:l}}=e.appContext,o=i.get(t);let r;return o?r=o:!n.length&&!s&&!a?r=t:(r={},n.length&&n.forEach(c=>vr(r,c,l,!0)),vr(r,t,l)),ht(t)&&i.set(t,r),r}function vr(e,t,s,a=!1){const{mixins:n,extends:i}=t;i&&vr(e,i,s,!0),n&&n.forEach(l=>vr(e,l,s,!0));for(const l in t)if(!(a&&l==="expose")){const o=E0[l]||s&&s[l];e[l]=o?o(e[l],t[l]):t[l]}return e}const E0={data:Rp,props:Ip,emits:Ip,methods:Al,computed:Al,beforeCreate:Is,created:Is,beforeMount:Is,mounted:Is,beforeUpdate:Is,updated:Is,beforeDestroy:Is,beforeUnmount:Is,destroyed:Is,unmounted:Is,activated:Is,deactivated:Is,errorCaptured:Is,serverPrefetch:Is,components:Al,directives:Al,watch:R0,provide:Rp,inject:A0};function Rp(e,t){return t?e?function(){return lt(Ve(e)?e.call(this,this):e,Ve(t)?t.call(this,this):t)}:t:e}function A0(e,t){return Al(fd(e),fd(t))}function fd(e){if(Me(e)){const t={};for(let s=0;s{let d,u=ct,p;return rv(()=>{const m=e[n];os(d,m)&&(d=m,c())}),{get(){return r(),s.get?s.get(d):d},set(m){const h=s.set?s.set(m):m;if(!os(h,d)&&!(u!==ct&&os(m,u)))return;const y=a.vnode.props,k=!!(y&&(t in y||n in y||i in y)&&(`onUpdate:${t}`in y||`onUpdate:${n}`in y||`onUpdate:${i}`in y));k||(d=m,c()),a.emit(`update:${t}`,h),os(m,u)&&(os(m,h)&&!os(h,p)||k&&u!==ct&&!os(h,d))&&c(),u=m,p=h}}});return o[Symbol.iterator]=()=>{let r=0;return{next(){return r<2?{value:r++?l||ct:o,done:!1}:{done:!0}}}},o}const Tv=(e,t)=>t==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Tt(t)}Modifiers`]||e[`${Hs(t)}Modifiers`];function N0(e,t,...s){if(e.isUnmounted)return;const a=e.vnode.props||ct;let n=s;const i=t.startsWith("update:"),l=i&&Tv(a,t.slice(7));l&&(l.trim&&(n=s.map(d=>Ze(d)?d.trim():d)),l.number&&(n=s.map(jr)));let o,r=a[o=Mi(t)]||a[o=Mi(Tt(t))];!r&&i&&(r=a[o=Mi(Hs(t))]),r&&Ys(r,e,6,n);const c=a[o+"Once"];if(c){if(!e.emitted)e.emitted={};else if(e.emitted[o])return;e.emitted[o]=!0,Ys(c,e,6,n)}}const M0=new WeakMap;function Ev(e,t,s=!1){const a=s?M0:t.emitsCache,n=a.get(e);if(n!==void 0)return n;const i=e.emits;let l={},o=!1;if(!Ve(e)){const r=c=>{const d=Ev(c,t,!0);d&&(o=!0,lt(l,d))};!s&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}return!i&&!o?(ht(e)&&a.set(e,null),null):(Me(i)?i.forEach(r=>l[r]=null):lt(l,i),ht(e)&&a.set(e,l),l)}function tc(e,t){return!e||!li(t)?!1:(t=t.slice(2).replace(/Once$/,""),bt(e,t[0].toLowerCase()+t.slice(1))||bt(e,Hs(t))||bt(e,t))}function er(e){const{type:t,vnode:s,proxy:a,withProxy:n,propsOptions:[i],slots:l,attrs:o,emit:r,render:c,renderCache:d,props:u,data:p,setupState:m,ctx:h,inheritAttrs:y}=e,k=Gl(e);let A,g;try{if(s.shapeFlag&4){const x=n||a,w=x;A=zs(c.call(w,x,d,u,m,p,h)),g=o}else{const x=t;A=zs(x.length>1?x(u,{attrs:o,slots:l,emit:r}):x(u,null)),g=t.props?o:P0(o)}}catch(x){Dl.length=0,ci(x,e,1),A=Bt(Zt)}let b=A;if(g&&y!==!1){const x=Object.keys(g),{shapeFlag:w}=b;x.length&&w&7&&(i&&x.some(Br)&&(g=$0(g,i)),b=Ma(b,g,!1,!0))}return s.dirs&&(b=Ma(b,null,!1,!0),b.dirs=b.dirs?b.dirs.concat(s.dirs):s.dirs),s.transition&&rn(b,s.transition),A=b,Gl(k),A}function D0(e,t=!0){let s;for(let a=0;a{let t;for(const s in e)(s==="class"||s==="style"||li(s))&&((t||(t={}))[s]=e[s]);return t},$0=(e,t)=>{const s={};for(const a in e)(!Br(a)||!(a.slice(9)in t))&&(s[a]=e[a]);return s};function F0(e,t,s){const{props:a,children:n,component:i}=e,{props:l,children:o,patchFlag:r}=t,c=i.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&r>=0){if(r&1024)return!0;if(r&16)return a?Op(a,l,c):!!l;if(r&8){const d=t.dynamicProps;for(let u=0;uObject.create(Rv),Ov=e=>Object.getPrototypeOf(e)===Rv;function B0(e,t,s,a=!1){const n={},i=Iv();e.propsDefaults=Object.create(null),Lv(e,t,n,i);for(const l in e.propsOptions[0])l in n||(n[l]=void 0);s?e.props=a?n:Yd(n):e.type.props?e.props=n:e.props=i,e.attrs=i}function U0(e,t,s,a){const{props:n,attrs:i,vnode:{patchFlag:l}}=e,o=ft(n),[r]=e.propsOptions;let c=!1;if((a||l>0)&&!(l&16)){if(l&8){const d=e.vnode.dynamicProps;for(let u=0;u{r=!0;const[p,m]=Nv(u,t,!0);lt(l,p),m&&o.push(...m)};!s&&t.mixins.length&&t.mixins.forEach(d),e.extends&&d(e.extends),e.mixins&&e.mixins.forEach(d)}if(!i&&!r)return ht(e)&&a.set(e,Li),Li;if(Me(i))for(let d=0;de==="_"||e==="_ctx"||e==="$stable",pu=e=>Me(e)?e.map(zs):[zs(e)],H0=(e,t,s)=>{if(t._n)return t;const a=su((...n)=>pu(t(...n)),s);return a._c=!1,a},Mv=(e,t,s)=>{const a=e._ctx;for(const n in e){if(uu(n))continue;const i=e[n];if(Ve(i))t[n]=H0(n,i,a);else if(i!=null){const l=pu(i);t[n]=()=>l}}},Dv=(e,t)=>{const s=pu(t);e.slots.default=()=>s},Pv=(e,t,s)=>{for(const a in t)(s||!uu(a))&&(e[a]=t[a])},j0=(e,t,s)=>{const a=e.slots=Iv();if(e.vnode.shapeFlag&32){const n=t._;n?(Pv(a,t,s),s&&Am(a,"_",n,!0)):Mv(t,a)}else t&&Dv(e,t)},q0=(e,t,s)=>{const{vnode:a,slots:n}=e;let i=!0,l=ct;if(a.shapeFlag&32){const o=t._;o?s&&o===1?i=!1:Pv(n,t,s):(i=!t.$stable,Mv(t,n)),l=t}else t&&(Dv(e,t),l={default:1});if(i)for(const o in n)!uu(o)&&l[o]==null&&delete n[o]},Qt=qv;function $v(e){return Bv(e)}function Fv(e){return Bv(e,jx)}function Bv(e,t){const s=qr();s.__VUE__=!0;const{insert:a,remove:n,patchProp:i,createElement:l,createText:o,createComment:r,setText:c,setElementText:d,parentNode:u,nextSibling:p,setScopeId:m=vs,insertStaticContent:h}=e,y=(S,P,G,he=null,fe=null,me=null,we=void 0,J=null,ie=!!P.dynamicChildren)=>{if(S===P)return;S&&!fa(S,P)&&(he=le(S),Q(S,fe,me,!0),S=null),P.patchFlag===-2&&(ie=!1,P.dynamicChildren=null);const{type:X,ref:_e,shapeFlag:U}=P;switch(X){case On:k(S,P,G,he);break;case Zt:A(S,P,G,he);break;case Xn:S==null&&g(P,G,he,we);break;case rs:I(S,P,G,he,fe,me,we,J,ie);break;default:U&1?w(S,P,G,he,fe,me,we,J,ie):U&6?H(S,P,G,he,fe,me,we,J,ie):(U&64||U&128)&&X.process(S,P,G,he,fe,me,we,J,ie,j)}_e!=null&&fe?$i(_e,S&&S.ref,me,P||S,!P):_e==null&&S&&S.ref!=null&&$i(S.ref,null,me,S,!0)},k=(S,P,G,he)=>{if(S==null)a(P.el=o(P.children),G,he);else{const fe=P.el=S.el;P.children!==S.children&&c(fe,P.children)}},A=(S,P,G,he)=>{S==null?a(P.el=r(P.children||""),G,he):P.el=S.el},g=(S,P,G,he)=>{[S.el,S.anchor]=h(S.children,P,G,he,S.el,S.anchor)},b=({el:S,anchor:P},G,he)=>{let fe;for(;S&&S!==P;)fe=p(S),a(S,G,he),S=fe;a(P,G,he)},x=({el:S,anchor:P})=>{let G;for(;S&&S!==P;)G=p(S),n(S),S=G;n(P)},w=(S,P,G,he,fe,me,we,J,ie)=>{if(P.type==="svg"?we="svg":P.type==="math"&&(we="mathml"),S==null)_(P,G,he,fe,me,we,J,ie);else{const X=S.el&&S.el._isVueCE?S.el:null;try{X&&X._beginPatch(),E(S,P,fe,me,we,J,ie)}finally{X&&X._endPatch()}}},_=(S,P,G,he,fe,me,we,J)=>{let ie,X;const{props:_e,shapeFlag:U,transition:de,dirs:ge}=S;if(ie=S.el=l(S.type,me,_e&&_e.is,_e),U&8?d(ie,S.children):U&16&&L(S.children,ie,null,he,fe,$c(S,me),we,J),ge&&Ia(S,null,he,"created"),T(ie,S,S.scopeId,we,he),_e){for(const $e in _e)$e!=="value"&&!en($e)&&i(ie,$e,null,_e[$e],me,he);"value"in _e&&i(ie,"value",null,_e.value,me),(X=_e.onVnodeBeforeMount)&&Bs(X,he,S)}ge&&Ia(S,null,he,"beforeMount");const Ae=Uv(fe,de);Ae&&de.beforeEnter(ie),a(ie,P,G),((X=_e&&_e.onVnodeMounted)||Ae||ge)&&Qt(()=>{try{X&&Bs(X,he,S),Ae&&de.enter(ie),ge&&Ia(S,null,he,"mounted")}finally{}},fe)},T=(S,P,G,he,fe)=>{if(G&&m(S,G),he)for(let me=0;me{for(let X=ie;X{const J=P.el=S.el;let{patchFlag:ie,dynamicChildren:X,dirs:_e}=P;ie|=S.patchFlag&16;const U=S.props||ct,de=P.props||ct;let ge;if(G&&Hn(G,!1),(ge=de.onVnodeBeforeUpdate)&&Bs(ge,G,P,S),_e&&Ia(P,S,G,"beforeUpdate"),G&&Hn(G,!0),(U.innerHTML&&de.innerHTML==null||U.textContent&&de.textContent==null)&&d(J,""),X?F(S.dynamicChildren,X,J,G,he,$c(P,fe),me):we||$(S,P,J,null,G,he,$c(P,fe),me,!1),ie>0){if(ie&16)O(J,U,de,G,fe);else if(ie&2&&U.class!==de.class&&i(J,"class",null,de.class,fe),ie&4&&i(J,"style",U.style,de.style,fe),ie&8){const Ae=P.dynamicProps;for(let $e=0;$e{ge&&Bs(ge,G,P,S),_e&&Ia(P,S,G,"updated")},he)},F=(S,P,G,he,fe,me,we)=>{for(let J=0;J{if(P!==G){if(P!==ct)for(const me in P)!en(me)&&!(me in G)&&i(S,me,P[me],null,fe,he);for(const me in G){if(en(me))continue;const we=G[me],J=P[me];we!==J&&me!=="value"&&i(S,me,J,we,fe,he)}"value"in G&&i(S,"value",P.value,G.value,fe)}},I=(S,P,G,he,fe,me,we,J,ie)=>{const X=P.el=S?S.el:o(""),_e=P.anchor=S?S.anchor:o("");let{patchFlag:U,dynamicChildren:de,slotScopeIds:ge}=P;ge&&(J=J?J.concat(ge):ge),S==null?(a(X,G,he),a(_e,G,he),L(P.children||[],G,_e,fe,me,we,J,ie)):U>0&&U&64&&de&&S.dynamicChildren&&S.dynamicChildren.length===de.length?(F(S.dynamicChildren,de,G,fe,me,we,J),(P.key!=null||fe&&P===fe.subTree)&&fu(S,P,!0)):$(S,P,G,_e,fe,me,we,J,ie)},H=(S,P,G,he,fe,me,we,J,ie)=>{P.slotScopeIds=J,S==null?P.shapeFlag&512?fe.ctx.activate(P,G,he,we,ie):te(P,G,he,fe,me,we,ie):ee(S,P,ie)},te=(S,P,G,he,fe,me,we)=>{const J=S.component=Qv(S,he,fe);if(mo(S)&&(J.ctx.renderer=j),Xv(J,!1,we),J.asyncDep){if(fe&&fe.registerDep(J,N,we),!S.el){const ie=J.subTree=Bt(Zt);A(null,ie,P,G),S.placeholder=ie.el}}else N(J,S,P,G,fe,me,we)},ee=(S,P,G)=>{const he=P.component=S.component;if(F0(S,P,G))if(he.asyncDep&&!he.asyncResolved){M(he,P,G);return}else he.next=P,he.update();else P.el=S.el,he.vnode=P},N=(S,P,G,he,fe,me,we)=>{const J=()=>{if(S.isMounted){let{next:U,bu:de,u:ge,parent:Ae,vnode:$e}=S;{const Qe=zv(S);if(Qe){U&&(U.el=$e.el,M(S,U,we)),Qe.asyncDep.then(()=>{Qt(()=>{S.isUnmounted||X()},fe)});return}}let We=U,je;Hn(S,!1),U?(U.el=$e.el,M(S,U,we)):U=$e,de&&Di(de),(je=U.props&&U.props.onVnodeBeforeUpdate)&&Bs(je,Ae,U,$e),Hn(S,!0);const Xe=er(S),it=S.subTree;S.subTree=Xe,y(it,Xe,u(it.el),le(it),S,fe,me),U.el=Xe.el,We===null&&sc(S,Xe.el),ge&&Qt(ge,fe),(je=U.props&&U.props.onVnodeUpdated)&&Qt(()=>Bs(je,Ae,U,$e),fe)}else{let U;const{el:de,props:ge}=P,{bm:Ae,m:$e,parent:We,root:je,type:Xe}=S,it=sn(P);if(Hn(S,!1),Ae&&Di(Ae),!it&&(U=ge&&ge.onVnodeBeforeMount)&&Bs(U,We,P),Hn(S,!0),de&&xe){const Qe=()=>{S.subTree=er(S),xe(de,S.subTree,S,fe,null)};it&&Xe.__asyncHydrate?Xe.__asyncHydrate(de,S,Qe):Qe()}else{je.ce&&je.ce._hasShadowRoot()&&je.ce._injectChildStyle(Xe,S.parent?S.parent.type:void 0);const Qe=S.subTree=er(S);y(null,Qe,G,he,S,fe,me),P.el=Qe.el}if($e&&Qt($e,fe),!it&&(U=ge&&ge.onVnodeMounted)){const Qe=P;Qt(()=>Bs(U,We,Qe),fe)}(P.shapeFlag&256||We&&sn(We.vnode)&&We.vnode.shapeFlag&256)&&S.a&&Qt(S.a,fe),S.isMounted=!0,P=G=he=null}};S.scope.on();const ie=S.effect=new zl(J);S.scope.off();const X=S.update=ie.run.bind(ie),_e=S.job=ie.runIfDirty.bind(ie);_e.i=S,_e.id=S.uid,ie.scheduler=()=>tu(_e),Hn(S,!0),X()},M=(S,P,G)=>{P.component=S;const he=S.vnode.props;S.vnode=P,S.next=null,U0(S,P.props,he,G),q0(S,P.children,G),ln(),yp(S),on()},$=(S,P,G,he,fe,me,we,J,ie=!1)=>{const X=S&&S.children,_e=S?S.shapeFlag:0,U=P.children,{patchFlag:de,shapeFlag:ge}=P;if(de>0){if(de&128){ce(X,U,G,he,fe,me,we,J,ie);return}else if(de&256){re(X,U,G,he,fe,me,we,J,ie);return}}ge&8?(_e&16&&Oe(X,fe,me),U!==X&&d(G,U)):_e&16?ge&16?ce(X,U,G,he,fe,me,we,J,ie):Oe(X,fe,me,!0):(_e&8&&d(G,""),ge&16&&L(U,G,he,fe,me,we,J,ie))},re=(S,P,G,he,fe,me,we,J,ie)=>{S=S||Li,P=P||Li;const X=S.length,_e=P.length,U=Math.min(X,_e);let de;for(de=0;de_e?Oe(S,fe,me,!0,!1,U):L(P,G,he,fe,me,we,J,ie,U)},ce=(S,P,G,he,fe,me,we,J,ie)=>{let X=0;const _e=P.length;let U=S.length-1,de=_e-1;for(;X<=U&&X<=de;){const ge=S[X],Ae=P[X]=ie?Wa(P[X]):zs(P[X]);if(fa(ge,Ae))y(ge,Ae,G,null,fe,me,we,J,ie);else break;X++}for(;X<=U&&X<=de;){const ge=S[U],Ae=P[de]=ie?Wa(P[de]):zs(P[de]);if(fa(ge,Ae))y(ge,Ae,G,null,fe,me,we,J,ie);else break;U--,de--}if(X>U){if(X<=de){const ge=de+1,Ae=ge<_e?P[ge].el:he;for(;X<=de;)y(null,P[X]=ie?Wa(P[X]):zs(P[X]),G,Ae,fe,me,we,J,ie),X++}}else if(X>de)for(;X<=U;)Q(S[X],fe,me,!0),X++;else{const ge=X,Ae=X,$e=new Map;for(X=Ae;X<=de;X++){const Le=P[X]=ie?Wa(P[X]):zs(P[X]);Le.key!=null&&$e.set(Le.key,X)}let We,je=0;const Xe=de-Ae+1;let it=!1,Qe=0;const se=new Array(Xe);for(X=0;X=Xe){Q(Le,fe,me,!0);continue}let Te;if(Le.key!=null)Te=$e.get(Le.key);else for(We=Ae;We<=de;We++)if(se[We-Ae]===0&&fa(Le,P[We])){Te=We;break}Te===void 0?Q(Le,fe,me,!0):(se[Te-Ae]=X+1,Te>=Qe?Qe=Te:it=!0,y(Le,P[Te],G,null,fe,me,we,J,ie),je++)}const Re=it?V0(se):Li;for(We=Re.length-1,X=Xe-1;X>=0;X--){const Le=Ae+X,Te=P[Le],W=P[Le+1],De=Le+1<_e?W.el||Hv(W):he;se[X]===0?y(null,Te,G,De,fe,me,we,J,ie):it&&(We<0||X!==Re[We]?Z(Te,G,De,2):We--)}}},Z=(S,P,G,he,fe=null)=>{const{el:me,type:we,transition:J,children:ie,shapeFlag:X}=S;if(X&6){Z(S.component.subTree,P,G,he);return}if(X&128){S.suspense.move(P,G,he);return}if(X&64){we.move(S,P,G,j);return}if(we===rs){a(me,P,G);for(let U=0;UJ.enter(me),fe));else{const{leave:U,delayLeave:de,afterLeave:ge}=J,Ae=()=>{S.ctx.isUnmounted?n(me):a(me,P,G)},$e=()=>{const We=me._isLeaving||!!me[na];me._isLeaving&&me[na](!0),J.persisted&&!We?Ae():U(me,()=>{Ae(),ge&&ge()})};de?de(me,Ae,$e):$e()}else a(me,P,G)},Q=(S,P,G,he=!1,fe=!1)=>{const{type:me,props:we,ref:J,children:ie,dynamicChildren:X,shapeFlag:_e,patchFlag:U,dirs:de,cacheIndex:ge,memo:Ae}=S;if(U===-2&&(fe=!1),J!=null&&(ln(),$i(J,null,G,S,!0),on()),ge!=null&&(P.renderCache[ge]=void 0),_e&256){P.ctx.deactivate(S);return}const $e=_e&1&&de,We=!sn(S);let je;if(We&&(je=we&&we.onVnodeBeforeUnmount)&&Bs(je,P,S),_e&6)be(S.component,G,he);else{if(_e&128){S.suspense.unmount(G,he);return}$e&&Ia(S,null,P,"beforeUnmount"),_e&64?S.type.remove(S,P,G,j,he):X&&!X.hasOnce&&(me!==rs||U>0&&U&64)?Oe(X,P,G,!1,!0):(me===rs&&U&384||!fe&&_e&16)&&Oe(ie,P,G),he&&ae(S)}const Xe=Ae!=null&&ge==null;(We&&(je=we&&we.onVnodeUnmounted)||$e||Xe)&&Qt(()=>{je&&Bs(je,P,S),$e&&Ia(S,null,P,"unmounted"),Xe&&(S.el=null)},G)},ae=S=>{const{type:P,el:G,anchor:he,transition:fe}=S;if(P===rs){V(G,he);return}if(P===Xn){x(S);return}const me=()=>{n(G),fe&&!fe.persisted&&fe.afterLeave&&fe.afterLeave()};if(S.shapeFlag&1&&fe&&!fe.persisted){const{leave:we,delayLeave:J}=fe,ie=()=>we(G,me);J?J(S.el,me,ie):ie()}else me()},V=(S,P)=>{let G;for(;S!==P;)G=p(S),n(S),S=G;n(P)},be=(S,P,G)=>{const{bum:he,scope:fe,job:me,subTree:we,um:J,m:ie,a:X}=S;hr(ie),hr(X),he&&Di(he),fe.stop(),me&&(me.flags|=8,Q(we,S,P,G)),J&&Qt(J,P),Qt(()=>{S.isUnmounted=!0},P)},Oe=(S,P,G,he=!1,fe=!1,me=0)=>{for(let we=me;we{if(S.shapeFlag&6)return le(S.component.subTree);if(S.shapeFlag&128)return S.suspense.next();const P=p(S.anchor||S.el),G=P&&P[dv];return G?p(G):P};let oe=!1;const ve=(S,P,G)=>{let he;S==null?P._vnode&&(Q(P._vnode,null,null,!0),he=P._vnode.component):y(P._vnode||null,S,P,null,null,null,G),P._vnode=S,oe||(oe=!0,yp(he),fr(),oe=!1)},j={p:y,um:Q,m:Z,r:ae,mt:te,mc:L,pc:$,pbc:F,n:le,o:e};let Y,xe;return t&&([Y,xe]=t(j)),{render:ve,hydrate:Y,createApp:O0(ve,Y)}}function $c({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function Hn({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Uv(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function fu(e,t,s=!1){const a=e.children,n=t.children;if(Me(a)&&Me(n))for(let i=0;i>1,e[s[o]]0&&(t[a]=s[i-1]),s[i]=a)}}for(i=s.length,l=s[i-1];i-- >0;)s[i]=l,l=t[l];return s}function zv(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:zv(t)}function hr(e){if(e)for(let t=0;te.__isSuspense;let vd=0;const G0={name:"Suspense",__isSuspense:!0,process(e,t,s,a,n,i,l,o,r,c){if(e==null)K0(t,s,a,n,i,l,o,r,c);else{if(i&&i.deps>0&&!e.suspense.isInFallback){t.suspense=e.suspense,t.suspense.vnode=t,t.el=e.el;return}J0(e,t,s,a,n,l,o,r,c)}},hydrate:Z0,normalize:Q0},W0=G0;function Kl(e,t){const s=e.props&&e.props[t];Ve(s)&&s()}function K0(e,t,s,a,n,i,l,o,r){const{p:c,o:{createElement:d}}=r,u=d("div"),p=e.suspense=jv(e,n,a,t,u,s,i,l,o,r);c(null,p.pendingBranch=e.ssContent,u,null,a,p,i,l),p.deps>0?(Kl(e,"onPending"),Kl(e,"onFallback"),c(null,e.ssFallback,t,s,a,null,i,l),Fi(p,e.ssFallback)):p.resolve(!1,!0)}function J0(e,t,s,a,n,i,l,o,{p:r,um:c,o:{createElement:d}}){const u=t.suspense=e.suspense;u.vnode=t,t.el=e.el;const p=t.ssContent,m=t.ssFallback,{activeBranch:h,pendingBranch:y,isInFallback:k,isHydrating:A}=u;if(y)u.pendingBranch=p,fa(y,p)?(r(y,p,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0?u.resolve():k&&(A||(r(h,m,s,a,n,null,i,l,o),Fi(u,m)))):(u.pendingId=vd++,A?(u.isHydrating=!1,u.activeBranch=y):c(y,n,u),u.deps=0,u.effects.length=0,u.hiddenContainer=d("div"),k?(r(null,p,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0?u.resolve():(r(h,m,s,a,n,null,i,l,o),Fi(u,m))):h&&fa(h,p)?(r(h,p,s,a,n,u,i,l,o),u.resolve(!0)):(r(null,p,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0&&u.resolve()));else if(h&&fa(h,p))r(h,p,s,a,n,u,i,l,o),Fi(u,p);else if(Kl(t,"onPending"),u.pendingBranch=p,p.shapeFlag&512?u.pendingId=p.component.suspenseId:u.pendingId=vd++,r(null,p,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0)u.resolve();else{const{timeout:g,pendingId:b}=u;g>0?setTimeout(()=>{u.pendingId===b&&u.fallback(m)},g):g===0&&u.fallback(m)}}function jv(e,t,s,a,n,i,l,o,r,c,d=!1){const{p:u,m:p,um:m,n:h,o:{parentNode:y,remove:k}}=c;let A;const g=Y0(e);g&&t&&t.pendingBranch&&(A=t.pendingId,t.deps++);const b=e.props?rr(e.props.timeout):void 0,x=i,w={vnode:e,parent:t,parentComponent:s,namespace:l,container:a,hiddenContainer:n,deps:0,pendingId:vd++,timeout:typeof b=="number"?b:-1,activeBranch:null,isFallbackMountPending:!1,pendingBranch:null,isInFallback:!d,isHydrating:d,isUnmounted:!1,effects:[],resolve(_=!1,T=!1){const{vnode:L,activeBranch:E,pendingBranch:F,pendingId:O,effects:I,parentComponent:H,container:te,isInFallback:ee}=w;let N=!1;if(w.isHydrating)w.isHydrating=!1;else if(!_){N=E&&F.transition&&F.transition.mode==="out-in";let re=!1;N&&(E.transition.afterLeave=()=>{O===w.pendingId&&(p(F,te,i===x&&!re?h(E):i,0),ql(I),ee&&L.ssFallback&&(L.ssFallback.el=null))}),E&&!w.isFallbackMountPending&&(y(E.el)===te&&(i=h(E),re=!0),m(E,H,w,!0),!N&&ee&&L.ssFallback&&Qt(()=>L.ssFallback.el=null,w)),N||p(F,te,i,0)}w.isFallbackMountPending=!1,Fi(w,F),w.pendingBranch=null,w.isInFallback=!1;let M=w.parent,$=!1;for(;M;){if(M.pendingBranch){M.effects.push(...I),$=!0;break}M=M.parent}!$&&!N&&ql(I),w.effects=[],g&&t&&t.pendingBranch&&A===t.pendingId&&(t.deps--,t.deps===0&&!T&&t.resolve()),Kl(L,"onResolve")},fallback(_){if(!w.pendingBranch)return;const{vnode:T,activeBranch:L,parentComponent:E,container:F,namespace:O}=w;Kl(T,"onFallback");const I=h(L),H=()=>{w.isFallbackMountPending=!1,w.isInFallback&&(u(null,_,F,I,E,null,O,o,r),Fi(w,_))},te=_.transition&&_.transition.mode==="out-in";te&&(w.isFallbackMountPending=!0,L.transition.afterLeave=H),w.isInFallback=!0,m(L,E,null,!0),te||H()},move(_,T,L){w.activeBranch&&p(w.activeBranch,_,T,L),w.container=_},next(){return w.activeBranch&&h(w.activeBranch)},registerDep(_,T,L){const E=!!w.pendingBranch;E&&w.deps++;const F=_.vnode.el;_.asyncDep.catch(O=>{ci(O,_,0)}).then(O=>{if(_.isUnmounted||w.isUnmounted||w.pendingId!==_.suspenseId)return;Ql(),_.asyncResolved=!0;const{vnode:I}=_;hd(_,O,!1),F&&(I.el=F);const H=!F&&_.subTree.el;T(_,I,y(F||_.subTree.el),F?null:h(_.subTree),w,l,L),H&&(I.placeholder=null,k(H)),sc(_,I.el),E&&--w.deps===0&&w.resolve()})},unmount(_,T){w.isUnmounted=!0,w.activeBranch&&m(w.activeBranch,s,_,T),w.pendingBranch&&m(w.pendingBranch,s,_,T)}};return w}function Z0(e,t,s,a,n,i,l,o,r){const c=t.suspense=jv(t,a,s,e.parentNode,document.createElement("div"),null,n,i,l,o,!0),d=r(e,c.pendingBranch=t.ssContent,s,c,i,l);return c.deps===0&&c.resolve(!1,!0),d}function Q0(e){const{shapeFlag:t,children:s}=e,a=t&32;e.ssContent=Np(a?s.default:s),e.ssFallback=a?Np(s.fallback):Bt(Zt)}function Np(e){let t;if(Ve(e)){const s=si&&e._c;s&&(e._d=!1,Jl()),e=e(),s&&(e._d=!0,t=ks,Vv())}return Me(e)&&(e=D0(e)),e=zs(e),t&&!e.dynamicChildren&&(e.dynamicChildren=t.filter(s=>s!==e)),e}function qv(e,t){t&&t.pendingBranch?Me(e)?t.effects.push(...e):t.effects.push(e):ql(e)}function Fi(e,t){e.activeBranch=t;const{vnode:s,parentComponent:a}=e;let n=t.el;for(;!n&&t.component;)t=t.component.subTree,n=t.el;s.el=n,a&&a.subTree===s&&(a.vnode.el=n,sc(a,n))}function Y0(e){const t=e.props&&e.props.suspensible;return t!=null&&t!==!1}const rs=Symbol.for("v-fgt"),On=Symbol.for("v-txt"),Zt=Symbol.for("v-cmt"),Xn=Symbol.for("v-stc"),Dl=[];let ks=null;function Jl(e=!1){Dl.push(ks=e?null:[])}function Vv(){Dl.pop(),ks=Dl[Dl.length-1]||null}let si=1;function Zl(e,t=!1){si+=e,e<0&&ks&&t&&(ks.hasOnce=!0)}function Gv(e){return e.dynamicChildren=si>0?ks||Li:null,Vv(),si>0&&ks&&ks.push(e),e}function X0(e,t,s,a,n,i){return Gv(mu(e,t,s,a,n,i,!0))}function br(e,t,s,a,n){return Gv(Bt(e,t,s,a,n,!0))}function cn(e){return e?e.__v_isVNode===!0:!1}function fa(e,t){return e.type===t.type&&e.key===t.key}function e_(e){}const Wv=({key:e})=>e??null,tr=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?Ze(e)||Xt(e)||Ve(e)?{i:ms,r:e,k:t,f:!!s}:e:null);function mu(e,t=null,s=null,a=0,n=null,i=e===rs?0:1,l=!1,o=!1){const r={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Wv(t),ref:t&&tr(t),scopeId:Zr,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:a,dynamicProps:n,dynamicChildren:null,appContext:null,ctx:ms};return o?(hu(r,s),i&128&&e.normalize(r)):s&&(r.shapeFlag|=Ze(s)?8:16),si>0&&!l&&ks&&(r.patchFlag>0||i&6)&&r.patchFlag!==32&&ks.push(r),r}const Bt=t_;function t_(e,t=null,s=null,a=0,n=null,i=!1){if((!e||e===wv)&&(e=Zt),cn(e)){const o=Ma(e,t,!0);return s&&hu(o,s),si>0&&!i&&ks&&(o.shapeFlag&6?ks[ks.indexOf(e)]=o:ks.push(o)),o.patchFlag=-2,o}if(r_(e)&&(e=e.__vccOpts),t){t=Kv(t);let{class:o,style:r}=t;o&&!Ze(o)&&(t.class=co(o)),ht(r)&&(uo(r)&&!Me(r)&&(r=lt({},r)),t.style=ro(r))}const l=Ze(e)?1:gr(e)?128:uv(e)?64:ht(e)?4:Ve(e)?2:0;return mu(e,t,s,a,n,l,i,!0)}function Kv(e){return e?uo(e)||Ov(e)?lt({},e):e:null}function Ma(e,t,s=!1,a=!1){const{props:n,ref:i,patchFlag:l,children:o,transition:r}=e,c=t?Zv(n||{},t):n,d={__v_isVNode:!0,__v_skip:!0,type:e.type,props:c,key:c&&Wv(c),ref:t&&t.ref?s&&i?Me(i)?i.concat(tr(t)):[i,tr(t)]:tr(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:o,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==rs?l===-1?16:l|16:l,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:r,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Ma(e.ssContent),ssFallback:e.ssFallback&&Ma(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return r&&a&&rn(d,r.clone(d)),d}function vu(e=" ",t=0){return Bt(On,null,e,t)}function s_(e,t){const s=Bt(Xn,null,e);return s.staticCount=t,s}function Jv(e="",t=!1){return t?(Jl(),br(Zt,null,e)):Bt(Zt,null,e)}function zs(e){return e==null||typeof e=="boolean"?Bt(Zt):Me(e)?Bt(rs,null,e.slice()):cn(e)?Wa(e):Bt(On,null,String(e))}function Wa(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Ma(e)}function hu(e,t){let s=0;const{shapeFlag:a}=e;if(t==null)t=null;else if(Me(t))s=16;else if(typeof t=="object")if(a&65){const n=t.default;n&&(n._c&&(n._d=!1),hu(e,n()),n._c&&(n._d=!0));return}else{s=32;const n=t._;!n&&!Ov(t)?t._ctx=ms:n===3&&ms&&(ms.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else Ve(t)?(t={default:t,_ctx:ms},s=32):(t=String(t),a&64?(s=16,t=[vu(t)]):s=8);e.children=t,e.shapeFlag|=s}function Zv(...e){const t={};for(let s=0;sfs||ms;let yr,Bi;{const e=qr(),t=(s,a)=>{let n;return(n=e[s])||(n=e[s]=[]),n.push(a),i=>{n.length>1?n.forEach(l=>l(i)):n[0](i)}};yr=t("__VUE_INSTANCE_SETTERS__",s=>fs=s),Bi=t("__VUE_SSR_SETTERS__",s=>ai=s)}const sl=e=>{const t=fs;return yr(e),e.scope.on(),()=>{e.scope.off(),yr(t)}},Ql=()=>{fs&&fs.scope.off(),yr(null)};function Yv(e){return e.vnode.shapeFlag&4}let ai=!1;function Xv(e,t=!1,s=!1){t&&Bi(t);const{props:a,children:n}=e.vnode,i=Yv(e);B0(e,a,i,t),j0(e,n,s||t);const l=i?i_(e,t):void 0;return t&&Bi(!1),l}function i_(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,ud);const{setup:a}=s;if(a){ln();const n=e.setupContext=a.length>1?sh(e):null,i=sl(e),l=tl(a,e,0,[e.props,n]),o=Wd(l);if(on(),i(),(o||e.sp)&&!sn(e)&&iu(e),o){if(l.then(Ql,Ql),t)return l.then(r=>{hd(e,r,t)}).catch(r=>{ci(r,e,0)});e.asyncDep=l}else hd(e,l,t)}else th(e,t)}function hd(e,t,s){Ve(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ht(t)&&(e.setupState=eu(t)),th(e,s)}let xr,gd;function eh(e){xr=e,gd=t=>{t.render._rc&&(t.withProxy=new Proxy(t.ctx,u0))}}const l_=()=>!xr;function th(e,t,s){const a=e.type;if(!e.render){if(!t&&xr&&!a.render){const n=a.template||du(e).template;if(n){const{isCustomElement:i,compilerOptions:l}=e.appContext.config,{delimiters:o,compilerOptions:r}=a,c=lt(lt({isCustomElement:i,delimiters:o},l),r);a.render=xr(n,c)}}e.render=a.render||vs,gd&&gd(e)}{const n=sl(e);ln();try{C0(e)}finally{on(),n()}}}const o_={get(e,t){return ws(e,"get",""),e[t]}};function sh(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,o_),slots:e.slots,emit:e.emit,expose:t}}function vo(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(eu(Zm(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in Ml)return Ml[s](e)},has(t,s){return s in t||s in Ml}})):e.proxy}function bd(e,t=!0){return Ve(e)?e.displayName||e.name:e.name||t&&e.__name}function r_(e){return Ve(e)&&"__vccOpts"in e}const B=(e,t)=>mx(e,t,ai);function qi(e,t,s){try{Zl(-1);const a=arguments.length;return a===2?ht(t)&&!Me(t)?cn(t)?Bt(e,null,[t]):Bt(e,t):Bt(e,null,t):(a>3?s=Array.prototype.slice.call(arguments,2):a===3&&cn(s)&&(s=[s]),Bt(e,t,s))}finally{Zl(1)}}function c_(){}function d_(e,t,s,a){const n=s[a];if(n&&ah(n,e))return n;const i=t();return i.memo=e.slice(),i.cacheIndex=a,s[a]=i}function ah(e,t){const s=e.memo;if(s.length!=t.length)return!1;for(let a=0;a0&&ks&&ks.push(e),!0}const nh="3.5.38",u_=vs,p_=kx,f_=Ti,m_=iv,v_={createComponentInstance:Qv,setupComponent:Xv,renderComponentRoot:er,setCurrentRenderingInstance:Gl,isVNode:cn,normalizeVNode:zs,getComponentPublicInstance:vo,ensureValidVNode:cu,pushWarningContext:yx,popWarningContext:xx},h_=v_,g_=null,b_=null,y_=null;/** +* @vue/runtime-dom v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let yd;const Mp=typeof window<"u"&&window.trustedTypes;if(Mp)try{yd=Mp.createPolicy("vue",{createHTML:e=>e})}catch{}const ih=yd?e=>yd.createHTML(e):e=>e,x_="http://www.w3.org/2000/svg",__="http://www.w3.org/1998/Math/MathML",Ga=typeof document<"u"?document:null,Dp=Ga&&Ga.createElement("template"),lh={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,a)=>{const n=t==="svg"?Ga.createElementNS(x_,e):t==="mathml"?Ga.createElementNS(__,e):s?Ga.createElement(e,{is:s}):Ga.createElement(e);return e==="select"&&a&&a.multiple!=null&&n.setAttribute("multiple",a.multiple),n},createText:e=>Ga.createTextNode(e),createComment:e=>Ga.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ga.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,a,n,i){const l=s?s.previousSibling:t.lastChild;if(n&&(n===i||n.nextSibling))for(;t.insertBefore(n.cloneNode(!0),s),!(n===i||!(n=n.nextSibling)););else{Dp.innerHTML=ih(a==="svg"?`${e}`:a==="mathml"?`${e}`:e);const o=Dp.content;if(a==="svg"||a==="mathml"){const r=o.firstChild;for(;r.firstChild;)o.appendChild(r.firstChild);o.removeChild(r)}t.insertBefore(o,s)}return[l?l.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},_n="transition",fl="animation",Vi=Symbol("_vtc"),oh={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},rh=lt({},nu,oh),w_=e=>(e.displayName="Transition",e.props=rh,e),k_=w_((e,{slots:t})=>qi(mv,ch(e),t)),jn=(e,t=[])=>{Me(e)?e.forEach(s=>s(...t)):e&&e(...t)},Pp=e=>e?Me(e)?e.some(t=>t.length>1):e.length>1:!1;function ch(e){const t={};for(const I in e)I in oh||(t[I]=e[I]);if(e.css===!1)return t;const{name:s="v",type:a,duration:n,enterFromClass:i=`${s}-enter-from`,enterActiveClass:l=`${s}-enter-active`,enterToClass:o=`${s}-enter-to`,appearFromClass:r=i,appearActiveClass:c=l,appearToClass:d=o,leaveFromClass:u=`${s}-leave-from`,leaveActiveClass:p=`${s}-leave-active`,leaveToClass:m=`${s}-leave-to`}=e,h=S_(n),y=h&&h[0],k=h&&h[1],{onBeforeEnter:A,onEnter:g,onEnterCancelled:b,onLeave:x,onLeaveCancelled:w,onBeforeAppear:_=A,onAppear:T=g,onAppearCancelled:L=b}=t,E=(I,H,te,ee)=>{I._enterCancelled=ee,Cn(I,H?d:o),Cn(I,H?c:l),te&&te()},F=(I,H)=>{I._isLeaving=!1,Cn(I,u),Cn(I,m),Cn(I,p),H&&H()},O=I=>(H,te)=>{const ee=I?T:g,N=()=>E(H,I,te);jn(ee,[H,N]),$p(()=>{Cn(H,I?r:i),Ta(H,I?d:o),Pp(ee)||Fp(H,a,y,N)})};return lt(t,{onBeforeEnter(I){jn(A,[I]),Ta(I,i),Ta(I,l)},onBeforeAppear(I){jn(_,[I]),Ta(I,r),Ta(I,c)},onEnter:O(!1),onAppear:O(!0),onLeave(I,H){I._isLeaving=!0;const te=()=>F(I,H);Ta(I,u),I._enterCancelled?(Ta(I,p),xd(I)):(xd(I),Ta(I,p)),$p(()=>{I._isLeaving&&(Cn(I,u),Ta(I,m),Pp(x)||Fp(I,a,k,te))}),jn(x,[I,te])},onEnterCancelled(I){E(I,!1,void 0,!0),jn(b,[I])},onAppearCancelled(I){E(I,!0,void 0,!0),jn(L,[I])},onLeaveCancelled(I){F(I),jn(w,[I])}})}function S_(e){if(e==null)return null;if(ht(e))return[Fc(e.enter),Fc(e.leave)];{const t=Fc(e);return[t,t]}}function Fc(e){return rr(e)}function Ta(e,t){t.split(/\s+/).forEach(s=>s&&e.classList.add(s)),(e[Vi]||(e[Vi]=new Set)).add(t)}function Cn(e,t){t.split(/\s+/).forEach(a=>a&&e.classList.remove(a));const s=e[Vi];s&&(s.delete(t),s.size||(e[Vi]=void 0))}function $p(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let C_=0;function Fp(e,t,s,a){const n=e._endId=++C_,i=()=>{n===e._endId&&a()};if(s!=null)return setTimeout(i,s);const{type:l,timeout:o,propCount:r}=dh(e,t);if(!l)return a();const c=l+"end";let d=0;const u=()=>{e.removeEventListener(c,p),i()},p=m=>{m.target===e&&++d>=r&&u()};setTimeout(()=>{d(s[h]||"").split(", "),n=a(`${_n}Delay`),i=a(`${_n}Duration`),l=Bp(n,i),o=a(`${fl}Delay`),r=a(`${fl}Duration`),c=Bp(o,r);let d=null,u=0,p=0;t===_n?l>0&&(d=_n,u=l,p=i.length):t===fl?c>0&&(d=fl,u=c,p=r.length):(u=Math.max(l,c),d=u>0?l>c?_n:fl:null,p=d?d===_n?i.length:r.length:0);const m=d===_n&&/\b(?:transform|all)(?:,|$)/.test(a(`${_n}Property`).toString());return{type:d,timeout:u,propCount:p,hasTransform:m}}function Bp(e,t){for(;e.lengthUp(s)+Up(e[a])))}function Up(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function xd(e){return(e?e.ownerDocument:document).body.offsetHeight}function T_(e,t,s){const a=e[Vi];a&&(t=(t?[t,...a]:[...a]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const _r=Symbol("_vod"),gu=Symbol("_vsh"),uh={name:"show",beforeMount(e,{value:t},{transition:s}){e[_r]=e.style.display==="none"?"":e.style.display,s&&t?s.beforeEnter(e):ml(e,t)},mounted(e,{value:t},{transition:s}){s&&t&&s.enter(e)},updated(e,{value:t,oldValue:s},{transition:a}){!t!=!s&&(a?t?(a.beforeEnter(e),ml(e,!0),a.enter(e)):a.leave(e,()=>{ml(e,!1)}):ml(e,t))},beforeUnmount(e,{value:t}){ml(e,t)}};function ml(e,t){e.style.display=t?e[_r]:"none",e[gu]=!t}function E_(){uh.getSSRProps=({value:e})=>{if(!e)return{style:{display:"none"}}}}const ph=Symbol("");function A_(e){const t=Ds();if(!t)return;const s=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(i=>wr(i,n))},a=()=>{const n=e(t.proxy);t.ce?wr(t.ce,n):_d(t.subTree,n),s(n)};lu(()=>{ql(a)}),st(()=>{Gt(a,vs,{flush:"post"});const n=new MutationObserver(a);n.observe(t.subTree.el.parentNode,{childList:!0}),wt(()=>n.disconnect())})}function _d(e,t){if(e.shapeFlag&128){const s=e.suspense;e=s.activeBranch,s.pendingBranch&&!s.isHydrating&&s.effects.push(()=>{_d(s.activeBranch,t)})}for(;e.component;)e=e.component.subTree;if(e.shapeFlag&1&&e.el)wr(e.el,t);else if(e.type===rs)e.children.forEach(s=>_d(s,t));else if(e.type===Xn){let{el:s,anchor:a}=e;for(;s&&(wr(s,t),s!==a);)s=s.nextSibling}}function wr(e,t){if(e.nodeType===1){const s=e.style;let a="";for(const n in t){const i=My(t[n]);s.setProperty(`--${n}`,i),a+=`--${n}: ${i};`}s[ph]=a}}const R_=/(?:^|;)\s*display\s*:/;function I_(e,t,s){const a=e.style,n=Ze(s);let i=!1;if(s&&!n){if(t)if(Ze(t))for(const l of t.split(";")){const o=l.slice(0,l.indexOf(":")).trim();s[o]==null&&Rl(a,o,"")}else for(const l in t)s[l]==null&&Rl(a,l,"");for(const l in s){l==="display"&&(i=!0);const o=s[l];o!=null?L_(e,l,!Ze(t)&&t?t[l]:void 0,o)||Rl(a,l,o):Rl(a,l,"")}}else if(n){if(t!==s){const l=a[ph];l&&(s+=";"+l),a.cssText=s,i=R_.test(s)}}else t&&e.removeAttribute("style");_r in e&&(e[_r]=i?a.display:"",e[gu]&&(a.display="none"))}const zp=/\s*!important$/;function Rl(e,t,s){if(Me(s))s.forEach(a=>Rl(e,t,a));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const a=O_(e,t);zp.test(s)?e.setProperty(Hs(a),s.replace(zp,""),"important"):e[a]=s}}const Hp=["Webkit","Moz","ms"],Bc={};function O_(e,t){const s=Bc[t];if(s)return s;let a=Tt(t);if(a!=="filter"&&a in e)return Bc[t]=a;a=ri(a);for(let n=0;nUc||(P_.then(()=>Uc=0),Uc=Date.now());function F_(e,t){const s=a=>{if(!a._vts)a._vts=Date.now();else if(a._vts<=s.attached)return;const n=s.value;if(Me(n)){const i=a.stopImmediatePropagation;a.stopImmediatePropagation=()=>{i.call(a),a._stopped=!0};const l=n.slice(),o=[a];for(let r=0;re.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,fh=(e,t,s,a,n,i)=>{const l=n==="svg";t==="class"?T_(e,a,l):t==="style"?I_(e,s,a):li(t)?Br(t)||M_(e,t,s,a,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):B_(e,t,a,l))?(Vp(e,t,a),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&qp(e,t,a,l,i,t!=="value")):e._isVueCE&&(U_(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!Ze(a)))?Vp(e,Tt(t),a,i,t):(t==="true-value"?e._trueValue=a:t==="false-value"&&(e._falseValue=a),qp(e,t,a,l))};function B_(e,t,s,a){if(a)return!!(t==="innerHTML"||t==="textContent"||t in e&&Kp(t)&&Ve(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const n=e.tagName;if(n==="IMG"||n==="VIDEO"||n==="CANVAS"||n==="SOURCE")return!1}return Kp(t)&&Ze(s)?!1:t in e}function U_(e,t){const s=e._def.props;if(!s)return!1;const a=Tt(t);return Array.isArray(s)?s.some(n=>Tt(n)===a):Object.keys(s).some(n=>Tt(n)===a)}const Jp={};function mh(e,t,s){let a=fo(e,t);Ur(a)&&(a=lt({},a,t));class n extends ac{constructor(l){super(a,l,s)}}return n.def=a,n}const z_=((e,t)=>mh(e,t,Eh)),H_=typeof HTMLElement<"u"?HTMLElement:class{};class ac extends H_{constructor(t,s={},a=Cr){super(),this._def=t,this._props=s,this._createApp=a,this._isVueCE=!0,this._instance=null,this._app=null,this._nonce=this._def.nonce,this._connected=!1,this._resolved=!1,this._patching=!1,this._dirty=!1,this._numberProps=null,this._styleChildren=new WeakSet,this._styleAnchors=new WeakMap,this._ob=null,this.shadowRoot&&a!==Cr?this._root=this.shadowRoot:t.shadowRoot!==!1?(this.attachShadow(lt({},t.shadowRootOptions,{mode:"open"})),this._root=this.shadowRoot):this._root=this}connectedCallback(){if(!this.isConnected)return;!this.shadowRoot&&!this._resolved&&this._parseSlots(),this._connected=!0;let t=this;for(;t=t&&(t.assignedSlot||t.parentNode||t.host);)if(t instanceof ac){this._parent=t;break}this._instance||(this._resolved?this._mount(this._def):t&&t._pendingResolve?this._pendingResolve=t._pendingResolve.then(()=>{this._pendingResolve=void 0,this._resolveDef()}):this._resolveDef())}_setParent(t=this._parent){t&&(this._instance.parent=t._instance,this._inheritParentContext(t))}_inheritParentContext(t=this._parent){t&&this._app&&Object.setPrototypeOf(this._app._context.provides,t._instance.provides)}disconnectedCallback(){this._connected=!1,Ut(()=>{this._connected||(this._ob&&(this._ob.disconnect(),this._ob=null),this._app&&this._app.unmount(),this._instance&&(this._instance.ce=void 0),this._app=this._instance=null,this._teleportTargets&&(this._teleportTargets.clear(),this._teleportTargets=void 0))})}_processMutations(t){for(const s of t)this._setAttr(s.attributeName)}_resolveDef(){if(this._pendingResolve)return;for(let a=0;a{this._resolved=!0,this._pendingResolve=void 0;const{props:i,styles:l}=a;let o;if(i&&!Me(i))for(const r in i){const c=i[r];(c===Number||c&&c.type===Number)&&(r in this._props&&(this._props[r]=rr(this._props[r])),(o||(o=Object.create(null)))[Tt(r)]=!0)}this._numberProps=o,this._resolveProps(a),this.shadowRoot&&this._applyStyles(l),this._mount(a)},s=this._def.__asyncLoader;s?this._pendingResolve=s().then(a=>{a.configureApp=this._def.configureApp,t(this._def=a,!0)}):t(this._def)}_mount(t){this._app=this._createApp(t),this._inheritParentContext(),t.configureApp&&t.configureApp(this._app),this._app._ceVNode=this._createVNode(),this._app.mount(this._root);const s=this._instance&&this._instance.exposed;if(s)for(const a in s)bt(this,a)||Object.defineProperty(this,a,{get:()=>La(s[a])})}_resolveProps(t){const{props:s}=t,a=Me(s)?s:Object.keys(s||{});for(const n of Object.keys(this))n[0]!=="_"&&a.includes(n)&&this._setProp(n,this[n]);for(const n of a.map(Tt))Object.defineProperty(this,n,{get(){return this._getProp(n)},set(i){this._setProp(n,i,!0,!this._patching)}})}_setAttr(t){if(t.startsWith("data-v-"))return;const s=this.hasAttribute(t);let a=s?this.getAttribute(t):Jp;const n=Tt(t);s&&this._numberProps&&this._numberProps[n]&&(a=rr(a)),this._setProp(n,a,!1,!0)}_getProp(t){return this._props[t]}_setProp(t,s,a=!0,n=!1){if(s!==this._props[t]&&(this._dirty=!0,s===Jp?delete this._props[t]:(this._props[t]=s,t==="key"&&this._app&&(this._app._ceVNode.key=s)),n&&this._instance&&this._update(),a)){const i=this._ob;i&&(this._processMutations(i.takeRecords()),i.disconnect()),s===!0?this.setAttribute(Hs(t),""):typeof s=="string"||typeof s=="number"?this.setAttribute(Hs(t),s+""):s||this.removeAttribute(Hs(t)),i&&i.observe(this,{attributes:!0})}}_update(){const t=this._createVNode();this._app&&(t.appContext=this._app._context),Th(t,this._root)}_createVNode(){const t={};this.shadowRoot||(t.onVnodeMounted=t.onVnodeUpdated=this._renderSlots.bind(this));const s=Bt(this._def,lt(t,this._props));return this._instance||(s.ce=a=>{this._instance=a,a.ce=this,a.isCE=!0;const n=(i,l)=>{this.dispatchEvent(new CustomEvent(i,Ur(l[0])?lt({detail:l},l[0]):{detail:l}))};a.emit=(i,...l)=>{n(i,l),Hs(i)!==i&&n(Hs(i),l)},this._setParent()}),s}_applyStyles(t,s,a){if(!t)return;if(s){if(s===this._def||this._styleChildren.has(s))return;this._styleChildren.add(s)}const n=this._nonce,i=this.shadowRoot,l=a?this._getStyleAnchor(a)||this._getStyleAnchor(this._def):this._getRootStyleInsertionAnchor(i);let o=null;for(let r=t.length-1;r>=0;r--){const c=document.createElement("style");n&&c.setAttribute("nonce",n),c.textContent=t[r],i.insertBefore(c,o||l),o=c,r===0&&(a||this._styleAnchors.set(this._def,c),s&&this._styleAnchors.set(s,c))}}_getStyleAnchor(t){if(!t)return null;const s=this._styleAnchors.get(t);return s&&s.parentNode===this.shadowRoot?s:(s&&this._styleAnchors.delete(t),null)}_getRootStyleInsertionAnchor(t){for(let s=0;s(delete e.props.mode,e),G_=V_({name:"TransitionGroup",props:lt({},rh,{tag:String,moveClass:String}),setup(e,{slots:t}){const s=Ds(),a=au();let n,i;return Xr(()=>{if(!n.length)return;const l=e.moveClass||`${e.name||"v"}-move`;if(!Q_(n[0].el,s.vnode.el,l)){n=[];return}n.forEach(K_),n.forEach(J_);const o=n.filter(Z_);xd(s.vnode.el),o.forEach(r=>{const c=r.el,d=c.style;Ta(c,l),d.transform=d.webkitTransform=d.transitionDuration="";const u=c[kr]=p=>{p&&p.target!==c||(!p||p.propertyName.endsWith("transform"))&&(c.removeEventListener("transitionend",u),c[kr]=null,Cn(c,l))};c.addEventListener("transitionend",u)}),n=[]}),()=>{const l=ft(e),o=ch(l);let r=l.tag||rs;if(n=[],i)for(let c=0;c{o.split(/\s+/).forEach(r=>r&&a.classList.remove(r))}),s.split(/\s+/).forEach(o=>o&&a.classList.add(o)),a.style.display="none";const i=t.nodeType===1?t:t.parentNode;i.appendChild(a);const{hasTransform:l}=dh(a);return i.removeChild(a),l}const Nn=e=>{const t=e.props["onUpdate:modelValue"]||!1;return Me(t)?s=>Di(t,s):t};function Y_(e){e.target.composing=!0}function Qp(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const ca=Symbol("_assign");function Yp(e,t,s){return t&&(e=e.trim()),s&&(e=jr(e)),e}const Sr={created(e,{modifiers:{lazy:t,trim:s,number:a}},n){e[ca]=Nn(n);const i=a||n.props&&n.props.type==="number";Qa(e,t?"change":"input",l=>{l.target.composing||e[ca](Yp(e.value,s,i))}),(s||i)&&Qa(e,"change",()=>{e.value=Yp(e.value,s,i)}),t||(Qa(e,"compositionstart",Y_),Qa(e,"compositionend",Qp),Qa(e,"change",Qp))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:a,trim:n,number:i}},l){if(e[ca]=Nn(l),e.composing)return;const o=(i||e.type==="number")&&!/^0\d/.test(e.value)?jr(e.value):e.value,r=t??"";if(o===r)return;const c=e.getRootNode();(c instanceof Document||c instanceof ShadowRoot)&&c.activeElement===e&&e.type!=="range"&&(a&&t===s||n&&e.value.trim()===r)||(e.value=r)}},bu={deep:!0,created(e,t,s){e[ca]=Nn(s),Qa(e,"change",()=>{const a=e._modelValue,n=Gi(e),i=e.checked,l=e[ca];if(Me(a)){const o=Vr(a,n),r=o!==-1;if(i&&!r)l(a.concat(n));else if(!i&&r){const c=[...a];c.splice(o,1),l(c)}}else if(oi(a)){const o=new Set(a);i?o.add(n):o.delete(n),l(o)}else l(xh(e,i))})},mounted:Xp,beforeUpdate(e,t,s){e[ca]=Nn(s),Xp(e,t,s)}};function Xp(e,{value:t,oldValue:s},a){e._modelValue=t;let n;if(Me(t))n=Vr(t,a.props.value)>-1;else if(oi(t))n=t.has(a.props.value);else{if(t===s)return;n=nn(t,xh(e,!0))}e.checked!==n&&(e.checked=n)}const yu={created(e,{value:t},s){e.checked=nn(t,s.props.value),e[ca]=Nn(s),Qa(e,"change",()=>{e[ca](Gi(e))})},beforeUpdate(e,{value:t,oldValue:s},a){e[ca]=Nn(a),t!==s&&(e.checked=nn(t,a.props.value))}},yh={deep:!0,created(e,{value:t,modifiers:{number:s}},a){const n=oi(t);Qa(e,"change",()=>{const i=Array.prototype.filter.call(e.options,l=>l.selected).map(l=>s?jr(Gi(l)):Gi(l));e[ca](e.multiple?n?new Set(i):i:i[0]),e._assigning=!0,Ut(()=>{e._assigning=!1})}),e[ca]=Nn(a)},mounted(e,{value:t}){ef(e,t)},beforeUpdate(e,t,s){e[ca]=Nn(s)},updated(e,{value:t}){e._assigning||ef(e,t)}};function ef(e,t){const s=e.multiple,a=Me(t);if(!(s&&!a&&!oi(t))){for(let n=0,i=e.options.length;nString(c)===String(o)):l.selected=Vr(t,o)>-1}else l.selected=t.has(o);else if(nn(Gi(l),t)){e.selectedIndex!==n&&(e.selectedIndex=n);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Gi(e){return"_value"in e?e._value:e.value}function xh(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const _h={created(e,t,s){$o(e,t,s,null,"created")},mounted(e,t,s){$o(e,t,s,null,"mounted")},beforeUpdate(e,t,s,a){$o(e,t,s,a,"beforeUpdate")},updated(e,t,s,a){$o(e,t,s,a,"updated")}};function wh(e,t){switch(e){case"SELECT":return yh;case"TEXTAREA":return Sr;default:switch(t){case"checkbox":return bu;case"radio":return yu;default:return Sr}}}function $o(e,t,s,a,n){const l=wh(e.tagName,s.props&&s.props.type)[n];l&&l(e,t,s,a)}function X_(){Sr.getSSRProps=({value:e})=>({value:e}),yu.getSSRProps=({value:e},t)=>{if(t.props&&nn(t.props.value,e))return{checked:!0}},bu.getSSRProps=({value:e},t)=>{if(Me(e)){if(t.props&&Vr(e,t.props.value)>-1)return{checked:!0}}else if(oi(e)){if(t.props&&e.has(t.props.value))return{checked:!0}}else if(e)return{checked:!0}},_h.getSSRProps=(e,t)=>{if(typeof t.type!="string")return;const s=wh(t.type.toUpperCase(),t.props&&t.props.type);if(s.getSSRProps)return s.getSSRProps(e,t)}}const ew=["ctrl","shift","alt","meta"],tw={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>ew.some(s=>e[`${s}Key`]&&!t.includes(s))},sw=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),a=t.join(".");return s[a]||(s[a]=((n,...i)=>{for(let l=0;l{const s=e._withKeys||(e._withKeys={}),a=t.join(".");return s[a]||(s[a]=(n=>{if(!("key"in n))return;const i=Hs(n.key);if(t.some(l=>l===i||aw[l]===i))return e(n)}))},kh=lt({patchProp:fh},lh);let Pl,tf=!1;function Sh(){return Pl||(Pl=$v(kh))}function Ch(){return Pl=tf?Pl:Fv(kh),tf=!0,Pl}const Th=((...e)=>{Sh().render(...e)}),iw=((...e)=>{Ch().hydrate(...e)}),Cr=((...e)=>{const t=Sh().createApp(...e),{mount:s}=t;return t.mount=a=>{const n=Rh(a);if(!n)return;const i=t._component;!Ve(i)&&!i.render&&!i.template&&(i.template=n.innerHTML),n.nodeType===1&&(n.textContent="");const l=s(n,!1,Ah(n));return n instanceof Element&&(n.removeAttribute("v-cloak"),n.setAttribute("data-v-app","")),l},t}),Eh=((...e)=>{const t=Ch().createApp(...e),{mount:s}=t;return t.mount=a=>{const n=Rh(a);if(n)return s(n,!0,Ah(n))},t});function Ah(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Rh(e){return Ze(e)?document.querySelector(e):e}let sf=!1;const lw=()=>{sf||(sf=!0,X_(),E_())},ow=Object.freeze(Object.defineProperty({__proto__:null,BaseTransition:mv,BaseTransitionPropsValidators:nu,Comment:Zt,DeprecationTypes:y_,EffectScope:Kd,ErrorCodes:wx,ErrorTypeStrings:p_,Fragment:rs,KeepAlive:t0,ReactiveEffect:zl,Static:Xn,Suspense:W0,Teleport:$x,Text:On,TrackOpTypes:vx,Transition:k_,TransitionGroup:W_,TriggerOpTypes:hx,VueElement:ac,assertNumber:_x,callWithAsyncErrorHandling:Ys,callWithErrorHandling:tl,camelize:Tt,capitalize:ri,cloneVNode:Ma,compatUtils:b_,computed:B,createApp:Cr,createBlock:br,createCommentVNode:Jv,createElementBlock:X0,createElementVNode:mu,createHydrationRenderer:Fv,createPropsRestProxy:k0,createRenderer:$v,createSSRApp:Eh,createSlots:r0,createStaticVNode:s_,createTextVNode:vu,createVNode:Bt,customRef:Ym,defineAsyncComponent:Xx,defineComponent:fo,defineCustomElement:mh,defineEmits:f0,defineExpose:m0,defineModel:g0,defineOptions:v0,defineProps:p0,defineSSRCustomElement:z_,defineSlots:h0,devtools:f_,effect:Fy,effectScope:Dy,getCurrentInstance:Ds,getCurrentScope:Mm,getCurrentWatcher:gx,getTransitionRawChildren:Qr,guardReactiveProps:Kv,h:qi,handleError:ci,hasInjectionContext:Ix,hydrate:iw,hydrateOnIdle:Wx,hydrateOnInteraction:Qx,hydrateOnMediaQuery:Zx,hydrateOnVisible:Jx,initCustomFormatter:c_,initDirectivesForSSR:lw,inject:ra,isMemoSame:ah,isProxy:uo,isReactive:tn,isReadonly:Na,isRef:Xt,isRuntimeOnly:l_,isShallow:qs,isVNode:cn,markRaw:Zm,mergeDefaults:_0,mergeModels:w0,mergeProps:Zv,nextTick:Ut,nodeOps:lh,normalizeClass:co,normalizeProps:wy,normalizeStyle:ro,onActivated:cs,onBeforeMount:gv,onBeforeUnmount:ec,onBeforeUpdate:lu,onDeactivated:es,onErrorCaptured:_v,onMounted:st,onRenderTracked:xv,onRenderTriggered:yv,onScopeDispose:Py,onServerPrefetch:bv,onUnmounted:wt,onUpdated:Xr,onWatcherCleanup:ev,openBlock:Jl,patchProp:fh,popScopeId:Ex,provide:Nl,proxyRefs:eu,pushScopeId:Tx,queuePostFlushCb:ql,reactive:Mn,readonly:dr,ref:f,registerRuntimeCompiler:eh,render:Th,renderList:o0,renderSlot:c0,resolveComponent:n0,resolveDirective:l0,resolveDynamicComponent:i0,resolveFilter:g_,resolveTransitionHooks:ji,setBlockTracking:Zl,setDevtoolsHook:m_,setTransitionHooks:rn,shallowReactive:Yd,shallowReadonly:ax,shallowRef:Xd,ssrContextKey:lv,ssrUtils:h_,stop:By,toDisplayString:Lm,toHandlerKey:Mi,toHandlers:d0,toRaw:ft,toRef:px,toRefs:cx,toValue:lx,transformVNodeArgs:e_,triggerRef:ix,unref:La,useAttrs:x0,useCssModule:q_,useCssVars:A_,useHost:vh,useId:Bx,useModel:L0,useSSRContext:ov,useShadowRoot:j_,useSlots:y0,useTemplateRef:Ux,useTransitionState:au,vModelCheckbox:bu,vModelDynamic:_h,vModelRadio:yu,vModelSelect:yh,vModelText:Sr,vShow:uh,version:nh,warn:u_,watch:Gt,watchEffect:Ox,watchPostEffect:Lx,watchSyncEffect:rv,withAsyncContext:S0,withCtx:su,withDefaults:b0,withDirectives:Rx,withKeys:nw,withMemo:d_,withModifiers:sw,withScopeId:Ax},Symbol.toStringTag,{value:"Module"}));/** +* @vue/compiler-core v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const Yl=Symbol(""),$l=Symbol(""),xu=Symbol(""),Tr=Symbol(""),Ih=Symbol(""),ni=Symbol(""),Oh=Symbol(""),Lh=Symbol(""),_u=Symbol(""),wu=Symbol(""),ho=Symbol(""),ku=Symbol(""),Nh=Symbol(""),Su=Symbol(""),Cu=Symbol(""),Tu=Symbol(""),Eu=Symbol(""),Au=Symbol(""),Ru=Symbol(""),Mh=Symbol(""),Dh=Symbol(""),nc=Symbol(""),Er=Symbol(""),Iu=Symbol(""),Ou=Symbol(""),Xl=Symbol(""),go=Symbol(""),Lu=Symbol(""),wd=Symbol(""),rw=Symbol(""),kd=Symbol(""),Ar=Symbol(""),cw=Symbol(""),dw=Symbol(""),Nu=Symbol(""),uw=Symbol(""),pw=Symbol(""),Mu=Symbol(""),Ph=Symbol(""),Wi={[Yl]:"Fragment",[$l]:"Teleport",[xu]:"Suspense",[Tr]:"KeepAlive",[Ih]:"BaseTransition",[ni]:"openBlock",[Oh]:"createBlock",[Lh]:"createElementBlock",[_u]:"createVNode",[wu]:"createElementVNode",[ho]:"createCommentVNode",[ku]:"createTextVNode",[Nh]:"createStaticVNode",[Su]:"resolveComponent",[Cu]:"resolveDynamicComponent",[Tu]:"resolveDirective",[Eu]:"resolveFilter",[Au]:"withDirectives",[Ru]:"renderList",[Mh]:"renderSlot",[Dh]:"createSlots",[nc]:"toDisplayString",[Er]:"mergeProps",[Iu]:"normalizeClass",[Ou]:"normalizeStyle",[Xl]:"normalizeProps",[go]:"guardReactiveProps",[Lu]:"toHandlers",[wd]:"camelize",[rw]:"capitalize",[kd]:"toHandlerKey",[Ar]:"setBlockTracking",[cw]:"pushScopeId",[dw]:"popScopeId",[Nu]:"withCtx",[uw]:"unref",[pw]:"isRef",[Mu]:"withMemo",[Ph]:"isMemoSame"};function fw(e){Object.getOwnPropertySymbols(e).forEach(t=>{Wi[t]=e[t]})}const ea={start:{line:1,column:1,offset:0},end:{line:1,column:1,offset:0},source:""};function mw(e,t=""){return{type:0,source:t,children:e,helpers:new Set,components:[],directives:[],hoists:[],imports:[],cached:[],temps:0,codegenNode:void 0,loc:ea}}function eo(e,t,s,a,n,i,l,o=!1,r=!1,c=!1,d=ea){return e&&(o?(e.helper(ni),e.helper(Zi(e.inSSR,c))):e.helper(Ji(e.inSSR,c)),l&&e.helper(Au)),{type:13,tag:t,props:s,children:a,patchFlag:n,dynamicProps:i,directives:l,isBlock:o,disableTracking:r,isComponent:c,loc:d}}function ei(e,t=ea){return{type:17,loc:t,elements:e}}function oa(e,t=ea){return{type:15,loc:t,properties:e}}function Yt(e,t){return{type:16,loc:ea,key:Ze(e)?tt(e,!0):e,value:t}}function tt(e,t=!1,s=ea,a=0){return{type:4,loc:s,content:e,isStatic:t,constType:t?3:a}}function va(e,t=ea){return{type:8,loc:t,children:e}}function ns(e,t=[],s=ea){return{type:14,loc:s,callee:e,arguments:t}}function Ki(e,t=void 0,s=!1,a=!1,n=ea){return{type:18,params:e,returns:t,newline:s,isSlot:a,loc:n}}function Sd(e,t,s,a=!0){return{type:19,test:e,consequent:t,alternate:s,newline:a,loc:ea}}function vw(e,t,s=!1,a=!1){return{type:20,index:e,value:t,needPauseTracking:s,inVOnce:a,needArraySpread:!1,loc:ea}}function hw(e){return{type:21,body:e,loc:ea}}function Ji(e,t){return e||t?_u:wu}function Zi(e,t){return e||t?Oh:Lh}function Du(e,{helper:t,removeHelper:s,inSSR:a}){e.isBlock||(e.isBlock=!0,s(Ji(a,e.isComponent)),t(ni),t(Zi(a,e.isComponent)))}const af=new Uint8Array([123,123]),nf=new Uint8Array([125,125]);function lf(e){return e>=97&&e<=122||e>=65&&e<=90}function Zs(e){return e===32||e===10||e===9||e===12||e===13}function wn(e){return e===47||e===62||Zs(e)}function Rr(e){const t=new Uint8Array(e.length);for(let s=0;s100){let l=-1,o=n;for(;l+1>>1;this.newlines[r]=0;l--)if(t>this.newlines[l]){i=l;break}return i>=0&&(s=i+2,a=t-this.newlines[i]),{column:a,line:s,offset:t}}peek(){return this.buffer.charCodeAt(this.index+1)}stateText(t){t===60?(this.index>this.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=5,this.sectionStart=this.index):!this.inVPre&&t===this.delimiterOpen[0]&&(this.state=2,this.delimiterIndex=0,this.stateInterpolationOpen(t))}stateInterpolationOpen(t){if(t===this.delimiterOpen[this.delimiterIndex])if(this.delimiterIndex===this.delimiterOpen.length-1){const s=this.index+1-this.delimiterOpen.length;s>this.sectionStart&&this.cbs.ontext(this.sectionStart,s),this.state=3,this.sectionStart=s}else this.delimiterIndex++;else this.inRCDATA?(this.state=32,this.stateInRCDATA(t)):(this.state=1,this.stateText(t))}stateInterpolation(t){t===this.delimiterClose[0]&&(this.state=4,this.delimiterIndex=0,this.stateInterpolationClose(t))}stateInterpolationClose(t){t===this.delimiterClose[this.delimiterIndex]?this.delimiterIndex===this.delimiterClose.length-1?(this.cbs.oninterpolation(this.sectionStart,this.index+1),this.inRCDATA?this.state=32:this.state=1,this.sectionStart=this.index+1):this.delimiterIndex++:(this.state=3,this.stateInterpolation(t))}stateSpecialStartSequence(t){const s=this.sequenceIndex===this.currentSequence.length;if(!(s?wn(t):(t|32)===this.currentSequence[this.sequenceIndex]))this.inRCDATA=!1;else if(!s){this.sequenceIndex++;return}this.sequenceIndex=0,this.state=6,this.stateInTagName(t)}stateInRCDATA(t){if(this.sequenceIndex===this.currentSequence.length){if(t===62||Zs(t)){const s=this.index-this.currentSequence.length;if(this.sectionStart=t||(this.state===28?this.currentSequence===ys.CdataEnd?this.cbs.oncdata(this.sectionStart,t):this.cbs.oncomment(this.sectionStart,t):this.state===6||this.state===11||this.state===18||this.state===17||this.state===12||this.state===13||this.state===14||this.state===15||this.state===16||this.state===20||this.state===19||this.state===21||this.state===9||this.cbs.ontext(this.sectionStart,t))}emitCodePoint(t,s){}}function of(e,{compatConfig:t}){const s=t&&t[e];return e==="MODE"?s||3:s}function ti(e,t){const s=of("MODE",t),a=of(e,t);return s===3?a===!0:a!==!1}function to(e,t,s,...a){return ti(e,t)}function Pu(e){throw e}function $h(e){}function Mt(e,t,s,a){const n=`https://vuejs.org/error-reference/#compiler-${e}`,i=new SyntaxError(String(n));return i.code=e,i.loc=t,i}const js=e=>e.type===4&&e.isStatic;function Fh(e){switch(e){case"Teleport":case"teleport":return $l;case"Suspense":case"suspense":return xu;case"KeepAlive":case"keep-alive":return Tr;case"BaseTransition":case"base-transition":return Ih}}const bw=/^$|^\d|[^\$\w\xA0-\uFFFF]/,$u=e=>!bw.test(e),Bh=/[A-Za-z_$\xA0-\uFFFF]/,yw=/[\.\?\w$\xA0-\uFFFF]/,xw=/\s+[.[]\s*|\s*[.[]\s+/g,Uh=e=>e.type===4?e.content:e.loc.source,_w=e=>{const t=Uh(e).trim().replace(xw,o=>o.trim());let s=0,a=[],n=0,i=0,l=null;for(let o=0;o|^\s*(?:async\s+)?function(?:\s+[\w$]+)?\s*\(/,kw=e=>ww.test(Uh(e)),Sw=kw;function la(e,t,s=!1){for(let a=0;at.type===7&&t.name==="bind"&&(!t.arg||t.arg.type!==4||!t.arg.isStatic))}function zc(e){return e.type===5||e.type===2}function rf(e){return e.type===7&&e.name==="pre"}function Tw(e){return e.type===7&&e.name==="slot"}function Ir(e){return e.type===1&&e.tagType===3}function Or(e){return e.type===1&&e.tagType===2}const Ew=new Set([Xl,go]);function Hh(e,t=[]){if(e&&!Ze(e)&&e.type===14){const s=e.callee;if(!Ze(s)&&Ew.has(s))return Hh(e.arguments[0],t.concat(e))}return[e,t]}function Lr(e,t,s){let a,n=e.type===13?e.props:e.arguments[2],i=[],l;if(n&&!Ze(n)&&n.type===14){const o=Hh(n);n=o[0],i=o[1],l=i[i.length-1]}if(n==null||Ze(n))a=oa([t]);else if(n.type===14){const o=n.arguments[0];!Ze(o)&&o.type===15?cf(t,o)||o.properties.unshift(t):n.callee===Lu?a=ns(s.helper(Er),[oa([t]),n]):n.arguments.unshift(oa([t])),!a&&(a=n)}else n.type===15?(cf(t,n)||n.properties.unshift(t),a=n):(a=ns(s.helper(Er),[oa([t]),n]),l&&l.callee===go&&(l=i[i.length-2]));e.type===13?l?l.arguments[0]=a:e.props=a:l?l.arguments[0]=a:e.arguments[2]=a}function cf(e,t){let s=!1;if(e.key.type===4){const a=e.key.content;s=t.properties.some(n=>n.key.type===4&&n.key.content===a)}return s}function so(e,t){return`_${t}_${e.replace(/[^\w]/g,(s,a)=>s==="-"?"_":e.charCodeAt(a).toString())}`}function Aw(e){return e.type===14&&e.callee===Mu?e.arguments[1].returns:e}const Rw=/([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/;function jh(e){for(let t=0;t0,isVoidTag:Ri,isPreTag:Ri,isIgnoreNewlineTag:Ri,isCustomElement:Ri,onError:Pu,onWarn:$h,comments:!1,prefixIdentifiers:!1};let vt=Vh,ao=null,an="",_s=null,dt=null,Fs="",Va=-1,Vn=-1,Bu=0,An=!1,Cd=null;const Nt=[],Vt=new gw(Nt,{onerr:Ha,ontext(e,t){Fo(ps(e,t),e,t)},ontextentity(e,t,s){Fo(e,t,s)},oninterpolation(e,t){if(An)return Fo(ps(e,t),e,t);let s=e+Vt.delimiterOpen.length,a=t-Vt.delimiterClose.length;for(;Zs(an.charCodeAt(s));)s++;for(;Zs(an.charCodeAt(a-1));)a--;let n=ps(s,a);n.includes("&")&&(n=vt.decodeEntities(n,!1)),Td({type:5,content:ar(n,!1,Jt(s,a)),loc:Jt(e,t)})},onopentagname(e,t){const s=ps(e,t);_s={type:1,tag:s,ns:vt.getNamespace(s,Nt[0],vt.ns),tagType:0,props:[],children:[],loc:Jt(e-1,t),codegenNode:void 0}},onopentagend(e){uf(e)},onclosetag(e,t){const s=ps(e,t);if(!vt.isVoidTag(s)){let a=!1;for(let n=0;n0&&Ha(24,Nt[0].loc.start.offset);for(let l=0;l<=n;l++){const o=Nt.shift();sr(o,t,l(a.type===7?a.rawName:a.name)===s)&&Ha(2,t)},onattribend(e,t){if(_s&&dt){if(Jn(dt.loc,t),e!==0)if(Fs.includes("&")&&(Fs=vt.decodeEntities(Fs,!0)),dt.type===6)dt.name==="class"&&(Fs=Kh(Fs).trim()),e===1&&!Fs&&Ha(13,t),dt.value={type:2,content:Fs,loc:e===1?Jt(Va,Vn):Jt(Va-1,Vn+1)},Vt.inSFCRoot&&_s.tag==="template"&&dt.name==="lang"&&Fs&&Fs!=="html"&&Vt.enterRCDATA(Rr("n.content==="sync"))>-1&&to("COMPILER_V_BIND_SYNC",vt,dt.loc,dt.arg.loc.source)&&(dt.name="model",dt.modifiers.splice(a,1))}(dt.type!==7||dt.name!=="pre")&&_s.props.push(dt)}Fs="",Va=Vn=-1},oncomment(e,t){vt.comments&&Td({type:3,content:ps(e,t),loc:Jt(e-4,t+3)})},onend(){const e=an.length;for(let t=0;t{const h=t.start.offset+p,y=h+u.length;return ar(u,!1,Jt(h,y),0,m?1:0)},o={source:l(i.trim(),s.indexOf(i,n.length)),value:void 0,key:void 0,index:void 0,finalized:!1};let r=n.trim().replace(Iw,"").trim();const c=n.indexOf(r),d=r.match(df);if(d){r=r.replace(df,"").trim();const u=d[1].trim();let p;if(u&&(p=s.indexOf(u,c+r.length),o.key=l(u,p,!0)),d[2]){const m=d[2].trim();m&&(o.index=l(m,s.indexOf(m,o.key?p+u.length:c+r.length),!0))}}return r&&(o.value=l(r,c,!0)),o}function ps(e,t){return an.slice(e,t)}function uf(e){Vt.inSFCRoot&&(_s.innerLoc=Jt(e+1,e+1)),Td(_s);const{tag:t,ns:s}=_s;s===0&&vt.isPreTag(t)&&Bu++,vt.isVoidTag(t)?sr(_s,e):(Nt.unshift(_s),(s===1||s===2)&&(Vt.inXML=!0)),_s=null}function Fo(e,t,s){{const i=Nt[0]&&Nt[0].tag;i!=="script"&&i!=="style"&&e.includes("&")&&(e=vt.decodeEntities(e,!1))}const a=Nt[0]||ao,n=a.children[a.children.length-1];n&&n.type===2?(n.content+=e,Jn(n.loc,s)):a.children.push({type:2,content:e,loc:Jt(t,s)})}function sr(e,t,s=!1){s?Jn(e.loc,Gh(t,60)):Jn(e.loc,Lw(t,62)+1),Vt.inSFCRoot&&(e.children.length?e.innerLoc.end=lt({},e.children[e.children.length-1].loc.end):e.innerLoc.end=lt({},e.innerLoc.start),e.innerLoc.source=ps(e.innerLoc.start.offset,e.innerLoc.end.offset));const{tag:a,ns:n,children:i}=e;if(An||(a==="slot"?e.tagType=2:pf(e)?e.tagType=3:Mw(e)&&(e.tagType=1)),Vt.inRCDATA||(e.children=Wh(i)),n===0&&vt.isIgnoreNewlineTag(a)){const l=i[0];l&&l.type===2&&(l.content=l.content.replace(/^\r?\n/,""))}n===0&&vt.isPreTag(a)&&Bu--,Cd===e&&(An=Vt.inVPre=!1,Cd=null),Vt.inXML&&(Nt[0]?Nt[0].ns:vt.ns)===0&&(Vt.inXML=!1);{const l=e.props;if(!Vt.inSFCRoot&&ti("COMPILER_NATIVE_TEMPLATE",vt)&&e.tag==="template"&&!pf(e)){const r=Nt[0]||ao,c=r.children.indexOf(e);r.children.splice(c,1,...e.children)}const o=l.find(r=>r.type===6&&r.name==="inline-template");o&&to("COMPILER_INLINE_TEMPLATE",vt,o.loc)&&e.children.length&&(o.value={type:2,content:ps(e.children[0].loc.start.offset,e.children[e.children.length-1].loc.end.offset),loc:o.loc})}}function Lw(e,t){let s=e;for(;an.charCodeAt(s)!==t&&s=0;)s--;return s}const Nw=new Set(["if","else","else-if","for","slot"]);function pf({tag:e,props:t}){if(e==="template"){for(let s=0;s64&&e<91}const Pw=/\r\n/g;function Wh(e){const t=vt.whitespace!=="preserve";let s=!1;for(let a=0;as.type!==3);return t.length===1&&t[0].type===1&&!Or(t[0])?t[0]:null}function nr(e,t,s,a=!1,n=!1){const{children:i}=e,l=[];for(let d=0;d0){if(p>=2){u.codegenNode.patchFlag=-1,l.push(u);continue}}else{const m=u.codegenNode;if(m.type===13){const h=m.patchFlag;if((h===void 0||h===512||h===1)&&Qh(u,s)>=2){const y=Yh(u);y&&(m.props=s.hoist(y))}m.dynamicProps&&(m.dynamicProps=s.hoist(m.dynamicProps))}}}else if(u.type===12&&(a?0:Qs(u,s))>=2){u.codegenNode.type===14&&u.codegenNode.arguments.length>0&&u.codegenNode.arguments.push("-1"),l.push(u);continue}if(u.type===1){const p=u.tagType===1;p&&s.scopes.vSlot++,nr(u,e,s,!1,n),p&&s.scopes.vSlot--}else if(u.type===11)nr(u,e,s,u.children.length===1,!0);else if(u.type===9)for(let p=0;pm.key===u||m.key.content===u);return p&&p.value}}l.length&&s.transformHoist&&s.transformHoist(i,s,e)}function Qs(e,t){const{constantCache:s}=t;switch(e.type){case 1:if(e.tagType!==0)return 0;const a=s.get(e);if(a!==void 0)return a;const n=e.codegenNode;if(n.type!==13||n.isBlock&&e.tag!=="svg"&&e.tag!=="foreignObject"&&e.tag!=="math")return 0;if(n.patchFlag===void 0){let l=3;const o=Qh(e,t);if(o===0)return s.set(e,0),0;o1)for(let r=0;rO&&(L.childIndex--,L.onNodeRemoved()),L.parent.children.splice(O,1)},onNodeRemoved:vs,addIdentifiers(E){},removeIdentifiers(E){},hoist(E){Ze(E)&&(E=tt(E)),L.hoists.push(E);const F=tt(`_hoisted_${L.hoists.length}`,!1,E.loc,2);return F.hoisted=E,F},cache(E,F=!1,O=!1){const I=vw(L.cached.length,E,F,O);return L.cached.push(I),I}};return L.filters=new Set,L}function Vw(e,t){const s=qw(e,t);lc(e,s),t.hoistStatic&&Hw(e,s),t.ssr||Gw(e,s),e.helpers=new Set([...s.helpers.keys()]),e.components=[...s.components],e.directives=[...s.directives],e.imports=s.imports,e.hoists=s.hoists,e.temps=s.temps,e.cached=s.cached,e.transformed=!0,e.filters=[...s.filters]}function Gw(e,t){const{helper:s}=t,{children:a}=e;if(a.length===1){const n=Jh(e);if(n&&n.codegenNode){const i=n.codegenNode;i.type===13&&Du(i,t),e.codegenNode=i}else e.codegenNode=a[0]}else if(a.length>1){let n=64;e.codegenNode=eo(t,s(Yl),void 0,e.children,n,void 0,void 0,!0,void 0,!1)}}function Ww(e,t){let s=0;const a=()=>{s--};for(;sa===e:a=>e.test(a);return(a,n)=>{if(a.type===1){const{props:i}=a;if(a.tagType===3&&i.some(Tw))return;const l=[];for(let o=0;o`${Wi[e]}: _${Wi[e]}`;function Kw(e,{mode:t="function",prefixIdentifiers:s=t==="module",sourceMap:a=!1,filename:n="template.vue.html",scopeId:i=null,optimizeImports:l=!1,runtimeGlobalName:o="Vue",runtimeModuleName:r="vue",ssrRuntimeModuleName:c="vue/server-renderer",ssr:d=!1,isTS:u=!1,inSSR:p=!1}){const m={mode:t,prefixIdentifiers:s,sourceMap:a,filename:n,scopeId:i,optimizeImports:l,runtimeGlobalName:o,runtimeModuleName:r,ssrRuntimeModuleName:c,ssr:d,isTS:u,inSSR:p,source:e.source,code:"",column:1,line:1,offset:0,indentLevel:0,pure:!1,map:void 0,helper(y){return`_${Wi[y]}`},push(y,k=-2,A){m.code+=y},indent(){h(++m.indentLevel)},deindent(y=!1){y?--m.indentLevel:h(--m.indentLevel)},newline(){h(m.indentLevel)}};function h(y){m.push(` +`+" ".repeat(y),0)}return m}function Jw(e,t={}){const s=Kw(e,t);t.onContextCreated&&t.onContextCreated(s);const{mode:a,push:n,prefixIdentifiers:i,indent:l,deindent:o,newline:r,scopeId:c,ssr:d}=s,u=Array.from(e.helpers),p=u.length>0,m=!i&&a!=="module";Zw(e,s);const y=d?"ssrRender":"render",A=(d?["_ctx","_push","_parent","_attrs"]:["_ctx","_cache"]).join(", ");if(n(`function ${y}(${A}) {`),l(),m&&(n("with (_ctx) {"),l(),p&&(n(`const { ${u.map(eg).join(", ")} } = _Vue +`,-1),r())),e.components.length&&(Hc(e.components,"component",s),(e.directives.length||e.temps>0)&&r()),e.directives.length&&(Hc(e.directives,"directive",s),e.temps>0&&r()),e.filters&&e.filters.length&&(r(),Hc(e.filters,"filter",s),r()),e.temps>0){n("let ");for(let g=0;g0?", ":""}_temp${g}`)}return(e.components.length||e.directives.length||e.temps)&&(n(` +`,0),r()),d||n("return "),e.codegenNode?Ss(e.codegenNode,s):n("null"),m&&(o(),n("}")),o(),n("}"),{ast:e,code:s.code,preamble:"",map:s.map?s.map.toJSON():void 0}}function Zw(e,t){const{ssr:s,prefixIdentifiers:a,push:n,newline:i,runtimeModuleName:l,runtimeGlobalName:o,ssrRuntimeModuleName:r}=t,c=o,d=Array.from(e.helpers);if(d.length>0&&(n(`const _Vue = ${c} +`,-1),e.hoists.length)){const u=[_u,wu,ho,ku,Nh].filter(p=>d.includes(p)).map(eg).join(", ");n(`const { ${u} } = _Vue +`,-1)}Qw(e.hoists,t),i(),n("return ")}function Hc(e,t,{helper:s,push:a,newline:n,isTS:i}){const l=s(t==="filter"?Eu:t==="component"?Su:Tu);for(let o=0;o3||!1;t.push("["),s&&t.indent(),bo(e,t,s),s&&t.deindent(),t.push("]")}function bo(e,t,s=!1,a=!0){const{push:n,newline:i}=t;for(let l=0;ls||"null")}function nk(e,t){const{push:s,helper:a,pure:n}=t,i=Ze(e.callee)?e.callee:a(e.callee);n&&s(oc),s(i+"(",-2,e),bo(e.arguments,t),s(")")}function ik(e,t){const{push:s,indent:a,deindent:n,newline:i}=t,{properties:l}=e;if(!l.length){s("{}",-2,e);return}const o=l.length>1||!1;s(o?"{":"{ "),o&&a();for(let r=0;r "),(r||o)&&(s("{"),a()),l?(r&&s("return "),Me(l)?Uu(l,t):Ss(l,t)):o&&Ss(o,t),(r||o)&&(n(),s("}")),c&&(e.isNonScopedSlot&&s(", undefined, true"),s(")"))}function rk(e,t){const{test:s,consequent:a,alternate:n,newline:i}=e,{push:l,indent:o,deindent:r,newline:c}=t;if(s.type===4){const u=!$u(s.content);u&&l("("),tg(s,t),u&&l(")")}else l("("),Ss(s,t),l(")");i&&o(),t.indentLevel++,i||l(" "),l("? "),Ss(a,t),t.indentLevel--,i&&c(),i||l(" "),l(": ");const d=n.type===19;d||t.indentLevel++,Ss(n,t),d||t.indentLevel--,i&&r(!0)}function ck(e,t){const{push:s,helper:a,indent:n,deindent:i,newline:l}=t,{needPauseTracking:o,needArraySpread:r}=e;r&&s("[...("),s(`_cache[${e.index}] || (`),o&&(n(),s(`${a(Ar)}(-1`),e.inVOnce&&s(", true"),s("),"),l(),s("(")),s(`_cache[${e.index}] = `),Ss(e.value,t),o&&(s(`).cacheIndex = ${e.index},`),l(),s(`${a(Ar)}(1),`),l(),s(`_cache[${e.index}]`),i()),s(")"),r&&s(")]")}new RegExp("\\b"+"arguments,await,break,case,catch,class,const,continue,debugger,default,delete,do,else,export,extends,finally,for,function,if,import,let,new,return,super,switch,throw,try,var,void,while,with,yield".split(",").join("\\b|\\b")+"\\b");const dk=Xh(/^(?:if|else|else-if)$/,(e,t,s)=>uk(e,t,s,(a,n,i)=>{const l=s.parent.children;let o=l.indexOf(a),r=0;for(;o-->=0;){const c=l[o];c&&c.type===9&&(r+=c.branches.length)}return()=>{if(i)a.codegenNode=mf(n,r,s);else{const c=pk(a.codegenNode);c.alternate=mf(n,r+a.branches.length-1,s)}}}));function uk(e,t,s,a){if(t.name!=="else"&&(!t.exp||!t.exp.content.trim())){const n=t.exp?t.exp.loc:e.loc;s.onError(Mt(28,t.loc)),t.exp=tt("true",!1,n)}if(t.name==="if"){const n=ff(e,t),i={type:9,loc:Fw(e.loc),branches:[n]};if(s.replaceNode(i),a)return a(i,n,!0)}else{const n=s.parent.children;let i=n.indexOf(e);for(;i-->=-1;){const l=n[i];if(l&&qh(l)){s.removeNode(l);continue}if(l&&l.type===9){(t.name==="else-if"||t.name==="else")&&l.branches[l.branches.length-1].condition===void 0&&s.onError(Mt(30,e.loc)),s.removeNode();const o=ff(e,t);l.branches.push(o);const r=a&&a(l,o,!1);lc(o,s),r&&r(),s.currentNode=null}else s.onError(Mt(30,e.loc));break}}}function ff(e,t){const s=e.tagType===3;return{type:10,loc:e.loc,condition:t.name==="else"?void 0:t.exp,children:s&&!la(e,"for")?e.children:[e],userKey:ic(e,"key"),isTemplateIf:s}}function mf(e,t,s){return e.condition?Sd(e.condition,vf(e,t,s),ns(s.helper(ho),['""',"true"])):vf(e,t,s)}function vf(e,t,s){const{helper:a}=s,n=Yt("key",tt(`${t}`,!1,ea,2)),{children:i}=e,l=i[0];if(i.length!==1||l.type!==1)if(i.length===1&&l.type===11){const r=l.codegenNode;return Lr(r,n,s),r}else return eo(s,a(Yl),oa([n]),i,64,void 0,void 0,!0,!1,!1,e.loc);else{const r=l.codegenNode,c=Aw(r);return c.type===13&&Du(c,s),Lr(c,n,s),r}}function pk(e){for(;;)if(e.type===19)if(e.alternate.type===19)e=e.alternate;else return e;else e.type===20&&(e=e.value)}const fk=Xh("for",(e,t,s)=>{const{helper:a,removeHelper:n}=s;return mk(e,t,s,i=>{const l=ns(a(Ru),[i.source]),o=Ir(e),r=la(e,"memo"),c=ic(e,"key",!1,!0);c&&c.type;let d=c&&(c.type===6?c.value?tt(c.value.content,!0):void 0:c.exp);const u=d?Yt("key",d):null,p=i.source.type===4&&i.source.constType>0,m=p?64:c?128:256;return i.codegenNode=eo(s,a(Yl),void 0,l,m,void 0,void 0,!0,!p,!1,e.loc),()=>{let h;const{children:y}=i,k=y.length!==1||y[0].type!==1,A=Or(e)?e:o&&e.children.length===1&&Or(e.children[0])?e.children[0]:null;if(A?(h=A.codegenNode,o&&u&&Lr(h,u,s)):k?h=eo(s,a(Yl),u?oa([u]):void 0,e.children,64,void 0,void 0,!0,void 0,!1):(h=y[0].codegenNode,o&&u&&Lr(h,u,s),h.isBlock!==!p&&(h.isBlock?(n(ni),n(Zi(s.inSSR,h.isComponent))):n(Ji(s.inSSR,h.isComponent))),h.isBlock=!p,h.isBlock?(a(ni),a(Zi(s.inSSR,h.isComponent))):a(Ji(s.inSSR,h.isComponent))),r){const g=Ki(Ed(i.parseResult,[tt("_cached")]));g.body=hw([va(["const _memo = (",r.exp,")"]),va(["if (_cached && _cached.el",...d?[" && _cached.key === ",d]:[],` && ${s.helperString(Ph)}(_cached, _memo)) return _cached`]),va(["const _item = ",h]),tt("_item.memo = _memo"),tt("return _item")]),l.arguments.push(g,tt("_cache"),tt(String(s.cached.length))),s.cached.push(null)}else l.arguments.push(Ki(Ed(i.parseResult),h,!0))}})});function mk(e,t,s,a){if(!t.exp){s.onError(Mt(31,t.loc));return}const n=t.forParseResult;if(!n){s.onError(Mt(32,t.loc));return}ag(n);const{addIdentifiers:i,removeIdentifiers:l,scopes:o}=s,{source:r,value:c,key:d,index:u}=n,p={type:11,loc:t.loc,source:r,valueAlias:c,keyAlias:d,objectIndexAlias:u,parseResult:n,children:Ir(e)?e.children:[e]};s.replaceNode(p),o.vFor++;const m=a&&a(p);return()=>{o.vFor--,m&&m()}}function ag(e,t){e.finalized||(e.finalized=!0)}function Ed({value:e,key:t,index:s},a=[]){return vk([e,t,s,...a])}function vk(e){let t=e.length;for(;t--&&!e[t];);return e.slice(0,t+1).map((s,a)=>s||tt("_".repeat(a+1),!1))}const hf=tt("undefined",!1),hk=(e,t)=>{if(e.type===1&&(e.tagType===1||e.tagType===3)){const s=la(e,"slot");if(s)return s.exp,t.scopes.vSlot++,()=>{t.scopes.vSlot--}}},gk=(e,t,s,a)=>Ki(e,s,!1,!0,s.length?s[0].loc:a);function bk(e,t,s=gk){t.helper(Nu);const{children:a,loc:n}=e,i=[],l=[];let o=t.scopes.vSlot>0||t.scopes.vFor>0;const r=la(e,"slot",!0);if(r){const{arg:k,exp:A}=r;k&&!js(k)&&(o=!0),i.push(Yt(k||tt("default",!0),s(A,void 0,a,n)))}let c=!1,d=!1;const u=[],p=new Set;let m=0;for(let k=0;k{const b=s(A,void 0,g,n);return t.compatConfig&&(b.isNonScopedSlot=!0),Yt("default",b)};c?u.length&&!u.every(Fu)&&(d?t.onError(Mt(39,u[0].loc)):i.push(k(void 0,u))):i.push(k(void 0,a))}const h=o?2:ir(e.children)?3:1;let y=oa(i.concat(Yt("_",tt(h+"",!1))),n);return l.length&&(y=ns(t.helper(Dh),[y,ei(l)])),{slots:y,hasDynamicSlots:o}}function Bo(e,t,s){const a=[Yt("name",e),Yt("fn",t)];return s!=null&&a.push(Yt("key",tt(String(s),!0))),oa(a)}function ir(e){for(let t=0;tfunction(){if(e=t.currentNode,!(e.type===1&&(e.tagType===0||e.tagType===1)))return;const{tag:a,props:n}=e,i=e.tagType===1;let l=i?xk(e,t):`"${a}"`;const o=ht(l)&&l.callee===Cu;let r,c,d=0,u,p,m,h=o||l===$l||l===xu||!i&&(a==="svg"||a==="foreignObject"||a==="math");if(n.length>0){const y=ig(e,t,void 0,i,o);r=y.props,d=y.patchFlag,p=y.dynamicPropNames;const k=y.directives;m=k&&k.length?ei(k.map(A=>wk(A,t))):void 0,y.shouldUseBlock&&(h=!0)}if(e.children.length>0)if(l===Tr&&(h=!0,d|=1024),i&&l!==$l&&l!==Tr){const{slots:k,hasDynamicSlots:A}=bk(e,t);c=k,A&&(d|=1024)}else if(e.children.length===1&&l!==$l){const k=e.children[0],A=k.type,g=A===5||A===8;g&&Qs(k,t)===0&&(d|=1),g||A===2?c=k:c=e.children}else c=e.children;p&&p.length&&(u=kk(p)),e.codegenNode=eo(t,l,r,c,d===0?void 0:d,u,m,!!h,!1,i,e.loc)};function xk(e,t,s=!1){let{tag:a}=e;const n=Ad(a),i=ic(e,"is",!1,!0);if(i)if(n||ti("COMPILER_IS_ON_ELEMENT",t)){let o;if(i.type===6?o=i.value&&tt(i.value.content,!0):(o=i.exp,o||(o=tt("is",!1,i.arg.loc))),o)return ns(t.helper(Cu),[o])}else i.type===6&&i.value.content.startsWith("vue:")&&(a=i.value.content.slice(4));const l=Fh(a)||t.isBuiltInComponent(a);return l?(s||t.helper(l),l):(t.helper(Su),t.components.add(a),so(a,"component"))}function ig(e,t,s=e.props,a,n,i=!1){const{tag:l,loc:o,children:r}=e;let c=[];const d=[],u=[],p=r.length>0;let m=!1,h=0,y=!1,k=!1,A=!1,g=!1,b=!1,x=!1;const w=[],_=F=>{c.length&&(d.push(oa(gf(c),o)),c=[]),F&&d.push(F)},T=()=>{t.scopes.vFor>0&&c.push(Yt(tt("ref_for",!0),tt("true")))},L=({key:F,value:O})=>{if(js(F)){const I=F.content,H=li(I);if(H&&(!a||n)&&I.toLowerCase()!=="onclick"&&I!=="onUpdate:modelValue"&&!en(I)&&(g=!0),H&&en(I)&&(x=!0),H&&O.type===14&&(O=O.arguments[0]),O.type===20||(O.type===4||O.type===8)&&Qs(O,t)>0)return;I==="ref"?y=!0:I==="class"?k=!0:I==="style"?A=!0:I!=="key"&&!w.includes(I)&&w.push(I),a&&(I==="class"||I==="style")&&!w.includes(I)&&w.push(I)}else b=!0};for(let F=0;Fce.content==="prop")&&(h|=32);const re=t.directiveTransforms[I];if(re){const{props:ce,needRuntime:Z}=re(O,e,t);!i&&ce.forEach(L),$&&H&&!js(H)?_(oa(ce,o)):c.push(...ce),Z&&(u.push(O),Cs(Z)&&ng.set(O,Z))}else fy(I)||(u.push(O),p&&(m=!0))}}let E;if(d.length?(_(),d.length>1?E=ns(t.helper(Er),d,o):E=d[0]):c.length&&(E=oa(gf(c),o)),b?h|=16:(k&&!a&&(h|=2),A&&!a&&(h|=4),w.length&&(h|=8),g&&(h|=32)),!m&&(h===0||h===32)&&(y||x||u.length>0)&&(h|=512),!t.inSSR&&E)switch(E.type){case 15:let F=-1,O=-1,I=!1;for(let ee=0;eeYt(l,i)),n))}return ei(s,e.loc)}function kk(e){let t="[";for(let s=0,a=e.length;s{if(Or(e)){const{children:s,loc:a}=e,{slotName:n,slotProps:i}=Ck(e,t),l=[t.prefixIdentifiers?"_ctx.$slots":"$slots",n,"{}","undefined","true"];let o=2;i&&(l[2]=i,o=3),s.length&&(l[3]=Ki([],s,!1,!1,a),o=4),t.scopeId&&!t.slotted&&(o=5),l.splice(o),e.codegenNode=ns(t.helper(Mh),l,a)}};function Ck(e,t){let s='"default"',a;const n=[];for(let i=0;i0){const{props:i,directives:l}=ig(e,t,n,!1,!1);a=i,l.length&&t.onError(Mt(36,l[0].loc))}return{slotName:s,slotProps:a}}const lg=(e,t,s,a)=>{const{loc:n,modifiers:i,arg:l}=e;!e.exp&&!i.length&&s.onError(Mt(35,n));let o;if(l.type===4)if(l.isStatic){let u=l.content;u.startsWith("vue:")&&(u=`vnode-${u.slice(4)}`);const p=t.tagType!==0||u.startsWith("vnode")||!/[A-Z]/.test(u)?Mi(Tt(u)):`on:${u}`;o=tt(p,!0,l.loc)}else o=va([`${s.helperString(kd)}(`,l,")"]);else o=l,o.children.unshift(`${s.helperString(kd)}(`),o.children.push(")");let r=e.exp;r&&!r.content.trim()&&(r=void 0);let c=s.cacheHandlers&&!r&&!s.inVOnce;if(r){const u=zh(r),p=!(u||Sw(r)),m=r.content.includes(";");(p||c&&u)&&(r=va([`${p?"$event":"(...args)"} => ${m?"{":"("}`,r,m?"}":")"]))}let d={props:[Yt(o,r||tt("() => {}",!1,n))]};return a&&(d=a(d)),c&&(d.props[0].value=s.cache(d.props[0].value)),d.props.forEach(u=>u.key.isHandlerKey=!0),d},Tk=(e,t,s)=>{const{modifiers:a,loc:n}=e,i=e.arg;let{exp:l}=e;return l&&l.type===4&&!l.content.trim()&&(l=void 0),i.type!==4?(i.children.unshift("("),i.children.push(') || ""')):i.isStatic||(i.content=i.content?`${i.content} || ""`:'""'),a.some(o=>o.content==="camel")&&(i.type===4?i.isStatic?i.content=Tt(i.content):i.content=`${s.helperString(wd)}(${i.content})`:(i.children.unshift(`${s.helperString(wd)}(`),i.children.push(")"))),s.inSSR||(a.some(o=>o.content==="prop")&&bf(i,"."),a.some(o=>o.content==="attr")&&bf(i,"^")),{props:[Yt(i,l)]}},bf=(e,t)=>{e.type===4?e.isStatic?e.content=t+e.content:e.content=`\`${t}\${${e.content}}\``:(e.children.unshift(`'${t}' + (`),e.children.push(")"))},Ek=(e,t)=>{if(e.type===0||e.type===1||e.type===11||e.type===10)return()=>{const s=e.children;let a,n=!1;for(let i=0;ii.type===7&&!t.directiveTransforms[i.name])&&e.tag!=="template")))for(let i=0;i{if(e.type===1&&la(e,"once",!0))return yf.has(e)||t.inVOnce||t.inSSR?void 0:(yf.add(e),t.inVOnce=!0,t.helper(Ar),()=>{t.inVOnce=!1;const s=t.currentNode;s.codegenNode&&(s.codegenNode=t.cache(s.codegenNode,!0,!0))})},og=(e,t,s)=>{const{exp:a,arg:n}=e;if(!a)return s.onError(Mt(41,e.loc)),vl();const i=a.loc.source.trim(),l=a.type===4?a.content:i,o=s.bindingMetadata[i];if(o==="props"||o==="props-aliased")return s.onError(Mt(44,a.loc)),vl();if(o==="literal-const"||o==="setup-const")return s.onError(Mt(45,a.loc)),vl();if(!l.trim()||!zh(a))return s.onError(Mt(42,a.loc)),vl();const r=n||tt("modelValue",!0),c=n?js(n)?`onUpdate:${Tt(n.content)}`:va(['"onUpdate:" + ',n]):"onUpdate:modelValue";let d;const u=s.isTS?"($event: any)":"$event";d=va([`${u} => ((`,a,") = $event)"]);const p=[Yt(r,e.exp),Yt(c,d)];if(e.modifiers.length&&t.tagType===1){const m=e.modifiers.map(y=>y.content).map(y=>($u(y)?y:JSON.stringify(y))+": true").join(", "),h=n?js(n)?`${n.content}Modifiers`:va([n,' + "Modifiers"']):"modelModifiers";p.push(Yt(h,tt(`{ ${m} }`,!1,e.loc,2)))}return vl(p)};function vl(e=[]){return{props:e}}const Rk=/[\w).+\-_$\]]/,Ik=(e,t)=>{ti("COMPILER_FILTERS",t)&&(e.type===5?Nr(e.content,t):e.type===1&&e.props.forEach(s=>{s.type===7&&s.name!=="for"&&s.exp&&Nr(s.exp,t)}))};function Nr(e,t){if(e.type===4)xf(e,t);else for(let s=0;s=0&&(g=s.charAt(A),g===" ");A--);(!g||!Rk.test(g))&&(l=!0)}}h===void 0?h=s.slice(0,m).trim():d!==0&&k();function k(){y.push(s.slice(d,m).trim()),d=m+1}if(y.length){for(m=0;m{if(e.type===1){const s=la(e,"memo");return!s||_f.has(e)||t.inSSR?void 0:(_f.add(e),()=>{const a=e.codegenNode||t.currentNode.codegenNode;a&&a.type===13&&(e.tagType!==1&&Du(a,t),e.codegenNode=ns(t.helper(Mu),[s.exp,Ki(void 0,a),"_cache",String(t.cached.length)]),t.cached.push(null))})}},Nk=(e,t)=>{if(e.type===1){for(const s of e.props)if(s.type===7&&s.name==="bind"&&(!s.exp||s.exp.type===4&&!s.exp.content.trim())&&s.arg){const a=s.arg;if(a.type!==4||!a.isStatic)t.onError(Mt(53,a.loc)),s.exp=tt("",!0,a.loc);else{const n=Tt(a.content);(Bh.test(n[0])||n[0]==="-")&&(s.exp=tt(n,!1,a.loc))}}}};function Mk(e){return[[Nk,Ak,dk,Lk,fk,Ik,Sk,yk,hk,Ek],{on:lg,bind:Tk,model:og}]}function Dk(e,t={}){const s=t.onError||Pu,a=t.mode==="module";t.prefixIdentifiers===!0?s(Mt(48)):a&&s(Mt(49));const n=!1;t.cacheHandlers&&s(Mt(50)),t.scopeId&&!a&&s(Mt(51));const i=lt({},t,{prefixIdentifiers:n}),l=Ze(e)?zw(e,i):e,[o,r]=Mk();return Vw(l,lt({},i,{nodeTransforms:[...o,...t.nodeTransforms||[]],directiveTransforms:lt({},r,t.directiveTransforms||{})})),Jw(l,i)}const Pk=()=>({props:[]});/** +* @vue/compiler-dom v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const rg=Symbol(""),cg=Symbol(""),dg=Symbol(""),ug=Symbol(""),Rd=Symbol(""),pg=Symbol(""),fg=Symbol(""),mg=Symbol(""),vg=Symbol(""),hg=Symbol("");fw({[rg]:"vModelRadio",[cg]:"vModelCheckbox",[dg]:"vModelText",[ug]:"vModelSelect",[Rd]:"vModelDynamic",[pg]:"withModifiers",[fg]:"withKeys",[mg]:"vShow",[vg]:"Transition",[hg]:"TransitionGroup"});let _i;function $k(e,t=!1){return _i||(_i=document.createElement("div")),t?(_i.innerHTML=`
    `,_i.children[0].getAttribute("foo")):(_i.innerHTML=e,_i.textContent)}const Fk={parseMode:"html",isVoidTag:Iy,isNativeTag:e=>Ey(e)||Ay(e)||Ry(e),isPreTag:e=>e==="pre",isIgnoreNewlineTag:e=>e==="pre"||e==="textarea",decodeEntities:$k,isBuiltInComponent:e=>{if(e==="Transition"||e==="transition")return vg;if(e==="TransitionGroup"||e==="transition-group")return hg},getNamespace(e,t,s){let a=t?t.ns:s;if(t&&a===2)if(t.tag==="annotation-xml"){if(e==="svg")return 1;t.props.some(n=>n.type===6&&n.name==="encoding"&&n.value!=null&&(n.value.content==="text/html"||n.value.content==="application/xhtml+xml"))&&(a=0)}else/^m(?:[ions]|text)$/.test(t.tag)&&e!=="mglyph"&&e!=="malignmark"&&(a=0);else t&&a===1&&(t.tag==="foreignObject"||t.tag==="desc"||t.tag==="title")&&(a=0);if(a===0){if(e==="svg")return 1;if(e==="math")return 2}return a}},Bk=e=>{e.type===1&&e.props.forEach((t,s)=>{t.type===6&&t.name==="style"&&t.value&&(e.props[s]={type:7,name:"bind",arg:tt("style",!0,t.loc),exp:Uk(t.value.content,t.loc),modifiers:[],loc:t.loc})})},Uk=(e,t)=>{const s=Rm(e);return tt(JSON.stringify(s),!1,t,3)};function Ln(e,t){return Mt(e,t)}const zk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Ln(54,n)),t.children.length&&(s.onError(Ln(55,n)),t.children.length=0),{props:[Yt(tt("innerHTML",!0,n),a||tt("",!0))]}},Hk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Ln(56,n)),t.children.length&&(s.onError(Ln(57,n)),t.children.length=0),{props:[Yt(tt("textContent",!0),a?Qs(a,s)>0?a:ns(s.helperString(nc),[a],n):tt("",!0))]}},jk=(e,t,s)=>{const a=og(e,t,s);if(!a.props.length||t.tagType===1)return a;e.arg&&s.onError(Ln(59,e.arg.loc));const{tag:n}=t,i=s.isCustomElement(n);if(n==="input"||n==="textarea"||n==="select"||i){let l=dg,o=!1;if(n==="input"||i){const r=ic(t,"type");if(r){if(r.type===7)l=Rd;else if(r.value)switch(r.value.content){case"radio":l=rg;break;case"checkbox":l=cg;break;case"file":o=!0,s.onError(Ln(60,e.loc));break}}else Cw(t)&&(l=Rd)}else n==="select"&&(l=ug);o||(a.needRuntime=s.helper(l))}else s.onError(Ln(58,e.loc));return a.props=a.props.filter(l=>!(l.key.type===4&&l.key.content==="modelValue")),a},qk=Xs("passive,once,capture"),Vk=Xs("stop,prevent,self,ctrl,shift,alt,meta,exact,middle"),Gk=Xs("left,right"),gg=Xs("onkeyup,onkeydown,onkeypress"),Wk=(e,t,s,a)=>{const n=[],i=[],l=[];for(let o=0;ojs(e)&&e.content.toLowerCase()==="onclick"?tt(t,!0):e.type!==4?va(["(",e,`) === "onClick" ? "${t}" : (`,e,")"]):e,Kk=(e,t,s)=>lg(e,t,s,a=>{const{modifiers:n}=e;if(!n.length)return a;let{key:i,value:l}=a.props[0];const{keyModifiers:o,nonKeyModifiers:r,eventOptionModifiers:c}=Wk(i,n,s,e.loc);if(r.includes("right")&&(i=wf(i,"onContextmenu")),r.includes("middle")&&(i=wf(i,"onMouseup")),r.length&&(l=ns(s.helper(pg),[l,JSON.stringify(r)])),o.length&&(!js(i)||gg(i.content.toLowerCase()))&&(l=ns(s.helper(fg),[l,JSON.stringify(o)])),c.length){const d=c.map(ri).join("");i=js(i)?tt(`${i.content}${d}`,!0):va(["(",i,`) + "${d}"`])}return{props:[Yt(i,l)]}}),Jk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Ln(62,n)),{props:[],needRuntime:s.helper(mg)}},Zk=(e,t)=>{e.type===1&&e.tagType===0&&(e.tag==="script"||e.tag==="style")&&t.removeNode()},Qk=[Bk],Yk={cloak:Pk,html:zk,text:Hk,model:jk,on:Kk,show:Jk};function Xk(e,t={}){return Dk(e,lt({},Fk,t,{nodeTransforms:[Zk,...Qk,...t.nodeTransforms||[]],directiveTransforms:lt({},Yk,t.directiveTransforms||{}),transformHoist:null}))}/** +* vue v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const kf=Object.create(null);function eS(e,t){if(!Ze(e))if(e.nodeType)e=e.innerHTML;else return vs;const s=hy(e,t),a=kf[s];if(a)return a;if(e[0]==="#"){const o=document.querySelector(e);e=o?o.innerHTML:""}const n=lt({hoistStatic:!0,onError:void 0,onWarn:vs},t);!n.isCustomElement&&typeof customElements<"u"&&(n.isCustomElement=o=>!!customElements.get(o));const{code:i}=Xk(e,n),l=new Function("Vue",i)(ow);return l._rc=!0,kf[s]=l}eh(eS);const Mr=Mn({items:[]});let tS=1;function rc(e,t="info",s=3e3){const a=tS++;return Mr.items.push({id:a,message:String(e),type:t}),s>0&&setTimeout(()=>zu(a),s),a}function zu(e){const t=Mr.items.findIndex(s=>s.id===e);t>=0&&Mr.items.splice(t,1)}function Se(e,t="info",s=3e3){return rc(e,t,s)}Se.success=(e,t=3e3)=>rc(e,"success",t);Se.error=(e,t=5e3)=>rc(e,"error",t);Se.info=(e,t=3e3)=>rc(e,"info",t);Se.dismiss=zu;const sS={setup(){return{state:Mr,dismiss:zu}},template:` +
    + +
    + + {{ t.message }} +
    +
    +
    + `},Ka=Mn({open:!1,title:"Confirm",message:"",confirmLabel:"Confirm",cancelLabel:"Cancel",danger:!1});let Ui=null;function is({title:e="Confirm",message:t="",confirmLabel:s="Confirm",cancelLabel:a="Cancel",danger:n=!1}={}){return Ui&&Ui(!1),Ka.title=e,Ka.message=t,Ka.confirmLabel=s,Ka.cancelLabel=a,Ka.danger=n,Ka.open=!0,new Promise(i=>{Ui=i})}function Sf(e){Ka.open=!1,Ui&&(Ui(e),Ui=null)}const aS={setup(){function e(t){Ka.open&&t.key==="Escape"&&(t.stopPropagation(),Sf(!1))}return st(()=>document.addEventListener("keydown",e,!0)),wt(()=>document.removeEventListener("keydown",e,!0)),{state:Ka,settle:Sf}},template:` + + + + `};/*! + * vue-router v4.6.4 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */const Ei=typeof document<"u";function bg(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function nS(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&bg(e.default)}const xt=Object.assign;function jc(e,t){const s={};for(const a in t){const n=t[a];s[a]=ga(n)?n.map(e):e(n)}return s}const Fl=()=>{},ga=Array.isArray;function Cf(e,t){const s={};for(const a in e)s[a]=a in t?t[a]:e[a];return s}const yg=/#/g,iS=/&/g,lS=/\//g,oS=/=/g,rS=/\?/g,xg=/\+/g,cS=/%5B/g,dS=/%5D/g,_g=/%5E/g,uS=/%60/g,wg=/%7B/g,pS=/%7C/g,kg=/%7D/g,fS=/%20/g;function Hu(e){return e==null?"":encodeURI(""+e).replace(pS,"|").replace(cS,"[").replace(dS,"]")}function mS(e){return Hu(e).replace(wg,"{").replace(kg,"}").replace(_g,"^")}function Id(e){return Hu(e).replace(xg,"%2B").replace(fS,"+").replace(yg,"%23").replace(iS,"%26").replace(uS,"`").replace(wg,"{").replace(kg,"}").replace(_g,"^")}function vS(e){return Id(e).replace(oS,"%3D")}function hS(e){return Hu(e).replace(yg,"%23").replace(rS,"%3F")}function gS(e){return hS(e).replace(lS,"%2F")}function no(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}const bS=/\/$/,yS=e=>e.replace(bS,"");function qc(e,t,s="/"){let a,n={},i="",l="";const o=t.indexOf("#");let r=t.indexOf("?");return r=o>=0&&r>o?-1:r,r>=0&&(a=t.slice(0,r),i=t.slice(r,o>0?o:t.length),n=e(i.slice(1))),o>=0&&(a=a||t.slice(0,o),l=t.slice(o,t.length)),a=kS(a??t,s),{fullPath:a+i+l,path:a,query:n,hash:no(l)}}function xS(e,t){const s=t.query?e(t.query):"";return t.path+(s&&"?")+s+(t.hash||"")}function Tf(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function _S(e,t,s){const a=t.matched.length-1,n=s.matched.length-1;return a>-1&&a===n&&Qi(t.matched[a],s.matched[n])&&Sg(t.params,s.params)&&e(t.query)===e(s.query)&&t.hash===s.hash}function Qi(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function Sg(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(var s in e)if(!wS(e[s],t[s]))return!1;return!0}function wS(e,t){return ga(e)?Ef(e,t):ga(t)?Ef(t,e):(e==null?void 0:e.valueOf())===(t==null?void 0:t.valueOf())}function Ef(e,t){return ga(t)?e.length===t.length&&e.every((s,a)=>s===t[a]):e.length===1&&e[0]===t}function kS(e,t){if(e.startsWith("/"))return e;if(!e)return t;const s=t.split("/"),a=e.split("/"),n=a[a.length-1];(n===".."||n===".")&&a.push("");let i=s.length-1,l,o;for(l=0;l1&&i--;else break;return s.slice(0,i).join("/")+"/"+a.slice(l).join("/")}const kn={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let Od=(function(e){return e.pop="pop",e.push="push",e})({}),Vc=(function(e){return e.back="back",e.forward="forward",e.unknown="",e})({});function SS(e){if(!e)if(Ei){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),yS(e)}const CS=/^[^#]+#/;function TS(e,t){return e.replace(CS,"#")+t}function ES(e,t){const s=document.documentElement.getBoundingClientRect(),a=e.getBoundingClientRect();return{behavior:t.behavior,left:a.left-s.left-(t.left||0),top:a.top-s.top-(t.top||0)}}const cc=()=>({left:window.scrollX,top:window.scrollY});function AS(e){let t;if("el"in e){const s=e.el,a=typeof s=="string"&&s.startsWith("#"),n=typeof s=="string"?a?document.getElementById(s.slice(1)):document.querySelector(s):s;if(!n)return;t=ES(n,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function Af(e,t){return(history.state?history.state.position-t:-1)+e}const Ld=new Map;function RS(e,t){Ld.set(e,t)}function IS(e){const t=Ld.get(e);return Ld.delete(e),t}function OS(e){return typeof e=="string"||e&&typeof e=="object"}function Cg(e){return typeof e=="string"||typeof e=="symbol"}let qt=(function(e){return e[e.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",e[e.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",e[e.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",e[e.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",e[e.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",e})({});const Tg=Symbol("");qt.MATCHER_NOT_FOUND+"",qt.NAVIGATION_GUARD_REDIRECT+"",qt.NAVIGATION_ABORTED+"",qt.NAVIGATION_CANCELLED+"",qt.NAVIGATION_DUPLICATED+"";function Yi(e,t){return xt(new Error,{type:e,[Tg]:!0},t)}function ja(e,t){return e instanceof Error&&Tg in e&&(t==null||!!(e.type&t))}const LS=["params","query","hash"];function NS(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;const t={};for(const s of LS)s in e&&(t[s]=e[s]);return JSON.stringify(t,null,2)}function MS(e){const t={};if(e===""||e==="?")return t;const s=(e[0]==="?"?e.slice(1):e).split("&");for(let a=0;an&&Id(n)):[a&&Id(a)]).forEach(n=>{n!==void 0&&(t+=(t.length?"&":"")+s,n!=null&&(t+="="+n))})}return t}function DS(e){const t={};for(const s in e){const a=e[s];a!==void 0&&(t[s]=ga(a)?a.map(n=>n==null?null:""+n):a==null?a:""+a)}return t}const PS=Symbol(""),If=Symbol(""),dc=Symbol(""),ju=Symbol(""),Nd=Symbol("");function hl(){let e=[];function t(a){return e.push(a),()=>{const n=e.indexOf(a);n>-1&&e.splice(n,1)}}function s(){e=[]}return{add:t,list:()=>e.slice(),reset:s}}function Rn(e,t,s,a,n,i=l=>l()){const l=a&&(a.enterCallbacks[n]=a.enterCallbacks[n]||[]);return()=>new Promise((o,r)=>{const c=p=>{p===!1?r(Yi(qt.NAVIGATION_ABORTED,{from:s,to:t})):p instanceof Error?r(p):OS(p)?r(Yi(qt.NAVIGATION_GUARD_REDIRECT,{from:t,to:p})):(l&&a.enterCallbacks[n]===l&&typeof p=="function"&&l.push(p),o())},d=i(()=>e.call(a&&a.instances[n],t,s,c));let u=Promise.resolve(d);e.length<3&&(u=u.then(c)),u.catch(p=>r(p))})}function Gc(e,t,s,a,n=i=>i()){const i=[];for(const l of e)for(const o in l.components){let r=l.components[o];if(!(t!=="beforeRouteEnter"&&!l.instances[o]))if(bg(r)){const c=(r.__vccOpts||r)[t];c&&i.push(Rn(c,s,a,l,o,n))}else{let c=r();i.push(()=>c.then(d=>{if(!d)throw new Error(`Couldn't resolve component "${o}" at "${l.path}"`);const u=nS(d)?d.default:d;l.mods[o]=d,l.components[o]=u;const p=(u.__vccOpts||u)[t];return p&&Rn(p,s,a,l,o,n)()}))}}return i}function $S(e,t){const s=[],a=[],n=[],i=Math.max(t.matched.length,e.matched.length);for(let l=0;lQi(c,o))?a.push(o):s.push(o));const r=e.matched[l];r&&(t.matched.find(c=>Qi(c,r))||n.push(r))}return[s,a,n]}/*! + * vue-router v4.6.4 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */let FS=()=>location.protocol+"//"+location.host;function Eg(e,t){const{pathname:s,search:a,hash:n}=t,i=e.indexOf("#");if(i>-1){let l=n.includes(e.slice(i))?e.slice(i).length:1,o=n.slice(l);return o[0]!=="/"&&(o="/"+o),Tf(o,"")}return Tf(s,e)+a+n}function BS(e,t,s,a){let n=[],i=[],l=null;const o=({state:p})=>{const m=Eg(e,location),h=s.value,y=t.value;let k=0;if(p){if(s.value=m,t.value=p,l&&l===h){l=null;return}k=y?p.position-y.position:0}else a(m);n.forEach(A=>{A(s.value,h,{delta:k,type:Od.pop,direction:k?k>0?Vc.forward:Vc.back:Vc.unknown})})};function r(){l=s.value}function c(p){n.push(p);const m=()=>{const h=n.indexOf(p);h>-1&&n.splice(h,1)};return i.push(m),m}function d(){if(document.visibilityState==="hidden"){const{history:p}=window;if(!p.state)return;p.replaceState(xt({},p.state,{scroll:cc()}),"")}}function u(){for(const p of i)p();i=[],window.removeEventListener("popstate",o),window.removeEventListener("pagehide",d),document.removeEventListener("visibilitychange",d)}return window.addEventListener("popstate",o),window.addEventListener("pagehide",d),document.addEventListener("visibilitychange",d),{pauseListeners:r,listen:c,destroy:u}}function Of(e,t,s,a=!1,n=!1){return{back:e,current:t,forward:s,replaced:a,position:window.history.length,scroll:n?cc():null}}function US(e){const{history:t,location:s}=window,a={value:Eg(e,s)},n={value:t.state};n.value||i(a.value,{back:null,current:a.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function i(r,c,d){const u=e.indexOf("#"),p=u>-1?(s.host&&document.querySelector("base")?e:e.slice(u))+r:FS()+e+r;try{t[d?"replaceState":"pushState"](c,"",p),n.value=c}catch(m){console.error(m),s[d?"replace":"assign"](p)}}function l(r,c){i(r,xt({},t.state,Of(n.value.back,r,n.value.forward,!0),c,{position:n.value.position}),!0),a.value=r}function o(r,c){const d=xt({},n.value,t.state,{forward:r,scroll:cc()});i(d.current,d,!0),i(r,xt({},Of(a.value,r,null),{position:d.position+1},c),!1),a.value=r}return{location:a,state:n,push:o,replace:l}}function zS(e){e=SS(e);const t=US(e),s=BS(e,t.state,t.location,t.replace);function a(i,l=!0){l||s.pauseListeners(),history.go(i)}const n=xt({location:"",base:e,go:a,createHref:TS.bind(null,e)},t,s);return Object.defineProperty(n,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(n,"state",{enumerable:!0,get:()=>t.state.value}),n}function HS(e){return e=location.host?e||location.pathname+location.search:"",e.includes("#")||(e+="#"),zS(e)}let Zn=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.Group=2]="Group",e})({});var ss=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.ParamRegExp=2]="ParamRegExp",e[e.ParamRegExpEnd=3]="ParamRegExpEnd",e[e.EscapeNext=4]="EscapeNext",e})(ss||{});const jS={type:Zn.Static,value:""},qS=/[a-zA-Z0-9_]/;function VS(e){if(!e)return[[]];if(e==="/")return[[jS]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(m){throw new Error(`ERR (${s})/"${c}": ${m}`)}let s=ss.Static,a=s;const n=[];let i;function l(){i&&n.push(i),i=[]}let o=0,r,c="",d="";function u(){c&&(s===ss.Static?i.push({type:Zn.Static,value:c}):s===ss.Param||s===ss.ParamRegExp||s===ss.ParamRegExpEnd?(i.length>1&&(r==="*"||r==="+")&&t(`A repeatable param (${c}) must be alone in its segment. eg: '/:ids+.`),i.push({type:Zn.Param,value:c,regexp:d,repeatable:r==="*"||r==="+",optional:r==="*"||r==="?"})):t("Invalid state to consume buffer"),c="")}function p(){c+=r}for(;ot.length?t.length===1&&t[0]===Ls.Static+Ls.Segment?1:-1:0}function Ag(e,t){let s=0;const a=e.score,n=t.score;for(;s0&&t[t.length-1]<0}const ZS={strict:!1,end:!0,sensitive:!1};function QS(e,t,s){const a=KS(VS(e.path),s),n=xt(a,{record:e,parent:t,children:[],alias:[]});return t&&!n.record.aliasOf==!t.record.aliasOf&&t.children.push(n),n}function YS(e,t){const s=[],a=new Map;t=Cf(ZS,t);function n(u){return a.get(u)}function i(u,p,m){const h=!m,y=Df(u);y.aliasOf=m&&m.record;const k=Cf(t,u),A=[y];if("alias"in u){const x=typeof u.alias=="string"?[u.alias]:u.alias;for(const w of x)A.push(Df(xt({},y,{components:m?m.record.components:y.components,path:w,aliasOf:m?m.record:y})))}let g,b;for(const x of A){const{path:w}=x;if(p&&w[0]!=="/"){const _=p.record.path,T=_[_.length-1]==="/"?"":"/";x.path=p.record.path+(w&&T+w)}if(g=QS(x,p,k),m?m.alias.push(g):(b=b||g,b!==g&&b.alias.push(g),h&&u.name&&!Pf(g)&&l(u.name)),Rg(g)&&r(g),y.children){const _=y.children;for(let T=0;T<_.length;T++)i(_[T],g,m&&m.children[T])}m=m||g}return b?()=>{l(b)}:Fl}function l(u){if(Cg(u)){const p=a.get(u);p&&(a.delete(u),s.splice(s.indexOf(p),1),p.children.forEach(l),p.alias.forEach(l))}else{const p=s.indexOf(u);p>-1&&(s.splice(p,1),u.record.name&&a.delete(u.record.name),u.children.forEach(l),u.alias.forEach(l))}}function o(){return s}function r(u){const p=t1(u,s);s.splice(p,0,u),u.record.name&&!Pf(u)&&a.set(u.record.name,u)}function c(u,p){let m,h={},y,k;if("name"in u&&u.name){if(m=a.get(u.name),!m)throw Yi(qt.MATCHER_NOT_FOUND,{location:u});k=m.record.name,h=xt(Mf(p.params,m.keys.filter(b=>!b.optional).concat(m.parent?m.parent.keys.filter(b=>b.optional):[]).map(b=>b.name)),u.params&&Mf(u.params,m.keys.map(b=>b.name))),y=m.stringify(h)}else if(u.path!=null)y=u.path,m=s.find(b=>b.re.test(y)),m&&(h=m.parse(y),k=m.record.name);else{if(m=p.name?a.get(p.name):s.find(b=>b.re.test(p.path)),!m)throw Yi(qt.MATCHER_NOT_FOUND,{location:u,currentLocation:p});k=m.record.name,h=xt({},p.params,u.params),y=m.stringify(h)}const A=[];let g=m;for(;g;)A.unshift(g.record),g=g.parent;return{name:k,path:y,params:h,matched:A,meta:e1(A)}}e.forEach(u=>i(u));function d(){s.length=0,a.clear()}return{addRoute:i,resolve:c,removeRoute:l,clearRoutes:d,getRoutes:o,getRecordMatcher:n}}function Mf(e,t){const s={};for(const a of t)a in e&&(s[a]=e[a]);return s}function Df(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:XS(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function XS(e){const t={},s=e.props||!1;if("component"in e)t.default=s;else for(const a in e.components)t[a]=typeof s=="object"?s[a]:s;return t}function Pf(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function e1(e){return e.reduce((t,s)=>xt(t,s.meta),{})}function t1(e,t){let s=0,a=t.length;for(;s!==a;){const i=s+a>>1;Ag(e,t[i])<0?a=i:s=i+1}const n=s1(e);return n&&(a=t.lastIndexOf(n,a-1)),a}function s1(e){let t=e;for(;t=t.parent;)if(Rg(t)&&Ag(e,t)===0)return t}function Rg({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function $f(e){const t=ra(dc),s=ra(ju),a=B(()=>{const r=La(e.to);return t.resolve(r)}),n=B(()=>{const{matched:r}=a.value,{length:c}=r,d=r[c-1],u=s.matched;if(!d||!u.length)return-1;const p=u.findIndex(Qi.bind(null,d));if(p>-1)return p;const m=Ff(r[c-2]);return c>1&&Ff(d)===m&&u[u.length-1].path!==m?u.findIndex(Qi.bind(null,r[c-2])):p}),i=B(()=>n.value>-1&&o1(s.params,a.value.params)),l=B(()=>n.value>-1&&n.value===s.matched.length-1&&Sg(s.params,a.value.params));function o(r={}){if(l1(r)){const c=t[La(e.replace)?"replace":"push"](La(e.to)).catch(Fl);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>c),c}return Promise.resolve()}return{route:a,href:B(()=>a.value.href),isActive:i,isExactActive:l,navigate:o}}function a1(e){return e.length===1?e[0]:e}const n1=fo({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:$f,setup(e,{slots:t}){const s=Mn($f(e)),{options:a}=ra(dc),n=B(()=>({[Bf(e.activeClass,a.linkActiveClass,"router-link-active")]:s.isActive,[Bf(e.exactActiveClass,a.linkExactActiveClass,"router-link-exact-active")]:s.isExactActive}));return()=>{const i=t.default&&a1(t.default(s));return e.custom?i:qi("a",{"aria-current":s.isExactActive?e.ariaCurrentValue:null,href:s.href,onClick:s.navigate,class:n.value},i)}}}),i1=n1;function l1(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function o1(e,t){for(const s in t){const a=t[s],n=e[s];if(typeof a=="string"){if(a!==n)return!1}else if(!ga(n)||n.length!==a.length||a.some((i,l)=>i.valueOf()!==n[l].valueOf()))return!1}return!0}function Ff(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const Bf=(e,t,s)=>e??t??s,r1=fo({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:s}){const a=ra(Nd),n=B(()=>e.route||a.value),i=ra(If,0),l=B(()=>{let c=La(i);const{matched:d}=n.value;let u;for(;(u=d[c])&&!u.components;)c++;return c}),o=B(()=>n.value.matched[l.value]);Nl(If,B(()=>l.value+1)),Nl(PS,o),Nl(Nd,n);const r=f();return Gt(()=>[r.value,o.value,e.name],([c,d,u],[p,m,h])=>{d&&(d.instances[u]=c,m&&m!==d&&c&&c===p&&(d.leaveGuards.size||(d.leaveGuards=m.leaveGuards),d.updateGuards.size||(d.updateGuards=m.updateGuards))),c&&d&&(!m||!Qi(d,m)||!p)&&(d.enterCallbacks[u]||[]).forEach(y=>y(c))},{flush:"post"}),()=>{const c=n.value,d=e.name,u=o.value,p=u&&u.components[d];if(!p)return Uf(s.default,{Component:p,route:c});const m=u.props[d],h=m?m===!0?c.params:typeof m=="function"?m(c):m:null,k=qi(p,xt({},h,t,{onVnodeUnmounted:A=>{A.component.isUnmounted&&(u.instances[d]=null)},ref:r}));return Uf(s.default,{Component:k,route:c})||k}}});function Uf(e,t){if(!e)return null;const s=e(t);return s.length===1?s[0]:s}const c1=r1;function d1(e){const t=YS(e.routes,e),s=e.parseQuery||MS,a=e.stringifyQuery||Rf,n=e.history,i=hl(),l=hl(),o=hl(),r=Xd(kn);let c=kn;Ei&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const d=jc.bind(null,le=>""+le),u=jc.bind(null,gS),p=jc.bind(null,no);function m(le,oe){let ve,j;return Cg(le)?(ve=t.getRecordMatcher(le),j=oe):j=le,t.addRoute(j,ve)}function h(le){const oe=t.getRecordMatcher(le);oe&&t.removeRoute(oe)}function y(){return t.getRoutes().map(le=>le.record)}function k(le){return!!t.getRecordMatcher(le)}function A(le,oe){if(oe=xt({},oe||r.value),typeof le=="string"){const P=qc(s,le,oe.path),G=t.resolve({path:P.path},oe),he=n.createHref(P.fullPath);return xt(P,G,{params:p(G.params),hash:no(P.hash),redirectedFrom:void 0,href:he})}let ve;if(le.path!=null)ve=xt({},le,{path:qc(s,le.path,oe.path).path});else{const P=xt({},le.params);for(const G in P)P[G]==null&&delete P[G];ve=xt({},le,{params:u(P)}),oe.params=u(oe.params)}const j=t.resolve(ve,oe),Y=le.hash||"";j.params=d(p(j.params));const xe=xS(a,xt({},le,{hash:mS(Y),path:j.path})),S=n.createHref(xe);return xt({fullPath:xe,hash:Y,query:a===Rf?DS(le.query):le.query||{}},j,{redirectedFrom:void 0,href:S})}function g(le){return typeof le=="string"?qc(s,le,r.value.path):xt({},le)}function b(le,oe){if(c!==le)return Yi(qt.NAVIGATION_CANCELLED,{from:oe,to:le})}function x(le){return T(le)}function w(le){return x(xt(g(le),{replace:!0}))}function _(le,oe){const ve=le.matched[le.matched.length-1];if(ve&&ve.redirect){const{redirect:j}=ve;let Y=typeof j=="function"?j(le,oe):j;return typeof Y=="string"&&(Y=Y.includes("?")||Y.includes("#")?Y=g(Y):{path:Y},Y.params={}),xt({query:le.query,hash:le.hash,params:Y.path!=null?{}:le.params},Y)}}function T(le,oe){const ve=c=A(le),j=r.value,Y=le.state,xe=le.force,S=le.replace===!0,P=_(ve,j);if(P)return T(xt(g(P),{state:typeof P=="object"?xt({},Y,P.state):Y,force:xe,replace:S}),oe||ve);const G=ve;G.redirectedFrom=oe;let he;return!xe&&_S(a,j,ve)&&(he=Yi(qt.NAVIGATION_DUPLICATED,{to:G,from:j}),Z(j,j,!0,!1)),(he?Promise.resolve(he):F(G,j)).catch(fe=>ja(fe)?ja(fe,qt.NAVIGATION_GUARD_REDIRECT)?fe:ce(fe):$(fe,G,j)).then(fe=>{if(fe){if(ja(fe,qt.NAVIGATION_GUARD_REDIRECT))return T(xt({replace:S},g(fe.to),{state:typeof fe.to=="object"?xt({},Y,fe.to.state):Y,force:xe}),oe||G)}else fe=I(G,j,!0,S,Y);return O(G,j,fe),fe})}function L(le,oe){const ve=b(le,oe);return ve?Promise.reject(ve):Promise.resolve()}function E(le){const oe=V.values().next().value;return oe&&typeof oe.runWithContext=="function"?oe.runWithContext(le):le()}function F(le,oe){let ve;const[j,Y,xe]=$S(le,oe);ve=Gc(j.reverse(),"beforeRouteLeave",le,oe);for(const P of j)P.leaveGuards.forEach(G=>{ve.push(Rn(G,le,oe))});const S=L.bind(null,le,oe);return ve.push(S),Oe(ve).then(()=>{ve=[];for(const P of i.list())ve.push(Rn(P,le,oe));return ve.push(S),Oe(ve)}).then(()=>{ve=Gc(Y,"beforeRouteUpdate",le,oe);for(const P of Y)P.updateGuards.forEach(G=>{ve.push(Rn(G,le,oe))});return ve.push(S),Oe(ve)}).then(()=>{ve=[];for(const P of xe)if(P.beforeEnter)if(ga(P.beforeEnter))for(const G of P.beforeEnter)ve.push(Rn(G,le,oe));else ve.push(Rn(P.beforeEnter,le,oe));return ve.push(S),Oe(ve)}).then(()=>(le.matched.forEach(P=>P.enterCallbacks={}),ve=Gc(xe,"beforeRouteEnter",le,oe,E),ve.push(S),Oe(ve))).then(()=>{ve=[];for(const P of l.list())ve.push(Rn(P,le,oe));return ve.push(S),Oe(ve)}).catch(P=>ja(P,qt.NAVIGATION_CANCELLED)?P:Promise.reject(P))}function O(le,oe,ve){o.list().forEach(j=>E(()=>j(le,oe,ve)))}function I(le,oe,ve,j,Y){const xe=b(le,oe);if(xe)return xe;const S=oe===kn,P=Ei?history.state:{};ve&&(j||S?n.replace(le.fullPath,xt({scroll:S&&P&&P.scroll},Y)):n.push(le.fullPath,Y)),r.value=le,Z(le,oe,ve,S),ce()}let H;function te(){H||(H=n.listen((le,oe,ve)=>{if(!be.listening)return;const j=A(le),Y=_(j,be.currentRoute.value);if(Y){T(xt(Y,{replace:!0,force:!0}),j).catch(Fl);return}c=j;const xe=r.value;Ei&&RS(Af(xe.fullPath,ve.delta),cc()),F(j,xe).catch(S=>ja(S,qt.NAVIGATION_ABORTED|qt.NAVIGATION_CANCELLED)?S:ja(S,qt.NAVIGATION_GUARD_REDIRECT)?(T(xt(g(S.to),{force:!0}),j).then(P=>{ja(P,qt.NAVIGATION_ABORTED|qt.NAVIGATION_DUPLICATED)&&!ve.delta&&ve.type===Od.pop&&n.go(-1,!1)}).catch(Fl),Promise.reject()):(ve.delta&&n.go(-ve.delta,!1),$(S,j,xe))).then(S=>{S=S||I(j,xe,!1),S&&(ve.delta&&!ja(S,qt.NAVIGATION_CANCELLED)?n.go(-ve.delta,!1):ve.type===Od.pop&&ja(S,qt.NAVIGATION_ABORTED|qt.NAVIGATION_DUPLICATED)&&n.go(-1,!1)),O(j,xe,S)}).catch(Fl)}))}let ee=hl(),N=hl(),M;function $(le,oe,ve){ce(le);const j=N.list();return j.length?j.forEach(Y=>Y(le,oe,ve)):console.error(le),Promise.reject(le)}function re(){return M&&r.value!==kn?Promise.resolve():new Promise((le,oe)=>{ee.add([le,oe])})}function ce(le){return M||(M=!le,te(),ee.list().forEach(([oe,ve])=>le?ve(le):oe()),ee.reset()),le}function Z(le,oe,ve,j){const{scrollBehavior:Y}=e;if(!Ei||!Y)return Promise.resolve();const xe=!ve&&IS(Af(le.fullPath,0))||(j||!ve)&&history.state&&history.state.scroll||null;return Ut().then(()=>Y(le,oe,xe)).then(S=>S&&AS(S)).catch(S=>$(S,le,oe))}const Q=le=>n.go(le);let ae;const V=new Set,be={currentRoute:r,listening:!0,addRoute:m,removeRoute:h,clearRoutes:t.clearRoutes,hasRoute:k,getRoutes:y,resolve:A,options:e,push:x,replace:w,go:Q,back:()=>Q(-1),forward:()=>Q(1),beforeEach:i.add,beforeResolve:l.add,afterEach:o.add,onError:N.add,isReady:re,install(le){le.component("RouterLink",i1),le.component("RouterView",c1),le.config.globalProperties.$router=be,Object.defineProperty(le.config.globalProperties,"$route",{enumerable:!0,get:()=>La(r)}),Ei&&!ae&&r.value===kn&&(ae=!0,x(n.location).catch(j=>{}));const oe={};for(const j in kn)Object.defineProperty(oe,j,{get:()=>r.value[j],enumerable:!0});le.provide(dc,be),le.provide(ju,Yd(oe)),le.provide(Nd,r);const ve=le.unmount;V.add(le),le.unmount=function(){V.delete(le),V.size<1&&(c=kn,H&&H(),H=null,r.value=kn,ae=!1,M=!1),ve()}}};function Oe(le){return le.reduce((oe,ve)=>oe.then(()=>E(ve)),Promise.resolve())}return be}function Ig(){return ra(dc)}function u1(e){return ra(ju)}const uc={props:{tabs:{type:Array,required:!0},defaultTab:{type:String,default:""},groupLabel:{type:String,default:""}},setup(e){const t=u1(),s=Ig(),a=B({get(){var r;const o=t.query.tab;return o&&e.tabs.some(c=>c.id===o)?o:e.defaultTab||((r=e.tabs[0])==null?void 0:r.id)||""},set(o){s.replace({query:{...t.query,tab:o}})}}),n=B(()=>{var o;return((o=e.tabs.find(r=>r.id===a.value))==null?void 0:o.component)||null}),i=B(()=>{var o;return((o=e.tabs.find(r=>r.id===a.value))==null?void 0:o.label)||""});Gt(i,o=>{e.groupLabel&&o&&(document.title=`Odin — ${e.groupLabel} › ${o}`)},{immediate:!0});function l(o,r){if(!["ArrowLeft","ArrowRight","Home","End"].includes(o.key))return;o.preventDefault();let c=r;o.key==="ArrowRight"&&(c=(r+1)%e.tabs.length),o.key==="ArrowLeft"&&(c=(r-1+e.tabs.length)%e.tabs.length),o.key==="Home"&&(c=0),o.key==="End"&&(c=e.tabs.length-1),a.value=e.tabs[c].id,requestAnimationFrame(()=>{var d;return(d=document.getElementById("tab-"+e.tabs[c].id))==null?void 0:d.focus()})}return{activeTab:a,activeComponent:n,activeLabel:i,onTabKeydown:l}},template:` +
    +
    +
    + +
    +
    +
    + +
    +
    + `},io=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),Da=e=>Number.isSafeInteger(e)&&e>=0,p1=e=>e===null||typeof e=="string",Dr=(e,t)=>Da(e)&&Da(t)&&t>=e,qu=e=>io(e)&&typeof e.status=="string"&&typeof e.truncated=="boolean"&&p1(e.cursor);function f1(e){return!qu(e)||e.kind!=="tool_output"?!1:e.retention==="failed"?typeof e.error=="string"&&typeof e.head=="string"&&io(e.tail)&&typeof e.tail.text=="string"&&e.cursor===null&&e.truncated:e.retention!=="retained"||typeof e.result_id!="string"||!Da(e.total_chars)||!Da(e.total_bytes)||e.offset_unit!=="unicode_code_points"||!Dr(e.start,e.end)||e.end>e.total_chars?!1:"head"in e?typeof e.head=="string"&&io(e.tail)&&typeof e.tail.text=="string"&&Dr(e.tail.start,e.tail.end)&&e.tail.end<=e.total_chars&&e.tail_is_context_only===!0:typeof e.text=="string"&&!("tail"in e)}function zf(e){return qu(e)&&e.kind==="process_output"&&Da(e.pid)&&typeof e.generation=="string"&&(e.exit_code===null||Number.isInteger(e.exit_code))&&["emitted_bytes","retained_bytes","shown_bytes","capture_limit_loss_bytes","not_retained_bytes"].every(t=>Da(e[t]))&&e.retained_bytes<=e.emitted_bytes&&Array.isArray(e.shown_intervals)&&e.shown_intervals.every(t=>Array.isArray(t)&&t.length===2&&Dr(...t)&&t[1]<=("text"in e?e.retained_bytes:e.emitted_bytes))}function m1(e){return qu(e)&&!("kind"in e)&&typeof e.id=="string"&&typeof e.label=="string"&&typeof e.preview=="string"&&["original_bytes","result_bytes","error_bytes","source_original_bytes"].every(t=>Da(e[t]))&&Dr(e.offset,e.end)&&e.end<=e.original_bytes&&e.result_bytes+e.error_bytes===e.original_bytes&&Array.isArray(e.tools_used)&&e.tools_used.every(t=>typeof t=="string")&&Da(e.tools_omitted)}function Md(e){try{return JSON.parse(e)}catch{return}}const Dd=e=>JSON.stringify(e,null,2),v1=e=>{const t=Md(e);return t===void 0?e:Dd(t)},Uo=(e,t,s)=>`[${e}, ${t}) ${s}`;function Og(e,{prettyPrint:t=!0}={}){var o,r;const s=typeof e=="string"?e:Dd(e)??"";let a=typeof e=="string"?Md(e):e,n=null;if(typeof e=="string"&&a===void 0){const c=` +[output retention] `,d=e.lastIndexOf(c),u=e.indexOf(` +`);if(d>u&&u>0){const p=Md(e.slice(d+c.length)),m=e.slice(0,u);zf(p)&&!("text"in p)&&m.startsWith(`[PID ${p.pid}] status=${p.status} `)&&(a=p,n=e.slice(u+1,d))}}const i={raw:s,kind:"text",header:[],sections:[],metadata:null},l=(c,d)=>({label:c,text:t?v1(d):d});if(io(a)&&a.kind==="audit_preview"&&a.audit_clipped===!0&&(!("original_chars"in a)||Da(a.original_chars))&&(!("preview"in a)||typeof a.preview=="string")){if(i.kind="audit_preview",i.header=["audit clipped: yes",...Da(a.original_chars)?[`original ${a.original_chars} code points`]:[]],io(a.source))for(const c of["kind","status","retention","truncated","capture_loss","capture_limit_loss_bytes","not_retained_bytes","cursor_present","total_bytes","total_chars","retained_bytes","emitted_bytes","shown_bytes","offset_unit","capture_error","pid","exit_code","start","end","tail_status","original_bytes","result_bytes","error_bytes","offset","source_original_bytes","id","capture_lost_bytes","dropped_bytes","output_lost","capture_truncated","retention_seconds_after_exit"])["string","number","boolean"].includes(typeof a.source[c])&&i.header.push(`source ${c}: ${a.source[c]}`);return i.sections.push({label:"Audit preview — incomplete source; raw shows stored wrapper",text:a.preview??(t?"(no preview retained in audit)":"")}),i.metadata=a,i}if(f1(a))i.kind="tool_output",i.header=[a.status,`retention: ${a.retention}`],a.retention==="retained"?(i.header.push(`${a.total_bytes} UTF-8 bytes`,`${a.total_chars} code points`),i.sections.push(l(`${"head"in a?"Head":"Page"} ${Uo(a.start,a.end,"code points")}`,a.head??a.text)),(o=a.tail)!=null&&o.text&&i.sections.push(l(`Tail context only ${Uo(a.tail.start,a.tail.end,"code points")} — not a continuation`,a.tail.text))):(i.header.push(a.error),i.sections.push(l("Head — retention failed",a.head),l("Tail context only — may overlap head",a.tail.text))),typeof((r=a.matches)==null?void 0:r.summary)=="string"&&i.header.push(a.matches.summary);else if(zf(a)&&(typeof a.text=="string"||n!==null)){i.kind="process_output",i.header=[a.status,`PID ${a.pid}`,...a.exit_code!==null?[`exit ${a.exit_code}`]:[],`emitted ${a.emitted_bytes} B`,`retained ${a.retained_bytes} B`,`shown ${a.shown_bytes} B`,`capture-limit loss ${a.capture_limit_loss_bytes} B`,`not retained ${a.not_retained_bytes} B`],a.capture_error&&i.header.push(`capture error: ${a.capture_error}`),a.tail_status&&i.header.push(`recent output: ${a.tail_status}`);const c=a.shown_intervals.map(d=>Uo(...d,"UTF-8 bytes")).join(", ");i.sections.push(l(`${n!==null?"Recent preview — retrieval starts at byte 0":"Page"}${c?" · "+c:""}`,n??a.text))}else if(m1(a))i.kind="agent_result",i.header=[a.status,`agent ${a.id}`,a.label,`original ${a.original_bytes} B`,`result ${a.result_bytes} B`,`error ${a.error_bytes} B`,`source ${a.source_original_bytes} B`,`tools ${a.tools_used.length} shown / ${a.tools_omitted} omitted`],i.sections.push(l(`Result + error page ${Uo(a.offset,a.end,"UTF-8 bytes")}`,a.preview));else return i.kind=a===void 0?"text":"json",i.sections.push({label:"",text:a===void 0||!t&&typeof e=="string"?s:t?Dd(a):JSON.stringify(a)}),i;if(i.metadata=a,i.header.push(`source truncated: ${a.truncated?"yes":"no"}`,`cursor: ${a.cursor?"present":"none"}`),typeof a.expires_at=="string"&&i.header.push(`expires: ${a.expires_at}`),typeof a.expires_at=="number"){const c=new Date(a.expires_at*1e3);Number.isNaN(c.valueOf())||i.header.push(`expires: ${c.toISOString()}`)}return i}function Lg(e,t=30,s=6e3){let a=1,n=0,i=0;if(t>0&&s>0)for(const l of e){if(n>=s||l===` +`&&a>=t)break;l===` +`&&a++,n++,i+=l.length}return{text:e.slice(0,i),folded:ie!==null&&typeof e=="object",g1=new Set(["_hmac","_prev_hmac"]),Pd=e=>e.replace(/\r\n?/g,` +`);function lo(e){const t=typeof e=="string"?e:JSON.stringify(e)??"";try{let s=!1;const a=JSON.parse(t,(n,i)=>{if(g1.has(n)){s=!0;return}return i});return Pd(s?JSON.stringify(a):t)}catch{return Pd(t)}}function b1(e){const t=e.metadata;if(!t)return[];const s=[],a=e.kind==="audit_preview"&&h1(t.source)?t.source:t;return e.kind==="audit_preview"&&s.push("audit clipped"),a.truncated===!0&&s.push("source truncated"),a.retention==="failed"&&s.push("retention unavailable"),a.capture_error&&s.push(`capture unavailable: ${a.capture_error}`),a.capture_limit_loss_bytes>0&&s.push(`capture loss ${a.capture_limit_loss_bytes} B`),a.not_retained_bytes>0&&s.push(`not retained ${a.not_retained_bytes} B`),a.capture_lost_bytes>0&&s.push(`capture lost ${a.capture_lost_bytes} B`),a.dropped_bytes>0&&s.push(`dropped ${a.dropped_bytes} B`),(a.capture_loss===!0||a.output_lost===!0||a.capture_truncated===!0)&&s.push("capture loss"),Number.isInteger(a.exit_code)&&a.exit_code!==0&&s.push(`process exit ${a.exit_code}`),["failed","error","cancelled","timed_out"].includes(a.status)&&s.push(`source ${a.status}`),e.kind==="audit_preview"&&!t.preview&&s.push("audit body unavailable"),s}function y1(e){var m;const t=Og(typeof e=="string"?Pd(e):e,{prettyPrint:!1}),s=t.sections.map(h=>({...h,text:lo(h.text)})),a=s.map(h=>h.text).filter(Boolean).join(` +`),n=a.replace(/\n$/,""),i=[...a].length,l=n?n.split(` +`).length:0,o=!["text","json"].includes(t.kind),r=l>=2||i>Wc.inlineChars||o&&n.length>0,c=s.filter(h=>h.text).map(h=>{let y=h.text;try{y=JSON.stringify(JSON.parse(y),null,2)}catch{}return h.label?`${h.label} +${y}`:y}).join(` + +`).replace(/\n$/,""),d=Lg(c,Wc.previewLines,Wc.previewChars),u=t.kind==="audit_preview"?(m=t.metadata)==null?void 0:m.source:t.metadata,p=u&&(t.kind==="process_output"||u.kind==="process_output")?`PID ${u.pid??"?"} ${u.status??""}${Number.isInteger(u.exit_code)?` exit ${u.exit_code}`:""}`.trim():t.kind==="agent_result"&&(u!=null&&u.status)?`agent ${u.status}`:"";return{promoted:r,chars:i,lines:l,envelope:o,kind:t.kind,formatted:c,preview:d,outcome:p,header:t.header,summary:n.replace(/\n/g," "),warnings:b1(t)}}const x1={name:"CompactOutput",props:{value:{default:""},rawValue:{default:void 0},label:{type:String,default:"Output"},hasContext:{type:Boolean,default:!1},recordId:{default:null}},setup(e){const t=f(!1),s=f(!0),a=f(!1),n=f(""),i=f(null),l=f(null),o=f(!1),r=B(()=>y1(e.value)),c=B(()=>{const x=e.rawValue===void 0?e.value:e.rawValue;return typeof x=="string"?x:JSON.stringify(x,null,2)??""}),d=B(()=>a.value?c.value:r.value.formatted),u=B(()=>r.value.promoted&&r.value.preview.folded||o.value),p=B(()=>t.value?!!d.value:r.value.promoted),m=B(()=>p.value?"":r.value.summary);let h;function y(){if(t.value)return;const x=r.value.promoted?i.value:l.value;o.value=!!(x&&(x.scrollHeight>x.clientHeight+1||x.scrollWidth>x.clientWidth+1))}function k(){h==null||h.disconnect();for(const x of[i.value,l.value])x&&(h==null||h.observe(x));y()}function A(){t.value=!t.value,t.value||(a.value=!1)}function g(){a.value=!a.value,t.value=!0,n.value=""}async function b(){const x=e.value;try{await navigator.clipboard.writeText(d.value),x===e.value&&(n.value="Copied")}catch{x===e.value&&(n.value="Copy unavailable — select text manually")}}return Gt([()=>e.value,()=>e.rawValue,()=>e.recordId],(x,w)=>{(e.recordId===null||x[2]!==w[2])&&(t.value=!1,a.value=!1),n.value=""}),Gt([i,l,t,s,r],()=>Ut(k),{flush:"post"}),st(()=>{h=new ResizeObserver(y),k()}),wt(()=>h==null?void 0:h.disconnect()),{expanded:t,wrapped:s,rawMode:a,copyStatus:n,previewElement:i,summaryElement:l,model:r,body:d,canExpand:u,showBody:p,headerSummary:m,toggleExpanded:A,toggleRaw:g,copyOutput:b}},template:` +
    +
    +
    + +
    +
    + + + +
    + +
    + +
    + + + + {{ model.outcome }} + {{ headerSummary }} + +
    +
    {{ expanded ? body : model.preview.text }}
    +
    +
    {{ model.warnings.join('; ') }}
    +
    {{ model.header.join(' · ') }}
    + +
    + {{ copyStatus }} +
    `},pc={name:"ToolOutput",components:{CompactOutput:x1},props:{value:{default:""},label:{type:String,default:"Output"},presentation:{type:String,default:"inspector"},rawValue:{default:void 0},hasContext:{type:Boolean,default:!1},recordId:{default:null}},setup(e){const t=f(!1),s=f(!0),a=f(!1),n=f(""),i=B(()=>Og(e.value)),l=B(()=>a.value?[{label:"Raw received value",text:i.value.raw}]:i.value.sections),o=B(()=>{let u=30,p=6e3;return l.value.map(m=>{const h=Lg(m.text,u,p);return u=Math.max(0,u-h.lines),p=Math.max(0,p-h.chars),{...m,display:t.value?m.text:h.text,folded:h.folded}})}),r=B(()=>o.value.some(u=>u.folded)),c=B(()=>a.value?i.value.raw:l.value.map(u=>u.label?`${u.label} +${u.text}`:u.text).join(` + +`));async function d(){const u=e.value;try{await navigator.clipboard.writeText(c.value),e.value===u&&(n.value="Copied")}catch{e.value===u&&(n.value="Copy unavailable — select text manually")}}return Gt(()=>e.value,()=>{t.value=!1,n.value=""}),Gt(a,()=>{t.value=!1,n.value=""}),{expanded:t,wrapped:s,rawMode:a,copyStatus:n,model:i,foldedSections:o,canExpand:r,copyOutput:d}},template:` + + + + + +
    +
    + {{ model.kind }} + {{ item }} +
    +
    + + + + {{ copyStatus }} +
    +
    + +
    {{ section.display }}
    + More received text hidden locally. +
    + +
    + `},_1={components:{ToolOutput:pc},setup(){const e=f([]),t=f([]),s=f({}),a=50;function n(p){var y,k,A,g,b,x,w,_,T,L,E;const m=p.payload||p,h=m.type||p.type;if(!(["loop_tool_start","loop_tool"].includes(h)&&!(m.agent_id||(y=m.metadata)!=null&&y.agent_id))&&!(["loop_tool_start","loop_tool"].includes(h)&&!(m.call_id||(k=m.metadata)!=null&&k.call_id))){if(h==="tool_start"||h==="loop_tool_start"){const F=m.call_id||((A=m.metadata)==null?void 0:A.call_id)||null,O=m.agent_id||((g=m.metadata)==null?void 0:g.agent_id)||"",I={callId:F,agentId:O,agentLabel:m.agent_label||((b=m.metadata)==null?void 0:b.agent_label)||"",toolInput:m.tool_input,id:F?`${O}:${F}`:`${m.action}-${Date.now()}`,tool:m.action,actor:m.actor||"",channel:m.channel_id||"",iteration:m.iteration??((x=m.metadata)==null?void 0:x.iteration)??0,startTime:Date.now(),elapsed:0,status:"running",output:"",result:""};e.value.unshift(I);return}if(h==="tool_end"||h==="loop_tool"){const F=m.call_id||((w=m.metadata)==null?void 0:w.call_id)||null,O=m.agent_id||((_=m.metadata)==null?void 0:_.agent_id)||"";let I=-1;if(F&&(I=e.value.findIndex(H=>H.callId===F&&H.agentId===O&&H.status==="running")),I<0&&!F)for(let H=e.value.length-1;H>=0;H--){const te=e.value[H];if(te.tool===m.action&&te.agentId===O&&te.status==="running"){I=H;break}}if(I>=0){const H=e.value[I];H.status=m.error||(T=m.metadata)!=null&&T.error||["error","failed","cancelled","denied","outcome_unknown"].includes(m.status||((L=m.metadata)==null?void 0:L.status))?"error":"success",H.elapsed=m.execution_time_ms??m.duration_ms??((E=m.metadata)==null?void 0:E.elapsed_ms)??Date.now()-H.startTime,H.result=m.result_summary??m.detail??"",H.fadingOut=!0,setTimeout(()=>{const te=e.value.indexOf(H);te>=0&&e.value.splice(te,1),t.value.unshift(H),t.value.length>a&&t.value.pop()},5e3)}return}if(h==="tool_stream"){const F=m.call_id||m.tool_name||"unknown";if(m.finished){const O={...s.value};delete O[F],s.value=O}else{const I=((s.value[F]||"")+(m.chunk||"")).split(` +`);s.value={...s.value,[F]:I.slice(-30).join(` +`)}}return}}}let i=null;function l(){const p=Date.now();e.value.forEach(m=>{m.status==="running"&&(m.elapsed=p-m.startTime)})}let o=!1;function r(){o||(o=!0,mt.on("events",n),i||(i=setInterval(l,500)))}function c(){o&&(o=!1,mt.off("events",n),i&&(clearInterval(i),i=null))}st(r),cs(r),es(c),wt(c);function d(p){return p<1e3?`${p}ms`:`${(p/1e3).toFixed(1)}s`}function u(p){return p==="running"?"clock":p==="success"?"success":p==="error"?"error":"info"}return{activeTasks:e,recentHistory:t,streamOutput:s,formatMs:d,statusIcon:u}},template:` +
    +

    + Execution Viewer +

    + + +
    +

    Active

    +
    + No active tool executions +
    +
    +
    +
    + + + {{ task.tool }} + iter {{ task.iteration }} + agent {{ task.agentLabel || task.agentId }} +
    + {{ formatMs(task.elapsed) }} +
    + +
    Arguments
    + + +
    +
    + + +
    +

    Live Output

    +
    +
    {{ tool }}
    + + +
    +
    + + +
    +

    + Recent ({{ recentHistory.length }}) +

    +
    + No recent executions +
    +
    + + {{ task.tool }} + agent {{ task.agentLabel || task.agentId }} + {{ formatMs(task.elapsed) }} +
    Arguments
    + +
    +
    +
    + `};function Vu(e){if(e instanceof Date)return e;if(typeof e=="string"){const t=new Date(e);return isNaN(t.getTime())?null:t}return typeof e=="number"&&isFinite(e)?new Date(e<1e12?e*1e3:e):null}function di(e){const t=Vu(e);return t?t.toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}):"—"}function w1(e){const t=Vu(e);return t?t.toLocaleTimeString():"—"}function Ng(e){const t=Vu(e);if(!t)return"—";const s=Math.max(0,Math.floor((Date.now()-t.getTime())/1e3));return s<60?`${s}s ago`:s<3600?`${Math.floor(s/60)}m ago`:s<86400?`${Math.floor(s/3600)}h ago`:`${Math.floor(s/86400)}d ago`}function k1(e){if(e==null||!isFinite(e))return"—";const t=Math.max(0,Math.floor(Number(e)));return t<60?"less than 1 min ago":t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)} hr ago`:`${Math.floor(t/86400)} day ago`}function Xi(e){if(e==null||!isFinite(e))return"—";const t=Math.max(0,Math.round(e));if(t<60)return`${t}s`;if(t<3600){const n=Math.floor(t/60),i=t%60;return i?`${n}m ${i}s`:`${n}m`}const s=Math.floor(t/3600),a=Math.floor(t%3600/60);return a?`${s}h ${a}m`:`${s}h`}function Gu(e,t=200){const s=String(e??"");return s.length>t?s.slice(0,t)+"…":s}function Mg(e,t=5e3){const s=String(e??"");return s.length>t?s.slice(0,t)+` +... (truncated)`:s}function Hf(e){return String(e??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function Wu(e){return e==null||!isFinite(e)?"—":Number(e).toLocaleString()}function Dg(e){return e==null||!isFinite(e)?"—":e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}const Pg=Symbol("agent-detail-cancelled"),S1=15e3;function C1(e,{timeoutMs:t,timeoutLabel:s,scheduleTimeout:a,cancelTimeout:n}){const i=typeof AbortController=="function"?new AbortController:null;let l=null,o=!1,r,c;const d=new Promise((m,h)=>{r=m,c=h});function u(m,h){o||(o=!0,l!==null&&n(l),l=null,(m?r:c)(h))}let p;try{p=e(i==null?void 0:i.signal)}catch(m){u(!1,m)}return o||Promise.resolve(p).then(m=>u(!0,m),m=>u(!1,m)),!o&&Number.isFinite(t)&&t>0&&(l=a(()=>{const m=Math.max(1,Math.round(t/1e3));u(!1,new Error(`${s} request timed out after ${m}s`)),i==null||i.abort()},t)),{promise:d,cancel(){u(!0,Pg),i==null||i.abort()}}}function $g({state:e,requestDetail:t,timeoutMs:s=S1,detailLabel:a="Agent detail",scheduleTimeout:n=globalThis.setTimeout.bind(globalThis),cancelTimeout:i=globalThis.clearTimeout.bind(globalThis)}){if(!e||typeof e!="object")throw new TypeError("agent detail state is required");if(typeof t!="function")throw new TypeError("requestDetail must be a function");let l=null;function o(){const p=l;l=null,p==null||p.cancel()}function r(p,{initial:m,coalesce:h}){if(!p)return Promise.resolve();if(h&&l&&l.agentId===p&&e.detailId===p)return l.promise;o();const y={agentId:p,cancel:null,promise:null};l=y,m?(e.detail=null,e.detailError=null,e.detailLoading=!0):e.detail===null&&e.detailError===null&&(e.detailLoading=!0);const k=C1(A=>t(p,{signal:A}),{timeoutMs:s,timeoutLabel:a,scheduleTimeout:n,cancelTimeout:i});return y.cancel=k.cancel,y.promise=(async()=>{let A=null,g=null;try{A=await k.promise}catch(b){g=b}A!==Pg&&(l!==y||e.detailId!==p||(l=null,!g&&(A===null||typeof A!="object")&&(g=new Error(`${a} response was empty or invalid`)),g?e.detail===null&&(e.detailError=(g==null?void 0:g.message)||`Failed to load ${a.toLowerCase()}`):(e.detail=A,e.detailError=null),e.detailLoading=!1))})(),y.promise}function c(p){return e.detailId=p,r(p,{initial:!0,coalesce:!1})}function d(){const p=e.detailId;return p?r(p,{initial:!1,coalesce:!0}):Promise.resolve()}function u(){o(),e.detailId=null,e.detail=null,e.detailError=null,e.detailLoading=!1}return{open:c,refresh:d,close:u,hasInFlight:()=>l!==null}}function T1({isEnabled:e,refreshList:t,hasOpenDetail:s,refreshDetail:a,intervalMs:n=5e3,scheduleInterval:i=globalThis.setInterval.bind(globalThis),cancelInterval:l=globalThis.clearInterval.bind(globalThis)}){let o=null;function r(){e()&&(t(),s()&&a())}function c(){o!==null&&(l(o),o=null)}function d(){c(),e()&&(o=i(r,n))}function u(){e()?d():c()}return{start:d,stop:c,sync:u,isRunning:()=>o!==null}}const E1={components:{ToolOutput:pc},template:` +
    +
    +

    Agents

    +
    + + +
    +
    + + +
    +
    + {{ agents.length }} + Total +
    +
    + {{ runningCount }} + Running +
    +
    + {{ completedCount }} + Completed +
    +
    + {{ failedCount }} + Failed +
    +
    + + + + + +
    +
    +
    + +
    + + No agents + Agents are spawned via Discord commands or the chat interface +
    + + +
    + +
    +
    + +
    +
    + + {{ agent.label }} + {{ agent.id }} +
    + {{ agent.status }} +
    + + + +
    + {{ displayModelText(agent) }} + {{ displayEffortText(agent) }} +
    + + +
    {{ agent.goal }}
    + + +
    +
    +
    + + +
    +
    + Iterations + {{ agent.iteration_count }} +
    +
    + Runtime + {{ formatDuration(agent.runtime_seconds) }} +
    +
    + Tools + {{ agent.tools_used_count ?? 0 }} +
    +
    + + +
    + + by {{ agent.requester_name }} + + + {{ formatTs(agent.created_at) }} + +
    + + +
    +
    Result
    +
    {{ agent.result }}
    +
    +
    +
    Error
    +
    {{ agent.error }}
    +
    + +
    + + +
    + +
    +
    +
    + + + +
    `,setup(){const e=f([]),t=f(!0),s=f(null),a=f(null),n=f(!0),i=f("all");let l=!1;const o=B(()=>e.value.filter($=>$.status==="running").length),r=B(()=>e.value.filter($=>$.status==="completed").length),c=B(()=>e.value.filter($=>["failed","timeout","killed"].includes($.status)).length),d=B(()=>[{value:"all",label:"All",count:e.value.length},{value:"running",label:"Running",count:o.value},{value:"completed",label:"Completed",count:r.value},{value:"failed",label:"Failed",count:c.value}]),u=B(()=>i.value==="all"?e.value:i.value==="failed"?e.value.filter($=>["failed","timeout","killed"].includes($.status)):e.value.filter($=>$.status===i.value));function p($){const re=Number($.max_iterations)||0;return re<=0?0:Math.min(100,Math.round($.iteration_count/re*100))}function m($){return(Number($.max_iterations)||0)>0}function h($,re){return $?$==="N/A"?"N/A":re==="current_inheritance"?`inherit (currently ${$})`:$:"unknown"}function y($){return h($.display_model,$.display_model_source||$.display_source)}function k($){return h($.display_reasoning_effort,$.display_reasoning_effort_source||$.display_source)}function A($){return{last_execution:"last executed",current_inheritance:"inherited from current config — not yet executed",spawn_override_pending:"requested at spawn — not yet executed",unknown:"no execution data"}[$]||""}const g=f(null),b=f(null),x=f(!1),w=f(null),_=f(""),L=$g({state:{get detail(){return g.value},set detail($){g.value=$},get detailId(){return b.value},set detailId($){b.value=$},get detailLoading(){return x.value},set detailLoading($){x.value=$},get detailError(){return w.value},set detailError($){w.value=$}},requestDetail:($,{signal:re})=>z.get(`/api/agents/${encodeURIComponent($)}`,{signal:re})});async function E($){_.value="",await L.open($.id)}function F(){L.close(),_.value=""}async function O(){await L.refresh()}async function I($,re){try{await navigator.clipboard.writeText(re||""),_.value=$,setTimeout(()=>{_.value===$&&(_.value="")},1500)}catch{Se.error("Copy failed")}}async function H($=!1){$=$===!0,$||(t.value=!0);try{const re=await z.get("/api/agents");e.value=Array.isArray(re)?re:[],s.value=null}catch(re){$||(s.value=re.message)}$||(t.value=!1)}async function te($){const re=e.value.find(Z=>Z.id===$);if(await is({title:"Kill agent",message:`Kill agent "${(re==null?void 0:re.label)||$}"? Its current work will be lost.`,confirmLabel:"Kill",danger:!0})){a.value=$;try{await z.del(`/api/agents/${encodeURIComponent($)}`),Se.success("Agent killed"),await H()}catch(Z){Se.error(Z.message||"Failed to kill agent")}a.value=null}}const ee=T1({isEnabled:()=>n.value&&l,refreshList:()=>H(!0),hasOpenDetail:()=>!!b.value,refreshDetail:O});function N(){ee.start()}function M(){ee.stop()}return Gt(n,()=>ee.sync()),st(()=>{l=!0,H(),N()}),cs(()=>{l=!0,H(!0),N()}),es(()=>{l=!1,M()}),wt(()=>{l=!1,M(),L.close()}),{agents:e,loading:t,error:s,killing:a,autoRefresh:n,statusFilter:i,runningCount:o,completedCount:r,failedCount:c,statusFilters:d,filteredAgents:u,formatTs:di,formatDuration:Xi,progressPercent:p,hasProgress:m,displayModelText:y,displayEffortText:k,displaySourceLabel:A,detail:g,detailId:b,detailLoading:x,detailError:w,copied:_,openDetail:E,closeDetail:F,copyText:I,fetchAgents:H,killAgent:te,startAutoRefresh:N,stopAutoRefresh:M}}},A1={template:` +
    +
    +

    Autonomous Loops

    +
    + + +
    +
    + + +
    +

    Start New Loop

    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    {{ createError }}
    + + +
    + + +
    +
    +
    + +
    + + No active loops + Click "Start Loop" to create an autonomous recurring task +
    +
    + +
    +
    +
    {{ loops.length }}
    +
    Total Loops
    +
    +
    +
    {{ runningCount }}
    +
    Running
    +
    +
    +
    {{ totalIterations }}
    +
    Total Iterations
    +
    +
    + + +
    +
    +
    +
    +
    + + {{ loop.status || 'running' }} + {{ loop.mode }} + {{ loop.id }} +
    +
    + +
    {{ loop.goal }}
    + +
    +
    + Interval: + {{ formatDuration(loop.interval_seconds) }} +
    +
    + Iterations: + {{ loop.iteration_count }} / {{ loop.max_iterations }} +
    +
    +
    +
    +
    + Last trigger: + {{ loop.last_trigger_age_seconds == null ? 'pending' : formatDuration(loop.last_trigger_age_seconds) + ' ago' }} +
    +
    + Created: + {{ formatAge(loop.created_at) }} +
    +
    + +
    + Stop when: {{ loop.stop_condition }} +
    + +
    + Started by {{ loop.requester_name }} +
    + +
    + Latest context preview + {{ loop.iteration_history[loop.iteration_history.length - 1] }} +
    +
    +
    + + +
    +
    +
    +
    + + + +
    `,setup(){const e=f([]),t=f(!0),s=f(null),a=f(!1),n=f({goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""}),i=f(!1),l=f(null),o=f(null),r=f(null),c=f(null),d=f(null),u=f(!1),p=f(null),m=f("");let h=!1;const k=$g({state:{get detail(){return c.value},set detail(M){c.value=M},get detailId(){return d.value},set detailId(M){d.value=M},get detailLoading(){return u.value},set detailLoading(M){u.value=M},get detailError(){return p.value},set detailError(M){p.value=M}},detailLabel:"Loop detail",requestDetail:(M,{signal:$})=>z.get(`/api/loops/${encodeURIComponent(M)}?limit=100`,{signal:$})});async function A(M){m.value="",await k.open(M.id)}function g(){k.close(),m.value=""}async function b(M,$){try{await navigator.clipboard.writeText($||""),m.value=M,setTimeout(()=>{m.value===M&&(m.value="")},1500)}catch{Se.error("Copy failed")}}const x=B(()=>e.value.reduce((M,$)=>M+($.iteration_count||0),0)),w=B(()=>e.value.filter(M=>M.status==="running").length);function _(M){return M==="running"?"loop-status-running":M==="error"?"loop-status-error":"loop-status-stopped"}function T(M){return M==="running"?"badge-success":M==="error"?"badge-danger":M==="completed"?"badge-info":"badge-warning"}function L(M){return M==="act"?"badge-warning":M==="silent"?"badge-info":"badge-success"}async function E(M=!1){M=M===!0,M||(t.value=!0);try{const $=await z.get("/api/loops");e.value=Array.isArray($)?$:[],s.value=null}catch($){M||(s.value=$.message)}M||(t.value=!1)}async function F(){l.value=null;const M=n.value;if(!M.goal.trim()){l.value="Goal is required";return}if(!M.channel_id.trim()){l.value="Channel ID is required";return}const $={goal:M.goal.trim(),channel_id:M.channel_id.trim(),interval_seconds:M.interval_seconds||60,mode:M.mode,max_iterations:M.max_iterations||50};M.stop_condition.trim()&&($.stop_condition=M.stop_condition.trim()),i.value=!0;try{const re=await z.post("/api/loops",$);Se.success(`Loop started: ${re.loop_id}`),n.value={goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""},a.value=!1,await E()}catch(re){l.value=re.message}i.value=!1}async function O(M){if(await is({title:"Stop loop",message:`Stop loop ${M}? The current iteration will finish before stopping.`,confirmLabel:"Stop Loop",danger:!0})){o.value=M;try{await z.del(`/api/loops/${encodeURIComponent(M)}`),Se.success("Loop stopped"),await E()}catch(re){Se.error(re.message||"Failed to stop loop")}o.value=null}}async function I(M){r.value=M;try{await z.post(`/api/loops/${encodeURIComponent(M)}/restart`),Se.success("Loop restarted"),await E()}catch($){Se.error($.message||"Failed to restart loop")}r.value=null}function H(M){h&&M.payload&&(M.payload.loop_id||M.payload.type==="loop")&&(E(!0),d.value&&k.refresh())}let te=null;function ee(){te!==null&&clearInterval(te),te=null}function N(){ee(),h&&(te=setInterval(()=>{E(!0),d.value&&k.refresh()},5e3))}return st(()=>{h=!0,E(),mt.subscribe("events",H),N()}),cs(()=>{h=!0,E(!0),N()}),es(()=>{h=!1,ee()}),wt(()=>{h=!1,mt.unsubscribe("events",H),ee(),k.close()}),{loops:e,loading:t,error:s,showCreate:a,form:n,creating:i,createError:l,stoppingId:o,restartingId:r,detail:c,detailId:d,detailLoading:u,detailError:p,copied:m,totalIterations:x,runningCount:w,statusDotClass:_,statusBadge:T,modeBadge:L,formatAge:Ng,formatDuration:Xi,formatTs:di,formatTokens:Dg,openDetail:A,closeDetail:g,copyText:b,fetchLoops:E,doCreate:F,doStop:O,doRestart:I}}},R1={template:` +
    +
    +
    +

    Processes

    +

    Inspect managed command lifecycles, output, and exit state.

    +
    +
    + + +
    +
    + +
    +
    +
    + +
    + + No background processes + Processes appear when Odin runs long-running commands +
    +
    + +
    +
    +
    {{ processes.length }}
    +
    Total
    +
    +
    +
    {{ runningCount }}
    +
    Running
    +
    +
    +
    {{ completedCount }}
    +
    Completed
    +
    +
    + + +
    +
    +
    +
    + + PID {{ p.pid }} + {{ p.status }} + (exit {{ p.exit_code }}) +
    +
    + {{ formatDuration(p.uptime_seconds) }} + +
    +
    + +
    + {{ p.command }} +
    + +
    + Host: {{ p.host || 'local' }} +
    + + +
    +
    Recent output:
    +
    {{ p.output_preview.join('\\n') }}
    +
    +
    +
    +
    + +
    `,setup(){const e=f([]),t=f(!0),s=f(null),a=f(!0);let n=null;const i=f(null),l=B(()=>e.value.filter(g=>g.status==="running").length),o=B(()=>e.value.filter(g=>g.status!=="running").length);function r(g){return g==="running"?"loop-status-running":g==="failed"||g==="error"?"loop-status-error":"loop-status-stopped"}function c(g){return g==="running"?"badge-success":g==="completed"||g==="exited"?"badge-info":g==="killed"||g==="error"||g==="failed"?"badge-danger":"badge-warning"}async function d(g=!1){g=g===!0,g||(t.value=!0);try{e.value=await z.get("/api/processes"),s.value=null}catch(b){g||(s.value=b.message)}g||(t.value=!1)}function u(){p(),a.value&&(n=setInterval(()=>{t.value||d(!0)},5e3))}function p(){n&&(clearInterval(n),n=null)}Gt(a,g=>{g?u():p()});async function m(g){if(await is({title:"Kill process",message:`Kill process ${g}?`,confirmLabel:"Kill",danger:!0})){i.value=g;try{await z.del(`/api/processes/${g}`),Se.success(`Process ${g} killed`),await d()}catch(x){Se.error(x.message||"Failed to kill process")}i.value=null}}function h(g){g.payload&&(g.payload.pid||g.payload.type==="process")&&d(!0)}let y=!1;function k(){y||(y=!0,d(),mt.subscribe("events",h),u())}function A(){y&&(y=!1,mt.unsubscribe("events",h),p())}return st(k),cs(k),es(A),wt(A),{processes:e,loading:t,error:s,autoRefresh:a,killingPid:i,runningCount:l,completedCount:o,procStatusDot:r,statusBadge:c,formatDuration:Xi,fetchProcesses:d,doKill:m}}},I1=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;function jf(e,t){return t==="cron"&&String(e.cron||"").trim()?e.run_at="":t==="run_at"&&String(e.run_at||"").trim()&&(e.cron=""),e}function O1(e,t=!1){const s=n=>String(n).padStart(2,"0"),a=`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())}T${s(e.getHours())}:${s(e.getMinutes())}`;return t?`${a}:${s(e.getSeconds())}`:a}function L1(e){const t=-e.getTimezoneOffset(),s=t>=0?"+":"-",a=Math.abs(t),n=Math.floor(a/60),i=a%60;return`UTC${s}${n}${i?`:${String(i).padStart(2,"0")}`:""}`}function qf(e){const t=String(e||"").trim();if(!t)return{state:"empty"};const s=I1.exec(t);if(!s)return{state:"invalid",typed:t};const[,a,n,i,l,o]=s.slice(0,6).map(Number),r=s[6]===void 0?0:Number(s[6]);if(r>59)return{state:"invalid",typed:t};const c=s[6]!==void 0,d=c?t.slice(0,19):t.slice(0,16),u=Date.UTC(a,n-1,i,l,o,r),p=new Date(u-864e5).getTimezoneOffset(),m=new Date(u+864e5).getTimezoneOffset(),h=[];for(const k of new Set([p,m])){const A=new Date(u+k*6e4);O1(A,c)===d&&(h.some(g=>g.getTime()===A.getTime())||h.push(A))}if(h.sort((k,A)=>k.getTime()-A.getTime()),h.length===0)return{state:"nonexistent",typed:t};if(h.length>1)return{state:"ambiguous",typed:t,options:h.map(k=>({instant:k,offset:L1(k),iso:k.toISOString()}))};const y=h[0];return{state:"ok",typed:t,instant:y,iso:y.toISOString()}}const N1=5e3;function lr(e){const t=(e==null?void 0:e.available)===!0,s=typeof(e==null?void 0:e.reason)=="string"?e.reason:"provider_error";return{available:t,reason:s,epoch:Number.isInteger(e==null?void 0:e.epoch)?e.epoch:null}}function zo(e){if(e.available)return"";switch(e.reason){case"unavailable":return"Scheduling is not configured.";case"connecting":return"Scheduling is connecting.";case"disconnected":return"Scheduling is disconnected.";case"provider_error":return"Scheduling status provider failed.";default:return"Scheduling is unavailable."}}function Vf(e){var t;return(e==null?void 0:e.status)!==503||!((t=e==null?void 0:e.data)!=null&&t.connection)?null:lr(e.data.connection)}function M1(e){return e!=="webhook"}function Gf(e,t){return!M1(t)||(e==null?void 0:e.available)===!0}function D1(e,t=null){if((e==null?void 0:e.state)==="ok")return e.instant.toISOString();if((e==null?void 0:e.state)==="ambiguous"){if(t==null||t==="")throw new Error("Choose which occurrence of this repeated local time to use.");const s=e.options[Number(t)];if(!(s!=null&&s.instant))throw new Error("Choose a valid occurrence of this local time.");return s.instant.toISOString()}throw(e==null?void 0:e.state)==="nonexistent"?new Error("That local time does not exist because clocks skip it. Choose another time."):new Error("Enter a valid new one-time run time.")}const P1={template:` +
    +
    +
    +

    Schedules

    +

    Create, inspect, and run recurring or one-time automation.

    +
    +
    + + +
    +
    + +
    + {{ schedulingAvailabilityMessage }} Discord-delivered actions cannot be created, run, or resumed. Outbound HTTP webhooks remain available. +
    + + +
    +

    Create Schedule

    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +

    Choose one timing mode. Entering a Cron expression clears One-Time, and entering a One-Time value clears Cron.

    +
    +
    + Cron Expression +
    + + +
    + +
    +
    + Valid. Next runs: +
    + {{ formatTs(run) }} ({{ formatFuture(run) }}) +
    +
    +
    {{ cronResult.error }}
    +
    + +
    + +
    +
    +
    + + +
    + + +
    + +

    + Fires at {{ runAtUtcPreview }} +

    +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +

    + Requires the command to emit the generic paginated JSON contract. +

    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    + +
    {{ createError }}
    + + +
    + + +
    +
    +
    + +
    + + No scheduled tasks + Click "New Schedule" to set up automated checks or reminders +
    +
    + +
    +
    +
    {{ schedules.length }}
    +
    Total
    +
    +
    +
    {{ cronCount }}
    +
    Recurring
    +
    +
    +
    {{ oneTimeCount }}
    +
    One-Time
    +
    +
    +
    {{ webhookCount }}
    +
    Webhook
    +
    +
    +
    {{ pausedCount }}
    +
    Paused
    +
    +
    +
    {{ failingCount }}
    +
    Failing
    +
    +
    + +
    + + + + + + + + + + + + + + + +
    DescriptionTypeScheduleLast RunStatusActions
    +
    +
    + +
    `,setup(){const e=f([]),t=f(!0),s=f(null),a=f(lr(null)),n=B(()=>a.value.available),i=B(()=>zo(a.value));let l=null;const o=f(!1),r=f({description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""}),c=f(!1),d=f(null),u=B(()=>Gf(a.value,r.value.action));function p(U){return Gf(a.value,U)}const m=f(null),h=B(()=>qf(r.value.run_at));Gt(()=>r.value.run_at,()=>{m.value=null});const y=B(()=>{var de;const U=h.value;return U.state==="ok"?U.instant:U.state==="ambiguous"&&m.value!==null&&((de=U.options[m.value])==null?void 0:de.instant)||null}),k=B(()=>{const U=y.value;return U?`${U.toLocaleString()} local — ${U.toISOString()} UTC`:""}),A=f(null),g=f(!1),b=[{label:"Every hour",expr:"0 * * * *"},{label:"Every 6h",expr:"0 */6 * * *"},{label:"Daily 9am",expr:"0 9 * * *"},{label:"Weekly Mon",expr:"0 9 * * 1"},{label:"Every 30m",expr:"*/30 * * * *"}],x=f(null),w=f(null),_=f(null),T=f(null),L=f(null),E=f(null),F=f({}),O=f({}),I=f(null),H=f([]),te=f(!1),ee=f("");let N=0;const M=B(()=>e.value.filter(U=>U.cron&&!U.one_time).length),$=B(()=>e.value.filter(U=>U.one_time).length),re=B(()=>e.value.filter(U=>U.trigger).length),ce=B(()=>e.value.filter(U=>U.paused).length),Z=B(()=>e.value.filter(U=>U.consecutive_failures>0).length);function Q(U){if(!U)return"-";const de=Date.now(),Ae=(new Date(U).getTime()-de)/1e3;if(Ae<0)return"overdue";if(Ae<60)return"in < 1 min";if(Ae<3600)return`in ${Math.floor(Ae/60)} min`;if(Ae<86400){const We=Math.floor(Ae/3600),je=Math.floor(Ae%3600/60);return je>0?`in ${We}h ${je}m`:`in ${We}h`}const $e=Math.floor(Ae/86400);return`in ${$e} day${$e!==1?"s":""}`}function ae(U){return U==null?"-":U<1e3?`${U}ms`:U<6e4?`${(U/1e3).toFixed(1)}s`:Xi(U/1e3)}function V(U=r.value.cron){r.value.cron=U,jf(r.value,"cron"),A.value=null}function be(U=r.value.run_at){r.value.run_at=U,jf(r.value,"run_at"),A.value=null}function Oe(U){return qf(F.value[U.id]||"")}function le(U,de){F.value[U]=de,delete O.value[U]}function oe(U,de){O.value[U]=de===""?null:Number(de)}async function ve(U){let de;try{de=D1(Oe(U),O.value[U.id])}catch(ge){Se.error(ge.message);return}E.value=U.id;try{const ge=await z.put(`/api/schedules/${encodeURIComponent(U.id)}`,{run_at:de});ge!=null&&ge.inert_reason||ge!=null&&ge.paused?Se.error(`Schedule remains paused and inert: ${ge.inert_reason||"the server kept it paused"}`):(Se.success("Schedule re-armed for the new run time"),delete F.value[U.id],delete O.value[U.id]),await Y()}catch(ge){S(ge),Se.error(ge.message||"Failed to re-arm schedule")}finally{E.value=null}}async function j(){const U=r.value.cron.trim();if(U){g.value=!0;try{A.value=await z.post("/api/schedules/validate-cron",{expression:U})}catch(de){A.value={valid:!1,error:de.message}}g.value=!1}}async function Y(){t.value=!0,s.value=null;try{e.value=await z.get("/api/schedules")}catch(U){s.value=U.message}t.value=!1}async function xe(){try{a.value=lr(await z.get("/api/schedules/status"))}catch(U){a.value=Vf(U)||lr(null)}}function S(U){const de=Vf(U);de&&(a.value=de)}async function P(U){if(I.value===U){I.value=null,H.value=[];return}I.value=U,te.value=!0,H.value=[];const de=++N;try{const ge=await z.get(`/api/schedules/${encodeURIComponent(U)}/history?limit=10`);if(de!==N||I.value!==U)return;H.value=ge,ee.value=""}catch(ge){if(de!==N||I.value!==U)return;H.value=[],ee.value=ge.message||"Failed to load execution history"}de===N&&(te.value=!1)}async function G(){if(d.value=null,!p(r.value.action)){d.value=zo(a.value);return}const U=r.value;if(!U.description.trim()){d.value="Description is required";return}if(U.action!=="webhook"&&!U.channel_id.trim()){d.value="Channel ID is required";return}if(!U.cron.trim()&&!U.run_at.trim()){d.value="Cron expression or run_at time is required";return}if(U.cron.trim()&&U.run_at.trim()){d.value="Choose either Cron or One-Time, not both";return}const de={description:U.description.trim(),action:U.action,channel_id:U.channel_id.trim()};if(U.cron.trim()&&(de.cron=U.cron.trim()),U.run_at.trim()){const ge=h.value;if(ge.state==="nonexistent"){d.value="That local time does not exist (daylight saving gap)";return}if(ge.state==="invalid"){d.value="One-time run time is not a valid date";return}const Ae=y.value;if(ge.state==="ambiguous"&&m.value===null){d.value="That local time happens twice — choose which occurrence to use";return}if(!Ae){d.value="One-time run time could not be resolved";return}de.run_at=Ae.toISOString()}if(U.action==="reminder"&&U.message.trim()&&(de.message=U.message.trim()),U.action==="check"&&(U.tool_name.trim()&&(de.tool_name=U.tool_name.trim()),U.report_format&&(de.report_format=U.report_format),U.tool_input_str.trim()))try{de.tool_input=JSON.parse(U.tool_input_str.trim())}catch{d.value="Tool input must be valid JSON";return}if(U.action==="webhook"){if(!U.webhook_url.trim()){d.value="Webhook URL is required";return}const ge={url:U.webhook_url.trim(),method:U.webhook_method};if(U.webhook_headers_str.trim())try{const Ae=JSON.parse(U.webhook_headers_str.trim());if(!Ae||Array.isArray(Ae)||typeof Ae!="object")throw new Error("not an object");ge.headers=Ae}catch{d.value="Webhook headers must be a valid JSON object";return}if(U.webhook_body&&(ge.body=U.webhook_body),U.webhook_expected_status_str.trim()){const Ae=U.webhook_expected_status_str.split(",").map($e=>Number($e.trim()));if(Ae.some($e=>!Number.isInteger($e)||$e<100||$e>599)){d.value="Expected status codes must be comma-separated HTTP codes";return}ge.expected_status_codes=Ae}de.webhook_config=ge}c.value=!0;try{await z.post("/api/schedules",de),Se.success("Schedule created"),r.value={description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""},A.value=null,o.value=!1,await Y()}catch(ge){S(ge),d.value=ge.message}c.value=!1}async function he(U){if(!p(U.action)){Se.error(zo(a.value));return}const de=U.id;x.value=de;try{const ge=await z.post(`/api/schedules/${encodeURIComponent(de)}/run`);if(ge.status==="failure")Se.error(`Execution failed: ${ge.error||"unknown error"}`);else{const Ae=ge.warning?`Executed (${ge.warning})`:"Executed successfully";Se.success(Ae)}await Y()}catch(ge){S(ge),Se.error(ge.message||"Failed to trigger")}x.value=null}async function fe(U){if(U.paused&&!p(U.action)){Se.error(zo(a.value));return}_.value=U.id;const de=!U.paused;try{const ge=await z.put(`/api/schedules/${encodeURIComponent(U.id)}`,{paused:de});!de&&(ge!=null&&ge.inert_reason)?Se.error(`Schedule remains paused and inert: ${ge.inert_reason}`):Se.success(de?"Schedule paused":"Schedule resumed"),await Y()}catch(ge){S(ge),Se.error(ge.message||"Failed to update schedule")}_.value=null}const me=new Map;function we(U,de){const ge=me.get(U.id);ge&&clearTimeout(ge.timer);const Ae={run:()=>J(U,de),timer:null};Ae.timer=setTimeout(()=>{me.delete(U.id),Ae.run()},500),me.set(U.id,Ae)}async function J(U,de){L.value=U.id;try{await z.put(`/api/schedules/${encodeURIComponent(U.id)}`,{report_format:de}),Se.success(de?"Structured report enabled":"Plain-text report enabled")}catch(ge){Se.error(`Update failed: ${ge.message}`)}finally{await Y(),L.value=null}}function ie(){for(const[U,de]of[...me])clearTimeout(de.timer),me.delete(U),de.run()}async function X(U){T.value=U;try{await z.post(`/api/schedules/${encodeURIComponent(U)}/reset-failures`),Se.success("Failure counters reset"),await Y()}catch(de){Se.error(de.message||"Failed to reset")}T.value=null}async function _e(U){const de=e.value.find(Ae=>Ae.id===U);if(await is({title:"Delete schedule",message:`Delete "${(de==null?void 0:de.description)||U}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){w.value=U;try{await z.del(`/api/schedules/${encodeURIComponent(U)}`),Se.success("Schedule deleted"),await Y()}catch(Ae){Se.error(Ae.message||"Failed to delete schedule")}w.value=null}}return st(()=>{Y(),xe(),l=setInterval(xe,N1)}),wt(()=>{ie(),l&&clearInterval(l)}),{schedules:e,loading:t,error:s,schedulingAvailable:n,schedulingAvailabilityMessage:i,selectedActionAvailable:u,actionAvailable:p,showCreate:o,form:r,creating:c,createError:d,runAtUtcPreview:k,runAtAnalysis:h,runAtOccurrence:m,cronResult:A,validatingCron:g,cronPresets:b,runningId:x,deletingId:w,togglingId:_,resettingId:T,reportUpdatingId:L,flushReportFormatTimers:ie,rearmingId:E,rearmRunAt:F,rearmOccurrences:O,rearmAnalysis:Oe,expandedId:I,history:H,historyLoading:te,historyError:ee,cronCount:M,oneTimeCount:$,webhookCount:re,pausedCount:ce,failingCount:Z,formatTs:di,formatAge:Ng,formatFuture:Q,formatMs:ae,formatDuration:Xi,onCronInput:V,onRunAtInput:be,validateCron:j,toggleExpand:P,fetchSchedules:Y,fetchSchedulingAvailability:xe,doCreate:G,doRunNow:he,doTogglePause:fe,doUpdateReportFormat:we,doResetFailures:X,doDelete:_e,setRearmRunAt:le,setRearmOccurrence:oe,doRearmSchedule:ve}}},Fg=[{id:"live",label:"Live",component:_1},{id:"agents",label:"Agents",component:E1},{id:"loops",label:"Loops",component:A1},{id:"processes",label:"Processes",component:R1},{id:"schedules",label:"Schedules",component:P1}],$1={components:{TabbedPage:uc},setup(){return{tabs:Fg}},template:''},$d=/^\d{15,25}$/,Ho=new Map,jo=new Map;let qo=null,Vo=null;async function F1(){return qo||(Vo||(Vo=z.get("/api/discord/members").then(e=>(qo=Array.isArray(e)?e:[],qo)).catch(()=>[]).finally(()=>{Vo=null})),Vo)}async function B1(e){if(!$d.test(String(e)))return null;const t=(await F1()).find(s=>String(s.id)===String(e));return t?(Ho.set(String(e),t),t):Ho.has(String(e))?Ho.get(String(e)):(jo.has(String(e))||jo.set(String(e),z.get(`/api/discord/users/${encodeURIComponent(e)}`).then(s=>(s==null?void 0:s.user)||null).catch(()=>null).then(s=>(s&&Ho.set(String(e),s),jo.delete(String(e)),s))),jo.get(String(e)))}const Dn={props:{userId:{type:[String,Number],required:!0},members:{type:Array,default:()=>[]},showId:{type:Boolean,default:!0},fallbackLabel:{type:String,default:""}},template:` + + + {{ displayFallback }} + {{ userId }} + + `,setup(e){const t=f(null),s=B(()=>(e.members||[]).find(l=>String(l.id)===String(e.userId))||null),a=B(()=>s.value||t.value),n=B(()=>$d.test(String(e.userId))?e.userId:e.fallbackLabel||e.userId);async function i(){t.value=null,$d.test(String(e.userId))&&!s.value&&(t.value=await B1(e.userId))}return Gt(()=>[e.userId,e.members],i,{deep:!0}),st(i),{identity:a,displayFallback:n}}},U1={components:{DiscordIdentity:Dn},template:` +
    +
    +

    Audit Log

    +
    + + +
    +
    + + +
    +

    Verification failed: {{ verifyError }}

    +
    +
    +

    Tamper-evidence is not enabled — no signing key is configured, so the chain cannot be verified.

    +
    +
    +

    + Chain valid — {{ verifyResult.verified }} signed entr{{ verifyResult.verified === 1 ? 'y' : 'ies' }} verified across {{ (verifyResult.segments || []).length }} retained file{{ (verifyResult.segments || []).length === 1 ? '' : 's' }}. +

    +

    + Chain INVALID — problem in {{ verifyResult.first_bad_file }}{{ verifyResult.first_bad ? ' at line ' + verifyResult.first_bad : '' }}. +

    +

    + {{ verifyResult.unsigned_prefix.toLocaleString() }} older entries predate signing and are permanently unsigned — expected, not tampering. +

    +
      +
    • + {{ seg.file }}{{ seg.position === 0 ? ' (current)' : '' }}: {{ segmentText(seg) }} +
    • +
    +

    + Each file is verified on its own from its first entry. Files already removed by rotation, or deleted by hand from the oldest end, cannot be detected. +

    +
    + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + + +
    +
    + +
    +
    +
    + + +
    +
    +
    + +
    + + No audit entries found + Try adjusting your filters or wait for tool executions to appear +
    +
    +
    Showing {{ entries.length }} entries
    +
    + + + + + + + + + + + + + + +
    TimestampToolUserHostDurationStatus
    +
    + +
    +
    `,setup(){const e=f([]),t=f(!0),s=f(null),a=f(null),n=f({tool:"",user:"",keyword:"",date:"",limit:50});function i(k){if(!k)return"";if(typeof k=="string")return k;try{return JSON.stringify(k,null,2)}catch{return String(k)}}function l(k){a.value=a.value===k?null:k}function o(){n.value={tool:"",user:"",keyword:"",date:"",limit:50},y()}let r=0;const c=f(!1),d=f(null),u=f(null);async function p(){c.value=!0,u.value=null;try{d.value=await z.get("/api/audit/verify")}catch(k){k.status===409&&k.data&&typeof k.data=="object"?d.value=k.data.availability==="not_enabled"?{...k.data,not_enabled:!0}:k.data:(d.value=null,u.value=k.message||"verification request failed")}c.value=!1}function m(k){return k.status==="verified"||k.status==="unsigned"}function h(k){const A=b=>Number(b||0).toLocaleString();if(k.status==="verified"){if(!k.total)return"no entries yet";const b=k.unsigned_prefix>0?`; ${A(k.unsigned_prefix)} older entries predate signing`:"";return`verified — ${A(k.verified)} signed entries${b}`}if(k.status==="unsigned")return"no signatures — written before signing was enabled, so it cannot be verified";if(k.status==="unreadable")return`could not be read (${k.error||"error"})`;if(k.status==="missing")return"missing — newer and older files exist, but this one does not";const g=`${A(k.verified)} entries verified before it; later lines not checked`;return k.reason==="hmac_mismatch"?`break at line ${k.first_bad} — entry altered, reordered, or signed with a different key; ${g}`:k.reason==="signing_gap"?"no signatures although older files are signed — signing was switched off for this period, or signatures were removed":k.reason==="unsigned_after_signed"?`break at line ${k.first_bad} — unsigned entry after signed ones (signing was switched off, or a line was inserted); ${g}`:`break at line ${k.first_bad} — not a valid audit entry; ${g}`}async function y(){const k=++r;t.value=!0,s.value=null,a.value=null;try{const A=new URLSearchParams;n.value.tool&&A.set("tool",n.value.tool),n.value.user&&A.set("user",n.value.user),n.value.keyword&&A.set("q",n.value.keyword),n.value.date&&A.set("date",n.value.date),A.set("limit",String(n.value.limit));const g=A.toString(),b=await z.get(`/api/audit${g?"?"+g:""}`);if(k!==r)return;e.value=Array.isArray(b)?b:[]}catch(A){if(k!==r)return;s.value=A.message}k===r&&(t.value=!1)}return st(()=>{y()}),{entries:e,loading:t,error:s,expandedIdx:a,filters:n,formatTs:di,formatDetail:i,truncateBlock:Mg,toggleExpand:l,clearFilters:o,fetchAudit:y,verifying:c,verifyResult:d,verifyError:u,verifyIntegrity:p,segmentOk:m,segmentText:h}}},Wf=[{id:"all",name:"All Sessions",icon:"list",filters:{}},{id:"active",name:"Recently Active",icon:"activity",filters:{minAge:0,maxAge:3600}},{id:"discord",name:"Discord Only",icon:"message",filters:{source:"discord"}},{id:"web",name:"Web Only",icon:"globe",filters:{source:"web"}},{id:"long",name:"Long Conversations",icon:"book",filters:{minMessages:10}},{id:"compacted",name:"Compacted",icon:"archive",filters:{hasCompaction:!0}}],z1=[{value:"last_active",label:"Last Active"},{value:"created_at",label:"Created"},{value:"message_count",label:"Message Count"}],H1={components:{DiscordIdentity:Dn},template:` +
    + +
    +
    +

    Sessions

    +

    + {{ sessions.length }} session{{ sessions.length !== 1 ? 's' : '' }} + + · {{ filteredSessions.length }} shown + +

    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + + + + +
    + +
    + + +
    + +
    +
    + + +
    +
    +
    + + +
    +
    + Search History + Full-text search across all sessions and archives +
    +
    +
    + +
    + + + + +
    + +
    +
    Searching... +
    + +
    +
    Previous results — not current for these filters.
    +
    No results found
    +
    +
    {{ ftsResults.length }} result{{ ftsResults.length !== 1 ? 's' : '' }}
    +
    +
    +
    + {{ r.type }} + {{ r.channel_id }} + + {{ r.author }} + + {{ formatTimestamp(r.timestamp) }} + + + score: {{ Math.abs(r.rank).toFixed(1) }} + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    + + No active sessions + Sessions appear when users interact with Odin via Discord or the chat interface +
    +
    + + No sessions match the current filter + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    +
    + {{ s.channel_id }} + {{ s.message_count }} msg + compacted +
    +
    + Active {{ formatAge(s.last_active) }} · Created {{ formatAge(s.created_at) }} + · +
    +
    +
    + + + + +
    +
    + + +
    +
    + + {{ p.role === 'user' ? 'USER' : 'ODIN' }}: + + {{ p.content || '(empty)' }} +
    +
    + + +
    +
    +
    Loading... +
    +
    + +
    +
    Compacted Summary
    +
    {{ detail.summary }}
    +
    + + +
    + + + + {{ detail.messages.length }} message{{ detail.messages.length !== 1 ? 's' : '' }} + + · {{ threads.length }} thread{{ threads.length !== 1 ? 's' : '' }} + + +
    + + +
    +
    +
    + #{{ ti + 1 }} + {{ threadSummary(thread) }} + {{ thread.length }} msg + {{ formatTimestamp(thread[0].timestamp) }} + +
    +
    +
    +
    + {{ m.role }} + + + {{ formatTimestamp(m.timestamp) }} + +
    +
    {{ truncateContent(m.content) }}
    +
    +
    +
    + +
    No messages in this session
    +
    + + +
    +
    +
    + + {{ m.role }} + + + {{ formatTimestamp(m.timestamp) }} + +
    +
    {{ truncateContent(m.content) }}
    +
    + +
    No messages in this session
    +
    +
    +
    +
    +
    +
    + + + + + + +
    `,setup(){const e=f([]),t=f(!0),s=f(null),a=f(null),n=f(null),i=f(!1);let l=0;const o=f(null),r=f(!1),c=f(new Set),d=f(!1),u=f("all"),p=f(""),m=f("last_active"),h=f(!1),y=Wf,k=z1,A=f([]),g=f(!1),b=f(""),x=f("flat"),w=f(new Set),_=f(""),T=f(""),L=f(""),E=f(null),F=f(!1),O=f(""),I=f(!1);let H=0;Gt([_,T,L],()=>{H++,F.value=!1,O.value="",I.value=E.value!==null},{flush:"sync"});function te(){try{const W=localStorage.getItem("odin-session-presets");W&&(A.value=JSON.parse(W))}catch{}}function ee(){try{localStorage.setItem("odin-session-presets",JSON.stringify(A.value))}catch{}}const N=B(()=>p.value.trim()!==""||u.value!=="all"),M=B(()=>{let W=[...e.value];const De=Wf.find(et=>et.id===u.value),ze=De?De.filters:{};if(ze.source&&(W=W.filter(et=>et.source===ze.source)),ze.minMessages&&(W=W.filter(et=>et.message_count>=ze.minMessages)),ze.hasCompaction&&(W=W.filter(et=>et.has_summary)),ze.maxAge!=null){const et=Date.now()/1e3;W=W.filter(Rt=>Rt.last_active&&et-Rt.last_active<=ze.maxAge)}if(p.value.trim()){const et=p.value.toLowerCase().trim();W=W.filter(Rt=>(Rt.channel_id||"").toLowerCase().includes(et)||(Rt.last_user_id||"").toLowerCase().includes(et)||(Rt.source||"").toLowerCase().includes(et))}const Ye=m.value,Dt=h.value?1:-1;return W.sort((et,Rt)=>{const Pt=et[Ye]||0,Ts=Rt[Ye]||0;return(Pt-Ts)*Dt}),W}),$=B(()=>{if(!n.value||!n.value.messages)return[];const W=n.value.messages;if(W.length===0)return[];const De=[];let ze=[];for(const Ye of W)Ye.role==="user"&&ze.length>0&&(De.push(ze),ze=[]),ze.push(Ye);return ze.length>0&&De.push(ze),De}),re=B(()=>M.value.length>0&&c.value.size===M.value.length);function ce(W){const De=W.find(ze=>ze.role==="user");if(De&&De.content){const ze=De.content.slice(0,120);return ze.lengthDe.id!==W),ee(),u.value===W&&(u.value="all")}function Oe(){u.value="all",p.value="",m.value="last_active",h.value=!1}function le(W){if(!W)return"—";const De=Date.now()/1e3-W;if(De<60)return"just now";if(De<3600){const Ye=Math.floor(De/60);return`${Ye} minute${Ye!==1?"s":""} ago`}if(De<86400){const Ye=Math.floor(De/3600);return`${Ye} hour${Ye!==1?"s":""} ago`}const ze=Math.floor(De/86400);return`${ze} day${ze!==1?"s":""} ago`}function oe(W){if(!W)return"";try{return new Date(W*1e3).toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return""}}function ve(W){if(!W)return"";try{return new Date(W*1e3).toLocaleString()}catch{return""}}function j(W){return W==="user"?"bg-gray-900/50 border border-gray-800":W==="assistant"?"bg-indigo-950/30 border border-indigo-900/30":"bg-gray-900/30 border border-gray-800/50"}function Y(W){return W==="user"?"sess-msg-user":W==="assistant"?"sess-msg-assistant":"sess-msg-system"}function xe(W){return W==="user"?"badge-info":W==="assistant"?"badge-success":"badge-warning"}function S(W){return W==="user"?"sess-dot-user":W==="assistant"?"sess-dot-assistant":"sess-dot-system"}function P(W){return W==="user"?"text-cyan-400":W==="assistant"?"text-indigo-400":"text-gray-500"}function G(W){return W?W.length>2e3?W.slice(0,2e3)+` +... (truncated)`:W:""}async function he(){const W=_.value.trim();if(!W)return;const De=++H;F.value=!0,O.value="",I.value=E.value!==null;try{let ze=`/api/sessions/search?q=${encodeURIComponent(W)}&limit=50`;T.value.trim()&&(ze+=`&channel_id=${encodeURIComponent(T.value.trim())}`),L.value.trim()&&(ze+=`&user_id=${encodeURIComponent(L.value.trim())}`);const Ye=await z.get(ze);if(De!==H)return;E.value=Ye.results||[],I.value=!1}catch(ze){if(De!==H)return;O.value=ze.message||"Search failed. Please retry."}finally{De===H&&(F.value=!1)}}function fe(){H++,_.value="",T.value="",L.value="",E.value=null,O.value="",I.value=!1,F.value=!1}function me(W){return W?W.replace(/&/g,"&").replace(//g,">").replace(/>>>/g,'').replace(/<<</g,""):""}function we(W){return W==="user"?"fts-result-user":W==="assistant"?"fts-result-assistant":W==="summary"?"fts-result-summary":W==="fts"?"fts-result-fts":W==="channel"?"fts-result-channel":"fts-result-default"}function J(W){return W==="user"?"badge-info":W==="assistant"?"badge-success":W==="summary"?"badge-warning":W==="fts"?"badge-success":"badge-info"}let ie=0;async function X(){const W=++ie;t.value=!0,s.value=null;try{const De=await z.get("/api/sessions");if(W!==ie)return;e.value=De}catch(De){if(W!==ie)return;s.value=De.message}W===ie&&(t.value=!1)}function _e(){s.value=null,X()}async function U(W){if(a.value===W){a.value=null,n.value=null,w.value=new Set;return}a.value=W,n.value=null,i.value=!0,w.value=new Set;const De=++l;try{const ze=await z.get(`/api/sessions/${encodeURIComponent(W)}`);De===l&&a.value===W&&(n.value=ze)}catch(ze){De===l&&a.value===W&&(n.value={messages:[],summary:"",error:ze.message||"Failed to load session"})}finally{De===l&&(i.value=!1)}}function de(W){const De=new Set(c.value);De.has(W)?De.delete(W):De.add(W),c.value=De}function ge(){re.value?c.value=new Set:c.value=new Set(M.value.map(W=>W.channel_id))}function Ae(W){o.value=W}async function $e(){if(o.value){r.value=!0;try{await z.del(`/api/sessions/${encodeURIComponent(o.value)}`),a.value===o.value&&(a.value=null,n.value=null),c.value.delete(o.value),await X()}catch(W){s.value=W.message||"Failed to clear session"}r.value=!1,o.value=null}}function We(){d.value=!0}async function je(){if(c.value.size!==0){r.value=!0;try{await z.post("/api/sessions/clear-bulk",{channel_ids:[...c.value]}),c.value.has(a.value)&&(a.value=null,n.value=null),c.value=new Set,await X()}catch(W){s.value=W.message||"Failed to clear sessions"}r.value=!1,d.value=!1}}async function Xe(W,De){const ze=`/api/sessions/${encodeURIComponent(W)}/export?format=${De}`;try{const Ye=await z.getBlob(ze),Dt=URL.createObjectURL(Ye),et=document.createElement("a");et.href=Dt,et.download=`session-${W}.${De==="text"?"txt":"json"}`,et.click(),URL.revokeObjectURL(Dt)}catch(Ye){s.value=Ye.message||"Failed to export session"}}let it=null;function Qe(W){W.payload&&W.payload.channel_id&&(clearTimeout(it),it=setTimeout(()=>{if(X(),a.value&&W.payload.channel_id===a.value){const De=a.value,ze=l;z.get(`/api/sessions/${encodeURIComponent(De)}`).then(Ye=>{ze!==l||a.value!==De||(n.value=Ye)}).catch(()=>{})}},2e3))}let se=!1,Re=null;function Le(){se||(se=!0,X(),mt.subscribe("events",Qe),Re=mt.onReconnected(()=>X()))}st(()=>{te(),Le()}),cs(()=>{Le()});function Te(){se&&(se=!1,mt.unsubscribe("events",Qe),Re&&(Re(),Re=null),clearTimeout(it))}return es(Te),wt(Te),{sessions:e,loading:t,error:s,expandedId:a,detail:n,detailLoading:i,clearTarget:o,clearing:r,selected:c,allSelected:re,bulkClearing:d,activePreset:u,searchQuery:p,sortBy:m,sortAsc:h,filterPresets:y,sortOptions:k,filteredSessions:M,hasActiveFilters:N,customPresets:A,showSavePreset:g,newPresetName:b,threadView:x,threads:$,collapsedThreads:w,ftsQuery:_,ftsChannelId:T,ftsUserId:L,ftsResults:E,ftsSearching:F,ftsError:O,ftsStale:I,formatAge:le,formatTimestamp:oe,formatFullTimestamp:ve,messageClass:j,threadMsgClass:Y,roleBadge:xe,roleDotClass:S,roleLabelClass:P,truncateContent:G,threadSummary:ce,fetchSessions:X,retry:_e,toggleSession:U,toggleSelect:de,toggleSelectAll:ge,confirmClear:Ae,clearSession:$e,confirmBulkClear:We,doBulkClear:je,exportSession:Xe,applyPreset:Q,applyCustomPreset:ae,saveCustomPreset:V,removeCustomPreset:be,resetFilters:Oe,toggleThread:Z,runFtsSearch:he,clearFtsSearch:fe,highlightSnippet:me,ftsResultClass:we,ftsTypeBadge:J}}},j1={props:["trace"],template:` + +
    +
    Context Assembly
    +
    +
    + System tokens + {{ formatTokens(trace.summary?.system_tokens) }} +
    +
    + History tokens + {{ formatTokens(trace.summary?.history_used_tokens) }} +
    +
    + Learned injected + {{ trace.summary?.learned_injected ?? '—' }} + ({{ trace.learned?.mode || '?' }}) + +
    +
    + Continuity + {{ trace.continuity_source || '—' }} +
    +
    + + + + + + + + + + + + +
    SectionTokens
    {{ s.section }}{{ formatTokens(s.tokens) }}
    history ({{ trace.history.kept_recent }} recent + {{ trace.history.kept_relevant }} relevant of {{ trace.history.candidates }}){{ formatTokens(trace.history.used) }}
    +
    + {{ w.code }} +
    +
    + trace truncated ({{ trace.truncation_reason }}) +
    +
    + `,setup(){return{formatTokens:Dg}}},q1={components:{ContextAssemblyPanel:j1,DiscordIdentity:Dn},template:` +
    +
    +

    Trace Viewer

    +
    + +
    +
    + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + Filters +
    + + +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + + +
    +
    + Trace for message {{ singleTrace.message_id }} + +
    +
    +
    +
    + User + {{ !singleTrace.user_id ? (singleTrace.user_name || '—') : '' }} +
    +
    + Channel + {{ singleTrace.channel_id || '—' }} +
    +
    + Time + {{ formatTs(singleTrace.timestamp) }} +
    +
    + Status + error + handoff + ok +
    +
    + + +
    +
    + Iterations + {{ singleTrace.iteration_count || 0 }} +
    +
    + Tools Used + {{ (singleTrace.tools_used || []).length }} +
    +
    + Duration + {{ formatDuration(singleTrace.total_duration_ms) }} +
    +
    + Tokens + {{ formatTokens(singleTrace.total_input_tokens + singleTrace.total_output_tokens) }} +
    +
    + + +
    +
    User Message
    +
    {{ singleTrace.user_content }}
    +
    + + +
    +
    Tool Chain
    +
    +
    + +
    +
    + #{{ it.iteration + 1 }} +
    + + {{ tc.name || 'unknown' }} + + no tool calls +
    +
    +
    + {{ formatDuration(it.duration_ms) }} + {{ it.input_tokens + it.output_tokens }} tok + +
    +
    + + +
    + +
    +
    +
    +
    +
    {{ Math.round((it.duration_ms / singleTrace.total_duration_ms) * 100) }}% of total
    +
    + + +
    +
    LLM Text
    +
    {{ it.llm_text }}
    +
    + + +
    +
    + Call: {{ tc.name }} +
    +
    {{ formatJSON(tc.input) }}
    +
    + + +
    +
    + Result: {{ tr.name || 'result ' + tri }} +
    +
    {{ truncateBlock(formatJSON(tr.output || tr.result || tr.error || tr), 5000) }}
    +
    +
    +
    +
    +
    + + +
    +
    Final Response
    +
    {{ truncateBlock(singleTrace.final_response, 5000) }}
    +
    + + + + +
    + Tools: + {{ t }} +
    +
    +
    + + +
    +
    +
    +
    + +
    + + No traces found + Traces appear when the bot processes messages with tool calls +
    +
    +
    + Showing {{ entries.length }} traces + ({{ totalSaved }} total saved) +
    + + +
    + + + + + + + + + + + + + + + +
    TimestampUserMessageToolsDurationTokensStatus
    +
    + +
    +
    +
    `,setup(){const e=f([]),t=f([]),s=f(!0),a=f(null),n=f(null),i=f(null),l=f(""),o=f(""),r=f(0),c=f({}),d=f({channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50});function u(T){if(!T)return"—";try{const L=new Date(T);return isNaN(L.getTime())?T:L.toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return T}}function p(T){return!T&&T!==0?"—":T<1e3?T+"ms":(T/1e3).toFixed(1)+"s"}function m(T){return!T&&T!==0?"—":T>=1e3?(T/1e3).toFixed(1)+"k":String(T)}function h(T){if(!T)return"";if(typeof T=="string")return T;try{return JSON.stringify(T,null,2)}catch{return String(T)}}function y(T){n.value===T?n.value=null:(n.value=T,c.value={})}function k(T,L){const E=T+"-"+L;c.value={...c.value,[E]:!c.value[E]}}function A(T,L){return!!c.value[T+"-"+L]}function g(){d.value={channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50},o.value="",l.value="",i.value=null,w()}async function b(){try{const T=await z.get("/api/trajectories");e.value=T.files||[],r.value=T.count||0}catch{}}let x=0;async function w(){const T=++x;s.value=!0,a.value=null,n.value=null,i.value=null,c.value={};try{if(o.value){const L=await z.get(`/api/trajectories/${encodeURIComponent(o.value)}?limit=${d.value.limit}`);if(T!==x)return;let E=L.entries||[];d.value.tool_name&&(E=E.filter(F=>(F.tools_used||[]).includes(d.value.tool_name))),d.value.errors_only&&(E=E.filter(F=>F.is_error)),d.value.channel_id&&(E=E.filter(F=>F.channel_id===d.value.channel_id)),d.value.user_id&&(E=E.filter(F=>F.user_id===d.value.user_id)),t.value=E}else{const L=new URLSearchParams;d.value.channel_id&&L.set("channel_id",d.value.channel_id),d.value.user_id&&L.set("user_id",d.value.user_id),d.value.tool_name&&L.set("tool_name",d.value.tool_name),d.value.errors_only&&L.set("errors_only","true"),L.set("limit",String(d.value.limit));const E=L.toString(),F=await z.get(`/api/trajectories/search/query?${E}`);if(T!==x)return;t.value=F.results||[]}}catch(L){if(T!==x)return;a.value=L.message}T===x&&(s.value=!1)}async function _(){if(!l.value.trim())return;const T=++x;s.value=!0,a.value=null,c.value={};try{const L=await z.get(`/api/trajectories/message/${encodeURIComponent(l.value.trim())}`);if(T!==x)return;i.value=L.entry||null,i.value||(a.value="No trace found for this message ID")}catch(L){if(T!==x)return;L.status===404?(i.value=null,a.value="No trace found for message ID: "+l.value):a.value=L.message}T===x&&(s.value=!1)}return st(async()=>{await b(),await w()}),{files:e,entries:t,loading:s,error:a,expandedIdx:n,singleTrace:i,messageIdQuery:l,selectedFile:o,totalSaved:r,filters:d,expandedIterations:c,formatTs:u,formatDuration:p,formatTokens:m,formatJSON:h,truncateBlock:Mg,toggleExpand:y,toggleIteration:k,isIterationExpanded:A,clearFilters:g,fetchFiles:b,fetchTraces:w,lookupMessage:_}}};function V1(e){const t=Number(e);return!Number.isFinite(t)||t<=0?"—":t<1e3?`${Math.round(t)} ms`:t<6e4?`${(t/1e3).toFixed(1)} s`:t<36e5?`${(t/6e4).toFixed(1)} min`:`${(t/36e5).toFixed(1)} h`}function G1(e){return e?`${e.approximate?"~":""}${Wu(e.total||0)}`:"0"}const W1={template:` +
    +
    +
    +

    Usage & Activity

    +

    Persistent statistics from settled trajectories and the read-only audit index.

    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + + + +
    +
    +

    Last refresh failed: {{ error }} — showing the last successful response.

    +
    +
    +

    Historical indexing is still running. Recent data appears first; all-time totals are incomplete.

    +

    {{ data.coverage.sources_complete }} / {{ data.coverage.sources_indexed }} sources complete · {{ data.coverage.malformed_rows_skipped }} malformed rows skipped

    +
    +
    + Statistics are stale. Last successful receipt was more than 30 seconds ago. +
    + +
    +

    How much work happened

    +
    +
    {{ fmtNum(work.settled_turns) }}
    Settled turns
    +
    {{ fmtNum(work.accepted_generations) }}
    Accepted generations
    +
    {{ tokenLabel(work.input_tokens) }}
    Input processed
    +
    {{ tokenLabel(work.output_tokens) }}
    Output generated
    +
    {{ fmtDuration(work.recorded_processing_ms) }}
    Recorded processing
    +
    +
    + Input provenance: {{ fmtNum(work.input_tokens.provider_reported) }} provider-reported · + {{ fmtNum(work.input_tokens.estimated) }} current estimates · + {{ fmtNum(work.input_tokens.legacy_estimated) }} legacy estimates · + {{ work.input_tokens.provider_reported_percent }}% reported coverage. + Recorded processing is summed operation time, not wall-clock uptime. A dash means timing was not recorded; unavailable samples are excluded. +
    +
    + +
    + No settled usage history in this range. +
    + +
    +

    Activity over time

    +
    + +
    +

    Daily settled work units by recorded surface. Hover a bar for its value.

    +
    + +
    +
    +

    What kind of work

    +
    + + + + +
    SurfaceOutcomeTurnsProcessing
    {{ row.surface }}{{ row.outcome }}{{ fmtNum(row.count) }}{{ fmtDuration(row.duration_ms) }}
    No activity yet
    +
    + +
    +

    What served it

    +
    + + + + +
    Provider / modelEffortGenerationsInputOutputProcessing
    {{ row.provider }}
    {{ row.model }}
    {{ row.effort || 'n/a' }}{{ fmtNum(row.generations) }}{{ fmtNum(row.input_tokens) }}{{ fmtNum(row.output_tokens) }}{{ fmtDuration(row.duration_ms) }}
    No generations yet
    +
    +
    + +
    +

    What tools Odin used

    +
    + + + + +
    ToolExecutionsErrorsError rateAverage time
    {{ row.tool_name }}{{ fmtNum(row.executions) }}{{ fmtNum(row.errors) }}{{ row.error_rate_percent }}%{{ fmtDuration(row.avg_duration_ms) }}
    No audited tool executions yet
    +
    + +
    +

    Automation

    +
    {{ row.state }}: {{ fmtNum(row.count) }} · recoveries {{ fmtNum(row.recovery_attempts) }}No agent outcomes yet
    +
    + +
    +

    Provider-reported actual cost

    +

    {{ formatActualCost(data.cost?.actual_spend_usd) }}

    +

    {{ data.cost?.note || 'No provider-reported cost in this range.' }}

    +
    +
    + {{ row.model }} via {{ row.upstream_provider }} · {{ row.cached_percent }}% cached · {{ fmtNum(row.samples) }} measured generations +
    +
    +
    +
    +
    + `,setup(){const e=f(!0),t=f(null),s=f(!1),a=f({available:!0,coverage:{},work:{},activity:[],serving:[],tools:[],automation:[]}),n=f("7d"),i=f(0),l=f(Date.now());let o=null,r=null,c=!1,d=0;const u=[{key:"24h",label:"24 hours"},{key:"7d",label:"7 days"},{key:"30d",label:"30 days"},{key:"all",label:"All time"}],p=B(()=>a.value.work||{}),m=T=>T==null?"Not reported":`$${Number(T).toFixed(6)}`,h=B(()=>Math.max(1,...(a.value.activity_over_time||[]).map(T=>Number(T.count||0)))),y=B(()=>({minWidth:`max(100%, ${(a.value.activity_over_time||[]).length*5}px)`})),k=T=>({height:`${Math.max(4,Math.round(Number(T||0)/h.value*100))}%`}),A=B(()=>s.value&&l.value-i.value>3e4);async function g(){const T=++d,L=n.value;try{const E=await z.get(`/api/usage?range=${encodeURIComponent(L)}`);if(T!==d||L!==n.value)return;a.value=E,i.value=Date.now(),l.value=i.value,s.value=!0,t.value=null}catch(E){T===d&&(t.value=E.message)}finally{T===d&&(e.value=!1)}}function b(T){n.value=T,e.value=!s.value,g()}function x(){e.value=!0,g()}function w(){c||(c=!0,g(),o=setInterval(g,15e3),r=setInterval(()=>{l.value=Date.now()},1e3))}function _(){c&&(c=!1,d+=1,o&&clearInterval(o),r&&clearInterval(r),o=null,r=null)}return st(w),cs(w),es(_),wt(_),{data:a,work:p,loading:e,error:t,hasData:s,range:n,ranges:u,isStale:A,fmtNum:Wu,fmtDuration:V1,tokenLabel:G1,formatActualCost:m,activityTrackStyle:y,activityBar:k,selectRange:b,retry:x}}},Bg=[{id:"audit",label:"Audit",component:U1},{id:"sessions",label:"Sessions",component:H1},{id:"traces",label:"Traces",component:q1},{id:"usage",label:"Usage & Activity",component:W1}],K1={components:{TabbedPage:uc},setup(){return{tabs:Bg}},template:''},Kc=[{id:"system",label:"System & Commands",icon:"terminal",match:e=>/^(run_command|run_script|read_file|apply_patch|list_directory|search_files|manage_process|file_|post_file)/.test(e)},{id:"devops",label:"DevOps & Infrastructure",icon:"server",match:e=>/^(http_probe)/.test(e)},{id:"agents",label:"Agents & Orchestration",icon:"bot",match:e=>/^(spawn_agent|send_to_agent|wait_for_agents|get_agent_results|kill_agent|list_agents)/.test(e)},{id:"workflow",label:"Workflows & Tasks",icon:"workflow",match:e=>/^(delegate_task|cancel_task|list_tasks|schedule_|start_loop|stop_loop|list_loops|delete_schedule|list_schedules|update_schedule|parse_time)/.test(e)},{id:"network",label:"Network & Web",icon:"globe",match:e=>/^(web_|browser_|search_web|fetch_url|http_)/.test(e)},{id:"knowledge",label:"Knowledge & Search",icon:"book",match:e=>/^(search_knowledge|ingest_|knowledge_|search_history|search_audit|bulk_ingest|delete_knowledge|list_knowledge)/.test(e)},{id:"discord",label:"Discord & Admin",icon:"message",match:e=>/^(send_|add_reaction|create_poll|purge_|discord_|embed_|read_channel|set_permission)/.test(e)},{id:"skills",label:"Skills",icon:"puzzle",match:e=>/^(create_skill|edit_skill|delete_skill|enable_skill|disable_skill|install_skill|export_skill|skill_status|invoke_skill|list_skills)/.test(e)},{id:"memory",label:"Memory & State",icon:"brain",match:e=>/^(memory_manage|list_manage)/.test(e)},{id:"ai",label:"AI & Generation",icon:"sparkles",match:e=>/^(generate_|analyze_|vision_)/.test(e)},{id:"integrations",label:"Integrations",icon:"link",match:e=>/^mcp_/.test(e)},{id:"other",label:"Other Tools",icon:"wrench",match:()=>!0}],J1={template:` +
    +
    +
    +

    Tools

    +

    Disabling a tool removes it from future model requests and causes stored jobs that call it to fail. Already-running calls are not cancelled.

    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    + +
    +
    +
    {{ tools.length }}
    +
    Total Tools
    +
    +
    +
    {{ coreCount }}
    +
    Core Tools
    +
    +
    +
    {{ skillCount }}
    +
    Skill Tools
    +
    +
    +
    {{ totalUsage.toLocaleString() }}
    +
    Total Executions
    +
    +
    + + +
    + + +
    + + +
    +
    +
    + + {{ group.label }} + {{ group.tools.length }} +
    +
    +
    +
    + {{ t.name }} + {{ stateBadge(t) }} +
    +
    {{ truncate(t.description, 80) }}
    + + +
    +
    {{ t.description }}
    +
    Core capability. Disabling it may cause automation, recovery, or stored workflows that depend on it to fail.
    +
    +
    Parameters
    +
    + {{ pname }} + {{ prop.type }} + required +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + + {{ group.label }} + {{ group.tools.length }} +
    +
    + + + + + + + + + + + + +
    NameDescriptionUsesEnabled for model
    +
    +
    +
    + + +
    + + No tools match "{{ search }}" + Try a different search term +
    +
    +
    `,setup(){const e=f([]),t=f(!0),s=f(null),a=f(""),n=f({}),i=f({}),l=f("cards"),o=f(null),r=f(null),c=f(!1),d=f(new Set),u={disabled:"Disabled by operator",unavailable:"Unavailable — required backend is not configured",global_disabled:"Global tools disabled"};function p(E){return E.source!=="builtin"?"":u[E.state]||""}function m(E,F){const O=E&&Array.isArray(E.tools)?E.tools:null;if(c.value=!!O,r.value=O?!!E.global_enabled:null,!O){e.value=F.map(te=>({...te,source:"unknown",enabled:void 0,state:null}));return}const I=new Set(O.map(te=>te.name)),H=F.filter(te=>!I.has(te.name)).map(te=>({...te,source:te.name.startsWith("mcp_")?"mcp":"skill",enabled:!0,state:null}));e.value=[...O.map(te=>({...te,source:"builtin"})),...H]}async function h(E,F){if(d.value.has(E.name))return;const O=!!F.target.checked,I=new Set(d.value);I.add(E.name),d.value=I;try{const H=await z.post(`/api/tools/builtins/${encodeURIComponent(E.name)}/enabled`,{enabled:O});m(H,e.value),s.value=null;try{const te=await z.get("/api/tools");m(H,te)}catch(te){console.warn("Built-in toggle committed; visible catalog refresh failed",te)}}catch(H){F.target.checked=!!E.enabled,s.value=H.message||`Failed to toggle ${E.name}`}finally{const H=new Set(d.value);H.delete(E.name),d.value=H}}const y=B(()=>e.value.filter(E=>E.source==="builtin"&&E.is_core).length),k=B(()=>e.value.filter(E=>E.source==="skill").length),A=B(()=>Object.values(n.value).reduce((E,F)=>E+F,0));function g(E){for(const F of Kc)if(F.id!=="other"&&F.match(E))return F.id;return"other"}const b=B(()=>{let E=e.value;if(a.value){const F=a.value.toLowerCase();E=E.filter(O=>O.name.toLowerCase().includes(F)||(O.description||"").toLowerCase().includes(F))}return o.value&&(E=E.filter(F=>g(F.name)===o.value)),E}),x=B(()=>{const E=new Set;for(const F of e.value)E.add(g(F.name));return Kc.filter(F=>E.has(F.id))}),w=B(()=>{const E=b.value,F={};for(const I of E){const H=g(I.name);F[H]||(F[H]=[]),F[H].push(I)}const O=[];for(const I of Kc)F[I.id]&&F[I.id].length>0&&O.push({label:I.label,icon:I.icon,tools:F[I.id].sort((H,te)=>H.name.localeCompare(te.name))});return O});function _(E){i.value={...i.value,[E]:!i.value[E]}}async function T(){t.value=!0,s.value=null;try{const[E,F,O]=await Promise.all([z.get("/api/tools"),z.get("/api/tools/stats").catch(()=>({})),z.get("/api/tools/builtins").catch(()=>null)]);m(O,E),n.value=F||{}}catch(E){s.value=E.message}t.value=!1}function L(){T()}return st(()=>{T()}),{tools:e,loading:t,error:s,search:a,stats:n,expanded:i,viewMode:l,activeCategory:o,globalEnabled:r,inventoryAvailable:c,togglePending:d,coreCount:y,skillCount:k,totalUsage:A,filteredTools:b,groupedTools:w,usedCategories:x,stateBadge:p,applyInventory:m,toggleBuiltinTool:h,truncate:Gu,toggleExpand:_,refresh:L}}};function Z1(e){if(!e)return"";let t=e.replace(/&/g,"&").replace(//g,">");t=t.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,'$1'),t=t.replace(/(#[^\n]*)/g,'$1');const s="\\b(def|class|return|if|elif|else|for|while|import|from|as|try|except|finally|raise|with|async|await|yield|pass|break|continue|and|or|not|in|is|None|True|False|self|lambda)\\b";t=t.replace(new RegExp(s,"g"),'$1');const a="\\b(print|len|range|str|int|float|list|dict|set|tuple|type|isinstance|hasattr|getattr|setattr|super|property|staticmethod|classmethod|enumerate|zip|map|filter|sorted|reversed|any|all|min|max|sum|abs|round|open|format)\\b";return t=t.replace(new RegExp(a,"g"),'$1'),t=t.replace(/(@\w+)/g,'$1'),t=t.replace(/\b(\d+\.?\d*)\b/g,'$1'),t}function Q1(e){if(!e)return"1";const t=e.split(` +`).length;return Array.from({length:t},(s,a)=>a+1).join(` +`)}const Y1={template:` +
    +
    +

    Skills

    +
    + + +
    +
    + + +
    +
    +
    {{ skills.length }}
    +
    Total Skills
    +
    +
    +
    {{ enabledCount }}
    +
    Active Skills
    +
    +
    +
    {{ totalExecutions.toLocaleString() }}
    +
    Total Runs
    +
    +
    +
    {{ totalLines.toLocaleString() }}
    +
    Lines of Code
    +
    +
    + + +
    + +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + +
    + + No skills loaded + Click "New Skill" to create a custom tool +
    + + +
    +
    +
    + +
    +
    + + {{ s.name }} + {{ s.execution_count.toLocaleString() }} runs +
    +
    + + + + +
    +
    + + +
    +
    {{ s.description || 'No description' }}
    +
    + Loaded: {{ formatTs(s.loaded_at) }} + {{ countLines(s.code) }} lines +
    +
    + + +
    +
    + {{ testResults[s.name].is_error ? 'Test failed' : 'Test passed' }} +
    +
    {{ truncate(testResults[s.name].result, 500) }}
    +
    + + +
    +
    + {{ s.name }}.py + +
    +
    +
    {{ getLineNumbers(s.code) }}
    +
    +
    +
    +
    +
    + + +
    + + No skills match "{{ search }}" + Try a different search term +
    +
    + + +
    +
    +

    + {{ editMode === 'create' ? 'Create Skill' : 'Edit Skill: ' + editName }} +

    + +
    + +
    + +
    Lowercase, alphanumeric + underscores, starts with letter
    +
    + +
    + Code +
    +
    {{ editorLineNums }}
    + +
    +
    + {{ editLineCount }} lines + {{ editCode.length.toLocaleString() }} chars +
    +
    + + +
    + {{ editValidation.valid ? 'Valid Python structure' : editValidation.message }} +
    + +
    +
    Error
    +
    {{ editError }}
    +
    +
    {{ editSuccess }}
    + +
    + + +
    +
    + + + +
    `,setup(){const e=f([]),t=f(!0),s=f(null),a=f({}),n=f({}),i=f(null),l=f(""),o=f(null),r=f(!1),c=f("create"),d=f(""),u=f(""),p=f(null),m=f(null),h=f(!1),y=f(null),k=f(null),A=f(!1),g=B(()=>e.value.length),b=B(()=>e.value.reduce((V,be)=>V+(be.execution_count||0),0)),x=B(()=>e.value.reduce((V,be)=>V+F(be.code),0)),w=B(()=>{if(!l.value)return e.value;const V=l.value.toLowerCase();return e.value.filter(be=>be.name.toLowerCase().includes(V)||(be.description||"").toLowerCase().includes(V))}),_=B(()=>u.value?u.value.split(` +`).length:0),T=B(()=>{const V=Math.max(_.value,1);return Array.from({length:V},(be,Oe)=>Oe+1).join(` +`)}),L=B(()=>{const V=u.value.trim();return V?V.includes("SKILL_DEFINITION")?V.includes("async def execute")?{valid:!0,message:""}:{valid:!1,message:"Missing async def execute function"}:{valid:!1,message:"Missing SKILL_DEFINITION dict"}:null});function E(V){return Z1(V)}function F(V){return V?V.split(` +`).length:0}function O(V){return Q1(V)}function I(V){a.value={...a.value,[V]:!a.value[V]}}async function H(V){try{await navigator.clipboard.writeText(V);const be=e.value.find(Oe=>Oe.code===V);be&&(o.value=be.name,setTimeout(()=>{o.value=null},2e3))}catch{}}function te(V){if(V.key==="Tab"){V.preventDefault();const be=V.target,Oe=be.selectionStart,le=be.selectionEnd;u.value=u.value.substring(0,Oe)+" "+u.value.substring(le),Ut(()=>{be.selectionStart=be.selectionEnd=Oe+4})}}function ee(V){const be=V.target.previousElementSibling;be&&(be.scrollTop=V.target.scrollTop)}async function N(){t.value=!0,s.value=null;try{e.value=await z.get("/api/skills")}catch(V){s.value=V.message}t.value=!1}async function M(V){i.value=V,delete n.value[V],n.value={...n.value};try{const be=await z.post(`/api/skills/${encodeURIComponent(V)}/test`);n.value={...n.value,[V]:be}}catch(be){n.value={...n.value,[V]:{result:be.message,is_error:!0}}}i.value=null}function $(){r.value=!0,c.value="create",d.value="",u.value="",p.value=null,m.value=null}function re(V){r.value=!0,c.value="edit",d.value=V.name,u.value=V.code||"",p.value=null,m.value=null}function ce(){r.value=!1,p.value=null,m.value=null}async function Z(){p.value=null,m.value=null;const V=d.value.trim(),be=u.value.trim();if(!V){p.value="Name is required";return}if(!be){p.value="Code is required";return}h.value=!0;try{c.value==="create"?(await z.post("/api/skills",{name:V,code:be}),m.value="Skill created successfully"):(await z.put(`/api/skills/${encodeURIComponent(V)}`,{code:be}),m.value="Skill updated successfully"),await N(),setTimeout(()=>{r.value=!1},800)}catch(Oe){p.value=Oe.message}h.value=!1}function Q(V){k.value=V}async function ae(){if(k.value){A.value=!0;try{await z.del(`/api/skills/${encodeURIComponent(k.value)}`),await N()}catch(V){Se.error(`Failed to delete skill: ${V.message||"unknown error"}`)}A.value=!1,k.value=null}}return st(()=>{N()}),{skills:e,loading:t,error:s,showCode:a,testResults:n,testing:i,search:l,copied:o,editing:r,editMode:c,editName:d,editCode:u,editError:p,editSuccess:m,saving:h,editorRef:y,deleteTarget:k,deleting:A,enabledCount:g,totalExecutions:b,totalLines:x,displayedSkills:w,editLineCount:_,editorLineNums:T,editValidation:L,highlight:E,truncate:Gu,formatTs:di,countLines:F,getLineNumbers:O,toggleCode:I,copyCode:H,handleEditorKey:te,syncScroll:ee,fetchSkills:N,testSkill:M,showCreate:$,editSkill:re,cancelEdit:ce,saveSkill:Z,confirmDelete:Q,doDelete:ae}}};class ia extends Error{constructor(t,s=""){super(t),this.name="MCPFormError",this.field=s}}const X1=/^[A-Za-z_][A-Za-z0-9_]*$/;function Kf(e){return String(e||"").split(/\r?\n/).map(t=>t.trim()).filter(Boolean)}function Jf(e,t,s){const a={},n=[...new Set((t||[]).map(l=>String(l)))],i=new Set(n);for(const l of e||[]){const o=String((l==null?void 0:l.key)||"").trim(),r=String((l==null?void 0:l.value)??"");if(!(!o&&!r)){if(!o)throw new ia(`${s} key is required when a value is entered.`,"authentication");if(/[\r\n\0]/.test(o))throw new ia(`${s} keys cannot contain line breaks or NUL bytes.`,"authentication");if(Object.hasOwn(a,o))throw new ia(`${s} key “${o}” appears more than once.`,"authentication");if(i.has(o))throw new ia(`${s} key “${o}” cannot be replaced and removed in the same save.`,"authentication");a[o]=r}}return{set:a,remove:n}}function eC(e){try{const t=new URL(e);return(t.protocol==="http:"||t.protocol==="https:")&&!!t.hostname}catch{return!1}}function tC(e,{mode:t="add",originalTransport:s=""}={}){const a=t==="add",n=String(e.name||"").trim();if(!n)throw new ia("Server name is required.","name");if(n.length>128||!X1.test(n))throw new ia("Use at most 128 letters, digits, or underscores, with no leading digit.","name");const i=e.transport==="http"?"http":"stdio",l=!a&&!!s&&i!==s,o={enabled:!!e.enabled,transport:i};if(a&&(o.name=n),i==="stdio"){const d=String(e.command||"").trim();if((a||l)&&!d)throw new ia("An executable path is required for a new stdio connection.","command");if(d&&(o.command=d),(a||e.replaceArgs)&&(o.args=Kf(e.argsText)),a||e.replaceCwd){const u=String(e.cwd||"").trim();if(u&&(!u.startsWith("/")||u.includes("\0")))throw new ia("Working directory must be an absolute path.","cwd");o.cwd=u}}else{const d=String(e.url||"").trim();if((a||l)&&!d)throw new ia("An HTTP endpoint is required for this connection.","url");if(d&&!eC(d))throw new ia("Endpoint must be a valid http:// or https:// URL.","url");d&&(o.url=d)}if(a||e.replaceTimeout){const d=Number(e.timeoutSeconds);if(!Number.isInteger(d)||d<1||d>3600)throw new ia("Timeout must be a whole number from 1 to 3600 seconds.","timeout");o.timeout_seconds=d}(a||e.replaceAllowlist)&&(o.tool_allowlist=Kf(e.allowlistText));const r=Jf(e.headerRows,e.headersRemove,"Header"),c=Jf(e.envRows,e.envRemove,"Environment variable");return Object.keys(r.set).length&&(o.headers_set=r.set),r.remove.length&&(o.headers_remove=r.remove),Object.keys(c.set).length&&(o.env_set=c.set),c.remove.length&&(o.env_remove=c.remove),o}function sC(e,t){return t?e.transport!==t.transport||!!e.enabled!=!!t.enabled?!0:Object.keys(e).some(s=>!["enabled","transport"].includes(s)):!1}function aC(e){const t=String(e||"").toLowerCase();return["disabled","connecting","connected","stale","error","blocked"].includes(t)?t:"error"}function nC(e,t){const s=String(t||"").trim().toLowerCase();return s?[e==null?void 0:e.original_name,e==null?void 0:e.published_name,e==null?void 0:e.description,e==null?void 0:e.exclusion_reason].filter(Boolean).some(a=>String(a).toLowerCase().includes(s)):!0}const iC=Object.freeze([{id:"identity",label:"Identity"},{id:"transport",label:"Transport"},{id:"authentication",label:"Authentication"},{id:"limits",label:"Limits"}]);function lC(e,{root:t=document,reducedMotion:s=typeof window<"u"&&(a=>(a=window.matchMedia)==null?void 0:a.call(window,"(prefers-reduced-motion: reduce)").matches)()}={}){var l;const n=t.querySelector(".mcp-editor-groups"),i=n==null?void 0:n.querySelector(`#mcp-form-${e}`);return i?(i.scrollIntoView({behavior:s?"auto":"smooth",block:"start",inline:"nearest"}),(l=i.querySelector("[data-mcp-form-heading]"))==null||l.focus({preventScroll:!0}),!0):!1}const oC=1e4,rC=Object.freeze({disabled:"Disabled",connecting:"Connecting",connected:"Connected",stale:"Stale",error:"Error",blocked:"Blocked"});function Jc(){return{name:"",enabled:!0,transport:"stdio",command:"",argsText:"",cwd:"",url:"",timeoutSeconds:120,allowlistText:"",replaceArgs:!1,replaceCwd:!1,replaceTimeout:!1,replaceAllowlist:!1,headerRows:[],envRows:[],headersRemove:[],envRemove:[]}}function cC(e){if(e==null)return"Never";const t=Math.max(0,Number(e)||0);return t<60?`${Math.round(t)}s ago`:t<3600?`${Math.round(t/60)}m ago`:t<86400?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`}const dC={template:` +
    +
    +
    +
    Model Context Protocol
    +

    MCP Servers

    +

    Connect tool providers over stdio or Streamable HTTP. Only current, connected, validated tools enter the model catalog.

    +
    +
    + + +
    +
    + +
    +
    +
    +
    + + + + + + +
    + `,setup(){const e=f(null),t=f(!1),s=f(!1),a=f(!1),n=f(""),i=f({max_published_tools_per_server:"",max_published_tools_global:""}),l=f(new Set),o=B(()=>Object.keys(i.value).every(se=>{var Re;return Number.isInteger((Re=e.value)==null?void 0:Re[se])})),r=f(""),c=f(new Set),d=f(new Set),u=f({}),p=f({}),m=f({}),h=f(new Set),y=f(!1),k=f("add"),A=f(""),g=f(null),b=f(Jc()),x=f(""),w=f(!1);let _=null,T=0,L=!1,E=!1;const F=iC,O=B(()=>{var se;return((se=e.value)==null?void 0:se.servers)||[]}),I=B(()=>{var se;return!!((se=e.value)!=null&&se.enabled)}),H=B(()=>{var se,Re,Le,Te;return{serverCount:((se=e.value)==null?void 0:se.server_count)||0,enabledCount:((Re=e.value)==null?void 0:Re.enabled_server_count)||0,connectedCount:((Le=e.value)==null?void 0:Le.connected_count)||0,toolCount:((Te=e.value)==null?void 0:Te.published_tool_count)||0}}),te=B(()=>{var se;return((se=g.value)==null?void 0:se.header_keys)||[]}),ee=B(()=>{var se;return((se=g.value)==null?void 0:se.env_keys)||[]}),N=B(()=>{var se;return k.value==="edit"&&((se=g.value)==null?void 0:se.transport)==="http"}),M=B(()=>k.value==="add"||!N.value),$=B(()=>N.value?"Replace endpoint URL":"Endpoint URL"),re=B(()=>N.value?"Leave blank to keep the saved endpoint":"https://mcp.example.com/mcp");function ce(){Z(),_=window.setInterval(()=>Q({quiet:!0}),oC)}function Z(){_&&window.clearInterval(_),_=null}async function Q({quiet:se=!1}={}){if(a.value)return;const Re=++T;se||(t.value=!0);try{const Le=await z.get("/api/mcp/status");if(Re!==T||!L)return;e.value=Le;for(const W of Object.keys(i.value))!l.value.has(W)&&Number.isInteger(Le[W])&&(i.value[W]=String(Le[W]));r.value="";const Te=new Set((Le.servers||[]).map(W=>W.name));d.value=new Set([...d.value].filter(W=>Te.has(W)))}catch(Le){Re===T&&L&&(r.value=Le.message||"Failed to load MCP status")}finally{Re===T&&(t.value=!1)}}function ae(se){return s.value||c.value.has(se)}function V(se,Re){const Le=new Set(c.value);Re?Le.add(se):Le.delete(se),c.value=Le}function be(se){return aC(se.state)}function Oe(se){if(be(se)==="disabled"){if(!se.enabled)return"Disabled — server switch off";if(!I.value)return"Disabled — global MCP is off"}return rC[be(se)]}function le(se){return se.transport==="http"?"Streamable HTTP":"stdio"}function oe(se){return se.negotiated_version?`${se.era?`${String(se.era).charAt(0).toUpperCase()}${String(se.era).slice(1)}`:"Protocol"} · ${se.negotiated_version}`:"Not negotiated"}function ve(se){return se.discovered_count?`${se.published_count||0} published · ${se.excluded_count||0} excluded`:"No tools discovered"}const j=f(new Set);async function Y(se,Re){if(j.value.has(se.name))return;const Le=!!Re.target.checked,Te=new Set(j.value);Te.add(se.name),j.value=Te;try{const W=await z.post(`/api/mcp/servers/${encodeURIComponent(se.name)}/enabled`,{enabled:Le});W&&Array.isArray(W.servers)?e.value=W:await Q({quiet:!0})}catch(W){Re.target.checked=!!se.enabled,Se.error(W.message||`Failed to toggle ${se.name}`)}finally{const W=new Set(j.value);W.delete(se.name),j.value=W}}function xe(se,Re){var Te;i.value[se]=Re;const Le=new Set(l.value);Re===String((Te=e.value)==null?void 0:Te[se])?Le.delete(se):Le.add(se),l.value=Le,n.value=""}async function S(){if(s.value||!o.value||!l.value.size)return;const se={};for(const Re of l.value){const Le=Number(i.value[Re]),Te=Re==="max_published_tools_per_server"?128:256;if(!Number.isInteger(Le)||Le<1||Le>Te){n.value=`Enter a whole number between 1 and ${Te}.`;return}se[Re]=Le}a.value=!0,s.value=!0,n.value="",++T,t.value=!1;try{const Re=await z.post("/api/mcp/limits",se);e.value=Re;for(const Le of Object.keys(i.value))Number.isInteger(Re[Le])&&(i.value[Le]=String(Re[Le]));l.value=new Set,Se.success("MCP limits saved. Applied at the next publication or tools refresh.")}catch(Re){n.value=Re.message||"Failed to save MCP publication limits"}finally{a.value=!1,s.value=!1,await Q({quiet:!0})}}async function P(se){if(se!==I.value&&!(!se&&!await is({title:"Disable MCP tool publication",message:"Disable MCP globally? All MCP tools will be unpublished immediately and active transports will be stopped. Saved server configuration remains.",confirmLabel:"Disable MCP",danger:!0}))){s.value=!0;try{await z.post("/api/mcp/enabled",{enabled:se}),Se.success(se?"MCP enabled":"MCP disabled"),await Q({quiet:!0})}catch(Re){Se.error(Re.message||"Failed to update MCP state"),await Q({quiet:!0})}finally{s.value=!1}}}async function G(se){V(se.name,!0);try{await z.post(`/api/mcp/servers/${encodeURIComponent(se.name)}/reconnect`,{}),Se.success(`Reconnected ${se.name}`)}catch(Re){Se.error(Re.message||`Failed to reconnect ${se.name}`)}finally{V(se.name,!1),await Q({quiet:!0})}}async function he(se){V(se.name,!0);try{await z.post(`/api/mcp/servers/${encodeURIComponent(se.name)}/refresh-tools`,{}),Se.success(`Refreshed tools from ${se.name}`),await we(se.name,!0)}catch(Re){Se.error(Re.message||`Failed to refresh ${se.name}`)}finally{V(se.name,!1),await Q({quiet:!0})}}async function fe(se){if(await is({title:`Remove ${se.name}`,message:`Remove this saved MCP server? Its ${se.published_count||0} published tool${se.published_count===1?"":"s"} will disappear immediately and configured authentication keys will be deleted. This cannot be undone.`,confirmLabel:"Remove server",danger:!0})){V(se.name,!0);try{await z.del(`/api/mcp/servers/${encodeURIComponent(se.name)}`),Se.success(`Removed ${se.name}`),delete p.value[se.name]}catch(Le){Se.error(Le.message||`Failed to remove ${se.name}`)}finally{V(se.name,!1),await Q({quiet:!0})}}}async function me(se){const Re=new Set(d.value);if(Re.has(se.name)){Re.delete(se.name),d.value=Re;return}Re.add(se.name),d.value=Re,Object.hasOwn(p.value,se.name)||await we(se.name)}async function we(se,Re=!1){if(!Re&&Object.hasOwn(p.value,se))return;const Le=new Set(h.value);Le.add(se),h.value=Le,m.value={...m.value,[se]:""};try{const Te=await z.get(`/api/mcp/servers/${encodeURIComponent(se)}/tools`);p.value={...p.value,[se]:Te.tools||[]}}catch(Te){m.value={...m.value,[se]:Te.message||"Failed to load tools"}}finally{const Te=new Set(h.value);Te.delete(se),h.value=Te}}function J(se){return(p.value[se]||[]).filter(Re=>nC(Re,u.value[se]))}function ie(se,Re){u.value={...u.value,[se]:Re}}function X(){k.value="add",A.value="",g.value=null,b.value=Jc(),x.value="",y.value=!0}function _e(se){k.value="edit",A.value=se.name,g.value=se,b.value={...Jc(),name:se.name,enabled:!!se.enabled,transport:se.transport||"stdio"},x.value="",y.value=!0}function U(){w.value||(y.value=!1)}function de(se){y.value&&lC(se)}function ge(se){const Re=se==="headers"?"headerRows":"envRows";b.value[Re].push({key:"",value:""})}function Ae(se,Re){const Le=se==="headers"?"headerRows":"envRows";b.value[Le].splice(Re,1)}function $e(se,Re){const Le=se==="headers"?"headersRemove":"envRemove",Te=b.value[Le];b.value[Le]=Te.includes(Re)?Te.filter(W=>W!==Re):[...Te,Re]}async function We(){var Re,Le;x.value="";let se;try{se=tC(b.value,{mode:k.value,originalTransport:((Re=g.value)==null?void 0:Re.transport)||""})}catch(Te){x.value=Te instanceof ia?Te.message:"Invalid MCP server configuration",await Ut(),(Le=document.querySelector(".mcp-editor"))==null||Le.scrollTo({top:0,behavior:"smooth"});return}if(!(k.value==="edit"&&sC(se,g.value)&&!await is({title:`Change ${A.value} connection`,message:"Saving this configuration replaces the server runtime. Any current connection will be retired and its tools unpublished; enabled servers reconnect after the change.",confirmLabel:"Save and reconnect",danger:!0}))){w.value=!0;try{k.value==="add"?await z.post("/api/mcp/servers",se):await z.put(`/api/mcp/servers/${encodeURIComponent(A.value)}`,se),Se.success(k.value==="add"?`Saved ${se.name}`:`Updated ${A.value}`),y.value=!1,await Q({quiet:!0})}catch(Te){x.value=Te.message||"Failed to save MCP server"}finally{w.value=!1}}}let je=null;function Xe(se){`${(se==null?void 0:se.event)||""} ${(se==null?void 0:se.type)||""} ${(se==null?void 0:se.tool)||""} ${(se==null?void 0:se.message)||""}`.toLowerCase().includes("mcp")&&(je&&window.clearTimeout(je),je=window.setTimeout(()=>Q({quiet:!0}),200))}function it(){L||(L=!0,E||(mt.subscribe("events",Xe),E=!0),Q(),ce())}function Qe(){L=!1,Z(),je&&window.clearTimeout(je),je=null,E&&(mt.unsubscribe("events",Xe),E=!1)}return st(it),cs(it),es(Qe),wt(Qe),{status:e,loading:t,mutating:s,pageError:r,servers:O,masterEnabled:I,aggregate:H,limitsSaving:a,limitsError:n,limitsAvailable:o,limitDraft:i,limitDirty:l,editLimit:xe,saveLimits:S,expandedServers:d,toolQueries:u,toolErrors:m,toolsLoading:h,editorOpen:y,editorMode:k,editingName:A,editingServer:g,form:b,formError:x,saving:w,editorGroups:F,configuredHeaderKeys:te,configuredEnvKeys:ee,savedHttpEndpoint:N,endpointRequired:M,endpointFieldLabel:$,endpointPlaceholder:re,refreshAll:Q,busy:ae,serverState:be,stateLabel:Oe,transportLabel:le,protocolLabel:oe,toolSummary:ve,formatAge:cC,setMasterEnabled:P,togglePending:j,toggleServerEnabled:Y,reconnect:G,refreshTools:he,removeServer:fe,toggleTools:me,filteredTools:J,setToolQuery:ie,openAdd:X,openEdit:_e,closeEditor:U,jumpToEditorGroup:de,addSecretRow:ge,removeSecretRow:Ae,toggleSecretRemoval:$e,saveServer:We}}};function uC(e,t){if(!e||!t)return Hf(e);const s=Hf(e),a=t.trim().split(/\s+/).filter(Boolean);if(!a.length)return s;const n=a.map(i=>i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");try{return s.replace(new RegExp(`(${n})`,"gi"),'$1')}catch{return s}}const pC={template:` +
    +
    +

    Knowledge

    +
    + + +
    +
    + + +
    +
    + {{ sources.length }} + Sources +
    +
    + {{ totalChunks }} + Chunks +
    +
    + {{ uploaderCount }} + Uploaders +
    +
    + + +
    + + + +
    + + +
    +
    + Search Results {{ searchResults.length }} + for "{{ lastQuery }}" +
    +
    +

    Search error: {{ searchError }}

    +
    +
    + + No results for "{{ lastQuery }}" + Try different search terms or ingest more documents +
    +
    +
    +
    + {{ r.source || 'unknown' }} + {{ r.score.toFixed(3) }} + chunk #{{ r.chunk_index }} +
    +
    +
    +
    +
    + + +
    +

    Ingest Document

    +
    + +
    +
    + +
    +
    {{ ingestError }}
    +
    + {{ ingestSuccess }} +
    + +
    + + +
    +
    +
    + +
    + + No documents ingested + Click "Ingest Document" to add knowledge for Odin to reference +
    +
    +
    + Sources {{ sources.length }} +
    +
    +
    + +
    + + + {{ s.source || s.name || s }} + {{ s.chunks || 0 }} chunks + {{ s.uploader }} +
    + + +
    +
    + + +
    + Ingested: {{ formatTs(s.ingested_at) }} +
    +
    {{ s.preview }}
    + + +
    + {{ reingestResult.message }} +
    + + +
    +
    +
    Loading chunks... +
    +
    +
    + {{ sourceChunks[s.source || s.name || s].length }} chunks + Ingested: {{ formatTs(s.ingested_at) }} +
    +
    +
    + #{{ chunk.chunk_index }} + {{ chunk.char_count }} chars +
    +
    +
    +
    +
    {{ chunk.content }}
    +
    {{ truncate(chunk.content, 120) }}
    +
    +
    +
    Couldn't load chunks — {{ chunkErrors[s.source || s.name || s] }}. Collapse and expand to retry.
    +
    No chunks found
    +
    +
    +
    +
    + + + +
    `,setup(){const e=f([]),t=f(!0),s=f(null),a=f(""),n=f(null),i=f(!1),l=f(""),o=f(null),r=f(!1),c=f(""),d=f(""),u=f(null),p=f(null),m=f("success"),h=f(!1),y=f(null),k=f(null);let A=null;const g=f(null),b=f(!1),x=f({}),w=f({}),_=f({}),T=f({}),L=new Map,E=f(null),F=B(()=>e.value.reduce((Q,ae)=>Q+(ae.chunks||0),0)),O=B(()=>new Set(e.value.map(ae=>ae.uploader).filter(Boolean)).size);function I(Q,ae){const V=w.value[ae];if(!V||V.length===0)return 0;const be=Math.max(...V.map(Oe=>Oe.char_count||0));return be===0?0:Math.round(Q.char_count/be*100)}async function H(){t.value=!0,s.value=null;try{const Q=await z.get("/api/knowledge");e.value=Array.isArray(Q)?Q:[]}catch(Q){s.value=Q.message}t.value=!1}async function te(Q){if(x.value[Q]){x.value[Q]=!1,E.value=null;return}if(x.value[Q]=!0,Object.prototype.hasOwnProperty.call(w.value,Q))return;if(L.has(Q))return L.get(Q);const ae={...T.value,[Q]:!0};T.value=ae;const V={..._.value};delete V[Q],_.value=V;const be=z.get(`/api/knowledge/${encodeURIComponent(Q)}/chunks`).then(Oe=>{w.value={...w.value,[Q]:Array.isArray(Oe)?Oe:[]}}).catch(Oe=>{_.value={..._.value,[Q]:Oe.message||"load failed"}}).finally(()=>{if(L.get(Q)!==be)return;L.delete(Q);const Oe={...T.value};delete Oe[Q],T.value=Oe});return L.set(Q,be),be}let ee=0;async function N(){const Q=a.value.trim();if(!Q)return;const ae=++ee;i.value=!0,o.value=null,l.value=Q;try{const V=await z.get(`/api/knowledge/search?q=${encodeURIComponent(Q)}`);if(ae!==ee)return;n.value=Array.isArray(V)?V:[]}catch(V){if(ae!==ee)return;n.value=[],o.value=V.message||"Search failed"}ae===ee&&(i.value=!1)}function M(){ee+=1,i.value=!1,n.value=null,a.value="",o.value=null}async function $(){u.value=null,p.value=null,m.value="success";const Q=c.value.trim(),ae=d.value.trim();if(!Q){u.value="Source name is required";return}if(!ae){u.value="Content is required";return}h.value=!0;try{const V=await z.post("/api/knowledge",{source:Q,content:ae}),be=V.outcome==="duplicate"||V.outcome==="conflict";V.outcome==="unchanged"?(p.value=`Already stored unchanged: "${Q}" (${V.chunks} chunks)`,c.value="",d.value=""):be?(m.value="warning",p.value=V.message||V.status):Number.isInteger(V.chunks)&&V.chunks>0?(p.value=`Ingested ${V.chunks} chunks from "${Q}"`,c.value="",d.value=""):(m.value="warning",p.value="Ingestion returned no confirmed stored content; check the source before retrying."),w.value={},await H(),!be&&m.value==="success"&&setTimeout(()=>{r.value=!1,p.value=null},1500)}catch(V){u.value=V.message}h.value=!1}async function re(Q){y.value=Q,k.value=null,A&&(clearTimeout(A),A=null);try{const ae=await z.post(`/api/knowledge/${encodeURIComponent(Q)}/reingest`),V=ae.outcome==="duplicate"||ae.outcome==="conflict";k.value={source:Q,error:!1,warning:V,message:V?ae.message||ae.status:ae.outcome==="unchanged"?`Already stored unchanged (${ae.chunks} chunks)`:`Re-ingested ${ae.chunks||0} chunks`},V||(delete w.value[Q],await H()),A=setTimeout(()=>{k.value=null,A=null},3e3)}catch(ae){k.value={source:Q,error:!0,message:ae.message}}y.value=null}function ce(Q){g.value=Q}async function Z(){if(g.value){b.value=!0;try{await z.del(`/api/knowledge/${encodeURIComponent(g.value)}`),delete w.value[g.value],await H()}catch(Q){Se.error(`Failed to delete source: ${Q.message||"unknown error"}`)}b.value=!1,g.value=null}}return st(()=>{H()}),{sources:e,loading:t,error:s,searchQuery:a,searchResults:n,searching:i,lastQuery:l,searchError:o,showIngest:r,ingestSource:c,ingestContent:d,ingestError:u,ingestSuccess:p,ingestNoticeType:m,ingesting:h,reingesting:y,reingestResult:k,deleteTarget:g,deleting:b,expanded:x,sourceChunks:w,chunkErrors:_,loadingChunks:T,selectedChunk:E,totalChunks:F,uploaderCount:O,truncate:Gu,formatTs:di,highlightTerms:uC,chunkBarWidth:I,fetchSources:H,toggleSource:te,doSearch:N,clearSearch:M,doIngest:$,doReingest:re,confirmDelete:ce,doDelete:Z}}},fC={template:` +
    +
    +

    Memory

    +
    + + +
    +
    + + +
    +
    + {{ totalEntries }} + Entries +
    +
    + {{ scopes.length }} + Scopes +
    +
    + {{ selectedCount }} + Selected +
    +
    + + Select entries to delete +
    +
    + + +
    + +
    + + +
    +

    Add Memory Entry

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    {{ addError }}
    +
    {{ addSuccess }}
    + +
    + + +
    +
    +

    {{ actionError }}

    + +
    +
    + + +
    +
    +
    + +
    + + No memory entries + Click "Add Entry" or let Odin learn preferences through conversations +
    + + +
    +
    + +
    + + + {{ scope.name }} + + {{ scope.count }} keys + +
    + + +
    +
    +
    Loading... +
    +
    + {{ filterQuery ? 'No matching keys' : 'No entries' }} +
    +
    +
    +
    + + {{ entry.key }} +
    + + + +
    +
    +
    + +
    + + +
    +
    + +
    {{ entry.value }}
    +
    +
    +
    +
    +
    + + + + + + +
    `,setup(){const e=f([]),t=f({}),s=f(!0),a=f(null),n=f({}),i=f(null),l=f(""),o=f(!1),r=f({scope:"global",key:"",value:""}),c=f(!1),d=f(null),u=f(null),p=f(null),m=f(""),h=f(!1),y=f(null),k=f(null),A=f(new Set),g=f(null),b=f(!1),x=f(!1),w=B(()=>e.value.reduce((Q,ae)=>Q+ae.count,0)),_=B(()=>A.value.size);function T(Q){const ae=t.value[Q];if(!ae)return[];if(!l.value.trim())return ae;const V=l.value.trim().toLowerCase();return ae.filter(be=>be.key.toLowerCase().includes(V)||be.value&&be.value.toLowerCase().includes(V))}function L(Q,ae){return A.value.has(Q+"/"+ae)}function E(Q,ae){const V=Q+"/"+ae,be=new Set(A.value);be.has(V)?be.delete(V):be.add(V),A.value=be}function F(Q){const ae=t.value[Q];return!ae||ae.length===0?!1:ae.every(V=>A.value.has(Q+"/"+V.key))}function O(Q,ae){const V=t.value[Q];if(!V)return;const be=new Set(A.value);for(const Oe of V){const le=Q+"/"+Oe.key;ae?be.add(le):be.delete(le)}A.value=be}async function I(){s.value=!0,a.value=null;try{const Q=await z.get("/api/memory");e.value=Object.entries(Q).map(([ae,V])=>({name:ae,keys:V.keys||[],count:V.count||0}))}catch(Q){a.value=Q.message}s.value=!1}async function H(Q){if(n.value[Q]){n.value[Q]=!1;return}n.value[Q]=!0;const ae=e.value.find(be=>be.name===Q);if(!ae||t.value[Q]||i.value===Q)return;i.value=Q;let V;try{const Oe=(await z.get(`/api/memory/${encodeURIComponent(Q)}`)).entries||{};V=ae.keys.map(le=>Object.prototype.hasOwnProperty.call(Oe,le)?{key:le,value:Oe[le]||"",failed:!1}:{key:le,value:"",failed:!0,error:"Not found in scope"})}catch(be){V=ae.keys.map(Oe=>({key:Oe,value:"",failed:!0,error:be.message||"Failed to load"}))}t.value[Q]=V,i.value=null}function te(Q,ae,V){p.value=Q+"/"+ae,m.value=V}async function ee(Q,ae){h.value=!0,y.value=null;try{await z.put(`/api/memory/${encodeURIComponent(Q)}/${encodeURIComponent(ae)}`,{value:m.value});const V=t.value[Q];if(V){const be=V.find(Oe=>Oe.key===ae);be&&(be.value=m.value)}p.value=null}catch(V){y.value=`Failed to save: ${V.message||"unknown error"}`}h.value=!1}async function N(Q,ae){try{await navigator.clipboard.writeText(ae.value),k.value=Q+"/"+ae.key,setTimeout(()=>{k.value=null},1500)}catch{}}async function M(){d.value=null,u.value=null;const Q=r.value.scope.trim(),ae=r.value.key.trim(),V=r.value.value.trim();if(!Q){d.value="Scope is required";return}if(!ae){d.value="Key is required";return}if(!V){d.value="Value is required";return}c.value=!0;try{await z.put(`/api/memory/${encodeURIComponent(Q)}/${encodeURIComponent(ae)}`,{value:V}),u.value="Entry saved",r.value={scope:"global",key:"",value:""},t.value={},await I(),setTimeout(()=>{o.value=!1,u.value=null},800)}catch(be){d.value=be.message}c.value=!1}function $(Q,ae){g.value={scope:Q,key:ae}}async function re(){if(!g.value)return;b.value=!0,y.value=null;const{scope:Q,key:ae}=g.value;try{await z.del(`/api/memory/${encodeURIComponent(Q)}/${encodeURIComponent(ae)}`);const V=t.value[Q];V&&(t.value[Q]=V.filter(le=>le.key!==ae));const be=e.value.find(le=>le.name===Q);be&&(be.count--,be.keys=be.keys.filter(le=>le!==ae));const Oe=new Set(A.value);Oe.delete(Q+"/"+ae),A.value=Oe}catch(V){y.value=`Failed to delete: ${V.message||"unknown error"}`}b.value=!1,g.value=null}function ce(){x.value=!0}async function Z(){b.value=!0,y.value=null;const Q=[];for(const ae of A.value){const V=ae.indexOf("/");Q.push({scope:ae.slice(0,V),key:ae.slice(V+1)})}try{await z.post("/api/memory/bulk-delete",{entries:Q}),A.value=new Set,t.value={},await I()}catch(ae){y.value=`Bulk delete failed: ${ae.message||"unknown error"}`}b.value=!1,x.value=!1}return st(()=>{I()}),{scopes:e,scopeEntries:t,loading:s,error:a,expanded:n,loadingScope:i,filterQuery:l,showAdd:o,addForm:r,adding:c,addError:d,addSuccess:u,editingKey:p,editValue:m,saving:h,actionError:y,copied:k,selected:A,selectedCount:_,totalEntries:w,deleteTarget:g,deleting:b,showBulkDelete:x,fetchMemory:I,toggleScope:H,startEdit:te,doEdit:ee,copyValue:N,doAdd:M,confirmDelete:$,doDelete:re,confirmBulkDelete:ce,doBulkDelete:Z,isSelected:L,toggleSelect:E,isScopeAllSelected:F,toggleSelectAll:O,filteredEntries:T}}},mC={components:{DiscordIdentity:Dn},template:` +
    +
    +
    +

    Learned Context

    +

    + {{ entries.length }} entries | Last reflection: {{ formatTs(meta.last_reflection) }} +

    +
    + +
    + +
    +
    +
    +

    Automatic learning

    +

    When off, Odin neither creates automatic lessons nor adds stored learned entries to model context. Existing entries are retained.

    +

    Current state: {{ learningEnabled ? 'On' : 'Off' }}

    + +

    Checking administrator configuration…

    +
    + +
    +
    + +
    +
    +
    +
    +

    {{ error }}

    + +
    +
    + + No learned entries yet + Existing entries remain available whether automatic learning is on or off +
    + +
    + +
    + +
    + + +
    +
    +
    +
    + {{ entry.key }} + {{ entry.category }} + + user: + +
    +
    + +
    + + +
    +
    +

    {{ entry.content }}

    +
    + Created: {{ formatTs(entry.created_at) }} + | Updated: {{ formatTs(entry.updated_at) }} +
    +
    +
    + + +
    +
    +
    +
    +
    + `,setup(){const e=f([]),t=f(null),s=f(!0),a=f(null),n=f(null),i=f(null),l=f(""),o=f(!1),r=f(!1),c=f(null),d=f(!1),u=B(()=>[...new Set(e.value.map(_=>_.category))].sort()),p=B(()=>{const w={};return e.value.forEach(_=>{w[_.category]=(w[_.category]||0)+1}),w}),m=B(()=>n.value?e.value.filter(w=>w.category===n.value):e.value);function h(w){return w==="correction"?"badge-warning":w==="operational"?"badge-info":w==="preference"?"badge-success":"badge-info"}function y(w){i.value=w.key,l.value=w.content}async function k(w){try{await z.put("/api/learned/"+encodeURIComponent(w),{content:l.value}),i.value=null,Se.success("Entry updated"),await g()}catch(_){Se.error(_.message||"Failed to save entry")}}async function A(w){if(await is({title:"Delete learned entry",message:`Delete "${w}"? Odin will no longer apply this learned context.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/learned/"+encodeURIComponent(w)),Se.success("Entry deleted"),await g()}catch(T){Se.error(T.message||"Failed to delete entry")}}async function g(){s.value=!0,a.value=null;try{const w=await z.get("/api/learned");e.value=w.entries||[],t.value={last_reflection:w.last_reflection,count:w.count}}catch(w){a.value=w.message}s.value=!1}async function b(){var w;r.value=!1,c.value=null;try{const _=await z.get("/api/config");o.value=((w=_.learning)==null?void 0:w.enabled)===!0,r.value=!0}catch(_){c.value=_.status===403?"Administrator access is required to change automatic learning.":_.message||"Automatic learning state is unavailable."}}async function x(w){if(!(!r.value||d.value)){d.value=!0,c.value=null;try{if(await z.put("/api/config",{learning:{enabled:w}}),await b(),!r.value)return;Se.success(`Automatic learning ${o.value?"enabled":"disabled"}`)}catch(_){r.value=!1,c.value=_.status===403?"Administrator access is required to change automatic learning.":_.message||"Failed to change automatic learning."}finally{d.value=!1}}}return st(()=>{g(),b()}),{entries:e,meta:t,loading:s,error:a,filterCat:n,editing:i,editContent:l,categories:u,catCounts:p,filtered:m,learningEnabled:o,configReady:r,configError:c,savingConfig:d,catBadge:h,formatTs:di,startEdit:y,saveEdit:k,deleteEntry:A,fetchEntries:g,fetchLearningConfig:b,setLearningEnabled:x}}},Ug=[{id:"tools",label:"Tools",component:J1},{id:"skills",label:"Skills",component:Y1},{id:"mcp-servers",label:"MCP Servers",component:dC},{id:"knowledge",label:"Knowledge",component:pC},{id:"memory",label:"Memory",component:fC},{id:"learned",label:"Learned",component:mC}],vC={components:{TabbedPage:uc},setup(){return{tabs:Ug}},template:''},hC={ok:"text-green-400",degraded:"text-yellow-400",down:"text-red-400",unconfigured:"text-gray-500"},gC={ok:"success",degraded:"warning",down:"error",unconfigured:"minus"},bC={healthy:"text-green-400",degraded:"text-yellow-400",unhealthy:"text-red-400"},yC={template:` +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + +
    +
    +

    Last refresh failed: {{ error }} — showing the most recent data.

    +
    + +
    +
    +
    + +
    +
    {{ overallLabel }}
    +
    + {{ data.healthy_count }} healthy, {{ data.degraded_count }} degraded, {{ data.down_count }} down, {{ data.unconfigured_count }} unconfigured +
    +
    +
    +
    + Updated {{ formatTime(data.checked_at) }} + +
    +
    +
    + + +
    +
    +
    + + {{ formatName(c.name) }} + {{ c.status }} +
    +
    {{ c.detail }}
    + + +
    +
    Configured hosts:
    +
    + + {{ h.alias }} + {{ h.ssh_user }}@{{ h.address }} + ({{ h.os }}) +
    +
    + Pool: {{ c.metadata.active_connections || 0 }} active, + {{ c.metadata.total_reused || 0 }} reused, + {{ c.metadata.total_opened || 0 }} opened +
    +
    + + +
    +
    + Model: + {{ c.metadata.model || 'unknown' }} +
    +
    + Circuit: + {{ c.metadata.circuit_breaker }} +
    +
    + Pool: + {{ c.metadata.http_pool_active_connections || 0 }}/{{ c.metadata.http_pool_max_connections || 0 }} connections +
    +
    + Requests: + {{ c.metadata.http_pool_total_requests || 0 }} total +
    +
    + + +
    +
    + Chunks: + {{ c.metadata.chunks || 0 }} +
    +
    + Vector search: + + {{ c.metadata.vector_search ? 'enabled' : 'FTS only' }} + +
    +
    + + +
    +
    + Active: + {{ c.metadata.count || 0 }} session(s) +
    +
    + Tokens: + {{ formatNumber(c.metadata.total_tokens || 0) }} +
    +
    + {{ c.metadata.over_budget }} over budget +
    +
    + + +
    +
    + {{ key }}: + {{ val }} +
    +
    +
    +
    +
    +
    `,setup(){const e=f({}),t=f(!0),s=f(null),a=f(!1),n=f(!1),i=B(()=>e.value.components||[]),l=B(()=>bC[e.value.overall]||"text-gray-400"),o=B(()=>e.value.overall==="healthy"?"success":e.value.overall==="degraded"?"warning":e.value.overall==="unhealthy"?"error":"minus"),r=B(()=>{const _=e.value.overall;return _==="healthy"?"All Systems Healthy":_==="degraded"?"Some Systems Degraded":_==="unhealthy"?"System Issues Detected":"Unknown"});function c(_){return hC[_]||"text-gray-400"}function d(_){return gC[_]||"info"}function u(_){return _==="ok"?"badge-success":_==="degraded"?"badge-warning":_==="down"?"badge-danger":"badge-info"}function p(_){return _==="closed"?"text-green-400":_==="half_open"?"text-yellow-400":_==="open"?"text-red-400":"text-gray-400"}function m(_){return _.replace(/_/g," ").replace(/\b\w/g,T=>T.toUpperCase())}function h(_){if(!_)return"—";try{return new Date(_).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return _}}function y(_){return _>=1e6?(_/1e6).toFixed(1)+"M":_>=1e3?(_/1e3).toFixed(1)+"K":String(_)}async function k(){n.value=!0;try{e.value=await z.get("/api/health/components"),s.value=null,a.value=!0}catch(_){s.value=_.message}finally{t.value=!1,n.value=!1}}function A(){t.value=!0,s.value=null,k()}let g=null,b=!1;function x(){b||(b=!0,k(),g||(g=setInterval(k,3e4)))}function w(){b&&(b=!1,g&&(clearInterval(g),g=null))}return st(x),cs(x),es(w),wt(w),{data:e,hasData:a,loading:t,error:s,refreshing:n,components:i,overallColor:l,overallIcon:o,overallLabel:r,statusColor:c,statusIcon:d,badgeClass:u,circuitColor:p,formatName:m,formatTime:h,formatNumber:y,fetchHealth:k,retry:A}}},xC={template:` +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + +
    +
    +

    Last refresh failed: {{ error }} — showing the most recent data.

    +
    + +
    +

    Resource Usage

    +
    + {{ collectedAt }} + +
    +
    + + +
    +
    +
    {{ data.sessions.active_count }}
    +
    Active Sessions
    +
    +
    +
    {{ fmtNum(data.knowledge.chunk_count) }}
    +
    Knowledge Chunks
    +
    +
    +
    {{ fmtNum(data.trajectories.total_count) }}
    +
    Trajectories Saved
    +
    +
    +
    {{ data.storage_total_mb }} MB
    +
    Total Storage
    +
    +
    + + +
    + +
    + + +
    +
    +
    +
    {{ fmtNum(data.sessions.total_tokens) }}
    +
    Total Tokens
    +
    +
    +
    {{ fmtNum(data.sessions.total_messages) }}
    +
    Total Messages
    +
    +
    +
    + {{ data.sessions.over_budget_count }} +
    +
    Over Budget
    +
    +
    +
    {{ data.sessions.persist_dir.total_mb }} MB
    +
    Persist Storage
    +
    +
    + +
    +

    Per-Session Breakdown

    +
    + + + + + + + + + + + + + + + +
    ChannelTokensMessagesSummary
    {{ s.channel_id }}{{ fmtNum(s.tokens) }}{{ s.messages }} + + {{ s.has_summary ? 'Yes' : 'No' }} + +
    +
    +
    +
    + No active sessions +
    +
    + + +
    +
    +
    +
    {{ data.knowledge.source_count }}
    +
    Sources
    +
    +
    +
    {{ fmtNum(data.knowledge.chunk_count) }}
    +
    Chunks
    +
    +
    +
    + {{ data.knowledge.vector_search ? 'Vector + FTS' : 'FTS Only' }} +
    +
    Search Mode
    +
    +
    +
    {{ data.knowledge.db_file.total_mb }} MB
    +
    DB Size
    +
    +
    + +
    +

    Ingested Sources

    +
    + + + + + + + + + + + + + +
    SourceChunksUploader
    {{ s.source }}{{ s.chunks }}{{ s.uploader }}
    +
    +
    +
    + {{ data.knowledge.available ? 'No documents ingested' : 'Knowledge store unavailable' }} +
    +
    + + +
    +
    +
    +
    {{ fmtNum(data.trajectories.message_count) }}
    +
    Message Turns
    +
    +
    +
    {{ fmtNum(data.trajectories.agent_count) }}
    +
    Agent Turns
    +
    +
    +
    {{ data.trajectories.message_dir.file_count }}
    +
    Message Files
    +
    +
    +
    {{ data.trajectories.combined_mb }} MB
    +
    Total Volume
    +
    +
    + +
    + +
    +

    + Message Trajectory Files + ({{ data.trajectories.message_dir.total_mb }} MB) +

    +
    +
    + {{ f }} +
    +
    +
    No trajectory files yet
    +
    + + +
    +

    + Agent Trajectory Files + ({{ data.trajectories.agent_dir.total_mb }} MB) +

    +
    +
    + {{ f }} +
    +
    +
    No agent trajectory files yet
    +
    +
    +
    + + +
    +
    +

    Storage Breakdown

    +
    +
    +
    + {{ item.label }} + {{ item.mb }} MB ({{ item.files }} files) +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `,setup(){const e=f(!0),t=f(null),s=f(!1),a=f(!1),n=f("sessions"),i=f(null);let l=null;const o=[{key:"sessions",label:"Sessions"},{key:"knowledge",label:"Knowledge"},{key:"trajectories",label:"Trajectories"},{key:"storage",label:"Storage"}],r=B(()=>{if(!i.value||!i.value.collected_at)return"";try{return new Date(i.value.collected_at).toLocaleTimeString()}catch{return""}}),c=B(()=>{if(!i.value)return[];const k=i.value,A=k.storage_total_bytes||1;return[{label:"Session Persistence",mb:k.sessions.persist_dir.total_mb,bytes:k.sessions.persist_dir.total_bytes,files:k.sessions.persist_dir.file_count,pct:Math.min(100,Math.round(k.sessions.persist_dir.total_bytes/A*100)),color:"res-bar-blue"},{label:"Knowledge Database",mb:k.knowledge.db_file.total_mb,bytes:k.knowledge.db_file.total_bytes,files:k.knowledge.db_file.file_count,pct:Math.min(100,Math.round(k.knowledge.db_file.total_bytes/A*100)),color:"res-bar-purple"},{label:"Message Trajectories",mb:k.trajectories.message_dir.total_mb,bytes:k.trajectories.message_dir.total_bytes,files:k.trajectories.message_dir.file_count,pct:Math.min(100,Math.round(k.trajectories.message_dir.total_bytes/A*100)),color:"res-bar-emerald"},{label:"Agent Trajectories",mb:k.trajectories.agent_dir.total_mb,bytes:k.trajectories.agent_dir.total_bytes,files:k.trajectories.agent_dir.file_count,pct:Math.min(100,Math.round(k.trajectories.agent_dir.total_bytes/A*100)),color:"res-bar-amber"}]});async function d(){try{const k=await z.get("/api/resource-usage");i.value=k,t.value=null,s.value=!0}catch(k){t.value=k.message||"Failed to load resource usage"}finally{e.value=!1,a.value=!1}}async function u(){a.value=!0,await d()}function p(){e.value=!0,t.value=null,d()}let m=!1;function h(){m||(m=!0,d(),l||(l=setInterval(d,3e4)))}function y(){m&&(m=!1,l&&(clearInterval(l),l=null))}return st(h),cs(h),es(y),wt(y),{hasData:s,loading:e,error:t,refreshing:a,data:i,activeTab:n,tabs:o,collectedAt:r,storageItems:c,fmtNum:Wu,refresh:u,retry:p}}},_C=new Set(["timestamp","type","level","tool_name","action","method","path","status","success","execution_time_ms","duration_ms","metadata","audit_metadata","turn","tool_input","audit_observer","_hmac","_prev_hmac","agent_id","agent_label","parent_agent_id","root_agent_id","originating_turn_id","turn_id","iteration","call_id","result_summary","detail","message","diff","error"]),wC=new Set(["agent_id","agent_label","label","parent_agent_id","root_agent_id","originating_turn_id","turn_id","iteration","call_id","status","duration_ms","tool_input_keys","audit_observer","_hmac","_prev_hmac"]);function kC(e){const t={};for(const s of["metadata","audit_metadata"]){if(!(e!=null&&e[s])||typeof e[s]!="object")continue;const a=Object.fromEntries(Object.entries(e[s]).filter(([n])=>!wC.has(n)));Object.keys(a).length&&(t[s]=a)}return Object.keys(t).length?lo(t):""}function SC(e){var n,i,l,o;const t=e.record;if(!t)return{body:e.text,action:"",status:"",duration:null};let s;for(const r of["result_summary","detail","message","diff","error"])if(t[r]!==void 0&&t[r]!==null&&t[r]!==""){s=t[r];break}if(s===void 0&&((n=t.metadata)!=null&&n.error)&&(s=t.metadata.error),s===void 0){const r=Object.fromEntries(Object.entries(t).filter(([c])=>!_C.has(c)));s=Object.keys(r).length?lo(r):""}const a=t.status??((i=t.metadata)==null?void 0:i.status)??(t.error||t.success===!1?"failed":t.success===!0?"success":["tool_start","loop_tool_start"].includes(t.type)?"started":"");return{body:s,action:t.tool_name||t.action||(t.method?`${t.method} ${t.path||""}`.trim():t.type||""),status:a,duration:t.execution_time_ms??t.duration_ms??((l=t.metadata)==null?void 0:l.duration_ms)??((o=t.metadata)==null?void 0:o.elapsed_ms)??null}}const In=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),oo=e=>typeof e=="string"||typeof e=="number"?String(e):"";function CC(e){const t=In(e)?e:{},s=In(t.metadata)?t.metadata:{},a=In(t.audit_metadata)?t.audit_metadata:{},n=In(t.turn)?t.turn:{},i=l=>oo(t[l]??s[l]??a[l]??n[l]);return{agentId:i("agent_id"),label:i("agent_label")||i("label"),parentId:i("parent_agent_id"),rootId:i("root_agent_id"),turnId:i("originating_turn_id")||i("turn_id"),iteration:i("iteration"),callId:i("call_id")}}function Zf(e){return e.record?JSON.stringify(zg(e),null,2):e.text}function zg(e){var t;return((t=e.events)==null?void 0:t.length)>1?e.events:e.record}function Fd(e){return e!=null&&e.tool_name?["tool_start","loop_tool_start"].includes(e.type)?"start":["tool_end","loop_tool"].includes(e.type)?"end":(!e.type||e.type==="execution")&&"result_summary"in e?"execution":"":""}function Qf(e){if(!Fd(e.record))return"";const t=e.attribution,s=e.record;return!t.callId||!t.turnId&&!t.agentId?"":JSON.stringify([t.turnId,t.agentId,t.iteration,t.callId,e.tool,oo(s.channel_id),oo(s.user_id??s.actor)])}function TC(e,t,s=2e3){var i,l,o;const a=Qf(t),n=a?e.findIndex(r=>Qf(r)===a):-1;if(n<0)e.push(t);else{const r=e[n],c=[...r.events||[r.record]],d=JSON.stringify(t.record);if(c.some(g=>JSON.stringify(g)===d))return;c.push(t.record);const u=g=>"result_summary"in g?2:Fd(g)==="end"?1:0,p=[...c].sort((g,b)=>u(g)-u(b)),m=Object.assign({},...p);m.type=p[p.length-1].type||"execution";for(const g of["metadata","audit_metadata","turn"]){const b=p.filter(x=>In(x[g])).map(x=>x[g]);b.length&&(m[g]=Object.assign({},...b))}const h=c.some(g=>Fd(g)!=="start"),y=c.find(g=>Bd(g,0).level==="ERROR"),k=(y==null?void 0:y.status)||((i=y==null?void 0:y.metadata)==null?void 0:i.status);m.status=y?["failed","error","cancelled","denied","outcome_unknown"].includes(k)?k:"failed":h?m.status||((l=m.metadata)==null?void 0:l.status)||"succeeded":"started",h&&m.status==="started"&&(m.status="succeeded"),y&&(m.error=y.error||((o=y.metadata)==null?void 0:o.error)||m.error);const A=Bd(m,r.id,r._time);Object.assign(A,{events:c,ts:r.ts,_time:r._time,searchText:c.map(g=>JSON.stringify(g)).join(` +`)}),e.splice(n,1,A)}e.length>s&&e.splice(0,e.length-s)}function Bd(e,t,s=new Date){var u,p;let a=e;if(In(e)&&e.type==="log"&&"line"in e?a=e.line:In(e)&&"payload"in e&&(a=e.payload),typeof a=="string")try{a=JSON.parse(a)}catch{}const n=In(a)?a:null,i=n!=null&&n.timestamp?new Date(n.timestamp):s,l=Number.isNaN(i.getTime())?s:i,o=n?n.result_summary??n.detail??n.message??JSON.stringify(n):typeof a=="string"?a:JSON.stringify(a)??"",c=(n==null?void 0:n.error)||((u=n==null?void 0:n.metadata)==null?void 0:u.error)||(n==null?void 0:n.success)===!1||[n==null?void 0:n.status,(p=n==null?void 0:n.metadata)==null?void 0:p.status].some(m=>["failed","error","cancelled","denied","outcome_unknown"].includes(m))?"ERROR":oo(n==null?void 0:n.level).toUpperCase()||"INFO",d={id:t,record:n,ts:l.toLocaleTimeString(),_time:l,level:c,text:typeof o=="string"?o:JSON.stringify(o),tool:oo(n==null?void 0:n.tool_name),raw:n?null:o,attribution:CC(n)};return d.searchText=n?JSON.stringify(n):d.text,d}function EC(e){const t=new Map;for(const s of e){const{turnId:a,agentId:n,rootId:i,label:l,parentId:o}=s.attribution,r=a?`turn:${a}`:n?`root:${i||n}`:"unattributed";t.has(r)||t.set(r,{key:r,title:a?`Turn ${a}`:n?`Agent root ${i||n} (turn unavailable)`:"Unattributed / legacy records",count:0,sections:[],_sections:new Map});const c=t.get(r),d=n?`agent:${n}`:"main";if(!c._sections.has(d)){const u={key:d,agentId:n,label:l,parentId:o,rootId:i,title:n?`${l||"Agent"} (${n})`:"Main thread / turn events",entries:[]};c._sections.set(d,u),c.sections.push(u)}c._sections.get(d).entries.push(s),c.count++}return[...t.values()].map(({_sections:s,...a})=>a)}const AC={components:{ToolOutput:pc},props:{entry:{type:Object,required:!0}},emits:["copy"],setup(e){const t=B(()=>SC(e.entry)),s=B(()=>{var o;return lo(((o=e.entry.record)==null?void 0:o.tool_input)??"")}),a=B(()=>{var o,r,c;return lo(((o=e.entry.record)==null?void 0:o.error)||((c=(r=e.entry.record)==null?void 0:r.metadata)==null?void 0:c.error)||"")}),n=B(()=>kC(e.entry.record)),i=B(()=>zg(e.entry)),l=B(()=>(e.entry.events||[e.entry.record]).filter(Boolean).map(o=>{var r;return{type:o.type||"execution",timestamp:o.timestamp||"Time not recorded",status:o.status||((r=o.metadata)==null?void 0:r.status)||""}}));return{display:t,argumentsText:s,errorText:a,metadataText:n,rawRecord:i,lifecycle:l}},template:` +
    + + + + + +
    `},RC=["INFO","WARNING","ERROR"],Yf="WARNING+",IC=[{id:"all",name:"All Logs",icon:"list",filters:{}},{id:"errors",name:"Errors Only",icon:"error",filters:{level:"ERROR"}},{id:"warnings",name:"Warnings+",icon:"warning",filters:{levels:["WARNING","ERROR"]}},{id:"tools",name:"Tool Activity",icon:"wrench",filters:{hasToolName:!0}},{id:"recent-errors",name:"Recent Errors",icon:"flame",filters:{level:"ERROR",timeRange:"last_1h"}}],Zc=[{value:"",label:"All Time"},{value:"last_5m",label:"Last 5 min",seconds:300},{value:"last_15m",label:"Last 15 min",seconds:900},{value:"last_1h",label:"Last 1 hour",seconds:3600},{value:"last_4h",label:"Last 4 hours",seconds:14400},{value:"last_24h",label:"Last 24 hours",seconds:86400}],OC=[50,100,200,500],LC={components:{ToolOutput:pc,LogRecord:AC,DiscordIdentity:Dn},template:` +
    +
    +
    +

    Logs

    +

    + {{ filteredLogs.length.toLocaleString() }} / {{ logs.length.toLocaleString() }} entries +

    +

    + {{ searchResults.length.toLocaleString() }} results +

    +
    +
    + +
    + + +
    + + +
    +
    + + + + + + +
    `,setup(){const e=f("live"),t=f([]);let s=0;const a=f(!1),n=f(!1),i=f(!0),l=f(""),o=f(""),r=f(!1),c=f(!1),d=f(mt.state||"disconnected"),u=B(()=>{switch(d.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}}),p=f(null),m=f(!1),h=f(null),y=2e3,k=RC,A=IC,g=Zc,b=f("all"),x=f(""),w=f(!1),_=f([]),T=f(!1),L=f(""),E=f([]);function F(){try{const ue=localStorage.getItem("odin-log-presets");ue&&(_.value=JSON.parse(ue))}catch{}}function O(){try{localStorage.setItem("odin-log-presets",JSON.stringify(_.value))}catch{}}const I=B(()=>l.value!==""||o.value.trim()!==""||x.value!==""||w.value),H=B(()=>{const ue=Zc.find(Ce=>Ce.value===x.value);return ue?ue.label:""}),te=B(()=>{if(!r.value||!o.value)return null;try{return new RegExp(o.value,"i"),null}catch(ue){return ue.message}}),ee=24,N=B(()=>{if(ae.value.length===0)return[];const ue=[],Ce=new Date,qe=3600*1e3;for(let Ge=ee-1;Ge>=0;Ge--){const It=new Date(Ce.getTime()-(Ge+1)*qe),ot=new Date(Ce.getTime()-Ge*qe);ue.push({start:It,end:ot,label:ce(It,ot),shortLabel:ot.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}),total:0,info:0,warnings:0,errors:0})}for(const Ge of ae.value){if(!Ge._time)continue;const It=Ge._time.getTime();for(const ot of ue)if(It>=ot.start.getTime()&&It{let ue=1;for(const Ce of N.value)Ce.total>ue&&(ue=Ce.total);return ue}),$=B(()=>{if(N.value.length===0)return"";const ue=ae.value.map(Ge=>Ge._time&&Ge._time.getTime()).filter(Boolean);if(ue.length===0)return"";const Ce=new Date(Math.min(...ue));return`${ae.value.length} shown, oldest ${Ce.toLocaleTimeString()}`}),re=B(()=>Math.ceil(ee/8));function ce(ue,Ce){const qe={hour:"2-digit",minute:"2-digit"};return ue.toLocaleTimeString([],qe)+" - "+Ce.toLocaleTimeString([],qe)}function Z(ue,Ce){return!Ce||!ue?"0px":Math.max(2,ue/Ce*100)+"%"}function Q(ue){const Ce=ae.value.findIndex(qe=>qe._time&&qe._time.getTime()>=ue.start.getTime()&&qe._time.getTime()=0&&p.value){const qe=p.value.querySelector('[data-log-id="'+ae.value[Ce].id+'"]');qe&&(qe.scrollIntoView({behavior:"smooth",block:"center"}),i.value=!1)}}const ae=B(()=>{let ue=t.value;if(w.value&&(ue=ue.filter(Ce=>!!Ce.tool)),l.value)if(l.value===Yf){const Ce={INFO:0,WARNING:1,ERROR:2,CRITICAL:3};ue=ue.filter(qe=>Ce[qe.level||"INFO"]>=Ce.WARNING)}else ue=ue.filter(Ce=>(Ce.level||"INFO")===l.value);if(x.value){const Ce=Zc.find(qe=>qe.value===x.value);if(Ce&&Ce.seconds){const qe=new Date(Date.now()-Ce.seconds*1e3);ue=ue.filter(Ge=>Ge._time&&Ge._time>=qe)}}if(o.value&&!te.value)if(r.value)try{const Ce=new RegExp(o.value,"i");ue=ue.filter(qe=>{const Ge=qe.searchText,It=qe.tool||"";return Ce.test(Ge)||Ce.test(It)})}catch{}else{const Ce=o.value.toLowerCase();ue=ue.filter(qe=>{const Ge=qe.searchText.toLowerCase(),It=(qe.tool||"").toLowerCase();return Ge.includes(Ce)||It.includes(Ce)})}return ue}),V=B(()=>EC(ae.value));function be(ue){const Ce=Bd(ue,++s);if(n.value){E.value.push(Ce);return}Oe(Ce)}function Oe(ue){TC(t.value,ue,y),i.value&&Ut(()=>le())}function le(ue=!1){const Ce=p.value;Ce&&Ce.scrollTo({top:Ce.scrollHeight,behavior:ue?"smooth":"instant"})}function oe(){i.value=!0,m.value=!1,Ut(()=>le(!0))}const ve=new Set(["PageUp","PageDown","ArrowUp","ArrowDown","Home","End"," "]);function j(){const ue=p.value;if(!ue)return;const Ce=ue.scrollHeight-ue.scrollTop-ue.clientHeight<40;m.value=!i.value&&!Ce&&t.value.length>0,P.value&&Y()}function Y(){const ue=p.value;!ue||!i.value||ue.scrollHeight-ue.scrollTop-ue.clientHeight>=40&&(i.value=!1,m.value=t.value.length>0)}function xe(){i.value&&requestAnimationFrame(Y)}function S(ue){ve.has(ue.key)&&xe()}const P=f(!1);function G(){i.value&&(P.value=!0,requestAnimationFrame(Y))}function he(){P.value&&(P.value=!1,Y())}function fe(){i.value&&(m.value=!1,Ut(()=>le()))}function me(){if(n.value=!n.value,!n.value&&E.value.length>0){for(const ue of E.value)Oe(ue);E.value=[]}}function we(){t.value=[],E.value=[],m.value=!1}function J(){let ue;e.value==="search"?ue=Ye.value.map(It=>{const ot=It.error?"ERROR":"INFO",pn=It.tool_name?`[${It.tool_name}] `:"";return`${It.timestamp||""} ${ot} ${pn}${It.result_summary||It.message||""}`}).join(` +`):ue=ae.value.map(Zf).join(` + +`);const Ce=new Blob([ue],{type:"text/plain"}),qe=URL.createObjectURL(Ce),Ge=document.createElement("a");Ge.href=qe,Ge.download=`odin-logs-${new Date().toISOString().slice(0,19).replace(/:/g,"-")}.txt`,Ge.click(),URL.revokeObjectURL(qe)}function ie(ue){const Ce=Zf(ue);navigator.clipboard.writeText(Ce).then(()=>{h.value=ue.id,setTimeout(()=>{h.value=null},1500)}).catch(()=>{})}function X(ue){l.value=l.value===ue?"":ue,b.value="all",w.value=!1}function _e(ue){return ue.level==="ERROR"?"log-line-error":ue.level==="WARNING"?"log-line-warning":"text-gray-300"}function U(ue){return ue==="ERROR"?"text-red-500 font-semibold":ue==="WARNING"?"text-yellow-500":"text-blue-500"}function de(ue){return ue==="ERROR"?"log-chip-error":ue==="WARNING"?"log-chip-warning":"log-chip-info"}function ge(ue){b.value=ue.id;const Ce=ue.filters;l.value=Ce.level||"",x.value=Ce.timeRange||"",o.value=Ce.text||"",w.value=!!Ce.hasToolName,Ce.levels&&(l.value=Ce.levels.includes("WARNING")&&Ce.levels.includes("ERROR")?Yf:Ce.levels[0]||"")}function Ae(ue){b.value=ue.id,l.value=ue.filters.level||"",x.value=ue.filters.timeRange||"",o.value=ue.filters.text||"",w.value=!!ue.filters.hasToolName}function $e(){if(!L.value.trim())return;const ue={id:"custom-"+Date.now(),name:L.value.trim(),filters:{level:l.value,timeRange:x.value,text:o.value,hasToolName:w.value||void 0}};_.value=[..._.value,ue],O(),T.value=!1,L.value=""}function We(ue){_.value=_.value.filter(Ce=>Ce.id!==ue),O(),b.value===ue&&(b.value="all",x.value="",w.value=!1,l.value="",o.value="")}const je=f("all"),Xe=f(""),it=f(""),Qe=f(""),se=f(""),Re=f(""),Le=f(100),Te=OC,W=f(!1),De=f(!1),ze=f(""),Ye=f([]),Dt=f(null),et=f(null);function Rt(){e.value="search",Dt.value||Pt()}async function Pt(){try{Dt.value=await z.get("/api/logs/stats")}catch{}}function Ts(){const ue=Re.value;if(!ue){Qe.value="",se.value="";return}const qe={last_5m:300,last_15m:900,last_1h:3600,last_4h:14400,last_24h:86400,last_7d:604800}[ue];if(qe){const Ge=new Date(Date.now()-qe*1e3);Qe.value=Vs(Ge),se.value=""}}function Vs(ue){const Ce=qe=>String(qe).padStart(2,"0");return`${ue.getFullYear()}-${Ce(ue.getMonth()+1)}-${Ce(ue.getDate())}T${Ce(ue.getHours())}:${Ce(ue.getMinutes())}`}function Gs(ue){if(!ue)return"";const Ce=new Date(ue);return isNaN(Ce.getTime())?"":Ce.toISOString()}let zt=0;async function ta(){if(W.value)return;const ue=++zt;W.value=!0,ze.value="",De.value=!0,et.value=null;try{const Ce=new URLSearchParams;je.value&&je.value!=="all"&&Ce.set("level",je.value),Xe.value&&Ce.set("tool",Xe.value),it.value&&Ce.set("q",it.value);const qe=Gs(Qe.value),Ge=Gs(se.value);qe&&Ce.set("start",qe),Ge&&Ce.set("end",Ge),Ce.set("limit",String(Le.value));const It=await z.get(`/api/logs/search?${Ce.toString()}`);if(ue!==zt)return;Ye.value=It.entries||[]}catch(Ce){if(ue!==zt)return;ze.value=Ce.message||"Search failed",Ye.value=[]}finally{ue===zt&&(W.value=!1)}}function Ws(){zt++,je.value="all",Xe.value="",it.value="",Qe.value="",se.value="",Re.value="",Le.value=100,Ye.value=[],De.value=!1,ze.value="",et.value=null,W.value=!1}function Pa(ue){et.value=et.value===ue?null:ue}function hs(ue){if(!ue.timestamp)return"";try{return new Date(ue.timestamp).toLocaleString()}catch{return ue.timestamp}}function $a(ue){return ue.type==="web_action"?`${ue.status||""} (${ue.execution_time_ms||0}ms)`:(ue.result_summary||"").slice(0,200)}function Es(ue){return ue.error?"log-line-error":"text-gray-300"}function Ct(ue){try{return JSON.stringify(ue,null,2)}catch{return String(ue)}}let ls=null,sa=!1;function ba(){sa||(sa=!0,mt.subscribe("logs",be),c.value=mt.connected,d.value=mt.state||"disconnected",ls=mt.onState(ue=>{d.value=ue,c.value=ue==="connected"}))}function Fe(){sa&&(sa=!1,mt.unsubscribe("logs",be),ls&&(ls(),ls=null))}return st(()=>{F(),window.addEventListener("pointerup",he),window.addEventListener("pointercancel",he)}),cs(ba),es(Fe),wt(()=>{Fe(),window.removeEventListener("pointerup",he),window.removeEventListener("pointercancel",he)}),{mode:e,logs:t,paused:n,autoScroll:i,levelFilter:l,textFilter:o,useRegex:r,groupByTurn:a,groupedLogs:V,subscribed:c,wsState:d,wsStateLabel:u,logContainer:p,filteredLogs:ae,pauseBuffer:E,showJumpBottom:m,copiedIndex:h,regexError:te,levels:k,logPresets:A,timeRanges:g,timeRange:x,toolOnly:w,activeLogPreset:b,customLogPresets:_,showSaveLogPreset:T,newLogPresetName:L,hasActiveLogFilters:I,timeRangeLabel:H,timelineBuckets:N,timelineMax:M,timelineSpanLabel:$,timelineLabelSkip:re,togglePause:me,clearLogs:we,exportLogs:J,logLineClass:_e,levelClass:U,levelChipClass:de,toggleLevel:X,copyLine:ie,jumpToBottom:oe,onScroll:j,onUserScrollIntent:xe,onUserScrollKey:S,onAutoScrollToggle:fe,onPointerDown:G,applyLogPreset:ge,applyCustomLogPreset:Ae,saveLogCustomPreset:$e,removeLogCustomPreset:We,segmentHeight:Z,jumpToTimelineBucket:Q,searchLevel:je,searchTool:Xe,searchKeyword:it,searchStart:Qe,searchEnd:se,searchTimePreset:Re,searchLimit:Le,searchLimits:Te,searching:W,searchRan:De,searchError:ze,searchResults:Ye,searchStats:Dt,expandedSearch:et,switchToSearch:Rt,runSearch:ta,clearSearchFilters:Ws,toggleSearchExpand:Pa,formatSearchTs:hs,searchEntryText:$a,searchLogLineClass:Es,formatJson:Ct,applySearchTimePreset:Ts}}};function Go(e=[]){const t=[],s=new Set;function a(n){const i=[n.kind,n.label,n.apply_mode||"",n.code||"",n.text||""].join("\0");s.has(i)||(s.add(i),t.push({...n,key:i}))}for(const n of e)for(const i of(n==null?void 0:n.consumers)||[])a({kind:"consumer",label:i.name,apply_mode:i.apply_mode,text:i.detail});for(const n of e)n!=null&&n.apply_handler&&a({kind:"handler",label:"Apply handler",code:n.apply_handler});for(const n of e)n!=null&&n.restart_reason&&a({kind:"restart",label:"Why a restart is required",text:n.restart_reason});for(const n of e)n!=null&&n.activation_policy&&a({kind:"activation",label:"Activation policy",text:n.activation_policy});return t}const NC=Object.freeze([{key:"all",label:"All fields",short:"All",icon:"grid"},{key:"applied",label:"Applied",short:"Applied",icon:"success"},{key:"pending_restart",label:"Pending restart",short:"Restart",icon:"refresh"},{key:"dormant",label:"Saved, not active",short:"Saved only",icon:"pause"},{key:"invalid",label:"Invalid",short:"Invalid",icon:"error"},{key:"drift",label:"Drift",short:"Drift",icon:"warning"},{key:"unknown",label:"Effective state unknown",short:"Unknown",icon:"info"}]);function MC(e,t={}){var n,i;const s=t.getStyle||(l=>globalThis.getComputedStyle(l)),a=Object.hasOwn(t,"fallback")?t.fallback:(n=globalThis.document)==null?void 0:n.scrollingElement;for(let l=e;l;l=l.parentElement){const o=((i=s(l))==null?void 0:i.overflowY)||"";if(/^(auto|scroll|overlay)$/.test(o)&&l.scrollHeight>l.clientHeight)return l}return a&&a.scrollHeight>a.clientHeight?a:e||a||null}const zi=[{key:"core",label:"Core",icon:"sliders",sections:["timezone","logging","permissions","graceful_degradation"]},{key:"models",label:"Models & AI",icon:"brain",sections:["image","llm_recovery"]},{key:"runtime",label:"Runtime",icon:"activity",sections:["context","sessions","agents","turn_state"]},{key:"data",label:"Data & Storage",icon:"database",sections:["learning","search","usage","audit","attachments"]},{key:"services",label:"Services",icon:"link",sections:["webhook","observability","email","browser","mcp"]},{key:"automation",label:"Automation",icon:"workflow",sections:["outbound_webhooks"]},{key:"infrastructure",label:"Infrastructure",icon:"server",sections:["tools","web"]}],DC={live_read:"Applies immediately",live_apply:"Dedicated live apply",live_for_new_work:"Applies to new work",restart:"Restart required",activation_required:"Saved only — see activation note",legacy_control:"Controlled elsewhere",dormant:"Saved for future support"},Wo=new Set(["llm_provider","openai_codex","ollama","openai_compatible","kimi","personality","discord","computer"]),PC=Object.freeze(["web.api_tokens","outbound_webhooks.targets"]);function Xf(e){return PC.some(t=>e===t||e.startsWith(`${t}.`))}const Hg="odin_config_center_expanded_v1",jg="odin_config_center_category_v1",$C=50,FC=650,gl=()=>z.get("/api/config/meta");function Gn(e){return e===void 0?void 0:JSON.parse(JSON.stringify(e))}function Oi(e,t){return JSON.stringify(e)===JSON.stringify(t)}function wi(e){return String(e).replace(/[_-]+/g," ").replace(/\b\w/g,t=>t.toUpperCase())}function BC(e){return e===void 0?"unset":e===null?"null":typeof e=="boolean"?e?"Enabled":"Disabled":Array.isArray(e)?e.length?`${e.length} item${e.length===1?"":"s"}`:"Empty list":typeof e=="object"?Object.keys(e).length?`${Object.keys(e).length} field${Object.keys(e).length===1?"":"s"}`:"Empty object":e===""?"Empty":String(e)}function UC(e){if(e===void 0)return"unset";if(e===null)return"null";if(typeof e=="object")try{return JSON.stringify(e,null,2)}catch{return String(e)}return String(e)}function qg(e,t){if(Oi(e,t))return;if(!(e&&t&&typeof e=="object"&&typeof t=="object"&&!Array.isArray(e)&&!Array.isArray(t)))return Gn(t);const a={};for(const[n,i]of Object.entries(t)){const l=qg(e[n],i);l!==void 0&&(a[n]=l)}return Object.keys(a).length?a:void 0}function zC(e,t){const s={};for(const[a,n]of Object.entries(t||{})){const i=qg(e==null?void 0:e[a],n);i!==void 0&&(s[a]=i)}return s}function Vg(e,t,s,a){if(Oi(e,t))return;if(e&&t&&typeof e=="object"&&typeof t=="object"&&!Array.isArray(e)&&!Array.isArray(t)){const i=new Set([...Object.keys(e),...Object.keys(t)]);for(const l of i)Vg(e[l],t[l],s?`${s}.${l}`:l,a);return}a.push({path:s,oldVal:e,newVal:t})}function HC(){try{const e=JSON.parse(localStorage.getItem(Hg)||"{}");return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}}function jC(){try{const e=localStorage.getItem(jg);return zi.some(t=>t.key===e)?e:zi[0].key}catch{return zi[0].key}}const qC={template:` +
    +
    +
    +
    System settings
    +

    Configuration center

    +

    + {{ sectionCount }} sections · {{ fieldCount }} settings · revision {{ meta?.revision || 'unavailable' }} +

    +
    +
    + + + + +
    +
    + +
    + {{ toast.message }} +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    + `,setup(){const e=f(null),t=f(null),s=f(!0),a=f(null),n=f(!1),i=f(null),l=f(!1),o=f(""),r=f(!1),c=f(""),d=f(""),u=f("");function p(C){i.value=C||null,C&&typeof C.authorized=="boolean"&&(l.value=C.authorized)}async function m(){try{const C=await z.get("/api/setup/status");p(C.listener),u.value=""}catch(C){p(null),u.value=`Listener status could not be loaded: ${C.message||"Unknown error"}`}}async function h(){if(!(!he.value||!o.value.trim()||r.value||G.value)){r.value=!0,c.value="",d.value="";try{const C=z.setListenerExposure(o.value.trim(),l.value);o.value="";const K=await C;c.value=K.message,p(K.listener)}catch(C){d.value=C.message||"Listener consent could not be saved."}finally{o.value="",r.value=!1}}}const y=f(null),k=["image_model","outer_model"],A=f(null),g=f(null),b=f(null),x=f(!1),w=f(!1),_=f(null),T=f(""),L=f("all"),E=f(jC()),F=f(HC()),O=f({}),I=f({}),H=f(""),te=f({}),ee=f({}),N=f([]),M=f([]),$=f(!1),re=f(!1),ce=f(!1);let Z=null,Q=null,ae={path:null,at:0},V=0;const be=B(()=>{var C;return(((C=t.value)==null?void 0:C.fields)||[]).filter(K=>!Wo.has(K.path.split(".")[0])&&!Xf(K.path))}),Oe=B(()=>new Map(be.value.map(C=>[C.path,C]))),le=B(()=>xe.value.reduce((C,K)=>C+K.sections.length,0)),oe=B(()=>be.value.length),ve=B(()=>NC),j=B(()=>N.value.length>0),Y=B(()=>M.value.length>0),xe=B(()=>{if(!e.value)return[];const C=new Set(zi.flatMap(Ee=>Ee.sections)),K=zi.map(Ee=>({...Ee,sections:Ee.sections.filter(Je=>Object.hasOwn(e.value,Je)&&!Wo.has(Je))})).filter(Ee=>Ee.sections.length),ne=Object.keys(e.value).filter(Ee=>!C.has(Ee)&&!Wo.has(Ee));return ne.length&&K.push({key:"other",label:"Other",icon:"folder",sections:ne}),K}),S=B(()=>e.value?{...e.value,...O.value}:null),P=B(()=>{if(!e.value)return[];const C=[];for(const[K,ne]of Object.entries(O.value))Vg(e.value[K],ne,K,C);return C.filter(K=>!Oi(K.oldVal,K.newVal)).map(K=>{const ne=it(K.path);return{...K,label:(ne==null?void 0:ne.label)||wi(K.path.split(".").at(-1)),apply_mode:(ne==null?void 0:ne.apply_mode)||De(K.path.split(".")[0])}})}),G=B(()=>P.value.length>0),he=B(()=>!!i.value&&l.value!==i.value.authorized),fe=B(()=>{var K;const C=(K=i.value)==null?void 0:K.state;return C==="active"||C==="authorized_loopback"?"active":["pending_widening","pending_narrowing","active_rebind_pending"].includes(C)?"pending":C==="restricted"?"restricted":"unknown"}),me=B(()=>{var C;return{active:"Exposure active",authorized_loopback:"Authorized · loopback host",pending_widening:"Authorized · restart pending",pending_narrowing:"Restriction saved · restart pending",active_rebind_pending:"Exposed · restart pending",restricted:"Loopback only",unknown:"Runtime state unavailable"}[(C=i.value)==null?void 0:C.state]||"Loading listener state"}),we=B(()=>i.value?i.value.authorized?i.value.authorization_source==="explicit"?"Beyond-loopback access is explicitly authorized":"Beyond-loopback access is retained from this installation":"Beyond-loopback access is not authorized":"Unavailable"),J=B(()=>{var C;return{explicit:"saved explicitly in config.yml",default:"schema default; no web.host key is saved",unknown:"source could not be verified"}[(C=i.value)==null?void 0:C.configured_host_source]||"source unavailable"}),ie=B(()=>{const C=i.value;return!C||C.running_scope==="unavailable"?"Actual bound address unavailable":`${(C.listening_hosts||[]).map((ne,Ee)=>{var St;const Je=(St=C.listening_ports)==null?void 0:St[Ee];return Je?`${ne}:${Je}`:ne}).join(", ")} · ${C.running_scope==="loopback"?"loopback only":"accepting beyond loopback"}`}),X=B(()=>P.value.length),_e=B(()=>new Set(P.value.map(C=>C.path.split(".")[0])).size),U=B(()=>!!T.value||L.value!=="all"),de=B(()=>{const C={...ee.value};for(const K of P.value){const ne=it(K.path),Ee=pe(ne,K.newVal);Ee&&(C[K.path]=Ee)}return C}),ge=B(()=>Object.keys(de.value).length>0),Ae=B(()=>e.value?(U.value?xe.value:xe.value.filter(K=>K.key===E.value)).map(K=>({...K,sections:K.sections.filter(ne=>Es(ne))})).filter(K=>K.sections.length):[]),$e=B(()=>{const C=["live_read","live_apply","live_for_new_work","restart","activation_required","legacy_control","dormant"],K=new Map(C.map(ne=>[ne,[]]));for(const ne of P.value){const Ee=K.has(ne.apply_mode)?ne.apply_mode:"restart";K.get(Ee).push(ne)}return C.filter(ne=>K.get(ne).length).map(ne=>({key:ne,label:ds(ne),entries:K.get(ne)}))}),We=B(()=>P.value.filter(C=>C.apply_mode==="restart").length),je=B(()=>be.value.filter(C=>C.pending_restart)),Xe=B(()=>je.value.length);function it(C){const K=Oe.value.get(C);return K?{...K,apply_details:Go([K])}:null}function Qe(C){const K=`${C}.`;return be.value.filter(ne=>ne.path===C||ne.path.startsWith(K))}function se(){return be.value.some(C=>C.path==="tools.hosts"||C.path.startsWith("tools.hosts."))}function Re(){var ne,Ee;const C=((Ee=(ne=e.value)==null?void 0:ne.tools)==null?void 0:Ee.hosts)||{},K=Object.keys(C).length;return`${K} host${K===1?"":"s"} configured.`}function Le(C){return Qe(C).length}function Te(C){return wi(C)}function W(C){const K=Qe(C);if(!K.length)return`${wi(C)} configuration.`;const ne=K.find(St=>St.sensitivity==="public"&&St.description)||K.find(St=>St.description),Ee=(ne==null?void 0:ne.description)||"";return Ee.match(/setting for (.+)\.$/i)?`${wi(C)} settings and runtime behaviour.`:Ee}function De(C){const K=[...new Set(Qe(C).map(ne=>ne.apply_mode))];return K.length===1?K[0]:K.includes("restart")?"restart":K.includes("activation_required")?"activation_required":K[0]||"restart"}function ze(C){const K=[...new Set(Qe(C).map(ne=>ds(ne.apply_mode)))];return K.length?K.length===1?K[0]:`Mixed apply behaviour: ${K.join(" · ")}`:""}function Ye(C){return Go(Qe(C))}function Dt(C){var K;return Object.hasOwn(O.value,C)?O.value[C]:(K=e.value)==null?void 0:K[C]}function et(){const C=Dt("mcp")||{},K=Object.keys(C.servers||{}).length;return`${C.enabled?"Globally enabled":"Globally disabled"} · ${K} configured server${K===1?"":"s"}.`}function Rt(C,K){return K.split(".").reduce((ne,Ee)=>ne==null?void 0:ne[Ee],C)}function Pt(C){const K=S.value;return Qe(C).filter(ne=>Xf(ne.path)?!1:ne.path.split(".").length<=2?!0:!ne.path.includes(".*")).map(ne=>({...ne,key:ne.path.split(".").at(-1),value:Rt(K,ne.path),apply_details:Go([ne]),editor:ne.path==="agents.final_warning_iterations"?"warning-chips":null}))}function Ts(C){const K=C.path.split(".");return K.length>2?K.slice(0,2).join("."):null}function Vs(C){const K=new Map;for(const ne of Pt(C)){const Ee=Ts(ne),Je=Ee||`${C}.__root`;K.has(Je)||K.set(Je,{key:Je,path:Ee,entries:[]}),K.get(Je).entries.push(ne)}return[...K.values()].map(ne=>{const Ee=ne.entries.find(Je=>Je.group_description);return{...ne,label:ne.path?wi(ne.path.split(".").at(-1)):null,description:(Ee==null?void 0:Ee.group_description)||null,apply_details:Go(ne.entries),runtime_summaries:zt(ne.entries)}})}function Gs(C){return{save:C.save_effect||(C.apply_mode==="dormant"?"Saving records this value in config.yml.":"Saving records this value and validates the section."),runtime:C.runtime_effect||{live_read:"Odin reads the saved value during current work.",live_apply:"Odin reloads this setting without a restart.",live_for_new_work:"New work uses the saved value; existing work keeps its snapshot.",restart:"Odin keeps using its startup value until a clean restart.",activation_required:"Odin keeps the current behavior until you enable this feature separately.",legacy_control:"Odin keeps the existing compatibility behavior until you apply this choice.",dormant:"This version of Odin does not use the saved value. Restarting will not activate it."}[C.apply_mode]||"Effective runtime state is not currently observable."}}function zt(C){const K=new Map;for(const ne of C){const Ee=Gs(ne),Je=`${ne.apply_mode}|${Ee.save}|${Ee.runtime}`;K.has(Je)||K.set(Je,{key:Je,label:ds(ne.apply_mode),save:Ee.save,runtime:Ee.runtime})}return[...K.values()]}function ta(C){if(Ws(C))return C.runtime_effect||C.activation_policy||"";if(C.apply_mode==="activation_required"){const K=C.activation_policy||C.runtime_effect;return K?`Not active after saving. No activation control exists in this release. ${K}`:"Not active after saving; no activation control exists in this release."}return""}function Ws(C){return C.action_available===!0&&!!(C.action_label&&C.action_endpoint)}async function Pa(C){if(Ws(C))try{if(Fe(C.path))throw new Error("Save this setting before applying its action.");const K=String(C.action_method||"POST").toLowerCase(),ne={post:z.post.bind(z),put:z.put.bind(z),delete:z.del.bind(z)}[K];if(!ne)throw new Error("Unsupported configuration action");await ne(C.action_endpoint,C.action_body||void 0),await gn(),As("success",`${C.action_label} completed.`)}catch(K){As("error",K.message||`${C.action_label} failed`)}}function hs(C,K){return[C.label,C.path,C.description,...C.aliases||[]].filter(Boolean).join(" ").toLowerCase().includes(K)}function $a(C){const K=T.value.trim().toLowerCase();return K?Qe(C).filter(ne=>hs(ne,K)):[]}function Es(C){const K=Qe(C);if(L.value!=="all"&&!K.some(Ee=>Ee.apply_state===L.value))return!1;const ne=T.value.trim().toLowerCase();return!ne||`${Te(C)} ${C}`.toLowerCase().includes(ne)?!0:K.some(Ee=>hs(Ee,ne))}function Ct(C,K){return Qe(C).filter(ne=>ne.apply_state===K).length}function ls(C){return C==="all"?oe.value:be.value.filter(K=>K.apply_state===C).length}function sa(C){const K=C.sections.flatMap(ne=>Qe(ne));return{fields:K.length,modified:P.value.filter(ne=>C.sections.includes(ne.path.split(".")[0])).length,pending_restart:K.filter(ne=>ne.apply_state==="pending_restart").length,invalid:K.filter(ne=>ne.apply_state==="invalid").length,dormant:K.filter(ne=>ne.apply_state==="dormant").length}}function ba(C){var K;return Object.hasOwn(O.value,C)&&!Oi((K=e.value)==null?void 0:K[C],O.value[C])}function Fe(C){return P.value.some(K=>K.path===C||K.path.startsWith(`${C}.`))}function ue(C){E.value=C,T.value="",L.value="all";try{localStorage.setItem(jg,C)}catch{}}function Ce(C){L.value=C}function qe(){T.value="",L.value="all"}function Ge(C){var K;return((K=xe.value.find(ne=>ne.sections.includes(C)))==null?void 0:K.sections)||[]}function It(C){const K=Ge(C),ne=K.find(Ee=>F.value[Ee]===!0);return ne||K.find(Ee=>F.value[Ee]!==!1)||null}function ot(C){return T.value&&!ce.value&&Es(C)?!0:ce.value?It(C)===C:Object.hasOwn(F.value,C)?F.value[C]===!0:!0}function pn(C){const K=!ot(C);if(ce.value){const ne={...F.value};for(const Ee of Ge(C))ne[Ee]===!0&&(ne[Ee]=!1);ne[C]=K,F.value=ne;return}F.value={...F.value,[C]:K}}function pi(){N.value.push(Gn(O.value)),N.value.length>$C&&N.value.shift(),M.value=[]}function Fa(){n.value||G.value&&(pi(),O.value={},ee.value={},$.value=!1)}function fi(C,K=!1){const ne=Date.now();if(K&&ae.path===C&&ne-ae.atJe-Ee);H.value="",kt(C,ne)}function $n(C,K){kt(C,(C.value||[]).filter(ne=>ne!==K))}function Ua(C){return C.apply_mode==="live_read"?"Odin reads the saved file value on next use.":C.apply_mode==="live_for_new_work"?"New work uses the saved file value.":C.apply_mode==="live_apply"?C.apply_handler?`Apply the saved value through ${C.apply_handler}.`:"Apply it through its dedicated owner page or endpoint.":C.apply_mode==="restart"?"Restart Odin for the saved collection to take effect.":C.apply_mode==="activation_required"?"Saving does not enable it. No activation control exists in this release.":C.apply_mode==="dormant"?"This release does not use the saved collection.":"Follow the runtime details shown for this setting."}function vn(C){return C.type==="array"&&Array.isArray(C.value)&&!C.structured_container&&!C.structured_container_child&&C.sensitivity==="public"&&C.value.every(K=>["string","number","boolean"].includes(typeof K))}function Ie(C){const K=String(te.value[C.path]??"").trim();if(!K)return;const ne=[...new Set([...C.value||[],K])];te.value={...te.value,[C.path]:""},kt(C,ne)}function D(C,K){kt(C,(C.value||[]).filter(ne=>ne!==K))}function pe(C,K){var Ee;if(!C)return null;if((Ee=C.enum)!=null&&Ee.length&&!C.enum.includes(K))return`Choose one of: ${C.enum.join(", ")}`;if(C.path==="agents.final_warning_iterations"&&(!Array.isArray(K)||!K.length))return"Add at least one warning threshold.";const ne=C.constraints||{};if((C.type==="integer"||C.type==="number")&&typeof K=="number"){if(ne.minimum!==void 0&&Kne.maximum)return`Must be at most ${ne.maximum}${C.unit?` ${C.unit}`:""}`}return null}function ke(C){return de.value[C.path]||null}function Be(C){const K=`${C}.`;return Object.keys(de.value).some(ne=>ne===C||ne.startsWith(K))}function Ue(){n.value||N.value.length&&(M.value.push(Gn(O.value)),O.value=N.value.pop(),ee.value={},I.value={},ae={path:null,at:0})}function Ke(){n.value||M.value.length&&(N.value.push(Gn(O.value)),O.value=M.value.pop(),ee.value={},I.value={},ae={path:null,at:0})}function $t(){!G.value||ge.value||($.value=!0,re.value=!1)}function gt(){$.value=!1}function Ot(){Fa()}function ds(C){return DC[C]||wi(C||"unknown")}function ut(C){return`apply-${String(C||"unknown").replaceAll("_","-")}`}function ua(C){return`cfgc-field-${C.replace(/[^a-zA-Z0-9_-]/g,"-")}`}function hn(C){return`${ua(C)}-input`}function _o(C){const K=document.getElementById(ua(C))||document.getElementById(ua(C.split(".").slice(0,2).join(".")));K==null||K.scrollIntoView({behavior:"smooth",block:"center"})}function As(C,K){g.value={type:C,message:K},window.setTimeout(()=>{var ne;((ne=g.value)==null?void 0:ne.message)===K&&(g.value=null)},3500)}function vc(){x.value=!1,L.value="pending_restart",T.value="";const C=MC(a.value);C&&(C.scrollTop=0)}function hc(){x.value=!1}function wo(C=1800){Q&&window.clearTimeout(Q),Q=window.setTimeout(gc,C)}async function gc(){if(w.value){if(V+=1,V>45){w.value=!1,_.value="Odin did not return with the new startup settings within 90 seconds.";return}try{if(t.value=await gl(),Xe.value===0){w.value=!1,_.value=null,As("success","Odin restarted and the saved startup settings are active.");return}}catch{}wo(2e3)}}async function bc(){if(!w.value){_.value=null;try{await z.post("/api/restart",{}),w.value=!0,V=0,x.value=!1,wo()}catch(C){_.value=C.message||"Odin could not schedule a restart."}}}async function ko(){if(!(!G.value||ge.value||n.value)){n.value=!0;try{const C=zC(e.value,O.value),K=await z.put("/api/config",C);e.value=K,O.value={},N.value=[],M.value=[],ee.value={},$.value=!1;try{t.value=await gl(),b.value=null,x.value=Xe.value>0,As("success",Xe.value?`Configuration saved. ${Xe.value} setting${Xe.value===1?"":"s"} still use startup values.`:"Configuration saved. Apply status has been refreshed.")}catch(ne){b.value=ne.message||"Unknown metadata error.",As("error",`Configuration saved, but apply status could not be refreshed: ${b.value}`)}}catch(C){As("error",C.message||"Configuration could not be saved")}finally{n.value=!1}}}async function yc(){if(!n.value){n.value=!0,y.value=null;try{t.value=await gl(),b.value=null}catch(C){y.value=`Image model status could not be refreshed: ${C.message||"Unknown error"}`}finally{n.value=!1}}}async function xc(C,K){if(n.value||!["follow","pin"].includes(K)||!C.length||C.some(Ee=>{var Je,St;return!k.includes(Ee)||!((St=(Je=t.value)==null?void 0:Je.image_model_defaults)!=null&&St[Ee])}))return;n.value=!0,y.value=null;let ne=!1;try{const Ee=await z.post("/api/config/image-models",{operations:Object.fromEntries(C.map(Je=>[Je,K])),expected_revision:t.value.image_model_revision});ne=!0;for(const Je of C){const St=`image.openai.${Je}`,Ks=Rt(e.value,St),gs=Rt(Ee.config,St),yn=Rs=>!Object.hasOwn(Rs,"image")||!Oi(Rt(Rs,St),Ks)?Rs:Ba(Rs,St.split("."),gs);O.value=yn(O.value),N.value=N.value.map(yn),M.value=M.value.map(yn),e.value=Ba(e.value,St.split("."),gs)}t.value={...t.value,image_model_defaults:Ee.image_model_defaults,image_model_revision:Ee.image_model_revision},As("success","Image model defaults saved. Unrelated drafts were not saved.")}catch(Ee){y.value=`Image model operation failed: ${Ee.message||"Unknown error"}. Refresh status before retrying if the outcome is uncertain.`}try{t.value=await gl(),b.value=null}catch(Ee){const Je=`Image model status could not be refreshed: ${Ee.message||"Unknown error"}`;b.value=Je,y.value=ne?`Image model defaults were saved, but ${Je}`:`${y.value} ${Je}`}finally{n.value=!1}}async function gn(){var C,K;if(!(G.value||n.value)){s.value=!0,A.value=null;try{const ne=await z.get("/api/config"),Ee=await gl();await m(),e.value=ne,t.value=Ee,b.value=null;const Je=xe.value;if(Je.some(St=>St.key===E.value)||(E.value=((C=Je[0])==null?void 0:C.key)||zi[0].key),ce.value){const Ks=(((K=Je.find(gs=>gs.key===E.value))==null?void 0:K.sections)||[]).find(gs=>F.value[gs]===!0);F.value=Ks?{...F.value,[Ks]:!0}:{}}}catch(ne){A.value=ne.message||"Unknown configuration error"}finally{s.value=!1}}}function bn(C){if($.value||!(C.ctrlKey||C.metaKey))return;const K=C.target;K instanceof HTMLElement&&(K.matches("input, textarea, select")||K.isContentEditable)||(!C.shiftKey&&C.key.toLowerCase()==="z"?(C.preventDefault(),Ue()):(C.key.toLowerCase()==="y"||C.shiftKey&&C.key.toLowerCase()==="z")&&(C.preventDefault(),Ke()))}function mi(C){ce.value=C.matches}Gt(F,C=>{try{localStorage.setItem(Hg,JSON.stringify(C))}catch{}},{deep:!0});let Fn=!1;function vi(){Fn||(Fn=!0,document.addEventListener("keydown",bn))}function al(){Fn&&(Fn=!1,document.removeEventListener("keydown",bn))}return st(()=>{var C;gn(),vi(),Z=window.matchMedia("(max-width: 760px)"),mi(Z),(C=Z.addEventListener)==null||C.call(Z,"change",mi)}),cs(vi),es(al),es(()=>{o.value=""}),wt(()=>{var C;o.value="",al(),(C=Z==null?void 0:Z.removeEventListener)==null||C.call(Z,"change",mi),Q&&window.clearTimeout(Q)}),{listenerState:i,listenerConsent:l,listenerCredential:o,listenerSaving:r,listenerMessage:c,listenerError:d,listenerStatusError:u,listenerChoiceChanged:he,listenerStatusTone:fe,listenerStatusLabel:me,listenerAuthorizationCopy:we,listenerConfiguredSourceCopy:J,listenerRunningCopy:ie,saveListenerConsent:h,armKeydown:vi,disarmKeydown:al,handleKeydown:bn,config:e,meta:t,loading:s,saving:n,error:A,toast:g,metaRefreshError:b,restartPromptOpen:x,restartScheduled:w,restartError:_,configMain:a,imageModelError:y,imageModelLeaves:k,setImageModelDefaults:xc,refreshImageModelMetadata:yc,searchQuery:T,healthFilter:L,activeCategory:E,reviewOpen:$,mobileOverflowOpen:re,warningThresholdInput:H,arrayInputs:te,healthFilters:ve,visibleCategories:xe,displayGroups:Ae,reviewGroups:$e,sectionCount:le,fieldCount:oe,hasChanges:G,changeCount:X,changedSectionCount:_e,hasDraftErrors:ge,canUndo:j,canRedo:Y,globalFilterActive:U,reviewRestartCount:We,pendingRestartCount:Xe,pendingRestartFields:je,healthCount:ls,categoryStats:sa,selectCategory:ue,selectHealthFilter:Ce,clearFilters:qe,sectionLabel:Te,sectionDescription:W,sectionFieldCount:Le,sectionHealthCount:Ct,sectionApplySummary:ze,sectionApplyDetails:Ye,sectionEntries:Pt,fieldGroups:Vs,sectionSearchHits:$a,mcpConfigSummary:et,fieldRuntimeCopy:Gs,fieldSpecificRuntimeNote:ta,hasHonestAction:Ws,runFieldAction:Pa,hasHostsCollection:se,hostsConfigSummary:Re,sectionChanged:ba,fieldChanged:Fe,isSectionExpanded:ot,toggleSection:pn,discardAllDrafts:Fa,setFieldValue:kt,setNumberFieldValue:Pn,numberInputValue:xa,beginInputEdit:$s,endTextInputEdit:Wt,endInputEdit:ya,addWarningThreshold:mn,removeWarningThreshold:$n,isScalarArray:vn,addScalarArrayItem:Ie,removeScalarArrayItem:D,fieldError:ke,sectionHasErrors:Be,undo:Ue,redo:Ke,openReview:$t,closeReview:gt,mobileCancel:Ot,applyModeLabel:ds,applyClass:ut,compactValue:BC,formatValue:UC,structuredApplyCopy:Ua,fieldId:ua,fieldInputId:hn,focusField:_o,fetchConfig:gn,saveConfig:ko,restartOdin:bc,restartLater:hc,reviewPendingRestart:vc}}},VC=/^\d{15,25}$/;function Gg(e){return String((e==null?void 0:e.display_name)||(e==null?void 0:e.username)||(e==null?void 0:e.id)||"Unknown user")}const Wg={props:{members:{type:Array,default:()=>[]},excludedIds:{type:Array,default:()=>[]},placeholder:{type:String,default:"Search Discord users…"},ariaLabel:{type:String,default:"Search Discord users"},optionsId:{type:String,required:!0},autofocus:{type:Boolean,default:!1},showAddButton:{type:Boolean,default:!1}},emits:["select"],template:` +
    + + +
    + + +
    +
    + `,setup(e,{emit:t}){const s=f(""),a=f(!1),n=f(0),i=f(null),l=B(()=>new Set((e.excludedIds||[]).map(String))),o=B(()=>{const _=s.value.toLowerCase().trim();return(e.members||[]).filter(T=>l.value.has(String(T.id))?!1:_?p(T).toLowerCase().includes(_)||String(T.username||"").toLowerCase().includes(_)||String(T.id).includes(_):!0)}),r=B(()=>{const _=s.value.trim();return o.value.length===0&&VC.test(_)&&!l.value.has(_)?_:""}),c=B(()=>o.value.length+(r.value?1:0)),d=B(()=>!!s.value.trim()&&!!(o.value[n.value]||r.value&&n.value===o.value.length)),u=B(()=>{if(a.value){if(o.value[n.value])return`${e.optionsId}-${n.value}`;if(r.value&&n.value===o.value.length)return`${e.optionsId}-raw`}});function p(_){return Gg(_)}function m(){a.value=!0,n.value=0}function h(){m()}function y(){const _=Math.max(c.value-1,0);n.value=Math.min(n.value+1,_)}function k(){n.value=Math.max(n.value-1,0)}function A(){const _=o.value[n.value];_?g(_):r.value&&n.value===o.value.length&&b(r.value)}function g(_){b(String(_.id))}function b(_){t("select",_),s.value="",a.value=!1,n.value=0}function x(){a.value=!1}function w(){setTimeout(x,150)}return st(()=>{e.autofocus&&Ut(()=>{var _;return(_=i.value)==null?void 0:_.focus()})}),{query:s,open:a,highlightedIndex:n,input:i,filteredMembers:o,rawId:r,activeOptionId:u,selectableValue:d,memberName:p,openOptions:m,onInput:h,highlightNext:y,highlightPrevious:k,selectHighlighted:A,selectMember:g,selectId:b,closeOptions:x,onBlur:w}}};function em(e,t,s){var a;return((a=e==null?void 0:e.config)==null?void 0:a[t])!=null?e.config[t]:s==null?void 0:s[t]}const GC={components:{DiscordUserCombobox:Wg},template:` +
    +
    +

    Discord Channels

    + +
    +
    +
    +
    +

    Gateway connection

    + {{ connectionState.label }} +
    +

    {{ connectionState.detail }}

    +
    + Configured + No usable credential + {{ connection.credential_preferred_storage ? 'Stored in the preferred environment format' : (connection.credential_usable ? 'Legacy storage; replace to migrate' : 'Not stored') }} +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +

    + For ordinary conversational intake, allowed users and channels are absolute global gates; guild and channel settings cannot readmit a blocked message. + Explicitly allowed test webhooks bypass the user gate. Require-mention and bot-response behavior + resolve channel → guild → global. An explicit mention bypasses the ignored-bot list, but the effective respond-to-bots policy still applies. + Changes take effect immediately. +

    + +
    +
    +
    +
    +

    {{ error }}

    + +
    + +
    +
    +
    +
    +

    Global defaults

    +

    Allowed users and channels are absolute. Require-mention and bot-response values are defaults that guild or channel settings may override.

    +
    +
    + +
    + + +
    +
    +
    +
    {{ editor.label }} +
    + ? +

    {{ editor.description }}

    +
    +
    +
    + {{ globalItemLabel(editor, item) }} + + + No entries +
    +
    + +
    +
    + + +
    +
    +
    + +
    + +
    + +
    +
    + +
    + {{ guild.name.charAt(0) }} +
    +
    + {{ guild.name }} + {{ guild.member_count }} members +
    +
    +
    + + + + +
    +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + + +
    ChannelCategoryEnabledRequire @mentionRespond to botsOverride
    #{{ ch.name }}{{ ch.category || '—' }} + + + + + + + + inherit +
    +
    +
    +
    +
    +
    + `,setup(){const e=f([]),t=f({persisted:!1,credential_usable:!1,credential_preferred_storage:!1,connection:{state:"unavailable",detail:"Connection status unavailable"}}),s=f(""),a=f(!1),n=f(!1),i=f(null);let l=null;const o=f(!0),r=f(null),c=f({}),d=f(null),u=f(null),p=f(!1),m=f(null),h=f({}),y=f([]);let k=0;const A=Object.freeze([{key:"allowed_users",label:"Allowed users",description:"Absolute gate for ordinary conversational intake. Guild/channel settings cannot readmit blocked users; explicitly allowed test webhooks bypass this gate.",placeholder:"Search users",userAutocomplete:!0},{key:"channels",label:"Allowed channels",description:"Absolute gate for ordinary conversational intake. Guild/channel settings cannot readmit blocked channels.",placeholder:"Search channels"},{key:"ignore_bot_ids",label:"Ignored bot IDs",description:"Ignored unless the bot explicitly mentions Odin; the effective respond-to-bots policy still applies.",placeholder:"Bot ID",userAutocomplete:!0}]),g=B(()=>{var Y,xe,S,P;const oe=String(((xe=(Y=t.value)==null?void 0:Y.connection)==null?void 0:xe.state)||"").toLowerCase();return{...{connected:{key:"connected",label:"Connected",badgeClass:"badge-success"},connecting:{key:"connecting",label:"Connecting",badgeClass:"badge-warning"},disconnected:{key:"disconnected",label:"Disconnected",badgeClass:""},detached:{key:"disconnected",label:"Disconnected",badgeClass:""},detaching:{key:"disconnected",label:"Disconnected",badgeClass:""},stopped:{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"},failed:{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"}}[oe]||{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"},detail:((P=(S=t.value)==null?void 0:S.connection)==null?void 0:P.detail)||"Connection status unavailable"}}),b=B(()=>JSON.stringify(d.value)!==JSON.stringify(u.value)),x=B(()=>new Map(y.value.map(oe=>[String(oe.id),oe])));function w(oe){return oe.config&&oe.config.enabled!==void 0?oe.config.enabled:!0}function _(oe){return em(oe,"require_mention",d.value)}function T(oe){return em(oe,"respond_to_bots",d.value)}function L(oe){return oe.config&&Object.keys(oe.config).length>0}function E(oe){c.value[oe]=!c.value[oe]}function F(oe){const ve=oe.discord||{};return{allowed_users:[...ve.allowed_users||[]],channels:[...ve.channels||[]],respond_to_bots:!!ve.respond_to_bots,require_mention:!!ve.require_mention,ignore_bot_ids:[...ve.ignore_bot_ids||[]]}}async function O({showLoading:oe=!0}={}){const ve=++k;oe&&(o.value=!0),r.value=null;try{const j=await z.get("/api/discord/guilds");ve===k&&(e.value=j)}catch(j){ve===k&&(r.value=j.message)}finally{oe&&ve===k&&(o.value=!1)}}async function I(){try{t.value=await z.get("/api/discord/connection"),i.value=null}catch(oe){i.value=oe.message}}async function H(oe,ve=null){if(!n.value){n.value=!0,i.value=null;try{const j={operation:oe};ve!==null&&(j.token=ve),t.value=await z.post("/api/discord/connection",j),oe==="credentials"&&(s.value="",a.value=!1)}catch(j){i.value=j.message||"Connection update failed."}finally{n.value=!1}}}function te(){if(!(!a.value||!s.value))return H("credentials",s.value)}function ee(){return H("connect")}function N(){return H("detach")}async function M(){o.value=!0,r.value=null;try{const[oe,ve,j]=await Promise.all([z.get("/api/discord/guilds"),z.get("/api/discord/members").catch(()=>[]),z.get("/api/config")]),Y=F(j),xe=b.value;d.value=Y,xe||(u.value=JSON.parse(JSON.stringify(Y))),y.value=ve,e.value=oe,m.value=null}catch(oe){r.value=oe.message}finally{o.value=!1}}let $=Promise.resolve();const re=f(new Set);function ce(oe,ve){const j=new Set(re.value);j.add(oe),re.value=j;const Y=$.then(ve);return $=Y.catch(()=>{}),Y.finally(()=>{const xe=new Set(re.value);xe.delete(oe),re.value=xe})}function Z(oe,ve,j,Y){const xe=(Y==null?void 0:Y.target)??null;return ce(`guild:${oe}:${ve}`,async()=>{try{await z.put("/api/discord/guild/"+oe+"/config",{[ve]:j}),await O({showLoading:!1})}catch(S){r.value=S.message,xe&&typeof j=="boolean"&&(xe.checked=!j)}})}function Q(oe,ve,j,Y,xe){const S=(xe==null?void 0:xe.target)??null;return ce(`channel:${oe}:${j}`,async()=>{try{await z.put("/api/discord/channel/"+oe+"/config",{[j]:Y}),await O({showLoading:!1})}catch(P){r.value=P.message,S&&typeof Y=="boolean"&&(S.checked=!Y)}})}function ae(oe,ve){return ce(`channel:${oe}:clear`,async()=>{try{await z.put("/api/discord/channel/"+oe+"/config",{clear:!0}),await O({showLoading:!1})}catch(j){r.value=j.message}})}function V(oe,ve){const j=String(ve);if(!oe.userAutocomplete)return j;const Y=x.value.get(j);return Y?Gg(Y):j}function be(oe,ve=null){const j=String(ve??h.value[oe]??"").trim();!j||u.value[oe].includes(j)||(u.value[oe]=[...u.value[oe],j],h.value={...h.value,[oe]:""})}function Oe(oe,ve){u.value[oe]=u.value[oe].filter(j=>j!==ve)}async function le(){if(!(!b.value||p.value)){p.value=!0,m.value=null;try{const ve=(await z.put("/api/config",{discord:u.value})).discord||u.value;d.value={allowed_users:[...ve.allowed_users||[]],channels:[...ve.channels||[]],respond_to_bots:!!ve.respond_to_bots,require_mention:!!ve.require_mention,ignore_bot_ids:[...ve.ignore_bot_ids||[]]},u.value=JSON.parse(JSON.stringify(d.value))}catch(oe){m.value=oe.message||"Global defaults could not be saved."}finally{p.value=!1}}}return st(()=>{M(),I(),l=window.setInterval(I,5e3)}),wt(()=>{l!==null&&window.clearInterval(l),l=null}),{guilds:e,loading:o,error:r,expanded:c,globalDraft:u,globalSaving:p,globalError:m,globalArrayInputs:h,globalMembers:y,globalListEditors:A,globalChanged:b,guildEnabled:w,guildMention:_,guildBots:T,hasOverride:L,toggleGuild:E,fetchAll:M,fetchGuilds:O,setGuildConfig:Z,setChannelConfig:Q,clearOverride:ae,mutationPending:re,globalItemLabel:V,addGlobalItem:be,removeGlobalItem:Oe,saveGlobalDefaults:le,connection:t,connectionState:g,connectionToken:s,connectionTokenDirty:a,connectionBusy:n,connectionError:i,saveDiscordCredentials:te,connectDiscord:ee,detachDiscord:N}}},Js=e=>e==null?e:JSON.parse(JSON.stringify(e));function WC({applyDefault:e,applyUser:t,applyDelete:s,onDefaultConfirmed:a=()=>{},onDefaultRollback:n=()=>{},onUserConfirmed:i=()=>{},onUserRollback:l=()=>{},onUserDeleted:o=()=>{},onError:r=()=>{}}){let c=Promise.resolve(),d=0,u=0;const p=new Map;let m=null;const h=new Map;function y(_){d+=1;const T=c.then(_,_);return c=T.catch(()=>{}),T}function k(_,T){m=Js(_),h.clear();for(const[L,E]of Object.entries(T||{}))h.set(L,Js(E))}function A(_){const T=Js(_),L=++u;return y(async()=>{try{await e(Js(T)),m=Js(T),L===u&&a(Js(T))}catch(E){L===u&&(n(Js(m)),r(E,{kind:"default"}))}})}function g(_,T){const L=Js(T),E=(p.get(_)||0)+1;return p.set(_,E),y(async()=>{try{await t(_,Js(L)),h.set(_,Js(L)),E===p.get(_)&&i(_,Js(L))}catch(F){E===p.get(_)&&(l(_,Js(h.get(_)??null)),r(F,{kind:"user",uid:_}))}})}function b(_){const T=(p.get(_)||0)+1;return p.set(_,T),y(async()=>{try{await s(_),h.delete(_),T===p.get(_)&&o(_)}catch(L){T===p.get(_)&&(l(_,Js(h.get(_)??null)),r(L,{kind:"delete",uid:_}))}})}async function x(){for(;;){const _=c;if(await _,_===c)return d}}async function w(_){for(;;){const T=await x(),L=await _();if(T===d)return L}}return{seed:k,saveDefault:A,saveUser:g,deleteUser:b,whenIdle:x,readSnapshot:w,get revision(){return d}}}const KC={components:{DiscordUserCombobox:Wg,DiscordIdentity:Dn},template:` +
    +
    +

    Host Access Control

    + +
    +

    + Control which hosts each user can execute commands on and set per-user defaults. + Users without an explicit entry fall back to the default policy. +

    + +
    +
    +
    +
    +

    {{ error }}

    + +
    + +
    + +
    +

    Default Policy

    +

    Applied to users without an explicit host access entry.

    +
    {{ chip }}
    + +

    Default host: {{ defaultPolicy.default_host || 'None' }}

    +
    + + +
    +
    +

    User Overrides

    + + +
    + + +
    +
    + + +
    +
    + + +
    + + + + + + + + + + + + + + + + + + +
    UserTierHostsDefault HostRemove
    + +
    Invalid tier: {{ permissions.invalid_overrides[uid] }}
    {{ chip }}
    + {{ (users[uid] || defaultPolicy).default_host || 'None' }} + + +
    +
    +

    No user overrides configured. All users follow the default policy.

    +
    +
    + + + +
    +

    Edit hosts

    {{ editor === 'default' ? 'Default Policy' : (getMember(editor)?.display_name || editor) }}

    +
    + + +
    + +

    No matching hosts.

    +
    + + +

    {{ defaultHostError }}

    + +
    +
    +
    +
    +
    +
    + `,setup(){const e=f(!0),t=f(""),s=f(null),a=f([]),n=f({}),i=f({allowed_hosts:[],default_host:""}),l=f({}),o=f([]),r=f(!1),c=f([]),d=f({overrides:{},config_tiers:{},invalid_overrides:{}}),u=["admin","user","guest"],p=f({}),m=f(""),h=f(null),y=f(null),k=f(!1),A=f(""),g=f(!1),b=f(""),x=f(""),w=B(()=>a.value.filter(j=>`${j} ${n.value[j]||""}`.toLowerCase().includes(b.value.toLowerCase()))),_=B(()=>a.value.filter(j=>{var Y,xe;return((Y=y.value)==null?void 0:Y.allow_all)||((xe=y.value)==null?void 0:xe.allowed_hosts.includes(j))})),T=B(()=>y.value?y.value.default_host&&!_.value.includes(y.value.default_host)?_.value.length?"Choose a new default from the allowed hosts before saving.":"No hosts are allowed. Select None for the default before saving.":g.value&&_.value.length&&!y.value.default_host?"Choose a new default from the allowed hosts before saving.":"":""),L=B(()=>[...new Set([...Object.keys(l.value),...Object.keys(d.value.overrides||{}),...Object.keys(d.value.config_tiers||{}),...Object.keys(d.value.invalid_overrides||{})])]),E=B(()=>L.value.filter(j=>{var Y,xe;return`${((Y=O(j))==null?void 0:Y.display_name)||""} ${((xe=O(j))==null?void 0:xe.username)||""} ${j}`.toLowerCase().includes(x.value.toLowerCase())})),F=B(()=>{const j={};for(const Y of c.value)j[Y.id]=Y;return j});function O(j){return F.value[j]||null}function I(j){if(!j||j.allow_all)return"All hosts";const Y=j.allowed_hosts||[];return Y.length>3?`${Y.slice(0,3).join(", ")} +${Y.length-3}`:Y.join(", ")||"No hosts"}function H(j){if(j.allow_all)return["All hosts"];const Y=j.allowed_hosts||[];return[...Y.slice(0,2),...Y.length>2?[`+${Y.length-2}`]:[],...Y.length?[]:["None"]]}function te(j){return o.value.includes(j)?H(l.value[j]):["Inherit policy"]}async function ee(j){var xe,S;if(k.value)return;const Y=j==="default"?i.value:l.value[j]||i.value;m.value=j,y.value={...Y,allowed_hosts:[...Y.allowed_hosts]},g.value=!!Y.default_host,b.value="",A.value="",await Ut(),y.value&&!((xe=h.value)!=null&&xe.open)&&((S=h.value)==null||S.showModal())}function N(){var j;k.value||((j=h.value)==null||j.close(),y.value=null,m.value="")}function M(j){if(j.key!=="Tab")return;const Y=[...h.value.querySelectorAll("button, input, select")].filter(P=>!P.matches(":disabled")),xe=Y[0],S=Y.at(-1);if(!xe){j.preventDefault();return}j.shiftKey&&document.activeElement===xe?(j.preventDefault(),S.focus()):!j.shiftKey&&document.activeElement===S&&(j.preventDefault(),xe.focus())}async function $(){var xe;if(!y.value||k.value||T.value)return;const j=m.value,Y={...y.value,allowed_hosts:[...y.value.allowed_hosts]};k.value=!0,A.value="",await Ut(),(xe=h.value)!=null&&xe.open&&h.value.focus();try{j==="default"?(i.value=Y,await ae.saveDefault(Y)):(l.value[j]=Y,o.value.includes(j)||o.value.push(j),await ae.saveUser(j,Y))}finally{k.value=!1}A.value||N()}function re(j){var Y,xe;return((Y=d.value.overrides)==null?void 0:Y[j])||((xe=d.value.config_tiers)==null?void 0:xe[j])||"default"}async function ce(j,Y){var xe;try{if(Y==="default"){if(!((xe=d.value.overrides)!=null&&xe[j]))return;await z.del(`/api/permissions/user/${encodeURIComponent(j)}`)}else await z.put(`/api/permissions/user/${encodeURIComponent(j)}`,{tier:Y});Y==="default"?delete d.value.overrides[j]:d.value.overrides[j]=Y,Se.success("Permission tier updated")}catch(S){Se.error(S.message||"Failed to update tier"),await be()}}async function Z(j){const Y=p.value[j];if(Y)try{await z.post(`/api/permissions/user/${encodeURIComponent(j)}/repair`,{tier:Y}),delete d.value.invalid_overrides[j],d.value.overrides[j]=Y}catch(xe){Se.error(xe.message||"Failed to repair tier")}}function Q(j,Y){return j?j.allowed_hosts===null||j.allowed_hosts===void 0?{allowed_hosts:[...Y],default_host:j.default_host||"",allow_all:!0}:{allowed_hosts:j.allowed_hosts,default_host:j.default_host||"",allow_all:!1}:{allowed_hosts:[...Y],default_host:Y[0]||"",allow_all:!0}}const ae=WC({applyDefault:async j=>{const Y=j.allow_all?null:j.allowed_hosts;await z.put("/api/host-access/default-policy",{allowed_hosts:Y,default_host:j.default_host})},applyUser:async(j,Y)=>{const xe=Y.allow_all?null:Y.allowed_hosts;await z.put(`/api/host-access/user/${j}`,{allowed_hosts:xe,default_host:Y.default_host})},applyDelete:j=>z.del(`/api/host-access/user/${j}`),onDefaultConfirmed:()=>Se.success("Default policy updated"),onDefaultRollback:j=>{j&&(i.value=j)},onUserConfirmed:j=>{const Y=O(j);Se.success(`Updated access for ${Y?Y.display_name:j}`)},onUserRollback:(j,Y)=>{const xe={...l.value};Y?xe[j]=Y:delete xe[j],l.value=xe,Y||(o.value=o.value.filter(S=>S!==j))},onUserDeleted:j=>{const Y={...l.value};delete Y[j],l.value=Y,o.value=o.value.filter(xe=>xe!==j)},onError:(j,Y)=>{var S;k.value&&(A.value=j.message||"Failed to save. Your draft is retained; try again.");const xe=Y.uid?` ${((S=O(Y.uid))==null?void 0:S.display_name)||Y.uid}`:"";Se.error(`${j.message||"Failed to save"} — reverted${xe}`)}});let V=0;async function be(){const j=++V;e.value=!0,t.value="";try{const Y=await ae.readSnapshot(()=>z.get("/api/host-access"));if(j!==V)return;s.value=Y,a.value=Y.available_hosts||[],n.value=Y.host_descriptions||{},i.value=Q(Y.default_policy,a.value);const xe=Y.users||{};o.value=Object.keys(xe);const S={};for(const[P,G]of Object.entries(xe))S[P]=Q(G,a.value);l.value=S,ae.seed(i.value,S),d.value=await z.get("/api/permissions/tiers")}catch(Y){j===V&&(t.value=Y.message||"Failed to fetch host access data")}finally{j===V&&(e.value=!1)}try{const Y=await z.get("/api/discord/members")||[];j===V&&(c.value=Y)}catch{j===V&&(c.value=[])}}function Oe(){r.value=!0}function le(j){!/^\d{15,25}$/.test(j)||l.value[j]||(l.value[j]={allowed_hosts:[...a.value],default_host:a.value[0]||"",allow_all:!1},o.value.includes(j)||o.value.push(j),ae.saveUser(j,l.value[j]),r.value=!1)}async function oe(j){const Y=O(j);await is({title:"Remove user override",message:`Remove the host access override for ${Y?Y.display_name:j}? They will fall back to the default policy.`,confirmLabel:"Remove",danger:!0})&&(await ae.deleteUser(j),l.value[j]||Se.success(`Removed override for ${Y?Y.display_name:j}`))}st(be);function ve(){var j;(j=h.value)==null||j.close(),y.value=null,m.value=""}return es(ve),wt(ve),{loading:e,error:t,data:s,availableHosts:a,hostDescriptions:n,defaultPolicy:i,users:l,showAddUser:r,members:c,hostOverrideIds:o,fetchData:be,getMember:O,openAddUser:Oe,addUserById:le,deleteUser:oe,permissions:d,validTiers:u,repairTiers:p,repairTier:Z,tierValue:re,setTier:ce,editor:m,hostQuery:b,userQuery:x,filteredHosts:w,allUserIds:L,visibleUserIds:E,hostSummary:I,hostChips:H,visibleHostChips:te,hostDialog:h,draft:y,saving:k,saveError:A,requiresDefault:g,defaultHostOptions:_,defaultHostError:T,openEditor:ee,closeEditor:N,saveEditor:$,trapEditorFocus:M}}},JC={template:` +
    +
    +

    Managed Hosts

    Live inventory, pinned SSH trust, and connection health.

    +
    +
    +
    {{ error }}
    +
    +
    Deletion is blocked by these references:
    +
    • {{ item.kind }} · {{ item.location }}
    +
    +
    + + + +
    +
    + + + + + + + + +
    AliasEndpointTrustStateLast testActions
    {{ host.alias }}
    {{ host.description || 'No description' }}
    {{ host.ssh_user }}@{{ host.address }}:{{ host.port }}
    {{ host.os }}
    {{ host.trust_state }}{{ host.targetable ? 'Targetable' : 'Disabled' }} · draining{{ host.last_test?.detail || 'Not tested this process' }}
    +
    +
    No managed hosts configured.
    + +
    +

    {{ editing ? 'Edit host' : 'Add host' }} · Step {{ step }} of 5

    +
    + + + + + + + + +
    +

    Install Odin's public key for {{ form.ssh_user }}@{{ form.address }}. Odin never accepts passwords or private keys here.

    {{ keyInfo.public_key }}
    {{ keyInfo.authorized_keys_command }}

    Fingerprint: {{ keyInfo.fingerprint }}. {{ keyInfo.permissions }}

    +

    Verify the host key out of band. Run ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the target and paste its SHA256 fingerprint.

    Observed: {{ observed.join(', ') }}
    +

    Test non-interactive authentication and platform identity before activation.

    {{ JSON.stringify(testResult,null,2) }}
    +

    Activation is live. Users with allowed_hosts: null gain this host automatically. Review grants on Host Access after saving.

    Open Host Access
    +
    +
    +
    `,setup(){const e=f([]),t=f(!1),s=f(""),a=f([]),n=f(!1),i=f(!1),l=f(1),o=f(""),r=f(!1),c=f(null),d=f(""),u=f([]),p=f(!1),m=f(null),h=f(""),y=()=>({alias:"",address:"",port:22,ssh_user:"root",os:"linux",description:"",trust_mode:"pinned",enabled:!0,confirm_local:!1,confirm_tofu:!1}),k=f(y()),A=B(()=>["127.0.0.1","localhost","::1"].includes(k.value.address));async function g(){t.value=!0,s.value="";try{const ee=await z.get("/api/hosts");e.value=ee.hosts||[],o.value=ee.default_host||"",r.value=!!ee.tofu_enabled}catch(ee){s.value=ee.message}finally{t.value=!1}}async function b(){try{await z.post("/api/hosts/settings",{default_host:o.value,allow_host_tofu:r.value}),Se.success("Host settings saved and published live"),await g()}catch(ee){Se.error(ee.message)}}function x(){d.value="",u.value=[],p.value=!1,m.value=null,c.value=null,h.value="",l.value=1,n.value=!0}function w(){i.value=!1,k.value=y(),x()}function _(ee){i.value=!0,k.value={...y(),...ee},x()}async function T(){try{c.value=await z.get("/api/hosts/public-key")}catch(ee){Se.error(ee.message)}}async function L(ee){try{const N=await z.post("/api/hosts/"+encodeURIComponent(ee.alias)+"/import-legacy",{});i.value=!0,k.value={...y(),...ee,trust_mode:"pinned"},x(),d.value=N.candidate_token,u.value=N.fingerprints||[],h.value=u.value.join(` +`),l.value=4,Se.info("Imported existing known_hosts trust. Test before activation.")}catch(N){Se.error(N.message)}}async function E(){try{const ee=h.value.split(/\s+/).filter(Boolean),N={...k.value,expected_fingerprints:ee,candidate_fingerprints:u.value},M=await z.post("/api/hosts/candidates",N);if(d.value=M.candidate_token,u.value=M.fingerprints||[],k.value.trust_mode==="tofu"&&N.candidate_fingerprints.length===0){k.value.confirm_tofu=!1,Se.info("Fingerprint scanned. Review it, tick confirmation, then scan again.");return}l.value=4}catch(ee){Se.error(ee.message)}}async function F(){var ee,N;p.value=!1,m.value=null;try{const M=await z.post("/api/hosts/candidates/"+d.value+"/test",{});p.value=!!M.tested,m.value=M.last_test,p.value&&(l.value=5)}catch(M){const $=(ee=M.data)==null?void 0:ee.last_test;$&&typeof $=="object"&&!Array.isArray($)&&(m.value=$);const re=(N=m.value)==null?void 0:N.detail;Se.error(typeof re=="string"&&re.trim()?re:M.message)}}async function O(){try{await z.post("/api/hosts/candidates/"+d.value+"/commit",{}),Se.success("Host saved and published live"),n.value=!1,await g()}catch(ee){Se.error(ee.message)}}async function I(ee){try{await z.post("/api/hosts/"+encodeURIComponent(ee.alias)+"/enabled",{enabled:!ee.enabled}),await g()}catch(N){Se.error(N.message)}}async function H(ee){var N;if(await is("Delete host "+ee.alias+"? Dependencies will block deletion.")){a.value=[];try{await z.del("/api/hosts/"+encodeURIComponent(ee.alias)),await g()}catch(M){a.value=Array.isArray((N=M.data)==null?void 0:N.pending_references)?M.data.pending_references:[],Se.error(M.message)}}}async function te(ee){if(await is("Force revoke "+ee.alias+"? Remote outcomes may be unknown."))try{await z.post("/api/hosts/"+encodeURIComponent(ee.alias)+"/force-revoke",{}),await g()}catch(N){Se.error(N.message)}}return st(g),{hosts:e,loading:t,error:s,pendingReferences:a,wizard:n,editing:i,step:l,defaultHost:o,tofuEnabled:r,form:k,isLocal:A,keyInfo:c,candidate:d,observed:u,tested:p,testResult:m,fingerprintsText:h,load:g,saveSettings:b,beginAdd:w,beginEdit:_,loadKey:T,importLegacy:L,prepare:E,testConnection:F,commit:O,toggle:I,remove:H,forceRevoke:te}}},ZC={components:{DiscordIdentity:Dn},template:` +
    +
    +

    API Tokens

    + +
    +

    + Manage API tokens for programmatic access, orchestrators, and web-chat identity. + Each token has its own user identity, permission tier, and host access scope. +

    + +
    +
    +
    +
    +

    {{ error }}

    + +
    + +
    + + + +
    +
    + Token Created + +
    +

    Copy this token now. It will not be shown again.

    +
    + {{ newToken }} + +
    +
    + + +
    +
    +

    Create Token

    + +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + +
    + +
    +
    +
    + +

    Used when API requests don't specify a host.

    +
    +
    + +
    +
    + +
    +
    +
    + + +
    +

    Active Tokens

    +
    + No API tokens configured. +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    User IDLabelTierHostsDefaultToolsSourceActions
    {{ t.label || '—' }} + {{ t.tier }} + + {{ t.allowed_hosts === null || t.allowed_hosts === undefined ? 'default policy' : t.allowed_hosts.length === 0 ? 'no host access' : t.allowed_hosts.join(', ') }} + + {{ t.default_host || 'policy' }} + + {{ t.allowed_tools && t.allowed_tools.length ? t.allowed_tools.length + ' tools' : 'tier default' }} + + + {{ t.source === 'config' ? 'config.yml' : 'dynamic' }} + + + + + + read-only
    +
    +
    + + + +
    + +
    + `,setup(){const e=f(!0),t=f(""),s=f(null),a=f([]),n=f([]),i=f("missing"),l=f(!1),o=f(!1),r=f(null),c=f(null),d=f(!1);async function u(O){var I;try{await z._request("DELETE","/api/tokens/unusable/"+O.index,{reason:O.reason,...O.user_id===void 0?{}:{user_id:O.user_id}}),Se.success("Unusable token entry removed"),await A()}catch(H){Se.error(((I=H.data)==null?void 0:I.error)||H.message||"Failed to remove unusable token")}}const p=f({user_id:"",username:"",tier:"admin",label:"",host_mode:"default",allowed_hosts:[],default_host:"",allowed_tools_str:""}),m=f({username:"",tier:"admin",label:"",host_mode:"default",allowed_hosts:[],default_host:"",allowed_tools_str:""}),h=B(()=>p.value.host_mode==="select"?p.value.allowed_hosts:p.value.host_mode==="none"?[]:a.value),y=B(()=>m.value.host_mode==="select"?m.value.allowed_hosts:m.value.host_mode==="none"?[]:a.value);function k(O){return O==="admin"?"text-xs px-1.5 py-0.5 rounded bg-red-900/50 text-red-400":O==="user"?"text-xs px-1.5 py-0.5 rounded bg-blue-900/50 text-blue-400":"text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400"}async function A(){e.value=!0,t.value="";try{const O=await z.get("/api/tokens");s.value=O.tokens||[],a.value=O.available_hosts||[],n.value=O.invalid_entries||[],i.value=O.store_status||"missing"}catch(O){t.value=O.message||"Failed to load tokens"}finally{e.value=!1}}function g(O){return!O||!O.trim()?[]:O.split(",").map(I=>I.trim()).filter(Boolean)}function b(O,I){const H=p.value.allowed_hosts;if(I&&!H.includes(O)&&H.push(O),!I){const te=H.indexOf(O);te>=0&&H.splice(te,1)}}function x(O,I){const H=m.value.allowed_hosts;if(I&&!H.includes(O)&&H.push(O),!I){const te=H.indexOf(O);te>=0&&H.splice(te,1)}}async function w(){var O;o.value=!0;try{const I=g(p.value.allowed_tools_str),H=p.value.host_mode,te=H==="none"?[]:H==="select"?p.value.allowed_hosts:null,ee={user_id:p.value.user_id.trim(),username:p.value.username.trim()||"API",tier:p.value.tier,label:p.value.label.trim(),allowed_tools:I.length?I:[]};te!==null&&(ee.allowed_hosts=te),ee.default_host=p.value.default_host||"";const N=await z.post("/api/tokens",ee);r.value=N.token,p.value={user_id:"",username:"",tier:"admin",label:"",host_mode:"default",allowed_hosts:[],default_host:"",allowed_tools_str:""},l.value=!1,Se.success("Token created"),await A()}catch(I){Se.error(((O=I.data)==null?void 0:O.error)||I.message||"Failed to create token")}finally{o.value=!1}}function _(O){c.value=O;const I=O.allowed_hosts;let H="default";I==null?H="default":Array.isArray(I)&&I.length===0?H="none":Array.isArray(I)&&(H="select"),m.value={username:O.username||"",tier:O.tier||"admin",label:O.label||"",host_mode:H,allowed_hosts:Array.isArray(I)?[...I]:[],default_host:O.default_host||"",allowed_tools_str:(O.allowed_tools||[]).join(", ")}}async function T(){var O;if(c.value){d.value=!0;try{const I=g(m.value.allowed_tools_str),H=m.value.host_mode,te={username:m.value.username,tier:m.value.tier,label:m.value.label,allowed_tools:I};H==="none"?te.allowed_hosts=[]:H==="select"?te.allowed_hosts=m.value.allowed_hosts:te.allowed_hosts=null,te.default_host=m.value.default_host||"",await z.put("/api/tokens/"+encodeURIComponent(c.value.user_id),te),c.value=null,Se.success("Token updated"),await A()}catch(I){Se.error(((O=I.data)==null?void 0:O.error)||I.message||"Failed to update")}finally{d.value=!1}}}async function L(O){var H;if(await is({title:"Regenerate token",message:`Regenerate token for ${O.username||O.user_id}? The old token will stop working immediately.`,confirmLabel:"Regenerate",danger:!0}))try{const te=await z.post("/api/tokens/"+encodeURIComponent(O.user_id)+"/regenerate");r.value=te.token,Se.success("Token regenerated")}catch(te){Se.error(((H=te.data)==null?void 0:H.error)||te.message||"Failed to regenerate")}}async function E(O){var H;if(await is({title:"Delete token",message:`Delete token for ${O.username||O.user_id}? This cannot be undone.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/tokens/"+encodeURIComponent(O.user_id)),Se.success("Token deleted"),await A()}catch(te){Se.error(((H=te.data)==null?void 0:H.error)||te.message||"Failed to delete")}}async function F(){if(r.value)try{await navigator.clipboard.writeText(r.value),Se.success("Copied to clipboard")}catch{Se.error("Copy failed — select and copy manually")}}return st(A),{loading:e,error:t,tokens:s,availableHosts:a,invalidEntries:n,storeStatus:i,showCreate:l,creating:o,newToken:r,editing:c,saving:d,createForm:p,editForm:m,removeUnusable:u,createDefaultHostOptions:h,editDefaultHostOptions:y,fetchData:A,tierBadge:k,toggleCreateHost:b,toggleEditHost:x,createToken:w,startEdit:_,saveEdit:T,confirmRegenerate:L,confirmDelete:E,copyToken:F}}};function QC(e,t=()=>"unknown"){const s=e==null?void 0:e.quota;return!s||typeof s!="object"?[]:["primary","secondary"].flatMap(a=>{const n=s[a],i=Number(n==null?void 0:n.used_percent),l=Number(n==null?void 0:n.window_minutes);if(!n||!Number.isFinite(i)||i<0||!Number.isFinite(l)||l<=0)return[];let o;return l===300?o="5-hour usage limit":l===10080?o="Weekly usage limit":Number.isFinite(l)&&l>0?o=l<60?`${l}-minute usage limit`:l<1440?`${Math.round(l/60)}-hour usage limit`:`${Math.round(l/1440)}-day usage limit`:o=a==="primary"?"Primary usage limit":"Secondary usage limit",[{key:a,label:o,remaining:Math.round(Math.max(0,Math.min(100,100-i))),limitReached:i>=100,statusLabel:i>=100?"Limit reached":"",resetLabel:n.resets_at==null?"unknown":t(n.resets_at)}]})}function YC(e){return!!(e!=null&&e.quota_check_failed)}const XC=Object.freeze(["enabled","model","reasoning_effort","agent_reasoning_effort"]),eT=Object.freeze(["request_timeout_seconds","stream_stall_timeout_seconds","retry","connection_pool","context_compression","context_budget_overrides","context_utilization"]),tT=Object.freeze(["enabled","base_url","model","max_tokens","num_ctx"]),sT=Object.freeze(["enabled","base_url","model","reasoning_effort"]);function yo(e,t){return Object.fromEntries(t.map(s=>[s,e[s]]))}function bl(e,t={}){const s=yo(e,sT);return t.includeApiKey&&(s.api_key=e.api_key),s}function Ko(e){return yo(e,["request_timeout_seconds","stream_stall_timeout_seconds","preset","model_profiles","context_utilization","openrouter"])}function yl(e){return yo(e,XC)}function xl(e){return yo(e,eT)}function _l(e,{includeApiKey:t=!1}={}){const s=yo(e,tT);return t&&(s.api_key=e.api_key),s}function Jo(e){return{timeout:e.timeout}}function Zo(e,t=500){let s=null;const a=(...n)=>{s&&clearTimeout(s),s=setTimeout(()=>{s=null,e(...n)},t)};return a.pending=()=>s!==null,a.cancel=()=>{s&&(clearTimeout(s),s=null)},a}const aT={template:` +
    +
    +
    +

    LLM Configuration

    +

    Provider routing, model selection, credentials, and Codex accounts.

    +

    Serving through Codex

    +

    Serving through OpenAI-compatible

    +

    Serving through Ollama

    +

    Selected model is unavailable

    +
    + +
    + +
    +
    +
    + +
    + + +
    +

    Model Selection

    +

    Enabled providers supply model choices. Unavailable saved selections are retained until you choose another model.

    +
    +
    + +
    +
    + +

    Main model is configured on a disabled provider. Enable that provider below or choose another model.

    + + +
    +
    + +

    Agent model is configured on a disabled provider. Enable that provider below or choose another model.

    + + + +
    +

    + Per-spawn reasoning: none / low / medium / high / xhigh / max. Each choice maps to the selected model's native capability; omission uses its allowlist default. +

    + +

    {{ allowlistSummary }} · Used when Agent model is Auto

    +
    + + + +
    +
    + +

    Auxiliary model is configured on a disabled provider. Enable that provider below or choose another model.

    +

    Used for compaction, reflection, consolidation, and background follow-up. Its output feeds sessions and memory, so choose deliberately.

    +
    +
    +
    + + +
    +
    +

    Codex (OpenAI)

    +
    +
    + Connected +
    + +
    +
    + +
    + {{ auxData.unavailable_reason }} +
    +
    + + Advanced Settings + Transport, retries, and model-aware context policy + +
    +
    +
    TransportRequest lifecycle limits
    + + +
    +
    +
    Retry policyTransient request failures
    + + + +
    +
    +
    Connection poolShared Codex HTTP transport
    +

    + Saved values need a restart. This process still uses {{ llmStatus.codex.effective_connection_pool?.max_connections }} connections with {{ llmStatus.codex.effective_connection_pool?.keepalive_timeout }}s keepalive. +

    +

    + Saved values match this process. Future changes take effect after restart. +

    +

    Future changes take effect after restart; current process values are unavailable.

    + + +
    +
    +
    Context compressionLong-conversation compaction
    +

    + Saved values need a restart. This process still uses compression {{ llmStatus.codex.effective_context_compression?.enabled ? 'on' : 'off' }}, {{ formatContextCeiling(llmStatus.codex.effective_context_compression?.max_context_chars) }}, and {{ llmStatus.codex.effective_context_compression?.keep_recent_iterations }} recent iterations. +

    +

    + Saved values match this process. Future changes take effect after restart. +

    +

    Future changes take effect after restart; current process values are unavailable.

    + + + +
    +
    +
    +
    + Context budgets + Capability, working-set policy, and temporary evidence +
    + +
    +

    + Overrides describe usable input capability. Utilization is the working-set policy applied to larger models; budgets at or below 272,000 tokens keep legacy behavior. Learned clamps are temporary evidence from successful overflow recovery, not operator policy. +

    +
    + Loading context budgets… +
    + + +
    + +
    +
    +
    +

    Authentication

    +

    + OAuth credentials for ChatGPT subscription. Supports automatic refresh and pool rotation. +

    + +
    +
    +
    +
    + {{ codexError }} + +
    + +
    + +
    + No Codex credentials configured. Use the device login below or run + python scripts/codex_login.py +
    +
    + {{ codexData.account_count }} account{{ codexData.account_count !== 1 ? 's' : '' }} configured, + active: #{{ codexData.current_index + 1 }} +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    #LabelEmailPlanQuotaStatusActiveActions
    {{ a.index + 1 }} + + + + + + + {{ a.email || '—' }} + + {{ a.plan_type }} + + — + +
    +
    +
    + {{ block.label }} + {{ block.remaining }}% remaining +
    +
    +
    +
    + + {{ block.statusLabel ? block.statusLabel + ' · ' : '' }}Resets {{ block.resetLabel }} + +
    +
    + Quota check failed + — + + checked {{ quotaAge(a.quota.observed_at) }} ago + +
    + Error + Expired + Rate limited + Active + + Current + + + + +
    +
    +
    + + +
    +
    +

    Add Account (Device Login)

    + +
    +
    +
    +
    +

    1. Open: {{ deviceInfo.verify_url }}

    +

    2. Enter code: {{ deviceInfo.user_code }}

    +
    +
    +
    Waiting...
    + +
    +
    +
    +

    Authenticated as {{ deviceResult.email }}.

    + +
    +
    +

    {{ deviceError }}

    + +
    +
    +
    +
    +
    + +
    +

    Additional providers

    Connect compatible endpoints or Ollama. Their models appear above automatically.

    + +
    +
    +

    OpenAI-compatible endpoint

    +
    +
    Status unavailable
    +
    + Connected + Unreachable +
    + +
    +
    +
    +
    + +

    {{ compatibleCatalogueStatus }}

    +
    +
    + API Key +
    + Configured + +
    +
    +
    +
    +
    +
    + Advanced SettingsStreaming timeouts +
    +
    + + +
    +
    + +
    +
    +
    OpenRouter provider preferencesOrder uses lowercase endpoint tags, never display names. A per-model pin is preferred; disable fallbacks below to make it a hard pin.
    + + + + + + +

    require_parameters is always sent when tools or reasoning are present. Measured cached-token ratios appear in the selected model list after real calls.

    +
    + +
    +
    +
    + {{ compatibleStatus.health.error }} +
    +
    + + +
    +
    +

    Ollama (Local/Remote)

    +
    +
    Status unavailable
    +
    + Connected + Unreachable +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    + +

    Prompt tokens sent as Ollama num_ctx. Higher values require more host memory.

    +
    +
    + +
    +
    + +
    +
    +
    + Advanced SettingsProvider request timeout +
    +
    + +
    + +
    +
    +
    + {{ ollamaStatus.health.error }} +
    +
    +
    +
    + +
    + `,setup(){const e=f(!0),t=f(null),s=15e3;let a=null,n=!1,i=null,l=null,o=!1;const r={codexBasic:null,codexAdvanced:null,ollamaBasic:null,ollamaAdvanced:null,compatibleBasic:null,compatibleAdvanced:null,auxiliary:null,mainModel:null,agents:null},c=v=>JSON.stringify(v),d=(v,R)=>{r[v]=c(R)},u=(v,R)=>r[v]!==null&&r[v]!==c(R),p=f(null),m=f(!1),h=f({main:"",main_capability:"medium",agent_capability:"adaptive"}),y=f(""),k=["none","low","medium","high","xhigh","max"],A=["none","low","medium","high","xhigh","max"],g=f({enabled:!1,model:"gpt-6.1-sol",reasoning_effort:"xhigh",agent_reasoning_effort:"auto",request_timeout_seconds:3600,stream_stall_timeout_seconds:180,retry:{max_retries:3,base_delay:1,max_delay:30},connection_pool:{max_connections:10,keepalive_timeout:30},context_compression:{enabled:!0,max_context_chars:null,keep_recent_iterations:30},context_budget_overrides:{},context_utilization:60}),b=["gpt-6-astra","gpt-6.1-sol","gpt-6-sol","gpt-6-luna","gpt-5.6-sol","gpt-5.6-terra","gpt-5.6-luna"],x=v=>!v||v==="auto"?null:v.startsWith("compat:")?"compat":v.startsWith("ollama:")?"ollama":v.includes(":")?null:"codex",w=v=>{var R,q;return((q=(R=p.value)==null?void 0:R[v==="compat"?"openai_compatible":v])==null?void 0:q.enabled)===!0},_=B(()=>w("codex")&&!w("compat")&&!w("ollama")),T=v=>{const R=v==="main"?h.value.main:v==="agent"?Fe.value.model:X.value.enabled?X.value.model:"";return L(R)},L=v=>{var q,ye;const R=x(v);return!!(R&&((ye=(q=p.value)==null?void 0:q[R==="compat"?"openai_compatible":R])==null?void 0:ye.enabled)===!1)},E=v=>{var q;const R=v==="main"?h.value.main:v==="agent"?Fe.value.model:X.value.model;return`Configured: ${((q=I.value.find(ye=>ye.ref===R))==null?void 0:q.name)||R.replace(/^(compat|ollama):/,"")} (provider disabled)`},F=v=>{var Ne,rt;const R=[],q=x(((Ne=p.value)==null?void 0:Ne.main_model)||h.value.main);q===v&&R.push("Main"),(Fe.value.model===""?q:x(Fe.value.model))===v&&R.push("Agent");const ye=((rt=p.value)==null?void 0:rt.auxiliary)||X.value;return ye.enabled&&x(ye.model)===v&&R.push("Auxiliary"),Fe.value.model==="auto"&&ot.value.filter(Lt=>x(Lt)!==v).map(Lt=>I.value.find(wa=>wa.ref===Lt)).filter(Lt=>Lt&&N(Lt)&&!(Lt.provider==="compat"&&ut.value&&!Lt.ref.slice(7).includes("/"))).length===0&&R.push("Agent Auto (zero effective choices)"),R};async function O(v,R){if(R!==!1||!w(v))return!0;const q=F(v);return is({title:`Disable ${v==="compat"?"OpenAI-compatible":v==="codex"?"Codex":"Ollama"}?`,message:`${q.length?`This provider is used by ${q.join(", ")}. `:""}Saved selections and ranked allowlist entries remain configured, not replaced. Continue?`,confirmLabel:"Disable provider",danger:!0})}const I=B(()=>{var wa,xn,at,Kt;const v=p.value||{},R=v.model_catalogue||v.model_catalog||{},q=Pe=>{var Ft,pt,ka;return((Ft=W.value.model_profiles)==null?void 0:Ft[Pe])||((ka=(pt=W.value.openrouter)==null?void 0:pt.catalogue_profiles)==null?void 0:ka[Pe])||null},ye=(Pe,Ft,pt)=>Ft.map(ka=>{var fp,mp;const Ac=typeof ka=="string"?ka:ka.name,Sa=Pe==="compat"?q(Ac):null;return{ref:Pe==="codex"?ka:`${Pe}:${Ac}`,name:Ac,provider:Pe,available:!!(pt!=null&&pt.enabled&&(Pe==="codex"?pt.configured:(fp=pt.health)!=null&&fp.healthy)),unavailable_reason:pt!=null&&pt.enabled?pt!=null&&pt.configured?!((mp=pt==null?void 0:pt.health)!=null&&mp.healthy)&&Pe!=="codex"?"unreachable":"":"not configured":"disabled",capability:Pe==="codex"||Sa!=null&&Sa.supports_reasoning?"reasoning":Sa!=null&&Sa.supports_thinking_mode?"thinking":"none",efforts:Sa==null?void 0:Sa.supported_efforts,profile:Sa}}),Ne=[...R.codex||ye("codex",b,v.codex),...R.compat||R.openai_compatible||[],...R.ollama||ye("ollama",zt.value,v.ollama)].map(Pe=>typeof Pe=="string"?{ref:Pe,name:Pe,provider:"codex",available:w("codex"),capability:"reasoning"}:{...Pe,available:w(Pe.provider)&&Pe.available,unavailable_reason:w(Pe.provider)?Pe.unavailable_reason:"disabled"}),rt=Pe=>{const Ft=Ne.findIndex(pt=>pt.ref===Pe.ref);if(Ft===-1)Ne.push(Pe);else{const pt=Ne[Ft];Ne[Ft]={...pt,...Pe,available:pt.available,unavailable_reason:pt.unavailable_reason,agent_available:pt.agent_available}}};for(const Pe of ye("compat",Es.value,v.openai_compatible))rt(Pe);if(As.value&&xa.value.length)for(const Pe of xa.value){const Ft=`compat:${Pe.id}`;rt({ref:Ft,name:Pe.name||Pe.id,provider:"compat",available:((wa=Ne.find(pt=>pt.ref===Ft))==null?void 0:wa.available)??!1,unavailable_reason:(xn=Ne.find(pt=>pt.ref===Ft))!=null&&xn.available?"":((at=Ne.find(pt=>pt.ref===Ft))==null?void 0:at.unavailable_reason)||"unavailable",capability:Pe.supports_reasoning?"reasoning":"none",efforts:k.filter(pt=>{var ka;return(ka=Pe.supported_efforts)==null?void 0:ka.includes(pt)}),agent_available:!!(Pe.agent_eligible&&Pe.profile&&((Kt=Ne.find(pt=>pt.ref===Ft))==null?void 0:Kt.agent_available)===!0),agent_unavailable_reason:Pe.agent_unavailable_reason||""})}const Lt=new Set(Ne.map(Pe=>Pe.ref));for(const Pe of[h.value.main,Fe.value.model,...X.value.enabled?[X.value.model]:[],...ot.value])if(Pe&&Pe!=="auto"&&!Lt.has(Pe)&&x(Pe)){const Ft=x(Pe);Ne.unshift({ref:Pe,name:Pe.replace(/^(compat|ollama):/,""),provider:Ft,available:!1,unavailable_reason:w(Ft)?"unavailable":"disabled",capability:Ft==="codex"?"reasoning":"none"}),Lt.add(Pe)}return Ne.filter(Pe=>!ut.value||Pe.provider!=="compat"||Pe.ref.slice(7).includes("/")||[h.value.main,Fe.value.model,X.value.enabled&&X.value.model,...ot.value].includes(Pe.ref)).map(Pe=>Pe.efforts?{...Pe,efforts:k.filter(Ft=>Pe.efforts.includes(Ft)&&!G(Pe.ref,Ft))}:Pe)}),H=()=>[h.value.main,Fe.value.model,X.value.enabled&&X.value.model],te=B(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].filter(([v])=>w(v)).map(([v,R])=>({id:v,label:R,models:I.value.filter(q=>{if(q.provider!==v||L(q.ref)&&H().includes(q.ref))return!1;const ye=_.value?"":y.value.trim().toLowerCase();return!ye||`${q.name} ${q.ref}`.toLowerCase().includes(ye)})})).filter(v=>v.models.length)),ee=B(()=>{var v;return((v=te.value.find(R=>R.id==="codex"))==null?void 0:v.models)||[]}),N=v=>w(v.provider)&&v.available&&v.agent_available!==!1,M=v=>v.available?`${v.name}${v.agent_available===!1?` (${v.agent_unavailable_reason||"not agent-eligible"})`:""}`:Y(v),$=B(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].filter(([v])=>w(v)).map(([v,R])=>({id:v,label:R,models:I.value.filter(q=>q.provider===v&&!(v==="compat"&&ut.value)&&`${q.ref} ${q.name}`.toLowerCase().includes(Pn.value.trim().toLowerCase()))})).filter(v=>v.models.length)),re=B(()=>I.value.find(v=>v.ref===h.value.main)),ce=B(()=>I.value.find(v=>v.ref===Fe.value.model)),Z=v=>I.value.find(R=>R.ref===v),Q=v=>{const R=Z(v);return(R==null?void 0:R.provider)==="codex"?k.filter(q=>!G(v,q)):(R==null?void 0:R.efforts)||[]},ae=v=>Ge.value.find(R=>qe(R)===v),V=(v,R)=>{const q=ae(v);return typeof q=="string"?"":(q==null?void 0:q[R])||""},be=B(()=>ot.value.map(v=>Z(v)).filter(v=>v&&N(v)&&!(v.provider==="compat"&&ut.value&&!v.ref.slice(7).includes("/")))),Oe=B(()=>[...new Set(be.value.map(v=>v.capability!=="reasoning"?v.capability||"none":v.provider==="codex"?"codex_reasoning":"compatible_reasoning"))]),le=B(()=>Oe.value.length>1),oe=B(()=>{var R;if(Fe.value.model!=="auto")return((R=ce.value)==null?void 0:R.capability)||"none";if(le.value)return"mixed";const v=Oe.value[0]||"none";return v.endsWith("_reasoning")?"reasoning":v}),ve=B(()=>{var v;return Fe.value.model==="auto"?k:((v=ce.value)==null?void 0:v.efforts)||k}),j=B(()=>oe.value==="thinking"?Fe.value.thinking_mode??g.value.agent_reasoning_effort??"":g.value.agent_reasoning_effort??""),Y=v=>`${v.name}${v.available?"":` (${v.unavailable_reason||"unavailable"})`}`,xe=B(()=>{const v=g.value.model;return v&&!b.includes(v)?[v,...b]:b}),S=B(()=>{const v=Fe.value.model;return v&&v!=="auto"&&!b.includes(v)?[v,...b]:b}),P={"gpt-5.4":["max"],"gpt-5.4-mini":["max"],"gpt-6-astra":["none"],"gpt-6.1-sol":["none"]},G=(v,R)=>!!v&&!!R&&(P[v]||[]).includes(R),he=B(()=>{const v=Fe.value.model;return v&&!v.includes(":")?v:null}),fe=v=>!G(g.value.model,v)&&!(g.value.agent_reasoning_effort===""&&G(he.value,v)),me=v=>{const R=Fe.value.model;return R==="auto"?!0:!G(R||g.value.model,v)},we=B(()=>{const v=g.value.agent_reasoning_effort;return v==="auto"?null:v||g.value.reasoning_effort}),J=v=>G(v,g.value.reasoning_effort)||Fe.value.model===""&&G(v,we.value),ie=v=>G(v,we.value),X=f({enabled:!1,model:"gpt-6-luna"}),_e=f({unavailable_reason:null}),U=B(()=>{const v=X.value.model;return v&&!b.includes(v)?[v,...b]:b});function de(v){const R=v.target.value;X.value.enabled=R!=="",R!==""&&(X.value.model=R),R.startsWith("compat:")&&ut.value?nl(il(R),Co(R)).then(()=>Un()).catch(q=>He(q.message||"Failed to prepare OpenRouter model","error")):Un()}const ge=f(!1),Ae=f({codex:!1,ollama:!1,compatible:!1}),$e=f(null),We=f(!1),je=f(""),Xe=f(null),it=f(!1);let Qe=0;const se=B(()=>{var v;return Object.entries(((v=$e.value)==null?void 0:v.models)||{}).map(([R,q])=>{var ye,Ne,rt;return{model:R,floor:q.floor,override:q.override,effectiveBudget:(ye=q.effective)==null?void 0:ye.effective_budget,configuredPrimaryChars:(Ne=q.configured)==null?void 0:Ne.primary_chars,primaryChars:(rt=q.effective)==null?void 0:rt.primary_chars,provenance:q.provenance,clampExpiresAt:q.clamp_expires_at,densityPriorMilli:q.density_prior_milli,densityScope:q.density_scope,workloadCalibration:q.workload_calibration}})}),Re=B(()=>{var v;return((v=$e.value)==null?void 0:v.clamps)||[]}),Le=B(()=>{var v,R;return((R=(v=$e.value)==null?void 0:v.models)==null?void 0:R[g.value.model])||null}),Te=f({enabled:!1,base_url:"",model:"",api_key:"",max_tokens:4096,num_ctx:32768,timeout:300}),W=f({enabled:!1,base_url:"https://api.deepseek.com/v1",api_key:"",model:"deepseek-v4-flash",request_timeout_seconds:3600,stream_stall_timeout_seconds:180,preset:"deepseek",reasoning_effort:"medium",model_profiles:{},context_utilization:75,openrouter:{order:[],allow_fallbacks:!0,quantizations:[],sort:null,data_collection:null,reasoning_effort:"medium",model_pins:{},catalogue_profiles:{}}}),De=f(!1),ze=f(!1),Ye=f(!1),Dt=f(!1),et=f(!1),Rt=f(!1);function Pt(){var R,q,ye;return!!(((R=t.value)==null?void 0:R.contains(document.activeElement))&&((ye=(q=document.activeElement)==null?void 0:q.matches)==null?void 0:ye.call(q,'input, textarea, select, [contenteditable="true"]'))||ue.value||To.value!==null||it.value||De.value||ze.value||Ce.value||Ye.value||Dt.value||et.value||ge.value||gi.pending()||bi.pending()||zn.pending()||Un.pending()||u("codexBasic",yl(g.value))||u("codexAdvanced",xl(g.value))||u("ollamaBasic",_l(Te.value))||u("ollamaAdvanced",Jo(Te.value))||u("compatibleBasic",bl(W.value))||u("compatibleAdvanced",Ko(W.value))||u("auxiliary",X.value)||u("mainModel",h.value.main)||u("agents",Fe.value))}function Ts(){r.codexBasic=c(yl(g.value)),r.codexAdvanced=c(xl(g.value)),r.ollamaBasic=c(_l(Te.value)),r.ollamaAdvanced=c(Jo(Te.value)),r.compatibleBasic=c(bl(W.value)),r.compatibleAdvanced=c(Ko(W.value)),r.auxiliary=c(X.value),r.mainModel=c(h.value.main)}const Vs=f({configured:null}),Gs=f(!1),zt=f([]),ta=f(""),Ws=f(!1),Pa=f(!1),hs=f({configured:null}),$a=f(!1),Es=f([]),Ct=B(()=>ut.value?xa.value.map(v=>v.id):Es.value),ls=f(""),sa=f(!1),ba=f(!1),Fe=f({model:"auto",thinking_mode:null,auto_model_allowlist:[]}),ue=f(!1),Ce=f(!1),qe=v=>typeof v=="string"?v:v==null?void 0:v.model,Ge=B(()=>(Fe.value.auto_model_allowlist||[]).map(v=>typeof v=="string"?v:{...v}).filter(v=>qe(v))),It=B(()=>{var v,R,q,ye;if(!w("codex")&&w("compat")){const Ne=(R=(v=p.value)==null?void 0:v.openai_compatible)==null?void 0:R.model;return Ne?[`compat:${Ne}`]:[]}if(!w("codex")&&!w("compat")&&w("ollama")){const Ne=(ye=(q=p.value)==null?void 0:q.ollama)==null?void 0:ye.model;return Ne?[`ollama:${Ne}`]:[]}return w("codex")?b:[]}),ot=B(()=>Ge.value.length?Ge.value.map(qe):It.value),pn=B(()=>be.value.length),pi=B(()=>{var v;return`${(v=Fe.value.auto_model_allowlist)!=null&&v.length?"Allowlist":"Default"}: ${ot.value.length} configured, ${pn.value} effective models`}),Fa=new Map;function fi(){ue.value=!1,So()}const Ba=v=>{const R=x(v);if(R&&!w(R))return"provider disabled";if(ut.value&&v.startsWith("compat:")&&!v.slice(7).includes("/"))return"OpenRouter requires a namespaced vendor/model ID; this is a direct-endpoint profile.";const q=I.value.find(ye=>ye.ref===v);return q?N(q)&&!(q.provider==="compat"&&ut.value&&!v.slice(7).includes("/"))?"":q.agent_unavailable_reason||q.unavailable_reason||"Not agent-eligible":"Model is absent from the current endpoint catalogue."},fn=v=>{if(gn.value.has(v))return Fn(v);const R=I.value.find(ye=>ye.ref===v),q=(R==null?void 0:R.hint_metadata)||{};return[q.hint||q.hint_derived,q.as_of&&`as of ${q.as_of}`,q.scope_note,q.evidence&&`Evidence: ${q.evidence}`,R&&ap(R)].filter(Boolean).join(" · ")||"No catalogue hint. Add an operator hint below."},kt=f(null),$s=f(!1),Wt=f("");let ya=0;const xa=B(()=>{var v;return(((v=kt.value)==null?void 0:v.models)||[]).map(R=>{var ye,Ne,rt;const q=((ye=W.value.model_profiles)==null?void 0:ye[R.id])||R.profile||((rt=(Ne=W.value.openrouter)==null?void 0:Ne.catalogue_profiles)==null?void 0:rt[R.id])||(R.context_length>0&&R.max_completion_tokens>0?{total_window_tokens:R.context_length,max_output_tokens:R.max_completion_tokens}:null);return{...R,profile:q}})}),Pn=f(""),mn=f(""),$n=f(!0),Ua=f(!0),vn=f(!0),Ie=f(!1),D=f(null),pe=f(""),ke=f(null),Be=f(""),Ue=f([]),Ke=f(!1),$t=f("throughput"),gt=(v,R,q=null)=>{const ye=v[R],Ne=q&&ye&&typeof ye=="object"?ye[q]:ye,rt=Number(Ne);return Number.isFinite(rt)?rt:null},Ot=B(()=>{const v=[...Ue.value],R=$t.value;return v.sort((q,ye)=>{var Pe,Ft;if(R==="quantization")return String(q.quantization).localeCompare(String(ye.quantization));const[Ne,rt,Lt]=R==="throughput"?["throughput_last_30m","p50",!0]:R==="latency_p99"?["latency_last_30m","p99",!1]:R==="cache_price"?["cache_read_per_token",null,!1]:["prompt_per_token",null,!1],wa=(Pe=q.pricing)==null?void 0:Pe[Ne],xn=(Ft=ye.pricing)==null?void 0:Ft[Ne],at=rt?gt(q,Ne,rt):wa==null?null:Number(wa),Kt=rt?gt(ye,Ne,rt):xn==null?null:Number(xn);return Number.isFinite(at)?Number.isFinite(Kt)?Lt?Kt-at:at-Kt:-1:1})}),ds=B(()=>{try{return new URL(W.value.base_url).hostname}catch{return""}}),ut=B(()=>/(^|\.)openrouter\.ai$/i.test(ds.value)),ua=B(()=>{var v,R;return((R=(v=p.value)==null?void 0:v.openai_compatible)==null?void 0:R.base_url)||""}),hn=B(()=>{try{return new URL(ua.value).href}catch{return""}}),_o=B(()=>{try{return new URL(W.value.base_url).href}catch{return""}}),As=B(()=>w("compat")&&ut.value&&!!hn.value&&hn.value===_o.value);Gt(()=>[As.value,_o.value,hn.value],()=>{++ya,kt.value=null,Wt.value="",$s.value=!1,So()},{flush:"sync"});const vc=B(()=>{var v,R;return ut.value?$s.value?"OpenRouter recognized · fetching catalogue…":Wt.value?`OpenRouter recognized · catalogue failed: ${Wt.value}`:`OpenRouter recognized · ${((R=(v=kt.value)==null?void 0:v.models)==null?void 0:R.length)||0} catalogue models loaded`:Es.value.length?`${Es.value.length} endpoint models loaded`:"Catalogue not loaded"}),hc=B(()=>Wt.value?"text-red-400":"text-gray-500"),wo=B(()=>{var v;return[...new Set((((v=kt.value)==null?void 0:v.models)||[]).map(R=>R.vendor))].sort()}),gc=B(()=>{var v;return[...new Set((((v=kt.value)==null?void 0:v.models)||[]).flatMap(R=>(R.endpoints||[]).map(q=>q.quantization)).filter(Boolean))].sort()}),bc=v=>{var R;return(((R=kt.value)==null?void 0:R.measured_cache)||[]).some(q=>q.model===v.id&&q.samples>0&&q.cached_percent>0)},ko=B(()=>{const v=Pn.value.trim().toLowerCase();return xa.value.filter(R=>{var q;return!(v&&!`${R.id} ${R.name} ${R.vendor}`.toLowerCase().includes(v)||mn.value&&R.vendor!==mn.value||$n.value&&!R.supports_tools||Ua.value&&!R.agent_eligible||vn.value&&R.variant!=="standard"||Ie.value&&!bc(R)||D.value!=null&&Number((q=R.pricing)==null?void 0:q.prompt_per_token)*1e6>Number(D.value)||pe.value&&!(R.endpoints||[]).some(ye=>ye.quantization===pe.value))})}),yc=B(()=>ko.value.slice(0,100)),xc=B(()=>ko.value.length),gn=B(()=>new Map((As.value?xa.value:[]).map(v=>[`compat:${v.id}`,v]))),bn=v=>v==null?"n/a":`$${(Number(v)*1e6).toFixed(3)}/M`,mi=v=>{var R,q,ye,Ne;return[v.vendor,v.context_length?`${Number(v.context_length).toLocaleString()} ctx`:"context unknown",`${bn((R=v.pricing)==null?void 0:R.prompt_per_token)} in`,`${bn((q=v.pricing)==null?void 0:q.completion_per_token)} out`,`${bn((ye=v.pricing)==null?void 0:ye.cache_read_per_token)} cache read`,`${bn((Ne=v.pricing)==null?void 0:Ne.cache_write_per_token)} cache write`,v.supports_tools?"tools":"no tools",v.supports_reasoning?"reasoning":"no reasoning",v.variant!=="standard"?v.variant:null].filter(Boolean).join(" · ")},Fn=v=>{var Ne;const R=gn.value.get(v);if(!R)return"Catalogue facts unavailable";const q=(((Ne=kt.value)==null?void 0:Ne.measured_cache)||[]).filter(rt=>rt.model===R.id),ye=q.length?q.map(rt=>`${rt.upstream_provider}: ${rt.cached_percent}% cached`).join(" · "):"No measured cache evidence yet";return`${mi(R)} · ${ye}${R.profile_conflict?" · operator profile conflicts with catalogue":""}`},vi=B(()=>Es.value.map(v=>typeof v=="string"?v:v.name).filter(Boolean)),al=async()=>{var R,q,ye;const v=(ye=(q=(R=p.value)==null?void 0:R.openai_compatible)==null?void 0:q.preset_catalogue)==null?void 0:ye[W.value.preset];v&&(W.value.base_url=v.base_url),zn.cancel(),await dl()},C=(v,R)=>{W.value.openrouter[v]=R.split(",").map(q=>q.trim()).filter(Boolean)},K=B(()=>zt.value||[]),ne=B(()=>{const v=[...b,...vi.value.map(R=>`compat:${R}`),...K.value.map(R=>`ollama:${R.name}`)];for(const R of[Fe.value.model,...ot.value])R&&R!=="auto"&&!v.includes(R)&&v.unshift(R);return v}),Ee=v=>v==="codex-auto-review"?"codex-auto-review (Codex alias → gpt-5.6-luna)":v;async function Je({poll:v=!1}={}){try{const R=await z.get("/api/agents/model");if(v&&Pt()||ue.value||Ce.value||u("agents",Fe.value))return;Fe.value={...Fe.value,...R},r.agents=c(Fe.value)}catch{}}async function St(){const v=ds.value,R=ua.value,q=++ya;if(!As.value){kt.value=null,Wt.value="",$s.value=!1;return}$s.value=!0;try{const ye=await z.get("/api/openrouter/catalogue");if(q!==ya||!As.value||v!==ds.value||R!==ua.value)return;kt.value=ye,Wt.value=""}catch(ye){if(q!==ya||!As.value)return;Wt.value=ye.message||"Failed to load OpenRouter catalogue"}finally{q===ya&&($s.value=!1)}}async function Ks(){var v;try{(v=Fe.value.model)!=null&&v.startsWith("compat:")&&ut.value&&await nl(il(Fe.value.model),Co(Fe.value.model));const R=await z.put("/api/agents/model",{model:Fe.value.model||null});Fe.value={...Fe.value,...R},r.agents=c(Fe.value),He("Agent model policy saved")}catch(R){He(R.message||"Failed to save agent model policy","error")}}const gs=()=>[...Ge.value.length?Ge.value:ot.value];async function yn(v,R){const q=gs(),ye=q.findIndex(Lt=>qe(Lt)===v);let Ne=null;if(R.target.checked&&ye<0&&q.push(Fa.get(v)||v),!R.target.checked&&ye>=0&&([Ne]=q.splice(ye,1)),!q.length){R.target.checked=!0,He("Keep one model selected. An empty list restores the provider default.","error");return}const rt=await Rs(q,"Agent Auto allowlist saved");rt&&R.target.checked&&Fa.delete(v),rt&&!R.target.checked&&typeof Ne=="object"&&Fa.set(v,Ne),rt||(R.target.checked=ot.value.includes(v))}async function Rs(v,R){if(Ce.value)return!1;Ce.value=!0;try{const q=await z.put("/api/agents/model",{auto_model_allowlist:v});return Fe.value={...Fe.value,...q},r.agents=c(Fe.value),He(R),!0}catch(q){return He(q.message||"Failed to save agent allowlist","error"),!1}finally{Ce.value=!1}}const db=()=>Rs([],"Provider default restored");async function ub(v,R,q){const ye=ot.value.map(Ne=>{const rt=ae(Ne);if(Ne!==v)return rt||Ne;const Lt=typeof rt=="string"?{model:Ne}:{...rt||{model:Ne}};return delete Lt.reasoning_effort,delete Lt.thinking_mode,q&&(Lt[R]=q),Object.keys(Lt).length===1?Lt.model:Lt});await Rs(ye,"Model default saved")}async function nl(v,R=""){const[q,...ye]=v.split("/");if(!q||!ye.length)throw new Error("OpenRouter model id is not namespaced");return z.post(`/api/openrouter/models/${encodeURIComponent(q)}/${encodeURIComponent(ye.join("/"))}/select`,{provider_tag:R})}const pb=(v,R)=>{var ye;const q=(((ye=kt.value)==null?void 0:ye.measured_cache)||[]).find(Ne=>Ne.model===v&&Ne.upstream_provider===R);return q?`${q.cached_percent}% cached over ${q.samples} calls`:"no measured cache evidence"},fb=v=>v==null?"n/a":`$${(Number(v)*1e6).toFixed(4)}/M`,mb=(v,R)=>{if(v==null)return"n/a";if(typeof v=="number")return Number(v).toLocaleString();const q=v[R];return q==null?"n/a":Number(q).toLocaleString()},vb=v=>{var Ne;const R=[];v.quantization==="fp4"&&R.push("fp4 quantization may change quality");const q=typeof v.latency_last_30m=="object"?Number((Ne=v.latency_last_30m)==null?void 0:Ne.p99):null,ye=Number(Fe.value.iteration_timeout_seconds||0)*1e3;return q&&ye&&q>ye&&R.push("p99 exceeds the agent iteration budget"),R.join("; ")};async function hb(v){var R;ke.value=v,Be.value=((R=W.value.openrouter.model_pins)==null?void 0:R[v.id])||"",Ke.value=!0;try{const[q,...ye]=v.id.split("/"),Ne=await z.get(`/api/openrouter/models/${encodeURIComponent(q)}/${encodeURIComponent(ye.join("/"))}/endpoints`);Ue.value=Ne.endpoints||[]}catch(q){Ue.value=[],He(q.message||"Failed to load OpenRouter provider routes","error")}finally{Ke.value=!1}}function So(){ke.value=null,Be.value="",Ue.value=[]}async function gb(v,R=""){try{await nl(v.id,R);const q=gs(),ye=`compat:${v.id}`;if(q.some(rt=>qe(rt)===ye)||q.push(ye),!await Rs(q,R?"OpenRouter model added and provider pinned.":"OpenRouter model added unpinned."))return;So(),await _a()}catch(q){He(q.message||"Failed to add OpenRouter model","error")}}const il=v=>v.startsWith("compat:")?v.slice(7):v,Co=v=>{var R;return((R=W.value.openrouter.model_pins)==null?void 0:R[il(v)])||""},bb=v=>ot.value.length>1&&Rs(gs().filter(R=>qe(R)!==v),"Model removed from allowlist");async function yb(){var v,R;try{const q=gs();for(const ye of((v=kt.value)==null?void 0:v.quick_add)||[])(R=gn.value.get(ye))!=null&&R.agent_eligible&&(await nl(il(ye),""),q.some(Ne=>qe(Ne)===ye)||q.push(ye));await Rs(q,"Curated OpenRouter models added"),await _a()}catch(q){He(q.message||"Failed to add curated OpenRouter models","error")}}function ap(v){const R=v.hint_metadata||{},q=[];return R.context_tokens&&q.push(`context ${Number(R.context_tokens).toLocaleString()}`),R.max_output_tokens&&q.push(`max output ${Number(R.max_output_tokens).toLocaleString()}`),R.structural_source&&q.push(`source ${R.structural_source}`),q.join("; ")}async function xb(v,R){const q={...Fe.value.model_selection_hints||{}},ye=R.trim();ye?q[v]=ye:delete q[v];try{const Ne=await z.put("/api/agents/model",{model_selection_hints:q});Fe.value={...Fe.value,...Ne},r.agents=c(Fe.value),He("Model hint saved")}catch(Ne){He(Ne.message||"Failed to save model hint","error")}}function np(v,R){const q=ot.value.indexOf(v);return!Ce.value&&q>=0&&q+R>=0&&q+Rqe(Ne)===v);ye<0||!np(v,R)||([q[ye],q[ye+R]]=[q[ye+R],q[ye]],await Rs(q,"Agent Auto allowlist order saved"))}const _c=f(!0),wc=f(""),ip=f({configured:null,accounts:[]}),kc=f(null),To=f(null),Sc=f(""),ll=f(null),Cc=f(!1),Tc=f(null),lp=f(null),op=f("");let hi=null;function He(v,R="success"){Se(v,R==="error"?"error":"success")}function wb(v){if(!v)return"?";const R=v/(1024*1024*1024);return R>=1?R.toFixed(1)+" GB":(v/(1024*1024)).toFixed(0)+" MB"}function kb(v){return Number.isFinite(Number(v))?Number(v).toLocaleString():"—"}function Sb(v){return v==null?"automatic (model-derived)":Number(v).toLocaleString()+" characters"}function Cb(v){const R=new Date(v);return Number.isNaN(R.getTime())?"unknown":R.toLocaleString([],{dateStyle:"medium",timeStyle:"short"})}function Tb(v){const R=new Date(Number(v)*1e3);return Number.isNaN(R.getTime())?"unknown":R.toLocaleString([],{dateStyle:"medium",timeStyle:"short"})}function Eb(v){const R=Number(v);if(!Number.isFinite(R)||R<=0)return"unknown";const q=Math.max(0,Math.floor((Date.now()/1e3-R)/60));return q<1?"<1 min":`${q} min`}function Ab(v){return typeof v=="string"&&v.length>12?v.slice(0,8)+"…"+v.slice(-4):v}function Rb(v){return typeof v!="number"||!Number.isFinite(v)?"—":(v/1e3).toFixed(2)}function Ib(v){return v==="temporary learned clamp"?"is-clamp":v==="override"?"is-override":"is-built-in"}function Ob(v){const R=g.value.context_budget_overrides[v.model];return v.floor!=null&&Number.isFinite(Number(R))&&Number(R)>v.floor}function Lb(v,R){const q={...g.value.context_budget_overrides};R.target.value===""?delete q[v]:q[v]=Number(R.target.value),g.value.context_budget_overrides=q,it.value=!0}function Nb(v){g.value.context_utilization=v.target.value===""?"":Number(v.target.value),it.value=!0}function Mb(v){const R={...g.value.context_budget_overrides};delete R[v],g.value.context_budget_overrides=R,it.value=!0}async function _a({quiet:v=!1,poll:R=!1}={}){if(!(R&&Pt())){if(l&&await l,i)return i;v||(e.value=!0),i=(async()=>{await Promise.all([bs({poll:R}),ol(),rl(),Je({poll:R}),pa(),Bn({poll:R})]),await St(),Pt()||Ts()})();try{await i}finally{i=null,v||(e.value=!1)}}}async function Db(){if(!(Pt()||i)){if(l)return l;l=Promise.all([bs({poll:!0}),ol({refreshModels:!1}),rl({refreshModels:!1}),Je({poll:!0}),pa(),Bn({poll:!0})]).then(()=>{Pt()||Ts()});try{await l}finally{l=null}}}async function bs({preserveBasic:v=!1,preserveAdvanced:R=!1,poll:q=!1}={}){var ye,Ne,rt,Lt,wa,xn;try{const at=await z.get("/api/llm/status");p.value=at,m.value=!1,!o&&!u("mainModel",h.value.main)&&(h.value.main=at.main_model||at.active_model||(at.active_provider==="compat"?`compat:${((ye=at.openai_compatible)==null?void 0:ye.model)||""}`:at.active_provider==="ollama"?`ollama:${((Ne=at.ollama)==null?void 0:Ne.model)||""}`:((rt=at.codex)==null?void 0:rt.model)||"gpt-6.1-sol")),at.codex&&!gi.pending()&&(!v&&!Ye.value&&!u("codexBasic",yl(g.value))&&(g.value.enabled=at.codex.enabled,g.value.model=at.codex.model||"gpt-6.1-sol",g.value.reasoning_effort=at.codex.reasoning_effort||"medium",g.value.agent_reasoning_effort=at.codex.agent_reasoning_effort||""),!R&&!Ye.value&&!u("codexAdvanced",xl(g.value))&&(g.value.request_timeout_seconds=at.codex.request_timeout_seconds??g.value.request_timeout_seconds,g.value.stream_stall_timeout_seconds=at.codex.stream_stall_timeout_seconds??g.value.stream_stall_timeout_seconds,g.value.retry={...g.value.retry,...at.codex.retry||{}},g.value.connection_pool={...g.value.connection_pool,...at.codex.connection_pool||{}},g.value.context_compression={...g.value.context_compression,...at.codex.context_compression||{}},!it.value&&!Ye.value&&(g.value.context_budget_overrides={...at.codex.context_budget_overrides||{}},g.value.context_utilization=at.codex.context_utilization??g.value.context_utilization))),at.ollama&&!bi.pending()&&(!v&&!Dt.value&&!u("ollamaBasic",_l(Te.value))&&(Te.value.enabled=at.ollama.enabled,Te.value.base_url=at.ollama.base_url||"",Te.value.model=at.ollama.model||"",Te.value.max_tokens=at.ollama.max_tokens||4096,Te.value.num_ctx=at.ollama.num_ctx||32768),!R&&!Dt.value&&!u("ollamaAdvanced",Jo(Te.value))&&(Te.value.timeout=at.ollama.timeout??Te.value.timeout));const Kt=at.openai_compatible;Kt&&!zn.pending()&&(!v&&!et.value&&!u("compatibleBasic",bl(W.value))&&(W.value.enabled=Kt.enabled,W.value.base_url=Kt.base_url||W.value.base_url,W.value.model=Kt.model||W.value.model,W.value.preset=Kt.preset||W.value.preset,W.value.reasoning_effort=Kt.reasoning_effort||"medium"),!R&&!et.value&&!u("compatibleAdvanced",Ko(W.value))&&(W.value.request_timeout_seconds=Kt.request_timeout_seconds??W.value.request_timeout_seconds,W.value.stream_stall_timeout_seconds=Kt.stream_stall_timeout_seconds??W.value.stream_stall_timeout_seconds,W.value.model_profiles=Kt.model_profiles||W.value.model_profiles,W.value.context_utilization=Kt.context_utilization??W.value.context_utilization,W.value.openrouter={...W.value.openrouter,...Kt.openrouter||{}})),!o&&!u("mainModel",h.value.main)&&((Lt=h.value.main)!=null&&Lt.startsWith("compat:")?h.value.main_capability=(Kt==null?void 0:Kt.reasoning_effort)||"medium":(wa=h.value.main)!=null&&wa.startsWith("ollama:")||(h.value.main_capability=((xn=at.codex)==null?void 0:xn.reasoning_effort)||"medium")),at.auxiliary&&(_e.value=at.auxiliary,!Un.pending()&&!ge.value&&!u("auxiliary",X.value)&&(X.value.enabled=at.auxiliary.enabled,X.value.model=at.auxiliary.model||"gpt-6-luna"))}catch{p.value||(p.value={active_provider:"",codex:{configured:null},ollama:{configured:null},openai_compatible:{configured:null}}),m.value=!0}}async function Bn({poll:v=!1}={}){const R=++Qe;We.value=!0,je.value="";try{const q=await z.get("/api/context/windows");if(R!==Qe)return;$e.value=q,!(v&&Pt())&&!Ye.value&&!it.value&&(g.value.context_budget_overrides=Object.fromEntries(Object.entries(q.models||{}).filter(([,ye])=>ye.override!=null).map(([ye,Ne])=>[ye,Ne.override])),g.value.context_utilization=q.utilization??g.value.context_utilization)}catch(q){R===Qe&&(je.value=q.message||"Failed to load context budgets")}finally{R===Qe&&(We.value=!1)}}async function ol({refreshModels:v=!0}={}){try{if(Vs.value=await z.get("/api/ollama/status"),Gs.value=!1,Vs.value.model&&(ta.value=Vs.value.model),v&&Vs.value.configured)try{const R=await z.get("/api/ollama/models");zt.value=R.models||[]}catch{zt.value=[]}else if(v&&Te.value.base_url)try{const R=await z.post("/api/ollama/probe-models",{base_url:Te.value.base_url});zt.value=R.models||[]}catch{zt.value=[]}}catch{Gs.value=!0}}async function pa(){_c.value=!0,wc.value="";try{ip.value=await z.get("/api/codex/status")}catch(v){wc.value=v.message||"Failed to fetch Codex status"}finally{_c.value=!1}}async function Pb(){if(o)return;o=!0;const v=h.value.main;try{v.startsWith("compat:")&&ut.value&&await nl(il(v),Co(v));try{await z.put("/api/llm/main-model",{model:v})}catch(R){if(!/404|not found/i.test(R.message||""))throw R;await z.post("/api/llm/switch",{model:v})}d("mainModel",v),He("Main model saved"),await _a()}catch(R){He(R.message||"Failed to save main model","error"),await bs()}finally{o=!1}}async function $b(v){h.value.main_capability=v;const R=re.value;R&&(R.provider==="compat"&&R.capability!=="none"?(W.value.reasoning_effort=v,await dl()):R.capability==="reasoning"?(g.value.reasoning_effort=v,await cl()):R.capability==="thinking"&&(await z.put("/api/openai-compatible/config",{thinking_mode:v}),He("Thinking mode saved")))}async function Fb(v){h.value.agent_capability=v;const R=ce.value,q=(R==null?void 0:R.capability)||oe.value;if(q!=="none"){if(v===""||v==="auto"||q==="reasoning"||q==="mixed"){if(g.value.agent_reasoning_effort=v,q==="thinking"){const ye=await z.put("/api/agents/model",{thinking_mode:null});Fe.value={...Fe.value,...ye}}await cl()}else if(q==="thinking"){const ye=await z.put("/api/agents/model",{thinking_mode:v});Fe.value={...Fe.value,...ye},He("Agent thinking mode saved")}}}async function Bb(){Ws.value=!0;try{const v=await z.post("/api/ollama/reload");He(v.configured?"Ollama reloaded":v.reason||"Ollama not configured",v.configured?"success":"error"),await _a()}catch(v){He(v.message||"Reload failed","error")}finally{Ws.value=!1}}async function Ub(){Pa.value=!0;try{await z.post("/api/ollama/model",{model:ta.value}),He("Model set to "+ta.value),await _a()}catch(v){He(v.message||"Failed","error")}finally{Pa.value=!1}}async function zb(){const v=Te.value.base_url;if(!v){He("Enter a base URL first","error");return}Rt.value=!0;try{const R=await z.post("/api/ollama/probe-models",{base_url:v});zt.value=R.models||[],zt.value.length?(He(zt.value.length+" model(s) found"),!Te.value.model&&zt.value.length&&(Te.value.model=zt.value[0].name)):He("No models found at "+v,"error")}catch(R){He(R.message||"Could not reach Ollama","error")}finally{Rt.value=!1}}async function rl({refreshModels:v=!0}={}){try{if(hs.value=await z.get("/api/openai-compatible/status"),$a.value=!1,hs.value.model&&(ls.value=hs.value.model),v&&hs.value.configured)try{const R=await z.get("/api/openai-compatible/models");Es.value=R.models||[]}catch{Es.value=[]}}catch{$a.value=!0}}async function Hb(){sa.value=!0;try{const v=await z.post("/api/openai-compatible/reload");He(v.configured?"OpenAI-compatible reloaded":v.reason||"OpenAI-compatible not configured",v.configured?"success":"error"),await _a()}catch(v){He(v.message||"Reload failed","error")}finally{sa.value=!1}}async function jb(){ba.value=!0;try{await z.post("/api/openai-compatible/model",{model:ls.value}),He("Model set to "+ls.value),await _a()}catch(v){He(v.message||"Failed","error")}finally{ba.value=!1}}async function cl(){var R,q;if(Ye.value){gi();return}Ye.value=!0;const v=yl(g.value);try{if(!await O("codex",v.enabled)){g.value.enabled=((q=(R=p.value)==null?void 0:R.codex)==null?void 0:q.enabled)===!0;return}await z.put("/api/llm/codex/config",v),d("codexBasic",v),He("Codex config saved"),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),pa()])}catch(ye){He(ye.message||"Failed","error");const Ne=JSON.stringify(yl(g.value))!==JSON.stringify(v);await Promise.all([bs({preserveBasic:Ne,preserveAdvanced:!0}),pa()])}finally{Ye.value=!1}}async function rp(){if(Ye.value)return;Ye.value=!0;const v=xl(g.value);try{await z.put("/api/llm/codex/config",v),d("codexAdvanced",v),JSON.stringify({context_budget_overrides:g.value.context_budget_overrides,context_utilization:g.value.context_utilization})===JSON.stringify({context_budget_overrides:v.context_budget_overrides,context_utilization:v.context_utilization})&&(it.value=!1),He("Codex advanced settings saved"),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),pa(),Bn()])}catch(R){He(R.message||"Failed","error");const q=JSON.stringify(xl(g.value))!==JSON.stringify(v);await Promise.all([bs({preserveBasic:!0,preserveAdvanced:q}),pa(),Bn()])}finally{Ye.value=!1}}async function Ec(){var v,R;if(Dt.value){bi();return}Dt.value=!0;try{const q=De.value?Te.value.api_key:null,ye=_l(Te.value,{includeApiKey:q!==null});if(!await O("ollama",ye.enabled)){Te.value.enabled=((R=(v=p.value)==null?void 0:v.ollama)==null?void 0:R.enabled)===!0;return}await z.put("/api/llm/ollama/config",ye),He("Ollama config saved"),q!==null&&Te.value.api_key===q&&(Te.value.api_key="",De.value=!1),d("ollamaBasic",_l(Te.value)),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),ol()])}catch(q){He(q.message||"Failed","error"),await bs({preserveBasic:!0,preserveAdvanced:!0})}finally{Dt.value=!1}}async function cp(){if(!Dt.value){Dt.value=!0;try{const v=Jo(Te.value);await z.put("/api/llm/ollama/config",v),d("ollamaAdvanced",v),He("Ollama timeout saved"),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),ol()])}catch(v){He(v.message||"Failed","error")}finally{Dt.value=!1}}}async function dl(){var v,R;if(et.value){zn();return}et.value=!0;try{const q=ze.value?W.value.api_key:null,ye=bl(W.value,{includeApiKey:q!==null});if(!await O("compat",ye.enabled)){W.value.enabled=((R=(v=p.value)==null?void 0:v.openai_compatible)==null?void 0:R.enabled)===!0;return}await z.put("/api/openai-compatible/config",ye),He("OpenAI-compatible config saved"),q!==null&&W.value.api_key===q&&(W.value.api_key="",ze.value=!1),d("compatibleBasic",bl(W.value)),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),rl()]),await St()}catch(q){He(q.message||"Failed","error"),await bs({preserveBasic:!0,preserveAdvanced:!0})}finally{et.value=!1}}async function dp(){if(!et.value){et.value=!0;try{const v=Ko(W.value);await z.put("/api/openai-compatible/config",v),d("compatibleAdvanced",v),He("OpenAI-compatible endpoint settings saved"),await Promise.all([bs({preserveBasic:!0,preserveAdvanced:!0}),rl()]),await St()}catch(v){He(v.message||"Failed","error")}finally{et.value=!1}}}async function qb(){if(ge.value){Un();return}ge.value=!0;try{const v={...X.value};await z.put("/api/llm/auxiliary/config",v),d("auxiliary",v),He("Auxiliary config saved"),await bs()}catch(v){He(v.message||"Failed","error"),await bs()}finally{ge.value=!1}}const gi=Zo(cl),Un=Zo(qb),bi=Zo(Ec),zn=Zo(dl),Vb=()=>(gi.cancel(),cl()),Gb=()=>(bi.cancel(),Ec()),Wb=()=>(zn.cancel(),dl()),Kb=()=>rp(),Jb=()=>cp(),Zb=()=>dp();async function Qb(v){const R=v.account_key+":"+v.model;Xe.value=R;try{const q=await z.post("/api/context/windows/clear",{account_key:v.account_key,model:v.model});He(q.cleared?"Temporary clamp cleared":"Clamp was already inactive"),await Bn()}catch(q){He(q.message||"Failed to clear clamp","error"),await Bn()}finally{Xe.value=null}}async function Yb(v){try{await z.post("/api/codex/account/"+v+"/activate"),He("Active account switched"),await pa()}catch(R){He(R.message||"Failed","error")}}async function Xb(v){kc.value=v;try{await z.post("/api/codex/account/"+v+"/refresh"),He("Token refreshed"),await pa()}catch(R){He(R.message||"Refresh failed","error")}finally{kc.value=null}}function ey(v,R){To.value=v,Sc.value=R||""}async function ty(v){try{await z.put("/api/codex/account/"+v+"/label",{label:Sc.value}),He("Label updated"),To.value=null,await pa()}catch(R){He(R.message||"Failed","error")}}async function sy(v,R){if(await is({title:"Delete Codex account",message:`Delete ${R||"account #"+(v+1)}? The pool will reload without it.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/codex/account/"+v),He("Deleted. Pool reloaded."),await pa()}catch(ye){He(ye.message||"Failed","error")}}async function ay(){Cc.value=!0;try{const v=await z.post("/api/codex/device-code");Tc.value=v,ll.value="pending",ny(v)}catch(v){He(v.message||"Failed","error")}finally{Cc.value=!1}}async function ny(v){hi={cancelled:!1};const R=hi;try{const q=await z.post("/api/codex/device-poll",{device_auth_id:v.device_auth_id,user_code:v.user_code,interval:v.interval});if(R.cancelled)return;lp.value=q,ll.value="success",await _a()}catch(q){if(R.cancelled)return;op.value=q.message||"Device login failed",ll.value="error"}}function iy(){hi&&(hi.cancelled=!0),ll.value=null,Tc.value=null}function up(){n||(n=!0,_a(),a=window.setInterval(Db,s))}function pp(){n&&(n=!1,a!==null&&window.clearInterval(a),a=null)}return st(up),cs(up),es(pp),wt(()=>{pp(),hi&&(hi.cancelled=!0),gi.cancel(),Un.cancel(),bi.cancel(),zn.cancel()}),{pageRoot:t,savedProviderEnabled:w,codexOnly:_,codexChoices:ee,configuredDisabledSelection:T,configuredDisabledLabel:E,disableWarningRoles:F,effectiveAutoAllowlistCount:pn,allowlistModalOpen:ue,closeAllowlistModal:fi,allowlistSaving:Ce,effectiveAllowlist:ot,allowlistSummary:pi,resetAgentAllowlist:db,selectedUnavailableReason:Ba,selectedModelFacts:fn,loading:e,llmStatus:p,llmStatusLoadFailed:m,modelSelection:h,modelSelectorSearch:y,reasoningEfforts:k,neutralReasoningLevels:A,modelCatalog:I,modelGroups:te,selectedMainModel:re,selectedAgentModel:ce,selectedAgentCapabilityValue:j,agentCapabilityKind:oe,agentCapabilityEfforts:ve,autoAllowlistModels:be,allowlistModel:Z,allowlistModelEfforts:Q,allowlistEntryCapabilityValue:V,modelOptionLabel:Y,agentModelAvailable:N,agentModelOptionLabel:M,advancedOpen:Ae,codexForm:g,codexModelOptions:xe,codexAgentModelOptions:S,mainEffortAllowed:fe,agentEffortAllowed:me,mainModelOptionDisabled:J,agentModelOptionDisabled:ie,auxForm:X,auxData:_e,auxModelOptions:U,onAuxModelChange:de,savingAux:ge,saveAuxConfigDebounced:Un,ollamaForm:Te,compatibleForm:W,savingCodex:Ye,savingOllama:Dt,savingCompatible:et,probingOllama:Rt,ollamaKeyDirty:De,compatibleKeyDirty:ze,fetchCodexStatus:pa,ollamaStatus:Vs,ollamaStatusLoadFailed:Gs,ollamaModels:zt,ollamaSelectedModel:ta,reloading:Ws,settingModel:Pa,compatibleStatus:hs,compatibleStatusLoadFailed:$a,compatibleModels:Es,visibleCompatibleModels:Ct,compatibleSelectedModel:ls,reloadingCompatible:sa,settingCompatibleModel:ba,applyCompatiblePreset:al,setOpenRouterList:C,agentsConfig:Fe,compatibleAgentModels:vi,ollamaAgentModels:K,knownAgentModelRefs:ne,agentModelLabel:Ee,saveAgentsModel:Ks,toggleAgentAutoAllowlist:yn,saveAllowlistEntryCapability:ub,autoAllowlistGroups:$,structuralFacts:ap,saveModelHint:xb,canMoveAllowlist:np,moveAgentAutoAllowlist:_b,openRouterCatalogue:kt,openRouterCatalogueLoading:$s,openRouterCatalogueError:Wt,openRouterRecognized:ut,openRouterCatalogueActive:As,compatibleCatalogueStatus:vc,compatibleCatalogueStatusClass:hc,openRouterSearch:Pn,openRouterVendor:mn,openRouterVendors:wo,openRouterToolsOnly:$n,openRouterEligibleOnly:Ua,openRouterStandardOnly:vn,openRouterMeasuredCacheOnly:Ie,openRouterMaxPromptPrice:D,openRouterQuantization:pe,openRouterQuantizations:gc,openRouterResults:yc,openRouterMatchCount:xc,openRouterInlineFacts:mi,openRouterSelectedFacts:Fn,prepareOpenRouterModel:hb,addOpenRouterModel:gb,removeOpenRouterModel:bb,quickAddOpenRouter:yb,openRouterModelMap:gn,openRouterPin:Co,openRouterPendingModel:ke,openRouterPendingTag:Be,openRouterPendingEndpoints:Ue,openRouterPendingLoading:Ke,openRouterEndpointSort:$t,openRouterSortedPendingEndpoints:Ot,openRouterEndpointCacheFact:pb,openRouterRate:fb,openRouterMetric:mb,openRouterRouteWarning:vb,cancelOpenRouterPending:So,codexLoading:_c,codexError:wc,codexData:ip,refreshing:kc,editingLabel:To,labelValue:Sc,contextWindows:$e,contextWindowsLoading:We,contextWindowsError:je,contextBudgetRows:se,activeClampRows:Re,activeContextBudget:Le,clearingClamp:Xe,contextPolicyDirty:it,deviceState:ll,deviceLoading:Cc,deviceInfo:Tc,deviceResult:lp,deviceError:op,fetchAll:_a,fetchLLMStatus:bs,fetchOllamaStatus:ol,fetchCompatibleStatus:rl,saveMainModel:Pb,saveMainCapability:$b,saveAgentCapability:Fb,reloadOllama:Bb,setOllamaModel:Ub,reloadCompatible:Hb,setCompatibleModel:jb,probeOllamaModels:zb,saveCodexConfig:cl,saveOllamaConfig:Ec,saveCompatibleConfig:dl,saveCodexAdvancedConfig:rp,saveOllamaAdvancedConfig:cp,saveCompatibleAdvancedConfig:dp,saveCodexConfigDebounced:gi,saveOllamaConfigDebounced:bi,saveCompatibleConfigDebounced:zn,saveCodexConfigNow:Vb,saveOllamaConfigNow:Gb,saveCompatibleConfigNow:Wb,saveCodexAdvancedConfigNow:Kb,saveOllamaAdvancedConfigNow:Jb,saveCompatibleAdvancedConfigNow:Zb,activateAccount:Yb,refreshAccount:Xb,startEditLabel:ey,saveLabel:ty,deleteAccount:sy,startDeviceLogin:ay,cancelDeviceLogin:iy,formatSize:wb,fetchContextWindows:Bn,clearContextClamp:Qb,setContextOverride:Lb,setContextUtilization:Nb,resetContextOverride:Mb,overrideAboveFloor:Ob,formatCount:kb,formatContextCeiling:Sb,formatExpiry:Cb,shortAccountKey:Ab,provenanceClass:Ib,formatDensity:Rb,quotaBlocks:QC,quotaFailureVisible:YC,quotaAge:Eb,formatQuotaDate:Tb}}},tm={ok:"text-green-400",pass:"text-green-400",degraded:"text-yellow-400",warn:"text-yellow-400",down:"text-red-400",fail:"text-red-400",unconfigured:"text-gray-500",skipped:"text-gray-500"};function nT(e){return tm[e]||tm[(e||"").toLowerCase()]||"text-gray-400"}const iT={template:` +
    +
    +
    +
    + + + +
    +
    +

    + {{ failedCount }} of {{ endpoints.length }} internal endpoints failed to load: + {{ failedEndpointSummary }}. +

    +
    + + +
    +

    Startup Diagnostics

    +
    +
    + {{ startup.passed_count || 0 }}/{{ startup.total_checks || 0 }} passed + ({{ startup.duration_ms }}ms) +
    +
    + + + + {{ d.name }} + {{ d.detail || '' }} +
    +
    +

    No diagnostics available

    +
    + + +
    +

    Subsystem Guard

    +
    +
    +
    + + + + {{ s.name }} +
    +
    + {{ s.total_successes || 0 }} ok / {{ s.total_failures || 0 }} fail + — last fail: {{ formatAgeSeconds(s.last_failure_age_seconds) }} +
    +
    +
    +

    No subsystems registered

    +
    + + +
    +

    Connection Pools

    +
    +
    +

    SSH Pool

    +
    +
    Active connections: {{ sshPool.active_connections || 0 }}
    +
    Active hosts: {{ sshPool.active_hosts?.length ? sshPool.active_hosts.join(', ') : 'None' }}
    +
    Opened: {{ sshPool.total_opened || 0 }}
    +
    Reused: {{ sshPool.total_reused || 0 }}
    +
    +

    No SSH pool data

    +
    +
    +

    HTTP Pools

    +
    +
    + {{ provider }} + + +
    +
    +

    No HTTP pool data

    +
    +
    +
    + + +
    +

    Command Governor

    +
    +
    + Blocked: {{ governorStats.blocked || 0 }} + High-risk allowed: {{ governorStats.allowed_high_risk || 0 }} +
    +
    +
    Recent blocks:
    +
    + [{{ b.risk }}] {{ b.reason }} — {{ b.command }} +
    +
    +
    +

    No governor data

    +
    + + +
    + + +
    +

    Risk Classifier

    +
    +
    Total assessed: {{ riskTotal }}
    +
    Critical: {{ riskStats.totals?.critical || 0 }}
    +
    High risk: {{ riskStats.totals?.high || 0 }}
    +
    Medium: {{ riskStats.totals?.medium || 0 }}
    +
    Low: {{ riskStats.totals?.low || 0 }}
    +
    +

    No risk data

    +
    + + +
    +

    Recovery

    +
    +
    Attempts: {{ recoveryStats.totals?.attempts || 0 }}
    +
    Recovered: {{ recoveryStats.totals?.successes || 0 }}
    +
    Failed: {{ recoveryStats.totals?.failures || 0 }}
    +
    +

    Recovery disabled or no data

    +
    + + +
    +

    Context Compression

    +
    +
    Compressions: {{ compressionStats.compressions || 0 }}
    +
    Iterations compressed: {{ compressionStats.iterations_compressed || 0 }}
    +
    Chars saved: {{ (compressionStats.chars_saved || 0).toLocaleString() }}
    +
    Prefix cache hit rate: {{ ((compressionStats.prefix_hit_rate || 0) * 100).toFixed(0) }}%
    +
    +

    No compression data

    +
    + +
    + + +
    +

    Branch Freshness

    +
    +
    Checks: {{ freshnessStats.total_checks || 0 }}
    +
    Stale detected: {{ freshnessStats.stale_found || 0 }}
    +
    Fetch failures: {{ freshnessStats.fetch_failures || 0 }}
    +
    +

    Freshness checking disabled or no data

    +
    + +
    +
    + `,setup(){const e=f(!0),t=f({}),s=f([]),a=f({}),n=f({}),i=f(null),l=f(null),o=f(null),r=f(null),c=f(null),d=B(()=>{var _;return Object.values(((_=i.value)==null?void 0:_.totals)||{}).reduce((T,L)=>T+Number(L||0),0)}),u=f(""),p=f(0),m=f([]),h=B(()=>m.value.map(_=>`${_.label} (${_.path}${_.reason?`: ${_.reason}`:""})`).join("; ")),y=Object.freeze([{key:"startup",label:"Startup diagnostics",path:"/api/startup/diagnostics"},{key:"subsystems",label:"Subsystem status",path:"/api/subsystems/status"},{key:"sshPool",label:"SSH pool",path:"/api/pools/ssh"},{key:"httpPool",label:"HTTP pool",path:"/api/pools/http"},{key:"riskStats",label:"Risk stats",path:"/api/risk/stats"},{key:"recoveryStats",label:"Recovery stats",path:"/api/recovery/stats"},{key:"compressionStats",label:"Compression stats",path:"/api/compression/stats"},{key:"freshnessStats",label:"Freshness stats",path:"/api/freshness/stats"},{key:"governorStats",label:"Governor stats",path:"/api/governor/stats"}]);let k=null;async function A(){var F;const _=await Promise.allSettled(y.map(O=>z.get(O.path))),T=O=>_[O].status==="fulfilled"?_[O].value:null;t.value=T(0)||{};const L=T(1);s.value=Array.isArray(L)?L:L&&L.subsystems||[],a.value=T(2)||{},n.value=T(3)||{},i.value=T(4),l.value=T(5),o.value=T(6),r.value=T(7),c.value=T(8);const E=_.filter(O=>O.status==="rejected");if(m.value=_.flatMap((O,I)=>{var H;return O.status==="rejected"?[{...y[I],reason:((H=O.reason)==null?void 0:H.message)||"request failed"}]:[]}),p.value=m.value.length,E.length===_.length){const O=(F=E[0])==null?void 0:F.reason;u.value=(O==null?void 0:O.message)||"Failed to load internals"}else u.value="";e.value=!1}function g(){e.value=!0,u.value="",A()}let b=!1;function x(){b||(b=!0,A(),k||(k=setInterval(A,3e4)))}function w(){b&&(b=!1,k&&(clearInterval(k),k=null))}return st(x),cs(x),es(w),wt(w),{loading:e,error:u,failedCount:p,failedEndpoints:m,failedEndpointSummary:h,endpoints:y,retry:g,startup:t,subsystems:s,sshPool:a,httpPool:n,riskStats:i,riskTotal:d,recoveryStats:l,compressionStats:o,freshnessStats:r,governorStats:c,statusColor:nT,formatAgeSeconds:k1}}},lT=1e4,sm=3e4;function wl(e,t){return Math.max(0,e-t)}function Qc(e,t){return new Set((e.operations||[]).map(a=>a.state)).has("MANUAL_RESOLUTION_REQUIRED")?0:e.expired_lease||e.status==="ACTIVE"&&(!e.lease_expires_at||e.lease_expires_at +
    +

    Turn State

    +
    + Data stale + +
    +
    +

    + Read-only current recovery posture. Historical interrupted-effect evidence + remains available below as diagnostics, not operator work. +

    + +
    +
    +
    +

    Current posture

    +

    Active work, suspended recovery, expired leases, and effects requiring a human.

    +
    + stale — last success {{ turnsAgeSeconds }}s ago +
    + +
    + Turn durability is not enabled in this deployment. +
    +
    +

    {{ turnsError }}

    + +
    + +
    +
    +
    +
    + +
    +
    +

    Model capacity breakers

    +
    + process lifetime + stale — last success {{ breakersAgeSeconds }}s ago +
    +
    + +
    + Breaker registry is not constructed in this deployment. +
    +
    +

    {{ breakersError }}

    + +
    + +
    +
    +
    +
    +
    + `,setup(){const e=f(null),t=f(""),s=f(null),a=f(!1),n=f(0),i=f(null),l=f(""),o=f(null),r=f(!1),c=f(0),d=f(Date.now());let u=null,p=0,m=0;async function h(){const M=++p;a.value=!0;try{const $=await z.get("/api/turn-state/turns?limit=100");if(M!==p)return;t.value=$.availability,e.value=$.availability==="available"?$.data:null,s.value=null,n.value=Date.now()}catch($){if(M!==p)return;s.value=$.message||"Turn-state read failed",$.status===503&&(t.value="unavailable")}M===p&&(a.value=!1)}async function y(){const M=++m;r.value=!0;try{const $=await z.get("/api/turn-state/capacity-breakers");if(M!==m)return;l.value=$.availability,i.value=$.availability==="available"?$.data:null,o.value=null,c.value=Date.now()}catch($){if(M!==m)return;o.value=$.message||"Breaker read failed",$.status===503&&(l.value="unavailable")}M===m&&(r.value=!1)}function k(){h(),y()}const A=B(()=>e.value!==null&&wl(d.value,n.value)>sm),g=B(()=>i.value!==null&&wl(d.value,c.value)>sm),b=B(()=>A.value||g.value),x=B(()=>Math.round(wl(d.value,n.value)/1e3)),w=B(()=>Math.round(wl(d.value,c.value)/1e3));function _(M){return Qc(M,d.value/1e3)}function T(M){return oT[_(M)]}const L=B(()=>{var re;const M=[...((re=e.value)==null?void 0:re.turns)||[]],$=d.value/1e3;return M.sort((ce,Z)=>Qc(ce,$)-Qc(Z,$)||(Z.last_progress_at||0)-(ce.last_progress_at||0))});function E(M){return M.state==="closed"?"badge-success":M.state==="probing"?"badge-warning":"badge-danger"}function F(M){if(M.state==="closed")return"—";const $=wl(d.value,c.value)/1e3,re=Math.max(0,(M.cooldown_remaining_seconds||0)-$);return re>0?`${Math.ceil(re)}s`:M.state==="probing"?"probe in flight":"probe eligible"}function O(M){if(!M)return"";const $=Math.max(0,Math.round(d.value/1e3-M));if($<90)return`${$}s ago`;const re=Math.round($/60);return re<90?`${re}m ago`:`${Math.round(re/60)}h ago`}let I=null,H=null,te=!1;function ee(){te||(te=!0,k(),I=setInterval(k,lT),u=setInterval(()=>{d.value=Date.now()},1e3),H=mt.onReconnected(k))}function N(){te&&(te=!1,I&&(clearInterval(I),I=null),u&&(clearInterval(u),u=null),H&&(H(),H=null))}return st(ee),cs(ee),es(N),wt(N),{turnsData:e,turnsAvailability:t,turnsError:s,turnsLoading:a,breakersData:i,breakersAvailability:l,breakersError:o,breakersLoading:r,turnsStale:A,breakersStale:g,anyStale:b,turnsAgeSeconds:x,breakersAgeSeconds:w,sortedTurns:L,priorityOf:_,priorityBadge:T,breakerBadge:E,cooldownLabel:F,ageLabel:O,fetchTurns:h,fetchBreakers:y,refreshAll:k,arm:ee,disarm:N}}},cT={setup(){const e=f(""),t=f(""),s=f(!1),a=f(""),n=f(!1),i=f(!1),l=f(!1),o=f(null),r=f(!1);async function c(){n.value=!0,o.value=null,r.value=!1;try{const u=await z.get("/api/update/check");e.value=u.current||"",t.value=u.latest||"",s.value=u.update_available||!1,a.value=u.changelog||"",u.error&&(o.value=u.error),r.value=!0}catch(u){o.value=u.message}finally{n.value=!1}}async function d(){if(await is({title:"Update & restart",message:"Update Odin and restart? Active tasks will be interrupted.",confirmLabel:"Update & Restart",danger:!0})){i.value=!0,o.value=null;try{await z.post("/api/update/apply",{version:"latest"}),l.value=!0,setTimeout(()=>location.reload(),8e3)}catch(p){o.value=p.message}finally{i.value=!1}}}return st(c),{current:e,latest:t,updateAvailable:s,changelog:a,checking:n,applying:i,applied:l,error:o,checkDone:r,checkUpdate:c,applyUpdate:d}},template:` +
    +
    +

    Updates

    +

    Check for new Odin releases and apply updates.

    +
    + + +
    +
    +
    + Current version +

    {{ current || '...' }}

    +
    + +
    +
    + + +
    +
    + + Update available: {{ latest }} +
    +
    {{ changelog }}
    + +
    + + +
    +
    + + You're running the latest version. +
    +
    + + +
    +
    + + Update applied. Restarting... This page will reload automatically. +
    +
    + + +
    +

    {{ error }}

    +
    +
    + `},am=e=>JSON.parse(JSON.stringify(e)),dT=(e,t)=>JSON.stringify(e)===JSON.stringify(t),uT={emits:["saved"],template:` +
    +
    +
    +

    Computer provisioning

    +

    Edit desired target, storage and launcher policy. All settings below require an Odin restart. Startup values remain pinned, even across disable/enable cycles.

    +
    + Restart required +
    +

    Saving does not install dependencies, create storage, grant OS permissions, attach to a desktop or restart Odin. Enable/disable and Stop session are separate lifecycle controls above.

    +

    Hyprland only: best-effort input. A hard guardian SIGKILL can leave input held. Releasing Odin's button can clobber the physical user's simultaneous same-button hold. Use Release owned input for recovery, then obtain a fresh observation. Native explicit-output capture only; there is no portal fallback. The operator must load the ABI-matched scope plugin once at setup. Saving or enabling never loads it or edits hyprland.conf.

    +

    {{ message }}

    + +

    Loading desired configuration and restart metadata…

    +

    {{ pending.length ? 'Saved settings still pending restart: ' + pending.join(', ') : 'No pending provisioning restart reported.' }}

    +

    Some startup values are unknown; absence of a pending flag does not prove those settings are active.

    +
    +
    + Desired computer settings +
    + +

    {{ field.description }}

    + + + + + +
    +
    +
    + + +
    +
    +
    +

    Review provisioning changes

    +
    +
    +
    {{ change.label }}
    +
    Saved: {{ format(original[change.key]) }}
    +
    Desired: {{ format(change.value) }}
    +
    +
    +

    Only these changed provisioning fields will be saved. Running sessions keep startup settings. Nothing is enabled, stopped or restarted.

    +
    + + +
    +
    + +

    Drafts are local to this view and discarded when leaving it. Backend validation remains authoritative.

    +
    `,setup(e,{emit:t}){const s=f([]),a=f({}),n=f({}),i=f(!1),l=f(!1),o=f(!1),r=f(!1),c=f(!1),d=f(""),u=f("");let p=!1,m=0,h=null;const y=(N,M)=>p&&m===N&&z.token===M,k=N=>"computer-provisioning-"+N.key,A=N=>N===null?"Unset":N===""?"Empty":JSON.stringify(N),g=N=>{const M=n.value[N.key];return N.type==="array"?String(M||"").split(/\r?\n/).map($=>$.trim()).filter(Boolean):["integer","number"].includes(N.type)?M===""||M==null?null:Number(M):M},b=B(()=>s.value.map(N=>({...N,value:g(N)})).filter(N=>!dT(N.value,a.value[N.key]))),x=B(()=>s.value.filter(N=>N.pending_restart).map(N=>N.label)),w=B(()=>s.value.some(N=>N.apply_state==="unknown")),_=B(()=>{const N={};for(const M of s.value){const $=g(M),re=M.constraints||{};["integer","number"].includes(M.type)&&($===null&&!M.nullable?N[M.key]="A number is required.":$!==null&&(!Number.isFinite($)||M.type==="integer"&&!Number.isInteger($)||re.minimum!=null&&$re.maximum)&&(N[M.key]="Enter a number within the allowed range.")),M.key==="monitor_names"&&($.length>16||new Set($).size!==$.length||$.some(ce=>!/^[A-Za-z0-9_.-]{1,64}$/.test(ce)))&&(N[M.key]="Use up to 16 unique monitor names, one per line (letters, digits, dot, underscore or hyphen).")}return N}),T=B(()=>Object.keys(_.value).length>0);function L(N,M){n.value[N.key]=M,u.value=""}function E(){n.value=Object.fromEntries(s.value.map(N=>[N.key,N.type==="array"?a.value[N.key].join(` +`):a.value[N.key]])),r.value=!1}async function F(N,M){const[$,re]=await Promise.all([z.get("/api/config"),z.get("/api/config/meta")]);if(!y(N,M))return!1;const ce=(re.fields||[]).filter(Z=>/^computer\.[^.]+$/.test(Z.path)&&Z.path!=="computer.enabled"&&Z.sensitivity==="public"&&Z.apply_mode==="restart");if(!$.computer||!ce.length)throw new Error("Provisioning metadata is unavailable. Reload before editing.");return s.value=ce.map(Z=>({...Z,key:Z.path.split(".")[1]})),a.value=Object.fromEntries(s.value.map(Z=>[Z.key,am($.computer[Z.key])])),E(),h=M,i.value=!0,c.value=!1,!0}async function O(){if(!p||l.value||o.value)return;const N=++m,M=z.token;l.value=!0,i.value=!1,d.value="",u.value="",r.value=!1;try{await F(N,M)}catch($){y(N,M)&&(c.value=!0,d.value=$.message||"Could not load provisioning. No changes were sent.")}finally{y(N,M)&&(l.value=!1)}}function I(){i.value&&!l.value&&!o.value&&!c.value&&b.value.length&&!T.value&&(r.value=!0)}async function H(){if(!p||!i.value||!r.value||o.value||l.value||c.value||T.value||!b.value.length)return;if(h!==z.token){ee(),te();return}const N={computer:Object.fromEntries(b.value.map(ce=>[ce.key,am(ce.value)]))},M=m,$=z.token;o.value=!0,d.value="",u.value="";let re=!1;try{if(await z.put("/api/config",N),re=!0,!y(M,$))return;await F(M,$)&&(u.value="Provisioning saved and configuration reloaded. Startup settings remain pinned until Odin restarts; no lifecycle action was requested.",t("saved"))}catch(ce){y(M,$)&&(c.value=!0,r.value=!1,d.value=re?"Provisioning was saved, but configuration refresh failed. Reload saved values before editing again.":`Save failed or its outcome is unknown${ce.status===400?": "+ce.message:"."} Reload saved values before editing or retrying. No request was replayed.`)}finally{y(M,$)&&(o.value=!1)}}function te(){p||(p=!0,O())}function ee(){p=!1,m++,i.value=!1,l.value=!1,o.value=!1,r.value=!1,h=null,s.value=[],a.value={},n.value={},d.value="",u.value=""}return st(te),cs(te),es(ee),wt(ee),{fields:s,original:a,draft:n,ready:i,loading:l,saving:o,reviewing:r,uncertain:c,error:d,message:u,pending:x,effectiveUnknown:w,changes:b,validation:_,invalid:T,fieldId:k,format:A,edit:L,discard:E,load:O,openReview:I,save:H}}},pT={components:{ComputerProvisioning:uT,DiscordIdentity:Dn},template:` +
    + + +
    +
    +
    {{ adminReady ? 'Current session' : 'Last-known session (not current)' }}
    +
    {{ status.state || 'unknown' }}
    +
    + {{ loading ? 'Checking status' : adminReady ? 'Session status' : 'Status not current' }} +
    +

    {{ checkedAt ? 'Last successful status check: ' + new Date(checkedAt).toISOString() + '. ' : 'No successful status check. ' }}Preserved details are read-only history, not current readiness, consent or session authority. Refresh status independently before further operations. Emergency Pause / Stop only request revocation and remain independently authenticated by the server.

    +
    +
    +
    +
    +

    Administrator lifecycle controls

    +

    Configuration and runtime state are separate. Changes here never restart Odin.

    +
    +
    +
    +
    Configured
    {{ enabledLabel(status.configured_enabled ?? status.enabled) }}
    +
    Runtime lifecycle
    {{ enabledLabel(status.runtime_enabled) }}
    +
    Runtime generation
    {{ status.generation ?? 'Unknown' }}
    +
    Backend platform / environment
    {{ status.backend?.platform || 'Unknown' }} / {{ status.backend?.environment || 'Unknown' }}
    +
    Restart-required settings
    {{ restartSettings }}
    +
    +
    + + +
    +

    Lifecycle controls require a successful authenticated administrator status check.

    +

    Applying lifecycle change and checking status…

    +

    Enabling does not prove backend readiness or start a session. Session startup checks capabilities. Unavailable input remains unavailable.

    +

    Input unavailable: this backend cannot act. Enabling computer use does not grant mouse or keyboard control.

    +

    Input capability is unknown. Do not assume this backend can act.

    +

    Backend reports input support; session authorization and startup checks still apply.

    +

    Readiness: {{ status.backend.readiness }}. Input blocker: {{ status.backend.input_blocker }}.

    +

    Display capture access is not application inspection access. Provision the worker under the desktop user's identity, or explicitly configure runtime_sudo and its restricted worker permissions. This page does not elevate privileges.

    +

    Runtime settings are generation-pinned. Pending restart-required settings are not live; this page does not restart Odin.

    +
    + +
    +

    Hyprland native input: best-effort

    +

    A hard guardian SIGKILL can leave owned input held. Releasing Odin's button may clobber the physical user's simultaneous same-button hold. Cooperative release acknowledgments are not receiver-side proof. These accepted residuals apply only to Hyprland.

    +

    Native capture is limited to the explicitly consented output. There is no portal fallback. Recovery revokes input and capture, leaves the session paused, and requires renewed consent and a fresh observation.

    + +

    {{ status.owned_input_recovery.released ? 'Cooperative release acknowledged; receiver-side release is not verified.' : 'Release not confirmed. Inspect the desktop and use documented operator recovery; do not resume input.' }}

    +
    +
    +
    +

    Desktop accessibility (AT-SPI)

    + {{ accessibilityLabel }} +
    +

    AT-SPI is Linux's accessibility interface. Enabling it lets Odin target UI elements by identity and replace field contents reliably when the application exposes editable elements.

    +

    Without accessible elements, Odin can use pixels and coordinates. Field replacement uses the explicit pixel action (replace_field_pixels): click an observed field, select all, then type. It is less reliable and needs visual verification; identity-based replace_field never silently switches paths.

    +

    Enabled is a session setting, not proof that a particular application exposes editable elements or that input is authorized. Fresh observations, application safety checks and consent still apply.

    +

    Read-only org.a11y.Status IsEnabled check for the configured runtime operator session, refreshed with status about every 5 seconds while this page is open. {{ accessibilityDetail }}

    +

    This page does not enable or disable accessibility. Change it explicitly in your desktop's accessibility settings; that affects your whole user session, not just Odin.

    +
    +
    +
    +

    Input eligibility evidence

    + {{ status.input_admission.state }} +
    +
    +

    {{ status.input_admission.state }}: {{ status.input_admission.code }}

    +

    Compositor: {{ status.input_admission.compositor.name }} {{ status.input_admission.compositor.version }} ({{ status.input_admission.compositor.backend }}). Build: {{ status.input_admission.compositor.build_id }}.

    +

    {{ status.input_admission.reason }}

    +

    Operator action: {{ status.input_admission.remedy }}

    +

    Probe scope: {{ status.input_admission.probe_scope }}.

    +
    +

    Behavior was tested in a separate disposable compositor with the matched stack, not by abandoning held input on your desktop.

    +

    Eligibility evidence does not replace current backend consent, source mapping or application checks. Opening this page runs no input probe.

    +
    +
    +
    +

    Application profiles

    +
    +
      +
    • + {{ profile.label }}: {{ profile.input === 'supported' ? 'Input eligible' : 'Capture only' }} + (application provenance unavailable) +

      {{ profile.task_scope }}

      +
    • +
    +

    No application profiles reported for this backend.

    +

    Profiles describe supported scope, not installation, focus, permission or task success. Input readiness is checked against a fresh observation.

    +
    +
    +
    +

    Attached application

    +
    +

    Focus the application you want help with, then ask in ordinary chat. There is no application allowlist. Normal dialogs, file pickers, menus and document open/new/close/reopen are ordinary use. Session Stop only detaches input; applications stay open.

    +

    Denied classes remain blocked: terminals, shells, authentication and password prompts, polkit, keyring, sudo and Odin control-plane windows. Session authorization, current target checks and input-release checks still apply.

    +
    +
    Observed executable
    {{ status.application_provenance?.exe_basename || 'Not observed' }}
    +
    Observed WM_CLASS
    {{ status.application_provenance?.wm_class || 'Not observed' }}
    +
    Observed PID
    {{ status.application_provenance?.pid ?? 'Not observed' }}
    +
    Trusted executable metadata
    {{ status.application_provenance?.trusted_executable === true ? 'Yes' : status.application_provenance?.trusted_executable === false ? 'No' : 'Unknown' }}
    +
    Script identity evidence
    {{ scriptIdentity }}
    +
    Pointer
    {{ status.backend?.pointer || 'unknown' }}
    +
    Keyboard focus
    {{ status.backend?.keyboard_focus || 'unknown' }}
    +
    Widget focus
    {{ status.backend?.widget_focus || 'unknown' }}
    +
    +

    Provenance describes the last observed target, not application approval or task success. Untrusted executable metadata is evidence, not an application refusal. Focus within one window may be shared even with an independent pointer. Unknown capabilities are not proof of independence.

    +

    Per-call input bounds: {{ inputLimits }}. These limits are not application restrictions.

    +
    +
    Disabled or unavailable. Check configured state, lifecycle state and backend prerequisites separately.
    +
    {{ adminReady ? 'Agent input is revoked.' : 'Last-known session was paused; this is not current evidence of revocation.' }} This inspector does not provide remote mouse or keyboard control. Resume requires a renewed generation and fresh evidence.
    +
    Outcome is unknown. Refresh status; do not replay the last action.
    +
    +
    +

    Recovery evidence

    + {{ status.recovery.complete ? 'Verified' : 'Review required' }} +
    +

    {{ status.recovery.status }}: {{ status.recovery.reason }}. Cleanup {{ status.recovery.complete ? 'verified' : 'not verified' }}.

    +

    Reconciliation only inspects the recorded workload. It never sends input, terminates applications or replays actions.

    + +
    +

    Manual reconciliation is an operator attestation, not verified cleanup. Independently inspect the desktop: no held input, owned master devices or workers may remain. Known running workers also block this request. The failed action remains unknown and is never replayed.

    + + + +
    +
    +
    +
    +

    Session details

    +
    +
    +
    Owner
    —
    +
    Session
    {{ status.session_id || '—' }}
    +
    Application
    {{ status.app || '—' }}
    +
    Last action / verification
    {{ status.last_action || '—' }} / {{ status.last_verification || 'unavailable' }}
    +
    +
    + +
    +
    +
    +

    Visual evidence

    +

    Frames are captured only on request. Opening or refreshing this view never captures or posts an image.

    +
    +
    + + + +
    +
    +
    +
    {{ freshness }} · Captured {{ frame.captured_at }} · Private evidence expires {{ frame.expires_at }}
    + Requested frame from the authorized application; read-only +
    +

    Frame expired. Observe again for current evidence.

    +
    + +
    +
    +
    +
    +

    Export a saved workspace file

    +

    Choose one exact filename, not a host path. Downloads are never executed.

    +
    +
    + +
    + + +
    +
    +
    + + Expires {{ artifact.expires_at }} +
    +
    +
    +
    `,setup(){const e=f({state:"unknown",available:!1}),t=f(!1),s=f(!1),a=f(!1),n=f(!1),i=f(!1),l=f(!1),o=f(""),r=f(!1),c=f(!1),d=f(!1),u=f(0),p=f(""),m=f(""),h=f(null),y=f(""),k=f(!1),A=f(Date.now()),g=f(""),b=f(null);let x=0,w=null,_=!1,T=z.token,L=0,E=null,F=null,O=!1;const I=J=>J===!0?"Enabled":J===!1?"Disabled":"Unknown",H=B(()=>{var J;return((J=e.value.backend)==null?void 0:J.environment)==="existing_session"}),te=B(()=>{var ie;const J=Date.parse(((ie=e.value.accessibility)==null?void 0:ie.checked_at)||"");return c.value&&Number.isFinite(J)&&A.value-J<15e3&&A.value>=J-5e3}),ee=B(()=>{var J;return te.value?I((J=e.value.accessibility)==null?void 0:J.enabled):"Unknown / not current"}),N=B(()=>{var J;return te.value?((J=e.value.accessibility)==null?void 0:J.reason)==="property_read"?"Last checked: "+e.value.accessibility.checked_at:"Property unavailable for this target. Isolated sessions, an unbound operator identity, an inactive accessibility service or an inaccessible session bus may prevent this read. Unknown does not mean disabled.":"No current property read; unknown does not mean disabled."}),M=B(()=>Object.entries(e.value.input_limits||{}).filter(([,J])=>typeof J=="number"&&Number.isFinite(J)).map(([J,ie])=>`${J}: ${ie}`).join(", ")),$=B(()=>{var ie;const J=(ie=e.value.application_provenance)==null?void 0:ie.script_identity;return typeof J=="string"?J:!J||typeof J!="object"?"Not observed":`${J.interpreter_basename||"Unknown interpreter"}; argv digest ${J.argv_digest||"not recorded"}; ${J.verified===!0?"verified":"not verified"}`}),re=B(()=>Array.isArray(e.value.application_profiles)?e.value.application_profiles.filter(J=>J&&typeof J.id=="string"&&typeof J.label=="string"&&["supported","capture_only"].includes(J.input)).slice(0,16):[]),ce=B(()=>{const J=e.value.restart_required;return Array.isArray(J)?J.length?J.join(", "):"None reported":J===!0?"Pending; restart required":J===!1?"None reported":"Unknown"}),Z=B(()=>{var ie,X;const J=Date.parse(((ie=h.value)==null?void 0:ie.captured_at)||"");return Number.isFinite(J)&&A.value2097152||!Number.isFinite(Date.parse(_e.frame.expires_at))||Date.parse(_e.frame.expires_at)<=Date.now())throw new Error("Invalid evidence");h.value=_e.frame,y.value=URL.createObjectURL(U),o.value=""}catch(_e){V(J,ie)&&Oe(_e)}finally{J===x&&(s.value=!1)}}async function he(){if(!be()||i.value||!e.value.available)return;b.value=null;const J=x,ie=z.token;i.value=!0;try{const X=await z.post("/api/computer/export",{name:g.value});V(J,ie)&&(b.value=X,o.value="")}catch(X){V(J,ie)&&Oe(X)}finally{J===x&&(i.value=!1)}}async function fe(){if(!be()||l.value||!b.value)return;const J=x,ie=z.token,X=b.value;l.value=!0;try{if(!/^[A-Za-z0-9_-]{8,128}$/.test((X==null?void 0:X.artifact_id)||""))throw new Error("Invalid export");const _e=await z.getBlob("/api/computer/download/"+X.artifact_id);if(!V(J,ie))return;const U=URL.createObjectURL(_e),de=document.createElement("a");de.href=U,de.download=X.name,de.click(),setTimeout(()=>URL.revokeObjectURL(U),1e3)}catch(_e){V(J,ie)&&Oe(_e)}finally{J===x&&(l.value=!1)}}function me(){_||(T!==z.token&&(T=z.token,ae(),u.value=0,F=null,e.value={state:"unknown",available:!1},o.value="",p.value=""),_=!0,le(),w=setInterval(()=>{A.value=Date.now(),T!==z.token&&(T=z.token,ae(),u.value=0,F=null,o.value="",p.value="",e.value={state:"unknown",available:!1}),c.value&&A.value-u.value>=15e3&&ae(),h.value&&Date.parse(h.value.expires_at)<=A.value&&(Q(),k.value=!0),b.value&&Date.parse(b.value.expires_at)<=A.value&&(b.value=null),!O&&A.value-L>=5e3&&le()},500))}function we(){_=!1,clearInterval(w),w=null,ae(),c.value=!1}return st(me),cs(me),es(we),wt(we),{status:e,checkedAt:u,remedy:p,loading:t,observing:s,stopping:a,pausing:n,exporting:i,downloading:l,error:o,frame:h,frameUrl:y,frameExpired:k,freshness:Z,name:g,artifact:b,refresh:le,control:j,observe:G,clearFrame:Q,exportFile:he,download:fe,toggling:r,adminReady:c,enabledLabel:I,restartSettings:ce,setEnabled:ve,recovering:d,recover:xe,reconcile:S,releaseOwnedInput:Y,reconciliationAck:m,applicationProfiles:re,attached:H,scriptIdentity:$,inputLimits:M,accessibilityLabel:ee,accessibilityDetail:N}}},Kg=[{id:"health",label:"Health",component:yC},{id:"resources",label:"Resources",component:xC},{id:"logs",label:"Logs",component:LC},{id:"config",label:"Config",component:qC},{id:"discord",label:"Discord",component:GC},{id:"hosts",label:"Hosts",component:JC},{id:"host-access",label:"Host Access",component:KC},{id:"api-tokens",label:"API Tokens",component:ZC},{id:"llm",label:"LLM Config",component:aT},{id:"internals",label:"Internals",component:iT},{id:"turn-state",label:"Turn State",component:rT},{id:"computer",label:"Computer",component:pT},{id:"update",label:"Update",component:cT}],fT={components:{TabbedPage:uc},setup(){return{tabs:Kg}},template:''},Qo=(e,t,s,a)=>a.map(({id:n,label:i})=>({group:e,label:i,icon:t,to:{path:s,query:{tab:n}}})),mT=[{group:"Workspace",label:"Dashboard",icon:"dashboard",to:{path:"/dashboard"}},{group:"Workspace",label:"Chat",icon:"chat",to:{path:"/chat"}},...Qo("Operations","operations","/operations",Fg),...Qo("History","history","/history",Bg),...Qo("Capabilities","capabilities","/capabilities",Ug),{group:"Manage",label:"Personality",icon:"personality",to:{path:"/personality"}},...Qo("System","system","/system",Kg)],Us=Mn({open:!1,query:"",selected:0});function nm(){Us.query="",Us.selected=0,Us.open=!0}function Yc(){Us.open=!1}function vT(e,t){const s=e.label.toLowerCase(),a=`${e.group} ${e.label}`.toLowerCase();return t?s.startsWith(t)?100:a.startsWith(t)?80:s.includes(t)?60:a.includes(t)?40:0:1}const hT={setup(){const e=Ig(),t=f(null),s=B(()=>{const i=Us.query.trim().toLowerCase();return mT.map(l=>({...l,_score:vT(l,i)})).filter(l=>l._score>0).sort((l,o)=>o._score-l._score)});Gt(()=>Us.open,async i=>{var l;i&&(await Ut(),(l=t.value)==null||l.focus())}),Gt(()=>Us.query,()=>{Us.selected=0});function a(i){Yc(),e.push(i.to)}function n(i){if(i.key==="Escape"){i.preventDefault(),Yc();return}if(i.key==="ArrowDown")i.preventDefault(),Us.selected=Math.min(Us.selected+1,s.value.length-1);else if(i.key==="ArrowUp")i.preventDefault(),Us.selected=Math.max(Us.selected-1,0);else if(i.key==="Enter"){i.preventDefault();const l=s.value[Us.selected];l&&a(l)}}return{state:Us,results:s,inputEl:t,go:a,onKeydown:n,closePalette:Yc}},template:` + + + + `},Ud={brand:"M12 3 4.5 8v8L12 21l7.5-5V8L12 3Zm0 4.2 4.6 3.1L12 16.8l-4.6-6.5L12 7.2Zm0 3.3v3.7",dashboard:"M4 13h6V4H4v9Zm0 7h6v-4H4v4Zm10 0h6v-9h-6v9Zm0-16v4h6V4h-6Z",chat:"M20 15a3 3 0 0 1-3 3H9l-5 3v-6a3 3 0 0 1-1-2.2V7a3 3 0 0 1 3-3h11a3 3 0 0 1 3 3v8Z",operations:"M5 12h3l2-6 4 12 2-6h3M4 4v16h16",history:"M4 12a8 8 0 1 0 2.3-5.7L4 8.5M4 4v4.5h4.5M12 7v5l3 2",home:"M3 11.5 12 4l9 7.5M5.5 10v10h13V10M9 20v-6h6v6",users:"M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2m7-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.9m-2-11.8a4 4 0 0 1 0 7.7",capabilities:"M14.7 6.3a4 4 0 0 0-5.6 5.6L4 17v3h3v-2h2v-2h2l1.1-1.1a4 4 0 0 0 5.6-5.6l-3 3-3-3 3-3Z",personality:"M12 3a8 8 0 0 0-8 8c0 4 3 7 7 7v3h3v-3c3 0 6-3 6-7a8 8 0 0 0-8-8ZM8.5 10h.01M15.5 10h.01M9 14c1.7 1.2 4.3 1.2 6 0",system:"M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4",menu:"M4 7h16M4 12h16M4 17h16",panelLeft:"M9 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4V4Zm0 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H9M6 8h.01M6 12h.01",chevronLeft:"m15 18-6-6 6-6",chevronRight:"m9 18 6-6-6-6",chevronDown:"m6 9 6 6 6-6",chevronUp:"m18 15-6-6-6 6",search:"m21 21-4.3-4.3M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z",logout:"M10 17l5-5-5-5m5 5H3m10-8h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5",success:"m5 12 4 4L19 6",warning:"M12 3 2.8 20h18.4L12 3Zm0 6v4m0 3h.01",info:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-8v4m0-8h.01",error:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm-3-12 6 6m0-6-6 6",edit:"M4 20h4l11-11-4-4L4 16v4Zm9-13 4 4",trash:"M4 7h16m-10 4v5m4-5v5M9 4h6l1 3H8l1-3Zm-3 3 1 13h10l1-13",brain:"M9 5a3 3 0 0 0-5 2.2A3.5 3.5 0 0 0 4 14a3 3 0 0 0 5 2.2V5Zm6 0a3 3 0 0 1 5 2.2 3.5 3.5 0 0 1 0 6.8 3 3 0 0 1-5 2.2V5ZM9 9H7m2 4H6m9-4h2m-2 4h3M12 4v16",refresh:"M20 6v5h-5M4 18v-5h5M18.5 10A7 7 0 0 0 6 7.5L4 11m16 2-2 3.5A7 7 0 0 1 5.5 14",close:"M6 6l12 12M18 6 6 18",command:"M7 8a3 3 0 1 1-3-3h3v14a3 3 0 1 1-3-3h13a3 3 0 1 1-3 3V5a3 3 0 1 1 3 3H7Z",external:"M14 4h6v6m0-6-9 9M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6",activity:"M4 12h4l2-5 4 10 2-5h4",shield:"M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z",database:"M20 6c0 1.7-3.6 3-8 3S4 7.7 4 6s3.6-3 8-3 8 1.3 8 3Zm0 0v6c0 1.7-3.6 3-8 3s-8-1.3-8-3V6m16 6v6c0 1.7-3.6 3-8 3s-8-1.3-8-3v-6",server:"M4 4h16v6H4V4Zm0 10h16v6H4v-6Zm3-7h.01M7 17h.01",terminal:"M5 7l4 4-4 4m6 1h8M3 4h18v16H3V4Z",wrench:"M14.7 6.3a4 4 0 0 0-5.6 5.6L4 17v3h3v-2h2v-2h2l1.1-1.1a4 4 0 0 0 5.6-5.6l-3 3-3-3 3-3Z",bot:"M8 4h8m-4-2v2M5 8h14a2 2 0 0 1 2 2v8H3v-8a2 2 0 0 1 2-2Zm3 4h.01M16 12h.01M8 16h8M3 13H1m22 0h-2",workflow:"M5 5h5v5H5V5Zm9 9h5v5h-5v-5ZM10 7.5h4a3 3 0 0 1 3 3V14M7.5 10v4a3 3 0 0 0 3 3H14",globe:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-18c2.2 2.5 3.3 5.5 3.3 9S14.2 18.5 12 21m0-18C9.8 5.5 8.7 8.5 8.7 12s1.1 6.5 3.3 9M3 12h18",book:"M4 5a3 3 0 0 1 3-2h5v17H7a3 3 0 0 0-3 1V5Zm16 0a3 3 0 0 0-3-2h-5v17h5a3 3 0 0 1 3 1V5Z",message:"M4 4h16v13H8l-4 4V4Zm4 5h8m-8 4h5",puzzle:"M9 4h3a2 2 0 1 1 4 0h4v5a2 2 0 1 0 0 4v7h-7a2 2 0 1 1-4 0H4v-7a2 2 0 1 0 0-4V4h5",sparkles:"m12 3 1.2 3.8L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.2L12 3Zm6 10 .8 2.2L21 16l-2.2.8L18 19l-.8-2.2L15 16l2.2-.8L18 13ZM5 14l1 2.8L9 18l-3 1.2L5 22l-1-2.8L1 18l3-1.2L5 14Z",link:"M9.5 14.5 14.5 9m-7 8H6a4 4 0 0 1 0-8h3m6 0h3a4 4 0 0 1 0 8h-3",file:"M6 3h8l4 4v14H6V3Zm8 0v5h5M9 13h6m-6 4h6",folder:"M3 6h7l2 2h9v11H3V6Z",image:"M4 4h16v16H4V4Zm3 12 4-4 3 3 2-2 4 4M9 9h.01",attachment:"m8 12 5-5a3 3 0 1 1 4 4l-7 7a5 5 0 0 1-7-7l7-7",clock:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v5l3 2",calendar:"M5 5h14v15H5V5Zm3-2v4m8-4v4M5 10h14",chart:"M4 20V10m5 10V4m5 16v-7m5 7V7M2 20h20",sliders:"M4 7h10m4 0h2M4 17h2m4 0h10M16 4v6M8 14v6",code:"m9 6-6 6 6 6m6-12 6 6-6 6",copy:"M8 8h11v12H8V8Zm-3 8H4V4h11v1",play:"m8 5 11 7-11 7V5Z",grid:"M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h6v6h-6v-6Z",list:"M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",target:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-5a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0-4h.01",rotate:"M20 6v5h-5M4 18v-5h5M18.5 10A7 7 0 0 0 6 7.5L4 11m16 2-2 3.5A7 7 0 0 1 5.5 14",archive:"M4 8h16v12H4V8Zm-1-4h18v4H3V4Zm6 8h6",flame:"M12 22c4 0 7-3 7-7 0-5-4-7-4-11-3 2-5 5-5 8-1-1-2-3-1-5-3 2-5 5-5 8 0 4 3 7 8 7Z",eye:"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z",upload:"M12 16V4m-5 5 5-5 5 5M5 20h14",download:"M12 4v12m-5-5 5 5 5-5M5 20h14",undo:"M9 7 4 12l5 5m-5-5h10a6 6 0 0 1 6 6",redo:"m15 7 5 5-5 5m5-5H10a6 6 0 0 0-6 6",minus:"M5 12h14",plus:"M12 5v14M5 12h14",network:"M12 3v4m0 10v4M3 12h4m10 0h4M7.8 7.8l2.1 2.1m4.2 4.2 2.1 2.1m0-8.4-2.1 2.1m-4.2 4.2-2.1 2.1M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",more:"M6 12h.01M12 12h.01M18 12h.01",pause:"M9 5v14m6-14v14",sort:"M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"};Object.freeze(Object.keys(Ud));const gT={name:"OdinIcon",props:{name:{type:String,required:!0},size:{type:[Number,String],default:18},strokeWidth:{type:[Number,String],default:1.8}},setup(e,{attrs:t}){return()=>qi("svg",{...t,class:["odin-icon",t.class],width:e.size,height:e.size,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":e.strokeWidth,"stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":t["aria-label"]?void 0:"true",focusable:"false"},[qi("path",{d:Ud[e.name]||Ud.info})])}},bT=["a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])'].join(",");function im(e){return[...e.querySelectorAll(bT)].filter(t=>!t.hasAttribute("hidden")&&t.getAttribute("aria-hidden")!=="true")}const yT={mounted(e){const t=document.activeElement,s=a=>{if(a.key!=="Tab")return;const n=im(e);if(!n.length){a.preventDefault(),e.focus();return}const i=n[0],l=n[n.length-1];a.shiftKey&&document.activeElement===i?(a.preventDefault(),l.focus()):!a.shiftKey&&document.activeElement===l&&(a.preventDefault(),i.focus())};e.__odinModalFocus={previous:t,onKeydown:s},e.addEventListener("keydown",s),requestAnimationFrame(()=>{(e.querySelector("[autofocus]")||im(e)[0]||e).focus()})},unmounted(e){var s;const t=e.__odinModalFocus;t&&(e.removeEventListener("keydown",t.onKeydown),(s=t.previous)!=null&&s.isConnected&&typeof t.previous.focus=="function"&&requestAnimationFrame(()=>t.previous.focus()),delete e.__odinModalFocus)}},xT={template:` +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    + +
    +
    +
    + + + + + +
    +
    +
    Odin
    +
    + + {{ status.status === 'online' ? 'Online' : 'Starting' }} + · + {{ uptime }} +
    +
    +
    +
    + + + +
    +
    + + +
    +
    +
    + + {{ s.label }} +
    +
    {{ s.value }}
    +
    {{ s.sub }}
    +
    +
    + + +
    +
    System Health
    +
    +
    + + {{ h.label }} + {{ h.detail }} +
    +
    +
    + + +
    + + +
    +
    + Active Agents + {{ agents.length }} +
    +
    + + No active agents +
    +
    +
    +
    + + {{ a.label }} + {{ a.iteration_count }} iters +
    +
    {{ a.goal }}
    +
    + {{ formatDuration(a.runtime_seconds) }} + {{ a.tools_used_count }} tools +
    +
    +
    +
    + + +
    +
    + + Recent Activity + +{{ newEventCount }} + + +
    +
    Loading...
    +
    + + No recent activity +
    +
    +
    + + {{ a.tool_name }} + {{ formatTime(a.timestamp) }} +
    +
    +
    + + +
    + +
    +
    + Guilds +
    +
    + + No guilds +
    +
    +
    + + {{ g.name }} + {{ g.member_count }} +
    +
    +
    + + +
    +
    + Recent Errors + {{ errors.length }} +
    +
    + + Couldn't load recent errors +
    +
    + + All clear +
    +
    +
    Refresh failed — showing known errors
    +
    +
    + + {{ e.tool_name }} + {{ formatTime(e.timestamp) }} +
    +
    {{ e.error }}
    +
    +
    +
    +
    +
    +
    +
    `,setup(){const e=f({}),t=f(!0),s=f(null),a=f([]),n=f(!1),i=f([]),l=f(!1),o=f(!1),r=f([]),c=f(0),d=f(null),u=f({reload:!1,clearSessions:!1,stopLoops:!1});let p=0;const m=B(()=>{const ce=e.value.uptime_seconds||0,Z=Math.floor(ce/86400),Q=Math.floor(ce%86400/3600),ae=Math.floor(ce%3600/60),V=[];return Z>0&&V.push(`${Z}d`),Q>0&&V.push(`${Q}h`),(V.length===0||Z===0&&Q===0)&&V.push(`${ae}m`),V.join(" ")}),h=B(()=>{const ce=e.value.uptime_seconds||0;return 125.66*(1-Math.min(ce/86400,1))}),y=B(()=>{const ce=e.value;return[{label:"Guilds",value:ce.guild_count??0,icon:"home",iconColor:"text-blue-400"},{label:"Sessions",value:ce.session_count??0,icon:"message",iconColor:"text-yellow-400"},{label:"Tools",value:ce.tool_count??0,icon:"wrench",iconColor:"text-purple-400",sub:`${ce.skill_count??0} skills`,subColor:"text-gray-500"},{label:"Loops",value:ce.loop_count??0,icon:"rotate",iconColor:"text-green-400",color:ce.loop_count>0?"text-green-400":"",highlight:ce.loop_count>0},{label:"Agents",value:ce.agent_running??0,icon:"bot",iconColor:"text-cyan-400",sub:ce.agent_count>0?`${ce.agent_count} total`:"",subColor:"text-gray-500",highlight:(ce.agent_running??0)>0},{label:"Processes",value:ce.process_running??0,icon:"sliders",iconColor:"text-orange-400",sub:ce.process_count>0?`${ce.process_count} total`:"",subColor:"text-gray-500",highlight:(ce.process_running??0)>0},{label:"Schedules",value:ce.schedule_count??0,icon:"clock",iconColor:"text-amber-400",sub:(ce.schedule_failing>0?`${ce.schedule_failing} failing`:"")+(ce.schedule_failing>0&&ce.schedule_paused>0?", ":"")+(ce.schedule_paused>0?`${ce.schedule_paused} paused`:"")||void 0,subColor:ce.schedule_failing>0?"text-red-400":"text-yellow-400",color:ce.schedule_failing>0?"text-red-400":"",highlight:ce.schedule_failing>0},{label:"Users",value:ce.user_count??0,icon:"users",iconColor:"text-indigo-400"},...d.value!==null?[{label:"Knowledge",value:d.value,icon:"book",iconColor:"text-teal-400",sub:"chunks",subColor:"text-gray-500"}]:[]]}),k=B(()=>{const ce=e.value,Z=[];return Z.push({label:"Bot",status:ce.status==="online"?"ok":"warn",detail:ce.status==="online"?"Online":"Starting"}),(ce.schedule_failing||0)>0?Z.push({label:"Schedules",status:"error",detail:`${ce.schedule_failing} failing`}):(ce.schedule_count||0)>0&&Z.push({label:"Schedules",status:"ok",detail:`${ce.schedule_count} configured`}),(ce.loop_count||0)>0&&Z.push({label:"Loops",status:"ok",detail:`${ce.loop_count} active`}),(ce.agent_running||0)>0&&Z.push({label:"Agents",status:"ok",detail:`${ce.agent_running} running`}),(ce.process_running||0)>0&&Z.push({label:"Processes",status:"ok",detail:`${ce.process_running} running`}),Z});async function A(){try{e.value=await z.get("/api/status"),s.value=null}catch(ce){s.value=ce.message}finally{t.value=!1}}let g=0,b=0,x=0,w=0;function _(ce,Z){const Q=new Set;return[...Z,...ce].filter(ae=>{const V=ae._hmac||JSON.stringify([ae.timestamp,ae.tool_name,ae.user_id,ae.result_summary,ae.error]);return Q.has(V)?!1:(Q.add(V),!0)})}async function T(){const ce=++g,Z=x;n.value=!0;try{const Q=await z.get("/api/audit?limit=10");if(ce!==g)return;const ae=Z===x?[]:a.value.filter(V=>(V._liveEpoch||0)>Z);a.value=_(Q,ae).slice(0,10),c.value=ae.length}catch{}ce===g&&(n.value=!1)}async function L(){const ce=++b,Z=w;l.value=!0;try{const Q=await z.get("/api/audit?error_only=1&limit=5");if(ce!==b)return;const ae=Z===w?[]:i.value.filter(V=>(V._liveErrorEpoch||0)>Z);i.value=_(Q,ae).slice(0,5),o.value=!1}catch{if(ce!==b)return;o.value=Z===w||i.value.length===0}ce===b&&(l.value=!1)}async function E(){try{const ce=await z.get("/api/knowledge");d.value=(Array.isArray(ce)?ce:[]).reduce((Z,Q)=>Z+(Q.chunks||0),0)}catch{d.value=null}}async function F(){try{const ce=await z.get("/api/agents");r.value=ce.filter(Z=>Z.status==="running")}catch{}}async function O(){u.value={...u.value,reload:!0};try{await z.post("/api/reload"),Se.success("Config reloaded")}catch(ce){Se.error(ce.message)}u.value={...u.value,reload:!1}}async function I(){if(!await is({title:"Clear all sessions",message:"Clear all conversation sessions? This cannot be undone.",confirmLabel:"Clear All",danger:!0}))return;u.value={...u.value,clearSessions:!0};const Z=e.value.session_count;e.value={...e.value,session_count:0};try{const Q=await z.post("/api/sessions/clear-all");Se.success(`Cleared ${Q.count} session${Q.count!==1?"s":""}`),await A()}catch(Q){e.value={...e.value,session_count:Z},Se.error(Q.message)}u.value={...u.value,clearSessions:!1}}async function H(){if(!await is({title:"Stop all loops",message:"Stop all running loops?",confirmLabel:"Stop Loops",danger:!0}))return;u.value={...u.value,stopLoops:!0};const Z=e.value.loop_count;e.value={...e.value,loop_count:0};try{const Q=await z.post("/api/loops/stop-all");Se.success(Q.result),await A()}catch(Q){e.value={...e.value,loop_count:Z},Se.error(Q.message)}u.value={...u.value,stopLoops:!1}}function te(){t.value=!0,s.value=null,A(),T(),L(),F()}let ee=null,N=null,M=null;function $(ce){if(ce.payload&&ce.payload.tool_name){x+=1;const Z={...ce.payload,_isNew:!0,_key:++p,_liveEpoch:x};a.value.unshift(Z),a.value.length>10&&a.value.pop(),c.value++,Z.error&&(w+=1,Z._liveErrorEpoch=w,o.value=!1,i.value.unshift(Z),i.value.length>5&&i.value.pop()),setTimeout(()=>{Z._isNew=!1},1500),clearTimeout(M),M=setTimeout(()=>{c.value=0},1e4)}}let re=null;return st(async()=>{await Promise.all([A(),T(),L(),F(),E()]),ee=setInterval(A,15e3),N=setInterval(F,1e4),mt.subscribe("events",$),re=mt.onReconnected(()=>{T(),L()})}),wt(()=>{ee&&clearInterval(ee),N&&clearInterval(N),clearTimeout(M),mt.unsubscribe("events",$),re&&(re(),re=null)}),{status:e,loading:t,error:s,uptime:m,uptimeRingOffset:h,stats:y,healthIndicators:k,activity:a,activityLoading:n,newEventCount:c,errors:i,errorsLoading:l,errorsError:o,agents:r,actionLoading:u,fetchActivity:T,fetchErrors:L,fetchStatus:A,onEvent:$,formatTime:w1,formatDuration:Xi,retry:te,reloadConfig:O,clearSessions:I,stopAllLoops:H}}};/*! @license DOMPurify 3.4.9 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.9/LICENSE */function lm(e,t){(t==null||t>e.length)&&(t=e.length);for(var s=0,a=Array(t);s2?a-2:0),i=2;i1?s-1:0),n=1;n"u"?null:ts(BigInt.prototype.toString),pm=typeof Symbol>"u"?null:ts(Symbol.prototype.toString),jt=ts(Object.prototype.hasOwnProperty),kl=ts(Object.prototype.toString),us=ts(RegExp.prototype.test),qn=MT(TypeError);function ts(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);for(var s=arguments.length,a=new Array(s>1?s-1:0),n=1;n2&&arguments[2]!==void 0?arguments[2]:Il;if(om&&om(e,null),!Os(t))return e;let a=t.length;for(;a--;){let n=t[a];if(typeof n=="string"){const i=s(n);i!==n&&(TT(t)||(t[a]=i),n=i)}e[n]=!0}return e}function DT(e){for(let t=0;t/g),HT=da(/\${[\w\W]*/g),jT=da(/^data-[\-\w.\u00B7-\uFFFF]+$/),qT=da(/^aria-[\-\w]+$/),gm=da(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),VT=da(/^(?:\w+script|data):/i),GT=da(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),WT=da(/^html$/i),KT=da(/^[a-z][.\w]*(-[.\w]+)+$/i),Ca={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},JT=function(){return typeof window>"u"?null:window},ZT=function(t,s){if(typeof t!="object"||typeof t.createPolicy!="function")return null;let a=null;const n="data-tt-policy-suffix";s&&s.hasAttribute(n)&&(a=s.getAttribute(n));const i="dompurify"+(a?"#"+a:"");try{return t.createPolicy(i,{createHTML(l){return l},createScriptURL(l){return l}})}catch{return console.warn("TrustedTypes policy "+i+" could not be created."),null}},bm=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}};function Qg(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:JT();const t=Ie=>Qg(Ie);if(t.version="3.4.9",t.removed=[],!e||!e.document||e.document.nodeType!==Ca.document||!e.Element)return t.isSupported=!1,t;let s=e.document;const a=s,n=a.currentScript;e.DocumentFragment;const i=e.HTMLTemplateElement,l=e.Node,o=e.Element,r=e.NodeFilter,c=e.NamedNodeMap;c===void 0&&(e.NamedNodeMap||e.MozNamedAttrMap),e.HTMLFormElement;const d=e.DOMParser,u=e.trustedTypes,p=o.prototype,m=Ea(p,"cloneNode"),h=Ea(p,"remove"),y=Ea(p,"nextSibling"),k=Ea(p,"childNodes"),A=Ea(p,"parentNode"),g=Ea(p,"shadowRoot"),b=Ea(p,"attributes"),x=l&&l.prototype?Ea(l.prototype,"nodeType"):null,w=l&&l.prototype?Ea(l.prototype,"nodeName"):null;if(typeof i=="function"){const Ie=s.createElement("template");Ie.content&&Ie.content.ownerDocument&&(s=Ie.content.ownerDocument)}let _,T="",L,E=!1,F=0;const O=function(){if(F>0)throw qn('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},I=function(D){O(),F++;try{return _.createHTML(D)}finally{F--}},H=function(D){O(),F++;try{return _.createScriptURL(D)}finally{F--}},te=function(){return E||(L=ZT(u,n),E=!0),L},ee=s,N=ee.implementation,M=ee.createNodeIterator,$=ee.createDocumentFragment,re=ee.getElementsByTagName,ce=a.importNode;let Z=bm();t.isSupported=typeof Jg=="function"&&typeof A=="function"&&N&&N.createHTMLDocument!==void 0;const Q=UT,ae=zT,V=HT,be=jT,Oe=qT,le=VT,oe=GT,ve=KT;let j=gm,Y=null;const xe=nt({},[...fm,...ed,...td,...sd,...mm]);let S=null;const P=nt({},[...vm,...ad,...hm,...Yo]);let G=Object.seal(Ai(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),he=null,fe=null;const me=Object.seal(Ai(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let we=!0,J=!0,ie=!1,X=!0,_e=!1,U=!0,de=!1,ge=!1,Ae=!1,$e=!1,We=!1,je=!1,Xe=!0,it=!1;const Qe="user-content-";let se=!0,Re=!1,Le={},Te=null;const W=nt({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let De=null;const ze=nt({},["audio","video","img","source","image","track"]);let Ye=null;const Dt=nt({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",Rt="http://www.w3.org/2000/svg",Pt="http://www.w3.org/1999/xhtml";let Ts=Pt,Vs=!1,Gs=null;const zt=nt({},[et,Rt,Pt],Xc);let ta=nt({},["mi","mo","mn","ms","mtext"]),Ws=nt({},["annotation-xml"]);const Pa=nt({},["title","style","font","a","script"]);let hs=null;const $a=["application/xhtml+xml","text/html"],Es="text/html";let Ct=null,ls=null;const sa=s.createElement("form"),ba=function(D){return D instanceof RegExp||D instanceof Function},Fe=function(){let D=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(ls&&ls===D)return;(!D||typeof D!="object")&&(D={}),D=xs(D),hs=$a.indexOf(D.PARSER_MEDIA_TYPE)===-1?Es:D.PARSER_MEDIA_TYPE,Ct=hs==="application/xhtml+xml"?Xc:Il,Y=jt(D,"ALLOWED_TAGS")&&Os(D.ALLOWED_TAGS)?nt({},D.ALLOWED_TAGS,Ct):xe,S=jt(D,"ALLOWED_ATTR")&&Os(D.ALLOWED_ATTR)?nt({},D.ALLOWED_ATTR,Ct):P,Gs=jt(D,"ALLOWED_NAMESPACES")&&Os(D.ALLOWED_NAMESPACES)?nt({},D.ALLOWED_NAMESPACES,Xc):zt,Ye=jt(D,"ADD_URI_SAFE_ATTR")&&Os(D.ADD_URI_SAFE_ATTR)?nt(xs(Dt),D.ADD_URI_SAFE_ATTR,Ct):Dt,De=jt(D,"ADD_DATA_URI_TAGS")&&Os(D.ADD_DATA_URI_TAGS)?nt(xs(ze),D.ADD_DATA_URI_TAGS,Ct):ze,Te=jt(D,"FORBID_CONTENTS")&&Os(D.FORBID_CONTENTS)?nt({},D.FORBID_CONTENTS,Ct):W,he=jt(D,"FORBID_TAGS")&&Os(D.FORBID_TAGS)?nt({},D.FORBID_TAGS,Ct):xs({}),fe=jt(D,"FORBID_ATTR")&&Os(D.FORBID_ATTR)?nt({},D.FORBID_ATTR,Ct):xs({}),Le=jt(D,"USE_PROFILES")?D.USE_PROFILES&&typeof D.USE_PROFILES=="object"?xs(D.USE_PROFILES):D.USE_PROFILES:!1,we=D.ALLOW_ARIA_ATTR!==!1,J=D.ALLOW_DATA_ATTR!==!1,ie=D.ALLOW_UNKNOWN_PROTOCOLS||!1,X=D.ALLOW_SELF_CLOSE_IN_ATTR!==!1,_e=D.SAFE_FOR_TEMPLATES||!1,U=D.SAFE_FOR_XML!==!1,de=D.WHOLE_DOCUMENT||!1,$e=D.RETURN_DOM||!1,We=D.RETURN_DOM_FRAGMENT||!1,je=D.RETURN_TRUSTED_TYPE||!1,Ae=D.FORCE_BODY||!1,Xe=D.SANITIZE_DOM!==!1,it=D.SANITIZE_NAMED_PROPS||!1,se=D.KEEP_CONTENT!==!1,Re=D.IN_PLACE||!1,j=$T(D.ALLOWED_URI_REGEXP)?D.ALLOWED_URI_REGEXP:gm,Ts=typeof D.NAMESPACE=="string"?D.NAMESPACE:Pt,ta=jt(D,"MATHML_TEXT_INTEGRATION_POINTS")&&D.MATHML_TEXT_INTEGRATION_POINTS&&typeof D.MATHML_TEXT_INTEGRATION_POINTS=="object"?xs(D.MATHML_TEXT_INTEGRATION_POINTS):nt({},["mi","mo","mn","ms","mtext"]),Ws=jt(D,"HTML_INTEGRATION_POINTS")&&D.HTML_INTEGRATION_POINTS&&typeof D.HTML_INTEGRATION_POINTS=="object"?xs(D.HTML_INTEGRATION_POINTS):nt({},["annotation-xml"]);const pe=jt(D,"CUSTOM_ELEMENT_HANDLING")&&D.CUSTOM_ELEMENT_HANDLING&&typeof D.CUSTOM_ELEMENT_HANDLING=="object"?xs(D.CUSTOM_ELEMENT_HANDLING):Ai(null);if(G=Ai(null),jt(pe,"tagNameCheck")&&ba(pe.tagNameCheck)&&(G.tagNameCheck=pe.tagNameCheck),jt(pe,"attributeNameCheck")&&ba(pe.attributeNameCheck)&&(G.attributeNameCheck=pe.attributeNameCheck),jt(pe,"allowCustomizedBuiltInElements")&&typeof pe.allowCustomizedBuiltInElements=="boolean"&&(G.allowCustomizedBuiltInElements=pe.allowCustomizedBuiltInElements),_e&&(J=!1),We&&($e=!0),Le&&(Y=nt({},mm),S=Ai(null),Le.html===!0&&(nt(Y,fm),nt(S,vm)),Le.svg===!0&&(nt(Y,ed),nt(S,ad),nt(S,Yo)),Le.svgFilters===!0&&(nt(Y,td),nt(S,ad),nt(S,Yo)),Le.mathMl===!0&&(nt(Y,sd),nt(S,hm),nt(S,Yo))),me.tagCheck=null,me.attributeCheck=null,jt(D,"ADD_TAGS")&&(typeof D.ADD_TAGS=="function"?me.tagCheck=D.ADD_TAGS:Os(D.ADD_TAGS)&&(Y===xe&&(Y=xs(Y)),nt(Y,D.ADD_TAGS,Ct))),jt(D,"ADD_ATTR")&&(typeof D.ADD_ATTR=="function"?me.attributeCheck=D.ADD_ATTR:Os(D.ADD_ATTR)&&(S===P&&(S=xs(S)),nt(S,D.ADD_ATTR,Ct))),jt(D,"ADD_URI_SAFE_ATTR")&&Os(D.ADD_URI_SAFE_ATTR)&&nt(Ye,D.ADD_URI_SAFE_ATTR,Ct),jt(D,"FORBID_CONTENTS")&&Os(D.FORBID_CONTENTS)&&(Te===W&&(Te=xs(Te)),nt(Te,D.FORBID_CONTENTS,Ct)),jt(D,"ADD_FORBID_CONTENTS")&&Os(D.ADD_FORBID_CONTENTS)&&(Te===W&&(Te=xs(Te)),nt(Te,D.ADD_FORBID_CONTENTS,Ct)),se&&(Y["#text"]=!0),de&&nt(Y,["html","head","body"]),Y.table&&(nt(Y,["tbody"]),delete he.tbody),D.TRUSTED_TYPES_POLICY){if(typeof D.TRUSTED_TYPES_POLICY.createHTML!="function")throw qn('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof D.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw qn('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const ke=_;_=D.TRUSTED_TYPES_POLICY;try{T=I("")}catch(Be){throw _=ke,Be}}else D.TRUSTED_TYPES_POLICY===null?(_=void 0,T=""):(_===void 0&&(_=te()),_&&typeof T=="string"&&(T=I("")));(Z.uponSanitizeElement.length>0||Z.uponSanitizeAttribute.length>0)&&Y===xe&&(Y=xs(Y)),Z.uponSanitizeAttribute.length>0&&S===P&&(S=xs(S)),Ps&&Ps(D),ls=D},ue=nt({},[...ed,...td,...FT]),Ce=nt({},[...sd,...BT]),qe=function(D){let pe=A(D);(!pe||!pe.tagName)&&(pe={namespaceURI:Ts,tagName:"template"});const ke=Il(D.tagName),Be=Il(pe.tagName);return Gs[D.namespaceURI]?D.namespaceURI===Rt?pe.namespaceURI===Pt?ke==="svg":pe.namespaceURI===et?ke==="svg"&&(Be==="annotation-xml"||ta[Be]):!!ue[ke]:D.namespaceURI===et?pe.namespaceURI===Pt?ke==="math":pe.namespaceURI===Rt?ke==="math"&&Ws[Be]:!!Ce[ke]:D.namespaceURI===Pt?pe.namespaceURI===Rt&&!Ws[Be]||pe.namespaceURI===et&&!ta[Be]?!1:!Ce[ke]&&(Pa[ke]||!ue[ke]):!!(hs==="application/xhtml+xml"&&Gs[D.namespaceURI]):!1},Ge=function(D){ki(t.removed,{element:D});try{A(D).removeChild(D)}catch{if(h(D),!A(D))throw qn("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},It=function(D){const pe=k?k(D):D.childNodes;if(pe){const Be=[];qa(pe,Ue=>{ki(Be,Ue)}),qa(Be,Ue=>{try{h(Ue)}catch{}})}const ke=b?b(D):null;if(ke)for(let Be=ke.length-1;Be>=0;--Be){const Ue=ke[Be],Ke=Ue&&Ue.name;if(typeof Ke=="string")try{D.removeAttribute(Ke)}catch{}}},ot=function(D,pe){try{ki(t.removed,{attribute:pe.getAttributeNode(D),from:pe})}catch{ki(t.removed,{attribute:null,from:pe})}if(pe.removeAttribute(D),D==="is")if($e||We)try{Ge(pe)}catch{}else try{pe.setAttribute(D,"")}catch{}},pn=function(D){const pe=b?b(D):D.attributes;if(pe)for(let ke=pe.length-1;ke>=0;--ke){const Be=pe[ke],Ue=Be&&Be.name;if(!(typeof Ue!="string"||S[Ct(Ue)]))try{D.removeAttribute(Ue)}catch{}}},pi=function(D){const pe=[D];for(;pe.length>0;){const ke=pe.pop();(x?x(ke):ke.nodeType)===Ca.element&&pn(ke);const Ue=k?k(ke):ke.childNodes;if(Ue)for(let Ke=Ue.length-1;Ke>=0;--Ke)pe.push(Ue[Ke])}},Fa=function(D){let pe=null,ke=null;if(Ae)D=""+D;else{const Ke=cm(D,/^[\r\n\t ]+/);ke=Ke&&Ke[0]}hs==="application/xhtml+xml"&&Ts===Pt&&(D=''+D+"");const Be=_?I(D):D;if(Ts===Pt)try{pe=new d().parseFromString(Be,hs)}catch{}if(!pe||!pe.documentElement){pe=N.createDocument(Ts,"template",null);try{pe.documentElement.innerHTML=Vs?T:Be}catch{}}const Ue=pe.body||pe.documentElement;return D&&ke&&Ue.insertBefore(s.createTextNode(ke),Ue.childNodes[0]||null),Ts===Pt?re.call(pe,de?"html":"body")[0]:de?pe.documentElement:Ue},fi=function(D){return M.call(D.ownerDocument||D,D,r.SHOW_ELEMENT|r.SHOW_COMMENT|r.SHOW_TEXT|r.SHOW_PROCESSING_INSTRUCTION|r.SHOW_CDATA_SECTION,null)},Ba=function(D){var pe,ke;D.normalize();const Be=M.call(D.ownerDocument||D,D,r.SHOW_TEXT|r.SHOW_COMMENT|r.SHOW_CDATA_SECTION|r.SHOW_PROCESSING_INSTRUCTION,null);let Ue=Be.nextNode();for(;Ue;){let $t=Ue.data;qa([Q,ae,V],gt=>{$t=Si($t,gt," ")}),Ue.data=$t,Ue=Be.nextNode()}const Ke=(pe=(ke=D.querySelectorAll)===null||ke===void 0?void 0:ke.call(D,"template"))!==null&&pe!==void 0?pe:[];qa(Array.from(Ke),$t=>{kt($t.content)&&Ba($t.content)})},fn=function(D){const pe=w?w(D):null;return typeof pe!="string"||Ct(pe)!=="form"?!1:typeof D.nodeName!="string"||typeof D.textContent!="string"||typeof D.removeChild!="function"||D.attributes!==b(D)||typeof D.removeAttribute!="function"||typeof D.setAttribute!="function"||typeof D.namespaceURI!="string"||typeof D.insertBefore!="function"||typeof D.hasChildNodes!="function"||D.nodeType!==x(D)||D.childNodes!==k(D)},kt=function(D){if(!x||typeof D!="object"||D===null)return!1;try{return x(D)===Ca.documentFragment}catch{return!1}},$s=function(D){if(!x||typeof D!="object"||D===null)return!1;try{return typeof x(D)=="number"}catch{return!1}};function Wt(Ie,D,pe){qa(Ie,ke=>{ke.call(t,D,pe,ls)})}const ya=function(D){let pe=null;if(Wt(Z.beforeSanitizeElements,D,null),fn(D))return Ge(D),!0;const ke=Ct(w?w(D):D.nodeName);if(Wt(Z.uponSanitizeElement,D,{tagName:ke,allowedTags:Y}),U&&D.hasChildNodes()&&!$s(D.firstElementChild)&&us(/<[/\w!]/g,D.innerHTML)&&us(/<[/\w!]/g,D.textContent)||U&&D.namespaceURI===Pt&&ke==="style"&&$s(D.firstElementChild)||D.nodeType===Ca.progressingInstruction||U&&D.nodeType===Ca.comment&&us(/<[/\w]/g,D.data))return Ge(D),!0;if(he[ke]||!(me.tagCheck instanceof Function&&me.tagCheck(ke))&&!Y[ke]){if(!he[ke]&&mn(ke)&&(G.tagNameCheck instanceof RegExp&&us(G.tagNameCheck,ke)||G.tagNameCheck instanceof Function&&G.tagNameCheck(ke)))return!1;if(se&&!Te[ke]){const Ue=A(D),Ke=k(D);if(Ke&&Ue){const $t=Ke.length;for(let gt=$t-1;gt>=0;--gt){const Ot=Re?Ke[gt]:m(Ke[gt],!0);Ue.insertBefore(Ot,y(D))}}}return Ge(D),!0}return(x?x(D):D.nodeType)===Ca.element&&!qe(D)||(ke==="noscript"||ke==="noembed"||ke==="noframes")&&us(/<\/no(script|embed|frames)/i,D.innerHTML)?(Ge(D),!0):(_e&&D.nodeType===Ca.text&&(pe=D.textContent,qa([Q,ae,V],Ue=>{pe=Si(pe,Ue," ")}),D.textContent!==pe&&(ki(t.removed,{element:D.cloneNode()}),D.textContent=pe)),Wt(Z.afterSanitizeElements,D,null),!1)},xa=function(D,pe,ke){if(fe[pe]||Xe&&(pe==="id"||pe==="name")&&(ke in s||ke in sa))return!1;const Be=S[pe]||me.attributeCheck instanceof Function&&me.attributeCheck(pe,D);if(!(J&&!fe[pe]&&us(be,pe))){if(!(we&&us(Oe,pe))){if(!Be||fe[pe]){if(!(mn(D)&&(G.tagNameCheck instanceof RegExp&&us(G.tagNameCheck,D)||G.tagNameCheck instanceof Function&&G.tagNameCheck(D))&&(G.attributeNameCheck instanceof RegExp&&us(G.attributeNameCheck,pe)||G.attributeNameCheck instanceof Function&&G.attributeNameCheck(pe,D))||pe==="is"&&G.allowCustomizedBuiltInElements&&(G.tagNameCheck instanceof RegExp&&us(G.tagNameCheck,ke)||G.tagNameCheck instanceof Function&&G.tagNameCheck(ke))))return!1}else if(!Ye[pe]){if(!us(j,Si(ke,oe,""))){if(!((pe==="src"||pe==="xlink:href"||pe==="href")&&D!=="script"&&dm(ke,"data:")===0&&De[D])){if(!(ie&&!us(le,Si(ke,oe,"")))){if(ke)return!1}}}}}}return!0},Pn=nt({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),mn=function(D){return!Pn[Il(D)]&&us(ve,D)},$n=function(D){Wt(Z.beforeSanitizeAttributes,D,null);const pe=D.attributes;if(!pe||fn(D))return;const ke={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:S,forceKeepAttr:void 0};let Be=pe.length;for(;Be--;){const Ue=pe[Be],Ke=Ue.name,$t=Ue.namespaceURI,gt=Ue.value,Ot=Ct(Ke),ds=gt;let ut=Ke==="value"?ds:OT(ds);if(ke.attrName=Ot,ke.attrValue=ut,ke.keepAttr=!0,ke.forceKeepAttr=void 0,Wt(Z.uponSanitizeAttribute,D,ke),ut=ke.attrValue,it&&(Ot==="id"||Ot==="name")&&dm(ut,Qe)!==0&&(ot(Ke,D),ut=Qe+ut),U&&us(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,ut)){ot(Ke,D);continue}if(Ot==="attributename"&&cm(ut,"href")){ot(Ke,D);continue}if(ke.forceKeepAttr)continue;if(!ke.keepAttr){ot(Ke,D);continue}if(!X&&us(/\/>/i,ut)){ot(Ke,D);continue}_e&&qa([Q,ae,V],hn=>{ut=Si(ut,hn," ")});const ua=Ct(D.nodeName);if(!xa(ua,Ot,ut)){ot(Ke,D);continue}if(_&&typeof u=="object"&&typeof u.getAttributeType=="function"&&!$t)switch(u.getAttributeType(ua,Ot)){case"TrustedHTML":{ut=I(ut);break}case"TrustedScriptURL":{ut=H(ut);break}}if(ut!==ds)try{$t?D.setAttributeNS($t,Ke,ut):D.setAttribute(Ke,ut),fn(D)?Ge(D):rm(t.removed)}catch{ot(Ke,D)}}Wt(Z.afterSanitizeAttributes,D,null)},Ua=function(D){let pe=null;const ke=fi(D);for(Wt(Z.beforeSanitizeShadowDOM,D,null);pe=ke.nextNode();)if(Wt(Z.uponSanitizeShadowNode,pe,null),ya(pe),$n(pe),kt(pe.content)&&Ua(pe.content),(x?x(pe):pe.nodeType)===Ca.element){const Ue=g?g(pe):pe.shadowRoot;kt(Ue)&&(vn(Ue),Ua(Ue))}Wt(Z.afterSanitizeShadowDOM,D,null)},vn=function(D){const pe=[{node:D,shadow:null}];for(;pe.length>0;){const ke=pe.pop();if(ke.shadow){Ua(ke.shadow);continue}const Be=ke.node,Ke=(x?x(Be):Be.nodeType)===Ca.element,$t=k?k(Be):Be.childNodes;if($t)for(let gt=$t.length-1;gt>=0;--gt)pe.push({node:$t[gt],shadow:null});if(Ke){const gt=w?w(Be):null;if(typeof gt=="string"&&Ct(gt)==="template"){const Ot=Be.content;kt(Ot)&&pe.push({node:Ot,shadow:null})}}if(Ke){const gt=g?g(Be):Be.shadowRoot;kt(gt)&&pe.push({node:null,shadow:gt},{node:gt,shadow:null})}}};return t.sanitize=function(Ie){let D=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},pe=null,ke=null,Be=null,Ue=null;if(Vs=!Ie,Vs&&(Ie=""),typeof Ie!="string"&&!$s(Ie)&&(Ie=PT(Ie),typeof Ie!="string"))throw qn("dirty is not a string, aborting");if(!t.isSupported)return Ie;ge||Fe(D),t.removed=[];const Ke=Re&&typeof Ie!="string"&&$s(Ie);if(Ke){const Ot=w?w(Ie):Ie.nodeName;if(typeof Ot=="string"){const ds=Ct(Ot);if(!Y[ds]||he[ds])throw qn("root node is forbidden and cannot be sanitized in-place")}if(fn(Ie))throw qn("root node is clobbered and cannot be sanitized in-place");try{vn(Ie)}catch(ds){throw It(Ie),ds}}else if($s(Ie))pe=Fa(""),ke=pe.ownerDocument.importNode(Ie,!0),ke.nodeType===Ca.element&&ke.nodeName==="BODY"||ke.nodeName==="HTML"?pe=ke:pe.appendChild(ke),vn(ke);else{if(!$e&&!_e&&!de&&Ie.indexOf("<")===-1)return _&&je?I(Ie):Ie;if(pe=Fa(Ie),!pe)return $e?null:je?T:""}pe&&Ae&&Ge(pe.firstChild);const $t=fi(Ke?Ie:pe);try{for(;Be=$t.nextNode();)ya(Be),$n(Be),kt(Be.content)&&Ua(Be.content)}catch(Ot){throw Ke&&It(Ie),Ot}if(Ke)return qa(t.removed,Ot=>{Ot.element&&pi(Ot.element)}),_e&&Ba(Ie),Ie;if($e){if(_e&&Ba(pe),We)for(Ue=$.call(pe.ownerDocument);pe.firstChild;)Ue.appendChild(pe.firstChild);else Ue=pe;return(S.shadowroot||S.shadowrootmode)&&(Ue=ce.call(a,Ue,!0)),Ue}let gt=de?pe.outerHTML:pe.innerHTML;return de&&Y["!doctype"]&&pe.ownerDocument&&pe.ownerDocument.doctype&&pe.ownerDocument.doctype.name&&us(WT,pe.ownerDocument.doctype.name)&&(gt=" +`+gt),_e&&qa([Q,ae,V],Ot=>{gt=Si(gt,Ot," ")}),_&&je?I(gt):gt},t.setConfig=function(){let Ie=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};Fe(Ie),ge=!0},t.clearConfig=function(){ls=null,ge=!1,_=L,T=""},t.isValidAttribute=function(Ie,D,pe){ls||Fe({});const ke=Ct(Ie),Be=Ct(D);return xa(ke,Be,pe)},t.addHook=function(Ie,D){typeof D=="function"&&ki(Z[Ie],D)},t.removeHook=function(Ie,D){if(D!==void 0){const pe=RT(Z[Ie],D);return pe===-1?void 0:IT(Z[Ie],pe,1)[0]}return rm(Z[Ie])},t.removeHooks=function(Ie){Z[Ie]=[]},t.removeAllHooks=function(){Z=bm()},t}var ym=Qg();function Ku(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var ui=Ku();function Yg(e){ui=e}var Bl={exec:()=>null};function _t(e,t=""){let s=typeof e=="string"?e:e.source;const a={replace:(n,i)=>{let l=typeof i=="string"?i:i.source;return l=l.replace(Ms.caret,"$1"),s=s.replace(n,l),a},getRegex:()=>new RegExp(s,t)};return a}var Ms={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},QT=/^(?:[ \t]*(?:\n|$))+/,YT=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,XT=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,xo=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,eE=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Ju=/(?:[*+-]|\d{1,9}[.)])/,Xg=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,eb=_t(Xg).replace(/bull/g,Ju).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),tE=_t(Xg).replace(/bull/g,Ju).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Zu=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,sE=/^[^\n]+/,Qu=/(?!\s*\])(?:\\.|[^\[\]\\])+/,aE=_t(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",Qu).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),nE=_t(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Ju).getRegex(),fc="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",Yu=/|$))/,iE=_t("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",Yu).replace("tag",fc).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),tb=_t(Zu).replace("hr",xo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",fc).getRegex(),lE=_t(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",tb).getRegex(),Xu={blockquote:lE,code:YT,def:aE,fences:XT,heading:eE,hr:xo,html:iE,lheading:eb,list:nE,newline:QT,paragraph:tb,table:Bl,text:sE},xm=_t("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",xo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",fc).getRegex(),oE={...Xu,lheading:tE,table:xm,paragraph:_t(Zu).replace("hr",xo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",xm).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",fc).getRegex()},rE={...Xu,html:_t(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",Yu).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:Bl,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:_t(Zu).replace("hr",xo).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",eb).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},cE=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,dE=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,sb=/^( {2,}|\\)\n(?!\s*$)/,uE=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ib=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,hE=_t(ib,"u").replace(/punct/g,mc).getRegex(),gE=_t(ib,"u").replace(/punct/g,nb).getRegex(),lb="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",bE=_t(lb,"gu").replace(/notPunctSpace/g,ab).replace(/punctSpace/g,ep).replace(/punct/g,mc).getRegex(),yE=_t(lb,"gu").replace(/notPunctSpace/g,mE).replace(/punctSpace/g,fE).replace(/punct/g,nb).getRegex(),xE=_t("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ab).replace(/punctSpace/g,ep).replace(/punct/g,mc).getRegex(),_E=_t(/\\(punct)/,"gu").replace(/punct/g,mc).getRegex(),wE=_t(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),kE=_t(Yu).replace("(?:-->|$)","-->").getRegex(),SE=_t("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",kE).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Pr=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,CE=_t(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",Pr).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ob=_t(/^!?\[(label)\]\[(ref)\]/).replace("label",Pr).replace("ref",Qu).getRegex(),rb=_t(/^!?\[(ref)\](?:\[\])?/).replace("ref",Qu).getRegex(),TE=_t("reflink|nolink(?!\\()","g").replace("reflink",ob).replace("nolink",rb).getRegex(),tp={_backpedal:Bl,anyPunctuation:_E,autolink:wE,blockSkip:vE,br:sb,code:dE,del:Bl,emStrongLDelim:hE,emStrongRDelimAst:bE,emStrongRDelimUnd:xE,escape:cE,link:CE,nolink:rb,punctuation:pE,reflink:ob,reflinkSearch:TE,tag:SE,text:uE,url:Bl},EE={...tp,link:_t(/^!?\[(label)\]\((.*?)\)/).replace("label",Pr).getRegex(),reflink:_t(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Pr).getRegex()},jd={...tp,emStrongRDelimAst:yE,emStrongLDelim:gE,url:_t(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},_m=e=>RE[e];function Aa(e,t){if(t){if(Ms.escapeTest.test(e))return e.replace(Ms.escapeReplace,_m)}else if(Ms.escapeTestNoEncode.test(e))return e.replace(Ms.escapeReplaceNoEncode,_m);return e}function wm(e){try{e=encodeURI(e).replace(Ms.percentDecode,"%")}catch{return null}return e}function km(e,t){var i;const s=e.replace(Ms.findPipe,(l,o,r)=>{let c=!1,d=o;for(;--d>=0&&r[d]==="\\";)c=!c;return c?"|":" |"}),a=s.split(Ms.splitPipe);let n=0;if(a[0].trim()||a.shift(),a.length>0&&!((i=a.at(-1))!=null&&i.trim())&&a.pop(),t)if(a.length>t)a.splice(t);else for(;a.length0?-2:-1}function Sm(e,t,s,a,n){const i=t.href,l=t.title||null,o=e[1].replace(n.other.outputLinkReplace,"$1");a.state.inLink=!0;const r={type:e[0].charAt(0)==="!"?"image":"link",raw:s,href:i,title:l,text:o,tokens:a.inlineTokens(o)};return a.state.inLink=!1,r}function OE(e,t,s){const a=e.match(s.other.indentCodeCompensation);if(a===null)return t;const n=a[1];return t.split(` +`).map(i=>{const l=i.match(s.other.beginningSpace);if(l===null)return i;const[o]=l;return o.length>=n.length?i.slice(n.length):i}).join(` +`)}var $r=class{constructor(e){Et(this,"options");Et(this,"rules");Et(this,"lexer");this.options=e||ui}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const s=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?s:Cl(s,` +`)}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const s=t[0],a=OE(s,t[3]||"",this.rules);return{type:"code",raw:s,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:a}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let s=t[2].trim();if(this.rules.other.endingHash.test(s)){const a=Cl(s,"#");(this.options.pedantic||!a||this.rules.other.endingSpaceChar.test(a))&&(s=a.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:s,tokens:this.lexer.inline(s)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:Cl(t[0],` +`)}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let s=Cl(t[0],` +`).split(` +`),a="",n="";const i=[];for(;s.length>0;){let l=!1;const o=[];let r;for(r=0;r1,n={type:"list",raw:"",ordered:a,start:a?+s.slice(0,-1):"",loose:!1,items:[]};s=a?`\\d{1,9}\\${s.slice(-1)}`:`\\${s}`,this.options.pedantic&&(s=a?s:"[*+-]");const i=this.rules.other.listItemRegex(s);let l=!1;for(;e;){let r=!1,c="",d="";if(!(t=i.exec(e))||this.rules.block.hr.test(e))break;c=t[0],e=e.substring(c.length);let u=t[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,A=>" ".repeat(3*A.length)),p=e.split(` +`,1)[0],m=!u.trim(),h=0;if(this.options.pedantic?(h=2,d=u.trimStart()):m?h=t[1].length+1:(h=t[2].search(this.rules.other.nonSpaceChar),h=h>4?1:h,d=u.slice(h),h+=t[1].length),m&&this.rules.other.blankLine.test(p)&&(c+=p+` +`,e=e.substring(p.length+1),r=!0),!r){const A=this.rules.other.nextBulletRegex(h),g=this.rules.other.hrRegex(h),b=this.rules.other.fencesBeginRegex(h),x=this.rules.other.headingBeginRegex(h),w=this.rules.other.htmlBeginRegex(h);for(;e;){const _=e.split(` +`,1)[0];let T;if(p=_,this.options.pedantic?(p=p.replace(this.rules.other.listReplaceNesting," "),T=p):T=p.replace(this.rules.other.tabCharGlobal," "),b.test(p)||x.test(p)||w.test(p)||A.test(p)||g.test(p))break;if(T.search(this.rules.other.nonSpaceChar)>=h||!p.trim())d+=` +`+T.slice(h);else{if(m||u.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||b.test(u)||x.test(u)||g.test(u))break;d+=` +`+p}!m&&!p.trim()&&(m=!0),c+=_+` +`,e=e.substring(_.length+1),u=T.slice(h)}}n.loose||(l?n.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(l=!0));let y=null,k;this.options.gfm&&(y=this.rules.other.listIsTask.exec(d),y&&(k=y[0]!=="[ ] ",d=d.replace(this.rules.other.listReplaceTask,""))),n.items.push({type:"list_item",raw:c,task:!!y,checked:k,loose:!1,text:d,tokens:[]}),n.raw+=c}const o=n.items.at(-1);if(o)o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd();else return;n.raw=n.raw.trimEnd();for(let r=0;ru.type==="space"),d=c.length>0&&c.some(u=>this.rules.other.anyLine.test(u.raw));n.loose=d}if(n.loose)for(let r=0;r({text:r,tokens:this.lexer.inline(r),header:!1,align:i.align[c]})));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const s=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:s,tokens:this.lexer.inline(s)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const s=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(s)){if(!this.rules.other.endAngleBracket.test(s))return;const i=Cl(s.slice(0,-1),"\\");if((s.length-i.length)%2===0)return}else{const i=IE(t[2],"()");if(i===-2)return;if(i>-1){const o=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,o).trim(),t[3]=""}}let a=t[2],n="";if(this.options.pedantic){const i=this.rules.other.pedanticHrefTitle.exec(a);i&&(a=i[1],n=i[3])}else n=t[3]?t[3].slice(1,-1):"";return a=a.trim(),this.rules.other.startAngleBracket.test(a)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(s)?a=a.slice(1):a=a.slice(1,-1)),Sm(t,{href:a&&a.replace(this.rules.inline.anyPunctuation,"$1"),title:n&&n.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let s;if((s=this.rules.inline.reflink.exec(e))||(s=this.rules.inline.nolink.exec(e))){const a=(s[2]||s[1]).replace(this.rules.other.multipleSpaceGlobal," "),n=t[a.toLowerCase()];if(!n){const i=s[0].charAt(0);return{type:"text",raw:i,text:i}}return Sm(s,n,s[0],this.lexer,this.rules)}}emStrong(e,t,s=""){let a=this.rules.inline.emStrongLDelim.exec(e);if(!a||a[3]&&s.match(this.rules.other.unicodeAlphaNumeric))return;if(!(a[1]||a[2]||"")||!s||this.rules.inline.punctuation.exec(s)){const i=[...a[0]].length-1;let l,o,r=i,c=0;const d=a[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(d.lastIndex=0,t=t.slice(-1*e.length+i);(a=d.exec(t))!=null;){if(l=a[1]||a[2]||a[3]||a[4]||a[5]||a[6],!l)continue;if(o=[...l].length,a[3]||a[4]){r+=o;continue}else if((a[5]||a[6])&&i%3&&!((i+o)%3)){c+=o;continue}if(r-=o,r>0)continue;o=Math.min(o,o+r+c);const u=[...a[0]][0].length,p=e.slice(0,i+a.index+u+o);if(Math.min(i,o)%2){const h=p.slice(1,-1);return{type:"em",raw:p,text:h,tokens:this.lexer.inlineTokens(h)}}const m=p.slice(2,-2);return{type:"strong",raw:p,text:m,tokens:this.lexer.inlineTokens(m)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let s=t[2].replace(this.rules.other.newLineCharGlobal," ");const a=this.rules.other.nonSpaceChar.test(s),n=this.rules.other.startingSpaceChar.test(s)&&this.rules.other.endingSpaceChar.test(s);return a&&n&&(s=s.substring(1,s.length-1)),{type:"codespan",raw:t[0],text:s}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let s,a;return t[2]==="@"?(s=t[1],a="mailto:"+s):(s=t[1],a=s),{type:"link",raw:t[0],text:s,href:a,tokens:[{type:"text",raw:s,text:s}]}}}url(e){var s;let t;if(t=this.rules.inline.url.exec(e)){let a,n;if(t[2]==="@")a=t[0],n="mailto:"+a;else{let i;do i=t[0],t[0]=((s=this.rules.inline._backpedal.exec(t[0]))==null?void 0:s[0])??"";while(i!==t[0]);a=t[0],t[1]==="www."?n="http://"+t[0]:n=t[0]}return{type:"link",raw:t[0],text:a,href:n,tokens:[{type:"text",raw:a,text:a}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const s=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:s}}}},Ya=class qd{constructor(t){Et(this,"tokens");Et(this,"options");Et(this,"state");Et(this,"tokenizer");Et(this,"inlineQueue");this.tokens=[],this.tokens.links=Object.create(null),this.options=t||ui,this.options.tokenizer=this.options.tokenizer||new $r,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const s={other:Ms,block:Xo.normal,inline:Sl.normal};this.options.pedantic?(s.block=Xo.pedantic,s.inline=Sl.pedantic):this.options.gfm&&(s.block=Xo.gfm,this.options.breaks?s.inline=Sl.breaks:s.inline=Sl.gfm),this.tokenizer.rules=s}static get rules(){return{block:Xo,inline:Sl}}static lex(t,s){return new qd(s).lex(t)}static lexInline(t,s){return new qd(s).inlineTokens(t)}lex(t){t=t.replace(Ms.carriageReturn,` +`),this.blockTokens(t,this.tokens);for(let s=0;s(o=c.call({lexer:this},t,s))?(t=t.substring(o.raw.length),s.push(o),!0):!1))continue;if(o=this.tokenizer.space(t)){t=t.substring(o.raw.length);const c=s.at(-1);o.raw.length===1&&c!==void 0?c.raw+=` +`:s.push(o);continue}if(o=this.tokenizer.code(t)){t=t.substring(o.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="paragraph"||(c==null?void 0:c.type)==="text"?(c.raw+=` +`+o.raw,c.text+=` +`+o.text,this.inlineQueue.at(-1).src=c.text):s.push(o);continue}if(o=this.tokenizer.fences(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.heading(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.hr(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.blockquote(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.list(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.html(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.def(t)){t=t.substring(o.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="paragraph"||(c==null?void 0:c.type)==="text"?(c.raw+=` +`+o.raw,c.text+=` +`+o.raw,this.inlineQueue.at(-1).src=c.text):this.tokens.links[o.tag]||(this.tokens.links[o.tag]={href:o.href,title:o.title});continue}if(o=this.tokenizer.table(t)){t=t.substring(o.raw.length),s.push(o);continue}if(o=this.tokenizer.lheading(t)){t=t.substring(o.raw.length),s.push(o);continue}let r=t;if((l=this.options.extensions)!=null&&l.startBlock){let c=1/0;const d=t.slice(1);let u;this.options.extensions.startBlock.forEach(p=>{u=p.call({lexer:this},d),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(r=t.substring(0,c+1))}if(this.state.top&&(o=this.tokenizer.paragraph(r))){const c=s.at(-1);a&&(c==null?void 0:c.type)==="paragraph"?(c.raw+=` +`+o.raw,c.text+=` +`+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=c.text):s.push(o),a=r.length!==t.length,t=t.substring(o.raw.length);continue}if(o=this.tokenizer.text(t)){t=t.substring(o.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="text"?(c.raw+=` +`+o.raw,c.text+=` +`+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=c.text):s.push(o);continue}if(t){const c="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return this.state.top=!0,s}inline(t,s=[]){return this.inlineQueue.push({src:t,tokens:s}),s}inlineTokens(t,s=[]){var o,r,c;let a=t,n=null;if(this.tokens.links){const d=Object.keys(this.tokens.links);if(d.length>0)for(;(n=this.tokenizer.rules.inline.reflinkSearch.exec(a))!=null;)d.includes(n[0].slice(n[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,n.index)+"["+"a".repeat(n[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(n=this.tokenizer.rules.inline.anyPunctuation.exec(a))!=null;)a=a.slice(0,n.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(n=this.tokenizer.rules.inline.blockSkip.exec(a))!=null;)a=a.slice(0,n.index)+"["+"a".repeat(n[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,l="";for(;t;){i||(l=""),i=!1;let d;if((r=(o=this.options.extensions)==null?void 0:o.inline)!=null&&r.some(p=>(d=p.call({lexer:this},t,s))?(t=t.substring(d.raw.length),s.push(d),!0):!1))continue;if(d=this.tokenizer.escape(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.tag(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.link(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(d.raw.length);const p=s.at(-1);d.type==="text"&&(p==null?void 0:p.type)==="text"?(p.raw+=d.raw,p.text+=d.text):s.push(d);continue}if(d=this.tokenizer.emStrong(t,a,l)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.codespan(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.br(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.del(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.autolink(t)){t=t.substring(d.raw.length),s.push(d);continue}if(!this.state.inLink&&(d=this.tokenizer.url(t))){t=t.substring(d.raw.length),s.push(d);continue}let u=t;if((c=this.options.extensions)!=null&&c.startInline){let p=1/0;const m=t.slice(1);let h;this.options.extensions.startInline.forEach(y=>{h=y.call({lexer:this},m),typeof h=="number"&&h>=0&&(p=Math.min(p,h))}),p<1/0&&p>=0&&(u=t.substring(0,p+1))}if(d=this.tokenizer.inlineText(u)){t=t.substring(d.raw.length),d.raw.slice(-1)!=="_"&&(l=d.raw.slice(-1)),i=!0;const p=s.at(-1);(p==null?void 0:p.type)==="text"?(p.raw+=d.raw,p.text+=d.text):s.push(d);continue}if(t){const p="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(p);break}else throw new Error(p)}}return s}},Fr=class{constructor(e){Et(this,"options");Et(this,"parser");this.options=e||ui}space(e){return""}code({text:e,lang:t,escaped:s}){var i;const a=(i=(t||"").match(Ms.notSpaceStart))==null?void 0:i[0],n=e.replace(Ms.endingNewline,"")+` +`;return a?'
    '+(s?n:Aa(n,!0))+`
    +`:"
    "+(s?n:Aa(n,!0))+`
    +`}blockquote({tokens:e}){return`
    +${this.parser.parse(e)}
    +`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
    +`}list(e){const t=e.ordered,s=e.start;let a="";for(let l=0;l +`+a+" +`}listitem(e){var s;let t="";if(e.task){const a=this.checkbox({checked:!!e.checked});e.loose?((s=e.tokens[0])==null?void 0:s.type)==="paragraph"?(e.tokens[0].text=a+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=a+" "+Aa(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:a+" ",text:a+" ",escaped:!0}):t+=a+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • +`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",s="";for(let n=0;n${a}`),` + +`+t+` +`+a+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){const t=this.parser.parseInline(e.tokens),s=e.header?"th":"td";return(e.align?`<${s} align="${e.align}">`:`<${s}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${Aa(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:s}){const a=this.parser.parseInline(s),n=wm(e);if(n===null)return a;e=n;let i='
    ",i}image({href:e,title:t,text:s,tokens:a}){a&&(s=this.parser.parseInline(a,this.parser.textRenderer));const n=wm(e);if(n===null)return Aa(s);e=n;let i=`${s}{const r=l[o].flat(1/0);s=s.concat(this.walkTokens(r,t))}):l.tokens&&(s=s.concat(this.walkTokens(l.tokens,t)))}}return s}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(s=>{const a={...s};if(a.async=this.defaults.async||a.async||!1,s.extensions&&(s.extensions.forEach(n=>{if(!n.name)throw new Error("extension name required");if("renderer"in n){const i=t.renderers[n.name];i?t.renderers[n.name]=function(...l){let o=n.renderer.apply(this,l);return o===!1&&(o=i.apply(this,l)),o}:t.renderers[n.name]=n.renderer}if("tokenizer"in n){if(!n.level||n.level!=="block"&&n.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=t[n.level];i?i.unshift(n.tokenizer):t[n.level]=[n.tokenizer],n.start&&(n.level==="block"?t.startBlock?t.startBlock.push(n.start):t.startBlock=[n.start]:n.level==="inline"&&(t.startInline?t.startInline.push(n.start):t.startInline=[n.start]))}"childTokens"in n&&n.childTokens&&(t.childTokens[n.name]=n.childTokens)}),a.extensions=t),s.renderer){const n=this.defaults.renderer||new Fr(this.defaults);for(const i in s.renderer){if(!(i in n))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const l=i,o=s.renderer[l],r=n[l];n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d||""}}a.renderer=n}if(s.tokenizer){const n=this.defaults.tokenizer||new $r(this.defaults);for(const i in s.tokenizer){if(!(i in n))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const l=i,o=s.tokenizer[l],r=n[l];n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d}}a.tokenizer=n}if(s.hooks){const n=this.defaults.hooks||new or;for(const i in s.hooks){if(!(i in n))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const l=i,o=s.hooks[l],r=n[l];or.passThroughHooks.has(i)?n[l]=c=>{if(this.defaults.async)return Promise.resolve(o.call(n,c)).then(u=>r.call(n,u));const d=o.call(n,c);return r.call(n,d)}:n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d}}a.hooks=n}if(s.walkTokens){const n=this.defaults.walkTokens,i=s.walkTokens;a.walkTokens=function(l){let o=[];return o.push(i.call(this,l)),n&&(o=o.concat(n.call(this,l))),o}}this.defaults={...this.defaults,...a}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return Ya.lex(e,t??this.defaults)}parser(e,t){return Xa.parse(e,t??this.defaults)}parseMarkdown(e){return(s,a)=>{const n={...a},i={...this.defaults,...n},l=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&n.async===!1)return l(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof s>"u"||s===null)return l(new Error("marked(): input parameter is undefined or null"));if(typeof s!="string")return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(s)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=e);const o=i.hooks?i.hooks.provideLexer():e?Ya.lex:Ya.lexInline,r=i.hooks?i.hooks.provideParser():e?Xa.parse:Xa.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(s):s).then(c=>o(c,i)).then(c=>i.hooks?i.hooks.processAllTokens(c):c).then(c=>i.walkTokens?Promise.all(this.walkTokens(c,i.walkTokens)).then(()=>c):c).then(c=>r(c,i)).then(c=>i.hooks?i.hooks.postprocess(c):c).catch(l);try{i.hooks&&(s=i.hooks.preprocess(s));let c=o(s,i);i.hooks&&(c=i.hooks.processAllTokens(c)),i.walkTokens&&this.walkTokens(c,i.walkTokens);let d=r(c,i);return i.hooks&&(d=i.hooks.postprocess(d)),d}catch(c){return l(c)}}}onError(e,t){return s=>{if(s.message+=` +Please report this to https://github.com/markedjs/marked.`,e){const a="

    An error occurred:

    "+Aa(s.message+"",!0)+"
    ";return t?Promise.resolve(a):a}if(t)return Promise.reject(s);throw s}}},ii=new LE;function yt(e,t){return ii.parse(e,t)}yt.options=yt.setOptions=function(e){return ii.setOptions(e),yt.defaults=ii.defaults,Yg(yt.defaults),yt};yt.getDefaults=Ku;yt.defaults=ui;yt.use=function(...e){return ii.use(...e),yt.defaults=ii.defaults,Yg(yt.defaults),yt};yt.walkTokens=function(e,t){return ii.walkTokens(e,t)};yt.parseInline=ii.parseInline;yt.Parser=Xa;yt.parser=Xa.parse;yt.Renderer=Fr;yt.TextRenderer=sp;yt.Lexer=Ya;yt.lexer=Ya.lex;yt.Tokenizer=$r;yt.Hooks=or;yt.parse=yt;yt.options;yt.setOptions;yt.use;yt.walkTokens;yt.parseInline;Xa.parse;Ya.lex;const NE={breaks:!0,gfm:!0};function Cm(e){if(!e)return"";try{if(typeof yt<"u"&&yt.parse){const t=yt.parse(e,NE);return typeof ym<"u"?ym.sanitize(t):t}}catch{}return e.replace(/&/g,"&").replace(//g,">").replace(/\n/g,"
    ")}function ME(e){const t=new Date(e),s=t.getHours().toString().padStart(2,"0"),a=t.getMinutes().toString().padStart(2,"0");return`${s}:${a}`}const DE={run_command:"terminal",ssh_command:"terminal",run_script:"terminal",read_file:"file",apply_patch:"edit",list_directory:"folder",search_knowledge:"search",ingest_document:"book",generate_image:"image",analyze_image:"eye",analyze_pdf:"file",browser_screenshot:"globe",manage_process:"sliders"};function PE(e){return DE[e]||"wrench"}const $E=/https?:\/\/\S+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?\S*)?/gi;function Tm(e){if(!e)return[];const t=e.match($E);return t?[...new Set(t)]:[]}const FE={template:` +
    + + +
    + +
    +
    +
    + + + + + +
    +
    Odin is watching
    +
    Ask anything. Run commands. Manage infrastructure.
    +
    + +
    +
    +
    + + + + + +
    +
    + + + +
    +
    +
    + + {{ typingText }} +
    +
    +
    +
    + + +
    +
    + + + +
    +
    + Enter to send · Shift+Enter for newline + + + {{ wsStatus }} + +
    +
    +
    `,setup(){const e=f([]),t=f(""),s=f(!1),a=f(""),n=f(null),i=f(null),l=f(0),o=f("");let r=null,c=0;const d=["Check system health","List running services","Show disk usage","What can you do?"],u=B(()=>t.value.trim().length>0&&!s.value),p=f(mt.state||"disconnected");let m=null;const h=B(()=>{const N=p.value;return N==="connected"?"Connected":N==="reconnecting"?"Reconnecting…":N==="connecting"?"Connecting…":"REST fallback"}),y=["Watching across all realms...","Processing...","Consulting the bifrost...","Observing..."],k=B(()=>{const N=Math.floor(l.value/4)%y.length,M=l.value;return M>3?`${y[N]} (${M}s)`:y[0]});function A(){Ut(()=>{n.value&&(n.value.scrollTop=n.value.scrollHeight)})}function g(){if(!i.value)return;const N=i.value;N.style.height="auto",N.style.height=Math.min(N.scrollHeight,120)+"px"}function b(N,M,$={}){const re={id:++c,role:N,content:M,timestamp:Date.now(),html:N==="bot"?Cm(M):"",tools_used:$.tools_used||[],is_error:$.is_error||!1,images:N==="bot"?Tm(M):[],files:$.files||[],_showTools:!1};return e.value.push(re),A(),N==="bot"&&Ut(()=>x()),re}function x(){if(!n.value)return;n.value.querySelectorAll(".chat-markdown pre:not([data-copy])").forEach(M=>{M.setAttribute("data-copy","true"),M.style.position="relative";const $=document.createElement("button");$.className="chat-code-copy",$.textContent="Copy",$.addEventListener("click",()=>{const re=M.querySelector("code"),ce=re?re.textContent:M.textContent;navigator.clipboard.writeText(ce).then(()=>{$.textContent="Copied!",setTimeout(()=>{$.textContent="Copy"},1500)}).catch(()=>{})}),M.appendChild($)})}function w(N){if(N===0)return!0;const M=e.value[N-1],$=e.value[N],re=new Date(M.timestamp).toDateString(),ce=new Date($.timestamp).toDateString();return re!==ce}function _(N){const M=new Date(N),$=new Date;if(M.toDateString()===$.toDateString())return"Today";const re=new Date($);return re.setDate(re.getDate()-1),M.toDateString()===re.toDateString()?"Yesterday":M.toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}function T(N){t.value=N,Ut(()=>te())}function L(N){window.open(N,"_blank","noopener")}function E(N){N.target.style.display="none"}function F(){l.value=0,r=setInterval(()=>{l.value++},1e3)}function O(){r&&(clearInterval(r),r=null),l.value=0}function I(N){s.value&&(s.value=!1,O(),N.type==="chat_response"?b("bot",N.content,{tools_used:N.tools_used||[],is_error:N.is_error||!1,files:N.files||[]}):N.type==="chat_error"&&b("bot",N.error||"Unknown error",{is_error:!0}),Ut(()=>{var M;return(M=i.value)==null?void 0:M.focus()}))}async function H(N){try{const M=await z.post("/api/chat",{content:N,channel_id:o.value});b("bot",M.response,{tools_used:M.tools_used||[],is_error:M.is_error||!1,files:M.files||[]})}catch(M){b("bot",M.message||"Failed to send message",{is_error:!0})}}async function te(){const N=t.value.trim();if(!N||s.value)return;b("user",N),t.value="",s.value=!0,F(),i.value&&(i.value.style.height="auto"),mt.connected&&mt.sendChat(N,{channelId:o.value})||(await H(N),s.value=!1,O()),Ut(()=>{var $;return($=i.value)==null?void 0:$.focus()})}async function ee(){a.value="";try{if(!o.value){const M=await z.get("/api/auth/session");o.value=M.channel_id||M.user_id||"web-user"}const N=await z.get("/api/sessions/"+encodeURIComponent(o.value));if(N&&N.messages&&N.messages.length>0){for(const M of N.messages){const $=M.role==="user"?"user":"bot";let re=M.content||"";if($==="user"){const Z=re.match(/^\[.*?\]:\s*/);Z&&(re=re.slice(Z[0].length))}if(!re.trim())continue;const ce={id:++c,role:$,content:re,timestamp:M.timestamp?M.timestamp*1e3:Date.now(),html:$==="bot"?Cm(re):"",tools_used:[],is_error:!1,images:$==="bot"?Tm(re):[],files:[],_showTools:!1};e.value.push(ce)}Ut(()=>{A(),x()})}}catch(N){N&&N.status!==404&&(a.value="Couldn't load chat history — earlier messages may be missing. Refresh to retry.",Se.error(a.value))}}return st(()=>{mt.subscribe("chat",I),p.value=mt.state||"disconnected",m=mt.onState(N=>{p.value=N}),ee(),Ut(()=>{var N;return(N=i.value)==null?void 0:N.focus()})}),wt(()=>{mt.unsubscribe("chat",I),m&&(m(),m=null),O()}),{messages:e,input:t,sending:s,historyError:a,messagesEl:n,inputEl:i,canSend:u,wsStatus:h,typingText:k,suggestions:d,send:te,autoResize:g,formatTime:ME,formatDate:_,showDateSeparator:w,useSuggestion:T,openImage:L,onImageError:E,getToolIcon:PE,loadHistory:ee}}},BE={setup(){const e=f("odin"),t=f(""),s=f(""),a=f(""),n=f({}),i=f([]),l=f([]),o=f(!1),r=f(!1),c=f(null),d=f(!0),u=f(""),p=f(!1),m=f(!1),h=B(()=>e.value==="custom"),y=B(()=>[...i.value,...l.value]),k=B(()=>l.value.includes(e.value)),A=B(()=>{var L;return h.value?t.value||"Odin":((L=n.value[e.value])==null?void 0:L.name)||e.value}),g=B(()=>{var L;return h.value?s.value||"(empty — will use Odin default)":((L=n.value[e.value])==null?void 0:L.identity)||""}),b=B(()=>{var L;return h.value?a.value||"(empty — will use Odin default)":((L=n.value[e.value])==null?void 0:L.voice)||""});async function x(){d.value=!0;try{const L=await z.get("/api/personality");e.value=L.preset||"odin",t.value=L.custom_name||"",s.value=L.custom_identity||"",a.value=L.custom_voice||"",n.value=L.presets||{},i.value=L.builtin_presets||[],l.value=L.user_presets||[]}catch(L){c.value=L.message}finally{d.value=!1}}async function w(){o.value=!0,c.value=null,r.value=!1;try{await z.put("/api/personality",{preset:e.value,custom_name:t.value,custom_identity:s.value,custom_voice:a.value}),r.value=!0,setTimeout(()=>r.value=!1,3e3)}catch(L){c.value=L.message}finally{o.value=!1}}async function _(){const L=u.value.trim();if(L){m.value=!0,c.value=null;try{await z.post("/api/personality/presets",{name:L,display_name:A.value,identity:g.value,voice:b.value}),p.value=!1,u.value="",await x(),e.value=L.toLowerCase().replace(/ /g,"_")}catch(E){c.value=E.message}finally{m.value=!1}}}async function T(){if(await is({title:"Delete preset",message:`Delete preset "${e.value}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){c.value=null;try{await z.del(`/api/personality/presets/${encodeURIComponent(e.value)}`),await x(),e.value="odin"}catch(E){c.value=E.message}}}return st(x),{preset:e,customName:t,customIdentity:s,customVoice:a,presets:n,presetNames:y,isCustom:h,isUserPreset:k,previewName:A,previewIdentity:g,previewVoice:b,saving:o,saved:r,error:c,loading:d,save:w,showSavePreset:p,newPresetName:u,savingPreset:m,saveAsPreset:_,deletePreset:T,builtinPresets:i,userPresets:l}},template:` +
    +
    +

    Personality

    +

    Configure how Odin presents itself. Changes apply immediately — no restart needed.

    +
    + +
    + Loading... +
    + + +
    + `},UE={props:["onComplete"],template:` +
    + +
    `,setup(e){const t=f(""),s=f(""),a=f(!1),n=f(!1),i=f(""),l=f("Initialization pending."),o=f(!1),r=f(""),c=f(null),d=f({}),u=f("");let p=0,m=null;function h(){return p+=1,m==null||m.abort(),m=null,p}function y(w,_,T){return typeof z.postWithOptions=="function"?z.postWithOptions(w,_,{signal:T}):z.post(w,_)}async function k(){var T,L,E;a.value=!0,i.value="",l.value="Saving setup…";const w={},_=!!s.value.trim();t.value.trim()&&(w.web_api_token=t.value.trim()),s.value.trim()&&(w.discord_token=s.value.trim());try{const F=z.post("/api/setup/complete",w);t.value="",s.value="";const O=await F,I=((T=O.discord)==null?void 0:T.state)||O.discord_status;if(I==="failed"?(i.value=((L=O.discord)==null?void 0:L.error)||"Discord attachment failed. Setup was saved.",l.value="Saved. Discord attachment failed."):I==="connecting"?l.value="Saved. Connecting Discord…":I==="ready"?l.value="Saved. Discord ready.":_?l.value="Saved. Discord token stored; attachment status is pending.":l.value=O.message||"Setup saved. Ready to sign in.",(E=O.restart_required)!=null&&E.length){const H=O.message||`Restart Odin to apply: ${O.restart_required.join(", ")}`;l.value.includes(H)||(l.value+=` ${H}`)}n.value=!0}catch(F){i.value=F.message||"Setup could not be saved.",l.value="Initialization pending."}finally{a.value=!1}}async function A(){const w=h(),_=typeof AbortController=="function"?new AbortController:null;m=_,o.value=!0,u.value="";try{const T=await y("/api/codex/device-code",void 0,_==null?void 0:_.signal);if(w!==p)return;c.value=T,r.value="pending";const L=await y("/api/codex/device-poll",{device_auth_id:T.device_auth_id,user_code:T.user_code,interval:T.interval},_==null?void 0:_.signal);if(w!==p)return;d.value=L||{},r.value="ready"}catch(T){w===p&&(T==null?void 0:T.name)!=="AbortError"&&(u.value=T.message||"Device sign-in failed.",r.value="failed")}finally{w===p&&(o.value=!1,m=null)}}function g(){h(),o.value=!1,x()}function b(){var w;(w=e.onComplete)==null||w.call(e)}function x(){r.value="",c.value=null,d.value={},u.value=""}return wt(()=>{h()}),{webApiToken:t,discordToken:s,saving:a,completed:n,error:i,statusMessage:l,save:k,onComplete:b,deviceLoading:o,deviceState:r,deviceInfo:c,deviceResult:d,deviceError:u,startDeviceLogin:A,cancelDeviceLogin:g,clearDeviceState:x}}},Ht=(e,t)=>s=>({path:e,query:{...s.query,tab:t}}),cb=[{path:"/",redirect:"/dashboard"},{path:"/dashboard",component:xT,meta:{label:"Dashboard",icon:"dashboard",section:"Workspace",description:"System posture and recent activity"}},{path:"/chat",component:FE,meta:{label:"Chat",icon:"chat",section:"Workspace",description:"Direct operator conversation"}},{path:"/operations",component:$1,meta:{label:"Operations",icon:"operations",section:"Operate",description:"Execution, agents, loops, processes, and schedules"}},{path:"/history",component:K1,meta:{label:"History",icon:"history",section:"Observe",description:"Audit trail, sessions, traces, and usage"}},{path:"/capabilities",component:vC,meta:{label:"Capabilities",icon:"capabilities",section:"Manage",description:"Tools, skills, knowledge, and memory"}},{path:"/personality",component:BE,meta:{label:"Personality",icon:"personality",section:"Manage",description:"Behavior and response profile"}},{path:"/system",component:fT,meta:{label:"System",icon:"system",section:"Manage",description:"Health, configuration, access, and updates"}},{path:"/execution",redirect:Ht("/operations","live")},{path:"/agents",redirect:Ht("/operations","agents")},{path:"/loops",redirect:Ht("/operations","loops")},{path:"/processes",redirect:Ht("/operations","processes")},{path:"/schedules",redirect:Ht("/operations","schedules")},{path:"/audit",redirect:Ht("/history","audit")},{path:"/sessions",redirect:Ht("/history","sessions")},{path:"/traces",redirect:Ht("/history","traces")},{path:"/usage",redirect:Ht("/history","usage")},{path:"/tools",redirect:Ht("/capabilities","tools")},{path:"/skills",redirect:Ht("/capabilities","skills")},{path:"/mcp",redirect:Ht("/capabilities","mcp-servers")},{path:"/knowledge",redirect:Ht("/capabilities","knowledge")},{path:"/memory",redirect:Ht("/capabilities","memory")},{path:"/learned",redirect:Ht("/capabilities","learned")},{path:"/health",redirect:Ht("/system","health")},{path:"/resources",redirect:Ht("/system","resources")},{path:"/logs",redirect:Ht("/system","logs")},{path:"/config",redirect:Ht("/system","config")},{path:"/host-access",redirect:Ht("/system","host-access")},{path:"/hosts",redirect:Ht("/system","hosts")},{path:"/internals",redirect:Ht("/system","internals")}],Ul=d1({history:HS(),routes:cb});Ul.afterEach(e=>{var s;const t=(s=e.meta)==null?void 0:s.label;document.title=t?`Odin — ${t}`:"Odin — Management"});const zE={template:` + `,props:["onLogin","sessionExpired"],setup(e){const t=f(""),s=f(null),a=f(!1),n=f(!1);async function i(){a.value=!0,s.value=null;try{z.setPersist(n.value),await z.login(t.value),e.onLogin()}catch(l){s.value=l.message||"Login failed"}finally{a.value=!1}}return{token:t,error:s,busy:a,persist:n,login:i}}},HE={template:` +
    +
    + Loading application... +
    + + +
    + + + + +
    + {{ wsToast.text }} +
    +
    + + + +
    + +
    +
    +
    + + + `,setup(){const e=f("checking"),t=f(!1),s=f(!1),a=f(!1),n=f(null),i=f(null),l=f(!1);let o=null,r=null;const c=f(!1),d=f("disconnected"),u=f(-1),p=f(null);let m=null;const h=f("starting"),y=f(""),k=cb.filter(Z=>Z.meta),A=B(()=>["Workspace","Operate","Observe","Manage"].map(Z=>({name:Z,routes:k.filter(Q=>Q.meta.section===Z)})).filter(Z=>Z.routes.length)),g=B(()=>{var Z;return((Z=Ul.currentRoute.value.meta)==null?void 0:Z.label)||"Odin"}),b=B(()=>{var Z;return((Z=Ul.currentRoute.value.meta)==null?void 0:Z.section)||"Management"}),x=B(()=>{var Z;return((Z=Ul.currentRoute.value.meta)==null?void 0:Z.description)||"Management console"});function w(){mt.disconnect(),N&&(clearInterval(N),N=null)}z.onSessionExpired=()=>{t.value=!0,w(),z.setToken(""),e.value="login"};function _(Z){var Q;if((Z.ctrlKey||Z.metaKey)&&Z.key.toLowerCase()==="k"){e.value==="ready"&&(Z.preventDefault(),nm());return}if(a.value&&Z.key==="Tab"){const ae=[...((Q=n.value)==null?void 0:Q.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'))||[]];if(ae.length){const V=ae[0],be=ae[ae.length-1];if(Z.shiftKey&&(document.activeElement===V||!n.value.contains(document.activeElement))){Z.preventDefault(),be.focus();return}if(!Z.shiftKey&&(document.activeElement===be||!n.value.contains(document.activeElement))){Z.preventDefault(),V.focus();return}}}if(Z.key==="Escape"&&a.value){a.value=!1,Z.preventDefault();return}if(Z.key==="/"&&!["INPUT","TEXTAREA","SELECT"].includes(Z.target.tagName)){Z.preventDefault();const ae=document.querySelector('.hm-main input[type="text"], .hm-main .hm-input:not(textarea):not(select)');ae&&ae.focus()}}function T(){l.value=!!(o!=null&&o.matches),l.value||(a.value=!1)}async function L(){try{const Z=await z.get("/api/setup/status");if(Z.mode==="pending"||Z.needed===!0)return w(),e.value="setup",!0}catch(Z){Z==null||Z.name}return!1}st(async()=>{if(document.addEventListener("keydown",_),o=window.matchMedia("(max-width: 900px)"),T(),o.addEventListener("change",T),await L())return;const Z=await z.check();Z.ok?(e.value="ready",re()):Z.needsAuth?e.value="login":(e.value="ready",re())});function E(){t.value=!1,e.value="ready",re()}async function F(){if(await L())return;const Z=await z.check();Z.ok?(e.value="ready",re()):Z.needsAuth?e.value="login":(e.value="ready",re())}async function O(){w(),e.value="login",await z.logout()}function I(){s.value=!s.value}function H(){a.value=!a.value}Gt(a,async Z=>{var Q,ae;if(Z)r=document.activeElement,await Ut(),(ae=(Q=n.value)==null?void 0:Q.querySelector(".nav-item"))==null||ae.focus();else if(r!=null&&r.isConnected){const V=r;r=null,requestAnimationFrame(()=>V.focus())}});const te=B(()=>{switch(d.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}});function ee(Z,Q="info",ae=3e3){p.value={text:Z,level:Q},clearTimeout(m),m=setTimeout(()=>{p.value=null},ae)}let N=null,M=!1,$=[];function re(){for(const Z of $)Z();$=[mt.onStatus(Z=>{c.value=Z}),mt.onLatencyChange(Z=>{u.value=Z}),mt.onState((Z,Q)=>{d.value=Z,Z==="connected"?(M&&ee("Connection restored","success"),M=!0):Z==="reconnecting"&&Q.attempt===1&&ee("Connection lost — reconnecting…","warn")})],mt.connect(),ce(),N&&clearInterval(N),N=setInterval(ce,15e3)}async function ce(){try{const Z=await z.get("/api/status");h.value=Z.status==="online"?"online":"starting";const Q=Z.uptime_seconds||0,ae=Math.floor(Q/3600),V=Math.floor(Q%3600/60);y.value=`${ae}h ${V}m uptime`}catch{h.value="offline",y.value=""}}return wt(()=>{N&&clearInterval(N);for(const Z of $)Z();$=[],mt.disconnect(),document.removeEventListener("keydown",_),o==null||o.removeEventListener("change",T)}),{authState:e,sessionExpired:t,sidebarCollapsed:s,mobileOpen:a,wsConnected:c,wsState:d,wsLatency:u,wsLabel:te,wsToast:p,botStatus:h,botUptime:y,navRoutes:k,navGroups:A,currentPage:g,currentSection:b,currentDescription:x,sidebarEl:n,mobileMenuButton:i,isMobileViewport:l,onLogin:E,onSetupComplete:F,logout:O,toggleSidebar:I,toggleMobileNavigation:H,openPalette:nm}}},un=Cr(HE);un.component("odin-icon",gT);un.component("login-screen",zE);un.component("setup-page",UE);un.component("toast-container",sS);un.component("confirm-host",aS);un.component("command-palette",hT);un.directive("modal-focus",yT);un.use(Ul);un.mount("#app"); From 8bc101ffc968deed630257a43de62405e6e2e253 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:40:31 -0400 Subject: [PATCH 007/162] fix(codex): securely publish complete credentials and merge manual logins --- scripts/codex_login.py | 9 ++- src/llm/codex_auth.py | 34 +++++++++-- tests/test_codex_credential_writes.py | 87 +++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 tests/test_codex_credential_writes.py diff --git a/scripts/codex_login.py b/scripts/codex_login.py index 333821497..73e2845f0 100644 --- a/scripts/codex_login.py +++ b/scripts/codex_login.py @@ -22,7 +22,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) -from src.llm.codex_auth import CodexAuth +from src.llm.codex_auth import CodexAuth, _atomic_write_secure, merge_authorized_account class CallbackHandler(BaseHTTPRequestHandler): @@ -64,8 +64,11 @@ def log_message(self, format, *args): def _save_creds(creds: dict, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(creds, indent=2)) - path.chmod(0o600) + raw = json.loads(path.read_text()) if path.exists() else [] + if not isinstance(raw, (dict, list)): + raise ValueError("Existing credentials must be an account object or list") + accounts = merge_authorized_account(raw, creds) + _atomic_write_secure(path, json.dumps(accounts, indent=2)) email = creds.get("email", "unknown") account_id = creds.get("account_id", "unknown") diff --git a/src/llm/codex_auth.py b/src/llm/codex_auth.py index 62f363989..ef9fe3250 100644 --- a/src/llm/codex_auth.py +++ b/src/llm/codex_auth.py @@ -5,6 +5,7 @@ import hashlib import json import os +import tempfile import time from pathlib import Path @@ -24,14 +25,35 @@ def _atomic_write_secure(path: Path, content: str) -> None: """Write content to a file atomically with 0600 permissions.""" - tmp = path.with_suffix(".tmp") - fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + fd, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + tmp = Path(name) try: - os.write(fd, content.encode()) - os.fsync(fd) + try: + remaining = memoryview(content.encode()) + while remaining: + written = os.write(fd, remaining) + if written <= 0: + raise OSError("Credential write made no progress") + remaining = remaining[written:] + os.fsync(fd) + finally: + os.close(fd) + tmp.replace(path) finally: - os.close(fd) - tmp.rename(path) + tmp.unlink(missing_ok=True) + + +def merge_authorized_account(raw: dict | list, creds: dict) -> list: + """Add an authorized account, replacing only the same identified account.""" + accounts = list(raw) if isinstance(raw, list) else [raw] + account_id = creds.get("account_id") + for index, existing in enumerate(accounts): + if (account_id and isinstance(existing, dict) + and existing.get("account_id") == account_id): + accounts[index] = {**existing, **creds} + return accounts + accounts.append(creds) + return accounts # OAuth constants for OpenAI Codex CLI CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" diff --git a/tests/test_codex_credential_writes.py b/tests/test_codex_credential_writes.py new file mode 100644 index 000000000..c345382e2 --- /dev/null +++ b/tests/test_codex_credential_writes.py @@ -0,0 +1,87 @@ +"""Synthetic filesystem regressions for credential publication and manual login.""" +import json +import os +import stat + +import pytest + +from scripts.codex_login import _save_creds +from src.llm import codex_auth as ca + + +def test_secure_writer_ignores_preexisting_unsafe_temp(tmp_path, monkeypatch): + path = tmp_path / "accounts.json" + unsafe = path.with_suffix(".tmp") + unsafe.write_text("unrelated") + unsafe.chmod(0o644) + real_write = os.write + + def checked_write(fd, data): + assert stat.S_IMODE(os.fstat(fd).st_mode) == 0o600 + return real_write(fd, data) + + monkeypatch.setattr(ca.os, "write", checked_write) + ca._atomic_write_secure(path, '{"synthetic": true}') + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert unsafe.read_text() == "unrelated" + + +def test_secure_writer_completes_short_writes(tmp_path, monkeypatch): + path = tmp_path / "accounts.json" + real_write = os.write + monkeypatch.setattr(ca.os, "write", lambda fd, data: real_write(fd, data[:5])) + ca._atomic_write_secure(path, json.dumps({"synthetic": "long value"})) + assert json.loads(path.read_text()) == {"synthetic": "long value"} + + +@pytest.mark.parametrize("failure", ["zero", "error"]) +def test_secure_writer_failure_preserves_destination(tmp_path, monkeypatch, failure): + path = tmp_path / "accounts.json" + path.write_text("old") + + def fail(fd, data): + if failure == "error": + raise OSError("synthetic failure") + return 0 + + monkeypatch.setattr(ca.os, "write", fail) + with pytest.raises(OSError): + ca._atomic_write_secure(path, "new") + assert path.read_text() == "old" + assert list(tmp_path.iterdir()) == [path] + + +def test_manual_login_adds_and_deduplicates_accounts(tmp_path): + path = tmp_path / "accounts.json" + a = {"account_id": "A", "access_token": "synthetic-A", "label": "first"} + b = {"account_id": "B", "access_token": "synthetic-B"} + path.write_text(json.dumps([a, b])) + c = {"account_id": "C", "access_token": "synthetic-C"} + _save_creds(c, path) + assert json.loads(path.read_text()) == [a, b, c] + _save_creds({"account_id": "A", "access_token": "synthetic-new"}, path) + rows = json.loads(path.read_text()) + assert len(rows) == 3 and rows[0]["label"] == "first" + assert rows[0]["access_token"] == "synthetic-new" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_manual_login_secure_before_first_write(tmp_path, monkeypatch): + real_write = os.write + + def checked_write(fd, data): + assert stat.S_IMODE(os.fstat(fd).st_mode) == 0o600 + return real_write(fd, data) + + monkeypatch.setattr(ca.os, "write", checked_write) + _save_creds({"access_token": "synthetic"}, tmp_path / "new.json") + + +def test_manual_login_write_failure_preserves_pool(tmp_path, monkeypatch): + path = tmp_path / "accounts.json" + path.write_text('[{"access_token": "synthetic-A"}]') + original = path.read_text() + monkeypatch.setattr(ca.os, "write", lambda fd, data: 0) + with pytest.raises(OSError): + _save_creds({"access_token": "synthetic-B"}, path) + assert path.read_text() == original From 8766010d63910c8f488d86b3731182484b0d6381 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:57:14 -0400 Subject: [PATCH 008/162] fix(codex): fence account mutations and serialize admin token rotations --- src/llm/codex_auth.py | 107 ++++++- src/llm/codex_quota_check.py | 17 +- src/web/api/codex_admin.py | 112 +++---- ...test_codex_account_mutation_regressions.py | 295 ++++++++++++++++++ tests/test_codex_quota_check.py | 1 + tests/test_web_api_codex_admin.py | 3 +- 6 files changed, 468 insertions(+), 67 deletions(-) create mode 100644 tests/test_codex_account_mutation_regressions.py diff --git a/src/llm/codex_auth.py b/src/llm/codex_auth.py index ef9fe3250..4396415fb 100644 --- a/src/llm/codex_auth.py +++ b/src/llm/codex_auth.py @@ -110,7 +110,7 @@ def _decode_jwt_payload(token: str) -> dict: class CodexAuth: - def __init__(self, credentials_path: str, on_save=None) -> None: + def __init__(self, credentials_path: str, on_save=None, save_guard=None) -> None: self._path = Path(credentials_path) self._credentials: dict | None = None self._refresh_lock = asyncio.Lock() @@ -119,6 +119,7 @@ def __init__(self, credentials_path: str, on_save=None) -> None: # multi-account file must see every rotation — otherwise a restart # resurrects a burned refresh token and the account dies on next use. self.on_save = on_save + self.save_guard = save_guard def is_configured(self) -> bool: """Check if credentials file exists and has tokens.""" @@ -141,6 +142,10 @@ def _load(self) -> dict: return self._credentials def _save(self, creds: dict) -> None: + # Check BEFORE touching either canonical or shadow storage. A retired + # refresh may finish after deletion/re-auth, but must never publish. + if self.save_guard is not None and not self.save_guard(): + raise LLMAuthError("Codex account changed during refresh.", provider="codex") self._path.parent.mkdir(parents=True, exist_ok=True) _atomic_write_secure(self._path, json.dumps(creds, indent=2)) self._credentials = creds @@ -465,6 +470,10 @@ def __init__(self, credentials_path: str) -> None: self.quota = CodexQuotaTracker() self._quota_check_failures: dict[str, str] = {} self._manual_active_index: int | None = None + self._generation = 0 + self._canonical_indices: list[int] = [] + self._loaded_records: dict[int, dict] = {} + self._loaded_auths: dict[int, CodexAuth] = {} self._init_accounts() def _init_accounts(self) -> None: @@ -479,6 +488,12 @@ def _init_accounts(self) -> None: account_id at the same slot (account deleted/reordered) means the canonical entry is authoritative. """ + self._generation = getattr(self, "_generation", 0) + 1 + self._canonical_indices = [] + previous_records = getattr(self, "_loaded_records", {}) + previous_auths = getattr(self, "_loaded_auths", {}) + self._loaded_records = {} + self._loaded_auths = {} if not self._path.exists(): return try: @@ -498,6 +513,8 @@ def _init_accounts(self) -> None: shadow is not None and bool(creds.get("account_id")) and shadow.get("account_id") == creds.get("account_id") + and (i not in previous_records + or self._same_credentials(creds, previous_records[i])) ) if ( # same_account (above) already requires shadow is not @@ -508,13 +525,29 @@ def _init_accounts(self) -> None: ): # Shadow holds newer (rotated) tokens — keep it and pull # the canonical entry up to date instead of the reverse. + if shadow is not None and "label" in creds: + shadow = {**shadow, "label": creds["label"]} + _atomic_write_secure(individual_path, json.dumps(shadow, indent=2)) if shadow != creds: raw[i] = shadow canonical_dirty = True else: _atomic_write_secure(individual_path, json.dumps(creds, indent=2)) - auth = CodexAuth(str(individual_path), on_save=self._canonical_sync(i)) + expected = dict(raw[i]) + self._loaded_records[i] = expected + generation = self._generation + # An unchanged reload must retain the refresh lock and any + # in-flight single-use rotation, not create a second consumer. + auth = previous_auths.get(i) + if (auth is None or i not in previous_records + or auth._path != individual_path + or not self._same_credentials(expected, previous_records[i])): + auth = CodexAuth(str(individual_path)) + auth.on_save = self._canonical_sync(i, expected, generation) + auth.save_guard = self._save_guard(i, expected, generation) + self._loaded_auths[i] = auth self._accounts.append(auth) + self._canonical_indices.append(i) valid_count = i + 1 if canonical_dirty: _atomic_write_secure(self._path, json.dumps(raw, indent=2)) @@ -528,7 +561,22 @@ def _init_accounts(self) -> None: log.info("Codex auth pool: %d account(s) loaded", len(self._accounts)) elif isinstance(raw, dict) and raw.get("access_token"): # Single account (backward compat) — use the file directly - self._accounts.append(CodexAuth(str(self._path))) + expected = dict(raw) + + def update_expected(creds: dict) -> None: + expected.clear() + expected.update(creds) + + auth = previous_auths.get(0) + if (auth is None or auth._path != self._path or 0 not in previous_records + or not self._same_credentials(raw, previous_records[0])): + auth = CodexAuth(str(self._path)) + auth.on_save = update_expected + auth.save_guard = self._save_guard(0, expected, self._generation) + self._loaded_records[0] = expected + self._loaded_auths[0] = auth + self._accounts.append(auth) + self._canonical_indices.append(0) log.info("Codex auth pool: 1 account loaded (single format)") @staticmethod @@ -541,17 +589,64 @@ def _load_creds_file(path: Path) -> dict | None: return None return data if isinstance(data, dict) else None - def _canonical_sync(self, index: int): + @property + def generation(self) -> int: + """Account-list revision, including reloads on the same pool object.""" + return getattr(self, "_generation", 0) + + @staticmethod + def canonical_index(raw: dict | list, index: int) -> int: + """Translate a displayed valid-account slot to its canonical record.""" + rows = raw if isinstance(raw, list) else [raw] + valid = [i for i, row in enumerate(rows) + if isinstance(row, dict) and row.get("access_token")] + if index < 0 or index >= len(valid): + raise ValueError("account index out of range") + return valid[index] + + @staticmethod + def _same_credentials(left: dict, right: dict) -> bool: + # Labels and other operator metadata may change during refresh. + return all(left.get(key) == right.get(key) for key in ( + "access_token", "refresh_token", "account_id", "expires_at", + )) + + def _save_guard(self, index: int, expected: dict, generation: int): + def _guard() -> bool: + if self.generation != generation: + return False + try: + raw = json.loads(self._path.read_text()) + row = raw[index] if isinstance(raw, list) else raw if index == 0 else None + return isinstance(row, dict) and self._same_credentials(row, expected) + except (OSError, ValueError, IndexError): + return False + return _guard + + def _canonical_sync(self, index: int, expected: dict | None = None, + generation: int | None = None): """Build an on_save hook that mirrors account *index* back to the canonical file.""" def _sync(creds: dict) -> None: + if generation is not None and self.generation != generation: + return try: raw = json.loads(self._path.read_text()) except Exception: return if not isinstance(raw, list) or index >= len(raw): return + if expected is not None and ( + not isinstance(raw[index], dict) + or not self._same_credentials(raw[index], expected) + ): + return + if isinstance(raw[index], dict) and "label" in raw[index]: + creds["label"] = raw[index]["label"] raw[index] = creds _atomic_write_secure(self._path, json.dumps(raw, indent=2)) + if expected is not None: + expected.clear() + expected.update(creds) return _sync @staticmethod @@ -906,6 +1001,8 @@ async def mark_auth_failed(self, index: int) -> bool: return False account = self._accounts[index] account.mark_rate_limited(AUTH_FAILED_BACKOFF_SECONDS) + if getattr(self, "_manual_active_index", None) == index: + self._manual_active_index = None label = self._account_label(account, index) if len(self._accounts) > 1: if self._current_index == index: @@ -968,6 +1065,7 @@ def reload(self) -> None: """Reload the pool from the canonical credentials file (sync compat).""" self._accounts.clear() self._current_index = 0 + self._manual_active_index = None self._init_accounts() log.info("Codex auth pool reloaded: %d account(s)", len(self._accounts)) @@ -975,6 +1073,7 @@ async def reload_async(self) -> int: """Reload under lock to avoid racing in-flight token operations.""" async with self._pool_lock: self._accounts.clear() + self._manual_active_index = None self._init_accounts() self._current_index = min(self._current_index, max(len(self._accounts) - 1, 0)) log.info("Codex auth pool reloaded (async): %d account(s)", len(self._accounts)) diff --git a/src/llm/codex_quota_check.py b/src/llm/codex_quota_check.py index fd92a5952..500be9fbc 100644 --- a/src/llm/codex_quota_check.py +++ b/src/llm/codex_quota_check.py @@ -80,13 +80,18 @@ async def check_once(self) -> None: pool = self.get_pool() if pool is None: return + generation = pool.generation + + def current() -> bool: + return self.get_pool() is pool and pool.generation == generation + now = pool.quota._clock() - for index in range(pool.account_count): - if self.get_pool() is not pool: + accounts = pool.describe_accounts() + for index, account in enumerate(accounts): + if not current(): return # Configured slots only. This query is read-only and does not # choose, rotate, or activate an account. - account = pool.describe_accounts()[index] if not account.get("configured"): continue key = account.get("key") @@ -96,7 +101,7 @@ async def check_once(self) -> None: request_started = False try: token, account_id = await pool.token_for(index) - if self.get_pool() is not pool: + if not current(): return key = opaque_account_key(account_id) if not key: @@ -114,7 +119,7 @@ async def check_once(self) -> None: json=_CHECK_BODY, timeout=aiohttp.ClientTimeout(total=self.timeout), ) as response: - if self.get_pool() is not pool: + if not current(): return pool.quota.record_headers(key, response.headers) if 200 <= response.status < 300: @@ -128,5 +133,5 @@ async def check_once(self) -> None: # Keep this display-safe: exception text can contain upstream # content or credential-adjacent details. reason = "request failed" if request_started else "credential refresh failed" - if self.get_pool() is pool: + if current(): pool.set_quota_check_failure(index, reason) diff --git a/src/web/api/codex_admin.py b/src/web/api/codex_admin.py index 5e3e9bbd3..25fe77a57 100644 --- a/src/web/api/codex_admin.py +++ b/src/web/api/codex_admin.py @@ -127,41 +127,52 @@ async def codex_device_poll(request: web.Request) -> web.Response: import json as _json from pathlib import Path as _Path - from ...llm.codex_auth import _atomic_write_secure + from ...llm.codex_auth import ( + CodexAuthPool, + _atomic_write_secure, + merge_authorized_account, + ) path = _Path(creds_path) path.parent.mkdir(parents=True, exist_ok=True) async with _codex_creds_lock: try: - if path.exists(): - raw = _json.loads(path.read_text()) - if save_index is not None: - if isinstance(raw, list) and 0 <= save_index < len(raw): - raw[save_index] = creds - elif isinstance(raw, list): - raw.append(creds) - else: - raw = [raw, creds] if isinstance(raw, dict) else [creds] - else: - if isinstance(raw, list): - raw.append(creds) - elif isinstance(raw, dict): - raw = [raw, creds] - else: - raw = [creds] - _atomic_write_secure(path, _json.dumps(raw, indent=2)) - else: - _atomic_write_secure(path, _json.dumps([creds], indent=2)) - except Exception as e: + raw = _json.loads(path.read_text()) if path.exists() else [] + if not isinstance(raw, (dict, list)): + raise ValueError("invalid credentials shape") + except (ValueError, OSError): bak = path.with_suffix(".bak") if path.exists(): try: import shutil shutil.copy2(path, bak) - except Exception: - pass - _atomic_write_secure(path, _json.dumps([creds], indent=2)) - log.warning("Failed to merge credentials (backup at %s), wrote fresh: %s", bak, e) + except OSError: + return web.json_response( + {"error": "failed to preserve existing credentials"}, status=500, + ) + raw = [] + if save_index is not None: + try: + canonical_index = CodexAuthPool.canonical_index(raw, save_index) + except ValueError: + # Preserve legacy append behavior for a missing slot. + if save_index < 0: + return web.json_response({"error": "invalid account index"}, status=400) + canonical_index = None + raw = list(raw) if isinstance(raw, list) else [raw] + if canonical_index is None: + raw.append(creds) + else: + if "label" in raw[canonical_index]: + creds["label"] = raw[canonical_index]["label"] + raw[canonical_index] = creds + else: + raw = merge_authorized_account(raw, creds) + try: + _atomic_write_secure(path, _json.dumps(raw, indent=2)) + except OSError: + # A persistence error must never trigger a fresh-only overwrite. + return web.json_response({"error": "failed to save credentials"}, status=500) await bot.llm_gateway.reload_codex() @@ -187,22 +198,10 @@ async def codex_refresh_account(request: web.Request) -> web.Response: auth = pool._accounts[index] try: - import json as _json - from pathlib import Path as _Path - - from ...llm.codex_auth import _atomic_write_secure - + stale_token = auth._load().get("access_token") + if not await pool.force_refresh(index, stale_token): + return web.json_response({"error": "credential refresh failed"}, status=500) creds = auth._load() - await auth._refresh(creds) - creds = auth._load() - - async with _codex_creds_lock: - path = _Path(bot.config.openai_codex.credentials_path) - if path.exists(): - raw = _json.loads(path.read_text()) - if isinstance(raw, list) and index < len(raw): - raw[index] = creds - _atomic_write_secure(path, _json.dumps(raw, indent=2)) return web.json_response({ "status": "refreshed", @@ -240,7 +239,7 @@ async def codex_set_label(request: web.Request) -> web.Response: import json as _json from pathlib import Path as _Path - from ...llm.codex_auth import _atomic_write_secure + from ...llm.codex_auth import CodexAuthPool, _atomic_write_secure try: index = int(request.match_info["index"]) @@ -266,9 +265,11 @@ async def codex_set_label(request: web.Request) -> web.Response: return web.json_response({"error": "failed to read credentials"}, status=500) if isinstance(raw, list): - if index < 0 or index >= len(raw): - return web.json_response({"error": f"index {index} out of range"}, status=400) - raw[index]["label"] = label + try: + canonical_index = CodexAuthPool.canonical_index(raw, index) + except ValueError: + return web.json_response({"error": "invalid index"}, status=400) + raw[canonical_index]["label"] = label elif isinstance(raw, dict) and index == 0: raw["label"] = label else: @@ -276,16 +277,15 @@ async def codex_set_label(request: web.Request) -> web.Response: _atomic_write_secure(path, _json.dumps(raw, indent=2)) - # Also update the in-memory shadow file so status reflects immediately + # A metadata-only update must not retire an in-flight token rotation. pool = getattr(bot.llm_gateway, "codex_client", None) pool = getattr(pool, "auth", None) if pool else None - if pool and index < len(pool._accounts): + if pool and 0 <= index < len(pool._accounts): + auth = pool._accounts[index] try: - creds = pool._accounts[index]._load() - creds["label"] = label - pool._accounts[index]._save(creds) + auth._save({**auth._load(), "label": label}) except Exception: - pass + return web.json_response({"error": "failed to update account metadata"}, status=500) return web.json_response({"status": "updated", "label": label}) @@ -294,7 +294,7 @@ async def codex_delete_account(request: web.Request) -> web.Response: import json as _json from pathlib import Path as _Path - from ...llm.codex_auth import _atomic_write_secure + from ...llm.codex_auth import CodexAuthPool, _atomic_write_secure try: index = int(request.match_info["index"]) @@ -312,11 +312,11 @@ async def codex_delete_account(request: web.Request) -> web.Response: return web.json_response({"error": "failed to read credentials"}, status=500) if isinstance(raw, list): - if index < 0 or index >= len(raw): - return web.json_response( - {"error": f"index {index} out of range (0-{len(raw)-1})"}, status=400 - ) - removed = raw.pop(index) + try: + canonical_index = CodexAuthPool.canonical_index(raw, index) + except ValueError: + return web.json_response({"error": "invalid index"}, status=400) + removed = raw.pop(canonical_index) _atomic_write_secure(path, _json.dumps(raw, indent=2)) email = removed.get("email", "unknown") elif isinstance(raw, dict) and index == 0: diff --git a/tests/test_codex_account_mutation_regressions.py b/tests/test_codex_account_mutation_regressions.py new file mode 100644 index 000000000..1b1a64f82 --- /dev/null +++ b/tests/test_codex_account_mutation_regressions.py @@ -0,0 +1,295 @@ +"""Real pools/routes, synthetic files and event-controlled OAuth transport.""" +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.llm import codex_auth as ca +from src.llm.account_key import opaque_account_key +from src.llm.codex_quota_check import CodexQuotaCheckService +from src.web.api.codex_admin import register_codex_oauth + + +def creds(account): + return {"account_id": account, "access_token": f"synthetic-{account}", + "refresh_token": f"synthetic-refresh-{account}", "expires_at": 9999999999} + + +def setup(tmp_path, rows): + path = tmp_path / "canonical.json" + path.write_text(json.dumps(rows)) + pool = ca.CodexAuthPool(str(path)) + gateway = SimpleNamespace(codex_client=SimpleNamespace(auth=pool), + reload_codex=AsyncMock()) + bot = SimpleNamespace(llm_gateway=gateway, config=SimpleNamespace( + openai_codex=SimpleNamespace(credentials_path=str(path)))) + routes = web.RouteTableDef() + register_codex_oauth(routes, bot) + app = web.Application() + app.router.add_routes(routes) + return path, pool, bot, app, routes + + +class OAuthTransport: + def __init__(self): + self.entered = asyncio.Event() + self.release = asyncio.Event() + self.calls = 0 + + def session(self, **kwargs): + transport = self + + class Response: + status = 200 + + async def __aenter__(self): + transport.calls += 1 + transport.entered.set() + await transport.release.wait() + return self + + async def __aexit__(self, *args): + pass + + async def read(self): + return json.dumps({"access_token": "synthetic-rotated", + "refresh_token": "synthetic-next", + "expires_in": 3600}).encode() + + class Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def post(self, url, **kwargs): + assert url == ca.TOKEN_URL + assert kwargs["data"]["grant_type"] == "refresh_token" + return Response() + + return Session() + + +@pytest.mark.asyncio +async def test_auth_failure_clears_manual_override(tmp_path): + _, pool, *_ = setup(tmp_path, [creds("A"), creds("B")]) + await pool.set_active(0) + assert await pool.mark_auth_failed(0) + assert pool._manual_active_index is None + token, account, index = await pool.acquire() + assert (token, account, index) == ("synthetic-B", "B", 1) + assert pool._accounts[0].is_rate_limited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mutation", ["delete", "reorder", "reauth"]) +async def test_retired_refresh_cannot_publish_shadow_or_canonical(tmp_path, monkeypatch, mutation): + path, pool, _, app, _ = setup(tmp_path, [creds("A"), creds("B")]) + transport = OAuthTransport() + async with TestClient(TestServer(app)) as client: + monkeypatch.setattr(ca.aiohttp, "ClientSession", transport.session) + old = pool._accounts[0] + task = asyncio.create_task(old.force_refresh()) + await transport.entered.wait() + if mutation == "delete": + assert (await client.delete("/api/codex/account/0")).status == 200 + else: + rows = [creds("B"), creds("A")] if mutation == "reorder" else [ + {**creds("A"), "access_token": "synthetic-reauth"}, creds("B")] + ca._atomic_write_secure(path, json.dumps(rows)) + await pool.reload_async() + before = path.read_text() + shadow = (tmp_path / "codex_auth_0.json").read_text() + transport.release.set() + assert await task is False + assert path.read_text() == before + assert (tmp_path / "codex_auth_0.json").read_text() == shadow + await pool.reload_async() + assert path.read_text() == before + + +@pytest.mark.asyncio +async def test_admin_and_serving_share_single_use_refresh_lock(tmp_path, monkeypatch): + path, pool, _, app, _ = setup(tmp_path, [creds("A")]) + transport = OAuthTransport() + async with TestClient(TestServer(app)) as client: + monkeypatch.setattr(ca.aiohttp, "ClientSession", transport.session) + foreground = asyncio.create_task(pool.force_refresh(0, "synthetic-A")) + await transport.entered.wait() + admin = asyncio.create_task(client.post("/api/codex/account/0/refresh")) + await asyncio.sleep(0.02) + transport.release.set() + assert await foreground + assert (await admin).status == 200 + assert transport.calls == 1 + assert json.loads(path.read_text())[0]["refresh_token"] == "synthetic-next" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("single_format", [False, True]) +async def test_unchanged_reload_keeps_single_use_refresh_in_flight( + tmp_path, monkeypatch, single_format, +): + path, pool, *_ = setup(tmp_path, [creds("A")]) + if single_format: + path.write_text(json.dumps(creds("A"))) + await pool.reload_async() + transport = OAuthTransport() + monkeypatch.setattr(ca.aiohttp, "ClientSession", transport.session) + auth = pool._accounts[0] + serving = asyncio.create_task(pool.force_refresh(0, "synthetic-A")) + await transport.entered.wait() + await pool.reload_async() + assert pool._accounts[0] is auth + concurrent = asyncio.create_task(pool.force_refresh(0, "synthetic-A")) + transport.release.set() + assert await serving and await concurrent + assert transport.calls == 1 + raw = json.loads(path.read_text()) + assert (raw if single_format else raw[0])["refresh_token"] == "synthetic-next" + + +@pytest.mark.asyncio +async def test_reauth_same_identity_older_expiry_does_not_revive_shadow(tmp_path): + path, pool, *_ = setup(tmp_path, [creds("A")]) + newer = {**creds("A"), "access_token": "synthetic-reauth", "expires_at": 5000} + path.write_text(json.dumps([newer])) + await pool.reload_async() + assert pool._accounts[0]._load() == newer + assert json.loads(path.read_text()) == [newer] + + +@pytest.mark.asyncio +async def test_cancelled_admin_settles_rotated_credentials(tmp_path, monkeypatch): + path, pool, _, _, routes = setup(tmp_path, [creds("A")]) + handler = next(route.handler for route in routes + if route.path == "/api/codex/account/{index}/refresh") + transport = OAuthTransport() + monkeypatch.setattr(ca.aiohttp, "ClientSession", transport.session) + request = SimpleNamespace(match_info={"index": "0"}) + task = asyncio.create_task(handler(request)) + await transport.entered.wait() + task.cancel() + await asyncio.sleep(0) + assert not task.done() + transport.release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert json.loads(path.read_text())[0]["refresh_token"] == "synthetic-next" + assert pool._accounts[0]._load()["refresh_token"] == "synthetic-next" + + +@pytest.mark.asyncio +async def test_device_merged_write_failure_preserves_pool(tmp_path, monkeypatch): + path, _, bot, app, _ = setup(tmp_path, [creds("A"), creds("B")]) + original = path.read_text() + monkeypatch.setattr(ca.CodexAuth, "poll_device_auth", AsyncMock(return_value=creds("C"))) + calls = [] + + def fail(path, content): + calls.append(content) + raise OSError("synthetic filesystem failure") + + monkeypatch.setattr(ca, "_atomic_write_secure", fail) + async with TestClient(TestServer(app)) as client: + response = await client.post("/api/codex/device-poll", json={ + "device_auth_id": "synthetic", "user_code": "synthetic"}) + assert response.status == 500 + assert len(calls) == 1 + assert path.read_text() == original + bot.llm_gateway.reload_codex.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["label", "delete", "reauth"]) +@pytest.mark.parametrize("invalid", [{}, None, "invalid"]) +async def test_management_uses_displayed_valid_index(tmp_path, monkeypatch, operation, invalid): + path, pool, _, app, _ = setup(tmp_path, [creds("A"), invalid, creds("B")]) + async with TestClient(TestServer(app)) as client: + status = await (await client.get("/api/codex/status")).json() + assert [row["account_id"] for row in status["accounts"]] == ["A", "B"] + if operation == "label": + response = await client.put("/api/codex/account/1/label", json={"label": "second"}) + elif operation == "delete": + response = await client.delete("/api/codex/account/1") + else: + monkeypatch.setattr(ca.CodexAuth, "poll_device_auth", + AsyncMock(return_value={**creds("B"), "access_token": "new-B"})) + response = await client.post("/api/codex/device-poll", json={ + "device_auth_id": "synthetic", "user_code": "synthetic", "save_index": 1}) + assert response.status == 200 + rows = json.loads(path.read_text()) + assert rows[0] == creds("A") and rows[1] == invalid + if operation == "label": + assert rows[2]["label"] == "second" + assert pool._accounts[1]._load()["label"] == "second" + elif operation == "delete": + assert len(rows) == 2 and pool.account_count == 1 + else: + assert rows[2]["access_token"] == "new-B" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mutation", ["shrink", "reorder"]) +@pytest.mark.parametrize("phase", ["token", "response", "token_error", "response_error"]) +async def test_in_place_quota_reload_fenced_across_awaits(tmp_path, mutation, phase): + path, pool, *_ = setup(tmp_path, [creds("A"), creds("B")]) + service = CodexQuotaCheckService(lambda: pool) + entered, release = asyncio.Event(), asyncio.Event() + original_token_for = pool.token_for + + async def token_for(index): + token = await original_token_for(index) + if phase.startswith("token"): + entered.set() + await release.wait() + if phase.endswith("error"): + raise RuntimeError("synthetic refresh failure") + return token + + pool.token_for = token_for + + class Response: + status = 401 + headers = {"x-codex-primary-used-percent": "25", + "x-codex-primary-window-minutes": "300"} + + async def __aenter__(self): + entered.set() + await release.wait() + if phase.endswith("error"): + raise RuntimeError("synthetic request failure") + return self + + async def __aexit__(self, *args): + pass + + class Session: + closed = False + calls = 0 + + def post(self, *args, **kwargs): + self.calls += 1 + return Response() + + async def close(self): + self.closed = True + + session = Session() + service._session = session + task = asyncio.create_task(service.check_once()) + await entered.wait() + path.write_text(json.dumps([] if mutation == "shrink" else [creds("B"), creds("A")])) + await pool.reload_async() + release.set() + await task + assert pool._quota_check_failures == {} + assert pool.quota.snapshot_for(opaque_account_key("A")) is None + assert pool.quota.snapshot_for(opaque_account_key("B")) is None + assert session.calls == (0 if phase.startswith("token") else 1) + await service.close() diff --git a/tests/test_codex_quota_check.py b/tests/test_codex_quota_check.py index 4b4c22ec5..92d4afbf2 100644 --- a/tests/test_codex_quota_check.py +++ b/tests/test_codex_quota_check.py @@ -18,6 +18,7 @@ class Pool: account_count = 3 + generation = 1 def __init__(self): self.quota = CodexQuotaTracker(clock=lambda: 1000) diff --git a/tests/test_web_api_codex_admin.py b/tests/test_web_api_codex_admin.py index 88d43da33..edb5370d3 100644 --- a/tests/test_web_api_codex_admin.py +++ b/tests/test_web_api_codex_admin.py @@ -431,7 +431,8 @@ async def test_dict_file_with_save_index_promotes(self, tmp_path, monkeypatch): json={"device_auth_id": "d", "user_code": "AB", "save_index": 0}) assert r.status == 200 data = json.loads(path.read_text()) - assert isinstance(data, list) and len(data) == 2 # [orig, new] + assert isinstance(data, list) and len(data) == 1 + assert data[0]["access_token"] == "promoted" # re-auth replaces displayed slot 0 @pytest.mark.asyncio async def test_no_file_writes_fresh(self, tmp_path, monkeypatch): From 1281b7de8001aebafa0f4b20e083107fe540392d Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:38:51 -0400 Subject: [PATCH 009/162] Fix next-clock DST admission and clock parsing bounds (#414 #415 #416) --- src/tools/time_parser.py | 16 ++++++++--- tests/test_campaign_scheduler_time.py | 39 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 tests/test_campaign_scheduler_time.py diff --git a/src/tools/time_parser.py b/src/tools/time_parser.py index db7af8c02..45acfd98e 100644 --- a/src/tools/time_parser.py +++ b/src/tools/time_parser.py @@ -69,7 +69,7 @@ def set_default_timezone(tz_name: str) -> None: "january|february|march|april|june|july|august|september|october|november|december" ) _TIME_12H = re.compile(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)") -_TIME_24H = re.compile(r"(\d{1,2}):(\d{2})$") +_TIME_24H = re.compile(r"(\d{1,2}):(\d{2})(?!\d)") _MORE_DURATION = re.compile(r"\s*(?:,\s*)?(?:and\s+)?(\d+)\s+(\w+)") _CLOCK_LEAD = re.compile(r"[\s,]*(?:at\s+)?") _DAY_AFTER_TIME = re.compile( @@ -156,6 +156,8 @@ def _split_time_of_day(text: str) -> tuple[tuple[int, int], str] | None: if m: hour = int(m.group(1)) minute = int(m.group(2) or 0) + if not 1 <= hour <= 12: + raise ValueError("AM/PM clock hours must be between 1 and 12") if m.group(3) == "pm" and hour != 12: hour += 12 elif m.group(3) == "am" and hour == 12: @@ -165,7 +167,7 @@ def _split_time_of_day(text: str) -> tuple[tuple[int, int], str] | None: # 24-hour: 17:00, 09:30 m = _TIME_24H.match(text) if m: - return (int(m.group(1)), int(m.group(2))), "" + return (int(m.group(1)), int(m.group(2))), text[m.end() :] # Bare hour: "9" — too ambiguous, skip return None @@ -202,7 +204,15 @@ def _clock_then_day(now: datetime, hit, expression: str) -> datetime: return _at_clock(target, hour, minute) _reject_unused(rest, expression) result = _at_clock(now, hour, minute) - return _at_clock(now + timedelta(days=1), hour, minute) if result <= now else result + if result.astimezone(UTC) > now.astimezone(UTC): + return result + # A clock-only request means the next occurrence, including the second + # occurrence of a repeated DST hour. Compare instants, not same-zone wall + # times (datetime's latter comparison deliberately ignores fold). + repeated = _local(result.replace(fold=1), now.tzinfo) + if repeated.astimezone(UTC) > now.astimezone(UTC): + return repeated + return _at_clock(now + timedelta(days=1), hour, minute) def _time_after_day(rest: str, expression: str) -> tuple[int, int]: diff --git a/tests/test_campaign_scheduler_time.py b/tests/test_campaign_scheduler_time.py new file mode 100644 index 000000000..4f2530e17 --- /dev/null +++ b/tests/test_campaign_scheduler_time.py @@ -0,0 +1,39 @@ +from datetime import UTC, datetime +from zoneinfo import ZoneInfo + +import pytest + +from src.tools.time_parser import parse_time + + +@pytest.mark.parametrize("fold,minute,expected", [(0, 15, "-04:00"), (1, 15, "-05:00"), (0, 45, "-05:00")]) +def test_next_clock_selects_future_instant_in_repeated_hour(fold, minute, expected): + now = datetime(2026, 11, 1, 1, minute, tzinfo=ZoneInfo("America/New_York"), fold=fold) + result = datetime.fromisoformat(parse_time("at 1:30am", now=now)) + assert result.isoformat() == f"2026-11-01T01:30:00{expected}" + assert result.astimezone(UTC) > now.astimezone(UTC) + + +def test_next_clock_after_both_folds_moves_to_next_day(): + now = datetime(2026, 11, 1, 1, 45, tzinfo=ZoneInfo("America/New_York"), fold=1) + assert parse_time("1:30am", now=now) == "2026-11-02T01:30:00-05:00" + + +@pytest.mark.parametrize("hour", [0, 13, 23]) +@pytest.mark.parametrize("suffix", ["am", "pm"]) +def test_invalid_twelve_hour_clocks_rejected(hour, suffix): + with pytest.raises(ValueError, match="between 1 and 12"): + parse_time(f"today at {hour}{suffix}", now=datetime(2026, 9, 29, tzinfo=UTC)) + + +@pytest.mark.parametrize("expression,expected", [("today at 12am", "00:00"), ("today at 12pm", "12:00"), ("today at 1am", "01:00"), ("today at 1pm", "13:00")]) +def test_twelve_hour_clock_boundaries(expression, expected): + assert parse_time(expression, now=datetime(2026, 9, 29, tzinfo=UTC)) == f"2026-09-29T{expected}:00+00:00" + + +@pytest.mark.parametrize("clock", ["17:00", "09:30"]) +@pytest.mark.parametrize("day", ["tomorrow", "friday", "next friday"]) +@pytest.mark.parametrize("prefix", ["", "at "]) +def test_twenty_four_hour_clock_before_day(clock, day, prefix): + now = datetime(2026, 9, 29, 12, tzinfo=UTC) + assert parse_time(f"{prefix}{clock} {day}", now=now) == parse_time(f"{day} at {clock}", now=now) From 1e8c0f8ca9c2e8741f08e354954d11dfb2620fe3 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:44:05 -0400 Subject: [PATCH 010/162] Fence queued scheduler effects, validate trigger filters and stamp retries (#411 #412 #413 #417) --- src/scheduler/scheduler.py | 45 +++++++++- tests/test_campaign_scheduler_admission.py | 100 +++++++++++++++++++++ 2 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 tests/test_campaign_scheduler_admission.py diff --git a/src/scheduler/scheduler.py b/src/scheduler/scheduler.py index 26e9927aa..62b7ef20c 100644 --- a/src/scheduler/scheduler.py +++ b/src/scheduler/scheduler.py @@ -231,6 +231,29 @@ async def _mark_run_started(self, schedule: dict) -> None: schedule["run_started_at"] = started return + async def _admit_reserved_execution(self, schedule: dict) -> bool: + """Fence queued work against acknowledged CRUD before effects start. + + A manual run can override an existing pause, but a new pause changes + execution identity. Publish the start marker under this same lock. + """ + async with self._lock: + candidate = copy.deepcopy(self._schedules) + for current in candidate: + if current.get("id") != schedule.get("id"): + continue + if self._execution_identity(current) != self._execution_identity(schedule): + return False + if current.get("paused") and not schedule.get("paused"): + return False + if self._tracks_run_start(schedule): + started = datetime.now(UTC).isoformat() + current["run_started_at"] = started + await self._publish(candidate) + schedule["run_started_at"] = started + return True + return False + def _load(self) -> None: if self.data_path.exists(): try: @@ -695,6 +718,9 @@ def _validate_trigger(trigger: dict) -> None: unknown = set(trigger.keys()) - valid_keys if unknown: raise ValueError(f"Unknown trigger keys: {', '.join(sorted(unknown))}") + for key, value in trigger.items(): + if value is not None and not isinstance(value, str): + raise ValueError(f"Trigger {key} must be a string or null") valid_sources = { "gitea", "grafana", @@ -708,7 +734,7 @@ def _validate_trigger(trigger: dict) -> None: f"Invalid trigger source '{source}'. " f"Valid: {', '.join(sorted(valid_sources))}" ) - if not trigger: + if not any(trigger.values()): raise ValueError("Trigger must have at least one condition") @staticmethod @@ -874,7 +900,15 @@ async def fire_triggers(self, source: str, event_data: dict) -> int: trigger = schedule.get("trigger") if not trigger: continue - if not self._trigger_matches(trigger, source, event_data): + try: + self._validate_trigger(trigger) + matches = self._trigger_matches(trigger, source, event_data) + except (ValueError, TypeError, AttributeError) as exc: + log.warning( + "Skipping malformed trigger for schedule %s: %s", schedule.get("id"), exc, + ) + continue + if not matches: continue if self._requires_connection(schedule) and not availability.available: continue @@ -1364,7 +1398,11 @@ async def _execute_and_record( ): await self._restore_unstarted_reservation(schedule, reservation) raise ScheduleConnectionUnavailableError(snapshot) - if self._tracks_run_start(schedule): + if reservation is not None: + if not await self._admit_reserved_execution(schedule): + await self._restore_unstarted_reservation(schedule, reservation) + return False + elif self._tracks_run_start(schedule): await self._mark_run_started(schedule) identity = self._execution_identity(schedule) nonce = uuid.uuid4().hex @@ -1649,6 +1687,7 @@ async def _tick(self) -> None: reservation = self._capture_reservation_before_mutation( schedule, epoch ) + schedule["last_run"] = now.isoformat() self._capture_reservation_after_mutation(reservation, schedule) to_fire.append((copy.deepcopy(schedule), reservation, epoch)) continue diff --git a/tests/test_campaign_scheduler_admission.py b/tests/test_campaign_scheduler_admission.py new file mode 100644 index 000000000..61dd5f954 --- /dev/null +++ b/tests/test_campaign_scheduler_admission.py @@ -0,0 +1,100 @@ +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest + +from src.scheduler.scheduler import Scheduler + + +@pytest.mark.parametrize("route", ["tick", "trigger"]) +@pytest.mark.parametrize("change", ["delete", "pause", "payload"]) +async def test_queued_obsolete_schedule_never_starts(tmp_path, route, change): + scheduler = Scheduler(str(tmp_path / "schedules.json")) + timing = {"cron": "0 * * * *"} if route == "tick" else {"trigger": {"source": "github"}} + first = await scheduler.add("first", "reminder", "1", **timing) + second = await scheduler.add("second", "reminder", "1", message="old", **timing) + if route == "tick": + for schedule in scheduler._schedules: + schedule["next_run"] = (datetime.now(UTC) - timedelta(seconds=1)).isoformat() + entered, release = asyncio.Event(), asyncio.Event() + calls = [] + + async def callback(schedule): + calls.append(schedule["id"]) + if schedule["id"] == first["id"]: + entered.set() + await release.wait() + + scheduler._callback = callback + pending = asyncio.create_task(scheduler._tick() if route == "tick" else scheduler.fire_triggers("github", {})) + await asyncio.wait_for(entered.wait(), 2) + try: + if change == "delete": + await scheduler.delete(second["id"]) + elif change == "pause": + await scheduler.update(second["id"], paused=True) + else: + await scheduler.update(second["id"], message="new") + finally: + release.set() + await pending + assert calls == [first["id"]] + assert len(await scheduler.history.query()) == 1 + assert scheduler._gate_reservations == {} + assert scheduler._in_flight == set() + + +@pytest.mark.parametrize("trigger", [{}, {"source": None}, {"event": ""}, {"source": None, "repo": ""}]) +def test_conditionless_triggers_rejected(trigger): + with pytest.raises(ValueError, match="at least one condition"): + Scheduler._validate_trigger(trigger) + + +@pytest.mark.parametrize("key", ["source", "event", "repo", "alert_name"]) +@pytest.mark.parametrize("value", [123, False, [], {}]) +def test_trigger_filters_require_strings(key, value): + with pytest.raises(ValueError, match="string or null"): + Scheduler._validate_trigger({"source": "github", key: value}) + + +async def test_bad_persisted_trigger_does_not_block_later_delivery(tmp_path): + scheduler = Scheduler(str(tmp_path / "schedules.json")) + await scheduler.add("bad", "reminder", "1", trigger={"source": "github"}) + valid = await scheduler.add("good", "reminder", "1", trigger={"repo": "odin", "event": None}) + scheduler._schedules[0]["trigger"]["repo"] = 123 + calls = [] + + async def callback(schedule): + calls.append(schedule["id"]) + + scheduler._callback = callback + assert await scheduler.fire_triggers("github", {"repo": "Calmingstorm/Odin"}) == 1 + assert calls == [valid["id"]] + + +async def test_retry_stamps_latest_attempt(tmp_path, monkeypatch): + import src.scheduler.scheduler as module + + now = datetime(2026, 9, 29, 12, tzinfo=UTC) + + class Clock(datetime): + @classmethod + def now(cls, tz=None): + return now.astimezone(tz or UTC) + + monkeypatch.setattr(module, "datetime", Clock) + scheduler = Scheduler(str(tmp_path / "schedules.json")) + schedule = await scheduler.add("retry", "reminder", "1", run_at=now.isoformat(), max_retries=2) + stamps = [] + + async def callback(record): + stamps.append(record["last_run"]) + raise RuntimeError("synthetic failure") + + scheduler._callback = callback + await scheduler._tick() + now += timedelta(seconds=61) + await scheduler._tick() + assert stamps == ["2026-09-29T12:00:00+00:00", now.isoformat()] + persisted = next(record for record in scheduler.list_all() if record["id"] == schedule["id"]) + assert persisted["last_run"] == now.isoformat() From 9482e2e1815a1624880d99b2b5bab5efbe64cfbd Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:52:46 -0400 Subject: [PATCH 011/162] Preserve deferred workflow conditions, failures, target contracts and skill schemas (#433 #606 #610 #611 #615) --- src/discord/background_task.py | 20 +- src/discord/scheduled_events.py | 11 +- src/tools/defs/media_scheduling.py | 9 + src/tools/nested_payload.py | 16 +- ...test_background_task_failure_visibility.py | 4 +- tests/test_campaign_scheduler_workflows.py | 179 ++++++++++++++++++ tests/test_scheduled_events.py | 2 +- 7 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 tests/test_campaign_scheduler_workflows.py diff --git a/src/discord/background_task.py b/src/discord/background_task.py index 8cdf69b1b..5cb8f1d3d 100644 --- a/src/discord/background_task.py +++ b/src/discord/background_task.py @@ -227,7 +227,7 @@ async def run_background_task( continue # Evaluate condition - if condition and prev_output: + if condition: if not _check_condition(condition, prev_output): task.results.append( StepResult( @@ -654,6 +654,17 @@ async def _execute_tool_captured( skill_input = tool_input.get("input") or {} if not isinstance(skill_input, dict): return "Error: invoke_skill 'input' must be an object." + definitions = skill_manager.get_tool_definitions() + if isinstance(definitions, list): + from ..tools.nested_payload import validate_nested_payload + + try: + validate_nested_payload( + "invoke_skill", {"name": target_name, "input": skill_input}, + definitions, allow_placeholders=False, + ) + except ValueError as exc: + return f"Error: invoke_skill invalid selected skill input: {exc}" return await skill_manager.execute( target_name, skill_input, requester_id=requester_id or None ) @@ -668,11 +679,8 @@ async def _execute_tool_captured( # consume .ok so a failed/uncertain MCP step aborts per on_failure. return await dispatch_mcp_tool(mcp_manager, tool_name, tool_input) - # Built-in tools via executor — default missing required fields - if "host" not in tool_input: - default_host = _get_default_host(executor, requester_id) - if default_host: - tool_input = {**tool_input, "host": default_host} + # Let the executor apply each tool's host contract. In particular, + # http_probe omission means local and apply_patch requires explicit host. # run_command/run_script: if 'command'/'script' missing, let executor handle it # (it will return an error that _is_error_output catches) # Return the structured result — run_background_task consumes .ok so a diff --git a/src/discord/scheduled_events.py b/src/discord/scheduled_events.py index b8ff685e2..1ec0ccad6 100644 --- a/src/discord/scheduled_events.py +++ b/src/discord/scheduled_events.py @@ -291,7 +291,14 @@ async def _execute_scheduled_tool( if isinstance(result, ToolResult): return result - return ToolResult(output=str(result), ok=True, tool_name=tool_name) + from .background_task import _is_error_output + + output = str(result) + failed = _is_error_output(output) + return ToolResult( + output=output, ok=not failed, + error="tool_reported_failure" if failed else None, tool_name=tool_name, + ) async def _run_scheduled_workflow( self, @@ -351,7 +358,7 @@ async def _run_scheduled_workflow( step_desc = step.get("description", tool_name) # Evaluate condition against previous step's output - if condition and prev_output: + if condition: if condition.startswith("!"): # Negated condition: skip if substring IS present if condition[1:].lower() in prev_output.lower(): diff --git a/src/tools/defs/media_scheduling.py b/src/tools/defs/media_scheduling.py index b053ab136..45d3df373 100644 --- a/src/tools/defs/media_scheduling.py +++ b/src/tools/defs/media_scheduling.py @@ -317,6 +317,15 @@ "tool_name": {"type": "string"}, "tool_input": {"type": "object"}, "description": {"type": "string"}, + "condition": { + "type": "string", + "description": "Run if previous output contains this (! to negate)", + }, + "on_failure": { + "type": "string", + "enum": ["abort", "continue"], + "description": "Default: abort", + }, }, "required": ["tool_name"], }, diff --git a/src/tools/nested_payload.py b/src/tools/nested_payload.py index d84e6f34a..2600a7913 100644 --- a/src/tools/nested_payload.py +++ b/src/tools/nested_payload.py @@ -119,6 +119,7 @@ def validate_nested_payload( catalog: list[dict], *, allow_placeholders: bool = True, + _depth: int = 0, ) -> dict: """Decode nested JSON fields, validate selected canonical targets, return canonical args. @@ -127,6 +128,8 @@ def validate_nested_payload( """ from .registry import TOOLS + if _depth > 16: + raise ValueError("nested tool input exceeds maximum validation depth") args = decode_nested_payloads(tool_name, canonical_arguments) def check(target, payload, label): @@ -150,6 +153,12 @@ def check(target, payload, label): _validate(schema, validation_view, allow_placeholders=allow_placeholders) else: _validate(schema, payload, allow_placeholders=allow_placeholders) + if target in _NESTED_FIELDS: + decoded = validate_nested_payload( + target, payload, catalog, allow_placeholders=allow_placeholders, + _depth=_depth + 1, + ) + payload.update(decoded) if tool_name in ("schedule_task", "update_schedule"): if ( @@ -181,6 +190,9 @@ def check(target, payload, label): check(step["tool_name"], step["tool_input"], f"step {i} tool_input") elif tool_name == "invoke_skill": target = args.get("name") - if target and isinstance(args.get("input"), dict): - check(target, args["input"], "input") + if target: + payload = args.get("input") + if payload is None: + payload = {} + check(target, payload, "input") return ValidatedNestedPayload(args) diff --git a/tests/test_background_task_failure_visibility.py b/tests/test_background_task_failure_visibility.py index 670a4d6ec..dc734e077 100644 --- a/tests/test_background_task_failure_visibility.py +++ b/tests/test_background_task_failure_visibility.py @@ -135,7 +135,9 @@ async def test_nested_invoke_skill_permission_is_checked_for_selected_skill(self side_effect=[None, "Permission denied: selected skill"] ) executor._tool_catalog = MagicMock() - executor._tool_catalog.merged_definitions.return_value = [] + executor._tool_catalog.merged_definitions.return_value = [ + {"name": "selected", "input_schema": {"type": "object"}}, + ] skill_manager = MagicMock() skill_manager.execute = AsyncMock() skill_manager.has_skill.return_value = True diff --git a/tests/test_campaign_scheduler_workflows.py b/tests/test_campaign_scheduler_workflows.py new file mode 100644 index 000000000..fa2f14aac --- /dev/null +++ b/tests/test_campaign_scheduler_workflows.py @@ -0,0 +1,179 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.discord.background_task import BackgroundTask, _execute_tool_captured, run_background_task +from src.llm.strict_tool_adapter import compile_catalog +from src.scheduler.scheduler import Scheduler +from src.tools.nested_payload import validate_nested_payload +from src.tools.registry import get_tool_definitions +from src.tools.result_validator import ToolResult +from tests.fakes import FakeChannel +from tests.test_scheduled_events import _handlers + + +class Executor: + def __init__(self): + self.calls = [] + self._tool_catalog = SimpleNamespace(merged_definitions=lambda: skill_catalog()) + + def check_permission(self, tool_name, user_id): + return None + + def _resolve_default_host(self, user_id): + return "nonlocal" + + async def execute(self, tool_name, tool_input, user_id=None): + self.calls.append((tool_name, tool_input)) + return ToolResult(output="", tool_name=tool_name) + + +class Skills: + def __init__(self): + self.calls = [] + + def has_skill(self, name): + return name == "needs_count" + + def get_tool_definitions(self): + return skill_catalog() + + async def execute(self, name, payload, requester_id=None): + self.calls.append((name, payload)) + return "done" + + +def skill_catalog(): + return [{"name": "needs_count", "input_schema": { + "type": "object", "properties": {"count": {"type": "integer"}}, + "required": ["count"], "additionalProperties": False, + }}] + + +@pytest.mark.parametrize("condition,expected", [("READY", False), ("!READY", True)]) +async def test_delegated_empty_output_obeys_conditions(condition, expected): + executor = Executor() + task = BackgroundTask(task_id="test", description="conditions", requester="tester", + requester_id="123", channel=FakeChannel(id=555), steps=[ + {"tool_name": "run_command", "tool_input": {"command": "inert"}}, + {"tool_name": "run_command", "tool_input": {"command": "guarded"}, + "condition": condition}, + ]) + await run_background_task(task, executor, Skills()) + assert len(executor.calls) == (2 if expected else 1) + assert task.results[1].status == ("ok" if expected else "skipped") + + +@pytest.mark.parametrize("condition,expected", [("READY", False), ("!READY", True)]) +async def test_scheduled_empty_output_obeys_conditions(condition, expected): + loop = MagicMock(dispatch_loop_tool_inner=AsyncMock(return_value=ToolResult(output=""))) + handler = _handlers(tool_loop=loop) + assert await handler._run_scheduled_workflow(FakeChannel(id=555), {"steps": [ + {"tool_name": "run_command", "tool_input": {"command": "inert"}}, + {"tool_name": "run_command", "tool_input": {"command": "guarded"}, "condition": condition}, + ]}) + assert loop.dispatch_loop_tool_inner.await_count == (2 if expected else 1) + + +@pytest.mark.parametrize("output", ["Error: native rejected", "Permission denied: selected skill", "Unknown tool: removed"]) +async def test_native_error_becomes_failed_scheduled_result(output): + loop = MagicMock(dispatch_loop_tool_inner=AsyncMock(return_value=output)) + handler = _handlers(tool_loop=loop) + result = await handler._execute_scheduled_tool("invoke_skill", {}, FakeChannel(id=555), "123") + assert not result.ok + assert result.error == "tool_reported_failure" + assert result.output == output + + +async def test_structured_scheduled_result_is_preserved(): + original = ToolResult(output="unknown settlement", ok=False, uncertain_outcome=True, + audit_metadata={"phase": "test"}) + loop = MagicMock(dispatch_loop_tool_inner=AsyncMock(return_value=original)) + result = await _handlers(tool_loop=loop)._execute_scheduled_tool("invoke_skill", {}, FakeChannel(id=555), "123") + assert result is original + + +async def test_native_error_aborts_workflow_and_reaches_scheduler_retry_counter(tmp_path): + channel = FakeChannel(id=555) + loop = MagicMock(dispatch_loop_tool_inner=AsyncMock(return_value="Error: native failure")) + handler = _handlers(tool_loop=loop, get_channel=lambda _: channel) + scheduler = Scheduler(str(tmp_path / "schedules.json")) + schedule = await scheduler.add("failed workflow", "workflow", "555", run_at="2999-01-01T00:00:00Z", max_retries=2, steps=[ + {"tool_name": "invoke_skill", "tool_input": {"name": "test"}}, + {"tool_name": "run_command", "tool_input": {"command": "must not execute"}}, + ]) + scheduler._callback = handler._on_scheduled_task + assert (await scheduler.run_now(schedule["id"]))["status"] == "failure" + current = scheduler.list_all()[0] + assert current["consecutive_failures"] == 1 + assert current["retry_count"] == 1 + assert current["retry_at"] + assert (await scheduler.history.query())[0]["status"] == "failure" + loop.dispatch_loop_tool_inner.assert_awaited_once() + + +@pytest.mark.parametrize("tool,payload", [("http_probe", {"url": "https://example.invalid"}), + ("apply_patch", {"root": "/tmp/test", "patch_text": "inert"}), + ("run_command", {"command": "inert"}), + ("run_script", {"script": "inert"})]) +async def test_background_preserves_executor_host_contract(tool, payload): + executor = Executor() + await _execute_tool_captured(tool, payload, executor, Skills(), None, None, "tester", requester_id="123") + assert executor.calls == [(tool, payload)] + assert "host" not in payload + + +@pytest.mark.parametrize("wrapper", ["delegate_task", "schedule_task", "update_schedule"]) +@pytest.mark.parametrize("payload", [{}, {"count": "wrong"}]) +def test_nested_invoke_skill_validates_selected_schema(wrapper, payload): + step = {"tool_name": "invoke_skill", "tool_input": {"name": "needs_count", "input": payload}} + with pytest.raises(ValueError, match="invalid input"): + validate_nested_payload(wrapper, {"steps": [step]}, skill_catalog(), allow_placeholders=False) + + +@pytest.mark.parametrize("strict", [False, True]) +async def test_delegated_invalid_selected_skill_never_executes(strict): + executor, skills = Executor(), Skills() + task = BackgroundTask(task_id="test", description="schema", requester="tester", + requester_id="123", channel=FakeChannel(id=555), steps=[ + {"tool_name": "invoke_skill", "tool_input": {"name": "needs_count", "input": {}}}, + ]) + task.nested_payload_validated = strict + await run_background_task(task, executor, skills) + assert task.status == "failed" + assert skills.calls == [] + + +async def test_valid_selected_skill_executes_with_integer_input(): + skills = Skills() + result = await _execute_tool_captured("invoke_skill", {"name": "needs_count", "input": {"count": 3}}, + Executor(), skills, None, None, "tester", requester_id="123") + assert result == "done" + assert skills.calls == [("needs_count", {"count": 3})] + + +async def test_scheduled_concrete_skill_schema_rechecked_before_dispatch(): + loop = MagicMock(dispatch_loop_tool_inner=AsyncMock()) + loop._tool_catalog.merged_definitions.return_value = skill_catalog() + handler = _handlers(tool_loop=loop) + assert not await handler._run_scheduled_workflow(FakeChannel(id=555), { + "_nested_payload_validated": True, "steps": [{"tool_name": "invoke_skill", + "tool_input": {"name": "needs_count", "input": {}}}], + }) + loop.dispatch_loop_tool_inner.assert_not_awaited() + + +def test_update_workflow_strict_transport_preserves_condition_and_failure_policy(): + adapter = compile_catalog(get_tool_definitions()) + schema = next(tool["parameters"] for tool in adapter.wire_tools if tool["name"] == "update_schedule") + args = {key: None for key in schema["properties"]} + steps_schema = next(branch for branch in schema["properties"]["steps"]["anyOf"] if branch.get("type") == "array") + step = {key: None for key in steps_schema["items"]["properties"]} + step.update(tool_name="run_command", tool_input=json.dumps({"command": "inert"}), + condition="READY", on_failure="continue") + args.update(schedule_id="schedule", steps=[step]) + result = adapter.accept("update_schedule", args) + assert result["steps"][0] == {"tool_name": "run_command", "tool_input": {"command": "inert"}, + "condition": "READY", "on_failure": "continue"} diff --git a/tests/test_scheduled_events.py b/tests/test_scheduled_events.py index b626cfe2b..be5d64688 100644 --- a/tests/test_scheduled_events.py +++ b/tests/test_scheduled_events.py @@ -345,7 +345,7 @@ async def test_strict_workflow_checks_skill_target_permission(self): h = _handlers(tool_executor=executor) h._tool_loop._tool_catalog = SimpleNamespace(merged_definitions=lambda: [{ "name": "invoke_skill", "input_schema": {"type": "object"}, - }]) + }, {"name": "private", "input_schema": {"type": "object"}}]) result = await h._run_scheduled_workflow(_channel(), { "description": "strict", "requester_id": "u", "_nested_payload_validated": True, "steps": [{"tool_name": "invoke_skill", "tool_input": {"name": "private"}}], From 31638968497c9b567b097abe24156c5cd78fa3f7 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:02:24 -0400 Subject: [PATCH 012/162] Retire native Grafana triggers without breaking stored schedules --- src/scheduler/scheduler.py | 30 ++++--------- tests/test_campaign_scheduler_admission.py | 19 +++++--- ...est_campaign_scheduler_removed_triggers.py | 39 ++++++++++++++++ tests/test_scheduler.py | 45 +++---------------- tests/test_scheduler_validation.py | 4 +- 5 files changed, 70 insertions(+), 67 deletions(-) create mode 100644 tests/test_campaign_scheduler_removed_triggers.py diff --git a/src/scheduler/scheduler.py b/src/scheduler/scheduler.py index 62b7ef20c..0816a295a 100644 --- a/src/scheduler/scheduler.py +++ b/src/scheduler/scheduler.py @@ -110,7 +110,7 @@ def __init__(self, snapshot: ConnectionAvailability) -> None: WEBHOOK_MAX_TIMEOUT = 300 # 5 minutes WEBHOOK_MAX_URL_LEN = 2048 WEBHOOK_MAX_BODY_LEN = 1_000_000 # 1 MB -REMOVED_TRIGGER_SOURCES = frozenset({"discord_reaction", "discord_message"}) +REMOVED_TRIGGER_SOURCES = frozenset({"discord_reaction", "discord_message", "grafana"}) _execution_admission: ContextVar[tuple[object, str, str] | None] = ContextVar( "scheduler_execution_admission", default=None @@ -273,7 +273,7 @@ def _load(self) -> None: self._schedules = [] def _degrade_removed_trigger_sources(self) -> None: - """Keep legacy Discord-trigger schedules visible but inert. + """Keep legacy removed-trigger schedules visible but inert. Older stores may contain trigger sources whose Discord cogs no longer exist. One obsolete entry must not fail the whole store load. These @@ -285,11 +285,13 @@ def _degrade_removed_trigger_sources(self) -> None: continue trigger = schedule.get("trigger") source = trigger.get("source") if isinstance(trigger, dict) else None - if source not in REMOVED_TRIGGER_SOURCES: + removed_filter = isinstance(trigger, dict) and "alert_name" in trigger + removed_source = isinstance(source, str) and source in REMOVED_TRIGGER_SOURCES + if not removed_source and not removed_filter: continue schedule["paused"] = True schedule["inert_reason"] = ( - f"Trigger source '{source}' was removed; replace the timing " + f"Trigger source '{source}' or its alert filter was removed; replace the timing " "trigger before resuming this schedule." ) log.warning( @@ -713,7 +715,7 @@ def _validate_trigger(trigger: dict) -> None: if not isinstance(trigger, dict): raise ValueError("'trigger' must be a dict") valid_keys = { - "source", "event", "repo", "alert_name", + "source", "event", "repo", } unknown = set(trigger.keys()) - valid_keys if unknown: @@ -723,7 +725,6 @@ def _validate_trigger(trigger: dict) -> None: raise ValueError(f"Trigger {key} must be a string or null") valid_sources = { "gitea", - "grafana", "generic", "github", "gitlab", @@ -852,11 +853,10 @@ def _trigger_matches(trigger: dict, source: str, event_data: dict) -> bool: - source: exact match (required if specified) - event: exact match against event_data["event"] - repo: case-insensitive substring match against event_data["repo"] - - alert_name: case-insensitive substring match against any string in - event_data["alert_names"], falling back to event_data["alert_name"] - All specified fields must match (AND logic). """ + if trigger.get("source") in REMOVED_TRIGGER_SOURCES or "alert_name" in trigger: + return False if trigger.get("source") and trigger["source"] != source: return False if trigger.get("event"): @@ -866,18 +866,6 @@ def _trigger_matches(trigger: dict, source: str, event_data: dict) -> bool: repo = event_data.get("repo", "") if trigger["repo"].lower() not in repo.lower(): return False - if trigger.get("alert_name"): - alert_names = event_data.get("alert_names") - if alert_names is None: - alert_names = [event_data.get("alert_name", "")] - elif not isinstance(alert_names, (list, tuple)): - alert_names = [] - if not any( - isinstance(name, str) - and trigger["alert_name"].lower() in name.lower() - for name in alert_names - ): - return False return True async def fire_triggers(self, source: str, event_data: dict) -> int: diff --git a/tests/test_campaign_scheduler_admission.py b/tests/test_campaign_scheduler_admission.py index 61dd5f954..5f082913e 100644 --- a/tests/test_campaign_scheduler_admission.py +++ b/tests/test_campaign_scheduler_admission.py @@ -26,7 +26,8 @@ async def callback(schedule): await release.wait() scheduler._callback = callback - pending = asyncio.create_task(scheduler._tick() if route == "tick" else scheduler.fire_triggers("github", {})) + dispatch = scheduler._tick() if route == "tick" else scheduler.fire_triggers("github", {}) + pending = asyncio.create_task(dispatch) await asyncio.wait_for(entered.wait(), 2) try: if change == "delete": @@ -44,24 +45,32 @@ async def callback(schedule): assert scheduler._in_flight == set() -@pytest.mark.parametrize("trigger", [{}, {"source": None}, {"event": ""}, {"source": None, "repo": ""}]) +@pytest.mark.parametrize("trigger", [ + {}, {"source": None}, {"event": ""}, {"source": None, "repo": ""}, +]) def test_conditionless_triggers_rejected(trigger): with pytest.raises(ValueError, match="at least one condition"): Scheduler._validate_trigger(trigger) -@pytest.mark.parametrize("key", ["source", "event", "repo", "alert_name"]) +@pytest.mark.parametrize("key", ["source", "event", "repo"]) @pytest.mark.parametrize("value", [123, False, [], {}]) def test_trigger_filters_require_strings(key, value): with pytest.raises(ValueError, match="string or null"): Scheduler._validate_trigger({"source": "github", key: value}) -async def test_bad_persisted_trigger_does_not_block_later_delivery(tmp_path): +@pytest.mark.parametrize("trigger", [ + {"source": "github", "repo": 123}, {"source": []}, {"source": {}}, + {"source": None}, {"source": "github", "event": False}, +]) +async def test_bad_persisted_trigger_does_not_block_later_delivery(tmp_path, trigger): scheduler = Scheduler(str(tmp_path / "schedules.json")) await scheduler.add("bad", "reminder", "1", trigger={"source": "github"}) valid = await scheduler.add("good", "reminder", "1", trigger={"repo": "odin", "event": None}) - scheduler._schedules[0]["trigger"]["repo"] = 123 + scheduler._schedules[0]["trigger"] = trigger + scheduler._save() + scheduler = Scheduler(str(tmp_path / "schedules.json")) calls = [] async def callback(schedule): diff --git a/tests/test_campaign_scheduler_removed_triggers.py b/tests/test_campaign_scheduler_removed_triggers.py new file mode 100644 index 000000000..28c9fc672 --- /dev/null +++ b/tests/test_campaign_scheduler_removed_triggers.py @@ -0,0 +1,39 @@ +import json +from unittest.mock import AsyncMock + +import pytest + +from src.scheduler.scheduler import Scheduler + + +@pytest.mark.parametrize("trigger", [{"source": "grafana", "event": "alert"}, + {"alert_name": "cpu"}, + {"source": "github", "alert_name": ""}]) +async def test_removed_triggers_load_inert_without_losing_other_schedules(tmp_path, trigger): + path = tmp_path / "schedules.json" + seed = Scheduler(str(path)) + legacy = await seed.add("legacy", "reminder", "1", trigger={"source": "github"}) + active = await seed.add("active", "reminder", "1", trigger={"source": "github"}) + seed._schedules[0]["trigger"] = trigger + seed._save() + original = path.read_bytes() + scheduler = Scheduler(str(path)) + scheduler._callback = AsyncMock() + records = scheduler.list_all() + assert len(records) == 2 + assert records[0]["id"] == legacy["id"] + assert records[0]["paused"] + assert "removed" in records[0]["inert_reason"] + assert records[0]["trigger"] == trigger + assert path.read_bytes() == original + assert await scheduler.fire_triggers("grafana", {"event": "alert"}) == 0 + assert await scheduler.fire_triggers("github", {}) == 1 + assert scheduler._callback.await_args.args[0]["id"] == active["id"] + with pytest.raises(ValueError, match="removed"): + await scheduler.run_now(legacy["id"]) + with pytest.raises(ValueError, match="removed"): + await scheduler.update(legacy["id"], paused=False) + repaired = await scheduler.update(legacy["id"], trigger={"source": "github"}) + assert not repaired.get("paused") + assert not repaired.get("inert_reason") + assert len(json.loads(path.read_text())) == 2 diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index a2b76d478..de2d5ae84 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -370,48 +370,15 @@ async def test_tick_callback_error_does_not_crash(self, tmp_path): # --------------------------------------------------------------------------- class TestSchedulerFireTriggers: - async def test_named_grafana_trigger_matches_any_alert_in_batch(self, tmp_path): + async def test_removed_trigger_source_is_rejected_for_new_schedules(self, tmp_path): s = _make_scheduler(tmp_path) - cb = AsyncMock() - s._callback = cb - - await s.add( - "second alert", "reminder", "chan1", - trigger={"source": "grafana", "event": "alert", "alert_name": "second"}, - ) - await s.add( - "catch all", "reminder", "chan1", - trigger={"source": "grafana", "event": "alert"}, - ) - await s.add( - "wrong event", "reminder", "chan1", - trigger={"source": "grafana", "event": "resolved", "alert_name": "second"}, - ) - await s.add( - "absent", "reminder", "chan1", - trigger={"source": "grafana", "event": "alert", "alert_name": "missing"}, - ) - - fired = await s.fire_triggers("grafana", { - "event": "alert", "alert_name": "FIRST", - "alert_names": ["FIRST", "Second CPU"], - }) - assert fired == 2 - assert cb.await_count == 2 + with pytest.raises(ValueError, match="Invalid trigger source"): + await s.add("removed", "reminder", "chan1", trigger={"source": "grafana"}) - async def test_single_name_event_data_still_matches(self, tmp_path): + async def test_removed_alert_filter_is_rejected_for_new_schedules(self, tmp_path): s = _make_scheduler(tmp_path) - cb = AsyncMock() - s._callback = cb - await s.add( - "legacy", "reminder", "chan1", - trigger={"source": "grafana", "event": "alert", "alert_name": "highcpu"}, - ) - - assert await s.fire_triggers("grafana", { - "event": "alert", "alert_name": "HighCPU", - }) == 1 - cb.assert_awaited_once() + with pytest.raises(ValueError, match="Unknown trigger keys"): + await s.add("removed", "reminder", "chan1", trigger={"alert_name": "cpu"}) async def test_fire_triggers_matching(self, tmp_path): s = _make_scheduler(tmp_path) diff --git a/tests/test_scheduler_validation.py b/tests/test_scheduler_validation.py index 90290fa62..3fe937ecb 100644 --- a/tests/test_scheduler_validation.py +++ b/tests/test_scheduler_validation.py @@ -90,13 +90,13 @@ class TestTriggerMatches: def test_field_matching(self, sched): m = sched._trigger_matches # source mismatch → False - assert m({"source": "gitea"}, "grafana", {}) is False + assert m({"source": "gitea"}, "github", {}) is False # exact-match fields assert m({"event": "push"}, "gitea", {"event": "push"}) is True assert m({"event": "push"}, "gitea", {"event": "pull"}) is False # substring (case-insensitive) fields assert m({"repo": "Odin"}, "gitea", {"repo": "calmingstorm/odin"}) is True - assert m({"alert_name": "cpu"}, "grafana", {"alert_name": "High CPU"}) is True + assert m({"alert_name": "cpu"}, "generic", {}) is False # empty trigger with any source → matches (no conditions) assert m({}, "generic", {}) is True From c2a8779cd1a5bb151cbefc7d9f93f649ab668743 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:02:24 -0400 Subject: [PATCH 013/162] Cover concrete deferred host contracts and nested skill wire decoding --- src/discord/background_task.py | 25 +++----- src/tools/nested_payload.py | 5 +- tests/test_campaign_scheduler_time.py | 12 +++- tests/test_campaign_scheduler_workflows.py | 72 ++++++++++++++++++---- 4 files changed, 84 insertions(+), 30 deletions(-) diff --git a/src/discord/background_task.py b/src/discord/background_task.py index 5cb8f1d3d..b2e67d0fd 100644 --- a/src/discord/background_task.py +++ b/src/discord/background_task.py @@ -442,12 +442,6 @@ async def run_background_task( ) -def _get_default_host(executor: ToolExecutor, requester_id: str = "") -> str: - """Use explicit requester/runtime default policy, never mapping order.""" - resolver = getattr(executor, "_resolve_default_host", None) - return resolver(requester_id or None) if callable(resolver) else "" - - def _is_error_output(output: str) -> bool: """Detect error strings returned as successful results by executor/handlers.""" if not output: @@ -655,16 +649,17 @@ async def _execute_tool_captured( if not isinstance(skill_input, dict): return "Error: invoke_skill 'input' must be an object." definitions = skill_manager.get_tool_definitions() - if isinstance(definitions, list): - from ..tools.nested_payload import validate_nested_payload + if not isinstance(definitions, list): + return "Error: invoke_skill selected skill catalog is unavailable." + from ..tools.nested_payload import validate_nested_payload - try: - validate_nested_payload( - "invoke_skill", {"name": target_name, "input": skill_input}, - definitions, allow_placeholders=False, - ) - except ValueError as exc: - return f"Error: invoke_skill invalid selected skill input: {exc}" + try: + validate_nested_payload( + "invoke_skill", {"name": target_name, "input": skill_input}, + definitions, allow_placeholders=False, + ) + except ValueError as exc: + return f"Error: invoke_skill invalid selected skill input: {exc}" return await skill_manager.execute( target_name, skill_input, requester_id=requester_id or None ) diff --git a/src/tools/nested_payload.py b/src/tools/nested_payload.py index 2600a7913..085baad9a 100644 --- a/src/tools/nested_payload.py +++ b/src/tools/nested_payload.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import json import re from typing import Any @@ -130,7 +131,7 @@ def validate_nested_payload( if _depth > 16: raise ValueError("nested tool input exceeds maximum validation depth") - args = decode_nested_payloads(tool_name, canonical_arguments) + args = decode_nested_payloads(tool_name, copy.deepcopy(canonical_arguments)) def check(target, payload, label): schema = _schema_for(target, catalog) or _schema_for(target, TOOLS) @@ -138,6 +139,8 @@ def check(target, payload, label): raise ValueError(f"{label} selects unknown tool {target!r}") if not isinstance(payload, dict): raise ValueError(f"{label} must be an object") + if target in _NESTED_FIELDS: + payload.update(decode_nested_payloads(target, payload)) # http_probe's new wire schema uses header records, while its public # canonical API and persisted legacy inputs still accept a dictionary. # Validate an equivalent record view, keeping the original dict for diff --git a/tests/test_campaign_scheduler_time.py b/tests/test_campaign_scheduler_time.py index 4f2530e17..4ddbe2aef 100644 --- a/tests/test_campaign_scheduler_time.py +++ b/tests/test_campaign_scheduler_time.py @@ -6,7 +6,9 @@ from src.tools.time_parser import parse_time -@pytest.mark.parametrize("fold,minute,expected", [(0, 15, "-04:00"), (1, 15, "-05:00"), (0, 45, "-05:00")]) +@pytest.mark.parametrize("fold,minute,expected", [ + (0, 15, "-04:00"), (1, 15, "-05:00"), (0, 45, "-05:00"), +]) def test_next_clock_selects_future_instant_in_repeated_hour(fold, minute, expected): now = datetime(2026, 11, 1, 1, minute, tzinfo=ZoneInfo("America/New_York"), fold=fold) result = datetime.fromisoformat(parse_time("at 1:30am", now=now)) @@ -26,9 +28,13 @@ def test_invalid_twelve_hour_clocks_rejected(hour, suffix): parse_time(f"today at {hour}{suffix}", now=datetime(2026, 9, 29, tzinfo=UTC)) -@pytest.mark.parametrize("expression,expected", [("today at 12am", "00:00"), ("today at 12pm", "12:00"), ("today at 1am", "01:00"), ("today at 1pm", "13:00")]) +@pytest.mark.parametrize("expression,expected", [ + ("today at 12am", "00:00"), ("today at 12pm", "12:00"), + ("today at 1am", "01:00"), ("today at 1pm", "13:00"), +]) def test_twelve_hour_clock_boundaries(expression, expected): - assert parse_time(expression, now=datetime(2026, 9, 29, tzinfo=UTC)) == f"2026-09-29T{expected}:00+00:00" + result = parse_time(expression, now=datetime(2026, 9, 29, tzinfo=UTC)) + assert result == f"2026-09-29T{expected}:00+00:00" @pytest.mark.parametrize("clock", ["17:00", "09:30"]) diff --git a/tests/test_campaign_scheduler_workflows.py b/tests/test_campaign_scheduler_workflows.py index fa2f14aac..f10e21a39 100644 --- a/tests/test_campaign_scheduler_workflows.py +++ b/tests/test_campaign_scheduler_workflows.py @@ -77,7 +77,9 @@ async def test_scheduled_empty_output_obeys_conditions(condition, expected): assert loop.dispatch_loop_tool_inner.await_count == (2 if expected else 1) -@pytest.mark.parametrize("output", ["Error: native rejected", "Permission denied: selected skill", "Unknown tool: removed"]) +@pytest.mark.parametrize("output", [ + "Error: native rejected", "Permission denied: selected skill", "Unknown tool: removed", +]) async def test_native_error_becomes_failed_scheduled_result(output): loop = MagicMock(dispatch_loop_tool_inner=AsyncMock(return_value=output)) handler = _handlers(tool_loop=loop) @@ -91,7 +93,9 @@ async def test_structured_scheduled_result_is_preserved(): original = ToolResult(output="unknown settlement", ok=False, uncertain_outcome=True, audit_metadata={"phase": "test"}) loop = MagicMock(dispatch_loop_tool_inner=AsyncMock(return_value=original)) - result = await _handlers(tool_loop=loop)._execute_scheduled_tool("invoke_skill", {}, FakeChannel(id=555), "123") + result = await _handlers(tool_loop=loop)._execute_scheduled_tool( + "invoke_skill", {}, FakeChannel(id=555), "123", + ) assert result is original @@ -100,7 +104,9 @@ async def test_native_error_aborts_workflow_and_reaches_scheduler_retry_counter( loop = MagicMock(dispatch_loop_tool_inner=AsyncMock(return_value="Error: native failure")) handler = _handlers(tool_loop=loop, get_channel=lambda _: channel) scheduler = Scheduler(str(tmp_path / "schedules.json")) - schedule = await scheduler.add("failed workflow", "workflow", "555", run_at="2999-01-01T00:00:00Z", max_retries=2, steps=[ + schedule = await scheduler.add( + "failed workflow", "workflow", "555", run_at="2999-01-01T00:00:00Z", + max_retries=2, steps=[ {"tool_name": "invoke_skill", "tool_input": {"name": "test"}}, {"tool_name": "run_command", "tool_input": {"command": "must not execute"}}, ]) @@ -115,22 +121,59 @@ async def test_native_error_aborts_workflow_and_reaches_scheduler_retry_counter( @pytest.mark.parametrize("tool,payload", [("http_probe", {"url": "https://example.invalid"}), - ("apply_patch", {"root": "/tmp/test", "patch_text": "inert"}), + ("apply_patch", {"root": "/tmp", "patch_text": "inert"}), ("run_command", {"command": "inert"}), ("run_script", {"script": "inert"})]) async def test_background_preserves_executor_host_contract(tool, payload): executor = Executor() - await _execute_tool_captured(tool, payload, executor, Skills(), None, None, "tester", requester_id="123") + await _execute_tool_captured( + tool, payload, executor, Skills(), None, None, "tester", requester_id="123", + ) assert executor.calls == [(tool, payload)] assert "host" not in payload +@pytest.mark.parametrize("tool,payload,expected_host", [ + ("http_probe", {"url": "https://example.invalid"}, None), + ("apply_patch", {"root": "/tmp", "patch_text": "inert"}, None), + ("run_command", {"command": "inert"}, "nonlocal"), + ("run_script", {"script": "inert"}, "nonlocal"), +]) +async def test_background_actual_executor_prepares_contract_defaults(tool, payload, expected_host): + from src.tools.executor import ToolExecutor + + executor = ToolExecutor.__new__(ToolExecutor) + executor._resolve_default_host = lambda _: "nonlocal" + executor.check_permission = lambda *_: None + executor._execute_inner = AsyncMock(return_value=ToolResult(output="inert")) + # Instance handlers are a supported inert seam, avoiding real host leases. + setattr(executor, "_handle_" + tool, AsyncMock()) + await _execute_tool_captured( + tool, payload, executor, Skills(), None, None, "tester", requester_id="123", + ) + prepared = executor._execute_inner.await_args.args[1] + assert prepared.get("host") == expected_host + if expected_host is None: + assert "host" not in prepared + + @pytest.mark.parametrize("wrapper", ["delegate_task", "schedule_task", "update_schedule"]) @pytest.mark.parametrize("payload", [{}, {"count": "wrong"}]) def test_nested_invoke_skill_validates_selected_schema(wrapper, payload): step = {"tool_name": "invoke_skill", "tool_input": {"name": "needs_count", "input": payload}} with pytest.raises(ValueError, match="invalid input"): - validate_nested_payload(wrapper, {"steps": [step]}, skill_catalog(), allow_placeholders=False) + validate_nested_payload( + wrapper, {"steps": [step]}, skill_catalog(), allow_placeholders=False, + ) + + +def test_nested_skill_wire_json_decodes_without_mutating_original(): + args = {"steps": [{"tool_name": "invoke_skill", "tool_input": { + "name": "needs_count", "input": '{"count":3}', + }}]} + result = validate_nested_payload("delegate_task", args, skill_catalog()) + assert result["steps"][0]["tool_input"]["input"] == {"count": 3} + assert args["steps"][0]["tool_input"]["input"] == '{"count":3}' @pytest.mark.parametrize("strict", [False, True]) @@ -138,7 +181,8 @@ async def test_delegated_invalid_selected_skill_never_executes(strict): executor, skills = Executor(), Skills() task = BackgroundTask(task_id="test", description="schema", requester="tester", requester_id="123", channel=FakeChannel(id=555), steps=[ - {"tool_name": "invoke_skill", "tool_input": {"name": "needs_count", "input": {}}}, + {"tool_name": "invoke_skill", + "tool_input": {"name": "needs_count", "input": {}}}, ]) task.nested_payload_validated = strict await run_background_task(task, executor, skills) @@ -148,8 +192,10 @@ async def test_delegated_invalid_selected_skill_never_executes(strict): async def test_valid_selected_skill_executes_with_integer_input(): skills = Skills() - result = await _execute_tool_captured("invoke_skill", {"name": "needs_count", "input": {"count": 3}}, - Executor(), skills, None, None, "tester", requester_id="123") + result = await _execute_tool_captured( + "invoke_skill", {"name": "needs_count", "input": {"count": 3}}, + Executor(), skills, None, None, "tester", requester_id="123", + ) assert result == "done" assert skills.calls == [("needs_count", {"count": 3})] @@ -167,9 +213,13 @@ async def test_scheduled_concrete_skill_schema_rechecked_before_dispatch(): def test_update_workflow_strict_transport_preserves_condition_and_failure_policy(): adapter = compile_catalog(get_tool_definitions()) - schema = next(tool["parameters"] for tool in adapter.wire_tools if tool["name"] == "update_schedule") + schema = next( + tool["parameters"] for tool in adapter.wire_tools if tool["name"] == "update_schedule" + ) args = {key: None for key in schema["properties"]} - steps_schema = next(branch for branch in schema["properties"]["steps"]["anyOf"] if branch.get("type") == "array") + steps_schema = next( + branch for branch in schema["properties"]["steps"]["anyOf"] if branch.get("type") == "array" + ) step = {key: None for key in steps_schema["items"]["properties"]} step.update(tool_name="run_command", tool_input=json.dumps({"command": "inert"}), condition="READY", on_failure="continue") From 66b9ed70f49ff0942f0e81e9843bab2244ef7df7 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:03:50 -0400 Subject: [PATCH 014/162] Preserve templated skill selection and manual paused-run admission --- src/tools/nested_payload.py | 2 ++ tests/test_campaign_scheduler_admission.py | 27 ++++++++++++++++++---- tests/test_campaign_scheduler_workflows.py | 9 ++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/tools/nested_payload.py b/src/tools/nested_payload.py index 085baad9a..2485b646c 100644 --- a/src/tools/nested_payload.py +++ b/src/tools/nested_payload.py @@ -194,6 +194,8 @@ def check(target, payload, label): elif tool_name == "invoke_skill": target = args.get("name") if target: + if allow_placeholders and isinstance(target, str) and _PLACEHOLDER.search(target): + return ValidatedNestedPayload(args) payload = args.get("input") if payload is None: payload = {} diff --git a/tests/test_campaign_scheduler_admission.py b/tests/test_campaign_scheduler_admission.py index 5f082913e..21a5fb56f 100644 --- a/tests/test_campaign_scheduler_admission.py +++ b/tests/test_campaign_scheduler_admission.py @@ -6,14 +6,19 @@ from src.scheduler.scheduler import Scheduler -@pytest.mark.parametrize("route", ["tick", "trigger"]) +@pytest.mark.parametrize("route", ["tick", "one_time", "trigger"]) @pytest.mark.parametrize("change", ["delete", "pause", "payload"]) async def test_queued_obsolete_schedule_never_starts(tmp_path, route, change): scheduler = Scheduler(str(tmp_path / "schedules.json")) - timing = {"cron": "0 * * * *"} if route == "tick" else {"trigger": {"source": "github"}} + if route == "trigger": + timing = {"trigger": {"source": "github"}} + elif route == "one_time": + timing = {"run_at": "2999-01-01T00:00:00Z"} + else: + timing = {"cron": "0 * * * *"} first = await scheduler.add("first", "reminder", "1", **timing) second = await scheduler.add("second", "reminder", "1", message="old", **timing) - if route == "tick": + if route != "trigger": for schedule in scheduler._schedules: schedule["next_run"] = (datetime.now(UTC) - timedelta(seconds=1)).isoformat() entered, release = asyncio.Event(), asyncio.Event() @@ -26,7 +31,7 @@ async def callback(schedule): await release.wait() scheduler._callback = callback - dispatch = scheduler._tick() if route == "tick" else scheduler.fire_triggers("github", {}) + dispatch = scheduler.fire_triggers("github", {}) if route == "trigger" else scheduler._tick() pending = asyncio.create_task(dispatch) await asyncio.wait_for(entered.wait(), 2) try: @@ -45,6 +50,20 @@ async def callback(schedule): assert scheduler._in_flight == set() +async def test_manual_existing_pause_override_still_runs(tmp_path): + scheduler = Scheduler(str(tmp_path / "schedules.json")) + schedule = await scheduler.add("manual", "reminder", "1", cron="0 * * * *") + await scheduler.update(schedule["id"], paused=True) + calls = [] + + async def callback(record): + calls.append(record["id"]) + + scheduler._callback = callback + assert (await scheduler.run_now(schedule["id"]))["status"] == "success" + assert calls == [schedule["id"]] + + @pytest.mark.parametrize("trigger", [ {}, {"source": None}, {"event": ""}, {"source": None, "repo": ""}, ]) diff --git a/tests/test_campaign_scheduler_workflows.py b/tests/test_campaign_scheduler_workflows.py index f10e21a39..5f8ae40f1 100644 --- a/tests/test_campaign_scheduler_workflows.py +++ b/tests/test_campaign_scheduler_workflows.py @@ -176,6 +176,15 @@ def test_nested_skill_wire_json_decodes_without_mutating_original(): assert args["steps"][0]["tool_input"]["input"] == '{"count":3}' +def test_templated_selected_skill_name_defers_until_concrete_execution(): + args = {"steps": [{"tool_name": "invoke_skill", "tool_input": { + "name": "{var.selected}", "input": {"count": 3}, + }}]} + assert validate_nested_payload("delegate_task", args, skill_catalog()) == args + with pytest.raises(ValueError, match="unknown tool"): + validate_nested_payload("delegate_task", args, skill_catalog(), allow_placeholders=False) + + @pytest.mark.parametrize("strict", [False, True]) async def test_delegated_invalid_selected_skill_never_executes(strict): executor, skills = Executor(), Skills() From e85dc5bb7ea65dd55e70197db80433e305799f69 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:59:06 -0400 Subject: [PATCH 015/162] Bound validation regex work and distinguish refused probes from executed checks --- pyproject.toml | 1 + src/tools/handlers/browser_web.py | 14 ++-- src/tools/handlers/validation.py | 8 +- src/tools/post_validation.py | 28 ++++--- tests/test_campaign_execution_validation.py | 87 +++++++++++++++++++++ tests/test_hosts_executor_leases.py | 8 +- 6 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 tests/test_campaign_execution_validation.py diff --git a/pyproject.toml b/pyproject.toml index 6b5c09fb5..bd9626b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pydantic>=2.0", "jsonschema>=4.23,<5", "packaging>=23.2", + "regex>=2024.11.6", "croniter>=2.0.0", "fastembed>=0.8.0", "sqlite-vec>=0.1.0", diff --git a/src/tools/handlers/browser_web.py b/src/tools/handlers/browser_web.py index 67d377d35..06666f3fd 100644 --- a/src/tools/handlers/browser_web.py +++ b/src/tools/handlers/browser_web.py @@ -95,6 +95,13 @@ async def _handle_http_probe(self, inp: dict) -> str | tuple[str, int]: except ValueError as e: return f"http_probe error: {e}", 1 + # Validate before acquiring a generation lease: refused construction + # has no transport and must not pin the host generation. + try: + cmd = build_http_probe_command(inp) + except ValueError as e: + return f"http_probe error: {e}", 1 + host = inp.get("host", "") if host: lease = self._acquire_host(host) @@ -108,13 +115,6 @@ async def _handle_http_probe(self, inp: dict) -> str | tuple[str, int]: address = "127.0.0.1" ssh_user = "root" - try: - cmd = build_http_probe_command(inp) - except ValueError as e: - # Invalid probe input is a failed tool call, not a successful probe - # whose prose happens to describe a rejection. - return f"http_probe error: {e}", 1 - if lease is not None: with lease: code, output = await lease.run( diff --git a/src/tools/handlers/validation.py b/src/tools/handlers/validation.py index 883d14932..0d0ed742f 100644 --- a/src/tools/handlers/validation.py +++ b/src/tools/handlers/validation.py @@ -67,9 +67,11 @@ async def _exec( # Odin flagged. Emit the error into the result so the # operator sees it, and treat the check as errored. log.exception("governor check raised for validation command") - return 1, f"validate_action: governor check raised {type(ge).__name__}: {ge}" + raise RuntimeError( + f"validate_action: governor check raised {type(ge).__name__}: {ge}" + ) from ge if not allowed: - return 1, f"governor-blocked: {denial}" + raise PermissionError(f"governor-blocked: {denial}") # Forwarded per check from run_bundle: True only for type=command # (user-supplied text, a raw command route like run_command — # round 10); fixed-shape probes must keep pre-PR cwd semantics so @@ -81,7 +83,7 @@ async def _exec( alias = _address lease = self._acquire_host(alias) if lease is None: - return 1, f"unknown host alias: {alias}" + raise PermissionError(f"unknown host alias: {alias}") with lease: target = lease.target return await lease.run( diff --git a/src/tools/post_validation.py b/src/tools/post_validation.py index 51fab21bb..3437cb40d 100644 --- a/src/tools/post_validation.py +++ b/src/tools/post_validation.py @@ -28,6 +28,8 @@ from dataclasses import asdict, dataclass, field from typing import Any +import regex as bounded_regex + from ..odin_log import get_logger log = get_logger("tools.post_validation") @@ -46,13 +48,12 @@ DEFAULT_MAX_PARALLEL_CHECKS = 12 # ReDoS hardening limits for the regex_match compare op. -# The combination of pattern + output size caps is cheap belt-and-braces -# protection against catastrophic-backtracking patterns — we can't use -# re2 without adding a native dep, and Python's stdlib re has no -# timeout. We cap both the pattern length and the output window so the -# worst-case runtime is bounded. +# Size limits and heuristic rejection are defense in depth, not a runtime +# bound. The regex engine's deadline is authoritative even for shapes the +# heuristic does not recognize. MAX_REGEX_PATTERN_LEN = 200 MAX_REGEX_INPUT_CHARS = 10_000 +REGEX_TIMEOUT_SECONDS = 0.05 # Patterns with nested quantifiers are a common ReDoS footgun — reject # the obvious shapes at parse time rather than letting them run. _REDOS_HEURISTIC = re.compile( @@ -518,7 +519,7 @@ def _evaluate(check: Check, exit_code: int, output: str) -> tuple[str, str]: return "fail", f"forbidden substring '{expected}' found" return "pass", "" if compare == "equals": - if out_stripped == str(expected or ""): + if out_stripped == str(expected if expected is not None else ""): return "pass", "" return "fail", f"expected '{expected}', got '{out_stripped[:200]}'" if compare == "regex_match": @@ -538,10 +539,14 @@ def _evaluate(check: Check, exit_code: int, output: str) -> tuple[str, str]: ) haystack = output[:MAX_REGEX_INPUT_CHARS] try: - if pattern and re.search(pattern, haystack): + if pattern and bounded_regex.search( + pattern, haystack, timeout=REGEX_TIMEOUT_SECONDS + ): return "pass", "" return "fail", f"regex '{pattern}' did not match" - except re.error as e: + except TimeoutError: + return "error", "regex evaluation exceeded its runtime deadline" + except bounded_regex.error as e: return "error", f"bad regex: {e}" return "error", f"unsupported compare '{compare}' for command check" @@ -667,7 +672,12 @@ async def _run_one(idx: int, check: Check) -> CheckResult: if check.type in ("log_absent", "log_present"): observed = _strip_log_status(observed) result.observed = observed[:500] - status, err = _evaluate(check, exit_code, output) + if check.type == "command" and check.compare == "regex_match": + # No synchronous regex work on the event loop. The engine + # deadline also bounds the worker's lifetime on cancellation. + status, err = await asyncio.to_thread(_evaluate, check, exit_code, output) + else: + status, err = _evaluate(check, exit_code, output) result.status = status result.error = err return result diff --git a/tests/test_campaign_execution_validation.py b/tests/test_campaign_execution_validation.py new file mode 100644 index 000000000..c1a11e3aa --- /dev/null +++ b/tests/test_campaign_execution_validation.py @@ -0,0 +1,87 @@ +"""Execution campaign regressions; transports and governors are inert fakes.""" +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.tools.post_validation import Check, _evaluate, run_bundle +from tests.test_hosts_executor_leases import _executor + + +@pytest.mark.parametrize("output,status", [("0", "pass"), ("", "fail")]) +def test_equals_preserves_numeric_zero(output, status): + check = Check("command", "fixture", expected=0, compare="equals") + assert _evaluate(check, 0, output)[0] == status + + +def test_brace_quantifier_has_real_deadline_in_isolation(): + # A subprocess timeout is the test's backstop, not the production bound. + code = ( + "import json; from src.tools.post_validation import Check, _evaluate; " + "print(json.dumps(_evaluate(Check('command', 'fixture', " + "expected='(a{1,}){1,}b', compare='regex_match'), 0, 'a'*10000)))" + ) + proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=5) + assert proc.returncode == 0, proc.stderr + status, error = json.loads(proc.stdout) + assert status == "error" and "deadline" in error + + +async def test_regex_evaluation_does_not_block_event_loop(): + ticked = asyncio.Event() + + async def heartbeat(): + await asyncio.sleep(0.005) + ticked.set() + + task = asyncio.create_task(heartbeat()) + report = await run_bundle( + [{"type": "command", "target": "fixture", "compare": "regex_match", + "expected": "(a{1,}){1,}b"}], bundle_name="deadline", default_host=None, + resolve_host=lambda _: ("localhost", "root", "linux"), + exec_command=AsyncMock(return_value=(0, "a" * 10000)), + ) + assert ticked.is_set() + await task + assert report.errored == 1 + + +@pytest.mark.parametrize("compare,expected", [("exit_nonzero", None), ("not_contains", "absent")]) +@pytest.mark.parametrize("raises", [False, True]) +async def test_real_validation_governor_refusal_cannot_pass(tmp_path, compare, expected, raises): + exe = _executor(tmp_path) + + class Governor: + def check(self, _command): + if raises: + raise RuntimeError("fixture governor unavailable") + return SimpleNamespace(allowed=False, denial_message=lambda: "fixture denied") + + exe.command_governor = Governor() + exe._exec_command = AsyncMock(side_effect=AssertionError("must never dispatch")) + report = json.loads(await exe.validation_tools._handle_validate_action({ + "format": "json", "default_host": "alpha", + "checks": [{"type": "command", "target": "fixture", "compare": compare, + **({"expected": expected} if expected is not None else {})}], + })) + assert report["verdict"] == "error" + assert report["checks"][0]["status"] == "error" + assert "governor" in report["checks"][0]["error"] + exe._exec_command.assert_not_awaited() + + +async def test_invalid_probe_never_pins_host_generation(tmp_path): + exe = _executor(tmp_path) + exe._exec_command = AsyncMock(side_effect=AssertionError("must never dispatch")) + raw = await exe.browser_web_tools._handle_http_probe({ + "host": "alpha", "url": "https://example.test", "method": "POST", "body": "x" * 60000, + }) + assert raw[1] == 1 + assert not exe.host_registry.has_active_leases("alpha") + exe._exec_command.assert_not_awaited() diff --git a/tests/test_hosts_executor_leases.py b/tests/test_hosts_executor_leases.py index 1d2299fc9..df5bfbd16 100644 --- a/tests/test_hosts_executor_leases.py +++ b/tests/test_hosts_executor_leases.py @@ -13,6 +13,8 @@ from types import SimpleNamespace from unittest.mock import AsyncMock +import pytest + from src.config.schema import ToolHost, ToolsConfig from src.tools.executor import ToolExecutor, _user_id_ctx from src.tools.hosts import HostRegistry @@ -218,10 +220,8 @@ async def transport(address, command, ssh_user, **kwargs): async def fake_run_bundle(_checks, *, resolve_host, exec_command, **_kwargs): assert resolve_host("alpha") == ("alpha", "", "") assert resolve_host("absent") is None - assert await exec_command("absent", "true", "", timeout=3) == ( - 1, - "unknown host alias: absent", - ) + with pytest.raises(PermissionError, match="unknown host alias: absent"): + await exec_command("absent", "true", "", timeout=3) assert await exec_command("alpha", "true", "", timeout=3, use_workspace=True) == ( 0, "checked", From 8dfddf7934ef032d22f595c783b6e835172fbb7a Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:06:10 -0400 Subject: [PATCH 016/162] Preserve dispatch uncertainty and typed failure metadata across tool routes --- pyproject.toml | 1 + src/discord/background_task.py | 5 +- src/discord/tool_loop.py | 21 +- src/tools/execution_outcome.py | 43 ++++ src/tools/executor.py | 75 +++++-- src/tools/handlers/state.py | 19 +- src/tools/handlers/system.py | 3 +- src/tools/output_delivery.py | 10 +- src/tools/result_validator.py | 3 +- src/tools/risk_classifier.py | 51 ++++- src/tools/runtime_delivery.py | 14 +- src/tools/skill_manager.py | 17 +- src/tools/ssh.py | 55 ++++- tests/test_campaign_execution_outcomes.py | 252 ++++++++++++++++++++++ tests/test_campaign_execution_risk.py | 50 +++++ tests/test_risk_classifier.py | 4 +- 16 files changed, 570 insertions(+), 53 deletions(-) create mode 100644 src/tools/execution_outcome.py create mode 100644 tests/test_campaign_execution_outcomes.py create mode 100644 tests/test_campaign_execution_risk.py diff --git a/pyproject.toml b/pyproject.toml index bd9626b95..407a5a2cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dev = [ "types-PyYAML==6.0.12.20260518", "types-croniter==6.2.3.20260704", "types-jsonschema==4.26.0.20260518", + "types-regex==2026.9.10.20260911", ] [project.scripts] diff --git a/src/discord/background_task.py b/src/discord/background_task.py index b2e67d0fd..102667c55 100644 --- a/src/discord/background_task.py +++ b/src/discord/background_task.py @@ -20,7 +20,6 @@ from ..llm.secret_scrubber import scrub_output_secrets from ..odin_log import get_logger from ..search.errors import InvalidSearchQuery -from ..tools.executor import _ERROR_RESULT_PREFIXES from ..tools.result_validator import ToolResult from ..tools.risk_classifier import classify_tool from .tool_loop_helpers import _scrub_tool_input_for_storage, ensure_failure_visible @@ -458,7 +457,9 @@ def _is_error_output(output: str) -> bool: # The executor's own canonical error grammar ("Error…", "Command failed", # "Script failed", "Blocked…", "Unknown or disallowed host") — the same # prefix set ensure_failure_visible treats as already-visible failures. - if output.startswith(_ERROR_RESULT_PREFIXES): + from ..tools.execution_outcome import is_tool_failure + + if is_tool_failure(output): return True return False diff --git a/src/discord/tool_loop.py b/src/discord/tool_loop.py index 29dc885ba..84471a466 100644 --- a/src/discord/tool_loop.py +++ b/src/discord/tool_loop.py @@ -281,6 +281,13 @@ def _unwrap_native_result(result): output)`` so the caller audits the metadata and sends the string.""" if isinstance(result, ToolResult): return result, result.output + from ..tools.execution_outcome import ToolFailure, is_tool_failure + + if is_tool_failure(result): + return ToolResult( + output=result, ok=False, error="tool reported failure", + uncertain_outcome=isinstance(result, ToolFailure) and result.uncertain_outcome, + ), result return None, result @@ -3060,7 +3067,12 @@ async def _audit_tool_outcome( for k, v in (tool_input or {}).items() }, ) + from ..tools.risk_classifier import classify_tool + + risk = classify_tool(tool_name, tool_input) await self._audit.log_execution( + # All routes use the same action-aware classifier, including + # native tools without an executor-produced ToolResult. user_id=str(st.message.author.id), user_name=str(st.message.author), channel_id=str(st.message.channel.id), @@ -3070,8 +3082,8 @@ async def _audit_tool_outcome( result_summary=result, execution_time_ms=elapsed_ms, error=error, - risk_level=tool_result.risk_level if tool_result else None, - risk_reason=tool_result.risk_reason if tool_result else None, + risk_level=risk.level.value, + risk_reason=risk.reason, audit_metadata=tool_result.audit_metadata if tool_result else None, attribution={"call_id": call_id, "iteration": st.iteration}, event_type=terminal_event, @@ -4023,6 +4035,9 @@ async def _run_one_loop_tool(self, st: _LoopTurn, block) -> dict: # Audit log try: + from ..tools.risk_classifier import classify_tool + + risk = classify_tool(tool_name, tool_input) await self._audit.log_execution( user_id=st.user_id, user_name=st.requester_name, @@ -4034,6 +4049,8 @@ async def _run_one_loop_tool(self, st: _LoopTurn, block) -> dict: execution_time_ms=elapsed_ms, error=error, audit_metadata=_audit_meta, + risk_level=risk.level.value, + risk_reason=risk.reason, attribution=attribution, event_type="loop_tool", ) diff --git a/src/tools/execution_outcome.py b/src/tools/execution_outcome.py new file mode 100644 index 000000000..e6ad7b21d --- /dev/null +++ b/src/tools/execution_outcome.py @@ -0,0 +1,43 @@ +"""Code-owned outcome provenance, never inferred from handler output bytes.""" +from __future__ import annotations + +from contextvars import ContextVar +from dataclasses import dataclass + + +@dataclass +class DispatchEvidence: + uncertain: bool = False + + +dispatch_evidence: ContextVar[DispatchEvidence | None] = ContextVar( + "tool_dispatch_evidence", default=None +) + + +def mark_dispatch_uncertain() -> None: + """Transport lost settlement after dispatch; cleanup is not absence proof.""" + evidence = dispatch_evidence.get() + if evidence is not None: + # Nested transport tasks inherit this invocation's evidence object. + # The next attempt installs a distinct object, never a global flag. + evidence.uncertain = True + + +class ToolFailure(str): + """A backward-compatible string with a trusted failure contract.""" + + uncertain_outcome: bool + + def __new__(cls, text: str, *, uncertain_outcome: bool = False): + value = super().__new__(cls, text) + value.uncertain_outcome = uncertain_outcome + return value + + +def is_tool_failure(value: object) -> bool: + from .tool_text import _ERROR_RESULT_PREFIXES + + return isinstance(value, ToolFailure) or ( + isinstance(value, str) and value.startswith(_ERROR_RESULT_PREFIXES) + ) diff --git a/src/tools/executor.py b/src/tools/executor.py index e446be5b4..248e5efa0 100644 --- a/src/tools/executor.py +++ b/src/tools/executor.py @@ -24,6 +24,7 @@ ) from .bulkhead import BulkheadFullError, BulkheadRegistry from .effect_classifier import ToolEffectClass, classify_tool_effect +from .execution_outcome import DispatchEvidence, ToolFailure, dispatch_evidence from .output_authorization import ( accessed_hosts, host_access_capture, @@ -114,11 +115,16 @@ class _ToolAttemptTimeout(NamedTuple): - """Timeout evidence kept separate from handler-controlled output text.""" + """Attempt failure evidence separate from handler-controlled output text. + + recovery_allowed preserves approved retries for ordinary exceptions and + inner transport failures. Outer executor timeouts still do not retry. + """ output: str exit_code: int uncertain_outcome: bool + recovery_allowed: bool = False def _validate_memory_shape(data: dict) -> None: @@ -246,7 +252,7 @@ def __init__( self.host_registry = host_registry self._metrics: dict[str, dict[str, int]] = {} self._memory_lock = asyncio.Lock() - self._memory_corrupt_logged_at = 0.0 + self._memory_corrupt_logged_at: float | None = None self._lists_lock = asyncio.Lock() self.risk_stats = RiskStats() self.recovery_stats = RecoveryStats() @@ -938,9 +944,16 @@ async def _execute_inner( else: raw_result = raw exit_code = None - is_error = isinstance(raw_result, str) and raw_result.startswith(_ERROR_RESULT_PREFIXES) + is_error = isinstance(raw_result, ToolFailure) or ( + isinstance(raw_result, str) and raw_result.startswith(_ERROR_RESULT_PREFIXES) + ) + unknown = unknown or bool( + isinstance(raw_result, ToolFailure) and raw_result.uncertain_outcome + ) - if self._recovery_enabled and not unknown: + if self._recovery_enabled and ( + not unknown or (isinstance(raw, _ToolAttemptTimeout) and raw.recovery_allowed) + ): category = self._check_recoverable(raw_result) if category is not None: snippet = raw_result[:120] if isinstance(raw_result, str) else "" @@ -979,7 +992,7 @@ async def _execute_inner( retry_raw = await self._try_tool( tool_name, handler, tool_input, timeout, user_id ) - unknown = ( + unknown = unknown or ( isinstance(retry_raw, _ToolAttemptTimeout) and retry_raw.uncertain_outcome ) @@ -988,14 +1001,20 @@ async def _execute_inner( is_error = exit_code != 0 else: raw_result = retry_raw + exit_code = None + is_error = isinstance(raw_result, ToolFailure) or ( + isinstance(raw_result, str) + and raw_result.startswith(_ERROR_RESULT_PREFIXES) + ) + unknown = unknown or bool( + isinstance(raw_result, ToolFailure) + and raw_result.uncertain_outcome + ) retry_cat = self._check_recoverable(raw_result) - if retry_cat is not None: + if retry_cat is not None or is_error or unknown: self.recovery_stats.record_failure(tool_name, category, snippet) else: self.recovery_stats.record_success(tool_name, category, snippet) - is_error = isinstance(raw_result, str) and raw_result.startswith( - _ERROR_RESULT_PREFIXES - ) break mutation_detected = False @@ -1023,20 +1042,22 @@ async def _execute_inner( if m: exit_code = int(m.group(1)) - unknown = unknown or ( - isinstance(raw_result, str) and "outcome_unknown=true" in raw_result - ) output = self.deliver_output( outcome.normalized, tool_name=tool_name, tool_input=tool_input, user_id=user_id, status="outcome_unknown" if unknown else "failed" if is_error else "succeeded") from ..llm.secret_scrubber import scrub_output_secrets + # Successful retry is not absence proof for an earlier ambiguous + # dispatch. Keep both approved retry policy and honest provenance. + is_error = is_error or unknown return ToolResult( output=output, ok=not is_error, error=scrub_output_secrets(raw_result[:200]) if is_error else None, exit_code=exit_code, - truncated="truncated" in outcome.violations, + truncated="truncated" in outcome.violations or bool( + isinstance(output, DeliveredOutput) and output.truncated + ), duration_ms=duration_ms, tool_name=tool_name, risk_level=assessment.level.value, @@ -1085,7 +1106,23 @@ async def _try_tool( coro = handler(tool_input, user_id=user_id) else: coro = handler(tool_input) - result = await asyncio.wait_for(coro, timeout=timeout) + + async def with_provenance(): + # wait_for creates a child task. Read provenance INSIDE that + # task, then carry it across as code-owned typed metadata. + evidence = DispatchEvidence() + token = dispatch_evidence.set(evidence) + try: + result = await coro + if evidence.uncertain: + text = result[0] if isinstance(result, tuple) else str(result) + code = result[1] if isinstance(result, tuple) else -1 + return _ToolAttemptTimeout(text, code or -1, True, True) + return result + finally: + dispatch_evidence.reset(token) + + result = await asyncio.wait_for(with_provenance(), timeout=timeout) return result except TimeoutError: self._metric(tool_name)["errors"] += 1 @@ -1100,7 +1137,12 @@ async def _try_tool( except Exception as e: self._metric(tool_name)["errors"] += 1 log.error("Tool %s failed: %s", tool_name, e) - return f"Error executing {tool_name}: {e}", -1 + return _ToolAttemptTimeout( + f"Error executing {tool_name}: {e}", -1, + classify_tool_effect(tool_name, tool_input) + != ToolEffectClass.EFFECT_FREE_OBSERVATION, + True, + ) finally: # Restore FIRST: settlement below runs in this task's own context, # and a nested tool call must never inherit this registry. @@ -1431,7 +1473,8 @@ def _load_all_memory_safe(self) -> dict[str, dict[str, str]]: return self._load_all_memory() except StoreCorruptError as exc: now = time.monotonic() - if now - self._memory_corrupt_logged_at > 300: + if (self._memory_corrupt_logged_at is None + or now - self._memory_corrupt_logged_at > 300): log.error("memory.json unavailable — injecting no working memory: %s", exc) self._memory_corrupt_logged_at = now return {"global": {}} diff --git a/src/tools/handlers/state.py b/src/tools/handlers/state.py index 2b47b12aa..f8ec09c87 100644 --- a/src/tools/handlers/state.py +++ b/src/tools/handlers/state.py @@ -15,6 +15,7 @@ from pathlib import Path from ...async_utils import to_thread_settled +from ..execution_outcome import ToolFailure from .deps import HandlerBase, HandlerDeps # Max working-memory notes retained per section (global / per-user). The full @@ -44,7 +45,7 @@ def _lists_lock(self): async def _handle_memory_manage(self, inp: dict, *, user_id: str | None = None) -> str: action = inp.get("action") if not action: - return ( + return ToolFailure( "memory_manage requires an 'action' field. " "Valid actions: list, save, get, delete. " "Example: {'action': 'get', 'key': 'foo'}." @@ -56,11 +57,11 @@ async def _handle_memory_manage(self, inp: dict, *, user_id: str | None = None) if action in ("get", "recall", "read"): key = inp.get("key") if not key: - return "'key' is required for get." + return ToolFailure("'key' is required for get.") try: all_mem = await to_thread_settled(self._load_all_memory) except StoreCorruptError as exc: - return ( + return ToolFailure( "Memory store is currently unreadable or corrupt (a backup copy was " f"preserved). Cannot complete '{action}' to avoid data loss. Details: {exc}" ) @@ -75,7 +76,7 @@ async def _handle_memory_manage(self, inp: dict, *, user_id: str | None = None) try: all_mem = await to_thread_settled(self._load_all_memory) except StoreCorruptError as exc: - return ( + return ToolFailure( "Memory store is currently unreadable or corrupt (a backup copy was " f"preserved). Cannot complete '{action}' to avoid data loss. Details: {exc}" ) @@ -94,11 +95,11 @@ async def _handle_memory_manage(self, inp: dict, *, user_id: str | None = None) key = inp.get("key") value = inp.get("value") if not key or not value: - return "Both 'key' and 'value' are required for save." + return ToolFailure("Both 'key' and 'value' are required for save.") try: all_mem = await to_thread_settled(self._load_all_memory) except StoreCorruptError as exc: - return ( + return ToolFailure( "Memory store is currently unreadable or corrupt (a backup copy was " f"preserved). Cannot complete '{action}' to avoid data loss. Details: {exc}" ) @@ -134,11 +135,11 @@ async def _handle_memory_manage(self, inp: dict, *, user_id: str | None = None) elif action == "delete": key = inp.get("key") if not key: - return "'key' is required for delete." + return ToolFailure("'key' is required for delete.") try: all_mem = await to_thread_settled(self._load_all_memory) except StoreCorruptError as exc: - return ( + return ToolFailure( "Memory store is currently unreadable or corrupt (a backup copy was " f"preserved). Cannot complete '{action}' to avoid data loss. Details: {exc}" ) @@ -153,7 +154,7 @@ async def _handle_memory_manage(self, inp: dict, *, user_id: str | None = None) return f"Deleted global note '{key}'." return f"No note found with key '{key}'." - return f"Unknown memory action: {action}" + return ToolFailure(f"Unknown memory action: {action}") def _lists_path(self) -> Path | None: """Return path to data/lists.json (sibling of memory.json).""" diff --git a/src/tools/handlers/system.py b/src/tools/handlers/system.py index 3afa75ae6..6f8b01515 100644 --- a/src/tools/handlers/system.py +++ b/src/tools/handlers/system.py @@ -19,6 +19,7 @@ import shlex from ..branch_freshness import is_test_command, is_test_failure +from ..execution_outcome import ToolFailure from ..input_defaults import default_if_empty from ..process_manager import MAX_POLL_WAIT_SECONDS from ..tool_text import _ERROR_RESULT_PREFIXES, _truncate_lines @@ -111,7 +112,7 @@ async def _handle_run_script(self, inp: dict) -> str | tuple[str, int]: # Sanitize interpreter to prevent injection allowed_interpreters = {"bash", "sh", "python3", "python", "node", "ruby", "perl"} if interpreter not in allowed_interpreters: - return ( + return ToolFailure( f"Unsupported interpreter: {interpreter}. " f"Use one of: {', '.join(sorted(allowed_interpreters))}" ) diff --git a/src/tools/output_delivery.py b/src/tools/output_delivery.py index d95abeb5b..01c9cc3c9 100644 --- a/src/tools/output_delivery.py +++ b/src/tools/output_delivery.py @@ -37,9 +37,13 @@ class DeliveredOutput(str): Never infer this property from untrusted content or a JSON kind field. """ + truncated: bool = False + def serialize(value: dict) -> str: - return DeliveredOutput(json.dumps(value, ensure_ascii=True, separators=(",", ":"))) + output = DeliveredOutput(json.dumps(value, ensure_ascii=True, separators=(",", ":"))) + output.truncated = bool(value.get("truncated", False)) + return output def delivery_failure(reason, status="unknown", *, text="", budget=12000): @@ -247,7 +251,9 @@ def deliver(text, *, store=None, owner="", channel="", tool="", hosts=(), available = budget-len(pointer) if len(preview) > available: preview = preview[:available-6] + "\n[...]" - return DeliveredOutput(preview + pointer) + output = DeliveredOutput(preview + pointer) + output.truncated = len(preview) < len(text) + return output return render_page(snapshot, budget=budget, initial=True) except (RetentionError, OSError, sqlite3.Error, UnicodeError) as exc: reason = str(exc) if isinstance(exc, RetentionError) else "Retention storage unavailable." diff --git a/src/tools/result_validator.py b/src/tools/result_validator.py index f2150d8fb..4bc6725a8 100644 --- a/src/tools/result_validator.py +++ b/src/tools/result_validator.py @@ -16,6 +16,7 @@ import logging from dataclasses import dataclass, field +from .execution_outcome import is_tool_failure from .media_result import BinaryAttachment log = logging.getLogger("odin.tools.result_validator") @@ -165,7 +166,7 @@ def as_dict(self) -> dict[str, int]: def _is_error_result(text: str) -> bool: """True when *text* looks like an executor error string.""" - return any(text.startswith(p) for p in _ERROR_PREFIXES) + return is_tool_failure(text) or any(text.startswith(p) for p in _ERROR_PREFIXES) def _truncate_smart(text: str, max_chars: int) -> str: diff --git a/src/tools/risk_classifier.py b/src/tools/risk_classifier.py index feac44df7..7d6a04c5b 100644 --- a/src/tools/risk_classifier.py +++ b/src/tools/risk_classifier.py @@ -452,7 +452,26 @@ def _contains_literal_git_push(command: str) -> bool: "email_read": RiskLevel.LOW, "email_list_recent": RiskLevel.LOW, "generate_image": RiskLevel.MEDIUM, - "ingest_knowledge": RiskLevel.MEDIUM, + "ingest_document": RiskLevel.MEDIUM, + "bulk_ingest_knowledge": RiskLevel.MEDIUM, + "delete_knowledge": RiskLevel.HIGH, + "schedule_task": RiskLevel.HIGH, + "update_schedule": RiskLevel.HIGH, + "delete_schedule": RiskLevel.HIGH, + "create_skill": RiskLevel.HIGH, + "edit_skill": RiskLevel.HIGH, + "delete_skill": RiskLevel.HIGH, + "enable_skill": RiskLevel.MEDIUM, + "disable_skill": RiskLevel.MEDIUM, + "invoke_skill": RiskLevel.HIGH, + "delegate_task": RiskLevel.HIGH, + "start_loop": RiskLevel.HIGH, + "stop_loop": RiskLevel.MEDIUM, + "cancel_task": RiskLevel.MEDIUM, + "kill_agent": RiskLevel.MEDIUM, + "add_reaction": RiskLevel.MEDIUM, + "post_file": RiskLevel.MEDIUM, + "generate_file": RiskLevel.MEDIUM, # High — arbitrary code execution "run_script": RiskLevel.HIGH, "run_command_multi": RiskLevel.HIGH, @@ -566,6 +585,36 @@ def classify_tool(tool_name: str, tool_input: dict | None = None) -> RiskAssessm """ tool_input = tool_input or {} + if tool_name in {"memory_manage", "manage_list"}: + action = tool_input.get("action", "") + observations = {"get", "list", "recall", "read", "show", "search"} + level = RiskLevel.LOW if action in observations else RiskLevel.MEDIUM + return RiskAssessment(level, f"{tool_name}: {action or 'unspecified action'}") + + if tool_name == "http_probe": + method = str(tool_input.get("method") or "GET").upper() + level = RiskLevel.LOW if method in {"GET", "HEAD", "OPTIONS"} else RiskLevel.HIGH + return RiskAssessment(level, f"HTTP {method} probe") + + if tool_name == "manage_process": + action = tool_input.get("action", "") + level = RiskLevel.LOW if action in {"poll", "list"} else RiskLevel.HIGH + return RiskAssessment(level, f"process {action or 'unspecified action'}") + + if tool_name == "validate_action": + assessments = [ + classify_command(str(check.get("target") or "")) + for check in (tool_input.get("checks") or []) + if isinstance(check, dict) and check.get("type") == "command" + ] + if assessments: + highest = max(assessments, key=lambda item: _LEVEL_ORDER[item.level]) + return RiskAssessment( + max(RiskLevel.HIGH, highest.level, key=lambda level: _LEVEL_ORDER[level]), + f"validation command: {highest.reason}", + ) + return RiskAssessment(RiskLevel.LOW, "fixed-shape validation probes") + if tool_name == "run_command": cmd = tool_input.get("command", "") assessment = classify_command(cmd) diff --git a/src/tools/runtime_delivery.py b/src/tools/runtime_delivery.py index 5b320a8e8..475228510 100644 --- a/src/tools/runtime_delivery.py +++ b/src/tools/runtime_delivery.py @@ -46,6 +46,13 @@ def deliver_runtime_output(executor, text, *, tool_name, tool_input, user_id, def deliver_runtime_result(executor, result, **kwargs): + from .execution_outcome import ToolFailure, is_tool_failure + + if not isinstance(result, ToolResult) and is_tool_failure(result): + result = ToolResult( + output=result, ok=False, error="tool reported failure", + uncertain_outcome=isinstance(result, ToolFailure) and result.uncertain_outcome, + ) if isinstance(result, ToolResult): from ..discord.tool_loop_helpers import ensure_failure_visible @@ -80,8 +87,11 @@ def deliver_runtime_result(executor, result, **kwargs): executor, ensure_failure_visible(text, result.ok), status=status, budget=budget, **kwargs) return replace(result, attachments=(), output=DeliveredOutput(text + pointer)) - return replace(result, attachments=(), output=deliver_runtime_output( - executor, ensure_failure_visible(text, result.ok), status=status, **kwargs)) + output = deliver_runtime_output( + executor, ensure_failure_visible(text, result.ok), status=status, **kwargs) + return replace(result, attachments=(), output=output, + truncated=result.truncated or bool( + isinstance(output, DeliveredOutput) and output.truncated)) if image_result_parts(result) is not None: return result return deliver_runtime_output(executor, result, **kwargs) diff --git a/src/tools/skill_manager.py b/src/tools/skill_manager.py index 6e06cd9cb..4c0e947ac 100644 --- a/src/tools/skill_manager.py +++ b/src/tools/skill_manager.py @@ -1202,21 +1202,24 @@ async def execute( requester_id: str | None = None, ) -> str: """Execute a user-created skill with timeout and sandboxing.""" + from .execution_outcome import ToolFailure from .output_authorization import tool_scope_allows # invoke_skill is only a wrapper, not authority for its selected skill. # This boundary also covers legacy/background and direct manager calls. if not tool_scope_allows(tool_name): - return "Permission denied: selected skill scope revoked or unavailable." + return ToolFailure("Permission denied: selected skill scope revoked or unavailable.") if isinstance(self._executor, ToolExecutor): denial = self._executor.check_permission(tool_name, requester_id) if denial: - return denial + return ToolFailure(denial) skill = self._skills.get(tool_name) if not skill: - return f"Skill '{tool_name}' not found." + return ToolFailure(f"Skill '{tool_name}' not found.") if skill.status == SkillStatus.DISABLED: - return f"Skill '{tool_name}' is disabled. Use enable_skill to re-activate it." + return ToolFailure( + f"Skill '{tool_name}' is disabled. Use enable_skill to re-activate it." + ) # Load config with defaults applied skill_config = self.get_skill_config(tool_name) @@ -1262,10 +1265,12 @@ async def execute( output_chars = len(result) return result except TimeoutError: - return f"Skill '{tool_name}' timed out after {skill_timeout}s." + return ToolFailure( + f"Skill '{tool_name}' timed out after {skill_timeout}s.", uncertain_outcome=True + ) except Exception as e: log.error("Skill %s execution error: %s", tool_name, e, exc_info=True) - return f"Skill error: {e}" + return ToolFailure(f"Skill error: {e}", uncertain_outcome=True) finally: elapsed_ms = (time.monotonic() - start) * 1000 stats = SkillExecutionStats( diff --git a/src/tools/ssh.py b/src/tools/ssh.py index 59c167991..ccb5a1ddc 100644 --- a/src/tools/ssh.py +++ b/src/tools/ssh.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import codecs import os import shlex import signal @@ -10,6 +11,7 @@ from ..llm.backoff import compute_backoff from ..odin_log import get_logger +from .execution_outcome import mark_dispatch_uncertain from .result_capture import capture_active from .workspace import workspace_env @@ -181,27 +183,48 @@ async def _read_lines_with_callback( on_output: OutputCallback, owned_pgid: int | None = None, ) -> tuple[int, str]: - """Read stdout line by line, calling *on_output* for each line.""" + """Read bounded chunks, framing lines without StreamReader's 64 KiB limit. + + Very long lines are emitted as bounded fragments. Capture remains byte- + ordered and the incremental decoder preserves split UTF-8 characters. + """ lines: list[str] = [] + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + pending = "" + + async def emit(text: str) -> None: + lines.append(text) + try: + await on_output(text) + except Exception: + log.debug("on_output callback error", exc_info=True) + try: async with asyncio.timeout(timeout): assert proc.stdout is not None while True: - raw = await proc.stdout.readline() + raw = await proc.stdout.read(16384) if not raw: break - line = raw.decode("utf-8", errors="replace") - lines.append(line) - try: - await on_output(line) - except Exception: - log.debug("on_output callback error", exc_info=True) + pending += decoder.decode(raw) + while "\n" in pending: + line, pending = pending.split("\n", 1) + await emit(line + "\n") + if len(pending) >= 16384: + await emit(pending) + pending = "" + pending += decoder.decode(b"", final=True) + if pending: + await emit(pending) # Wait for process exit with a bounded timeout to avoid indefinite hang try: await asyncio.wait_for(proc.wait(), timeout=min(timeout, 10)) except TimeoutError: + mark_dispatch_uncertain() await terminate_process_tree(proc, owned_pgid=owned_pgid) + return 1, f"Command timed out after {timeout} seconds" except TimeoutError: + mark_dispatch_uncertain() await terminate_process_tree(proc, owned_pgid=owned_pgid) return 1, f"Command timed out after {timeout} seconds" except asyncio.CancelledError: @@ -209,6 +232,10 @@ async def _read_lines_with_callback( # the child or its descendants past this process's lifetime. await terminate_process_tree(proc, owned_pgid=owned_pgid) raise + except Exception: + mark_dispatch_uncertain() + await terminate_process_tree(proc, owned_pgid=owned_pgid) + raise output = "".join(lines) return proc.returncode or 0, _truncate_output(output) @@ -262,6 +289,7 @@ async def run_local_command( except TimeoutError: if proc is not None: + mark_dispatch_uncertain() await terminate_process_tree(proc, owned_pgid=proc.pid) return 1, f"Command timed out after {timeout} seconds" except asyncio.CancelledError: @@ -273,6 +301,9 @@ async def run_local_command( await terminate_process_tree(proc, owned_pgid=proc.pid) raise except Exception as e: + if proc is not None: + mark_dispatch_uncertain() + await terminate_process_tree(proc, owned_pgid=proc.pid) log.error("Local command failed: %s", safe_error(e)) return 1, f"Local exec error: {safe_error(e)}" @@ -332,7 +363,9 @@ async def run_ssh_command( last_exit_code = 1 last_output = "" - for attempt in range(max_retries): + # Positive values historically count attempts: keep approved retry policy. + # Zero disables retries, not the initial attempt. + for attempt in range(max(1, max_retries)): proc: asyncio.subprocess.Process | None = None pool_acquired = False try: @@ -420,6 +453,7 @@ async def run_ssh_command( # to leave the previous ssh process running while spawning the # next attempt. if proc is not None: + mark_dispatch_uncertain() await terminate_process_tree(proc) if pool is not None: # A legacy socket may still name a detached master; preserve @@ -447,6 +481,9 @@ async def run_ssh_command( raise except Exception as e: + if proc is not None: + mark_dispatch_uncertain() + await terminate_process_tree(proc) log.error("SSH command failed: %s", safe_error(e)) return 1, f"SSH error: {safe_error(e)}" finally: diff --git a/tests/test_campaign_execution_outcomes.py b/tests/test_campaign_execution_outcomes.py new file mode 100644 index 000000000..f28b9822c --- /dev/null +++ b/tests/test_campaign_execution_outcomes.py @@ -0,0 +1,252 @@ +"""Typed tool settlement and capture provenance, without real commands.""" +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from src.config.schema import ToolsConfig +from src.discord.tool_loop import _unwrap_native_result +from src.tools import ssh +from src.tools.execution_outcome import ToolFailure +from src.tools.executor import ToolExecutor +from src.tools.result_capture import result_capture +from src.tools.result_validator import _is_error_result +from tests.test_hosts_executor_leases import _executor + + +def _proc(text="fixture\n"): + reader = asyncio.StreamReader() + reader.feed_data(text.encode()) + reader.feed_eof() + return SimpleNamespace(stdout=reader, returncode=0, pid=54321, + wait=AsyncMock(return_value=0), + communicate=AsyncMock(return_value=(text.encode(), None))) + + +async def test_zero_ssh_retries_still_dispatches_initial_attempt(monkeypatch): + spawn = AsyncMock(return_value=_proc()) + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + result = await ssh.run_ssh_command("example.test", "fixture", "k", "kh", max_retries=0) + assert result == (0, "fixture\n") + assert spawn.await_count == 1 + + +@pytest.mark.parametrize("remote", [False, True]) +async def test_streaming_large_line_preserves_utf8_and_capture(monkeypatch, remote): + text = "é漢" * 40000 + "\nlast\n" + spawn = AsyncMock(return_value=_proc(text)) + if remote: + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + else: + monkeypatch.setattr("src.tools.local_supervisor.create_supervised_shell", spawn) + cb = AsyncMock() + with result_capture(): + if remote: + code, output = await ssh.run_ssh_command( + "example.test", "fixture", "k", "kh", on_output=cb) + else: + code, output = await ssh.run_local_command("fixture", on_output=cb) + assert code == 0 and output == text + assert "".join(call.args[0] for call in cb.await_args_list) == text + + +@pytest.mark.parametrize("remote", [False, True]) +async def test_stream_read_failure_cleans_owned_child(monkeypatch, remote): + proc = _proc() + proc.stdout = SimpleNamespace(read=AsyncMock(side_effect=RuntimeError("read failed"))) + proc.returncode = None + spawn = AsyncMock(return_value=proc) + cleanup = AsyncMock() + monkeypatch.setattr(ssh, "terminate_process_tree", cleanup) + if remote: + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + code, output = await ssh.run_ssh_command( + "example.test", "fixture", "k", "kh", on_output=AsyncMock()) + else: + monkeypatch.setattr("src.tools.local_supervisor.create_supervised_shell", spawn) + code, output = await ssh.run_local_command("fixture", on_output=AsyncMock()) + assert code != 0 and "read failed" in output + assert cleanup.await_count >= 1 + + +@pytest.mark.parametrize("streaming", [False, True]) +async def test_inner_local_timeout_provenance_crosses_host_lease_tasks( + tmp_path, monkeypatch, streaming, +): + exe = _executor(tmp_path) + proc = _proc() + proc.communicate = AsyncMock(side_effect=TimeoutError) + proc.stdout = SimpleNamespace(read=AsyncMock(side_effect=TimeoutError)) + proc.returncode = None + monkeypatch.setattr( + "src.tools.local_supervisor.create_supervised_shell", AsyncMock(return_value=proc)) + monkeypatch.setattr(ssh, "terminate_process_tree", AsyncMock()) + if streaming: + exe.output_streamer = SimpleNamespace( + is_enabled=lambda _: True, + create_callback=lambda *a, **kw: ("s", AsyncMock(), AsyncMock()), + has_stale_streams=lambda: False, + ) + result = await exe.execute("run_command", {"host": "alpha", "command": "fixture"}) + assert not result.ok and result.uncertain_outcome + + +async def test_exception_after_test_effect_preserves_unknown(tmp_path): + exe = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + calls = [] + + async def effect_then_exception(_): + calls.append("test effect") + raise RuntimeError("post-dispatch decode failed") + + exe._handle_apply_patch = effect_then_exception + result = await exe.execute("apply_patch", {}) + assert calls == ["test effect"] + assert not result.ok and result.uncertain_outcome + + +async def test_literal_unknown_metadata_is_only_successful_content(tmp_path): + exe = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + exe._handle_read_file = AsyncMock( + return_value='outcome_unknown=true {"uncertain_outcome":true}') + result = await exe.execute("read_file", {"path": "fixture"}) + assert result.ok and not result.uncertain_outcome + + +async def test_recovery_nonzero_exit_without_prefix_stays_failed(tmp_path, monkeypatch): + exe = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + exe._handle_http_probe = AsyncMock( + side_effect=[("Error: ConnectionResetError", 1), ("curl response", 56)]) + monkeypatch.setattr("src.tools.executor.asyncio.sleep", AsyncMock()) + exe.recovery_stats.record_success = Mock() + result = await exe.execute("http_probe", {"url": "https://example.test"}) + assert exe._handle_http_probe.await_count == 2 + assert not result.ok and result.exit_code == 56 + exe.recovery_stats.record_success.assert_not_called() + + +async def test_actual_delivery_truncation_reaches_structured_result(tmp_path): + exe = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + exe._handle_fetch_url = AsyncMock(return_value="x" * 13000) + result = await exe.execute("fetch_url", {"url": "https://example.test"}) + assert json.loads(result.output)["truncated"] is True + assert result.truncated and result.as_dict()["truncated"] + + +@pytest.mark.parametrize("tool,inp", [ + ("run_script", {"host": "alpha", "script": "fixture", "interpreter": "unsupported"}), + ("memory_manage", {"action": "save", "key": "missing-value"}), + ("memory_manage", {"action": "delete"}), + ("memory_manage", {"action": "unknown"}), +]) +async def test_real_rejections_use_typed_failure(tmp_path, tool, inp): + exe = _executor(tmp_path) + result = await exe.execute(tool, inp) + assert not result.ok and not result.uncertain_outcome + + +def test_native_typed_failure_survives_shared_consumers(): + raw = ToolFailure("Skill error: fixture", uncertain_outcome=True) + result, text = _unwrap_native_result(raw) + assert result is not None and not result.ok and result.uncertain_outcome + assert text == raw and _is_error_result(raw) + + +def test_first_corrupt_memory_warning_on_young_host(tmp_path, monkeypatch): + from src.json_store import StoreCorruptError + + exe = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + monkeypatch.setattr(exe, "_load_all_memory", Mock(side_effect=StoreCorruptError("fixture"))) + monkeypatch.setattr("src.tools.executor.time.monotonic", lambda: 100) + error = Mock() + monkeypatch.setattr("src.tools.executor.log.error", error) + assert exe._load_all_memory_safe() == {"global": {}} + exe._load_all_memory_safe() + assert error.call_count == 1 + + +@pytest.mark.parametrize("failure", ["exception", "timeout"]) +async def test_real_skill_manager_failure_retains_native_and_deferred_metadata(tmp_path, failure): + from src.tools.runtime_delivery import deliver_runtime_result + from src.tools.skill_manager import LoadedSkill, SkillManager + + exe = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + manager = SkillManager(str(tmp_path / "skills"), exe, tool_timeouts={"fixture": 0.01}) + effects = [] + + async def execute(_inp, _context): + effects.append("test effect") + if failure == "timeout": + await asyncio.Event().wait() + raise RuntimeError("post-effect fixture failure") + + manager._skills["fixture"] = LoadedSkill( + "fixture", {"name": "fixture", "input_schema": {}}, execute, + Path("unused-fixture.py"), "fixture", + ) + raw = await manager.execute("fixture", {}) + assert effects == ["test effect"] + assert isinstance(raw, ToolFailure) and raw.uncertain_outcome + native, _ = _unwrap_native_result(raw) + deferred = deliver_runtime_result(exe, raw, tool_name="fixture", tool_input={}, user_id="u") + assert native is not None and not native.ok and native.uncertain_outcome + assert not deferred.ok and deferred.uncertain_outcome + + +@pytest.mark.parametrize("failure", ["exception", "inner-timeout"]) +async def test_dispatch_failure_is_durably_unknown_and_fenced(tmp_path, monkeypatch, failure): + from src.turn_state import OpState, StaleTurnError, TurnStateStore + from tests.test_executor_timeout_durability import ( + _durability, + _ledger_state, + _record_and_start, + _runner, + _state, + ) + + tools = ToolsConfig() + exe = ToolExecutor(config=tools, memory_path=str(tmp_path / "memory.json")) + runner = _runner(exe, tools) + store = TurnStateStore(tmp_path / "turns.sqlite3") + durability = await _durability(store) + block = SimpleNamespace(name="run_command", input={"host": "unused", "command": "fixture"}, + id="dispatch-failure", parse_error=None) + await _record_and_start(durability, block) + effects = [] + + async def handler(_): + effects.append("test effect") + if failure == "exception": + raise RuntimeError("decode after dispatch") + code, output = await ssh.run_local_command("fixture") + return output, code + + exe._handle_run_command = handler + proc = _proc() + proc.communicate = AsyncMock(side_effect=TimeoutError) + monkeypatch.setattr( + "src.tools.local_supervisor.create_supervised_shell", AsyncMock(return_value=proc)) + monkeypatch.setattr(ssh, "terminate_process_tree", AsyncMock()) + await runner._run_one_tool_with_timeout(_state(durability), block) + assert effects == ["test effect"] + assert _ledger_state(store, durability, block.id) == OpState.OUTCOME_UNKNOWN + with pytest.raises(StaleTurnError): + await durability.before_tool(block) + await durability.settle_terminal(cancelled=False, is_error=True) + store.close() + + +async def test_approved_generic_exception_recovery_still_retries(tmp_path, monkeypatch): + exe = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + exe._handle_read_file = AsyncMock( + side_effect=[ConnectionResetError("ConnectionResetError fixture"), "recovered"]) + monkeypatch.setattr("src.tools.executor.asyncio.sleep", AsyncMock()) + result = await exe.execute("read_file", {"path": "fixture"}) + assert exe._handle_read_file.await_count == 2 + assert "recovered" in result.output + assert not result.ok and result.uncertain_outcome diff --git a/tests/test_campaign_execution_risk.py b/tests/test_campaign_execution_risk.py new file mode 100644 index 000000000..46d812836 --- /dev/null +++ b/tests/test_campaign_execution_risk.py @@ -0,0 +1,50 @@ +"""Risk is observational metadata; verify action-aware parity without effects.""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.discord.tool_loop import ToolLoopRunner +from src.tools.result_validator import ToolResult +from src.tools.risk_classifier import RiskLevel, classify_tool + + +@pytest.mark.parametrize("tool,inp,level", [ + ("http_probe", {"method": "GET"}, RiskLevel.LOW), + ("http_probe", {"method": "PATCH"}, RiskLevel.HIGH), + ("memory_manage", {"action": "get"}, RiskLevel.LOW), + ("memory_manage", {"action": "save"}, RiskLevel.MEDIUM), + ("manage_list", {"action": "add"}, RiskLevel.MEDIUM), + ("manage_process", {"action": "poll"}, RiskLevel.LOW), + ("manage_process", {"action": "kill"}, RiskLevel.HIGH), + ("validate_action", {"checks": [{"type": "port", "target": "80"}]}, RiskLevel.LOW), + ("validate_action", {"checks": [{"type": "command", "target": "fixture"}]}, RiskLevel.HIGH), + ("ingest_document", {}, RiskLevel.MEDIUM), + ("schedule_task", {}, RiskLevel.HIGH), + ("update_schedule", {}, RiskLevel.HIGH), + ("create_skill", {}, RiskLevel.HIGH), + ("invoke_skill", {}, RiskLevel.HIGH), + ("start_loop", {}, RiskLevel.HIGH), + ("spawn_agent", {}, RiskLevel.LOW), # settled affordance is unchanged +]) +def test_actual_names_and_action_aware_risk(tool, inp, level): + assert classify_tool(tool, inp).level == level + + +@pytest.mark.parametrize("structured", [False, True]) +async def test_foreground_native_and_executor_audit_use_shared_assessment(structured): + runner = ToolLoopRunner.__new__(ToolLoopRunner) + runner._audit = SimpleNamespace(log_execution=AsyncMock(), log_event=AsyncMock()) + st = SimpleNamespace( + message=SimpleNamespace(author=SimpleNamespace(id="u"), + channel=SimpleNamespace(id="c")), iteration=1, + ) + tool, inp = "http_probe", {"method": "POST"} + result = ToolResult("fixture") if structured else None + await runner._audit_tool_outcome(st, tool, inp, "fixture", 1, None, result, call_id="call") + kwargs = runner._audit.log_execution.await_args.kwargs + expected = classify_tool(tool, inp) + assert kwargs["risk_level"] == expected.level.value + assert kwargs["risk_reason"] == expected.reason diff --git a/tests/test_risk_classifier.py b/tests/test_risk_classifier.py index e4ba5a68b..b89471aa8 100644 --- a/tests/test_risk_classifier.py +++ b/tests/test_risk_classifier.py @@ -529,9 +529,9 @@ def test_none_input(self): a = classify_tool("read_file") assert a.level == RiskLevel.LOW - def test_manage_process_medium(self): + def test_manage_process_start_high(self): a = classify_tool("manage_process", {"action": "start", "command": "sleep 10"}) - assert a.level == RiskLevel.MEDIUM + assert a.level == RiskLevel.HIGH def test_browser_read_page_low(self): a = classify_tool("browser_read_page", {"url": "https://example.com"}) From 7ac31525c7b3091d89d8730f0511fdbc78c5dda4 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:08:24 -0400 Subject: [PATCH 017/162] Pin approved SSH retry behavior alongside durable uncertainty --- tests/test_campaign_execution_outcomes.py | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_campaign_execution_outcomes.py b/tests/test_campaign_execution_outcomes.py index f28b9822c..d4df38c4e 100644 --- a/tests/test_campaign_execution_outcomes.py +++ b/tests/test_campaign_execution_outcomes.py @@ -250,3 +250,38 @@ async def test_approved_generic_exception_recovery_still_retries(tmp_path, monke assert exe._handle_read_file.await_count == 2 assert "recovered" in result.output assert not result.ok and result.uncertain_outcome + + +async def test_approved_ssh_timeout_retry_keeps_earlier_dispatch_uncertainty(tmp_path, monkeypatch): + exe = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + first, second = _proc(), _proc("retry response") + first.communicate = AsyncMock(side_effect=TimeoutError) + spawn = AsyncMock(side_effect=[first, second]) + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + monkeypatch.setattr(ssh, "terminate_process_tree", AsyncMock()) + monkeypatch.setattr("src.tools.ssh.asyncio.sleep", AsyncMock()) + + async def handler(_): + code, text = await ssh.run_ssh_command( + "example.test", "fixture", "k", "kh", max_retries=2) + return text, code + + exe._handle_run_command = handler + result = await exe.execute("run_command", {}) + assert spawn.await_count == 2 # approved #425 behavior is still intact + assert "retry response" in result.output + assert not result.ok and result.uncertain_outcome + + +async def test_local_spawn_refusal_is_definite_before_dispatch(tmp_path, monkeypatch): + exe = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + monkeypatch.setattr("src.tools.local_supervisor.create_supervised_shell", + AsyncMock(side_effect=PermissionError("fixture spawn refused"))) + + async def handler(_): + code, text = await ssh.run_local_command("fixture") + return text, code + + exe._handle_run_command = handler + result = await exe.execute("run_command", {}) + assert not result.ok and not result.uncertain_outcome From 9a10fc4b6909b6ad8e2c575a9ba0e3c7360b9252 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:11:57 -0400 Subject: [PATCH 018/162] Reap streamed children when an output consumer fails --- src/tools/ssh.py | 7 +++---- tests/test_campaign_execution_outcomes.py | 14 ++++++++++++++ tests/test_output_streamer.py | 12 ++++++------ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/tools/ssh.py b/src/tools/ssh.py index ccb5a1ddc..943bf58f2 100644 --- a/src/tools/ssh.py +++ b/src/tools/ssh.py @@ -194,10 +194,9 @@ async def _read_lines_with_callback( async def emit(text: str) -> None: lines.append(text) - try: - await on_output(text) - except Exception: - log.debug("on_output callback error", exc_info=True) + # A failing consumer must not abandon a running child outside the + # command deadline. The common exception arm owns cleanup. + await on_output(text) try: async with asyncio.timeout(timeout): diff --git a/tests/test_campaign_execution_outcomes.py b/tests/test_campaign_execution_outcomes.py index d4df38c4e..6ab39e596 100644 --- a/tests/test_campaign_execution_outcomes.py +++ b/tests/test_campaign_execution_outcomes.py @@ -285,3 +285,17 @@ async def handler(_): exe._handle_run_command = handler result = await exe.execute("run_command", {}) assert not result.ok and not result.uncertain_outcome + + +async def test_stream_callback_failure_reaps_owned_child(monkeypatch): + proc = _proc() + proc.returncode = None + monkeypatch.setattr( + "src.tools.local_supervisor.create_supervised_shell", AsyncMock(return_value=proc)) + cleanup = AsyncMock() + monkeypatch.setattr(ssh, "terminate_process_tree", cleanup) + callback = AsyncMock(side_effect=RuntimeError("fixture consumer failed")) + code, output = await ssh.run_local_command("fixture", on_output=callback) + assert code != 0 and "consumer failed" in output + assert cleanup.await_count >= 1 + assert all(call.kwargs.get("owned_pgid") == proc.pid for call in cleanup.await_args_list) diff --git a/tests/test_output_streamer.py b/tests/test_output_streamer.py index 72694c0a5..8831a1b62 100644 --- a/tests/test_output_streamer.py +++ b/tests/test_output_streamer.py @@ -588,7 +588,7 @@ async def on_output(line: str) -> None: assert "timed out" in output.lower() @pytest.mark.asyncio - async def test_callback_exception_ignored(self): + async def test_callback_exception_fails_with_owned_child_cleanup(self): from src.tools.ssh import run_local_command async def bad_callback(line: str) -> None: @@ -599,8 +599,8 @@ async def bad_callback(line: str) -> None: timeout=10, on_output=bad_callback, ) - assert code == 0 - assert "test" in output + assert code == 1 + assert "boom" in output @pytest.mark.asyncio async def test_callback_multiline(self): @@ -637,7 +637,7 @@ async def on_output(line: str) -> None: with patch("src.tools.ssh.asyncio.create_subprocess_exec") as mock_exec: proc = AsyncMock() - proc.stdout.readline = AsyncMock( + proc.stdout.read = AsyncMock( side_effect=[b"line1\n", b"line2\n", b""], ) proc.wait = AsyncMock() @@ -1255,7 +1255,7 @@ async def test_read_lines_with_callback_helper(self): from src.tools.ssh import _read_lines_with_callback proc = AsyncMock() - proc.stdout.readline = AsyncMock( + proc.stdout.read = AsyncMock( side_effect=[b"hello\n", b"world\n", b""], ) proc.wait = AsyncMock() @@ -1963,7 +1963,7 @@ async def flaky(_inp): executor._handle_test_tool = flaky result = await executor.execute("test_tool", {}) - assert result.ok and calls == 2 + assert not result.ok and result.uncertain_outcome and calls == 2 assert streamer.active_stream_count == 0, ( "each attempt's stream must be settled, retry included" ) From f822bc81b4345173a89dfc0eda8694bbba3aaf47 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:43:29 -0400 Subject: [PATCH 019/162] Fix scoped memory reads, ranked fusion payloads and cold embedding loading --- src/search/embedder.py | 15 +++++--- src/search/hybrid.py | 5 ++- src/tools/handlers/state.py | 9 +++-- tests/test_embedder_loading.py | 67 ++++++++++++++++++++++++++++++++++ tests/test_hybrid_search.py | 10 +++++ tests/test_state_handlers.py | 39 ++++++++++++++++++++ 6 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 tests/test_embedder_loading.py diff --git a/src/search/embedder.py b/src/search/embedder.py index 48d4bcfd0..6322f1063 100644 --- a/src/search/embedder.py +++ b/src/search/embedder.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import threading from functools import partial from typing import TYPE_CHECKING @@ -23,16 +24,20 @@ class LocalEmbedder: def __init__(self) -> None: self._model: TextEmbedding | None = None + self._model_lock = threading.Lock() def _ensure_model(self): - if self._model is None: - from fastembed import TextEmbedding - self._model = TextEmbedding(self.MODEL) + # Loading (including import/download) runs in the inference worker. + # A thread lock keeps first callers single-flight even if a waiting + # coroutine is cancelled while the worker is still initializing. + with self._model_lock: + if self._model is None: + from fastembed import TextEmbedding + self._model = TextEmbedding(self.MODEL) async def embed(self, text: str) -> list[float] | None: """Embed text. Returns 384-dim float list or None on failure.""" try: - self._ensure_model() text = text[:MAX_INPUT_CHARS] loop = asyncio.get_event_loop() result = await loop.run_in_executor( @@ -44,6 +49,6 @@ async def embed(self, text: str) -> list[float] | None: return None def _embed_sync(self, text: str) -> list[float]: - # _ensure_model() has run in every caller before dispatch here. + self._ensure_model() vectors = list(self._model.embed([text])) # type: ignore[union-attr] return vectors[0].tolist() diff --git a/src/search/hybrid.py b/src/search/hybrid.py index eb241cac3..7a5bcd4f3 100644 --- a/src/search/hybrid.py +++ b/src/search/hybrid.py @@ -21,6 +21,7 @@ def reciprocal_rank_fusion( """ scores: dict[str, float] = {} best: dict[str, dict] = {} + best_ranks: dict[str, int] = {} for result_list in result_lists: for rank_0, item in enumerate(result_list): @@ -32,8 +33,10 @@ def reciprocal_rank_fusion( rrf = 1.0 / (k + rank_0 + 1) # rank is 1-based in the formula scores[item_id] = scores.get(item_id, 0.0) + rrf # Keep the version from the list where it ranked highest - if item_id not in best: + # Equal ranks retain the earlier list's payload deterministically. + if item_id not in best or rank_0 < best_ranks[item_id]: best[item_id] = item + best_ranks[item_id] = rank_0 ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:limit] out = [] diff --git a/src/tools/handlers/state.py b/src/tools/handlers/state.py index f8ec09c87..32176d7ea 100644 --- a/src/tools/handlers/state.py +++ b/src/tools/handlers/state.py @@ -51,6 +51,7 @@ async def _handle_memory_manage(self, inp: dict, *, user_id: str | None = None) "Example: {'action': 'get', 'key': 'foo'}." ) scope = inp.get("scope", "personal") + explicit_scope = inp.get("scope") from ...json_store import StoreCorruptError async with self._memory_lock: @@ -66,9 +67,9 @@ async def _handle_memory_manage(self, inp: dict, *, user_id: str | None = None) f"preserved). Cannot complete '{action}' to avoid data loss. Details: {exc}" ) user_key = f"user_{user_id}" if user_id else None - if user_key and key in all_mem.get(user_key, {}): + if explicit_scope != "global" and user_key and key in all_mem.get(user_key, {}): return f"**{key}** (personal): {all_mem[user_key][key]}" - if key in all_mem.get("global", {}): + if explicit_scope != "personal" and key in all_mem.get("global", {}): return f"**{key}** (global): {all_mem['global'][key]}" return f"No note found with key '{key}'." @@ -144,11 +145,11 @@ async def _handle_memory_manage(self, inp: dict, *, user_id: str | None = None) f"preserved). Cannot complete '{action}' to avoid data loss. Details: {exc}" ) user_key = f"user_{user_id}" if user_id else None - if user_key and key in all_mem.get(user_key, {}): + if explicit_scope != "global" and user_key and key in all_mem.get(user_key, {}): del all_mem[user_key][key] await to_thread_settled(self._save_all_memory, all_mem) return f"Deleted personal note '{key}'." - elif key in all_mem.get("global", {}): + elif explicit_scope != "personal" and key in all_mem.get("global", {}): del all_mem["global"][key] await to_thread_settled(self._save_all_memory, all_mem) return f"Deleted global note '{key}'." diff --git a/tests/test_embedder_loading.py b/tests/test_embedder_loading.py new file mode 100644 index 000000000..a1f5bd0f8 --- /dev/null +++ b/tests/test_embedder_loading.py @@ -0,0 +1,67 @@ +"""Cold embedding load stays off-loop and single-flight, including cancellation.""" +import asyncio +import sys +import threading +from types import SimpleNamespace + +import pytest + +from src.search.embedder import LocalEmbedder + + +@pytest.mark.asyncio +async def test_cold_load_single_flight_and_cancelled_waiter(monkeypatch): + started = threading.Event() + release = threading.Event() + loads = [] + + class Model: + def __init__(self, name): + loads.append(name) + started.set() + assert release.wait(3) + + def embed(self, texts): + return [SimpleNamespace(tolist=lambda: [0.5] * 384) for _ in texts] + + monkeypatch.setitem(sys.modules, "fastembed", SimpleNamespace(TextEmbedding=Model)) + embedder = LocalEmbedder() + first = asyncio.create_task(embedder.embed("first")) + try: + for _ in range(100): + if started.is_set(): + break + await asyncio.sleep(0.005) + assert started.is_set() + second = asyncio.create_task(embedder.embed("second")) + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + await asyncio.sleep(0.02) + assert not second.done() + assert len(loads) == 1 + finally: + release.set() + assert await second == [0.5] * 384 + assert await embedder.embed("warm") == [0.5] * 384 + assert loads == [LocalEmbedder.MODEL] + + +@pytest.mark.asyncio +async def test_failed_load_can_be_retried(monkeypatch): + attempts = [] + + class Model: + def __init__(self, name): + attempts.append(name) + if len(attempts) == 1: + raise RuntimeError("download failed") + + def embed(self, texts): + return [SimpleNamespace(tolist=lambda: [1.0])] + + monkeypatch.setitem(sys.modules, "fastembed", SimpleNamespace(TextEmbedding=Model)) + embedder = LocalEmbedder() + assert await embedder.embed("first") is None + assert await embedder.embed("retry") == [1.0] + assert len(attempts) == 2 diff --git a/tests/test_hybrid_search.py b/tests/test_hybrid_search.py index 7cf3d7fdc..396959284 100644 --- a/tests/test_hybrid_search.py +++ b/tests/test_hybrid_search.py @@ -10,6 +10,16 @@ from src.search.errors import SearchInvariantError from src.search.hybrid import reciprocal_rank_fusion + +def test_later_list_better_rank_supplies_payload_and_ties_keep_first(): + first = [{"doc_id": "other"}, {"doc_id": "shared", "content": "semantic"}] + second = [{"doc_id": "shared", "content": "exact keyword snippet"}] + fused = reciprocal_rank_fusion(first, second) + assert fused[0]["content"] == "exact keyword snippet" + assert fused[0]["rrf_score"] == round(1 / 62 + 1 / 61, 6) + tied = reciprocal_rank_fusion(second, [{"doc_id": "shared", "content": "later"}]) + assert tied[0]["content"] == "exact keyword snippet" + # --------------------------------------------------------------------------- # Basic merging # --------------------------------------------------------------------------- diff --git a/tests/test_state_handlers.py b/tests/test_state_handlers.py index 8303d826c..1b8132df7 100644 --- a/tests/test_state_handlers.py +++ b/tests/test_state_handlers.py @@ -35,6 +35,45 @@ def _save(all_mem): class TestMemoryManage: + @pytest.mark.asyncio + @pytest.mark.parametrize("scope", ["personal", "global"]) + async def test_explicit_get_delete_scope_does_not_cross_namespaces(self, tools, scope): + for section, value in [("personal", "private"), ("global", "shared")]: + await tools._handle_memory_manage( + {"action": "save", "scope": section, "key": "same", "value": value}, + user_id="u1") + get = {"action": "get", "scope": scope, "key": "same"} + assert f"({scope})" in await tools._handle_memory_manage(get, user_id="u1") + assert f"Deleted {scope}" in await tools._handle_memory_manage( + {**get, "action": "delete"}, user_id="u1") + assert "No note found" in await tools._handle_memory_manage(get, user_id="u1") + assert "No note found" in await tools._handle_memory_manage( + {**get, "action": "delete"}, user_id="u1") + other = "global" if scope == "personal" else "personal" + assert f"({other})" in await tools._handle_memory_manage( + {**get, "scope": other}, user_id="u1") + + @pytest.mark.asyncio + async def test_omitted_scope_keeps_personal_first_then_global(self, tools): + for scope in ("personal", "global"): + await tools._handle_memory_manage( + {"action": "save", "scope": scope, "key": "same", "value": scope}, + user_id="u1") + for scope in ("personal", "global"): + assert f"({scope})" in await tools._handle_memory_manage( + {"action": "get", "key": "same"}, user_id="u1") + assert f"Deleted {scope}" in await tools._handle_memory_manage( + {"action": "delete", "key": "same"}, user_id="u1") + + @pytest.mark.asyncio + async def test_explicit_personal_without_identity_never_accesses_global(self, tools): + await tools._handle_memory_manage( + {"action": "save", "scope": "global", "key": "same", "value": "shared"}) + for action in ("get", "delete"): + assert "No note found" in await tools._handle_memory_manage( + {"action": action, "scope": "personal", "key": "same"}) + assert "shared" in await tools._handle_memory_manage({"action": "get", "key": "same"}) + @pytest.mark.asyncio async def test_missing_action(self, tools): assert "requires an 'action'" in await tools._handle_memory_manage({}) From 4fa5b53b79007192bdd878a64cc3a40194b35f68 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:49:22 -0400 Subject: [PATCH 020/162] Preserve knowledge identities and authoritative diffs; order fallback history globally --- src/discord/channel_logger.py | 6 +- src/knowledge/importer.py | 4 +- src/knowledge/store.py | 29 +++++--- tests/test_channel_history_recency.py | 18 +++++ tests/test_knowledge_import.py | 6 +- tests/test_knowledge_review_regressions.py | 85 ++++++++++++++++++++++ 6 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 tests/test_channel_history_recency.py create mode 100644 tests/test_knowledge_review_regressions.py diff --git a/src/discord/channel_logger.py b/src/discord/channel_logger.py index c4aa8a2df..274c4bc1e 100644 --- a/src/discord/channel_logger.py +++ b/src/discord/channel_logger.py @@ -207,7 +207,7 @@ def search(self, query: str, limit: int = 20, channel_id: str | None = None, """Keyword search on JSONL files (fallback when FTS is unavailable). Returns dicts with content, author, channel_id, timestamp, type="channel". - Reads files in reverse (newest messages first) for better relevance. + Merge matches globally by timestamp before applying the result limit. """ validate_search_query(query) results: list[dict] = [] @@ -245,10 +245,8 @@ def search(self, query: str, limit: int = 20, channel_id: str | None = None, "timestamp": record.get("ts", 0.0), "type": "channel", }) - if len(results) >= limit: - return results except Exception: continue except Exception: log.debug("Channel log keyword search failed", exc_info=True) - return results + return sorted(results, key=lambda r: r["timestamp"], reverse=True)[:max(0, limit)] diff --git a/src/knowledge/importer.py b/src/knowledge/importer.py index ebb1979d2..e38264cd4 100644 --- a/src/knowledge/importer.py +++ b/src/knowledge/importer.py @@ -413,7 +413,9 @@ async def import_pdf_url( except ImportError: return ImportResult(source=url, status="error", error="PyMuPDF (fitz) not installed") - src = source or url.rsplit("/", 1)[-1] or url + # Preserve host/path/query identity rather than replacing unrelated + # documents sharing a basename. Explicit legacy source names still work. + src = source or url.split("#", 1)[0] from ..tools.safe_fetch import BlockedAddressError, ResponseTooLargeError, safe_fetch try: resp = await safe_fetch(url, max_bytes=MAX_PDF_BYTES, timeout=15.0) diff --git a/src/knowledge/store.py b/src/knowledge/store.py index 5ea3ef441..7f45a691a 100644 --- a/src/knowledge/store.py +++ b/src/knowledge/store.py @@ -301,9 +301,16 @@ async def _ingest_locked( return outcome # Existing rows remain searchable until replacement is verified. - old_content = await to_thread_settled(self.get_source_content, source) - action = "update" if old_content is not None else "create" - diff_summary = self._make_diff_summary(old_content, content) + old_content = await to_thread_settled(self.get_source_snapshot, source) + source_exists = old_content is not None or await to_thread_settled( + self.get_source_content, source, + ) is not None + action = "update" if source_exists else "create" + diff_summary = ( + "previous full snapshot unavailable; content changes not measured" + if source_exists and old_content is None + else self._make_diff_summary(old_content, content) + ) indexed = await to_thread_settled( self._write_chunks_sync, chunks, @@ -1201,19 +1208,21 @@ def find_duplicates(self) -> list[dict]: return [] try: rows = self._conn.execute( # type: ignore[union-attr] - "SELECT doc_content_hash, GROUP_CONCAT(DISTINCT source) as sources, " - "COUNT(DISTINCT source) as src_count " + "SELECT DISTINCT doc_content_hash, source " "FROM knowledge_chunks " "WHERE doc_content_hash IS NOT NULL AND doc_content_hash != '' " - "GROUP BY doc_content_hash HAVING src_count > 1" + "ORDER BY doc_content_hash, source" ).fetchall() + grouped: dict[str, list[str]] = {} + for content_hash, source in rows: + grouped.setdefault(content_hash, []).append(source) return [ { - "content_hash": r[0], - "sources": r[1].split(","), - "source_count": r[2], + "content_hash": content_hash, + "sources": sources, + "source_count": len(sources), } - for r in rows + for content_hash, sources in grouped.items() if len(sources) > 1 ] except Exception: return [] diff --git a/tests/test_channel_history_recency.py b/tests/test_channel_history_recency.py new file mode 100644 index 000000000..19d5b3026 --- /dev/null +++ b/tests/test_channel_history_recency.py @@ -0,0 +1,18 @@ +import json + +from src.discord.channel_logger import ChannelLogger + + +def test_cross_channel_fallback_returns_newest_independent_of_file_order(tmp_path): + logger = ChannelLogger(str(tmp_path)) + for channel, timestamp, author in [("old", 1, "alice"), ("new", 200, "alice"), + ("private", 300, "bob")]: + (tmp_path / f"{channel}.jsonl").write_text(json.dumps({ + "content": "needle", "channel_id": channel, "ts": timestamp, + "author_id": author, "author": author, + }) + "\n") + assert logger.search("needle", 1)[0]["channel_id"] == "private" + assert logger.search("needle", 1, author_id="alice")[0]["channel_id"] == "new" + assert logger.search("needle", 1, accept=lambda r: r["ts"] < 300)[0]["channel_id"] == "new" + assert logger.search("needle", 1, channel_id="old")[0]["channel_id"] == "old" + assert logger.search("needle", 0) == [] diff --git a/tests/test_knowledge_import.py b/tests/test_knowledge_import.py index 0b62d014c..7e6d6c42e 100644 --- a/tests/test_knowledge_import.py +++ b/tests/test_knowledge_import.py @@ -912,7 +912,7 @@ async def test_successful_pdf_import(self): r = await importer.import_pdf_url("https://example.com/test.pdf") assert r.status == "ok" assert r.chunks > 0 - assert r.source == "test.pdf" + assert r.source == "https://example.com/test.pdf" finally: _cleanup(store) @@ -977,7 +977,7 @@ async def test_source_from_url_path(self): with patch.dict("sys.modules", {"fitz": mock_fitz}), \ patch("src.tools.safe_fetch.safe_fetch", mock_session): r = await importer.import_pdf_url("https://example.com/path/to/manual.pdf") - assert r.source == "manual.pdf" + assert r.source == "https://example.com/path/to/manual.pdf" finally: _cleanup(store) @@ -996,7 +996,7 @@ async def test_multi_page_pdf(self): patch("src.tools.safe_fetch.safe_fetch", mock_session): r = await importer.import_pdf_url("https://example.com/multi.pdf") assert r.status == "ok" - content = store.get_source_content("multi.pdf") + content = store.get_source_content("https://example.com/multi.pdf") assert "Page 1" in content assert "Page 3" in content finally: diff --git a/tests/test_knowledge_review_regressions.py b/tests/test_knowledge_review_regressions.py new file mode 100644 index 000000000..d4a72a8a8 --- /dev/null +++ b/tests/test_knowledge_review_regressions.py @@ -0,0 +1,85 @@ +"""Knowledge regressions through real ingest/import publication.""" +import sys +from types import SimpleNamespace + +import pytest + +from src.knowledge.importer import BulkImporter +from src.knowledge.store import KnowledgeStore + + +@pytest.fixture +def store(tmp_path): + store = KnowledgeStore(str(tmp_path / "knowledge.db")) + yield store + store.close() + + +@pytest.mark.asyncio +async def test_update_diff_uses_full_snapshot_not_overlapping_chunk_preview(store): + original = "\n".join(f"Line {n}: unique paragraph " + "word " * 25 for n in range(100)) + changed = original.replace("Line 50:", "Changed 50:") + await store.ingest(original, "long", dedup=False) + assert store.get_source_content("long") != original + await store.ingest(changed, "long", dedup=False) + versions = store.get_versions("long") + assert versions[0]["action"] == "update" + assert versions[0]["diff_summary"] == "+1 lines, -1 lines" + diff = store.get_version_diff("long", 1, 2) + assert diff is not None + assert store.get_source_snapshot("long") == changed + + +@pytest.mark.asyncio +async def test_legacy_update_discloses_missing_snapshot(store): + await store.ingest("legacy content", "legacy", dedup=False) + store._conn.execute("DELETE FROM knowledge_versions WHERE source='legacy'") + store._conn.commit() + await store.ingest("replacement", "legacy", dedup=False) + version = store.get_versions("legacy")[0] + assert version["action"] == "update" + assert "snapshot unavailable" in version["diff_summary"] + + +@pytest.mark.asyncio +async def test_duplicate_sources_remain_lossless(store): + names = ["first,source 雪", "second,é", "third"] + for source in names: + await store.ingest("identical body " * 150, source, dedup=False) + groups = store.find_duplicates() + assert len(groups) == 1 + assert set(groups[0]["sources"]) == set(names) + assert groups[0]["source_count"] == 3 + + +@pytest.mark.asyncio +async def test_pdf_basename_collisions_and_explicit_legacy_name(store, monkeypatch): + async def fetch(url, **kwargs): + return SimpleNamespace(status=200, body=url.encode()) + + class Pdf: + page_count = 1 + + def __init__(self, content): + self.content = content + + def __getitem__(self, index): + return SimpleNamespace(get_text=lambda: self.content) + + def close(self): + pass + + monkeypatch.setattr("src.tools.safe_fetch.safe_fetch", fetch) + monkeypatch.setitem(sys.modules, "fitz", SimpleNamespace( + open=lambda stream, filetype: Pdf(stream.decode()))) + importer = BulkImporter(store) + urls = ["https://first.example/manual.pdf", "https://second.example/manual.pdf"] + for url in urls: + result = await importer.import_pdf_url(url) + assert result.status == "ok" + assert result.source == url + assert all(url in store.get_source_snapshot(url) for url in urls) + result = await importer.import_pdf_url(urls[0], source="manual.pdf") + assert result.source == "manual.pdf" + assert result.status in ("ok", "skipped") + assert all(store.get_source_snapshot(url) for url in urls) From 886f0aa67c14efd5aa6e90ea906940972fac127a Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:08:06 -0400 Subject: [PATCH 021/162] Preserve compacted context and eligible history; keep API executions memory-only --- src/sessions/manager.py | 122 +++++++++-- src/web/api/sessions_chat.py | 58 ++++-- tests/test_chat_session.py | 3 +- tests/test_execute_api.py | 59 ++---- tests/test_knowledge_review_regressions.py | 7 +- tests/test_sessions_review_regressions.py | 228 +++++++++++++++++++++ tests/test_web_api_sessions_chat.py | 3 +- 7 files changed, 398 insertions(+), 82 deletions(-) create mode 100644 tests/test_sessions_review_regressions.py diff --git a/src/sessions/manager.py b/src/sessions/manager.py index 42fed4b42..a8f2faa88 100644 --- a/src/sessions/manager.py +++ b/src/sessions/manager.py @@ -10,6 +10,7 @@ import time import uuid from collections.abc import Awaitable, Callable, Iterable +from contextlib import contextmanager from dataclasses import asdict, dataclass, field from datetime import datetime from functools import wraps @@ -399,6 +400,13 @@ def estimated_tokens(self) -> int: """Current estimated token count for this session's full content.""" return _estimate_session_tokens(self.messages, self.summary, self.summary_segments) + @property + def has_summary(self) -> bool: + """Whether legacy or rolling context contains meaningful summary text.""" + return bool(self.summary.strip()) or any( + bool(seg.get("summary", "").strip()) for seg in self.summary_segments + ) + class SessionManager: def __init__( @@ -439,6 +447,7 @@ def __init__( self._continuity_source: dict[str, str] = {} self._sessions: dict[str, Session] = {} self._dirty: set[str] = set() + self._ephemeral_channels: set[str] = set() # Monotonic per-channel mutation watermark (PR #242, round-4 # blocker #3): bumped on EVERY semantic history mutation (append, # removal, compaction, secret scrub, tombstone) — unlike message @@ -468,12 +477,37 @@ def set_compaction_fn(self, fn: CompactionFn) -> None: """ self._compaction_fn = fn + @contextmanager + def ephemeral(self, channel_id: str): + """Own a unique, memory-only channel until the request settles. + + Registration is serialized with publication, so background saves cannot + publish this channel. Persistent channels never use this lifecycle. + """ + with self._publication_lock, self._state_lock: + if (channel_id in self._sessions or channel_id in self._ephemeral_channels + or channel_id in self._reset_epochs + or channel_id in self._pending_reset_epochs + or (self.persist_dir / f"{channel_id}.json").exists()): + raise ValueError("Ephemeral channel must be new and unique") + self._ephemeral_channels.add(channel_id) + try: + yield + finally: + with self._publication_lock, self._state_lock: + self._sessions.pop(channel_id, None) + self._dirty.discard(channel_id) + self._mutation_revisions.pop(channel_id, None) + self._continuity_source.pop(channel_id, None) + self._ephemeral_channels.discard(channel_id) + @_owned def get_or_create(self, channel_id: str) -> Session: if channel_id in self._pending_reset_epochs: raise RuntimeError("Reset durability unresolved; retry reset before session activity") if channel_id not in self._sessions: - session = self._restore_from_archive(channel_id) + session = (None if channel_id in self._ephemeral_channels + else self._restore_from_archive(channel_id)) if session is None: session = Session(channel_id=channel_id) self._continuity_source[channel_id] = "fresh" @@ -844,7 +878,7 @@ def get_session_token_usage(self) -> dict[str, dict]: result[cid] = { "estimated_tokens": tokens, "message_count": len(session.messages), - "has_summary": bool(session.summary), + "has_summary": session.has_summary, "budget": self.token_budget, "budget_pct": (round(tokens / self.token_budget * 100, 1) if self.token_budget > 0 else 0.0), @@ -1062,6 +1096,8 @@ async def _compact(self, session: Session) -> None: [{"role": "user", "content": convo_text}], system_instruction, ) + if not isinstance(summary_text, str) or not summary_text.strip(): + raise ValueError("Compaction backend returned an empty summary") with self._state_lock: if (self._sessions.get(session.channel_id) is not session @@ -1080,7 +1116,8 @@ async def _compact(self, session: Session) -> None: len(session.summary_segments), ) - if self._reflector and len(discarded) >= 5: + if (self._reflector and len(discarded) >= 5 + and session.channel_id not in self._ephemeral_channels): # Collect all distinct user_ids from discarded messages participant_ids = list(dict.fromkeys( m.user_id for m in discarded if m.user_id @@ -1264,6 +1301,7 @@ def prune(self) -> int: for cid, s in self._sessions.items() if now - s.last_active > self.max_age_seconds and cid not in self._pending_reset_epochs + and cid not in self._ephemeral_channels ] for cid in expired: self._archive_session(cid) @@ -1283,6 +1321,8 @@ def _archive_session(self, channel_id: str) -> None: session = self._sessions.get(channel_id) if not session or not (session.messages or session.summary or session.summary_segments): return + if channel_id in self._ephemeral_channels: + return archive_dir = self.persist_dir / "archive" archive_dir.mkdir(exist_ok=True) timestamp: float = int(session.last_active) @@ -1470,14 +1510,16 @@ async def search_history( - before: only messages with timestamp <= before (epoch seconds) """ validate_search_query(query) + if limit <= 0: + return [] query_lower = query.lower() results: list[dict] = [] seen_segments: set[str] = set() def _ts_ok(ts: float) -> bool: - if after and ts < after: + if after is not None and ts < after: return False - if before and ts > before: + if before is not None and ts > before: return False return True @@ -1536,33 +1578,68 @@ def _ts_ok(ts: float) -> bool: if len(results) >= limit: return results[:limit] + async def _eligible_ranked(fetch, *, segments: bool = False) -> list[dict]: + """Grow retrieval until eligible slots are filled or exhausted. + + Backend ranking and source priority stay unchanged. Scope, time, + reset and duplicate candidates cannot consume the caller's budget. + """ + candidate_limit = max(1, limit) + existing = {(r.get("channel_id", ""), r.get("timestamp", 0)) for r in results} + while True: + candidates = await fetch(candidate_limit) + selected = [] + seen = set(existing) + for record in candidates: + cid = record.get("channel_id", "") + ts = record.get("timestamp", 0) + if ((channel_id and cid != channel_id) or not _ts_ok(ts) + or ts <= self._reset_epochs.get(cid, 0.0) + or (segments and record.get("doc_id") in seen_segments)): + continue + key = (cid, ts) + if key in seen: + continue + selected.append(record) + seen.add(key) + if len(selected) >= limit - len(results): + return selected + if len(candidates) < candidate_limit: + return selected + candidate_limit *= 2 + async def _append_channel_matches() -> None: # Full channel history from all users. An FTS execution failure is # not equivalent to an empty result set. if len(results) < limit and self._channel_logger: - remaining = limit - len(results) fts = self._fts_index channel_results = [] if not user_id and fts and hasattr(fts, "search_channel_logs"): - channel_results = await asyncio.to_thread( - fts.search_channel_logs, query, limit=remaining, - channel_id=channel_id, + channel_results = await _eligible_ranked( + lambda count: asyncio.to_thread( + fts.search_channel_logs, query, limit=count, + channel_id=channel_id, + ), ) if not channel_results and hasattr(self._channel_logger, "search"): if user_id: - channel_results = await asyncio.to_thread( - self._channel_logger.search, query, remaining, channel_id, - author_id=user_id, - accept=lambda record: ( - _ts_ok(record.get("ts", 0)) and - record.get("ts", 0) > self._reset_epochs.get( - record.get("channel_id", ""), 0.0)), + channel_results = await _eligible_ranked( + lambda count: asyncio.to_thread( + self._channel_logger.search, query, count, channel_id, + author_id=user_id, + accept=lambda record: ( + _ts_ok(record.get("ts", 0)) and + record.get("ts", 0) > self._reset_epochs.get( + record.get("channel_id", ""), 0.0)), + ), ) else: # Preserve compatibility with legacy search(query, limit) # implementations; channel scope is filtered below. - channel_results = await asyncio.to_thread( - self._channel_logger.search, query, remaining, + channel_results = await _eligible_ranked( + lambda count: asyncio.to_thread( + self._channel_logger.search, query, count, + ), ) seen = {(r.get("channel_id", ""), r.get("timestamp", 0)) for r in results} for cr in channel_results: @@ -1591,8 +1668,10 @@ async def _append_channel_matches() -> None: # Backend failures propagate to the caller rather than masquerading as # a keyword-only result set. if not user_id and len(results) < limit and self._vector_store: - hybrid_results = await self._vector_store.search_hybrid( - query, self._embedder, limit=limit, + hybrid_results = await _eligible_ranked( + lambda count: self._vector_store.search_hybrid( + query, self._embedder, limit=count, + ), segments=True, ) seen = {(r["channel_id"], r.get("timestamp", 0)) for r in results} for hr in hybrid_results: @@ -1686,6 +1765,9 @@ def _save_sessions(self, *, all_sessions: bool) -> None: channels = list(self._sessions if all_sessions else self._dirty) for cid in channels: with self._state_lock: + if cid in self._ephemeral_channels: + self._dirty.discard(cid) + continue if cid in self._pending_reset_epochs: # Keep this channel dirty/fenced, not the entire bot. log.warning("Session %s save suspended: reset durability unresolved", cid) diff --git a/src/web/api/sessions_chat.py b/src/web/api/sessions_chat.py index 4ecfd8822..55c0e6e9b 100644 --- a/src/web/api/sessions_chat.py +++ b/src/web/api/sessions_chat.py @@ -15,6 +15,7 @@ from ...odin_log import get_logger from ...search.errors import InvalidSearchQuery +from ...sessions.manager import SessionManager from ..api_common import ( _SESSION_ID_RE, _safe_filename, @@ -145,7 +146,7 @@ async def execute(request: web.Request) -> web.Response: {"error": f"prompt exceeds {MAX_CHAT_CONTENT_LEN} chars"}, status=400 ) - channel_id = f"api-{uuid.uuid4().hex[:12]}" + channel_id = f"api-{uuid.uuid4().hex}" # Resolve identity from middleware or fallback identity = getattr(request, "_api_identity", None) @@ -167,17 +168,16 @@ async def execute(request: web.Request) -> web.Response: ) token_default_host = getattr(identity, "default_host", "") if identity else "" - result = await _pkg_process_web_chat( - bot, content, channel_id, - user_id=user_id, username=username, - allowed_tools=token_tools, tier=tier, - token_allowed_hosts=token_hosts, - token_default_host=token_default_host, - persist_channel_lock=False, # ephemeral per-request channel — no lock to cache or leak - _request=request, - ) - - bot.sessions.reset(channel_id) + with bot.sessions.ephemeral(channel_id): + result = await _pkg_process_web_chat( + bot, content, channel_id, + user_id=user_id, username=username, + allowed_tools=token_tools, tier=tier, + token_allowed_hosts=token_hosts, + token_default_host=token_default_host, + persist_channel_lock=False, # no lock to cache or leak + _request=request, + ) status = 200 if not result["is_error"] else 502 resp = { @@ -226,7 +226,7 @@ async def list_sessions(request: web.Request) -> web.Response: "estimated_tokens": session.estimated_tokens, "last_active": session.last_active, "created_at": session.created_at, - "has_summary": bool(session.summary), + "has_summary": session.has_summary, "preview": preview, "source": source, "last_user_id": session.last_user_id, @@ -260,8 +260,10 @@ async def search_sessions(request: web.Request) -> web.Response: limit = _safe_int_param(request, "limit", 20, hi=50) channel_id = request.query.get("channel_id") or None if not is_admin: - # is_admin is only False when identity was truthy (see above). - channel_id = identity.user_id # type: ignore[union-attr] + if channel_id: + denied = _check_session_access(request, channel_id) + if denied is not None: + return denied user_id = request.query.get("user_id") or None after: float | None = None before: float | None = None @@ -276,10 +278,26 @@ async def search_sessions(request: web.Request) -> web.Response: except ValueError: pass try: - results = await bot.sessions.search_history( - query, limit=limit, channel_id=channel_id, - user_id=user_id, after=after, before=before, - ) + if not is_admin and not channel_id: + # Unscoped searches cover default and currently owned scoped + # conversations. Explicit scopes also reach archived channels. + own_id = identity.user_id # type: ignore[union-attr] + channels = {own_id} + channels.update(cid for cid, _ in bot.sessions.items_snapshot() + if cid.startswith(f"web:{own_id}:session:")) + results = [] + for cid in sorted(channels): + results.extend(await bot.sessions.search_history( + query, limit=limit, channel_id=cid, + user_id=user_id, after=after, before=before, + )) + results.sort(key=lambda r: r.get("timestamp", 0), reverse=True) + results = results[:limit] + else: + results = await bot.sessions.search_history( + query, limit=limit, channel_id=channel_id, + user_id=user_id, after=after, before=before, + ) except InvalidSearchQuery: return web.json_response({"error": "invalid query"}, status=400) except Exception: @@ -320,7 +338,7 @@ async def get_session(request: web.Request) -> web.Response: return web.json_response({ "channel_id": cid, "messages": messages, - "summary": session.summary, + "summary": SessionManager._render_context_summary(session), "created_at": session.created_at, "last_active": session.last_active, "estimated_tokens": session.estimated_tokens, diff --git a/tests/test_chat_session.py b/tests/test_chat_session.py index 383559c78..6687dd596 100644 --- a/tests/test_chat_session.py +++ b/tests/test_chat_session.py @@ -131,7 +131,8 @@ async def test_non_admin_sees_own_scoped_sessions_only(): def _sess(): return SimpleNamespace(messages=[], estimated_tokens=0, last_active=0.0, - created_at=0.0, summary="", last_user_id="x") + created_at=0.0, summary="", summary_segments=[], + has_summary=False, last_user_id="x") bot.sessions.items_snapshot.return_value = [ ("api-user", _sess()), # own default diff --git a/tests/test_execute_api.py b/tests/test_execute_api.py index 258df56a5..3d6bb2aef 100644 --- a/tests/test_execute_api.py +++ b/tests/test_execute_api.py @@ -1,64 +1,45 @@ """Tests for the /api/execute stateless endpoint and CLI script. -Uses a standalone aiohttp app that mirrors the real handler logic from -src/web/api.py. This avoids bootstrapping a full OdinBot but means -changes to the production handler must be reflected here. +Uses the real route registrar with a fake bot and provider boundary. """ from __future__ import annotations -import uuid from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiohttp import web from aiohttp.test_utils import TestClient, TestServer +from src.config.schema import Config +from src.web.api.sessions_chat import register_chat + MAX_CHAT_CONTENT_LEN = 32_000 def _make_bot(): bot = MagicMock() bot.sessions = MagicMock() + bot.config = Config(discord={"token": "test"}) + bot.api_token_manager = None return bot _mock_result = None def _make_app(bot): - """Build a minimal app whose /api/execute mirrors the real handler.""" - app = web.Application() - routes = web.RouteTableDef() - - @routes.post("/api/execute") - async def execute(request): + """Exercise production validation, identity and ephemeral ownership.""" + @web.middleware + async def provider_boundary(request, handler): auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer ") or auth[7:] != "test-token": return web.json_response({"error": "unauthorized"}, status=401) - try: - data = await request.json() - except Exception: - return web.json_response({"error": "invalid JSON"}, status=400) - content = (data.get("prompt") or data.get("content") or "").strip() - if not content: - return web.json_response({"error": "prompt is required"}, status=400) - - channel_id = f"api-{uuid.uuid4().hex[:12]}" result = _mock_result or {"response": "", "tools_used": [], "is_error": True, "files": []} + with patch("src.web.api.process_web_chat", new=AsyncMock(return_value=result)): + return await handler(request) - # Mirror production: identity is hardcoded, not caller-controlled - _user_id = "api-user" - _username = "API" - - bot.sessions.reset(channel_id) - - status_code = 200 if not result["is_error"] else 502 - resp = { - "response": result["response"], - "tools_used": result["tools_used"], - "is_error": result["is_error"], - } - return web.json_response(resp, status=status_code) - + app = web.Application(middlewares=[provider_boundary]) + routes = web.RouteTableDef() + register_chat(routes, bot) app.router.add_routes(routes) return app @@ -165,8 +146,9 @@ async def test_ephemeral_session_cleaned_up(self): headers={"Authorization": "Bearer test-token"}, ) assert resp.status == 200 - bot.sessions.reset.assert_called_once() - call_arg = bot.sessions.reset.call_args[0][0] + bot.sessions.reset.assert_not_called() + bot.sessions.ephemeral.assert_called_once() + call_arg = bot.sessions.ephemeral.call_args[0][0] assert call_arg.startswith("api-") finally: _mock_result = None @@ -244,7 +226,7 @@ async def test_identity_hardcoded_not_caller_controlled(self): assert process_web_chat.call_args.kwargs["username"] == "API" @pytest.mark.asyncio - async def test_session_reset_called(self): + async def test_ephemeral_lifecycle_without_durable_reset(self): bot = _make_bot() bot.config = MagicMock() bot.config.web.api_token = "" @@ -270,8 +252,9 @@ async def test_session_reset_called(self): json={"prompt": "test"}, ) assert resp.status == 200 - bot.sessions.reset.assert_called_once() - channel_id = bot.sessions.reset.call_args[0][0] + bot.sessions.reset.assert_not_called() + bot.sessions.ephemeral.assert_called_once() + channel_id = bot.sessions.ephemeral.call_args[0][0] assert channel_id.startswith("api-") @pytest.mark.asyncio diff --git a/tests/test_knowledge_review_regressions.py b/tests/test_knowledge_review_regressions.py index d4a72a8a8..5c926fd87 100644 --- a/tests/test_knowledge_review_regressions.py +++ b/tests/test_knowledge_review_regressions.py @@ -79,7 +79,10 @@ def close(self): assert result.status == "ok" assert result.source == url assert all(url in store.get_source_snapshot(url) for url in urls) - result = await importer.import_pdf_url(urls[0], source="manual.pdf") + await store.ingest("legacy snapshot", "manual.pdf") + replacement_url = "https://legacy.example/manual.pdf" + result = await importer.import_pdf_url(replacement_url, source="manual.pdf") assert result.source == "manual.pdf" - assert result.status in ("ok", "skipped") + assert result.status == "ok" + assert replacement_url in store.get_source_snapshot("manual.pdf") assert all(store.get_source_snapshot(url) for url in urls) diff --git a/tests/test_sessions_review_regressions.py b/tests/test_sessions_review_regressions.py new file mode 100644 index 000000000..148523603 --- /dev/null +++ b/tests/test_sessions_review_regressions.py @@ -0,0 +1,228 @@ +"""Review regressions exercise the real session manager and route handlers.""" +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import web + +from src.config.schema import Config +from src.discord.channel_logger import ChannelLogger +from src.search.fts import FullTextIndex +from src.search.vectorstore import SessionVectorStore +from src.sessions.manager import Session, SessionManager +from src.web.api.sessions_chat import register_chat, register_sessions +from src.web.chat import process_web_chat + + +@pytest.fixture +def manager(tmp_path): + return SessionManager(max_history=20, max_age_hours=24, + persist_dir=str(tmp_path / "sessions"), + token_budget=1, adaptive_compaction=False) + + +def handler(registrar, bot, name): + routes = web.RouteTableDef() + registrar(routes, bot) + return next(route.handler for route in routes if route.handler.__name__ == name) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reply", ["", " \n\t ", None]) +async def test_empty_compaction_uses_deterministic_nonempty_fallback(manager, reply): + manager.set_compaction_fn(AsyncMock(return_value=reply)) + for i in range(120): + manager.add_message("channel", "user", f"source fact {i}", user_id="alice") + history = await manager.get_task_history("channel", current_query="source fact") + session = manager.get("channel") + assert len(session.messages) == 20 + assert session.summary_segments[0]["fallback"] is True + assert session.summary_segments[0]["summary"].strip() + assert "source fact 0" in session.summary_segments[0]["summary"] + assert any("compaction fallback" in str(message) for message in history) + manager.save() + saved = json.loads((manager.persist_dir / "channel.json").read_text()) + assert saved["summary_segments"][0]["summary"].strip() + + +@pytest.mark.asyncio +async def test_rolling_summary_presence_reaches_metrics_list_and_detail(manager): + manager.set_compaction_fn(AsyncMock(return_value="Earlier facts and decisions")) + for i in range(120): + manager.add_message("alice", "user", f"source fact {i}") + await manager.get_task_history("alice", current_query="facts") + session = manager.get("alice") + assert session.summary == "" and session.summary_segments + assert session.has_summary + assert manager.get_session_token_usage()["alice"]["has_summary"] is True + bot = SimpleNamespace(sessions=manager, config=Config(discord={"token": "test"}), + api_token_manager=None) + listed = await handler(register_sessions, bot, "list_sessions")(SimpleNamespace()) + assert json.loads(listed.text)[0]["has_summary"] is True + detail = await handler(register_sessions, bot, "get_session")( + SimpleNamespace(match_info={"channel_id": "alice"})) + assert "Earlier facts" in json.loads(detail.text)["summary"] + assert not Session("blank", summary=" \t", summary_segments=[{"summary": "\n"}]).has_summary + assert Session("legacy", summary="legacy facts").has_summary + + +@pytest.mark.asyncio +async def test_non_admin_scoped_search_parity_and_forbidden_scope(manager): + scoped = "web:alice:session:project" + for cid in ["alice", scoped, "web:bob:session:project", "alice-not-owned"]: + manager.add_message(cid, "user", "needle", user_id="alice") + bot = SimpleNamespace(sessions=manager) + search = handler(register_sessions, bot, "search_sessions") + identity = SimpleNamespace(user_id="alice", tier="user") + + def request(query): + return SimpleNamespace(query=query, _api_identity=identity) + + response = await search(request({"q": "needle", "channel_id": scoped})) + assert response.status == 200 + assert [r["channel_id"] for r in json.loads(response.text)["results"]] == [scoped] + response = await search(request({"q": "needle"})) + assert {r["channel_id"] for r in json.loads(response.text)["results"]} == {"alice", scoped} + for denied in ["web:bob:session:project", "alice-not-owned"]: + assert (await search(request({"q": "needle", "channel_id": denied}))).status == 403 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("outcome", ["success", "failure", "cancelled"]) +async def test_execute_never_persists_sessions_or_reset_metadata(manager, outcome): + bot = MagicMock() + bot.sessions = manager + bot.config = Config(discord={"token": "test"}) + bot.api_token_manager = None + execute = handler(register_chat, bot, "execute") + channels = [] + + async def chat(bot, content, channel_id, **kwargs): + channels.append(channel_id) + assert kwargs["persist_channel_lock"] is False + manager.add_message(channel_id, "user", content, user_id="alice") + manager.add_message(channel_id, "assistant", "answer") + # Both per-turn and concurrent background/shutdown saves skip it. + await asyncio.to_thread(manager.save) + await asyncio.to_thread(manager.save_all) + manager._archive_session(channel_id) + assert not list(manager.persist_dir.glob("*.json")) + if outcome == "failure": + raise RuntimeError("provider failed") + if outcome == "cancelled": + raise asyncio.CancelledError() + return {"response": "answer", "tools_used": [], "is_error": False} + + request = SimpleNamespace(json=AsyncMock(return_value={"prompt": "question"}), + headers={}, path="/api/execute") + with patch("src.web.api.process_web_chat", new=chat): + for _ in range(3): + if outcome == "success": + assert (await execute(request)).status == 200 + else: + error = RuntimeError if outcome == "failure" else asyncio.CancelledError + with pytest.raises(error): + await execute(request) + assert len(set(channels)) == 3 + assert manager.count() == 0 + assert manager._dirty == set() + assert manager._reset_epochs == manager._mutation_revisions == manager._continuity_source == {} + assert manager._ephemeral_channels == set() + assert list(manager.persist_dir.iterdir()) == [] + manager.add_message("persistent", "user", "preserved") + manager.save() + manager.reset("persistent") + assert "persistent" in manager._reset_epochs + + +@pytest.mark.asyncio +async def test_channel_fts_eligibility_before_limit(manager, tmp_path): + fts = FullTextIndex(str(tmp_path / "fts.db")) + logger = ChannelLogger(str(tmp_path / "logs")) + try: + for i, (cid, ts) in enumerate([("other", 200), ("wanted", 50), ("wanted", 150), + ("wanted", 250)]): + fts.index_channel_messages([{"message_id": str(i), "content": "needle", + "author": "author", "channel_id": cid, "ts": ts}]) + manager._reset_epochs["wanted"] = 175 + manager.set_channel_search(logger, fts) + results = await manager.search_history("needle", limit=1, channel_id="wanted", after=100) + assert len(results) == 1 + assert results[0]["timestamp"] == 250 + results = await manager.search_history("needle", limit=1, channel_id="wanted", before=0) + assert results == [] + finally: + fts._conn.close() + + +@pytest.mark.asyncio +async def test_hybrid_eligibility_before_limit_with_real_fts(manager, tmp_path): + fts = FullTextIndex(str(tmp_path / "fts.db")) + vector = SessionVectorStore(str(tmp_path / "vectors.db"), fts_index=fts) + try: + for doc_id, cid, ts in [("other", "other", 250), ("purged", "wanted", 50), + ("expired", "wanted", 150), ("eligible", "wanted", 250)]: + assert fts.index_session(doc_id, "needle", cid, ts) + manager._vector_store = vector + manager._reset_epochs["wanted"] = 100 + results = await manager.search_history("needle", limit=1, channel_id="wanted", after=200) + assert len(results) == 1 and results[0]["doc_id"] == "eligible" + finally: + vector._conn.close() + fts._conn.close() + + +@pytest.mark.asyncio +async def test_execute_real_chat_history_and_save_are_ephemeral(manager): + bot = MagicMock() + bot.sessions = manager + bot.config = Config(discord={"token": "test"}) + bot.api_token_manager = None + bot.llm_gateway.codex_client = object() + bot.prompt_builder.build_full_prompt.return_value = "System prompt" + bot.turn_recorder._new_context_trace.return_value = None + bot.delivery.set_status = AsyncMock() + channels = [] + + async def run(message, history, **kwargs): + cid = str(message.channel.id) + channels.append(cid) + assert manager.exists(cid) + assert history[-1]["content"].endswith("question") + await asyncio.to_thread(manager.save_all) + assert not list(manager.persist_dir.glob("*.json")) + return "answer", False, False, [], None + + bot.tool_loop.run = AsyncMock(side_effect=run) + execute = handler(register_chat, bot, "execute") + request = SimpleNamespace(json=AsyncMock(return_value={"prompt": "question"}), + headers={}, path="/api/execute") + with patch("src.web.api.process_web_chat", new=process_web_chat): + response = await execute(request) + assert response.status == 200 + assert json.loads(response.text)["response"] == "answer" + assert len(channels) == 1 and manager.count() == 0 + assert manager._reset_epochs == manager._mutation_revisions == {} + assert list(manager.persist_dir.iterdir()) == [] + + +@pytest.mark.asyncio +async def test_ranked_channel_duplicate_candidates_cannot_hide_next_match(manager, tmp_path): + fts = FullTextIndex(str(tmp_path / "fts.db")) + logger = ChannelLogger(str(tmp_path / "logs")) + try: + manager.add_message("wanted", "user", "needle") + timestamp = manager.get("wanted").messages[0].timestamp + records = [{"message_id": str(i), "content": "needle", "author": "alice", + "channel_id": "wanted", "ts": timestamp} for i in range(6)] + records.append({"message_id": "distinct", "content": "needle", "author": "alice", + "channel_id": "wanted", "ts": timestamp + 1}) + fts.index_channel_messages(records) + manager.set_channel_search(logger, fts) + results = await manager.search_history("needle", limit=2) + assert len(results) == 2 + assert {r["timestamp"] for r in results} == {timestamp, timestamp + 1} + finally: + fts._conn.close() diff --git a/tests/test_web_api_sessions_chat.py b/tests/test_web_api_sessions_chat.py index 271e4038e..cf7a10684 100644 --- a/tests/test_web_api_sessions_chat.py +++ b/tests/test_web_api_sessions_chat.py @@ -38,7 +38,8 @@ def _msg(role="user", content="hi", ts=1.0, uid="u"): def _session(messages=None, summary=""): return SimpleNamespace(messages=messages if messages is not None else [_msg()], estimated_tokens=10, last_active=1.0, created_at=1.0, - summary=summary, last_user_id="u") + summary=summary, summary_segments=[], has_summary=bool(summary), + last_user_id="u") def _bot(): From ecc48abe8da273323c952ed1e2a0821276324fc5 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:10:24 -0400 Subject: [PATCH 022/162] Fence ephemeral clears and bound globally ordered fallback matches --- src/discord/channel_logger.py | 17 +++++++--- src/sessions/manager.py | 13 +++++++- tests/test_sessions_review_regressions.py | 39 +++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/discord/channel_logger.py b/src/discord/channel_logger.py index 274c4bc1e..e3e7f0120 100644 --- a/src/discord/channel_logger.py +++ b/src/discord/channel_logger.py @@ -7,6 +7,7 @@ from __future__ import annotations import hashlib +import heapq import json import threading import time @@ -212,8 +213,10 @@ def search(self, query: str, limit: int = 20, channel_id: str | None = None, validate_search_query(query) results: list[dict] = [] query_lower = query.lower() - if not query_lower: + if not query_lower or limit <= 0: return results + newest: list[tuple[float, int, dict]] = [] + ordinal = 0 try: if not self._log_dir.exists(): return results @@ -238,15 +241,21 @@ def search(self, query: str, limit: int = 20, channel_id: str | None = None, continue content = record.get("content", "") if query_lower in content.lower(): - results.append({ + match = { "content": content[:500], "author": record.get("author", "Unknown"), "channel_id": record.get("channel_id", ""), "timestamp": record.get("ts", 0.0), "type": "channel", - }) + } + ordinal += 1 + entry = (match["timestamp"], ordinal, match) + if len(newest) < limit: + heapq.heappush(newest, entry) + elif entry[:2] > newest[0][:2]: + heapq.heapreplace(newest, entry) except Exception: continue except Exception: log.debug("Channel log keyword search failed", exc_info=True) - return sorted(results, key=lambda r: r["timestamp"], reverse=True)[:max(0, limit)] + return [entry[2] for entry in sorted(newest, reverse=True)] diff --git a/src/sessions/manager.py b/src/sessions/manager.py index a8f2faa88..24a8ad7e9 100644 --- a/src/sessions/manager.py +++ b/src/sessions/manager.py @@ -1242,13 +1242,24 @@ def reset_many(self, channel_ids: list[str]) -> int: requested = set(channel_ids) if not requested: return 0 + # An administrative clear can overlap an API request. Its in-memory + # channel still gets cleared/fenced, but cannot acquire a tombstone. + ephemeral = requested & self._ephemeral_channels + ephemeral_removed = sum(cid in self._sessions for cid in ephemeral) + for cid in ephemeral: + self._sessions.pop(cid, None) + self._dirty.discard(cid) + self._bump_mutation_revision(cid) + requested -= ephemeral + if not requested: + return ephemeral_removed if self._reset_epochs_degraded: # Never replace an unreadable tombstone ledger with a partial # one. Live sessions can still load, accept activity and save. raise RuntimeError( "Reset epoch store degraded; repair store and reload before reset", ) - removed = sum(cid in self._sessions for cid in requested) + removed = ephemeral_removed + sum(cid in self._sessions for cid in requested) previous = self._reset_epochs self._reset_epochs = { **previous, **dict.fromkeys(requested, time.time()), diff --git a/tests/test_sessions_review_regressions.py b/tests/test_sessions_review_regressions.py index 148523603..78534e2ae 100644 --- a/tests/test_sessions_review_regressions.py +++ b/tests/test_sessions_review_regressions.py @@ -226,3 +226,42 @@ async def test_ranked_channel_duplicate_candidates_cannot_hide_next_match(manage assert {r["timestamp"] for r in results} == {timestamp, timestamp + 1} finally: fts._conn.close() + + +def test_ephemeral_administrative_clear_remains_memory_only(manager): + with manager.ephemeral("api-unique"): + manager.add_message("api-unique", "user", "temporary") + revision = manager.mutation_revision("api-unique") + assert manager.clear_all() == 1 + assert manager.mutation_revision("api-unique") > revision + manager.add_message("api-unique", "assistant", "finishing") + manager.save_all() + assert list(manager.persist_dir.iterdir()) == [] + assert manager._mutation_revisions == manager._reset_epochs == {} + + +def test_ephemeral_lifecycle_refuses_existing_persistent_session(manager): + manager.add_message("persistent", "user", "preserve") + manager.save() + with pytest.raises(ValueError, match="new and unique"): + with manager.ephemeral("persistent"): + pass + assert manager.get("persistent").messages[0].content == "preserve" + assert (manager.persist_dir / "persistent.json").exists() + + +@pytest.mark.asyncio +async def test_author_filtered_fallback_duplicates_do_not_consume_limit(manager, tmp_path): + log_dir = tmp_path / "logs" + logger = ChannelLogger(str(log_dir)) + manager.add_message("wanted", "user", "needle", user_id="alice") + timestamp = manager.get("wanted").messages[0].timestamp + records = [{"content": "needle", "author_id": "alice", "channel_id": "wanted", + "ts": timestamp} for _ in range(4)] + records.append({"content": "needle distinct", "author_id": "alice", + "channel_id": "wanted", "ts": timestamp - 1}) + (log_dir / "wanted.jsonl").write_text("\n".join(json.dumps(r) for r in records) + "\n") + manager.set_channel_search(logger) + results = await manager.search_history("needle", limit=2, channel_id="wanted", user_id="alice") + assert len(results) == 2 + assert {r["timestamp"] for r in results} == {timestamp, timestamp - 1} From 1b8404aa176abbfce3cf9bcfd4e5973253af8364 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:13:47 -0400 Subject: [PATCH 023/162] Assert version summaries agree with authoritative snapshot diffs --- tests/test_knowledge_review_regressions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_knowledge_review_regressions.py b/tests/test_knowledge_review_regressions.py index 5c926fd87..b4d26796b 100644 --- a/tests/test_knowledge_review_regressions.py +++ b/tests/test_knowledge_review_regressions.py @@ -27,6 +27,10 @@ async def test_update_diff_uses_full_snapshot_not_overlapping_chunk_preview(stor assert versions[0]["diff_summary"] == "+1 lines, -1 lines" diff = store.get_version_diff("long", 1, 2) assert diff is not None + assert diff["lines_added"] == diff["lines_removed"] == 1 + assert versions[0]["diff_summary"] == ( + f"+{diff['lines_added']} lines, -{diff['lines_removed']} lines" + ) assert store.get_source_snapshot("long") == changed From 0f32ca0dbddeddd3e1e7a9a7b9f08f1eca06ec82 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:41:50 -0400 Subject: [PATCH 024/162] fix(computer): revoke focus input despite receipt storage failure (#510) --- src/computer/controller.py | 24 ++++++++++++--------- tests/test_computer_x11_focus_controller.py | 20 +++++++++++++++++ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/computer/controller.py b/src/computer/controller.py index 9304d5272..08bc96412 100644 --- a/src/computer/controller.py +++ b/src/computer/controller.py @@ -2294,16 +2294,20 @@ def preflight(code, state): return {**receipt, "next_observation": next_observation} return receipt except BaseException as exc: - self._finish_action( - live.capabilities, grant.session_id, inp["action_id"], - {"status": "interrupted" if released else "unknown", - "reason": ("effect_unknown_reconcile_no_replay" if released - else "input_release_unknown"), - "execution": {"injected": None, "sent": None, "released": released}, - "verification": {"status": "unavailable"}}, - ) - if not released: - await self._stop(grant.session_id, "cancelled") + try: + self._finish_action( + live.capabilities, grant.session_id, inp["action_id"], + {"status": "interrupted" if released else "unknown", + "reason": ("effect_unknown_reconcile_no_replay" if released + else "input_release_unknown"), + "execution": {"injected": None, "sent": None, "released": released}, + "verification": {"status": "unavailable"}}, + ) + finally: + # Receipt storage must never prevent native revocation after + # an unknown release, including a second persistence failure. + if not released: + await self._stop(grant.session_id, "cancelled") if isinstance(exc, asyncio.CancelledError): raise return self.store.receipt(grant.session_id, inp["action_id"], digest) diff --git a/tests/test_computer_x11_focus_controller.py b/tests/test_computer_x11_focus_controller.py index 87c0f6bc5..388f16b36 100644 --- a/tests/test_computer_x11_focus_controller.py +++ b/tests/test_computer_x11_focus_controller.py @@ -117,6 +117,26 @@ async def test_focus_requires_new_eligible_delivery_before_typing(tmp_path): assert "next_observation" not in replay and len(backend.focus_calls) == 1 +async def test_focus_storage_failure_still_revokes_unknown_release(tmp_path, monkeypatch): + async with setup(tmp_path) as (controller, backend, ctx, _, action): + backend.fail_release = True + calls = [] + + def broken_finish(*args, **kwargs): + calls.append(args) + raise OSError("receipt storage unavailable") + + monkeypatch.setattr(controller.store, "finish_action", broken_finish) + with pytest.raises(OSError, match="receipt storage unavailable"): + await controller.act(ctx, action) + assert len(calls) == 2 + assert len(backend.focus_calls) == 1 + assert controller._live[action["session_id"]].revoked + assert backend.detached + assert controller.store.cleanup(action["session_id"]) is not None + assert controller.store.get_session(action["session_id"]).state != "active" + + @pytest.mark.parametrize("unknown_release", [False, True]) async def test_focus_through_integration_classifies_release_truthfully( tmp_path, monkeypatch, unknown_release): From 2d580c23b25f776151f579358521170613ed2e0a Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:53:29 -0400 Subject: [PATCH 025/162] fix(computer): preserve emergency release recovery lineage (#511) --- src/computer/controller.py | 6 ++++- tests/test_hyprland_recovery_controller.py | 29 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/computer/controller.py b/src/computer/controller.py index 08bc96412..6a31c77a7 100644 --- a/src/computer/controller.py +++ b/src/computer/controller.py @@ -2032,7 +2032,11 @@ async def operator_release_owned_input(self, context, session_id, generation): # acquire the action lock first: the native recovery closes input. self._fence(session_id) try: - self.store.set_state(session_id, "paused", revoke=True) + pending = self.store.get_recovery_pending(session_id) + # Release-only cleanup must preserve the incident fence and lineage + # until supported recovery resolves it, including an empty ledger. + state = "quarantined" if pending is not None or grant.state == "quarantined" else "paused" + self.store.set_state(session_id, state, revoke=True) except BaseException: await self._stop(session_id, "cancelled") raise diff --git a/tests/test_hyprland_recovery_controller.py b/tests/test_hyprland_recovery_controller.py index 8d5dfe35e..5b92708f2 100644 --- a/tests/test_hyprland_recovery_controller.py +++ b/tests/test_hyprland_recovery_controller.py @@ -85,6 +85,35 @@ async def test_automatic_handoff_persists_command_and_successor_without_replay(r assert store.get_recovery_pending(grant.session_id) is None +@pytest.mark.parametrize("released", [True, False]) +async def test_emergency_owned_release_preserves_pending_quarantine_on_reopen(rig, tmp_path, released): + from dataclasses import replace + from unittest.mock import AsyncMock + + controller, store, context, grant, live, _, _ = rig + grant = store.begin_hyprland_reconciliation( + grant, phase="unknown_release", reason="unknown_release", + old_grant={"generation": grant.generation, + "consent_generation": grant.consent_generation, + "task_hints": {"goal": "draw"}, "authorizes_input": False, + "recovery_command_id": "a" * 32}) + pending = store.get_recovery_pending(grant.session_id) + live.backend.recover_owned_input = AsyncMock(return_value={ + "released": released, "input_revoked": True, "capture_revoked": True}) + result = await controller.operator_release_owned_input( + replace(context, surface="webui"), grant.session_id, grant.generation) + assert result["state"] == "quarantined" + assert result["owned_input_recovery"]["released"] is released + assert store.get_recovery_pending(grant.session_id) == pending + assert live.revoked + reopened = ComputerStore(tmp_path / "db", tmp_path / "evidence") + try: + assert reopened.get_session(grant.session_id).state == "quarantined" + assert reopened.get_recovery_pending(grant.session_id) == pending + finally: + reopened.close() + + def test_owner_descriptor_rejects_unknown_schema_and_hides_private_values(rig): controller, store, _, grant, live, _, _ = rig controller._prepare_runtime(grant, live.backend) From ba563961b09c36968eed0246dd52e7d7a6e76f06 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:57:49 -0400 Subject: [PATCH 026/162] fix(computer): archive qualified absence before resolving recovery (#512) --- src/computer/store.py | 24 +++++++++++++ ...st_computer_campaign_absence_resolution.py | 34 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/test_computer_campaign_absence_resolution.py diff --git a/src/computer/store.py b/src/computer/store.py index cb694cff0..94fbc6bdc 100644 --- a/src/computer/store.py +++ b/src/computer/store.py @@ -1378,6 +1378,13 @@ def _hyprland_recovery_record(self, session_id: str) -> dict: self._validate_hyprland_owner(result["native_owner"]) if "recovery_owner" in result: self._validate_hyprland_owner(result["recovery_owner"]) + if "qualified_absence_history" in result: + history = result["qualified_absence_history"] + if type(history) is not dict or history.get("runtime_qualified") is not True: + raise ComputerError("invalid_recovery_pending") + self._validated_hyprland_retirement( + history, self.get_recovery_pending(session_id), + result.get("recovery_owner", result.get("native_owner"))) if "durable_reconnect" in result: reconnect = result["durable_reconnect"] if (type(reconnect) is not dict @@ -1926,6 +1933,7 @@ def recovery_status(self, session_id: str) -> dict | None: result.pop("recovery_command_id", None) result.pop("durable_reconnect", None) result.pop("resolved_recovery_pending", None) + result.pop("qualified_absence_history", None) if ( result.get("local_recovery_status") == "locally_released" and self._local_cleanup_verified( @@ -2026,6 +2034,19 @@ def finish_recovery(self, grant: SessionGrant, result: dict, *, acknowledged=Fal prior = self._hyprland_recovery_record(grant.session_id) pending = self.get_recovery_pending(grant.session_id) owner = prior.get("recovery_owner", prior.get("native_owner")) + if (clean or acknowledged) and prior.get("runtime_qualified") is True: + # Qualification belongs to the prior absence assessment, + # not the new resolution. Retain exact validated facts + # without incompatible top-level discriminators. + fields = ("status", "runtime_qualified", "retirement_evidence", + "retirement_basis", "original_outcome", "owner_digest", + "recovery_command_id", "recovery_generation", + "resources_retired", "unknown_release", "released", + "release_ack", "receiver_release_verified", "complete") + prior["qualified_absence_history"] = {key: prior[key] for key in fields} + prior["runtime_qualified"] = False + prior.pop("retirement_evidence", None) + prior.pop("retirement_basis", None) if acknowledged and pending is not None and owner is not None: candidate = {"external_cleanup_attestation": receipt} if self._external_cleanup_attested(candidate): @@ -2044,6 +2065,7 @@ def finish_recovery(self, grant: SessionGrant, result: dict, *, acknowledged=Fal "INSERT OR REPLACE INTO session_recovery VALUES (?,?)", (grant.session_id, json.dumps(prior, sort_keys=True))) closed = self.set_state(grant.session_id, "closed", revoke=True) + self._hyprland_recovery_record(grant.session_id) settle_closed_local_recovery = self._local_cleanup_verified( closed, prior ) @@ -2062,6 +2084,7 @@ def finish_recovery(self, grant: SessionGrant, result: dict, *, acknowledged=Fal self.db.execute( "INSERT OR REPLACE INTO session_recovery VALUES (?,?)", (grant.session_id, json.dumps(prior, sort_keys=True))) + self._hyprland_recovery_record(grant.session_id) current = self.get_session(grant.session_id) self.db.execute("COMMIT") return current @@ -2085,6 +2108,7 @@ def finish_recovery(self, grant: SessionGrant, result: dict, *, acknowledged=Fal "INSERT OR REPLACE INTO session_recovery VALUES (?,?)", (grant.session_id, json.dumps(receipt, sort_keys=True)), ) + self._hyprland_recovery_record(grant.session_id) if clean or acknowledged: # Attestation retains the original failed cleanup evidence. if clean or self.cleanup(grant.session_id) is None: diff --git a/tests/test_computer_campaign_absence_resolution.py b/tests/test_computer_campaign_absence_resolution.py new file mode 100644 index 000000000..27bf19daa --- /dev/null +++ b/tests/test_computer_campaign_absence_resolution.py @@ -0,0 +1,34 @@ +"""Real SQLite resolution/reopen, no native sessions or input.""" +import pytest + +from src.computer.store import ComputerStore +from tests.test_hyprland_absence_store import absence, pending, record # noqa: F401 +from tests.test_hyprland_durable_reconnect import durable # noqa: F401 + + +@pytest.mark.parametrize("resolution", ["attestation", "cancelled_attestation", "absence"]) +def test_qualified_absence_resolution_is_valid_on_reopen(pending, tmp_path, resolution): + store, _, grant, owner = pending + record(store, grant, owner) + if resolution == "cancelled_attestation": + store.cancel_hyprland_continuation(grant.session_id) + acknowledged = resolution != "absence" + result = ({"status": "operator_acknowledged_unverified", + "reason": "operator_verified_external_cleanup", + "recorded_processes_absent": True, "operator_id": "owner"} + if acknowledged else {"status": "absence_verified", "reason": "owned_runtime_gone"}) + resolved = store.finish_recovery(grant, result, acknowledged=acknowledged) + expected_state = "quarantined" if resolution == "attestation" else "closed" + assert resolved.state == expected_state + reopened = ComputerStore(tmp_path / "db", tmp_path / "evidence") + try: + assert reopened.get_session(grant.session_id).state == expected_state + assessment = reopened._hyprland_recovery_record(grant.session_id) + assert not assessment["runtime_qualified"] + assert assessment["qualified_absence_history"]["retirement_evidence"] == absence(owner) + assert "retirement_evidence" not in assessment + public = reopened.recovery_status(grant.session_id) + assert "qualified_absence_history" not in public + assert public["released"] is False and public["receiver_release_verified"] is False + finally: + reopened.close() From 1d9a7f595466dde1695f45c1784a65586b2ba227 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:00:01 -0400 Subject: [PATCH 027/162] fix(computer): reconcile private evidence crash orphans (#513) --- src/computer/store.py | 64 ++++++++++++++++++------- tests/test_computer_campaign_orphans.py | 50 +++++++++++++++++++ 2 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 tests/test_computer_campaign_orphans.py diff --git a/src/computer/store.py b/src/computer/store.py index 94fbc6bdc..6cea914a9 100644 --- a/src/computer/store.py +++ b/src/computer/store.py @@ -2510,28 +2510,60 @@ def validate_name(name: str) -> str: raise ComputerError("invalid_export_name") return name + def _prune_orphan_evidence(self) -> None: + """Caller holds an IMMEDIATE transaction, excluding unpublished writers. + + Only private, single-link regular files in the UUID4 evidence namespace + are owned evidence. Never follow links or sweep arbitrary operator names. + """ + tracked = {row[0] for row in self.db.execute("SELECT evidence_id FROM evidence")} + for name in os.listdir(self.dir_fd): + if name in tracked or re.fullmatch(r"[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}", name) is None: + continue + try: + info = os.stat(name, dir_fd=self.dir_fd, follow_symlinks=False) + if (stat.S_ISREG(info.st_mode) and info.st_nlink == 1 + and info.st_uid == os.geteuid() and info.st_mode & 0o777 == 0o600): + os.unlink(name, dir_fd=self.dir_fd) + except FileNotFoundError: + pass + def prune(self) -> None: with self.lock: - rows = self.db.execute( - "SELECT evidence_id FROM evidence WHERE expires_at<=?", (self.clock(),) - ).fetchall() - for row in rows: - try: - os.unlink(row[0], dir_fd=self.dir_fd) - except FileNotFoundError: - pass - self.db.execute("DELETE FROM evidence WHERE evidence_id=?", (row[0],)) + self.db.execute("BEGIN IMMEDIATE") + try: + rows = self.db.execute( + "SELECT evidence_id FROM evidence WHERE expires_at<=?", (self.clock(),) + ).fetchall() + for row in rows: + try: + os.unlink(row[0], dir_fd=self.dir_fd) + except FileNotFoundError: + pass + self.db.execute("DELETE FROM evidence WHERE evidence_id=?", (row[0],)) + self._prune_orphan_evidence() + self.db.execute("COMMIT") + except BaseException: + self.db.execute("ROLLBACK") + raise def purge_evidence(self) -> None: """Disable/shutdown revokes retained downloads and removes their bytes.""" with self.lock: - rows = self.db.execute("SELECT evidence_id FROM evidence").fetchall() - for row in rows: - try: - os.unlink(row[0], dir_fd=self.dir_fd) - except FileNotFoundError: - pass - self.db.execute("DELETE FROM evidence WHERE evidence_id=?", (row[0],)) + self.db.execute("BEGIN IMMEDIATE") + try: + rows = self.db.execute("SELECT evidence_id FROM evidence").fetchall() + for row in rows: + try: + os.unlink(row[0], dir_fd=self.dir_fd) + except FileNotFoundError: + pass + self.db.execute("DELETE FROM evidence WHERE evidence_id=?", (row[0],)) + self._prune_orphan_evidence() + self.db.execute("COMMIT") + except BaseException: + self.db.execute("ROLLBACK") + raise def put_evidence( self, session_id: str, content: bytes, *, kind="frame", name="frame.png" diff --git a/tests/test_computer_campaign_orphans.py b/tests/test_computer_campaign_orphans.py new file mode 100644 index 000000000..74c028b7a --- /dev/null +++ b/tests/test_computer_campaign_orphans.py @@ -0,0 +1,50 @@ +"""Disposable synthetic bytes only; crash before SQLite publication.""" +import multiprocessing +import os +import uuid + +import pytest + +from src.computer.models import RequestContext +from src.computer.store import ComputerStore + + +def crash_writer(db, directory, session_id): + store = ComputerStore(db, directory) + real_fsync = os.fsync + + def crash_after_fsync(fd): + real_fsync(fd) + os._exit(0) + + os.fsync = crash_after_fsync + store.put_evidence(session_id, b"private synthetic fixture") + + +@pytest.mark.parametrize("operation", ["prune", "purge_evidence"]) +def test_crash_orphan_removed_but_operator_files_preserved(tmp_path, operation): + db, directory = tmp_path / "db", tmp_path / "evidence" + store = ComputerStore(db, directory) + context = RequestContext("owner", "channel", "turn", "host") + grant = store.create_session(context, "drawing") + tracked = store.put_evidence(grant.session_id, b"tracked synthetic fixture") + process = multiprocessing.get_context("fork").Process( + target=crash_writer, args=(db, directory, grant.session_id)) + process.start() + process.join(5) + assert process.exitcode == 0 + orphans = set(os.listdir(directory)) - {tracked} + assert len(orphans) == 1 + operator = directory / "operator-notes.txt" + operator.write_bytes(b"operator file") + unsafe = directory / uuid.uuid4().hex + unsafe.write_bytes(b"non-private unrelated file") + unsafe.chmod(0o644) + link = directory / uuid.uuid4().hex + link.symlink_to(operator) + getattr(store, operation)() + assert not any((directory / name).exists() for name in orphans) + assert operator.read_bytes() == b"operator file" + assert unsafe.exists() and link.is_symlink() + assert (directory / tracked).exists() is (operation == "prune") + store.close() From 9b4a0c84a783ea06f85571f57944bb057c8771e4 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:50:35 -0400 Subject: [PATCH 028/162] fix(computer): rebind native recovery context on clean resume (#514) --- src/computer/controller.py | 4 +++ tests/test_hyprland_recovery_controller.py | 30 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/computer/controller.py b/src/computer/controller.py index 6a31c77a7..176fb8c54 100644 --- a/src/computer/controller.py +++ b/src/computer/controller.py @@ -1521,6 +1521,10 @@ async def _session(self, context: RequestContext, inp: dict, boundary) -> dict: grant = self.store.set_state( grant.session_id, "paused", revoke=True, turn_id=context.turn_id ) + if live.capabilities is not None and live.capabilities.backend == "hyprland": + # Publish the authorized turn together with its durable + # rebind, before any capture can trigger native recovery. + self._hyprland_contexts[grant.session_id] = context live.observations.clear() resume = getattr(live.backend, "resume", None) if resume is None: diff --git a/tests/test_hyprland_recovery_controller.py b/tests/test_hyprland_recovery_controller.py index 5b92708f2..940c9929b 100644 --- a/tests/test_hyprland_recovery_controller.py +++ b/tests/test_hyprland_recovery_controller.py @@ -245,6 +245,36 @@ async def capture(*args, **kwargs): assert not live.revoked +async def test_clean_resume_rebinds_recovery_to_current_authorized_turn(rig, monkeypatch): + controller, store, context, grant, live, committed, _ = rig + from dataclasses import replace + + current_context = replace(context, turn_id="new-authorized-turn", surface="webui") + controller.authorize = lambda ctx: ctx.turn_id == current_context.turn_id + live.backend.pause = lambda: asyncio.sleep(0, result={"released": True}) + live.backend.resume = lambda **kwargs: asyncio.sleep(0) + + async def capture(*args, **kwargs): + assert controller._hyprland_contexts[grant.session_id] == current_context + return SimpleNamespace(modal=None), None + + monkeypatch.setattr(controller, "_capture", capture) + await controller._pause(grant.session_id) + paused = store.get_session(grant.session_id) + await controller.session(current_context, {"operation": "resume", + "session_id": grant.session_id, "generation": paused.generation}) + active = store.get_session(grant.session_id) + assert active.turn_id == current_context.turn_id + + async def recover(*, consent_generation, command_id): + return HyprlandRecoveryResult("ready_for_replan", binding(), evidence(), "test") + + live.backend.recover_native_authority = recover + await controller._quarantine_hyprland(active, live, phase="native_continuity_lost") + assert committed == [{"consent_generation": active.consent_generation + 1}] + assert store.get_session(grant.session_id).state == "active" + + @pytest.mark.asyncio async def test_resume_actual_identity_loss_enters_recovery(rig): controller, store, context, grant, live, committed, _ = rig From 484f61b18b65277377833c9751e6586b777629a3 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:01:59 -0400 Subject: [PATCH 029/162] fix(computer): expose bounded native recovery status and next steps (#515) --- src/web/api/computer.py | 26 ++++++++++++++++-- tests/test_computer_api_recovery_r18.py | 35 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/web/api/computer.py b/src/web/api/computer.py index ec387a1b4..d2666d851 100644 --- a/src/web/api/computer.py +++ b/src/web/api/computer.py @@ -312,7 +312,8 @@ def status_json(value, actor, *, accessibility=None): recovery = value.get("recovery") if isinstance(recovery, dict): statuses = {"operator_reconciliation_required", "operator_cleanup_required", "unknown", - "absence_verified", "operator_acknowledged_unverified"} + "absence_verified", "operator_acknowledged_unverified", + "fresh_target_required", "operator_release_required", "native_reconciled"} reasons = {"controller_lost", "legacy_runtime_identity_missing", "host_rebooted", "launch_identity_incomplete", "owned_process_remaining", "owned_process_group_remaining", "process_inspection_unavailable", @@ -320,7 +321,8 @@ def status_json(value, actor, *, accessibility=None): "owned_input_release_unproven", "owned_runtime_gone", "inspection_unavailable", "inspection_timeout", "operator_verified_external_cleanup", "recorded_processes_gone", - "persistent_input_state_unproven", "operator_reconciliation_unsupported"} + "persistent_input_state_unproven", "operator_reconciliation_unsupported", + "native_continuity_lost", "unknown_release"} result["recovery"] = { "status": (recovery.get("status") if recovery.get("status") in statuses else "unknown"), @@ -328,6 +330,10 @@ def status_json(value, actor, *, accessibility=None): if recovery.get("reason") in reasons else "unknown"), "complete": recovery.get("complete") is True, } + for key in ("released", "resources_retired", "runtime_qualified", "unknown_release", + "receiver_release_verified", "continuation_cancelled"): + if type(recovery.get(key)) is bool: + result["recovery"][key] = recovery[key] # Local cleanup and receiver delivery are different facts. Preserve # the historical result while exposing whether recovery still blocks. if recovery.get("local_recovery_status") == "locally_released": @@ -336,6 +342,22 @@ def status_json(value, actor, *, accessibility=None): "receiver_release_verified"): if type(recovery.get(key)) is bool: result["recovery"][key] = recovery[key] + reconciliation = value.get("native_reconciliation") + if isinstance(reconciliation, dict): + public_reconciliation = {} + for key in ("phase", "reason"): + if reconciliation.get(key) in {"unknown_release", "native_continuity_lost"}: + public_reconciliation[key] = reconciliation[key] + for key in ("required", "authorizes_input", "replay_allowed", "receiver_release_verified"): + if type(reconciliation.get(key)) is bool: + public_reconciliation[key] = reconciliation[key] + next_action = reconciliation.get("next_action") + if next_action in { + "inventory_then_start_with_recovery_session_id_and_fresh_target", + "operator_reconcile_then_fresh_target_and_new_session"}: + public_reconciliation["next_action"] = next_action + if public_reconciliation: + result["native_reconciliation"] = public_reconciliation if accessibility is not None: result["accessibility"] = accessibility owned_recovery = value.get("owned_input_recovery") diff --git a/tests/test_computer_api_recovery_r18.py b/tests/test_computer_api_recovery_r18.py index 4260a4a5a..9124e9487 100644 --- a/tests/test_computer_api_recovery_r18.py +++ b/tests/test_computer_api_recovery_r18.py @@ -33,6 +33,41 @@ async def operator_reconcile(self, acknowledgment, **kwargs): "complete": False}} +@pytest.mark.parametrize("state,next_action", [ + ("fresh_target_required", "inventory_then_start_with_recovery_session_id_and_fresh_target"), + ("operator_release_required", "operator_reconcile_then_fresh_target_and_new_session"), + ("native_reconciled", "inventory_then_start_with_recovery_session_id_and_fresh_target"), +]) +async def test_native_recovery_status_preserves_distinct_safe_guidance(state, next_action): + class NativeStatus(RecoveryController): + async def operator_status(self, **actor): + result = await super().operator_status(**actor) + result["recovery"].update( + status=state, reason="native_continuity_lost", released=False, + resources_retired=state != "operator_release_required", + runtime_qualified=state == "fresh_target_required", unknown_release=True, + receiver_release_verified=False, continuation_cancelled=False, + native_owner={"private": "must-not-escape"}, owner_digest="must-not-escape") + result["native_reconciliation"] = { + "phase": "native_continuity_lost", "reason": "native_continuity_lost", + "required": state != "native_reconciled", "authorizes_input": False, + "replay_allowed": False, "receiver_release_verified": False, + "next_action": next_action, "task_hints": {"goal": "must-not-escape"}} + return result + + async with client(NativeStatus()) as c: + response = await c.get("/api/computer") + assert response.status == 200 + result = await response.json() + assert result["recovery"]["status"] == state + assert result["recovery"]["reason"] == "native_continuity_lost" + assert result["recovery"]["released"] is False + assert result["recovery"]["unknown_release"] is True + assert result["native_reconciliation"]["next_action"] == next_action + assert result["native_reconciliation"]["authorizes_input"] is False + assert "must-not-escape" not in str(result) + + def body_for(route): body = {"session_id": "computer-session", "generation": 8} if route != "recover": From f6c10b92b80ecc6545afee42662b98bd1a3fa589 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:05:12 -0400 Subject: [PATCH 030/162] fix(computer): retire expired inventory and settled session caches (#516) --- src/computer/controller.py | 30 ++++++++ ...test_computer_campaign_cache_retirement.py | 77 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/test_computer_campaign_cache_retirement.py diff --git a/src/computer/controller.py b/src/computer/controller.py index 176fb8c54..98e5c1213 100644 --- a/src/computer/controller.py +++ b/src/computer/controller.py @@ -157,6 +157,32 @@ def __init__( self._x11_focus_candidates: dict[str, tuple[str, object]] = {} self.store.recover() + def _prune_caches(self): + """Retire expired selection proof and settled terminal-session metadata.""" + now = self.monotonic() + for epoch, binding in tuple(self._selection_bindings.items()): + if binding["expires_at"] <= now: + self._selection_bindings.pop(epoch, None) + caches = (self._stop_locks, self._hyprland_contexts, self._hyprland_bindings, + self._hyprland_recovery_epochs, self._delivered_observations, + self._x11_focus_candidates) + for sid in set().union(*(cache.keys() for cache in caches)): + if (sid in self._live or sid in self._stops or sid in self._recoveries + or sid in self._hyprland_preparations or sid in self._watchdogs): + continue + lock = self._stop_locks.get(sid) + # Queued waiters still own the exact lock. Never create a second + # lock for a later request until every user has settled. + if lock is not None and (lock.locked() or getattr(lock, "_waiters", None)): + continue + try: + terminal = self.store.get_session(sid).state in {"closed", "cancelled"} + except ComputerError: + terminal = False + if terminal: + for cache in caches: + cache.pop(sid, None) + async def _close_inventory_backend(self, backend): close = getattr(backend, "close", None) if callable(close): @@ -889,6 +915,7 @@ async def _stop(self, sid, state): def forget(completed): if self._stops.get(sid) is completed: self._stops.pop(sid) + self._prune_caches() task.add_done_callback(forget) try: @@ -1027,6 +1054,8 @@ async def set_enabled(self, enabled: bool): async def close(self): await self.set_enabled(False) + self._selection_bindings.clear() + self._prune_caches() async def finish_turn(self, context: RequestContext): """Release this turn's owned desktop, never a later turn's session.""" @@ -1181,6 +1210,7 @@ async def _session(self, context: RequestContext, inp: dict, boundary) -> dict: ) operation = inp["operation"] await self._auth(context, emergency=operation in {"stop", "cancel", "close", "status"}) + self._prune_caches() if operation == "inventory_targets": exact_keys(inp, {"operation"}, {"operation"}) backend = self.backend_factory(None) diff --git a/tests/test_computer_campaign_cache_retirement.py b/tests/test_computer_campaign_cache_retirement.py new file mode 100644 index 000000000..f1ddc50e2 --- /dev/null +++ b/tests/test_computer_campaign_cache_retirement.py @@ -0,0 +1,77 @@ +"""Pure fake inventories and native adapters; no desktop or live sessions.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from src.computer.controller import ComputerController +from src.computer.models import BackendCapabilities, RequestContext +from src.computer.store import ComputerStore +from tests.test_computer_contract_r1 import Stub + + +async def test_repeated_unused_inventory_expires_private_proof_and_close_clears(tmp_path): + now = [0.0] + store = ComputerStore(tmp_path / "db", tmp_path / "evidence") + backend = SimpleNamespace( + capabilities=BackendCapabilities("wayland", "existing_session", backend="hyprland"), + inventory_targets=AsyncMock(return_value={"candidate_epoch": 1, "candidates": [ + {"id": "private-native-id", "label": "fixture", "output_id": "output"}]}), + export_selection_proof=lambda _: {"private": "proof"}, close=AsyncMock()) + controller = ComputerController(store, lambda _: backend, lambda _: True, + enabled=True, monotonic=lambda: now[0]) + context = RequestContext("owner", "channel", "turn", "host") + for _ in range(6): + await controller.session(context, {"operation": "inventory_targets"}) + assert len(controller._selection_bindings) == 1 + now[0] += 1000 + await controller.close() + assert not controller._selection_bindings + store.close() + + +async def test_clean_terminal_stop_retires_all_caches(tmp_path): + store = ComputerStore(tmp_path / "db", tmp_path / "evidence") + controller = ComputerController(store, lambda _: Stub(), lambda _: True, enabled=True) + context = RequestContext("owner", "channel", "turn", "host") + result = await controller.session(context, {"operation": "start", "app": "drawing"}) + sid = result["session_id"] + controller._hyprland_contexts[sid] = context + controller._hyprland_bindings[sid] = {"private": "binding"} + controller._hyprland_recovery_epochs[sid] = 1 + controller._x11_focus_candidates[sid] = ("observation", "private") + stopped = await controller._stop(sid, "closed") + assert stopped["state"] == "closed" + await asyncio.sleep(0) + for cache in (controller._live, controller._stop_locks, controller._stops, + controller._hyprland_contexts, controller._hyprland_bindings, + controller._hyprland_recovery_epochs, controller._x11_focus_candidates): + assert sid not in cache + await controller.close() + store.close() + + +async def test_terminal_lock_identity_survives_queued_waiter_and_worker(tmp_path): + store = ComputerStore(tmp_path / "db", tmp_path / "evidence") + controller = ComputerController(store, None, lambda _: True, enabled=True) + context = RequestContext("owner", "channel", "turn", "host") + grant = store.create_session(context, "drawing") + store.set_state(grant.session_id, "closed") + sid = grant.session_id + lock = controller._stop_locks[sid] = asyncio.Lock() + await lock.acquire() + waiter = asyncio.create_task(lock.acquire()) + await asyncio.sleep(0) + lock.release() + controller._prune_caches() + assert controller._stop_locks[sid] is lock + await waiter + lock.release() + worker = asyncio.create_task(asyncio.sleep(0)) + controller._recoveries[sid] = worker + controller._prune_caches() + assert controller._stop_locks[sid] is lock + await worker + controller._recoveries.pop(sid) + controller._prune_caches() + assert sid not in controller._stop_locks + store.close() From e89ec7b54ed4d77f6c76dee02652bbb8ed85febd Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:49:10 -0400 Subject: [PATCH 031/162] fix(computer): accept clean native pre-input rejection ledger (#619) --- src/computer/runtime/hyprland_guardian.py | 10 ++++++++ tests/test_hyprland_owned_release.py | 29 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/computer/runtime/hyprland_guardian.py b/src/computer/runtime/hyprland_guardian.py index 6f4c25c41..2566b7007 100644 --- a/src/computer/runtime/hyprland_guardian.py +++ b/src/computer/runtime/hyprland_guardian.py @@ -289,6 +289,16 @@ async def checked_permit(): command, pixel_guard=checked_permit if pixel_guard is not None else None, scope_deadline_ns=scope_deadline_ns) except Exception as exc: + details = getattr(exc, "details", None) + # Native plan rejection is a terminal no-input result, not an + # unknown dispatch exception. Admit fresh group observations only + # with the same strict ledger proof used after successful actions. + if (isinstance(exc, WaylandGuardianError) + and type(details) is dict + and details.get("event") == "action_rejected" + and details.get("input_was_sent") is False + and owned_release_v1({**details, "event": "action_done"}, closed=False)): + self._group_refresh_clean = True # Controller receipts conservatively collapse dispatch exceptions. # Preserve bounded native facts in the journal, never commands, # coordinates, socket tokens, application text or raw exceptions. diff --git a/tests/test_hyprland_owned_release.py b/tests/test_hyprland_owned_release.py index 8288da42f..551a36c57 100644 --- a/tests/test_hyprland_owned_release.py +++ b/tests/test_hyprland_owned_release.py @@ -1,11 +1,13 @@ """Local ownership release is not a compositor or receiver acknowledgement.""" from types import SimpleNamespace from unittest.mock import AsyncMock +import time import pytest from src.computer.runtime.hyprland_guardian import HyprlandGuardian, owned_release_v1 from src.computer.runtime.wayland_guardian import WaylandGuardian +from src.computer.runtime.wayland_guardian import WaylandGuardianError def terminal(): @@ -40,6 +42,33 @@ def test_no_legacy_fallback(): assert not owned_release_v1(row, closed=True) +@pytest.mark.parametrize("fault", [None, "ledger", "sent", "proof", "closed"]) +async def test_preinput_rejection_restores_group_refresh_only_with_clean_ledger(monkeypatch, fault): + owner = HyprlandGuardian("/unused", 0) + owner._child = SimpleNamespace(returncode=None) + owner._ready = {"scope_lease_v1": True} + owner._scope_deadline = time.monotonic_ns() + 1_000_000_000 + receipt = {"event": "action_rejected", "reason": "invalid-command", + "input_was_sent": False, "release_sent": True, + "release_acknowledged": True, "receiver_release_verified": False, + "owned_release_v1": {"release_sent": True, "ledger_empty": True, + "resources_closed": False}} + if fault == "ledger": + receipt["owned_release_v1"]["ledger_empty"] = False + elif fault == "sent": + receipt["input_was_sent"] = True + elif fault == "proof": + receipt.pop("owned_release_v1") + elif fault == "closed": + receipt["owned_release_v1"]["resources_closed"] = True + monkeypatch.setattr(owner, "_send", AsyncMock()) + owner._events.put_nowait(receipt) + with pytest.raises(WaylandGuardianError): + await owner.act("K unsupported", scope_deadline_ns=owner._scope_deadline) + assert not owner._active + assert owner.application_group_refresh_ready is (fault is None) + + @pytest.mark.asyncio @pytest.mark.parametrize("reaped", [True, False]) async def test_close_local_proof_without_ack(monkeypatch, reaped): From 4f59c342c857d830f6d4dc47ead26aa0cb831948 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:46:35 -0400 Subject: [PATCH 032/162] fix(computer): recognize direct X11 pre-input refusals (#620) --- src/computer/runtime/x11_attached.py | 12 ++++++++ tests/test_computer_x11_attached_r10_extra.py | 30 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/computer/runtime/x11_attached.py b/src/computer/runtime/x11_attached.py index 5a2b53c99..d26ab1327 100644 --- a/src/computer/runtime/x11_attached.py +++ b/src/computer/runtime/x11_attached.py @@ -1650,6 +1650,18 @@ async def _input_worker_owned(self, revoked, request): receipt = json.loads(line) if child.returncode != 0 or type(receipt) is not dict: raise AttachedFailure("input_outcome_unknown") + # Direct workers also refuse before opening native input. Preserve + # that bounded proof without manufacturing a device identity. + details = receipt.get("diagnostics") + preflight_refusal = ( + receipt.get("status") == "unavailable" + and receipt.get("injected") is False + and type(receipt.get("released")) is bool + and type(details) is dict + and details.get("phase") == "preflight" + and type(details.get("steps_completed")) is int + and details["steps_completed"] == 0 + ) if receipt.get("released") is not True: self._release_failed = True if ( diff --git a/tests/test_computer_x11_attached_r10_extra.py b/tests/test_computer_x11_attached_r10_extra.py index 18ab34196..cfa1d5b1c 100644 --- a/tests/test_computer_x11_attached_r10_extra.py +++ b/tests/test_computer_x11_attached_r10_extra.py @@ -71,6 +71,36 @@ def test_configuration_strict_types(): backend(**kwargs) +@pytest.mark.parametrize("sudo", [False, True]) +@pytest.mark.parametrize("change", [None, "input_opened", "injected", "released", "phase"]) +async def test_known_no_input_refusal_preserves_prior_device_evidence(monkeypatch, sudo, change): + receipt = {"status": "unavailable", "injected": False, "released": True, + "input_opened": False, + "diagnostics": {"phase": "preflight", "steps_completed": 0}} + if change == "phase": + receipt["diagnostics"]["phase"] = "dispatch" + elif change is not None: + receipt[change] = not receipt[change] + child = Process([json.dumps(receipt).encode() + b"\n"]) + b = backend(runtime_sudo=sudo) + b._device_identity = {"private": "prior identity"} + b._device_state = "prior_clean_state" + monkeypatch.setattr(b, "_record_spawn", lambda *args, **kwargs: None) + monkeypatch.setattr(b, "_worker_ready", AsyncMock(return_value={"pid": 700})) + monkeypatch.setattr(b, "_identities_gone", AsyncMock(return_value=True)) + monkeypatch.setattr(asyncio, "create_subprocess_exec", AsyncMock(return_value=child)) + if change is None: + assert await b._input_worker({}) == receipt + assert b._device_state == "prior_clean_state" + assert b._device_identity == {"private": "prior identity"} + assert not b._release_failed + else: + with pytest.raises(attached.AttachedFailure, match="input_outcome_unknown"): + await b._input_worker({}) + assert b._release_failed + assert child.closed and child.signals == [] + + def test_spawn_descriptor_atomic_callback_and_limits(monkeypatch): b = backend() b._runtime_descriptor = {"processes": [], "launch_pending": False} From 75c68a9d7c84638a0465032f1030072145af5c2b Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:43:57 -0400 Subject: [PATCH 033/162] fix(computer): require sudo only for privileged isolated launches (#621) --- src/computer/runtime/backend.py | 2 +- src/computer/runtime/profile.py | 5 +++-- tests/test_computer_app_profiles_r6.py | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/computer/runtime/backend.py b/src/computer/runtime/backend.py index 878ee31c8..d978cfe78 100644 --- a/src/computer/runtime/backend.py +++ b/src/computer/runtime/backend.py @@ -121,7 +121,7 @@ async def start(self, session_id: str) -> dict: if self._closed or self._process is not None: raise RuntimeFailure("backend instances are single-use") validate_session(session_id) - preflight(self.app_profile) + preflight(self.app_profile, runtime_sudo=self.runtime_sudo) descriptor = self.startup_descriptor(session_id) assert self._descriptor is not None # startup_descriptor establishes this identity. owned = descriptor["token"] diff --git a/src/computer/runtime/profile.py b/src/computer/runtime/profile.py index ba4fa19a2..863857b6d 100644 --- a/src/computer/runtime/profile.py +++ b/src/computer/runtime/profile.py @@ -213,11 +213,10 @@ def clean_environment() -> dict[str, str]: return {"PATH": "/usr/bin", "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"} -def preflight(app_profile: str | None = None) -> None: +def preflight(app_profile: str | None = None, *, runtime_sudo: bool = False) -> None: if app_profile is not None and app_profile not in APP_PROFILES: raise ValueError("unapproved application profile") required: tuple[str, ...] = ( - "sudo", "systemd-run", "systemctl", "bwrap", @@ -227,6 +226,8 @@ def preflight(app_profile: str | None = None) -> None: "xdotool", "openbox", ) + if runtime_sudo: + required = ("sudo", *required) if app_profile is not None: required = (*required, app_profile) missing = [name for name in required if not os.access(f"/usr/bin/{name}", os.X_OK)] diff --git a/tests/test_computer_app_profiles_r6.py b/tests/test_computer_app_profiles_r6.py index f0d4c1ba4..14702bc84 100644 --- a/tests/test_computer_app_profiles_r6.py +++ b/tests/test_computer_app_profiles_r6.py @@ -37,6 +37,20 @@ def test_isolated_profiles_unchanged(): assert profile["input"] == "supported" +@pytest.mark.parametrize("runtime_sudo", [False, True]) +def test_isolated_preflight_requires_only_used_sudo(monkeypatch, runtime_sudo): + from src.computer.runtime.profile import launch_argv, preflight + + monkeypatch.setattr(os, "access", lambda path, mode: path != "/usr/bin/sudo") + argv = launch_argv("a" * 32, "drawing", runtime_sudo=runtime_sudo) + assert ("/usr/bin/sudo" in argv) is runtime_sudo + if runtime_sudo: + with pytest.raises(RuntimeError, match="unavailable: sudo"): + preflight("drawing", runtime_sudo=runtime_sudo) + else: + preflight("drawing", runtime_sudo=runtime_sudo) + + def app_scope(monkeypatch, profile, wm_class): # profile is only a legacy test-fixture label, never passed to scope. display = Display() From 3b39b899c138e394f7fec3f2af2c02d161eb2fc0 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:12:02 -0400 Subject: [PATCH 034/162] test(computer): harden recovery archival and evidence writer coverage --- src/computer/controller.py | 8 ++- src/computer/manager.py | 2 +- src/computer/store.py | 11 +++- src/web/api/computer.py | 3 +- tests/test_computer_app_profiles_r6.py | 16 ++++++ ...st_computer_campaign_absence_resolution.py | 20 +++++++ tests/test_computer_campaign_orphans.py | 52 +++++++++++++++++++ tests/test_computer_provisioning_r29.py | 2 +- tests/test_hyprland_owned_release.py | 5 +- tests/test_hyprland_recovery_controller.py | 3 +- 10 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/computer/controller.py b/src/computer/controller.py index 98e5c1213..4cff86258 100644 --- a/src/computer/controller.py +++ b/src/computer/controller.py @@ -177,7 +177,9 @@ def _prune_caches(self): continue try: terminal = self.store.get_session(sid).state in {"closed", "cancelled"} - except ComputerError: + except Exception: + # Cache retirement is best effort, never a new failure in the + # completed cleanup callback if storage itself is unavailable. terminal = False if terminal: for cache in caches: @@ -2069,7 +2071,9 @@ async def operator_release_owned_input(self, context, session_id, generation): pending = self.store.get_recovery_pending(session_id) # Release-only cleanup must preserve the incident fence and lineage # until supported recovery resolves it, including an empty ledger. - state = "quarantined" if pending is not None or grant.state == "quarantined" else "paused" + state = ( + "quarantined" if pending is not None or grant.state == "quarantined" else "paused" + ) self.store.set_state(session_id, state, revoke=True) except BaseException: await self._stop(session_id, "cancelled") diff --git a/src/computer/manager.py b/src/computer/manager.py index d54935c74..eafd498e5 100644 --- a/src/computer/manager.py +++ b/src/computer/manager.py @@ -115,7 +115,7 @@ def _construct(self): from .runtime.profile import preflight try: - preflight() + preflight(runtime_sudo=settings.runtime_sudo) except RuntimeError as exc: raise ComputerProvisioningError("computer_dependency_unavailable") from exc else: diff --git a/src/computer/store.py b/src/computer/store.py index 6cea914a9..c8a4eba9f 100644 --- a/src/computer/store.py +++ b/src/computer/store.py @@ -1382,8 +1382,14 @@ def _hyprland_recovery_record(self, session_id: str) -> dict: history = result["qualified_absence_history"] if type(history) is not dict or history.get("runtime_qualified") is not True: raise ComputerError("invalid_recovery_pending") + historical_pending = self.get_recovery_pending(session_id) + if historical_pending is None: + archive = result.get("resolved_recovery_pending") + if (type(archive) is dict and archive.get("status") == "locally_released" + and type(archive.get("historical_record")) is dict): + historical_pending = self._recovery_pending_row(archive["historical_record"]) self._validated_hyprland_retirement( - history, self.get_recovery_pending(session_id), + history, historical_pending, result.get("recovery_owner", result.get("native_owner"))) if "durable_reconnect" in result: reconnect = result["durable_reconnect"] @@ -2518,7 +2524,8 @@ def _prune_orphan_evidence(self) -> None: """ tracked = {row[0] for row in self.db.execute("SELECT evidence_id FROM evidence")} for name in os.listdir(self.dir_fd): - if name in tracked or re.fullmatch(r"[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}", name) is None: + if (name in tracked + or re.fullmatch(r"[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}", name) is None): continue try: info = os.stat(name, dir_fd=self.dir_fd, follow_symlinks=False) diff --git a/src/web/api/computer.py b/src/web/api/computer.py index d2666d851..db9f68d7b 100644 --- a/src/web/api/computer.py +++ b/src/web/api/computer.py @@ -348,7 +348,8 @@ def status_json(value, actor, *, accessibility=None): for key in ("phase", "reason"): if reconciliation.get(key) in {"unknown_release", "native_continuity_lost"}: public_reconciliation[key] = reconciliation[key] - for key in ("required", "authorizes_input", "replay_allowed", "receiver_release_verified"): + for key in ("required", "authorizes_input", "replay_allowed", + "receiver_release_verified"): if type(reconciliation.get(key)) is bool: public_reconciliation[key] = reconciliation[key] next_action = reconciliation.get("next_action") diff --git a/tests/test_computer_app_profiles_r6.py b/tests/test_computer_app_profiles_r6.py index 14702bc84..22a2ef354 100644 --- a/tests/test_computer_app_profiles_r6.py +++ b/tests/test_computer_app_profiles_r6.py @@ -51,6 +51,22 @@ def test_isolated_preflight_requires_only_used_sudo(monkeypatch, runtime_sudo): preflight("drawing", runtime_sudo=runtime_sudo) +@pytest.mark.parametrize("runtime_sudo", [False, True]) +async def test_isolated_backend_start_uses_configured_preflight(monkeypatch, runtime_sudo): + from src.computer.runtime.backend import LinuxDesktopBackend + + monkeypatch.setattr(os, "access", lambda path, mode: path != "/usr/bin/sudo") + backend = LinuxDesktopBackend(enabled=True, runtime_sudo=runtime_sudo) + + def inert_descriptor(_): + raise RuntimeError("past preflight, no native launch") + + monkeypatch.setattr(backend, "startup_descriptor", inert_descriptor) + message = "unavailable: sudo" if runtime_sudo else "past preflight, no native launch" + with pytest.raises(RuntimeError, match=message): + await backend.start("a" * 32) + + def app_scope(monkeypatch, profile, wm_class): # profile is only a legacy test-fixture label, never passed to scope. display = Display() diff --git a/tests/test_computer_campaign_absence_resolution.py b/tests/test_computer_campaign_absence_resolution.py index 27bf19daa..dd706e2a6 100644 --- a/tests/test_computer_campaign_absence_resolution.py +++ b/tests/test_computer_campaign_absence_resolution.py @@ -1,4 +1,5 @@ """Real SQLite resolution/reopen, no native sessions or input.""" +# ruff: noqa: F811 - imported pytest fixture. import pytest from src.computer.store import ComputerStore @@ -32,3 +33,22 @@ def test_qualified_absence_resolution_is_valid_on_reopen(pending, tmp_path, reso assert public["released"] is False and public["receiver_release_verified"] is False finally: reopened.close() + + +def test_absence_history_remains_valid_after_pending_fence_is_archived( + pending, tmp_path, monkeypatch): + store, _, grant, owner = pending + record(store, grant, owner) + closed = store.finish_recovery(grant, {"status": "absence_verified"}) + # Local cleanup qualification is a separate seam; the real transaction + # archives the full pending record, which must still bind absence history. + monkeypatch.setattr(store, "_local_cleanup_verified", lambda *args: True) + store.resolve_closed_local_recovery(closed) + assert store.get_recovery_pending(grant.session_id) is None + reopened = ComputerStore(tmp_path / "db", tmp_path / "evidence") + try: + assert reopened._hyprland_recovery_record(grant.session_id)[ + "qualified_absence_history"]["retirement_evidence"] == absence(owner) + reopened._validate_current_schema() + finally: + reopened.close() diff --git a/tests/test_computer_campaign_orphans.py b/tests/test_computer_campaign_orphans.py index 74c028b7a..96f47e468 100644 --- a/tests/test_computer_campaign_orphans.py +++ b/tests/test_computer_campaign_orphans.py @@ -1,6 +1,7 @@ """Disposable synthetic bytes only; crash before SQLite publication.""" import multiprocessing import os +import threading import uuid import pytest @@ -48,3 +49,54 @@ def test_crash_orphan_removed_but_operator_files_preserved(tmp_path, operation): assert unsafe.exists() and link.is_symlink() assert (directory / tracked).exists() is (operation == "prune") store.close() + + +def test_orphan_sweep_waits_for_other_store_unpublished_writer(tmp_path, monkeypatch): + db, directory = tmp_path / "db", tmp_path / "evidence" + writer = ComputerStore(db, directory) + sweeper = ComputerStore(db, directory) + grant = writer.create_session(RequestContext("owner", "channel", "turn", "host"), "drawing") + file_ready, allow_commit, sweep_started, sweep_finished = (threading.Event() for _ in range(4)) + errors, published = [], [] + real_fsync = os.fsync + + def paused_fsync(fd): + real_fsync(fd) + file_ready.set() + assert allow_commit.wait(5) + + monkeypatch.setattr(os, "fsync", paused_fsync) + + def write(): + try: + published.append(writer.put_evidence(grant.session_id, b"unpublished private fixture")) + except BaseException as exc: + errors.append(exc) + + def sweep(): + sweep_started.set() + try: + sweeper.prune() + except BaseException as exc: + errors.append(exc) + finally: + sweep_finished.set() + + writing = threading.Thread(target=write) + writing.start() + assert file_ready.wait(5) + pruning = threading.Thread(target=sweep) + pruning.start() + assert sweep_started.wait(5) + try: + assert not sweep_finished.wait(0.1) + finally: + allow_commit.set() + writing.join(5) + pruning.join(5) + assert not writing.is_alive() and not pruning.is_alive() and not errors + assert sweeper.read_evidence( + RequestContext("owner", "channel", "turn", "host"), published[0])[0] == ( + b"unpublished private fixture") + writer.close() + sweeper.close() diff --git a/tests/test_computer_provisioning_r29.py b/tests/test_computer_provisioning_r29.py index af4e494cb..fa57da909 100644 --- a/tests/test_computer_provisioning_r29.py +++ b/tests/test_computer_provisioning_r29.py @@ -88,7 +88,7 @@ async def test_missing_native_dependencies_are_typed_before_enable(tmp_path, mon manager = lifecycle(tmp_path / "state") manager._factory = None - def missing(): + def missing(**kwargs): raise RuntimeError("private dependency diagnostics") monkeypatch.setattr(profile, "preflight", missing) diff --git a/tests/test_hyprland_owned_release.py b/tests/test_hyprland_owned_release.py index 551a36c57..722d45f45 100644 --- a/tests/test_hyprland_owned_release.py +++ b/tests/test_hyprland_owned_release.py @@ -1,13 +1,12 @@ """Local ownership release is not a compositor or receiver acknowledgement.""" +import time from types import SimpleNamespace from unittest.mock import AsyncMock -import time import pytest from src.computer.runtime.hyprland_guardian import HyprlandGuardian, owned_release_v1 -from src.computer.runtime.wayland_guardian import WaylandGuardian -from src.computer.runtime.wayland_guardian import WaylandGuardianError +from src.computer.runtime.wayland_guardian import WaylandGuardian, WaylandGuardianError def terminal(): diff --git a/tests/test_hyprland_recovery_controller.py b/tests/test_hyprland_recovery_controller.py index 940c9929b..5648704b6 100644 --- a/tests/test_hyprland_recovery_controller.py +++ b/tests/test_hyprland_recovery_controller.py @@ -86,7 +86,8 @@ async def test_automatic_handoff_persists_command_and_successor_without_replay(r @pytest.mark.parametrize("released", [True, False]) -async def test_emergency_owned_release_preserves_pending_quarantine_on_reopen(rig, tmp_path, released): +async def test_emergency_owned_release_preserves_pending_quarantine_on_reopen( + rig, tmp_path, released): from dataclasses import replace from unittest.mock import AsyncMock From 5998cac791828e3e2c87b05a2223f348b34c3b95 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:43:19 -0400 Subject: [PATCH 035/162] fix(learning): scope lesson identity and supersession to ownership --- src/learning/reflector.py | 52 +++++++++---- tests/test_campaign_learning_observability.py | 76 +++++++++++++++++++ 2 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 tests/test_campaign_learning_observability.py diff --git a/src/learning/reflector.py b/src/learning/reflector.py index bcb8554a3..c137bcaa6 100644 --- a/src/learning/reflector.py +++ b/src/learning/reflector.py @@ -193,7 +193,7 @@ def __init__( self._text_fn: TextFn | None = None self._consolidation_fn: TextFn | None = None self._injection_cache: tuple[float, dict] | None = None - self._use_stamps: dict[str, str] = {} + self._use_stamps: dict[tuple[str, str | None], str] = {} def is_enabled(self) -> bool: """Return the live master switch for automatic learned behavior. @@ -700,14 +700,14 @@ def _note_used(self, entries: list[dict]) -> None: the 180-day staleness window, so eventual persistence is fine.""" now = datetime.now(UTC).isoformat(timespec="seconds") for e in entries: - self._use_stamps[e.get("key", "")] = now + self._use_stamps[self._entry_identity(e)] = now def _apply_use_stamps(self, entries: list[dict]) -> None: """Fold pending in-memory usage stamps into entries (call under lock).""" if not self._use_stamps: return for e in entries: - stamp = self._use_stamps.get(e.get("key", "")) + stamp = self._use_stamps.get(self._entry_identity(e)) if stamp: e["last_used_at"] = stamp self._use_stamps.clear() @@ -754,7 +754,8 @@ async def reflect_on_operation( ) existing = self._load().get("entries", []) - existing_text = self._relevant_existing_text(existing, operation_summary) + visible = [e for e in existing if e.get("user_id") in (None, user_id)] + existing_text = self._relevant_existing_text(visible, operation_summary) prompt = ( "Review this completed operation and extract ONLY durable operational " @@ -904,7 +905,9 @@ async def _reflect( return existing = data.get("entries", []) - existing_text = self._relevant_existing_text(existing, conversation) + visible = [e for e in existing if e.get("user_id") is None + or e.get("user_id") in (user_ids or [])] + existing_text = self._relevant_existing_text(visible, conversation) # When multiple users participated, instruct the LLM to attribute entries user_hint = "" @@ -1000,23 +1003,30 @@ def _attribute_personal_entries(entries: list[dict], user_ids: list[str]) -> lis accepted.append(entry) return accepted + @staticmethod + def _entry_identity(entry: dict) -> tuple[str, str | None]: + """A lesson key is local to its owner; None is the explicit shared scope.""" + return entry.get("key", ""), entry.get("user_id") + @staticmethod def _merge_entries(existing: list[dict], new_entries: list[dict]) -> list[dict]: now = datetime.now(UTC).isoformat(timespec="seconds") - by_key = {e["key"]: e for e in existing} + by_key = {ConversationReflector._entry_identity(e): e for e in existing} for entry in new_entries: + identity = ConversationReflector._entry_identity(entry) _clip_content(entry) entry.setdefault("confidence", _default_confidence(entry.get("category", ""))) # A new entry may explicitly supersede older ones — remove them, # keeping the list on the new entry for provenance. for superseded_key in entry.get("supersedes", []): - if superseded_key in by_key and superseded_key != entry["key"]: - by_key.pop(superseded_key) + superseded = (superseded_key, identity[1]) + if superseded in by_key and superseded != identity: + by_key.pop(superseded) log.info( "Learned entry %r superseded by %r", superseded_key, entry["key"], ) - if entry["key"] in by_key: - current = by_key[entry["key"]] + if identity in by_key: + current = by_key[identity] current["content"] = entry["content"] current["category"] = entry["category"] current["updated_at"] = now @@ -1031,7 +1041,7 @@ def _merge_entries(existing: list[dict], new_entries: list[dict]) -> list[dict]: else: entry["created_at"] = now entry["updated_at"] = now - by_key[entry["key"]] = entry + by_key[identity] = entry return list(by_key.values()) def _expire_entries(self, entries: list[dict]) -> list[dict]: @@ -1312,12 +1322,24 @@ def _fallback() -> list[dict]: return _fallback() # Preserve timestamps and metadata from originals where possible - orig_by_key = {e["key"]: e for e in candidates} + orig_by_identity = {self._entry_identity(e): e for e in candidates} + originals_by_key: dict[str, list[dict]] = {} + for original in candidates: + originals_by_key.setdefault(original["key"], []).append(original) now = datetime.now(UTC).isoformat(timespec="seconds") for entry in consolidated: _clip_content(entry) - if entry["key"] in orig_by_key: - orig = orig_by_key[entry["key"]] + orig = orig_by_identity.get(self._entry_identity(entry)) + same_key = originals_by_key.get(entry["key"], []) + if orig is None and same_key: + # Legacy consolidations may omit ownership of an unambiguous + # key. Never guess when the same key belongs to multiple users. + if len(same_key) == 1 and "user_id" not in entry: + orig = same_key[0] + else: + log.warning("Consolidation changed or lost ownership; retaining originals") + return candidates + damaged + if orig is not None: entry["created_at"] = orig.get("created_at", now) entry["updated_at"] = now for field in ("user_id", "topic", "tags", "confidence", "source", "last_used_at"): @@ -1335,7 +1357,7 @@ def _fallback() -> list[dict]: participants = {e["user_id"] for e in candidates if isinstance(e.get("user_id"), str) and e["user_id"].strip()} for entry in consolidated: - original = orig_by_key.get(entry["key"]) + original = orig_by_identity.get(self._entry_identity(entry)) if original and original.get("user_id"): if entry.get("user_id") != original["user_id"]: log.warning("Consolidation changed personal ownership; retaining originals") diff --git a/tests/test_campaign_learning_observability.py b/tests/test_campaign_learning_observability.py new file mode 100644 index 000000000..27b5034be --- /dev/null +++ b/tests/test_campaign_learning_observability.py @@ -0,0 +1,76 @@ +import json +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock + +import pytest + +from src.learning.reflector import ConversationReflector + + +@pytest.mark.asyncio +async def test_reflections_keep_colliding_keys_and_supersession_within_owner(tmp_path): + reflector = ConversationReflector(str(tmp_path / "learned.json")) + completion = AsyncMock() + reflector.set_text_fn(completion) + for owner, content in [("alice", "Alice uses Vim"), ("bob", "Bob uses Emacs")]: + completion.return_value = json.dumps([ + {"key": "preferred_editor", "category": "preference", "content": content}, + {"key": "old_editor", "category": "preference", "content": content}, + ]) + await reflector.reflect_on_operation("editor", ["tool"], [], "done", user_id=owner) + entries = reflector.get_all_entries() + assert len(entries) == 4 + assert "Bob uses Emacs" not in reflector.get_prompt_section(user_id="alice") + reflector._apply_use_stamps(entries) + assert all(e.get("last_used_at") for e in entries if e["user_id"] == "alice") + assert not any(e.get("last_used_at") for e in entries if e["user_id"] == "bob") + completion.return_value = json.dumps([ + {"key": "preferred_editor", "category": "preference", "content": "Bob uses Nano", + "supersedes": ["old_editor"]}, + ]) + await reflector.reflect_on_operation("editor", ["tool"], [], "done", user_id="bob") + assert "Alice uses Vim" not in completion.await_args.args[0][0]["content"] + entries = reflector.get_all_entries() + assert len(entries) == 3 + assert any(e["key"] == "old_editor" and e["user_id"] == "alice" for e in entries) + assert "Alice uses Vim" in reflector.get_prompt_section(user_id="alice") + assert "Bob uses Nano" in reflector.get_prompt_section(user_id="bob") + + +@pytest.mark.asyncio +async def test_personal_collision_cannot_replace_or_supersede_shared_lesson(tmp_path): + path = tmp_path / "learned.json" + originals = [ + {"key": key, "category": "preference", "content": "operator shared"} + for key in ("editor", "old_editor") + ] + path.write_text(json.dumps({"version": 2, "entries": originals})) + reflector = ConversationReflector(str(path)) + reflector.set_text_fn(AsyncMock(return_value=json.dumps([ + {"key": "editor", "category": "preference", "content": "personal", + "supersedes": ["old_editor"]}, + ]))) + await reflector.reflect_on_operation("editor", ["tool"], [], "done", user_id="bob") + assert len(reflector.get_all_entries()) == 3 + assert "personal" not in reflector.get_prompt_section(user_id="alice") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("omit_owner", [False, True]) +async def test_consolidation_keeps_colliding_owner_metadata(tmp_path, omit_owner): + reflector = ConversationReflector(str(tmp_path / "learned.json")) + reflector._consolidation_target = 1 + old = (datetime.now(UTC) - timedelta(days=1)).isoformat() + originals = [ + {"key": "editor", "category": "preference", "content": owner, + "user_id": owner, "created_at": old, "source": {"owner": owner}} + for owner in ("alice", "bob") + ] + returned = [{k: v for k, v in e.items() if k != "source"} for e in originals] + if omit_owner: + returned[0].pop("user_id") + reflector.set_consolidation_fn(AsyncMock(return_value=json.dumps(returned))) + result = await reflector._consolidate(originals) + assert len(result) == 2 + assert all(e["source"]["owner"] == e["user_id"] for e in result) + assert all(e["created_at"] == old for e in result) From 940f4020952c47a1400a96525849d8d5c141174a Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:44:16 -0400 Subject: [PATCH 036/162] fix(learning): expire lessons from newest valid activity --- src/learning/reflector.py | 10 +++---- tests/test_campaign_learning_observability.py | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/learning/reflector.py b/src/learning/reflector.py index c137bcaa6..41bfed935 100644 --- a/src/learning/reflector.py +++ b/src/learning/reflector.py @@ -1059,14 +1059,12 @@ def _expire_entries(self, entries: list[dict]) -> list[dict]: if days is None: kept.append(e) continue - ref = e.get("last_used_at") or e.get("updated_at") or e.get("created_at") - if not ref: - kept.append(e) - continue - ref_dt = _parse_entry_timestamp(ref) - if ref_dt is None: + activity = [parsed for field in _ENTRY_TIMESTAMP_FIELDS + if (parsed := _parse_entry_timestamp(e.get(field))) is not None] + if not activity: kept.append(e) continue + ref_dt = max(activity) try: is_recent = now - ref_dt <= timedelta(days=days) except (TypeError, ValueError, OverflowError): diff --git a/tests/test_campaign_learning_observability.py b/tests/test_campaign_learning_observability.py index 27b5034be..4b75748e0 100644 --- a/tests/test_campaign_learning_observability.py +++ b/tests/test_campaign_learning_observability.py @@ -74,3 +74,31 @@ async def test_consolidation_keeps_colliding_owner_metadata(tmp_path, omit_owner assert len(result) == 2 assert all(e["source"]["owner"] == e["user_id"] for e in result) assert all(e["created_at"] == old for e in result) + + +@pytest.mark.asyncio +async def test_updated_old_used_lesson_survives_real_consolidation(tmp_path): + path = tmp_path / "learned.json" + old = (datetime.now(UTC) - timedelta(days=400)).isoformat() + path.write_text(json.dumps({"version": 2, "entries": [ + {"key": "lesson", "category": "operational", "content": "old", + "created_at": old, "updated_at": old, "last_used_at": old}, + ]})) + reflector = ConversationReflector(str(path)) + assert await reflector.update_entry_async("lesson", "freshly edited") + result = await reflector._consolidate(reflector.get_all_entries()) + assert [e["content"] for e in result] == ["freshly edited"] + + +@pytest.mark.parametrize("fresh_field", ["updated_at", "last_used_at", "created_at"]) +def test_expiry_uses_newest_valid_activity(tmp_path, fresh_field): + reflector = ConversationReflector(str(tmp_path / "learned.json")) + old = (datetime.now(UTC) - timedelta(days=400)).isoformat() + stale = {"key": "stale", "category": "fact", "content": "stale", + "created_at": old, "updated_at": old, "last_used_at": old} + recent = dict(stale, key="recent") + recent[fresh_field] = datetime.now(UTC).isoformat() + # In-memory defense: an invalid old candidate must not obscure good evidence. + if fresh_field != "last_used_at": + recent["last_used_at"] = "invalid" + assert reflector._expire_entries([stale, recent]) == [recent] From 6dedf2ac2c0e92462d519a045b08215fc5a61a25 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:47:42 -0400 Subject: [PATCH 037/162] fix(audit): summarize stable retained generations for log filters --- src/audit/logger.py | 49 +++++++++++---------- tests/test_campaign_audit_observability.py | 51 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 24 deletions(-) create mode 100644 tests/test_campaign_audit_observability.py diff --git a/src/audit/logger.py b/src/audit/logger.py index c7a79f528..9963aa8e5 100644 --- a/src/audit/logger.py +++ b/src/audit/logger.py @@ -795,36 +795,37 @@ def _match(entry: dict) -> bool: return results async def get_log_stats(self) -> dict: - """Return summary statistics for the log file.""" - if not self.path.exists(): - return {"total": 0, "errors": 0, "tools": 0, "web_actions": 0} - + """Summarize the same stable retained generations searched by history.""" total = 0 errors = 0 tools: set[str] = set() web_actions = 0 + snapshot = await self._open_read_snapshot() try: - async with aiofiles.open(self.path) as f: - async for line in f: - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue - total += 1 - if entry.get("error"): - errors += 1 - tn = entry.get("tool_name") - if (tn and entry.get("type") not in {"token_change", "permission_change"} - and not entry.get("audit_observer")): - tools.add(tn) - if entry.get("type") == "web_action": - web_actions += 1 - except Exception as exc: - log.error("Failed to read audit log for stats: %s", exc) + for handle, _stat in snapshot: + try: + async for line in _iter_lines_reverse(handle.fileno()): + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(entry, dict): + continue + total += 1 + if entry.get("error"): + errors += 1 + tn = entry.get("tool_name") + if (tn and entry.get("type") not in {"token_change", "permission_change"} + and not entry.get("audit_observer")): + tools.add(tn) + if entry.get("type") == "web_action": + web_actions += 1 + except OSError as exc: + log.error("Failed to read audit log snapshot for stats: %s", exc) + finally: + for handle, _stat in snapshot: + handle.close() return { "total": total, diff --git a/tests/test_campaign_audit_observability.py b/tests/test_campaign_audit_observability.py new file mode 100644 index 000000000..4a16dabe8 --- /dev/null +++ b/tests/test_campaign_audit_observability.py @@ -0,0 +1,51 @@ +import json + +import pytest + +from src.audit.logger import AuditLogger + + +@pytest.mark.asyncio +async def test_log_stats_retained_rotation_matches_search_and_tool_inventory(tmp_path): + logger = AuditLogger(str(tmp_path / "audit.jsonl"), max_bytes=1, max_files=3) + await logger.log_execution(user_id="user", user_name="User", tool_name="old_tool", + channel_id="channel", approved=True, + tool_input={}, result_summary="failed", execution_time_ms=1, + error="failure") + await logger.log_execution(user_id="user", user_name="User", tool_name="new_tool", + channel_id="channel", approved=True, + tool_input={}, result_summary="done", execution_time_ms=1) + stats = await logger.get_log_stats() + assert stats["total"] == len(await logger.search(limit=100)) == 2 + assert stats["tools"] == sorted(await logger.count_by_tool()) == ["new_tool", "old_tool"] + assert stats["errors"] == 1 + + +@pytest.mark.asyncio +async def test_stats_stable_snapshot_survives_rotation_after_open(tmp_path, monkeypatch): + logger = AuditLogger(str(tmp_path / "audit.jsonl")) + logger.path.write_text(json.dumps({"tool_name": "old_tool"}) + "\n") + original = logger._open_read_snapshot + + async def rotate_after_open(): + snapshot = await original() + logger.path.rename(logger.path.with_name(logger.path.name + ".1")) + logger.path.write_text(json.dumps({"tool_name": "new_tool"}) + "\n") + return snapshot + + monkeypatch.setattr(logger, "_open_read_snapshot", rotate_after_open) + stats = await logger.get_log_stats() + assert stats["total"] == 1 + assert stats["tools"] == ["old_tool"] + + +@pytest.mark.asyncio +async def test_stats_empty_shape_and_rotation_without_active_file(tmp_path): + logger = AuditLogger(str(tmp_path / "audit.jsonl")) + assert await logger.get_log_stats() == { + "total": 0, "errors": 0, "tool_count": 0, "tools": [], "web_actions": 0, + } + logger.path.with_name(logger.path.name + ".1").write_text( + json.dumps({"type": "web_action", "tool_name": "config_update"}) + "\n" + ) + assert (await logger.get_log_stats())["tools"] == ["config_update"] From 6194cce505788b975264836abf77ca0d6998c7a1 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:50:45 -0400 Subject: [PATCH 038/162] fix(observability): aggregate full retained audit snapshots --- src/observability/aggregates.py | 100 ++++++++++++++----- src/web/api/observability.py | 14 ++- tests/test_campaign_audit_observability.py | 37 +++++++ tests/test_observability_aggregates_paths.py | 6 +- tests/test_web_api_observability.py | 4 +- 5 files changed, 127 insertions(+), 34 deletions(-) diff --git a/src/observability/aggregates.py b/src/observability/aggregates.py index 353e698cb..b0ada0a9d 100644 --- a/src/observability/aggregates.py +++ b/src/observability/aggregates.py @@ -8,8 +8,10 @@ from __future__ import annotations import json +import os from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import BinaryIO from ..odin_log import get_logger @@ -21,9 +23,6 @@ DRIFT_ABS_TOKENS = 300 DRIFT_REL_FRACTION = 0.25 -_AUDIT_TAIL_BYTES = 4 * 1024 * 1024 # parse at most the last 4MB of audit log - - def _percentile(values: list[float], pct: float) -> float: if not values: return 0.0 @@ -138,25 +137,15 @@ def context_aggregates(trajectory_dir: str, window_hours: int = 24) -> dict: } -def _tail_lines(path: Path, max_bytes: int) -> list[str]: - size = path.stat().st_size - with path.open("rb") as fh: - if size > max_bytes: - fh.seek(size - max_bytes) - fh.readline() # discard the partial first line - return fh.read().decode(errors="replace").splitlines() - - -def failure_aggregates(audit_path: str, window_hours: int = 24) -> dict: +def failure_aggregates( + audit_path: str, window_hours: int = 24, *, + snapshot: list[tuple[BinaryIO, os.stat_result]] | None = None, +) -> dict: """Failure-class counts for the current vs previous window. Only classification metadata is aggregated — raw error strings are never surfaced here. """ - path = Path(audit_path) - if not path.exists(): - return {"window_hours": window_hours, "classified": 0, "by_class": {}, "trends": []} - now = datetime.now(UTC) window = timedelta(hours=window_hours) current: dict[str, int] = {} @@ -164,24 +153,68 @@ def failure_aggregates(audit_path: str, window_hours: int = 24) -> dict: by_tool: dict[str, dict[str, int]] = {} classified = 0 - try: - lines = _tail_lines(path, _AUDIT_TAIL_BYTES) - except OSError as e: - log.warning("Could not read audit log: %s", e) - return {"window_hours": window_hours, "classified": 0, "by_class": {}, "trends": []} + # The HTTP caller supplies AuditLogger's rotation-locked descriptor snapshot. + # Standalone callers still include retained numbered generations. Scan all + # captured bytes, not an arbitrary tail, and never load whole files in RAM. + owns_snapshot = snapshot is None + read_errors = 0 + if snapshot is None: + snapshot = [] + path = Path(audit_path) + paths = [path] + sorted( + p for p in path.parent.glob(path.name + ".*") + if p.name.removeprefix(path.name + ".").isdigit() + ) + seen = set() + for candidate in paths: + try: + handle = candidate.open("rb") + stat = os.fstat(handle.fileno()) + identity = (stat.st_dev, stat.st_ino) + if identity in seen: + handle.close() + continue + seen.add(identity) + snapshot.append((handle, stat)) + except FileNotFoundError: + continue + except OSError as exc: + read_errors += 1 + log.warning("Could not open retained audit log: %s", exc) - for line in lines: + def lines(): + nonlocal read_errors + for handle, stat in snapshot: + try: + handle.seek(0) + remaining = stat.st_size + while remaining > 0: + raw = handle.readline(remaining) + if not raw: + break + remaining -= len(raw) + yield raw + except OSError as exc: + read_errors += 1 + log.warning("Could not scan retained audit log: %s", exc) + + def consume(line): + nonlocal classified try: entry = json.loads(line) - except json.JSONDecodeError: - continue + except (json.JSONDecodeError, UnicodeDecodeError): + return + if not isinstance(entry, dict): + return failure = entry.get("failure") if not isinstance(failure, dict): - continue + return try: ts = datetime.fromisoformat(entry.get("timestamp", "")) except (ValueError, TypeError): - continue + return + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) cls = failure.get("class", "unknown") if now - window <= ts <= now: classified += 1 @@ -192,6 +225,14 @@ def failure_aggregates(audit_path: str, window_hours: int = 24) -> dict: elif now - 2 * window <= ts < now - window: previous[cls] = previous.get(cls, 0) + 1 + try: + for line in lines(): + consume(line) + finally: + if owns_snapshot: + for handle, _stat in snapshot: + handle.close() + trends = [] for cls in sorted(set(current) | set(previous)): cur_n, prev_n = current.get(cls, 0), previous.get(cls, 0) @@ -204,6 +245,11 @@ def failure_aggregates(audit_path: str, window_hours: int = 24) -> dict: return { "window_hours": window_hours, "classified": classified, + "coverage": { + "scope": "retained_audit_generations", "generations": len(snapshot), + "tail_truncated": False, "read_errors": read_errors, + "complete_retained_scan": read_errors == 0, + }, "by_class": { cls: {"count": n, "top_tools": dict(sorted( by_tool.get(cls, {}).items(), key=lambda kv: -kv[1])[:5])} diff --git a/src/web/api/observability.py b/src/web/api/observability.py index 1fba12904..fadbc5970 100644 --- a/src/web/api/observability.py +++ b/src/web/api/observability.py @@ -278,9 +278,17 @@ async def get_observability_context(request: web.Request) -> web.Response: async def get_observability_failures(request: web.Request) -> web.Response: from ...observability.aggregates import failure_aggregates audit_path = getattr(bot.config.tools, "audit_log_path", "./data/audit.jsonl") - data = await asyncio.to_thread( - failure_aggregates, audit_path, _obs_window(request), - ) + audit = getattr(bot, "audit", None) + snapshot = await audit.open_read_snapshot() if audit is not None else None + try: + from ...async_utils import to_thread_settled + data = await to_thread_settled( + failure_aggregates, audit_path, _obs_window(request), snapshot=snapshot, + ) + finally: + if snapshot is not None: + for handle, _stat in snapshot: + handle.close() return web.json_response(data) @routes.get("/api/usage/totals") diff --git a/tests/test_campaign_audit_observability.py b/tests/test_campaign_audit_observability.py index 4a16dabe8..f49ef51ad 100644 --- a/tests/test_campaign_audit_observability.py +++ b/tests/test_campaign_audit_observability.py @@ -1,8 +1,10 @@ import json +from datetime import UTC, datetime, timedelta import pytest from src.audit.logger import AuditLogger +from src.observability.aggregates import failure_aggregates @pytest.mark.asyncio @@ -49,3 +51,38 @@ async def test_stats_empty_shape_and_rotation_without_active_file(tmp_path): json.dumps({"type": "web_action", "tool_name": "config_update"}) + "\n" ) assert (await logger.get_log_stats())["tools"] == ["config_update"] + + +def test_failures_include_rotations_and_evidence_before_old_tail_limit(tmp_path): + path = tmp_path / "audit.jsonl" + now = datetime.now(UTC) + + def failure(hours): + return json.dumps({"timestamp": (now - timedelta(hours=hours)).isoformat(), + "failure": {"class": "timeout"}, "tool_name": "tool"}) + "\n" + + path.write_text(failure(1) + json.dumps({"padding": "x" * (4 * 1024 * 1024)}) + "\n") + path.with_name(path.name + ".1").write_text(failure(2) + failure(30)) + result = failure_aggregates(str(path)) + assert result["classified"] == 2 + assert result["trends"] == [{"class": "timeout", "current": 2, "previous": 1, "delta": 1}] + assert result["coverage"]["generations"] == 2 + assert result["coverage"]["complete_retained_scan"] is True + + +@pytest.mark.asyncio +async def test_failures_use_logger_snapshot_and_exclude_later_appends(tmp_path): + logger = AuditLogger(str(tmp_path / "audit.jsonl")) + row = json.dumps({"timestamp": datetime.now(UTC).isoformat(), + "failure": {"class": "timeout"}, "tool_name": "tool"}) + "\n" + logger.path.write_text(row) + snapshot = await logger.open_read_snapshot() + try: + with logger.path.open("a") as handle: + handle.write(row) + result = failure_aggregates(str(logger.path), snapshot=snapshot) + assert result["classified"] == 1 + assert not snapshot[0][0].closed + finally: + for handle, _stat in snapshot: + handle.close() diff --git a/tests/test_observability_aggregates_paths.py b/tests/test_observability_aggregates_paths.py index c806117fd..fdd9e446b 100644 --- a/tests/test_observability_aggregates_paths.py +++ b/tests/test_observability_aggregates_paths.py @@ -96,6 +96,6 @@ def test_tail_truncation_discards_partial_first_line(self, tmp_path): entry = json.dumps({"timestamp": (now - timedelta(hours=1)).isoformat(), "failure": {"class": "OK"}, "tool_name": "t"}) p.write_text("x" * 500 + "\n" + entry + "\n") # long padding line, then the entry - with patch("src.observability.aggregates._AUDIT_TAIL_BYTES", 120): - out = aggregates.failure_aggregates(str(p), window_hours=24) - assert out["classified"] == 1 # padding line seeked past, entry parsed + out = aggregates.failure_aggregates(str(p), window_hours=24) + assert out["classified"] == 1 # malformed padding is skipped, not tail-clipped + assert out["coverage"]["tail_truncated"] is False diff --git a/tests/test_web_api_observability.py b/tests/test_web_api_observability.py index 677b2a314..0d4133f17 100644 --- a/tests/test_web_api_observability.py +++ b/tests/test_web_api_observability.py @@ -38,6 +38,7 @@ def _bot(): a.verify_integrity = AsyncMock(return_value={"valid": True}) a.search_logs = AsyncMock(return_value=[{"l": 1}]) a.get_log_stats = AsyncMock(return_value={"total": 3}) + a.open_read_snapshot = AsyncMock(return_value=[]) a.search_by_risk = AsyncMock(return_value=[{"r": 1}]) ex = bot.tool_executor ex.risk_stats.get_summary.return_value = {"risk": 1} @@ -320,7 +321,8 @@ async def test_aggregates(self): bot = _bot() with pytest.MonkeyPatch().context() as mp: mp.setattr("src.observability.aggregates.context_aggregates", lambda d, w: {"ctx": w}) - mp.setattr("src.observability.aggregates.failure_aggregates", lambda p, w: {"fail": w}) + mp.setattr("src.observability.aggregates.failure_aggregates", + lambda p, w, **kwargs: {"fail": w}) async with TestClient(TestServer(_app(obs.register_aggregates, bot=bot))) as c: assert (await (await c.get("/api/observability/context?window=5")).json())[ "ctx" From dad9695343a5a5b58fce836ad57dc6c0954fb3e7 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:52:34 -0400 Subject: [PATCH 039/162] fix(observability): count injected learned context in prompt totals --- src/observability/aggregates.py | 5 +++ src/observability/context_trace.py | 3 ++ tests/test_campaign_prompt_accounting.py | 42 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 tests/test_campaign_prompt_accounting.py diff --git a/src/observability/aggregates.py b/src/observability/aggregates.py index b0ada0a9d..4e657691c 100644 --- a/src/observability/aggregates.py +++ b/src/observability/aggregates.py @@ -68,6 +68,11 @@ def _window_section_stats(turns: list[dict]) -> tuple[dict, list[float], int]: traced += 1 summary = trace.get("summary", {}) total = summary.get("system_tokens", 0) + summary.get("history_used_tokens", 0) + # Historical traces counted learned selection separately but omitted it + # from system_tokens. The explicit marker prevents new traces counting + # it twice and lets retained v1 evidence keep accurate headline totals. + if not summary.get("system_includes_learned", False): + total += (trace.get("learned") or {}).get("tokens", 0) totals.append(float(total)) for section in trace.get("sections", []): name = section.get("section", "?") diff --git a/src/observability/context_trace.py b/src/observability/context_trace.py index 59b0c8676..fd1bcfb00 100644 --- a/src/observability/context_trace.py +++ b/src/observability/context_trace.py @@ -269,6 +269,8 @@ def finalize(self) -> dict | None: try: duration_ms = round((time.monotonic() - self._started) * 1000, 2) total_tokens = sum(s.get("tokens", 0) for s in self._sections) + if not any(s.get("section") == "learned" for s in self._sections): + total_tokens += self._learned.get("tokens", 0) trace: dict[str, Any] = { "schema_version": TRACE_SCHEMA_VERSION, "assembly": { @@ -279,6 +281,7 @@ def finalize(self) -> dict | None: }, "summary": { "system_tokens": total_tokens, + "system_includes_learned": True, "sections_count": len(self._sections), "history_used_tokens": self._history.get("used", 0), "history_candidates": self._history.get("candidates", 0), diff --git a/tests/test_campaign_prompt_accounting.py b/tests/test_campaign_prompt_accounting.py new file mode 100644 index 000000000..6e14efe4d --- /dev/null +++ b/tests/test_campaign_prompt_accounting.py @@ -0,0 +1,42 @@ +import json +from datetime import UTC, datetime + +from src.learning.reflector import ConversationReflector +from src.observability.aggregates import context_aggregates +from src.observability.context_trace import ContextTraceCollector + + +def test_real_injected_learned_block_conserves_prompt_tokens(tmp_path): + path = tmp_path / "learned.json" + path.write_text(json.dumps({"version": 2, "entries": [ + {"key": "lesson", "category": "operational", "content": "durable lesson " * 20}, + ]})) + reflector = ConversationReflector(str(path)) + trace = ContextTraceCollector() + trace.section("base", tokens=100) + learned = reflector.get_prompt_section(trace=trace) + trace.history(budget=1000, used=80, candidates=1, kept_recent=1, kept_relevant=0, + dropped_relevance=0, dropped_budget=0) + finalized = trace.finalize() + assert finalized["learned"]["tokens"] == len(learned) // 4 + expected_system = 100 + len(learned) // 4 + assert finalized["summary"]["system_tokens"] == expected_system + # Same block represented as a section must not count twice. + other = ContextTraceCollector() + other.section("base", tokens=100) + other.section("learned", tokens=len(learned) // 4) + reflector.get_prompt_section(trace=other) + assert other.finalize()["summary"]["system_tokens"] == expected_system + now = datetime.now(UTC) + legacy = json.loads(json.dumps(finalized)) + legacy["summary"].pop("system_includes_learned") + legacy["summary"]["system_tokens"] = 100 + trajectory = tmp_path / f"{now.date().isoformat()}.jsonl" + trajectory.write_text("".join( + json.dumps({"timestamp": now.isoformat(), "context_trace": data}) + "\n" + for data in (finalized, legacy) + )) + result = context_aggregates(str(tmp_path)) + assert result["prompt_tokens_avg"] == expected_system + 80 + assert result["prompt_tokens_p95"] == expected_system + 80 + assert result["by_section"]["learned"]["avg_tokens"] == len(learned) // 4 From 2cdd2938f5b3d4b7bc04bf39380967a2fbb224e5 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:53:37 -0400 Subject: [PATCH 040/162] fix(audit): preserve unified diff line framing and EOF markers --- src/audit/diff_tracker.py | 8 ++++++-- tests/test_campaign_audit_observability.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/audit/diff_tracker.py b/src/audit/diff_tracker.py index fcc030ad5..43da0d6d7 100644 --- a/src/audit/diff_tracker.py +++ b/src/audit/diff_tracker.py @@ -27,9 +27,13 @@ def compute_unified_diff( after_lines, fromfile=f"a/{label}", tofile=f"b/{label}", - lineterm="", + lineterm="\n", ) - result = "".join(diff) + # difflib leaves content records unterminated when their source line was + # unterminated. Preserve that fact using the conventional patch marker, + # rather than gluing the next +/- record onto the same line. + result = "".join(line if line.endswith("\n") else line + "\n\\ No newline at end of file\n" + for line in diff) if len(result) > max_chars: return result[:max_chars] + "\n[diff truncated]" return result diff --git a/tests/test_campaign_audit_observability.py b/tests/test_campaign_audit_observability.py index f49ef51ad..cf292d5ed 100644 --- a/tests/test_campaign_audit_observability.py +++ b/tests/test_campaign_audit_observability.py @@ -4,6 +4,7 @@ import pytest from src.audit.logger import AuditLogger +from src.audit.diff_tracker import compute_dict_diff, compute_unified_diff from src.observability.aggregates import failure_aggregates @@ -86,3 +87,20 @@ async def test_failures_use_logger_snapshot_and_exclude_later_appends(tmp_path): finally: for handle, _stat in snapshot: handle.close() + + +@pytest.mark.parametrize("terminal_newline", [False, True]) +def test_unified_diff_frames_headers_and_content(terminal_newline): + ending = "\n" if terminal_newline else "" + result = compute_unified_diff("old" + ending, "new" + ending, label="sample") + lines = result.splitlines() + assert lines[:3] == ["--- a/sample", "+++ b/sample", "@@ -1 +1 @@"] + assert "-old" in lines and "+new" in lines + assert ("\\ No newline at end of file" in lines) is not terminal_newline + + +def test_dict_diff_is_valid_line_framed_patch(): + lines = compute_dict_diff({"x": 1}, {"x": 2}).splitlines() + assert lines[:3] == ["--- a/config", "+++ b/config", "@@ -1,3 +1,3 @@"] + assert '- "x": 1' in lines + assert '+ "x": 2' in lines From b511eefa7bc30f04951cf26a39807b2356e7e65f Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:56:39 -0400 Subject: [PATCH 041/162] fix(usage): settle owned lock and connection on database open failures --- src/usage/rollup.py | 22 +++++++++++------ tests/test_campaign_usage_lock.py | 41 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 tests/test_campaign_usage_lock.py diff --git a/src/usage/rollup.py b/src/usage/rollup.py index 4a51948d1..cee62826c 100644 --- a/src/usage/rollup.py +++ b/src/usage/rollup.py @@ -282,10 +282,14 @@ def __init__( def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(self.db_path, timeout=0.1) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA synchronous=NORMAL") - conn.execute("PRAGMA foreign_keys=ON") + try: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + except BaseException: + conn.close() + raise return conn def _initialize(self) -> None: @@ -480,8 +484,9 @@ def _ingest_trajectory(self, record: dict, kind: str, conn=None) -> bool: owns = conn is None if owns: self._lock.acquire() - conn = self._connect() try: + if owns: + conn = self._connect() conn.execute( """INSERT OR IGNORE INTO turn_facts( fact_id, occurred_at, surface, outcome, duration_ms, @@ -548,8 +553,11 @@ def _ingest_trajectory(self, record: dict, kind: str, conn=None) -> bool: return True finally: if owns: - conn.close() - self._lock.release() + try: + if conn is not None: + conn.close() + finally: + self._lock.release() @staticmethod def _tool_fact(raw: bytes | memoryview) -> tuple | None: diff --git a/tests/test_campaign_usage_lock.py b/tests/test_campaign_usage_lock.py new file mode 100644 index 000000000..4337161b8 --- /dev/null +++ b/tests/test_campaign_usage_lock.py @@ -0,0 +1,41 @@ +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime + +import pytest + +from src.usage.rollup import UsageRollup + + +@pytest.mark.parametrize("failure", ["open", "pragma"]) +def test_failed_ingestion_releases_lock_for_other_worker(tmp_path, monkeypatch, failure): + rollup = UsageRollup(str(tmp_path / "usage"), trajectory_directory=str(tmp_path / "turns"), + agent_trajectory_directory=str(tmp_path / "agents"), audit=None) + real_connect = sqlite3.connect + closed = [] + + class BrokenPragmaConnection: + def execute(self, sql): + raise sqlite3.OperationalError("injected pragma failure") + + def close(self): + closed.append(True) + + def failing_connect(*args, **kwargs): + if failure == "open": + raise sqlite3.OperationalError("injected open failure") + return BrokenPragmaConnection() + + record = {"message_id": "m1", "channel_id": "c1", "source": "discord", + "timestamp": datetime.now(UTC).isoformat(), "iterations": []} + monkeypatch.setattr("src.usage.rollup.sqlite3.connect", failing_connect) + with ThreadPoolExecutor(max_workers=1) as executor: + with pytest.raises(sqlite3.OperationalError): + executor.submit(rollup._ingest_trajectory, record, "turn").result(timeout=5) + # Deliberately a different thread from the original owner: RLock's + # reentrancy would otherwise conceal the leak. + assert rollup._lock.acquire(timeout=1) + rollup._lock.release() + monkeypatch.setattr("src.usage.rollup.sqlite3.connect", real_connect) + assert executor.submit(rollup._ingest_trajectory, record, "turn").result(timeout=5) + assert bool(closed) == (failure == "pragma") From d36f9c85afae9d9e9b36d94fc0e78611634ab7d2 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:59:20 -0400 Subject: [PATCH 042/162] fix(monitoring): scan actual session manager persistence directory --- src/monitoring/resource_usage.py | 2 +- tests/test_campaign_resource_sessions.py | 15 +++++++++++++++ tests/test_resource_usage.py | 22 +++++++++++----------- 3 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 tests/test_campaign_resource_sessions.py diff --git a/src/monitoring/resource_usage.py b/src/monitoring/resource_usage.py index bbb2ff93a..9fa8aed52 100644 --- a/src/monitoring/resource_usage.py +++ b/src/monitoring/resource_usage.py @@ -167,7 +167,7 @@ def collect_session_stats(bot: Any) -> SessionStats: "has_summary": bool(getattr(session, "summary", "")), }) - persist_dir = getattr(sm, "persist_directory", DEFAULT_SESSION_DIR) + persist_dir = getattr(sm, "persist_dir", DEFAULT_SESSION_DIR) stats.persist_dir = scan_directory(persist_dir) except Exception as exc: log.debug("Error collecting session stats: %s", exc) diff --git a/tests/test_campaign_resource_sessions.py b/tests/test_campaign_resource_sessions.py new file mode 100644 index 000000000..1b0698e08 --- /dev/null +++ b/tests/test_campaign_resource_sessions.py @@ -0,0 +1,15 @@ +from types import SimpleNamespace + +from src.monitoring.resource_usage import collect_session_stats +from src.sessions.manager import SessionManager + + +def test_real_session_manager_custom_persist_directory(tmp_path): + directory = tmp_path / "custom" / "sessions" + manager = SessionManager(max_history=50, max_age_hours=24, persist_dir=str(directory)) + payload = '{"session": "stored"}' + (directory / "session.json").write_text(payload) + stats = collect_session_stats(SimpleNamespace(sessions=manager)) + assert stats.persist_dir.path == str(directory) + assert stats.persist_dir.file_count == 1 + assert stats.persist_dir.total_bytes == len(payload.encode()) diff --git a/tests/test_resource_usage.py b/tests/test_resource_usage.py index 202761f95..811cbb236 100644 --- a/tests/test_resource_usage.py +++ b/tests/test_resource_usage.py @@ -297,7 +297,7 @@ def test_with_sessions(self): session2.summary = "" _mock_sessions(bot, {"ch1": session1, "ch2": session2}) bot.sessions.token_budget = 128000 - bot.sessions.persist_directory = "/nonexistent/sessions" + bot.sessions.persist_dir = "/nonexistent/sessions" stats = collect_session_stats(bot) assert stats.active_count == 2 assert stats.total_tokens == 13000 @@ -313,7 +313,7 @@ def test_over_budget_sessions(self): session1.summary = "" _mock_sessions(bot, {"ch1": session1}) bot.sessions.token_budget = 128000 - bot.sessions.persist_directory = "/nonexistent" + bot.sessions.persist_dir = "/nonexistent" stats = collect_session_stats(bot) assert stats.over_budget_count == 1 @@ -325,7 +325,7 @@ def test_per_session_data(self): session1.summary = "has summary" _mock_sessions(bot, {"chan_42": session1}) bot.sessions.token_budget = 128000 - bot.sessions.persist_directory = "/nonexistent" + bot.sessions.persist_dir = "/nonexistent" stats = collect_session_stats(bot) assert len(stats.per_session) == 1 ps = stats.per_session[0] @@ -342,7 +342,7 @@ def test_zero_token_budget(self): session1.summary = "" _mock_sessions(bot, {"ch1": session1}) bot.sessions.token_budget = 0 - bot.sessions.persist_directory = "/nonexistent" + bot.sessions.persist_dir = "/nonexistent" stats = collect_session_stats(bot) assert stats.over_budget_count == 0 @@ -358,7 +358,7 @@ def test_persist_dir_scanning(self): bot.sessions.token_budget = 128000 with tempfile.TemporaryDirectory() as td: (Path(td) / "sess.json").write_text("{}") - bot.sessions.persist_directory = td + bot.sessions.persist_dir = td stats = collect_session_stats(bot) assert stats.persist_dir.file_count == 1 @@ -594,7 +594,7 @@ def _make_bot(self): bot = MagicMock() _mock_sessions(bot, {}) bot.sessions.token_budget = 128000 - bot.sessions.persist_directory = "/nonexistent/sessions" + bot.sessions.persist_dir = "/nonexistent/sessions" bot.knowledge = None bot.trajectory_saver = None bot.agent_trajectory_saver = None @@ -621,7 +621,7 @@ def test_storage_total_aggregation(self): bot = self._make_bot() with tempfile.TemporaryDirectory() as td: (Path(td) / "file.json").write_text("x" * 1000) - bot.sessions.persist_directory = td + bot.sessions.persist_dir = td result = collect_all(bot) assert result["storage_total_bytes"] >= 1000 @@ -666,7 +666,7 @@ def test_with_all_subsystems(self): session1.summary = "" _mock_sessions(bot, {"ch1": session1}) bot.sessions.token_budget = 128000 - bot.sessions.persist_directory = "/nonexistent" + bot.sessions.persist_dir = "/nonexistent" bot.knowledge.available = True bot.knowledge.count.return_value = 50 @@ -695,7 +695,7 @@ def _make_bot(): bot.guilds = [] _mock_sessions(bot, {}) bot.sessions.token_budget = 128000 - bot.sessions.persist_directory = "/nonexistent" + bot.sessions.persist_dir = "/nonexistent" bot.sessions.get_session_token_usage.return_value = {} bot.sessions.get_token_metrics.return_value = {} bot.sessions.get_activity_metrics.return_value = {} @@ -850,7 +850,7 @@ def test_session_stats_no_persist_dir_attr(self): bot = MagicMock() _mock_sessions(bot, {}) bot.sessions.token_budget = 0 - del bot.sessions.persist_directory + del bot.sessions.persist_dir stats = collect_session_stats(bot) assert isinstance(stats.persist_dir, DirStats) @@ -879,7 +879,7 @@ def test_collect_all_storage_total_sums(self): bot.sessions.token_budget = 0 with tempfile.TemporaryDirectory() as td: (Path(td) / "s1.json").write_text("x" * 500) - bot.sessions.persist_directory = td + bot.sessions.persist_dir = td bot.knowledge = None bot.trajectory_saver = None bot.agent_trajectory_saver = None From b96fcde8d1f89b3c794f7a1bf3f91b92bea2ec9a Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:01:16 -0400 Subject: [PATCH 043/162] fix(health): validate knowledge database integrity at startup --- src/health/startup.py | 12 ++++++++++-- tests/test_campaign_startup_health.py | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/test_campaign_startup_health.py diff --git a/src/health/startup.py b/src/health/startup.py index c584d367d..05de7034f 100644 --- a/src/health/startup.py +++ b/src/health/startup.py @@ -458,10 +458,15 @@ def check_knowledge_db(search_config: Any) -> DiagnosticResult: # Check SQLite can open a connection at this path test_db = parent / "knowledge.db" if parent.is_dir() else parent + conn = None try: conn = sqlite3.connect(str(test_db)) - conn.execute("SELECT 1") - conn.close() + # A constant SELECT never reads the database header/schema and can + # succeed even on arbitrary bytes. quick_check validates existing + # storage while an empty first-run database remains valid. + check = conn.execute("PRAGMA quick_check").fetchall() + if check != [("ok",)]: + raise sqlite3.DatabaseError("knowledge database integrity check failed") except sqlite3.Error as exc: return DiagnosticResult( name="knowledge_db", @@ -470,6 +475,9 @@ def check_knowledge_db(search_config: Any) -> DiagnosticResult: recommendation="Check disk space and file permissions.", metadata={"path": str(test_db)}, ) + finally: + if conn is not None: + conn.close() return DiagnosticResult( name="knowledge_db", diff --git a/tests/test_campaign_startup_health.py b/tests/test_campaign_startup_health.py new file mode 100644 index 000000000..7e726e0d6 --- /dev/null +++ b/tests/test_campaign_startup_health.py @@ -0,0 +1,26 @@ +import sqlite3 +from types import SimpleNamespace + +from src.health.startup import check_knowledge_db + + +def test_knowledge_diagnostic_rejects_corrupt_existing_database(tmp_path): + path = tmp_path / "knowledge.db" + path.write_bytes(b"not a SQLite database") + cfg = SimpleNamespace(enabled=True, search_db_path=str(tmp_path)) + result = check_knowledge_db(cfg) + assert not result.passed + assert "SQLite cannot open knowledge DB" in result.detail + assert path.read_bytes() == b"not a SQLite database" + + +def test_knowledge_diagnostic_accepts_first_run_and_valid_schema(tmp_path): + cfg = SimpleNamespace(enabled=True, search_db_path=str(tmp_path)) + assert check_knowledge_db(cfg).passed + conn = sqlite3.connect(tmp_path / "knowledge.db") + try: + conn.execute("CREATE TABLE chunks (id INTEGER PRIMARY KEY, content TEXT)") + conn.commit() + finally: + conn.close() + assert check_knowledge_db(cfg).passed From eccf202bebedd0133dc73b1afd5f1fa0b5d32ca0 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:03:48 -0400 Subject: [PATCH 044/162] fix(health): diagnose effective static and dynamic API credentials --- src/discord/client.py | 5 ++++- src/health/startup.py | 21 +++++++++++++++----- tests/test_campaign_startup_auth.py | 30 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 tests/test_campaign_startup_auth.py diff --git a/src/discord/client.py b/src/discord/client.py index 5ee133139..384ae3561 100644 --- a/src/discord/client.py +++ b/src/discord/client.py @@ -264,7 +264,10 @@ async def start_application(self) -> None: if self.loop is discord.utils.MISSING: await discord.Client._async_setup_hook(self) try: - report = self._run_startup_diagnostics(yaml_config=self.config) + report = self._run_startup_diagnostics( + yaml_config=self.config, + credential_inventory=self.api_token_manager.credential_inventory, + ) self.startup_report = report for r in report.results: level = log.warning if not r.passed else log.info diff --git a/src/health/startup.py b/src/health/startup.py index 05de7034f..a1612ad9c 100644 --- a/src/health/startup.py +++ b/src/health/startup.py @@ -487,7 +487,7 @@ def check_knowledge_db(search_config: Any) -> DiagnosticResult: ) -def check_config_sections(config: Any) -> DiagnosticResult: +def check_config_sections(config: Any, *, credential_inventory: Any = None) -> DiagnosticResult: """Validate that key config sections are internally consistent.""" issues: list[str] = [] @@ -498,12 +498,19 @@ def check_config_sections(config: Any) -> DiagnosticResult: elif not getattr(discord_cfg, "token", ""): issues.append("discord.token is empty") - # Web config: warn about default/empty api_token + # Count static identities and the already validated runtime inventory, + # without inspecting secret stores a second time in the diagnostics. web_cfg = getattr(config, "web", None) if web_cfg and getattr(web_cfg, "enabled", False): api_token = getattr(web_cfg, "api_token", "") - if not api_token: - issues.append("web.api_token is empty — API has no authentication (dev mode)") + has_static = bool(api_token) or any( + bool(getattr(identity, "token", "")) + for identity in getattr(web_cfg, "api_tokens", ()) + ) + has_dynamic = credential_inventory is not None and credential_inventory.has_usable_auth + if not has_static and not has_dynamic: + issues.append("No usable web.api_token, web.api_tokens or dynamic API credentials — " + "API has no configured authentication (dev mode)") # Webhook: if enabled, verify secret is set webhook_cfg = getattr(config, "webhook", None) @@ -713,6 +720,7 @@ def run_startup_diagnostics( *, odin_config: Any | None = None, yaml_config: Any | None = None, + credential_inventory: Any = None, ) -> StartupReport: """Run all boot-time diagnostic checks and return a :class:`StartupReport`. @@ -751,7 +759,10 @@ def run_startup_diagnostics( detail="YAML config not provided — skipped", )) continue - result = check_fn(yaml_config) + if name == "config_consistency": + result = check_fn(yaml_config, credential_inventory=credential_inventory) + else: + result = check_fn(yaml_config) else: if yaml_config is None: report.results.append(DiagnosticResult( diff --git a/tests/test_campaign_startup_auth.py b/tests/test_campaign_startup_auth.py new file mode 100644 index 000000000..43d5bf4e8 --- /dev/null +++ b/tests/test_campaign_startup_auth.py @@ -0,0 +1,30 @@ +import json + +import pytest + +from src.config.schema import Config +from src.health.startup import check_config_sections, run_startup_diagnostics +from src.permissions.token_manager import ApiTokenManager + + +def test_startup_auth_recognizes_static_user_token_without_shared_token(): + cfg = Config(discord={"token": "gateway"}, web={"enabled": True, "api_tokens": [ + {"token": "test-static-marker", "user_id": "123", "tier": "user"}, + ]}) + result = check_config_sections(cfg) + assert result.passed + assert "test-static-marker" not in json.dumps(result.to_dict()) + + +@pytest.mark.asyncio +async def test_runtime_dynamic_inventory_reaches_startup_auth_check(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + manager = ApiTokenManager(str(tmp_path / "tokens.json")) + identity = await manager.create_token("123", tier="user") + cfg = Config(discord={"token": "gateway"}, web={"enabled": True}) + assert not check_config_sections(cfg).passed + report = run_startup_diagnostics(yaml_config=cfg, + credential_inventory=manager.credential_inventory) + result = next(r for r in report.results if r.name == "config_consistency") + assert result.passed + assert identity.token not in json.dumps(result.to_dict()) From ace5a4084e9c8123d2a7978e70238e77a8fa17f0 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:04:33 -0400 Subject: [PATCH 045/162] fix(health): treat unconfigured Discord gateway as dormant --- src/__main__.py | 2 ++ src/observability/aggregates.py | 4 ++-- tests/test_campaign_startup_health.py | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/__main__.py b/src/__main__.py index bda51e9be..3ca56d06d 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -28,6 +28,8 @@ def _wire_observability(health, bot, log) -> None: scheduler = getattr(bot, "scheduler", None) def _discord_health() -> tuple[bool, str]: try: + if not bot.config.discord.token: + return (True, "not configured (HTTP-only mode)") latency = bot.latency # latency == latency filters out NaN (discord.py before first heartbeat) if latency and latency == latency: diff --git a/src/observability/aggregates.py b/src/observability/aggregates.py index 4e657691c..880901bdc 100644 --- a/src/observability/aggregates.py +++ b/src/observability/aggregates.py @@ -2,8 +2,8 @@ Reads trajectory context traces and audit failure classifications, computes windowed aggregates and drift candidates, and returns plain dicts for the -API layer. No alert delivery here — exposure only; consumers (Grafana, -future heartbeat) decide what to do with drift candidates. +API layer. No alert delivery here — exposure only; API consumers decide +what to do with drift candidates. """ from __future__ import annotations diff --git a/tests/test_campaign_startup_health.py b/tests/test_campaign_startup_health.py index 7e726e0d6..23811e765 100644 --- a/tests/test_campaign_startup_health.py +++ b/tests/test_campaign_startup_health.py @@ -3,6 +3,16 @@ from src.health.startup import check_knowledge_db +import json +import logging + +import pytest +from aiohttp.test_utils import make_mocked_request + +from src.__main__ import _wire_observability +from src.config.schema import Config, WebhookConfig +from src.health.server import HealthServer + def test_knowledge_diagnostic_rejects_corrupt_existing_database(tmp_path): path = tmp_path / "knowledge.db" @@ -14,6 +24,22 @@ def test_knowledge_diagnostic_rejects_corrupt_existing_database(tmp_path): assert path.read_bytes() == b"not a SQLite database" +@pytest.mark.asyncio +@pytest.mark.parametrize("gateway_token, ready_status", [("", 200), ("configured", 503)]) +async def test_production_health_wiring_keeps_http_bootstrap_ready(gateway_token, ready_status): + bot = SimpleNamespace(config=Config(discord={"token": gateway_token}), latency=float("nan"), + is_ready=lambda: False) + server = HealthServer(port=0, webhook_config=WebhookConfig(enabled=False)) + _wire_observability(server, bot, logging.getLogger("test")) + server.set_ready(True) + response = await server._health_ready(make_mocked_request("GET", "/health/ready")) + assert response.status == ready_status + component = json.loads(response.text)["components"]["discord"] + assert component["healthy"] is (not bool(gateway_token)) + if not gateway_token: + assert component["detail"] == "not configured (HTTP-only mode)" + + def test_knowledge_diagnostic_accepts_first_run_and_valid_schema(tmp_path): cfg = SimpleNamespace(enabled=True, search_db_path=str(tmp_path)) assert check_knowledge_db(cfg).passed From 7191e61479a7f741d747dadc68158fc1f34bb1d3 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:11:57 -0400 Subject: [PATCH 046/162] fix(observability): label prefix stability unmeasured instead of cache hits --- scripts/check-prefix-measurement.mjs | 35 +++++++++++++++++++++++ src/llm/context_compressor.py | 14 ++++++--- tests/test_campaign_prefix_measurement.py | 29 +++++++++++++++++++ tests/test_context_compressor.py | 5 +++- ui/js/pages/internals.js | 4 ++- 5 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 scripts/check-prefix-measurement.mjs create mode 100644 tests/test_campaign_prefix_measurement.py diff --git a/scripts/check-prefix-measurement.mjs b/scripts/check-prefix-measurement.mjs new file mode 100644 index 000000000..ba5ac3676 --- /dev/null +++ b/scripts/check-prefix-measurement.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { createSSRApp } from 'vue'; +import { renderToString } from '@vue/server-renderer'; + +globalThis.localStorage = { getItem() { return null; } }; +globalThis.sessionStorage = { getItem() { return null; } }; +globalThis.document = { addEventListener() {}, removeEventListener() {} }; +globalThis.window = { setInterval, clearInterval, setTimeout, clearTimeout }; +const { default: page } = await import('../ui/js/pages/internals.js'); + +async function render(compressionStats) { + const originalWarn = console.warn; + console.warn = () => {}; + let state; + try { state = page.setup(); } finally { console.warn = originalWarn; } + state.loading.value = false; + state.compressionStats.value = compressionStats; + const app = createSSRApp({ template: page.template, setup() { return state; } }); + app.component('odin-icon', { template: '' }); + return renderToString(app); +} + +for (const fixture of [ + { prefix_hit_rate: 0 }, // Older API response must not fabricate measurements. + { prefix_hit_rate: null, prefix_measurement: 'unmeasured' }, +]) { + const html = await render(fixture); + assert.ok(html.includes('Prefix stability: Not measured')); + assert.ok(!html.includes('Local prefix equality: 0%')); + assert.ok(html.includes('Upstream cache hits: Not measured here')); +} +const local = await render({ prefix_hit_rate: 0.5, prefix_measurement: 'local_prefix_equality' }); +assert.ok(local.includes('Local prefix equality: 50% (not upstream cache hits)')); +assert.ok(local.includes('Upstream cache hits: Not measured here')); +console.log('prefix measurement: real page rendering checks passed'); diff --git a/src/llm/context_compressor.py b/src/llm/context_compressor.py index 1b9d9651c..8389db2d1 100644 --- a/src/llm/context_compressor.py +++ b/src/llm/context_compressor.py @@ -25,7 +25,10 @@ @dataclass class CompressionStats: - """Observable counters for context compression and prefix caching.""" + """Compression counters and optional local prefix-equality diagnostics. + + Local prefix equality is not evidence of provider-side cached tokens. + """ compressions: int = 0 iterations_compressed: int = 0 @@ -42,8 +45,10 @@ def as_dict(self) -> dict: "prefix_hits": self.prefix_hits, "prefix_misses": self.prefix_misses, "total_checks": self.total_checks, + "prefix_measurement": "local_prefix_equality" if self.total_checks else "unmeasured", + "upstream_cache_measured": False, "prefix_hit_rate": ( - round(self.prefix_hits / self.total_checks, 3) if self.total_checks > 0 else 0.0 + round(self.prefix_hits / self.total_checks, 3) if self.total_checks > 0 else None ), } @@ -57,7 +62,8 @@ class PrefixTracker: Call :meth:`check` before each LLM call with the system prompt and the non-tool-iteration prefix messages. Returns *True* when the - prefix matches the previous call (a provider-side "cache hit"). + prefix matches the previous call. This is local equality only, not + provider-side cache-hit evidence. """ __slots__ = ("_last_hash", "_stats") @@ -71,7 +77,7 @@ def stats(self) -> CompressionStats: return self._stats def check(self, system: str, prefix_messages: list[dict]) -> bool: - """Return *True* if prefix matches the previous call (cache hit). + """Return *True* if prefix matches the previous call locally. The very first call always returns *False* (nothing to compare). """ diff --git a/tests/test_campaign_prefix_measurement.py b/tests/test_campaign_prefix_measurement.py new file mode 100644 index 000000000..5d94b2508 --- /dev/null +++ b/tests/test_campaign_prefix_measurement.py @@ -0,0 +1,29 @@ +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.llm.context_compressor import CompressionStats, PrefixTracker +from src.web.api.observability import register_compression_stats + + +@pytest.mark.asyncio +async def test_compression_api_distinguishes_unmeasured_local_and_upstream_cache(): + stats = CompressionStats() + routes = web.RouteTableDef() + register_compression_stats(routes, SimpleNamespace(compression_stats=stats)) + app = web.Application() + app.router.add_routes(routes) + async with TestClient(TestServer(app)) as client: + data = await (await client.get("/api/compression/stats")).json() + assert data["prefix_hit_rate"] is None + assert data["prefix_measurement"] == "unmeasured" + assert data["upstream_cache_measured"] is False + tracker = PrefixTracker(stats) + assert not tracker.check("system", []) + assert tracker.check("system", []) + data = await (await client.get("/api/compression/stats")).json() + assert data["prefix_measurement"] == "local_prefix_equality" + assert data["prefix_hit_rate"] == 0.5 + assert data["upstream_cache_measured"] is False diff --git a/tests/test_context_compressor.py b/tests/test_context_compressor.py index 9162eb7ca..d40c8389f 100644 --- a/tests/test_context_compressor.py +++ b/tests/test_context_compressor.py @@ -758,6 +758,7 @@ def test_as_dict_keys(self): assert set(d.keys()) == { "compressions", "iterations_compressed", "chars_saved", "prefix_hits", "prefix_misses", "total_checks", "prefix_hit_rate", + "prefix_measurement", "upstream_cache_measured", } def test_live_stats_sink_tracks_real_compression(self): @@ -794,7 +795,9 @@ def test_live_stats_sink_tracks_real_compression(self): def test_hit_rate_zero_when_no_checks(self): s = CompressionStats() - assert s.as_dict()["prefix_hit_rate"] == 0.0 + assert s.as_dict()["prefix_hit_rate"] is None + assert s.as_dict()["prefix_measurement"] == "unmeasured" + assert s.as_dict()["upstream_cache_measured"] is False def test_hit_rate_calculated(self): s = CompressionStats(prefix_hits=3, total_checks=10) diff --git a/ui/js/pages/internals.js b/ui/js/pages/internals.js index d5e38396e..3b944a32e 100644 --- a/ui/js/pages/internals.js +++ b/ui/js/pages/internals.js @@ -168,7 +168,9 @@ export default {
    Compressions: {{ compressionStats.compressions || 0 }}
    Iterations compressed: {{ compressionStats.iterations_compressed || 0 }}
    Chars saved: {{ (compressionStats.chars_saved || 0).toLocaleString() }}
    -
    Prefix cache hit rate: {{ ((compressionStats.prefix_hit_rate || 0) * 100).toFixed(0) }}%
    +
    Local prefix equality: {{ (compressionStats.prefix_hit_rate * 100).toFixed(0) }}% (not upstream cache hits)
    +
    Prefix stability: Not measured
    +
    Upstream cache hits: Not measured here

    No compression data

    From 15241d2b0c2415654c8c4a4eda56447dff0e0442 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:15:34 -0400 Subject: [PATCH 047/162] test(observability): verify retained failure API and type-safe ownership --- src/health/startup.py | 4 +- src/learning/reflector.py | 12 +++--- src/observability/aggregates.py | 4 +- tests/test_campaign_audit_observability.py | 40 +++++++++++++++++++- tests/test_campaign_startup_health.py | 8 ++-- tests/test_observability_aggregates_paths.py | 1 - 6 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/health/startup.py b/src/health/startup.py index a1612ad9c..796d362cc 100644 --- a/src/health/startup.py +++ b/src/health/startup.py @@ -760,7 +760,9 @@ def run_startup_diagnostics( )) continue if name == "config_consistency": - result = check_fn(yaml_config, credential_inventory=credential_inventory) + result = check_config_sections( + yaml_config, credential_inventory=credential_inventory, + ) else: result = check_fn(yaml_config) else: diff --git a/src/learning/reflector.py b/src/learning/reflector.py index 41bfed935..84a83ed77 100644 --- a/src/learning/reflector.py +++ b/src/learning/reflector.py @@ -1355,17 +1355,17 @@ def _fallback() -> list[dict]: participants = {e["user_id"] for e in candidates if isinstance(e.get("user_id"), str) and e["user_id"].strip()} for entry in consolidated: - original = orig_by_identity.get(self._entry_identity(entry)) - if original and original.get("user_id"): - if entry.get("user_id") != original["user_id"]: + attributed_original = orig_by_identity.get(self._entry_identity(entry)) + if attributed_original and attributed_original.get("user_id"): + if entry.get("user_id") != attributed_original["user_id"]: log.warning("Consolidation changed personal ownership; retaining originals") return candidates + damaged if entry["category"] in ("preference", "correction"): # An existing explicitly global entry may stay global. A model # cannot manufacture global personal entries under a new key. - existing_global = (original is not None - and not original.get("user_id") - and original.get("category") == entry["category"] + existing_global = (attributed_original is not None + and not attributed_original.get("user_id") + and attributed_original.get("category") == entry["category"] and not entry.get("user_id")) if not existing_global and entry.get("user_id") not in participants: log.warning("Consolidation lost personal attribution; retaining originals") diff --git a/src/observability/aggregates.py b/src/observability/aggregates.py index 880901bdc..36db10d09 100644 --- a/src/observability/aggregates.py +++ b/src/observability/aggregates.py @@ -235,8 +235,8 @@ def consume(line): consume(line) finally: if owns_snapshot: - for handle, _stat in snapshot: - handle.close() + for owned_handle, _stat in snapshot: + owned_handle.close() trends = [] for cls in sorted(set(current) | set(previous)): diff --git a/tests/test_campaign_audit_observability.py b/tests/test_campaign_audit_observability.py index cf292d5ed..72057aa0a 100644 --- a/tests/test_campaign_audit_observability.py +++ b/tests/test_campaign_audit_observability.py @@ -1,11 +1,15 @@ import json from datetime import UTC, datetime, timedelta +from types import SimpleNamespace import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer -from src.audit.logger import AuditLogger from src.audit.diff_tracker import compute_dict_diff, compute_unified_diff +from src.audit.logger import AuditLogger from src.observability.aggregates import failure_aggregates +from src.web.api.observability import register_aggregates @pytest.mark.asyncio @@ -89,6 +93,40 @@ async def test_failures_use_logger_snapshot_and_exclude_later_appends(tmp_path): handle.close() +@pytest.mark.asyncio +async def test_failure_endpoint_scans_real_logger_rotations_and_closes_snapshot( + tmp_path, monkeypatch, +): + logger = AuditLogger(str(tmp_path / "audit.jsonl")) + logger.path.with_name(logger.path.name + ".1").write_text(json.dumps({ + "timestamp": datetime.now(UTC).isoformat(), "failure": {"class": "timeout"}, + "tool_name": "retained_tool", + }) + "\n") + opened = [] + original = logger.open_read_snapshot + + async def capture_snapshot(): + snapshot = await original() + opened.extend(handle for handle, _stat in snapshot) + return snapshot + + monkeypatch.setattr(logger, "open_read_snapshot", capture_snapshot) + bot = SimpleNamespace(audit=logger, config=SimpleNamespace( + tools=SimpleNamespace(audit_log_path=str(logger.path)))) + routes = web.RouteTableDef() + register_aggregates(routes, bot) + app = web.Application() + app.router.add_routes(routes) + async with TestClient(TestServer(app)) as client: + response = await client.get("/api/observability/failures") + assert response.status == 200 + data = await response.json() + assert data["classified"] == 1 + assert data["by_class"]["timeout"]["top_tools"] == {"retained_tool": 1} + assert data["coverage"]["generations"] == 1 + assert opened and all(handle.closed for handle in opened) + + @pytest.mark.parametrize("terminal_newline", [False, True]) def test_unified_diff_frames_headers_and_content(terminal_newline): ending = "\n" if terminal_newline else "" diff --git a/tests/test_campaign_startup_health.py b/tests/test_campaign_startup_health.py index 23811e765..1452cc0ab 100644 --- a/tests/test_campaign_startup_health.py +++ b/tests/test_campaign_startup_health.py @@ -1,10 +1,7 @@ -import sqlite3 -from types import SimpleNamespace - -from src.health.startup import check_knowledge_db - import json import logging +import sqlite3 +from types import SimpleNamespace import pytest from aiohttp.test_utils import make_mocked_request @@ -12,6 +9,7 @@ from src.__main__ import _wire_observability from src.config.schema import Config, WebhookConfig from src.health.server import HealthServer +from src.health.startup import check_knowledge_db def test_knowledge_diagnostic_rejects_corrupt_existing_database(tmp_path): diff --git a/tests/test_observability_aggregates_paths.py b/tests/test_observability_aggregates_paths.py index fdd9e446b..231fdcfd6 100644 --- a/tests/test_observability_aggregates_paths.py +++ b/tests/test_observability_aggregates_paths.py @@ -11,7 +11,6 @@ import json from datetime import UTC, datetime, timedelta from pathlib import Path -from unittest.mock import patch from src.observability import aggregates From 50ce835ee5381b17da3471b3b02d91d300bde64c Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:12:35 -0400 Subject: [PATCH 048/162] Fix packaging and deployment regressions --- Dockerfile | 2 +- docker-compose.yml | 23 ++------------ package.json | 2 +- packaging/nfpm.yml | 10 ++++++ packaging/postinstall.sh | 7 +++-- pyproject.toml | 3 ++ results/packaging.md | 32 +++++++++++++++++++ scripts/incus-deploy.sh | 8 +++-- scripts/odin-cli.py | 5 +-- scripts/odin-server | 2 ++ src/cli.py | 41 ++++++++++++++++++++++++ tests/test_packaging_campaign.py | 53 ++++++++++++++++++++++++++++++++ tests/test_rate_limiter.py | 35 +++++++++++---------- 13 files changed, 177 insertions(+), 46 deletions(-) create mode 100644 results/packaging.md create mode 100755 scripts/odin-server create mode 100644 src/cli.py create mode 100644 tests/test_packaging_campaign.py diff --git a/Dockerfile b/Dockerfile index 645e8de1c..08a0018fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app COPY pyproject.toml . # .[pdf] — analyze_pdf needs PyMuPDF; without it the catalog gate hides the # tool, so an official image would ship without a capability it advertises. -RUN pip install --no-cache-dir ".[pdf]" +RUN pip install --no-cache-dir ".[pdf,browser]" # Copy application source COPY src/ src/ diff --git a/docker-compose.yml b/docker-compose.yml index 35d6d404c..0e09c9978 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: - TZ=${TZ:-UTC} - OLLAMA_URL=${OLLAMA_URL:-http://host.docker.internal:11434} volumes: - - ./config.yml:/app/config.yml:ro + - ./config:/app/config - ./data:/app/data # NAMED volume, not a bind mount: Docker initializes a named volume from # the image directory, preserving the 0700 ownership the resolver @@ -23,6 +23,7 @@ services: - "127.0.0.1:3939:3000" networks: - default + command: ["sh", "-c", "if [ -f /app/config/config.yml ]; then exec python -m src /app/config/config.yml; else exec python -m src; fi"] odin-browser: image: ghcr.io/browserless/chromium @@ -43,26 +44,6 @@ services: networks: - default - odin-voice: - build: ./voice-service - container_name: odin-voice - restart: unless-stopped - profiles: - - voice - volumes: - - ./voice-service/config.yml:/app/config.yml:ro - - ./voice-service/voices:/app/voices - - ./voice-service/models:/app/models - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [gpu] - networks: - - default - volumes: # Local command workspace (tools.local_working_dir). Initialized from the # image's 0700 directory so a fresh deployment satisfies the resolver. diff --git a/package.json b/package.json index 8f1a6dea7..1a3f61513 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "check:setup-ui": "node scripts/check-setup-ui.mjs", "check:template-bindings": "node scripts/check-template-bindings.mjs", "check:modal-race": "node scripts/check-modal-race.mjs", - "check": "npm run check:templates && npm run check:codex-quota-ui && npm run check:llm-config-refresh && npm run check:llm-provider-visibility && npm run check:template-bindings && npm run check:permission-token-repair && npm run check:listener-consent && npm run check:w1-webui && npm run check:modal-race && npm run check:host-access-races && npm run check:host-enrollment && npm run check:socket-identity && npm run check:ws-lifecycle && npm run check:login-persistence && npm run check:w2-freshness && npm run check:toggle-serialization && npm run check:search-races && npm run check:config-keydown && npm run check:css-tokens && npm run check:turn-state-webui && npm run check:audit-verify && npm run check:schedule-time && npm run check:schedule-report-format && npm run check:config-health && npm run check:config-center-ui2 && npm run check:mcp-webui && npm run check:tools-webui && npm run check:learning-toggle && npm run check:config-save-boundaries && npm run check:usage-activity && npm run check:output-renderer && npm run check:live-logs && npm run check:logs-preset-search-races && npm run check:discord-identity && npm run build", + "check": "npm run check:templates && npm run check:setup-ui && npm run check:computer && npm run check:schedule-availability && npm run check:codex-quota-ui && npm run check:llm-config-refresh && npm run check:llm-provider-visibility && npm run check:template-bindings && npm run check:permission-token-repair && npm run check:listener-consent && npm run check:w1-webui && npm run check:modal-race && npm run check:host-access-races && npm run check:host-enrollment && npm run check:socket-identity && npm run check:ws-lifecycle && npm run check:login-persistence && npm run check:w2-freshness && npm run check:toggle-serialization && npm run check:search-races && npm run check:config-keydown && npm run check:css-tokens && npm run check:turn-state-webui && npm run check:audit-verify && npm run check:schedule-time && npm run check:schedule-report-format && npm run check:config-health && npm run check:config-center-ui2 && npm run check:mcp-webui && npm run check:tools-webui && npm run check:learning-toggle && npm run check:config-save-boundaries && npm run check:usage-activity && npm run check:output-renderer && npm run check:live-logs && npm run check:logs-preset-search-races && npm run check:discord-identity && npm run build", "check:listener-consent": "node scripts/check-listener-consent.mjs", "check:host-access-races": "node scripts/check-host-access-races.mjs && node scripts/check-host-access-redesign.mjs && node scripts/check-host-access-modal.mjs", "check:host-enrollment": "node scripts/check-host-enrollment-browser.mjs", diff --git a/packaging/nfpm.yml b/packaging/nfpm.yml index 79fb8ad79..c18722276 100644 --- a/packaging/nfpm.yml +++ b/packaging/nfpm.yml @@ -97,6 +97,16 @@ contents: file_info: mode: 0755 + - src: ./scripts/odin-server + dst: /usr/local/bin/odin-server + file_info: + mode: 0755 + + - src: ./scripts/odin-cli.py + dst: /usr/local/bin/odin-client + file_info: + mode: 0755 + - src: ./packaging/odin.service dst: /usr/lib/systemd/system/odin.service diff --git a/packaging/postinstall.sh b/packaging/postinstall.sh index 3699c9a73..cb0a61c89 100755 --- a/packaging/postinstall.sh +++ b/packaging/postinstall.sh @@ -71,6 +71,9 @@ FRESH_INSTALL=false if [ ! -f "$CONFIG_DIR/config.yml" ]; then if [ -f "$APP_DIR/config.yml.default" ]; then cp "$APP_DIR/config.yml.default" "$CONFIG_DIR/config.yml" + # The Debian package's generated identity lives under /opt/odin. + # Rewrite only the fresh default, never an operator-owned config. + sed -i 's|^ ssh_key_path: /app/\.ssh/id_ed25519$| ssh_key_path: /opt/odin/.ssh/id_ed25519|' "$CONFIG_DIR/config.yml" fi FRESH_INSTALL=true elif [ -f "$APP_DIR/config.yml.default" ]; then @@ -146,14 +149,14 @@ if [ -f "$APP_DIR/pyproject.toml" ]; then # them on BOTH fresh installs and upgrades; missing extras must not silently # hide documented tools. OS desktop packages remain APT Recommends. Installing # dependencies is not an authorization grant and never enables computer use. - "$APP_DIR/.venv/bin/pip" install --quiet "$APP_DIR[pdf,computer]" + "$APP_DIR/.venv/bin/pip" install --quiet "$APP_DIR[pdf,computer,browser]" else echo "Odin: mandatory application metadata is missing." >&2 exit 1 fi # A successful dependency command alone does not prove the installed app imports. (cd "$APP_DIR" && "$APP_DIR/.venv/bin/python" -c \ - 'import src.__main__; import src.discord.client; import pymupdf; import PIL; import Xlib; import dbus_next') + 'import src.__main__; import src.discord.client; import pymupdf; import playwright; import PIL; import Xlib; import dbus_next') # Install Playwright browsers for native browser support (optional feature) "$APP_DIR/.venv/bin/playwright" install chromium 2>/dev/null || \ diff --git a/pyproject.toml b/pyproject.toml index 407a5a2cf..a1f068e4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,8 @@ dev = [ [project.scripts] odin = "src.__main__:main" +odin-client = "src.cli:main" +odin-server = "src.__main__:main" [tool.pytest.ini_options] asyncio_mode = "auto" @@ -90,3 +92,4 @@ include = ["src*"] [tool.setuptools.package-data] "src.computer.runtime" = ["assets/*", "assets/services/*"] +"src.tools" = ["model_hints_seed.json"] diff --git a/results/packaging.md b/results/packaging.md new file mode 100644 index 000000000..85c692f89 --- /dev/null +++ b/results/packaging.md @@ -0,0 +1,32 @@ +# Packaging campaign results + +## Implemented + +- **#487** Fixed the malformed template-copy expansion in `scripts/incus-deploy.sh`; added syntax validation coverage. +- **#488** Incus deployment now transfers the UI directory needed by the web server. +- **#489** Declared `src.tools/model_hints_seed.json` as setuptools package data. +- **#490** Kept `odin` bound to the server behavior for compatibility; added `odin-client` and `odin-server` explicit aliases in Python installs and Debian packaging. No automatic rewrite of existing entrypoint semantics. +- **#491** JSON and plain output now both return failure for `is_error` responses. +- **#492** Added the browser extra to Docker, Debian and Incus install dependency sets, and asserted the browser package in Debian's import smoke check. +- **#493** Fresh Debian config defaults to the package's `/opt/odin/.ssh/id_ed25519`; existing operator config is not rewritten. Existing key material remains untouched. +- **#494** Compose now mounts the config directory, rather than a replace-incompatible individual file mount, and starts with that config path when present. +- **#497** No `voice-service` path remains in the current Compose tree; nothing to remove in this checkout. +- **#498** Aggregate WebUI check now invokes setup, computer-browser, and schedule-availability focused checks. +- **#587** Reworked stale-bucket regression to use two trusted synthetic forwarded IPs and assert stale removal versus active retention; corrected active-bucket timing. + +Regression coverage: `tests/test_packaging_campaign.py`, `tests/test_rate_limiter.py`, plus existing `tests/test_execute_api.py` coverage. + +## Validation + +- `bash -n scripts/incus-deploy.sh packaging/postinstall.sh scripts/odin-server`: passed. +- `/home/odin/odin-dev/.venv/bin/python -m pytest tests/test_packaging_campaign.py tests/test_execute_api.py tests/test_rate_limiter.py -q`: **27 passed**, one upstream `audioop` deprecation warning. +- `python -m json.tool package.json`: passed. +- No deployment, install, restart, destructive command, UI build, or full campaign gate was run. + +## Question / caveat + +- **#494 existing Compose upgrades:** the new directory mount expects operator config at `./config/config.yml`; prior Compose mounts used `./config.yml`. The shipped compose entrypoint has a fallback to the image config, but an existing operator config at the old path will not be selected automatically. Please decide whether to add an explicit documented migration or provide a compatibility-safe mount layout before integrating. No config is overwritten by this change. + +## Commit + +Pending parent integration; this worktree is not pushed or merged. diff --git a/scripts/incus-deploy.sh b/scripts/incus-deploy.sh index 2e6f4c525..a40a15169 100755 --- a/scripts/incus-deploy.sh +++ b/scripts/incus-deploy.sh @@ -86,6 +86,8 @@ echo "Pushing project files..." incus file push "$SCRIPT_DIR/pyproject.toml" "$INSTANCE/app/pyproject.toml" incus file push "$SCRIPT_DIR/config.yml" "$INSTANCE/app/config.yml" incus file push -r "$SCRIPT_DIR/src" "$INSTANCE/app/" +incus file push -r "$SCRIPT_DIR/ui" "$INSTANCE/app/" +incus file push -r "$SCRIPT_DIR/ui" "$INSTANCE/app/" # Push .env as environment file incus file push "$SCRIPT_DIR/.env" "$INSTANCE/app/.env" @@ -101,14 +103,16 @@ fi # Push data templates if they exist for tmpl in "$SCRIPT_DIR"/data/context/*.template "$SCRIPT_DIR"/data/skills/*.template; do - [ -f "$tmpl" ] && incus file push "$tmpl" "$INSTANCE/app/data/$(dirname "${tmpl#$SCRIPT_DIR/data/"}")/$(basename "$tmpl")" + [ -f "$tmpl" ] || continue + relative_path="${tmpl#"$SCRIPT_DIR"/}" + incus file push "$tmpl" "$INSTANCE/app/$relative_path" done # Install Python dependencies echo "Installing Python dependencies..." incus exec "$INSTANCE" -- bash -c " # .[pdf] — see packaging/postinstall.sh: analyze_pdf needs PyMuPDF. - cd /app && pip install --no-cache-dir --break-system-packages '.[pdf]' > /dev/null 2>&1 + cd /app && pip install --no-cache-dir --break-system-packages '.[pdf,browser]' > /dev/null 2>&1 " # Set ownership diff --git a/scripts/odin-cli.py b/scripts/odin-cli.py index 75d114118..9f26deb27 100755 --- a/scripts/odin-cli.py +++ b/scripts/odin-cli.py @@ -98,8 +98,9 @@ def main() -> int: tools = data.get("tools_used", []) if tools and not response: print(f"Tools used: {', '.join(tools)}") - if data.get("is_error"): - return 1 + # Machine-readable output changes presentation, not the process outcome. + if data.get("is_error"): + return 1 return 0 diff --git a/scripts/odin-server b/scripts/odin-server new file mode 100755 index 000000000..590a1dfb3 --- /dev/null +++ b/scripts/odin-server @@ -0,0 +1,2 @@ +#!/bin/sh +exec /opt/odin/.venv/bin/python -m src "$@" diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 000000000..1dc118e6f --- /dev/null +++ b/src/cli.py @@ -0,0 +1,41 @@ +"""HTTP client CLI entrypoint (distinct from the ``odin-server`` daemon).""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + + +def main() -> int: + parser = argparse.ArgumentParser(description="Send a prompt to an Odin API.") + parser.add_argument("prompt", nargs="?") + parser.add_argument("--url", default=os.environ.get("ODIN_URL", "http://localhost:3000")) + parser.add_argument("--token", default=os.environ.get("ODIN_API_TOKEN", "")) + parser.add_argument("--json", action="store_true", dest="json_output") + parser.add_argument("--timeout", type=int, default=600) + args = parser.parse_args() + prompt = args.prompt or (sys.stdin.read().strip() if not sys.stdin.isatty() else "") + if not prompt: + parser.print_help() + return 1 + headers = {"Content-Type": "application/json"} + if args.token: + headers["Authorization"] = f"Bearer {args.token}" + request = urllib.request.Request( + f"{args.url.rstrip('/')}/api/execute", + data=json.dumps({"prompt": prompt}).encode(), headers=headers, + ) + try: + with urllib.request.urlopen(request, timeout=args.timeout) as response: + data = json.loads(response.read().decode()) + except (urllib.error.URLError, urllib.error.HTTPError) as exc: + print(f"Connection/API error: {exc}", file=sys.stderr) + return 1 + if args.json_output: + print(json.dumps(data, indent=2)) + else: + print(data.get("response", "")) + return 1 if data.get("is_error") else 0 diff --git a/tests/test_packaging_campaign.py b/tests/test_packaging_campaign.py new file mode 100644 index 000000000..fcefdcf6b --- /dev/null +++ b/tests/test_packaging_campaign.py @@ -0,0 +1,53 @@ +"""Regression tests for shipped install manifests and executable contracts.""" +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +ROOT = Path(__file__).parents[1] + + +def test_wheel_declares_import_time_model_catalogue_resource(): + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + assert "model_hints_seed.json" in project["tool"]["setuptools"]["package-data"]["src.tools"] + assert (ROOT / "src/tools/model_hints_seed.json").is_file() + + +def test_official_python_install_paths_include_browser_dependency(): + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + assert "playwright" in project["project"]["optional-dependencies"]["browser"][0] + assert "browser" in (ROOT / "Dockerfile").read_text() + assert "browser" in (ROOT / "packaging/postinstall.sh").read_text() + incus = (ROOT / "scripts/incus-deploy.sh").read_text() + assert "ui\" \"$INSTANCE/app/" in incus + assert "[pdf,browser]" in incus + + +def test_packaged_cli_names_are_distinct_and_scripts_exist(): + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + scripts = project["project"]["scripts"] + assert scripts["odin"] != scripts["odin-client"] + assert scripts["odin-server"] != scripts["odin-client"] + package = (ROOT / "packaging/nfpm.yml").read_text() + assert "dst: /usr/local/bin/odin\n" in package + assert "/usr/local/bin/odin-client" in package + assert "/usr/local/bin/odin-server" in package + assert (ROOT / "scripts/odin-server").is_file() + + +def test_compose_persists_config_as_directory_and_has_no_removed_voice_tree(): + compose = (ROOT / "docker-compose.yml").read_text() + assert "./config:/app/config" in compose + assert ":ro" not in next(line for line in compose.splitlines() if "./config:" in line) + assert "voice-service" not in compose + + +def test_ui_guards_are_required_by_aggregate_check_and_workflow(): + package = json.loads((ROOT / "package.json").read_text()) + check = package["scripts"]["check"] + for name in ("check:setup-ui", "check:computer", "check:schedule-availability"): + assert f"npm run {name}" in check + assert name in package["scripts"] + workflow = (ROOT / ".github/workflows/ui.yml").read_text() + assert "npm run check" in workflow diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py index 727ca31a1..7e4623948 100644 --- a/tests/test_rate_limiter.py +++ b/tests/test_rate_limiter.py @@ -79,17 +79,20 @@ async def test_stale_ip_keys_evicted(self): with patch("src.health.server.time") as mock_time: mock_time.monotonic.return_value = base_time - async with TestClient(TestServer(_make_app())) as client: - # Make a request — this creates an entry in _buckets - resp = await client.get("/api/test") - assert resp.status == 200 - - # Advance time past the window AND past the eviction interval (300s) - mock_time.monotonic.return_value = base_time + _RATE_LIMIT_WINDOW + 301 - - # This request triggers periodic eviction of stale keys - resp = await client.get("/api/test") - assert resp.status == 200 + app = web.Application(middlewares=[_make_rate_limit_middleware(("127.0.0.1",))]) + + async def ok(_request): + return web.Response(text="ok") + app.router.add_get("/api/test", ok) + async with TestClient(TestServer(app)) as client: + await client.get("/api/test", headers={"X-Forwarded-For": "198.51.100.1"}) + await client.get("/api/test", headers={"X-Forwarded-For": "198.51.100.2"}) + mock_time.monotonic.return_value = base_time + 302 + response = await client.get("/api/test", headers={"X-Forwarded-For": "198.51.100.2"}) + assert response.status == 200 + buckets = app.middlewares[0].__closure__[0].cell_contents + assert "198.51.100.1" not in buckets + assert "198.51.100.2" in buckets async def test_rate_limit_resets_after_window(self): """After the rate-limit window passes, requests should succeed again.""" @@ -128,12 +131,10 @@ async def test_eviction_only_removes_stale_not_active(self): resp = await client.get("/api/test") assert resp.status == 200 - # Advance past eviction interval but keep timestamps within window - mock_time.monotonic.return_value = base_time + 301 + # Keep the timestamp within the 60-second window; no eviction + # is due, so this client's bucket remains a recent bucket. + mock_time.monotonic.return_value = base_time + 30 - # Request should still succeed (timestamps still in window since - # _RATE_LIMIT_WINDOW is 60s, but 301 > 60 so they ARE stale) - # The key point: this request itself creates a fresh entry, so - # the IP is not evicted + # Request succeeds and the fresh activity remains recorded. resp = await client.get("/api/test") assert resp.status == 200 From cc1512f6fd01961ff4658b4915a8b08187fd71e7 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:15:34 -0400 Subject: [PATCH 049/162] Preserve legacy Compose config on upgrade --- Dockerfile | 1 + docker-compose.yml | 5 ++++- results/packaging.md | 4 ++-- scripts/docker-compose-entrypoint.sh | 18 +++++++++++++++++ tests/test_packaging_campaign.py | 29 ++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 3 deletions(-) create mode 100755 scripts/docker-compose-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 08a0018fe..fe7fc2bbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN pip install --no-cache-dir ".[pdf,browser]" # Copy application source COPY src/ src/ COPY ui/ ui/ +COPY scripts/docker-compose-entrypoint.sh /app/docker-compose-entrypoint.sh # Working directory for local user commands (tools.local_working_dir). # Deliberately OUTSIDE the install root (/app here) and outside the data dir: diff --git a/docker-compose.yml b/docker-compose.yml index 0e09c9978..c6afd7062 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - OLLAMA_URL=${OLLAMA_URL:-http://host.docker.internal:11434} volumes: - ./config:/app/config + - ./config.yml:/app/config.yml:ro - ./data:/app/data # NAMED volume, not a bind mount: Docker initializes a named volume from # the image directory, preserving the 0700 ownership the resolver @@ -23,7 +24,9 @@ services: - "127.0.0.1:3939:3000" networks: - default - command: ["sh", "-c", "if [ -f /app/config/config.yml ]; then exec python -m src /app/config/config.yml; else exec python -m src; fi"] + # Existing deployments keep their old config.yml bind until the entrypoint + # copies it once into the writable directory mount. Never overwrite there. + command: ["sh", "/app/docker-compose-entrypoint.sh"] odin-browser: image: ghcr.io/browserless/chromium diff --git a/results/packaging.md b/results/packaging.md index 85c692f89..8beff44ff 100644 --- a/results/packaging.md +++ b/results/packaging.md @@ -23,9 +23,9 @@ Regression coverage: `tests/test_packaging_campaign.py`, `tests/test_rate_limite - `python -m json.tool package.json`: passed. - No deployment, install, restart, destructive command, UI build, or full campaign gate was run. -## Question / caveat +## #494 existing Compose upgrade compatibility -- **#494 existing Compose upgrades:** the new directory mount expects operator config at `./config/config.yml`; prior Compose mounts used `./config.yml`. The shipped compose entrypoint has a fallback to the image config, but an existing operator config at the old path will not be selected automatically. Please decide whether to add an explicit documented migration or provide a compatibility-safe mount layout before integrating. No config is overwritten by this change. +Resolved with `scripts/docker-compose-entrypoint.sh`: the Compose file keeps the previous `./config.yml` read-only bind and adds the writable directory bind. On startup, if the new directory config is absent and the legacy config exists, the entrypoint copies it with metadata preservation. It never overwrites an existing directory config, then starts the server against the directory config so atomic persistence works. Regression test uses temporary fixture paths and a fake `python` executable, not a running service. ## Commit diff --git a/scripts/docker-compose-entrypoint.sh b/scripts/docker-compose-entrypoint.sh new file mode 100755 index 000000000..ededa96a2 --- /dev/null +++ b/scripts/docker-compose-entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +config_dir="${ODIN_COMPOSE_CONFIG_DIR:-/app/config}" +legacy_config="${ODIN_COMPOSE_LEGACY_CONFIG:-/app/config.yml}" +target="$config_dir/config.yml" + +mkdir -p "$config_dir" +if [ ! -e "$target" ] && [ -f "$legacy_config" ]; then + # One-time, non-overwriting migration enables atomic config replacement + # through the writable directory mount without discarding legacy settings. + cp -p "$legacy_config" "$target" +fi + +if [ -f "$target" ]; then + exec python -m src "$target" +fi +exec python -m src diff --git a/tests/test_packaging_campaign.py b/tests/test_packaging_campaign.py index fcefdcf6b..164fc0e28 100644 --- a/tests/test_packaging_campaign.py +++ b/tests/test_packaging_campaign.py @@ -2,6 +2,8 @@ from __future__ import annotations import json +import os +import subprocess import tomllib from pathlib import Path @@ -39,8 +41,35 @@ def test_packaged_cli_names_are_distinct_and_scripts_exist(): def test_compose_persists_config_as_directory_and_has_no_removed_voice_tree(): compose = (ROOT / "docker-compose.yml").read_text() assert "./config:/app/config" in compose + assert "./config.yml:/app/config.yml:ro" in compose assert ":ro" not in next(line for line in compose.splitlines() if "./config:" in line) assert "voice-service" not in compose + assert "docker-compose-entrypoint.sh" in compose + + +def test_compose_legacy_config_migrates_once_without_overwriting_existing(tmp_path): + config_dir = tmp_path / "config" + config_dir.mkdir() + legacy = tmp_path / "config.yml" + legacy.write_text("web:\n port: 3001\n") + bindir = tmp_path / "bin" + bindir.mkdir() + python = bindir / "python" + python.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') + python.chmod(0o755) + env = os.environ | { + "ODIN_COMPOSE_CONFIG_DIR": str(config_dir), + "ODIN_COMPOSE_LEGACY_CONFIG": str(legacy), + "PATH": f"{bindir}:{os.environ['PATH']}", + } + script = ROOT / "scripts/docker-compose-entrypoint.sh" + first = subprocess.run(["sh", str(script)], env=env, text=True, capture_output=True, check=True) + target = config_dir / "config.yml" + assert target.read_text() == legacy.read_text() + assert first.stdout.splitlines() == ["-m", "src", str(target)] + target.write_text("operator edit\n") + subprocess.run(["sh", str(script)], env=env, text=True, capture_output=True, check=True) + assert target.read_text() == "operator edit\n" def test_ui_guards_are_required_by_aggregate_check_and_workflow(): From 2ef93a07a1b031bbb22a3f87ae835adbb23803c4 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:20:20 -0400 Subject: [PATCH 050/162] Fix provider settlement, model dispatch, accounting and reload lifecycle regressions --- src/agents/manager.py | 6 + src/discord/background_task.py | 13 + src/discord/intake_pipeline.py | 17 +- src/discord/llm_gateway.py | 69 ++- src/discord/native_tools/agents_tasks.py | 6 +- src/discord/tool_loop.py | 28 + src/discord/wiring.py | 3 +- src/llm/auxiliary.py | 35 +- src/llm/circuit_breaker.py | 40 +- src/llm/client_lifecycle.py | 14 +- src/llm/errors.py | 12 + src/llm/model_breaker.py | 6 +- src/llm/ollama.py | 31 +- src/llm/openai_codex.py | 22 +- src/llm/openai_compatible.py | 31 +- src/llm/openrouter.py | 16 +- src/llm/recovery.py | 20 + src/llm/types.py | 15 + src/tools/agent_tool_policy.py | 7 +- src/web/api/_agent_display.py | 45 +- tests/test_agents_tasks_provider_paths.py | 2 +- tests/test_llm_gateway.py | 5 +- tests/test_native_agents_tasks.py | 2 +- tests/test_openai_codex_client.py | 6 +- tests/test_openrouter.py | 4 +- tests/test_provider_campaign_regressions.py | 534 ++++++++++++++++++++ tests/test_web_api_agents_loops.py | 10 +- 27 files changed, 914 insertions(+), 85 deletions(-) create mode 100644 tests/test_provider_campaign_regressions.py diff --git a/src/agents/manager.py b/src/agents/manager.py index 681a55dd7..d94ee24f6 100644 --- a/src/agents/manager.py +++ b/src/agents/manager.py @@ -1555,6 +1555,12 @@ def _check_lifetime() -> bool: text = content_text(response.get("text", "")) tool_calls = normalize_tool_calls(response.get("tool_calls", [])) + if response.get("stop_reason") == "incomplete": + agent.result = text + agent.error = "Provider marked this response incomplete; partial output retained." + agent.transition(AgentState.FAILED, agent.error) + agent.ended_at = time.time() + return context_density, context_density_source, context_primary_chars = _budget_observation( generation_state ) diff --git a/src/discord/background_task.py b/src/discord/background_task.py index 102667c55..6618514d9 100644 --- a/src/discord/background_task.py +++ b/src/discord/background_task.py @@ -903,6 +903,19 @@ async def _send_conversational_followup( await task.channel.send(response) except Exception as e: log.warning("Failed to generate conversational follow-up for task %s: %s", task.task_id, e) + from ..llm.errors import LLMIncompleteResponseError + + if isinstance(e, LLMIncompleteResponseError) and not task._cancel_event.is_set(): + import io + + partial = scrub_output_secrets(e.partial_text) + notice = "[Provider marked this response incomplete.]" + if len(partial) + len(notice) + 2 <= 2000: + await task.channel.send(partial + "\n\n" + notice) + else: + await task.channel.send(notice, file=discord.File( + io.BytesIO(partial.encode("utf-8")), filename="incomplete-response.txt", + )) def create_task_id() -> str: diff --git a/src/discord/intake_pipeline.py b/src/discord/intake_pipeline.py index b60a4d7ea..da28c598c 100644 --- a/src/discord/intake_pipeline.py +++ b/src/discord/intake_pipeline.py @@ -541,7 +541,7 @@ async def _run_inner( query=content, ) try: - response = await self._llm_gateway.active_client.chat( + response = await self._llm_gateway.chat( messages=history, system=chat_prompt, ) @@ -551,6 +551,12 @@ async def _run_inner( except Exception as e: log.warning("LLM chat failed: %s", e) response = "Chat is temporarily unavailable. Please try again in a moment." + from ..llm.errors import LLMIncompleteResponseError + + if isinstance(e, LLMIncompleteResponseError): + response = ( + e.partial_text + "\n\n[Provider marked this response incomplete.]" + ) is_error = True else: log.info("No chat backend configured for guest user") @@ -697,7 +703,7 @@ async def _run_inner( }, ] try: - response = await self._llm_gateway.active_client.chat( + response = await self._llm_gateway.chat( messages=codex_messages, system=chat_prompt, ) @@ -708,6 +714,13 @@ async def _run_inner( except Exception as e: log.warning("Codex handoff failed, using skill result directly: %s", e) response = _skill_response + from ..llm.errors import LLMIncompleteResponseError + + if isinstance(e, LLMIncompleteResponseError): + response = ( + e.partial_text + "\n\n[Provider marked this response incomplete.]" + ) + is_error = True already_sent = False except (TimeoutError, discord.HTTPException, discord.Forbidden) as e: # Same raw-interpolation disease as the tool-loop catch: str() on diff --git a/src/discord/llm_gateway.py b/src/discord/llm_gateway.py index db169c1fb..df1e625b0 100644 --- a/src/discord/llm_gateway.py +++ b/src/discord/llm_gateway.py @@ -295,6 +295,10 @@ def notify_generation_success(self, provider: str | None) -> None: def wire_callbacks(self) -> None: """Attach LLM-backed compaction and reflection callbacks using the active provider.""" + if self.auxiliary_llm_client is not None: + primary = self.capture_serving_identity() + self.auxiliary_llm_client.primary_client = primary.client + self.auxiliary_llm_client.primary_model = primary.model # Compaction emits a segment of up to ~2500 chars (≈625 tokens) plus # structured header lines; reflection emits multi-lesson JSON. The old @@ -312,10 +316,7 @@ async def _named_task(task: str, messages: list[dict], system: str, max_tokens: aux = self.auxiliary_llm_client if aux is not None: return await aux.chat(messages, system, task=task, max_tokens=max_tokens) - client = self.active_client - if not client: - raise RuntimeError("No LLM provider configured") - return await client.chat(messages=messages, system=system, max_tokens=max_tokens) + return await self.chat(messages=messages, system=system, max_tokens=max_tokens) async def _llm_compaction(messages: list[dict], system: str) -> str: return await _named_task("compaction", messages, system, 1500) @@ -344,13 +345,26 @@ def _reconcile_auxiliary_primary(self) -> None: probes the configured wrapper. """ aux = self.auxiliary_llm_client + primary = self.capture_serving_identity() + if aux is not None: + # Existing calls already captured and leased their old fallback. + aux.primary_client = primary.client + aux.primary_model = primary.model # Compatible/Ollama auxiliary wrappers borrow the provider transport. # A provider reload retires that concrete generation, so the wrapper - # must be removed rather than retaining a stale client. + # must be rebuilt before retiring the stale wrapper. if aux is not None and getattr(aux, "provider", "codex") != "codex": provider_client = self._provider_client(getattr(aux, "provider", "")) if getattr(aux, "aux_client", None) is not provider_client: - self.auxiliary_llm_client = None + from ..llm.auxiliary import AuxiliaryLLMClient + + self.auxiliary_llm_client = ( + AuxiliaryLLMClient( + provider_client, primary.client, self.cost_tracker, + provider=aux.provider, model=aux.model, owns_aux_client=False, + primary_model=primary.model, + ) if provider_client is not None else None + ) self._schedule_drain(aux) return if self.codex_client is None and getattr(aux, "provider", "codex") == "codex": @@ -359,11 +373,6 @@ def _reconcile_auxiliary_primary(self) -> None: self._schedule_drain(aux) return if aux is not None: - if ( - getattr(aux, "provider", "codex") == "codex" - and getattr(aux, "primary_client", None) is not self.codex_client - ): - aux.primary_client = self.codex_client return # Primary present, no live wrapper: build it if configured+enabled. # reload_auxiliary is self-locking — schedule it so it runs AFTER this @@ -574,6 +583,7 @@ def _build_aux_candidate(self, desired: dict, primary, build: _AuxBuildInputs, s provider=serving.provider, model=serving.model, owns_aux_client=False, + primary_model=self.capture_serving_identity().model, ), probe_client aux_auth = primary.auth if not aux_auth.is_configured(): @@ -607,6 +617,10 @@ async def _probe_aux(self, client) -> str | None: await client.chat([{"role": "user", "content": "ok"}], "", max_tokens=1) return None except Exception as exc: + if isinstance(exc, LLMRequestError) and exc.code in { + "output_truncated", "empty_response", + }: + return None # Only this deliberately tiny qualification request. return f"model probe failed: {type(exc).__name__}" async def run_persist_settled(self, persist_sync): @@ -897,6 +911,8 @@ async def reload_openai_compatible_inner(self) -> dict: return {"configured": False, "reason": "openai-compatible disabled in config"} if not cfg.api_key: return {"configured": False, "reason": "openai-compatible api_key not set"} + from ..llm.openai_compatible import preset_context_overflow_pattern + candidate = OpenAICompatibleClient( api_key=cfg.api_key, model=cfg.model, @@ -906,6 +922,9 @@ async def reload_openai_compatible_inner(self) -> dict: request_timeout_seconds=cfg.request_timeout_seconds, stream_stall_timeout_seconds=cfg.stream_stall_timeout_seconds, tool_quirks=self._compatible_quirks(cfg), + context_overflow_pattern=preset_context_overflow_pattern( + getattr(cfg, "preset", "custom") + ), reasoning_dialect=self._compatible_reasoning_dialect(cfg), glm_clear_thinking=getattr(cfg, "glm_clear_thinking", None), reasoning_content_feedback_policy=getattr( @@ -918,11 +937,16 @@ async def reload_openai_compatible_inner(self) -> dict: ), model_profiles=getattr(cfg, "model_profiles", None), ) - reason = await self._probe_openai_compatible(candidate) - if reason: - self._schedule_client_drain(candidate) - return {"configured": self.compatible_client is not None, "reason": reason} - old, self.compatible_client = self.compatible_client, candidate + installed = False + try: + reason = await self._probe_openai_compatible(candidate) + if reason: + return {"configured": self.compatible_client is not None, "reason": reason} + old, self.compatible_client = self.compatible_client, candidate + installed = True + finally: + if not installed: + self._schedule_client_drain(candidate) self._reconcile_auxiliary_primary() if old: self._schedule_client_drain(old) @@ -980,6 +1004,7 @@ async def switch_provider( provider_config.active_provider = provider if model_ref is not None: provider_config.model = model_ref + self._reconcile_auxiliary_primary() self.wire_callbacks() if self.on_provider_switch is not None: self.on_provider_switch() @@ -1020,6 +1045,16 @@ async def switch_provider( # ---------- guarded call -------------------------------------------------- + async def chat(self, messages, system, max_tokens=None): + """Direct-text lane with the same canonical selection as tool dispatch.""" + serving = self.capture_serving_identity() + if serving.client is None: + raise RuntimeError("No LLM provider configured") + kwargs = {"max_tokens": max_tokens} + if serving.model: + kwargs["model"] = serving.model + return await serving.client.chat(messages=messages, system=system, **kwargs) + async def call_with_tools( self, *, @@ -1070,6 +1105,8 @@ async def call_with_tools( # cached learned block in the physical request. if system_provider is not None: system = system_provider() + if serving.model: + kwargs.setdefault("model", serving.model) resp = await client.chat_with_tools( messages=messages, system=system, tools=tools, **kwargs ) diff --git a/src/discord/native_tools/agents_tasks.py b/src/discord/native_tools/agents_tasks.py index 4c1ce1824..a31ff1c3f 100644 --- a/src/discord/native_tools/agents_tasks.py +++ b/src/discord/native_tools/agents_tasks.py @@ -841,7 +841,7 @@ async def _codex_followup(messages: list[dict], system: str, max_tokens: int) -> return await aux.chat( messages, system, task="background_followup", max_tokens=max_tokens ) - return await self._llm_gateway.active_client.chat( + return await self._llm_gateway.chat( messages=messages, system=system, max_tokens=max_tokens, @@ -1118,7 +1118,9 @@ async def _attempt(): request_kwargs["apply_reasoning"] = reasoning_capable return await client.chat_with_tools(**request_kwargs) - resp = await generate_with_recovery(_attempt, policy=policy, breaker=breaker) + resp = await generate_with_recovery( + _attempt, policy=policy, breaker=breaker, generation_client=client, + ) # Bypass-path success clears a latched llm_* guard key — provenance # only, never the post-await active provider. self._llm_gateway.notify_generation_success(getattr(resp, "provenance_provider", None)) diff --git a/src/discord/tool_loop.py b/src/discord/tool_loop.py index 84471a466..82af8db5b 100644 --- a/src/discord/tool_loop.py +++ b/src/discord/tool_loop.py @@ -520,6 +520,7 @@ class _LoopTurn: pending_image_blocks: list = field(default_factory=list) final_text: str = "" completed_naturally: bool = False # True only when a tool-free turn ended the loop + provider_incomplete: bool = False tool_calls_made: int = 0 # Context-budget campaign (phase 4): the surface boundary for emergency # recovery (prev_context replay elidable, current prompt protected), the @@ -1893,6 +1894,8 @@ def _on_wait(wait: float, remaining: float, error: BaseException) -> None: # live-reloadable in place, so merely retaining the object is not an # identity freeze. pin_kwargs = {} + if serving_identity.model: + pin_kwargs["model"] = serving_identity.model if serving_identity.is_codex: if serving_identity.model: pin_kwargs["model"] = serving_identity.model @@ -2011,6 +2014,7 @@ async def _attempt(): try: llm_resp = await generate_with_recovery( _attempt, + generation_client=serving_identity.client, policy=policy, breaker=breaker, deadline_seconds=( @@ -2517,6 +2521,17 @@ async def _finalize_or_retry(self, st: _ChatTurn, llm_resp): """ if st._cancel.is_set(): return ("done", self._stopped(st, "before_validation")) + if llm_resp.stop_reason == "incomplete": + # Preserve every byte of the accepted output. No automatic retry, + # guard budget, or successful trajectory can erase its settlement. + final = (llm_resp.text or "") + "\n\n[Provider marked this response incomplete.]" + self._channel_state.close_steer_inbox(st._ch_id, st._req_id) + await self._turn_recorder._save_turn_trajectory( + st._trajectory, error=final, + tools_used=st.tools_used_in_loop, trace=st.trace, + ) + self._clear_active(st) + return ("done", (final, False, True, st.tools_used_in_loop, False)) # Enforce pending validation before allowing final response if st._validation_required and st._validation_retries < st._max_validation_retries: st._validation_retries += 1 @@ -3679,6 +3694,8 @@ async def _call_loop_llm( policy = self._llm_gateway.recovery_policy() pin_kwargs = {} + if serving_identity.model: + pin_kwargs["model"] = serving_identity.model if serving_identity.is_codex: if serving_identity.model: pin_kwargs["model"] = serving_identity.model @@ -3745,6 +3762,7 @@ async def _attempt(): ) response = await generate_with_recovery( _attempt, + generation_client=serving_identity.client, policy=policy, breaker=breaker, retry_circuit_open=False, @@ -3907,6 +3925,10 @@ def _record_loop_iteration(self, st: _LoopTurn, response, _iteration: int) -> bo if response.text: st.final_text = response.text + if getattr(response, "stop_reason", None) == "incomplete": + st.provider_incomplete = True + st.completed_naturally = False + return True if not response.tool_calls: st.completed_naturally = True return True @@ -4122,6 +4144,12 @@ async def _execute_loop_tools(self, st: _LoopTurn, response) -> list: async def _finalize_loop(self, st: _LoopTurn) -> str: """Loop exits: natural completion, cap exhaustion, or no response. Scrub final text; posting is handled by _post_response in LoopManager.""" + if getattr(st, "provider_incomplete", False): + return await self._finish_loop( + st, st.final_text + "\n\n[Provider marked this response incomplete.]", + is_error=True, failure_class="llm_incomplete", + error_text=st.final_text + "\n\n[Provider marked this response incomplete.]", + ) # Only treat final_text as a clean success when the loop ended NATURALLY # (a tool-free response). If we fell out by exhausting the cap, any # final_text is stale pre-tool text from some earlier iteration — diff --git a/src/discord/wiring.py b/src/discord/wiring.py index c85ad650b..90a9a1172 100644 --- a/src/discord/wiring.py +++ b/src/discord/wiring.py @@ -378,7 +378,7 @@ def build_services( compatible_client: OpenAICompatibleClient | None = None compat_cfg = getattr(config, "openai_compatible", None) if compat_cfg and compat_cfg.enabled and compat_cfg.api_key: - from ..llm.openai_compatible import KIMI_TOOL_ENFORCEMENT + from ..llm.openai_compatible import KIMI_TOOL_ENFORCEMENT, preset_context_overflow_pattern quirks = {} if compat_cfg.preset == "kimi": @@ -399,6 +399,7 @@ def build_services( request_timeout_seconds=compat_cfg.request_timeout_seconds, stream_stall_timeout_seconds=compat_cfg.stream_stall_timeout_seconds, tool_quirks=quirks, + context_overflow_pattern=preset_context_overflow_pattern(compat_cfg.preset), reasoning_dialect=compatible_reasoning_dialect(compat_cfg), glm_clear_thinking=getattr(compat_cfg, "glm_clear_thinking", None), reasoning_content_feedback_policy=getattr( diff --git a/src/llm/auxiliary.py b/src/llm/auxiliary.py index 4c0a51245..a4260ee34 100644 --- a/src/llm/auxiliary.py +++ b/src/llm/auxiliary.py @@ -18,6 +18,7 @@ from ..odin_log import get_logger from .circuit_breaker import CircuitOpenError from .cost_tracker import CostTracker +from .errors import LLMIncompleteResponseError if TYPE_CHECKING: pass @@ -47,9 +48,11 @@ def __init__( provider: str = "codex", model: str | None = None, owns_aux_client: bool = True, + primary_model: str | None = None, ) -> None: self.aux_client = aux_client self.primary_client = primary_client + self.primary_model = primary_model self.cost_tracker = cost_tracker self.provider = provider self.model = model or getattr(aux_client, "model", None) @@ -106,9 +109,14 @@ async def chat( # A reload may rebind the wrapper while the cheap call is pending. # Its fallback belongs to this call's captured primary generation. primary = self.primary_client + primary_model = self.primary_model lease = getattr(primary, "generation_lease", None) - async with lease() if lease else contextlib.nullcontext(): - return await self._chat_aux(messages, system, task, max_tokens, primary) + aux_lease = getattr(self.aux_client, "generation_lease", None) + async with aux_lease() if aux_lease else contextlib.nullcontext(): + async with lease() if lease else contextlib.nullcontext(): + return await self._chat_aux( + messages, system, task, max_tokens, primary, primary_model, + ) async def _chat_aux( self, @@ -117,6 +125,7 @@ async def _chat_aux( task: str, max_tokens: int | None, primary_client=None, + primary_model=None, ) -> str: try: kwargs: dict[str, Any] = {"max_tokens": max_tokens} @@ -125,9 +134,12 @@ async def _chat_aux( result = await self.aux_client.chat(messages, system, **kwargs) if result: self._aux_calls += 1 - self._track_cost(task, client=self.aux_client) + self._track_cost(task, client=self.aux_client, model=self.model, result=result) return result log.warning("Auxiliary LLM returned empty response for %s, falling back", task) + except LLMIncompleteResponseError: + # An accepted partial generation must not be retried away via fallback. + raise except CircuitOpenError: log.warning("Auxiliary LLM circuit open for %s, falling back", task) except Exception as exc: @@ -135,8 +147,11 @@ async def _chat_aux( self._fallback_calls += 1 primary = primary_client if primary_client is not None else self.primary_client - result = await primary.chat(messages, system, max_tokens=max_tokens) - self._track_cost(task, client=primary) + kwargs = {"max_tokens": max_tokens} + if primary_model: + kwargs["model"] = primary_model + result = await primary.chat(messages, system, **kwargs) + self._track_cost(task, client=primary, model=primary_model, result=result) return result def make_chat_fn(self, task: str): @@ -178,13 +193,15 @@ async def close(self) -> None: if self.owns_aux_client: await self.aux_client.close() - def _track_cost(self, task: str, *, client: Any) -> None: + def _track_cost(self, task: str, *, client: Any, model: str | None = None, result=None) -> None: if self.cost_tracker is None: return self.cost_tracker.record( - input_tokens=getattr(client, "_last_input_tokens", 0), - output_tokens=getattr(client, "_last_output_tokens", 0), - model=client.model, + input_tokens=getattr(result, "input_tokens", getattr(client, "_last_input_tokens", 0)), + output_tokens=getattr( + result, "output_tokens", getattr(client, "_last_output_tokens", 0) + ), + model=getattr(result, "model", None) or model or client.model, user_id=f"auxiliary:{task}", channel_id="system", ) diff --git a/src/llm/circuit_breaker.py b/src/llm/circuit_breaker.py index 334315866..fbb96212f 100644 --- a/src/llm/circuit_breaker.py +++ b/src/llm/circuit_breaker.py @@ -15,8 +15,29 @@ """ from __future__ import annotations +import asyncio import threading import time +from functools import wraps + + +def breaker_call(method): + """Settle probe ownership even when a transport is cancelled or rejects locally.""" + @wraps(method) + async def call(self, *args, **kwargs): + self.breaker.check() + try: + return await method(self, *args, **kwargs) + finally: + self.breaker.abandon() + return call + + +def _caller(): + try: + return asyncio.current_task() or threading.current_thread() + except RuntimeError: + return threading.current_thread() class CircuitOpenError(Exception): @@ -61,6 +82,7 @@ def __init__( self._failure_count = 0 self._last_failure_time = 0.0 self._state = "closed" + self._probe_owner = None self._lock = threading.Lock() @property @@ -85,18 +107,34 @@ def check(self) -> None: if elapsed < self.recovery_timeout: remaining = self.recovery_timeout - elapsed raise CircuitOpenError(self.name, max(0.0, remaining)) - # Recovery timeout passed — allow probe (half_open) + self._state = "half_open" + self._probe_owner = _caller() + elif current == "half_open": + raise CircuitOpenError(self.name, self.recovery_timeout) + + def abandon(self) -> None: + """Release only this caller's probe, without counting a provider failure.""" + with self._lock: + if self._state == "half_open" and self._probe_owner is _caller(): + self._probe_owner = None + self._state = "open" # elapsed timer allows the next probe immediately def record_success(self) -> None: """Record a successful API call. Resets failure count, closes breaker.""" with self._lock: + if self._state == "half_open" and self._probe_owner is not _caller(): + return self._failure_count = 0 self._state = "closed" + self._probe_owner = None def record_failure(self) -> None: """Record a failed API call. Opens breaker after threshold is reached.""" with self._lock: + if self._state == "half_open" and self._probe_owner is not _caller(): + return self._failure_count += 1 self._last_failure_time = time.monotonic() if self._failure_count >= self.failure_threshold: self._state = "open" + self._probe_owner = None diff --git a/src/llm/client_lifecycle.py b/src/llm/client_lifecycle.py index 4aeeed617..dd8d3d2d4 100644 --- a/src/llm/client_lifecycle.py +++ b/src/llm/client_lifecycle.py @@ -2,14 +2,16 @@ import asyncio from contextlib import asynccontextmanager +from contextvars import ContextVar from functools import wraps from ..odin_log import get_logger -from .errors import LLMTransportError +from .errors import LLMClientRetiredError log = get_logger("client_lifecycle") SHUTDOWN_DRAIN_SECONDS = 3.0 SHUTDOWN_CLOSE_SECONDS = 1.0 +_leased_generations: ContextVar[tuple] = ContextVar("leased_generations", default=()) def _observe_shutdown_task(task): @@ -98,14 +100,20 @@ def _lifecycle_state(self): async def generation_lease(self): self._lifecycle_state() owner = asyncio.current_task() - if self._generation_retired and owner not in self._generation_owners: - raise LLMTransportError("Provider generation retired; capture the current client") + inherited = any( + client is self and parent in self._generation_owners + for client, parent in _leased_generations.get() + ) + if self._generation_retired and owner not in self._generation_owners and not inherited: + raise LLMClientRetiredError("Provider generation retired; capture the current client") + context_token = _leased_generations.set((*_leased_generations.get(), (self, owner))) self._generation_owners[owner] = self._generation_owners.get(owner, 0) + 1 self._generation_inflight += 1 self._generation_idle.clear() try: yield finally: + _leased_generations.reset(context_token) self._generation_owners[owner] -= 1 if not self._generation_owners[owner]: del self._generation_owners[owner] diff --git a/src/llm/errors.py b/src/llm/errors.py index 2309c3447..4fd584e4e 100644 --- a/src/llm/errors.py +++ b/src/llm/errors.py @@ -121,3 +121,15 @@ class LLMContextLengthError(LLMRequestError): """A provider positively identified a context-window overflow.""" retryable = False + + +class LLMIncompleteResponseError(LLMRequestError): + """Provider-declared partial output, retained verbatim and never replayed.""" + + def __init__(self, message: str, *, partial_text: str = "", **kwargs) -> None: + super().__init__(message, **kwargs) + self.partial_text = partial_text + + +class LLMClientRetiredError(LLMRequestError): + """Local generation retirement, not an upstream transport failure.""" diff --git a/src/llm/model_breaker.py b/src/llm/model_breaker.py index bb853797a..48d47dfb5 100644 --- a/src/llm/model_breaker.py +++ b/src/llm/model_breaker.py @@ -15,8 +15,7 @@ exactly once when a whole generation's recovery budget is exhausted. - **True single-probe half-open.** When the cooldown elapses, exactly one caller is admitted as the probe; everyone else keeps waiting until the - probe resolves. (``CircuitBreaker.check()`` lets every concurrent caller - through after the timeout — documented gap.) + probe resolves. The transport breaker independently reserves its own probe. - **Adaptive cooldown.** Consecutive opens double the cooldown up to a cap, so a long outage probes progressively less often; any success resets it. @@ -27,6 +26,7 @@ from __future__ import annotations +import math import threading import time @@ -94,6 +94,8 @@ def _current_cooldown(self) -> float: # Lock held by caller. First open waits cooldown_base, each # consecutive re-open doubles it up to the cap. exponent = max(0, self._consecutive_opens - 1) + if exponent >= math.log2(self.cooldown_cap) - math.log2(self.cooldown_base): + return self.cooldown_cap return min(self.cooldown_cap, self.cooldown_base * (2.0**exponent)) def snapshot(self) -> dict: diff --git a/src/llm/ollama.py b/src/llm/ollama.py index 4da7f0036..f2425c49c 100644 --- a/src/llm/ollama.py +++ b/src/llm/ollama.py @@ -14,7 +14,7 @@ from ..odin_log import get_logger from .backoff import DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_MAX_RETRIES, compute_backoff -from .circuit_breaker import CircuitBreaker +from .circuit_breaker import CircuitBreaker, breaker_call from .client_lifecycle import leased_call from .cost_tracker import estimate_tokens from .errors import LLMRequestError, LLMTransportError @@ -183,11 +183,11 @@ def _convert_tools(self, tools: list[dict]) -> list[dict]: }) return ollama_tools + @breaker_call async def _request_with_retry(self, body: dict) -> dict: """Send a request to Ollama with retry logic.""" from ..observability.diagnostics import safe_error - self.breaker.check() session = await self._get_session() self._total_requests += 1 url = f"{self.base_url}/api/chat" @@ -259,7 +259,24 @@ async def chat( }, } data = await self._request_with_retry(body) - return data.get("message", {}).get("content", "") + response = self._parse_response(data) + self._last_input_tokens = response.input_tokens + self._last_output_tokens = response.output_tokens + served_model = data.get("model") + self._last_model = ( + served_model if isinstance(served_model, str) and served_model else model or self.model + ) + if response.stop_reason == "incomplete": + from .errors import LLMIncompleteResponseError + + raise LLMIncompleteResponseError( + "Ollama returned an incomplete response", partial_text=response.text, + provider="ollama", model=body["model"], code="output_truncated", + ) + from .types import ChatText + + return ChatText(response.text, model=self._last_model, + input_tokens=response.input_tokens, output_tokens=response.output_tokens) @leased_call async def chat_with_tools( @@ -310,6 +327,14 @@ def _parse_response(self, data: dict) -> LLMResponse: )) stop_reason = "tool_use" if tool_calls else "end_turn" + if data.get("done_reason") == "length" or data.get("done") is False: + stop_reason = "incomplete" + tool_calls = [] # Never execute calls from an unsettled response. + elif not text.strip() and not tool_calls: + from .errors import LLMRequestError + + raise LLMRequestError("Ollama returned an empty response", provider="ollama", + model=self.model, code="empty_response") raw_input_tokens = data.get("prompt_eval_count") raw_output_tokens = data.get("eval_count") diff --git a/src/llm/openai_codex.py b/src/llm/openai_codex.py index 84be54565..1eca09b55 100644 --- a/src/llm/openai_codex.py +++ b/src/llm/openai_codex.py @@ -12,7 +12,7 @@ from ..config.schema import effort_incompatibility_error from ..odin_log import get_logger from .backoff import DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_MAX_RETRIES, compute_backoff -from .circuit_breaker import CircuitBreaker +from .circuit_breaker import CircuitBreaker, breaker_call from .client_lifecycle import ClientLifecycle, leased_call from .codex_auth import CodexAuth, CodexAuthPool from .cost_tracker import estimate_tokens @@ -486,15 +486,18 @@ async def chat( messages: list[dict], system: str, max_tokens: int | None = None, + *, + model: str | None = None, ) -> str: """Send a chat request via the Codex backend API (streaming). The optional third argument is accepted for the shared provider interface. The Responses request shape is intentionally unchanged. """ - _reject_known_bad_pair(self.model, self.reasoning_effort) + resolved_model = model or self.model + _reject_known_bad_pair(resolved_model, self.reasoning_effort) body = { - "model": self.model, + "model": resolved_model, "instructions": system, "input": self._convert_messages(messages), "store": False, @@ -867,7 +870,7 @@ def observe(event): return await self._send_with_retries( body, observed_reader, - lambda r: not (r.text or r.tool_calls), + lambda r: r.stop_reason != "incomplete" and not (r.text or r.tool_calls), progress_observer=progress_observer, ) @@ -879,6 +882,7 @@ async def _stream_request(self, body: dict) -> str: lambda r: not r, ) + @breaker_call async def _send_with_retries( self, body: dict, @@ -900,7 +904,6 @@ async def _send_with_retries( concurrent traffic "whatever account is current when I take the lock" is frequently a different, healthy one. """ - self.breaker.check() session = await self._get_session() self._total_requests += 1 last_error = None @@ -1434,6 +1437,7 @@ async def _read_stream(self, resp: aiohttp.ClientResponse) -> str: """Read SSE stream and extract text content.""" text_parts = [] terminal_received = False + incomplete = False async for raw_line in resp.content: line = raw_line.decode("utf-8", errors="replace").strip() @@ -1474,6 +1478,7 @@ async def _read_stream(self, resp: aiohttp.ClientResponse) -> str: elif event_type == "response.incomplete": terminal_received = True + incomplete = True reason = ((event.get("response") or {}).get("incomplete_details") or {}).get( "reason" ) or "unknown" @@ -1502,6 +1507,13 @@ async def _read_stream(self, resp: aiohttp.ClientResponse) -> str: f"(partial_chars={sum(map(len, text_parts))})", error_code="unexpected_eof", ) + if incomplete: + from .errors import LLMIncompleteResponseError + + raise LLMIncompleteResponseError( + "Codex returned an incomplete response", partial_text="".join(text_parts), + provider="codex", model=self.model, code="output_truncated", + ) if not text_parts: log.warning("Codex stream returned 200 but produced no text content") return "" diff --git a/src/llm/openai_compatible.py b/src/llm/openai_compatible.py index 4d49a7af8..7c51d6f7a 100644 --- a/src/llm/openai_compatible.py +++ b/src/llm/openai_compatible.py @@ -12,7 +12,7 @@ from ..odin_log import get_logger from .backoff import DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY, DEFAULT_MAX_RETRIES, compute_backoff -from .circuit_breaker import CircuitBreaker +from .circuit_breaker import CircuitBreaker, breaker_call from .client_lifecycle import leased_call from .context_budget import canonical_compatible_model, compatible_request_output_tokens from .errors import LLMContextLengthError, LLMRateLimitError, LLMRequestError, LLMTransportError @@ -34,6 +34,11 @@ CONNECT_TIMEOUT_SECONDS = 30 +def preset_context_overflow_pattern(preset: str) -> str | None: + """Provider-specific structured error classification for production clients.""" + return _DEEPSEEK_CONTEXT_LIMIT_RE.pattern if preset == "deepseek" else None + + class CompatibleStreamError(Exception): """A 200 response whose SSE stream did not settle successfully.""" @@ -275,7 +280,14 @@ def _sanitize_schema(self, schema: dict) -> dict: for k, v in schema.items(): if k in self._MFJS_STRIP_KEYS: continue - if isinstance(v, dict): + if k in {"properties", "patternProperties", "dependentSchemas"} and isinstance(v, dict): + # These are name-indexed maps, not schema nodes. A parameter + # named "format" or "title" is data, not an annotation. + clean[k] = { + name: self._sanitize_schema(node) if isinstance(node, dict) else node + for name, node in v.items() + } + elif isinstance(v, dict): clean[k] = self._sanitize_schema(v) elif isinstance(v, list): clean[k] = [ @@ -407,6 +419,7 @@ def _apply_openrouter_routing(self, body: dict, *, has_tools: bool) -> None: has_reasoning="reasoning" in body, ) + @breaker_call async def _request_with_retry( self, body: dict, @@ -416,7 +429,6 @@ async def _request_with_retry( """Send a streaming request to the configured endpoint with retries.""" from ..observability.diagnostics import safe_error - self.breaker.check() session = await self._get_session() self._total_requests += 1 self._last_stream_usage_received = False @@ -889,7 +901,18 @@ async def chat( choices = data.get("choices", []) if not choices: return "" - return choices[0].get("message", {}).get("content", "") or "" + from .types import ChatText + + served_model = data.get("model") + return ChatText( + choices[0].get("message", {}).get("content", "") or "", + model=( + served_model if isinstance(served_model, str) and served_model + else self.model if self.tool_quirks.get("ignore_request_model") + else model or self.model + ), + input_tokens=parsed.input_tokens, output_tokens=parsed.output_tokens, + ) @leased_call async def chat_with_tools( diff --git a/src/llm/openrouter.py b/src/llm/openrouter.py index 574e8b116..77ac63232 100644 --- a/src/llm/openrouter.py +++ b/src/llm/openrouter.py @@ -194,27 +194,23 @@ def conservative_profile( ] if not usable: return None - from .context_budget import COMPATIBLE_REQUEST_OUTPUT_CEILING - - # Preserve a real route's pair, choosing the least usable input budget. - limiting = min( - usable, - key=lambda row: int(row["context_length"]) - - min(int(row["max_completion_tokens"]), COMPATIBLE_REQUEST_OUTPUT_CEILING), - ) + # Both constraints must hold across EVERY permitted fallback route. + # Keep independent provenance when different routes constrain each axis. + limiting = min(usable, key=lambda row: int(row["context_length"])) + output_limiting = min(usable, key=lambda row: int(row["max_completion_tokens"])) policy = request_provider_policy( routing, model=model or "", has_tools=True, has_reasoning=require_reasoning ) return { "total_window_tokens": int(limiting["context_length"]), - "max_output_tokens": int(limiting["max_completion_tokens"]), + "max_output_tokens": int(output_limiting["max_completion_tokens"]), "source": ( "openrouter_pinned_endpoint" if policy.get("order") and not policy["allow_fallbacks"] else "openrouter_conservative_routes" ), "context_route_tag": limiting.get("tag"), - "output_route_tag": limiting.get("tag"), + "output_route_tag": output_limiting.get("tag"), } diff --git a/src/llm/recovery.py b/src/llm/recovery.py index b5f907f49..3f6a4b978 100644 --- a/src/llm/recovery.py +++ b/src/llm/recovery.py @@ -180,6 +180,26 @@ async def generate_with_recovery( cancel_event: asyncio.Event | None = None, on_wait: Callable[[float, float, BaseException], None] | None = None, retry_circuit_open: bool = True, + generation_client=None, +) -> T: + """Hold one concrete client generation through all attempts and backoff.""" + lease = getattr(generation_client, "generation_lease", None) + async with lease() if lease else contextlib.nullcontext(): + return await _generate_with_recovery( + attempt, policy=policy, breaker=breaker, deadline_seconds=deadline_seconds, + cancel_event=cancel_event, on_wait=on_wait, retry_circuit_open=retry_circuit_open, + ) + + +async def _generate_with_recovery( + attempt: Callable[[], Awaitable[T]], + *, + policy: RecoveryPolicy, + breaker: ModelCapacityBreaker | None = None, + deadline_seconds: float | None = None, + cancel_event: asyncio.Event | None = None, + on_wait: Callable[[float, float, BaseException], None] | None = None, + retry_circuit_open: bool = True, ) -> T: """Run one logical LLM generation with deadline-based recovery. diff --git a/src/llm/types.py b/src/llm/types.py index bb8fa5949..90d36c106 100644 --- a/src/llm/types.py +++ b/src/llm/types.py @@ -5,6 +5,21 @@ from dataclasses import dataclass, field +class ChatText(str): + """String-compatible direct reply carrying result-scoped accounting facts.""" + + model: str + input_tokens: int + output_tokens: int + + def __new__(cls, text: str, *, model: str, input_tokens: int, output_tokens: int): + value = super().__new__(cls, text) + value.model = model + value.input_tokens = input_tokens + value.output_tokens = output_tokens + return value + + @dataclass(slots=True) class ToolCall: """A single tool call extracted from an LLM response. diff --git a/src/tools/agent_tool_policy.py b/src/tools/agent_tool_policy.py index 1587228e3..d9e71fed4 100644 --- a/src/tools/agent_tool_policy.py +++ b/src/tools/agent_tool_policy.py @@ -58,10 +58,13 @@ def configured_agent_model(config) -> str | None: if main: return str(main) active = getattr(provider, "active_provider", "codex") - leaf_name = {"compat": "openai_compatible", "ollama": "ollama"}.get(active, "openai_codex") + leaf_name = { + "compat": "openai_compatible", "ollama": "ollama", "kimi": "kimi", + }.get(active, "openai_codex") leaf = getattr(config, leaf_name, None) model = getattr(leaf, "model", None) - return f"{active}:{model}" if model and active in {"compat", "ollama"} else model + prefix = "compat" if active == "kimi" else active + return f"{prefix}:{model}" if model and prefix in {"compat", "ollama"} else model def effective_agent_model_choices(config) -> list[str]: diff --git a/src/web/api/_agent_display.py b/src/web/api/_agent_display.py index a17fe7922..5ee7da5a8 100644 --- a/src/web/api/_agent_display.py +++ b/src/web/api/_agent_display.py @@ -128,23 +128,42 @@ def agent_display_policy(info, bot) -> dict: # Nothing has executed. Resolve each axis on its own merits. model_override = getattr(info, "model_override", None) effort_override = getattr(info, "reasoning_effort_override", None) - # Per-spawn overrides are a Codex-path concept; under another provider - # they are inert, so displaying them would advertise a policy execution - # will ignore. - overrides_apply = provider not in _EFFORTLESS_PROVIDERS - - if overrides_apply and model_override: - model, model_source = model_override, "spawn_override_pending" + from ...llm.model_ref import parse_model_ref + from ...tools.agent_tool_policy import configured_agent_model, model_reasoning_dialect + + config = getattr(bot, "config", None) + selected = model_override or configured_agent_model(config) + if not isinstance(selected, str): + selected = None + if selected is None: + # Auto is a per-spawn policy; legacy pending records have no choice. + main = getattr(getattr(config, "llm_provider", None), "model", None) + selected = main if isinstance(main, str) else _live_model(bot, provider) + if not isinstance(selected, str): + selected = None + if selected: + ref = parse_model_ref(selected, allow_auto=False) + provider = ref.provider.value + model = ref.model else: - model = _live_model(bot, provider) - model_source = "current_inheritance" if model else "unknown" - - if not _provider_has_effort(provider): + model = "" + model_source = ( + "spawn_override_pending" if model_override + else "current_inheritance" if model else "unknown" + ) + dialect = model_reasoning_dialect(config, selected) if selected else "none" + + if dialect == "none": effort, effort_source = NOT_APPLICABLE, "current_inheritance" - elif overrides_apply and effort_override: + elif effort_override: effort, effort_source = effort_override, "spawn_override_pending" else: - effort = _live_effort(bot, provider) + from ...discord.native_tools.agents_tasks import _entry_native_reasoning + + native_effort, native_thinking = _entry_native_reasoning(config, selected) + effort = native_effort or native_thinking or _live_effort(bot, provider) + if not isinstance(effort, str): + effort = UNKNOWN effort_source = "current_inheritance" if effort else "unknown" if model_source == "unknown" and effort_source == "unknown": diff --git a/tests/test_agents_tasks_provider_paths.py b/tests/test_agents_tasks_provider_paths.py index 39d42aedb..0f8baf6e0 100644 --- a/tests/test_agents_tasks_provider_paths.py +++ b/tests/test_agents_tasks_provider_paths.py @@ -225,7 +225,7 @@ async def test_agent_generate_uses_thinking_and_compat_request_controls(): tools = object.__new__(mod.AgentTaskTools) tools._llm_gateway = gateway - async def fake_recovery(attempt, *, policy, breaker): + async def fake_recovery(attempt, *, policy, breaker, generation_client=None): return await attempt() original = mod.generate_with_recovery diff --git a/tests/test_llm_gateway.py b/tests/test_llm_gateway.py index 742ed7f49..b3339994a 100644 --- a/tests/test_llm_gateway.py +++ b/tests/test_llm_gateway.py @@ -1341,13 +1341,14 @@ def test_kimi_alias_setter_updates_neutral_compatible_generation(self): def test_stale_compatible_auxiliary_is_retired_on_provider_reload(self): live_transport = object() - stale_aux = SimpleNamespace(provider="compat", aux_client=object()) + stale_aux = SimpleNamespace(provider="compat", aux_client=object(), model="vendor/cheap") gateway = _gw(_cfg(), kimi=live_transport, aux=stale_aux) gateway._schedule_drain = MagicMock() gateway._reconcile_auxiliary_primary() - assert gateway.auxiliary_llm_client is None + assert gateway.auxiliary_llm_client.aux_client is live_transport + assert gateway.auxiliary_llm_client.model == "vendor/cheap" gateway._schedule_drain.assert_called_once_with(stale_aux) def test_live_compatible_auxiliary_survives_reconcile(self): diff --git a/tests/test_native_agents_tasks.py b/tests/test_native_agents_tasks.py index 19fd7a68f..225186899 100644 --- a/tests/test_native_agents_tasks.py +++ b/tests/test_native_agents_tasks.py @@ -985,7 +985,7 @@ async def test_routes_cheap_when_enabled(self): async def test_falls_to_active_when_no_aux(self): active = SimpleNamespace(chat=AsyncMock(return_value="strong")) - gateway = SimpleNamespace(active_client=active, auxiliary_llm_client=None) + gateway = SimpleNamespace(active_client=active, auxiliary_llm_client=None, chat=active.chat) cb = await self._capture_cb(gateway) assert await cb([], "s", 200) == "strong" active.chat.assert_awaited_once() diff --git a/tests/test_openai_codex_client.py b/tests/test_openai_codex_client.py index f0e50d567..9a7194a93 100644 --- a/tests/test_openai_codex_client.py +++ b/tests/test_openai_codex_client.py @@ -261,7 +261,11 @@ async def test_incomplete_returns_partial(self): _sse({"type": "response.incomplete", "response": {"incomplete_details": {"reason": "max_tokens"}}}), ]) - assert await _client()._read_stream(resp) == "partial" + from src.llm.errors import LLMIncompleteResponseError + + with pytest.raises(LLMIncompleteResponseError) as caught: + await _client()._read_stream(resp) + assert caught.value.partial_text == "partial" @pytest.mark.asyncio async def test_empty_stream_returns_empty(self): diff --git a/tests/test_openrouter.py b/tests/test_openrouter.py index 201369d8d..a8822ce8c 100644 --- a/tests/test_openrouter.py +++ b/tests/test_openrouter.py @@ -130,10 +130,10 @@ def test_endpoint_tags_drive_pin_and_profile_uses_one_limiting_route(): conservative = conservative_profile(rows, OpenRouterRoutingConfig()) assert conservative == { "total_window_tokens": 1_000_000, - "max_output_tokens": 393_216, + "max_output_tokens": 131_072, "source": "openrouter_conservative_routes", "context_route_tag": "alibaba", - "output_route_tag": "alibaba", + "output_route_tag": "deepinfra/fp8", } pinned = conservative_profile( rows, diff --git a/tests/test_provider_campaign_regressions.py b/tests/test_provider_campaign_regressions.py new file mode 100644 index 000000000..1b96b7c87 --- /dev/null +++ b/tests/test_provider_campaign_regressions.py @@ -0,0 +1,534 @@ +"""Real provider/gateway settlement and reload regressions for v4.10.0.""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.config.schema import Config, OpenRouterRoutingConfig +from src.discord.llm_gateway import LLMGateway +from src.discord.tool_loop import ToolLoopRunner +from src.llm.auxiliary import AuxiliaryLLMClient +from src.llm.circuit_breaker import CircuitBreaker, CircuitOpenError +from src.llm.client_lifecycle import ClientLifecycle +from src.llm.errors import ( + LLMContextLengthError, + LLMIncompleteResponseError, + LLMRequestError, + LLMTransportError, +) +from src.llm.model_breaker import ModelCapacityBreaker +from src.llm.ollama import OllamaClient +from src.llm.openai_codex import CodexChatClient +from src.llm.openai_compatible import OpenAICompatibleClient +from src.llm.openrouter import conservative_profile +from src.llm.recovery import RecoveryPolicy, generate_with_recovery +from src.web.api._agent_display import agent_display_policy + + +def config(**kwargs): + return Config(discord={"token": "fixture"}, **kwargs) + + +def gateway(config, **kwargs): + defaults = dict( + codex_client=None, + ollama_client=None, + kimi_client=None, + subsystem_guard=None, + auxiliary_llm_client=None, + cost_tracker=None, + ) + return LLMGateway( + get_config=lambda: config, + sessions=MagicMock(), + reflector=MagicMock(), + **(defaults | kwargs), + ) + + +class Response: + status = 200 + + def __init__(self, payload, status=200): + self.payload = payload + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + async def json(self): + return self.payload + + async def text(self): + return json.dumps(self.payload) + + +def test_keyword_named_parameters_survive_kimi_sanitization(): + from src.tools.defs.integrations_email import TOOLS_SECTION as TOOLS + + client = OpenAICompatibleClient("fixture", model="kimi", tool_quirks={"sanitize_schema": True}) + schema = { + "type": "object", + "title": "annotation", + "additionalProperties": False, + "properties": { + name: {"type": "string", "title": "annotation"} for name in ("format", "title", "$ref") + }, + "required": ["format", "title", "$ref"], + } + cleaned = client._sanitize_schema(schema) + assert set(cleaned["properties"]) == set(schema["properties"]) + assert "title" not in cleaned + assert cleaned["properties"]["title"] == {"type": "string"} + validate = next(tool for tool in TOOLS if tool["name"] == "validate_action") + converted = client._convert_tools([validate])[0]["function"]["parameters"] + assert converted["properties"]["format"] == validate["input_schema"]["properties"]["format"] + + +def test_openrouter_constraints_cover_heterogeneous_routes(): + profile = conservative_profile( + [ + { + "tag": "context", + "supports_tools": True, + "context_length": 64000, + "max_completion_tokens": 32000, + }, + { + "tag": "output", + "supports_tools": True, + "context_length": 128000, + "max_completion_tokens": 4096, + }, + ], + OpenRouterRoutingConfig(), + ) + assert profile["total_window_tokens"] == 64000 + assert profile["max_output_tokens"] == 4096 + assert profile["context_route_tag"] == "context" + assert profile["output_route_tag"] == "output" + + +def test_adaptive_cooldown_saturates_thousands_of_opens(monkeypatch): + clock = [0.0] + monkeypatch.setattr("src.llm.model_breaker.time.monotonic", lambda: clock[0]) + breaker = ModelCapacityBreaker("fixture") + breaker.record_generation_failure() + for _ in range(2000): + clock[0] += 301 + admission = breaker.acquire_attempt() + breaker.attempt_failed_capacity(admission) + assert breaker.snapshot()["cooldown_seconds"] <= 300 + assert breaker.snapshot()["consecutive_opens"] == 2001 + + +async def test_transport_breaker_reserves_one_probe_and_cancel_releases(monkeypatch): + clock = [0.0] + monkeypatch.setattr("src.llm.circuit_breaker.time.monotonic", lambda: clock[0]) + client = OllamaClient(model="fixture", max_retries=0) + client.breaker = CircuitBreaker("fixture", failure_threshold=1, recovery_timeout=10) + client.breaker.record_failure() + clock[0] = 11 + entered = asyncio.Event() + + async def session(): + entered.set() + await asyncio.Event().wait() + + client._get_session = session + probe = asyncio.create_task(client.chat([], "")) + await entered.wait() + for _ in range(50): + with pytest.raises(CircuitOpenError): + await client.chat([], "") + probe.cancel() + with pytest.raises(asyncio.CancelledError): + await probe + client.breaker.check() # cancellation did not strand ownership + client.breaker.abandon() + + +@pytest.mark.parametrize("provider", ["compat", "ollama"]) +async def test_canonical_model_reaches_tool_and_direct_outbound_bodies(provider): + cfg = config(llm_provider={"model": f"{provider}:chosen"}) + bodies = [] + + async def request(body): + bodies.append(body) + if provider == "ollama": + return {"message": {"content": "complete"}, "done": True, "done_reason": "stop"} + return {"choices": [{"message": {"content": "complete"}, "finish_reason": "stop"}]} + + client = ( + OllamaClient(model="default") + if provider == "ollama" + else OpenAICompatibleClient("fixture", model="default") + ) + client._request_with_retry = request + gw = gateway( + cfg, + **({"ollama_client": client} if provider == "ollama" else {"compatible_client": client}), + ) + await gw.call_with_tools(messages=[], system="", tools=[]) + assert await gw.chat([], "") == "complete" + assert [body["model"] for body in bodies] == ["chosen", "chosen"] + gw.wire_callbacks() + await gw.sessions.set_compaction_fn.call_args.args[0]([], "") + assert bodies[-1]["model"] == "chosen" + + +async def test_codex_direct_model_override_reaches_request(): + client = CodexChatClient(auth=MagicMock(), model="gpt-6-luna") + client._stream_request = AsyncMock(return_value="complete") + gw = gateway(config(llm_provider={"model": "gpt-6.1-sol"}), codex_client=client) + assert await gw.chat([], "") == "complete" + assert client._stream_request.call_args.args[0]["model"] == "gpt-6.1-sol" + + +async def test_one_token_aux_probe_accepts_length_without_relaxing_chat(): + client = OpenAICompatibleClient("fixture", model="chosen") + client._request_with_retry = AsyncMock( + return_value={ + "choices": [{"message": {"content": "p"}, "finish_reason": "length"}], + } + ) + gw = gateway(config()) + assert await gw._probe_aux(client) is None + with pytest.raises(LLMRequestError, match="truncated"): + await client.chat([], "") + + +async def test_borrowed_aux_accounting_uses_override_and_ollama_usage(): + client = OllamaClient(model="primary") + client._request_with_retry = AsyncMock( + return_value={ + "model": "cheap", + "message": {"content": "complete"}, + "done": True, + "done_reason": "stop", + "prompt_eval_count": 123, + "eval_count": 17, + } + ) + cost = MagicMock() + wrapper = AuxiliaryLLMClient( + client, client, cost, provider="ollama", model="cheap", owns_aux_client=False + ) + assert await wrapper.chat([], "", task="compaction") == "complete" + assert cost.record.call_args.kwargs["model"] == "cheap" + assert cost.record.call_args.kwargs["input_tokens"] == 123 + assert cost.record.call_args.kwargs["output_tokens"] == 17 + + +@pytest.mark.parametrize("provider", ["compat", "ollama"]) +async def test_reload_preserves_borrowed_aux_and_rebinds_cross_provider_fallback(provider): + cfg = config( + llm_provider={"model": "ollama:primary"}, + openai_codex={"auxiliary": {"enabled": True, "model": f"{provider}:cheap"}}, + ) + old_primary = OllamaClient(model="primary") + old_transport = ( + old_primary if provider == "ollama" else OpenAICompatibleClient("fixture", model="default") + ) + wrapper = AuxiliaryLLMClient( + old_transport, old_primary, provider=provider, model="cheap", owns_aux_client=False + ) + gw = gateway( + cfg, + ollama_client=old_primary, + compatible_client=old_transport if provider == "compat" else None, + auxiliary_llm_client=wrapper, + ) + new_primary = OllamaClient(model="primary") + gw.ollama_client = new_primary + gw._reconcile_auxiliary_primary() + current = gw.auxiliary_llm_client + assert current is not None + assert current.primary_client is new_primary + assert current.aux_client is (old_transport if provider == "compat" else new_primary) + assert current.model == "cheap" + old_primary.retire() + current.aux_client._request_with_retry = AsyncMock( + return_value=( + {"message": {"content": "complete"}, "done": True, "done_reason": "stop"} + if provider == "ollama" + else {"choices": [{"message": {"content": "complete"}}]} + ) + ) + assert await current.chat([], "", task="compaction") == "complete" + await asyncio.gather(*gw._aux_drains) + + +async def test_cancelled_compatible_qualification_drains_candidate(monkeypatch): + cfg = config(openai_compatible={"enabled": True, "api_key": "fixture", "model": "chosen"}) + gw = gateway(cfg) + candidate = OpenAICompatibleClient("fixture", model="chosen") + candidate.close = AsyncMock() + entered = asyncio.Event() + + async def probe(_): + entered.set() + await asyncio.Event().wait() + + monkeypatch.setattr("src.discord.llm_gateway.OpenAICompatibleClient", lambda **_: candidate) + gw._probe_openai_compatible = probe + task = asyncio.create_task(gw.reload_openai_compatible()) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.gather(*gw._aux_drains) + candidate.close.assert_awaited_once() + assert candidate._generation_retired + + +async def test_logical_generation_keeps_client_through_retry_and_child_attempt(): + class Client(ClientLifecycle): + async def close(self): + pass + + client = Client() + calls = 0 + + async def attempt(): + nonlocal calls + async with client.generation_lease(): + calls += 1 + if calls == 1: + client.retire() + raise LLMTransportError("transient") + return "complete" + + assert ( + await generate_with_recovery( + attempt, + policy=RecoveryPolicy(backoff_base=0.001, backoff_cap=0.001), + generation_client=client, + cancel_event=asyncio.Event(), + ) + == "complete" + ) + assert calls == 2 + assert client._generation_inflight == 0 + + +@pytest.mark.parametrize( + "main,agent,effort", + [ + ("compat:main", "gpt-6.1-sol", "high"), + ("gpt-6.1-sol", "ollama:chosen", "N/A"), + ], +) +def test_pending_display_resolves_agent_not_main_provider(main, agent, effort): + cfg = config(llm_provider={"model": main}) + info = SimpleNamespace( + has_executed=False, model_override=agent, reasoning_effort_override="high" + ) + display = agent_display_policy(info, SimpleNamespace(config=cfg)) + assert display["display_model"] == agent.removeprefix("ollama:") + assert display["display_reasoning_effort"] == effort + + +@pytest.mark.parametrize("native", [False, True]) +async def test_partial_provider_output_is_failed_verbatim_not_retried(native): + partial = "uncut output\n" * 20000 + if native: + response = OllamaClient(model="fixture")._parse_response( + { + "message": {"content": partial}, + "done": True, + "done_reason": "length", + } + ) + else: + + async def events(): + for event in [ + {"type": "response.output_text.delta", "delta": partial}, + {"type": "response.incomplete", "response": {}}, + ]: + yield ("data: " + json.dumps(event) + "\n").encode() + + response = await CodexChatClient(auth=MagicMock(), model="gpt-6.1-sol")._read_tool_stream( + SimpleNamespace(content=events()) + ) + runner = ToolLoopRunner.__new__(ToolLoopRunner) + runner._channel_state = MagicMock() + runner._turn_recorder = SimpleNamespace(_save_turn_trajectory=AsyncMock()) + runner._clear_active = MagicMock() + st = SimpleNamespace( + _cancel=asyncio.Event(), + _ch_id="c", + _req_id="r", + _trajectory=object(), + tools_used_in_loop=[], + trace=None, + ) + kind, result = await runner._finalize_or_retry(st, response) + assert kind == "done" and result[2] is True + assert result[0] == partial + "\n\n[Provider marked this response incomplete.]" + assert runner._turn_recorder._save_turn_trajectory.call_args.kwargs["error"] == result[0] + + +async def test_ollama_direct_incomplete_retains_text_and_empty_is_not_silence(): + client = OllamaClient(model="fixture") + client._request_with_retry = AsyncMock( + return_value={"message": {"content": "partial"}, "done_reason": "length", "done": True} + ) + with pytest.raises(LLMIncompleteResponseError) as caught: + await client.chat([], "") + assert caught.value.partial_text == "partial" + with pytest.raises(LLMRequestError) as empty: + client._parse_response({"message": {"content": ""}, "done": True, "done_reason": "stop"}) + assert empty.value.code == "empty_response" + + +async def test_long_completed_native_reply_unchanged(): + client = OllamaClient(model="fixture") + text = "legitimate long answer\n" * 20000 + client._request_with_retry = AsyncMock( + return_value={"message": {"content": text}, "done": True, "done_reason": "stop"} + ) + assert await client.chat([], "") == text + assert (await client.chat_with_tools([], "", [])).text == text + + +@pytest.mark.parametrize("lane", ["boot", "reload"]) +async def test_production_deepseek_clients_classify_context_overflow(lane, tmp_path, monkeypatch): + cfg = config( + openai_compatible={ + "enabled": True, + "api_key": "fixture", + "preset": "deepseek", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com/v1", + } + ) + if lane == "boot": + from tests.fakes import make_bot + + monkeypatch.chdir(tmp_path) + bot = make_bot( + config_overrides={ + "search": {"enabled": False}, + "openai_compatible": cfg.openai_compatible.model_dump(), + } + ) + client = bot.llm_gateway.compatible_client + else: + gw = gateway(cfg) + gw._probe_openai_compatible = AsyncMock(return_value=None) + assert (await gw.reload_openai_compatible())["configured"] + client = gw.compatible_client + payload = { + "error": { + "type": "invalid_request_error", + "message": "This model's maximum context length is 64000 tokens.", + } + } + session = SimpleNamespace(post=lambda *_, **__: Response(payload, 400)) + client._get_session = AsyncMock(return_value=session) + with pytest.raises(LLMContextLengthError) as caught: + await client._request_with_retry({"model": "deepseek-chat"}) + assert caught.value.context_window_tokens == 64000 + + +async def test_incomplete_agent_retains_result_and_failed_trajectory(): + from src.agents.manager import AgentInfo, _run_agent + + text = "partial long output\n" * 20000 + agent = AgentInfo( + id="fixture", + label="test", + goal="reason", + channel_id="c", + requester_id="u", + requester_name="user", + ) + callback = AsyncMock(return_value={"text": text, "tool_calls": [], "stop_reason": "incomplete"}) + saver = SimpleNamespace(save=AsyncMock()) + await _run_agent(agent, "", [], callback, AsyncMock(), trajectory_saver=saver) + assert agent.status == "failed" + assert agent.result == text + assert callback.await_count == 1 + saved = saver.save.call_args.args[0] + assert saved.result == text + assert saved.final_state == "failed" + + +async def test_incomplete_loop_preserves_full_output_and_failure(): + from src.llm.types import LLMResponse + + runner = ToolLoopRunner.__new__(ToolLoopRunner) + runner._finish_loop = AsyncMock(side_effect=lambda _, text, **kwargs: text) + text = "partial loop output\n" * 20000 + st = SimpleNamespace(_trajectory=None, final_text="", completed_naturally=False) + assert runner._record_loop_iteration(st, LLMResponse(text=text, stop_reason="incomplete"), 1) + assert not st.completed_naturally + assert ( + await runner._finalize_loop(st) == text + "\n\n[Provider marked this response incomplete.]" + ) + assert runner._finish_loop.call_args.kwargs["is_error"] is True + + +async def test_compatible_aux_uses_result_scoped_served_model_and_usage(): + client = OpenAICompatibleClient("fixture", model="primary") + client._request_with_retry = AsyncMock( + return_value={ + "model": "served-cheap", + "choices": [{"message": {"content": "complete"}}], + "usage": {"prompt_tokens": 123, "completion_tokens": 17}, + } + ) + cost = MagicMock() + wrapper = AuxiliaryLLMClient( + client, + client, + cost, + provider="compat", + model="cheap", + owns_aux_client=False, + ) + assert await wrapper.chat([], "", task="reflection") == "complete" + assert client._request_with_retry.call_args.args[0]["model"] == "cheap" + assert cost.record.call_args.kwargs["model"] == "served-cheap" + assert cost.record.call_args.kwargs["input_tokens"] == 123 + assert cost.record.call_args.kwargs["output_tokens"] == 17 + + +async def test_long_completed_codex_output_unchanged(): + text = "complete reasoning\n" * 20000 + + async def events(): + for event in [ + {"type": "response.output_text.delta", "delta": text}, + {"type": "response.completed", "response": {}}, + ]: + yield ("data: " + json.dumps(event) + "\n").encode() + + client = CodexChatClient(auth=MagicMock(), model="gpt-6.1-sol") + assert await client._read_stream(SimpleNamespace(content=events())) == text + response = await client._read_tool_stream(SimpleNamespace(content=events())) + assert response.text == text and response.stop_reason == "end_turn" + + +async def test_retirement_before_dispatch_fast_fails_without_outage_latch(): + from src.llm.errors import LLMClientRetiredError + + client = OllamaClient(model="chosen") + client.retire() + guard = MagicMock() + guard.check.return_value = None + gw = gateway( + config(llm_provider={"model": "ollama:chosen"}), ollama_client=client, subsystem_guard=guard + ) + with pytest.raises(LLMClientRetiredError): + await gw.call_with_tools(messages=[], system="", tools=[]) + guard.record_failure.assert_not_called() + assert gw.inflight_requests == 0 diff --git a/tests/test_web_api_agents_loops.py b/tests/test_web_api_agents_loops.py index 488095a95..1c5faec4c 100644 --- a/tests/test_web_api_agents_loops.py +++ b/tests/test_web_api_agents_loops.py @@ -781,16 +781,16 @@ async def test_pending_compatible_reports_compatible_model(self): assert row["display_reasoning_effort"] == "N/A" @pytest.mark.asyncio - async def test_codex_overrides_inert_under_non_codex_provider(self): - # The overrides exist but execution ignores them — showing them would - # advertise a policy that will not happen. + async def test_codex_override_selects_independent_agent_provider(self): + # Agent selection is independent of the main-chat provider. agent = _agent_info(model_override="gpt-5.6-luna", reasoning_effort_override="max") bot = _display_bot(agent, provider="ollama") bot.config.ollama = SimpleNamespace(model="qwen3:14b") async with TestClient(TestServer(_app(register_agents, bot=bot))) as c: row = (await (await c.get("/api/agents")).json())[0] - assert row["display_model"] == "qwen3:14b" - assert row["display_source"] == "current_inheritance" + assert row["display_model"] == "gpt-5.6-luna" + assert row["display_reasoning_effort"] == "max" + assert row["display_source"] == "spawn_override_pending" class TestDisplayPolicyPerAxisSources: From 4edef00cd86092306a1db090ab95e9fc4ac31ec5 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:22:11 -0400 Subject: [PATCH 051/162] Keep half-open provider probe reserved through internal retry settlement --- src/discord/tool_loop.py | 2 +- src/llm/circuit_breaker.py | 12 +++++++----- tests/test_provider_campaign_regressions.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/discord/tool_loop.py b/src/discord/tool_loop.py index 82af8db5b..e8dd85f7a 100644 --- a/src/discord/tool_loop.py +++ b/src/discord/tool_loop.py @@ -2521,7 +2521,7 @@ async def _finalize_or_retry(self, st: _ChatTurn, llm_resp): """ if st._cancel.is_set(): return ("done", self._stopped(st, "before_validation")) - if llm_resp.stop_reason == "incomplete": + if getattr(llm_resp, "stop_reason", None) == "incomplete": # Preserve every byte of the accepted output. No automatic retry, # guard budget, or successful trajectory can erase its settlement. final = (llm_resp.text or "") + "\n\n[Provider marked this response incomplete.]" diff --git a/src/llm/circuit_breaker.py b/src/llm/circuit_breaker.py index fbb96212f..89e705d31 100644 --- a/src/llm/circuit_breaker.py +++ b/src/llm/circuit_breaker.py @@ -102,6 +102,8 @@ def check(self) -> None: """ with self._lock: current = self._state + if self._probe_owner is not None: + raise CircuitOpenError(self.name, self.recovery_timeout) if current == "open": elapsed = time.monotonic() - self._last_failure_time if elapsed < self.recovery_timeout: @@ -115,14 +117,15 @@ def check(self) -> None: def abandon(self) -> None: """Release only this caller's probe, without counting a provider failure.""" with self._lock: - if self._state == "half_open" and self._probe_owner is _caller(): + if self._probe_owner is _caller(): self._probe_owner = None - self._state = "open" # elapsed timer allows the next probe immediately + if self._state == "half_open": + self._state = "open" # elapsed timer allows the next probe immediately def record_success(self) -> None: """Record a successful API call. Resets failure count, closes breaker.""" with self._lock: - if self._state == "half_open" and self._probe_owner is not _caller(): + if self._probe_owner is not None and self._probe_owner is not _caller(): return self._failure_count = 0 self._state = "closed" @@ -131,10 +134,9 @@ def record_success(self) -> None: def record_failure(self) -> None: """Record a failed API call. Opens breaker after threshold is reached.""" with self._lock: - if self._state == "half_open" and self._probe_owner is not _caller(): + if self._probe_owner is not None and self._probe_owner is not _caller(): return self._failure_count += 1 self._last_failure_time = time.monotonic() if self._failure_count >= self.failure_threshold: self._state = "open" - self._probe_owner = None diff --git a/tests/test_provider_campaign_regressions.py b/tests/test_provider_campaign_regressions.py index 1b96b7c87..39ac35802 100644 --- a/tests/test_provider_campaign_regressions.py +++ b/tests/test_provider_campaign_regressions.py @@ -154,6 +154,22 @@ async def session(): client.breaker.abandon() +def test_probe_reservation_survives_internal_failure_backoff(monkeypatch): + clock = [0.0] + monkeypatch.setattr("src.llm.circuit_breaker.time.monotonic", lambda: clock[0]) + breaker = CircuitBreaker("fixture", failure_threshold=1, recovery_timeout=1) + breaker.record_failure() + clock[0] = 2 + breaker.check() + breaker.record_failure() + clock[0] = 100 + with pytest.raises(CircuitOpenError): + breaker.check() + breaker.abandon() + breaker.check() + breaker.record_success() + + @pytest.mark.parametrize("provider", ["compat", "ollama"]) async def test_canonical_model_reaches_tool_and_direct_outbound_bodies(provider): cfg = config(llm_provider={"model": f"{provider}:chosen"}) From 96d1f437db9d41c49f391587a8c9e2a5d7e8c43a Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:40:26 -0400 Subject: [PATCH 052/162] Fix patch snapshot ownership, nonblocking opens and byte-preserving lines (#554 #555 #556) --- src/tools/apply_patch.py | 53 +++++++++++++++++++------- tests/test_campaign_process_patch.py | 56 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 tests/test_campaign_process_patch.py diff --git a/src/tools/apply_patch.py b/src/tools/apply_patch.py index dcdb5b6f1..b55d04416 100644 --- a/src/tools/apply_patch.py +++ b/src/tools/apply_patch.py @@ -11,6 +11,7 @@ from __future__ import annotations import os +import re import secrets import stat from collections.abc import Callable @@ -440,8 +441,12 @@ def _hunk_label(path: str, hunk: dict[str, Any]) -> str: def _apply_hunks(path: str, source: str, hunks: list[dict[str, Any]]) -> str: had_final_newline = source.endswith(("\n", "\r")) - newline = "\r\n" if "\n" in source and source.count("\r\n") == source.count("\n") else "\n" - lines = source.splitlines() + # Text boundaries are CR/LF only. splitlines() also splits valid Unicode + # string content (and VT/FF), and joining it corrupts untouched bytes. + records = re.findall(r"[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$", source) + endings = [record[len(record.rstrip("\r\n")):] for record in records] + lines = [record.rstrip("\r\n") for record in records] + newline = next((ending for ending in endings if ending), "\n") cursor = 0 for hunk in hunks: anchors = hunk["anchors"] @@ -457,12 +462,28 @@ def _apply_hunks(path: str, source: str, hunks: list[dict[str, Any]]) -> str: at = _find_unique(lines, old_lines, cursor, label) else: at = len(lines) + old_endings = endings[at : at + len(old_lines)] + replacement_ending = next((ending for ending in old_endings if ending), newline) + new_endings = [] + old_index = 0 + for line in hunk["lines"]: + if line[0] == " ": + new_endings.append(old_endings[old_index]) + elif line[0] == "+": + new_endings.append( + old_endings[old_index - 1] or replacement_ending + if old_index else replacement_ending + ) + if line[0] in " -": + old_index += 1 + if at + len(old_lines) == len(lines) and new_endings and not had_final_newline: + new_endings[-1] = "" + if at and new_lines and not endings[at - 1]: + endings[at - 1] = replacement_ending lines[at : at + len(old_lines)] = new_lines + endings[at : at + len(old_lines)] = new_endings cursor = at + len(new_lines) - result = newline.join(lines) - if had_final_newline: - result += newline - return result + return "".join(line + ending for line, ending in zip(lines, endings)) class _DirectoryRegistry: @@ -772,7 +793,10 @@ def _read_fd(fd: int) -> bytes: def _open_regular_at(parent_fd: int, name: str, relative: str) -> dict[str, Any]: try: - fd = os.open(name, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW, dir_fd=parent_fd) + fd = os.open( + name, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=parent_fd, + ) except FileNotFoundError: raise PatchError(f"file does not exist: {relative}") from None except OSError as exc: @@ -1095,6 +1119,7 @@ def apply_plan( operations = _validated_plan(plan) directories = _DirectoryRegistry(root_value, rename_noreplace=rename_noreplace) prepared: list[dict[str, Any]] = [] + snapshots: list[dict[str, Any]] = [] stages: list[dict[str, Any]] = [] preserve_artifacts = False keep_created_directories = False @@ -1141,6 +1166,8 @@ def apply_plan( "parent_label": source_parent_label, } snapshot = _open_regular_at(source_parent_fd, source_name, op["path"]) + # Own the descriptor before UTF-8/context/destination validation. + snapshots.append(snapshot) if action == "delete": new = None destination = None @@ -1357,13 +1384,11 @@ def apply_plan( _cleanup_named([item["recovery"] for item in prepared if item.get("recovery")] + stages) if not keep_created_directories and not preserve_artifacts: directories.rollback_created() - for item in prepared: - final_snapshot = item.get("snapshot") - if final_snapshot is not None: - try: - os.close(final_snapshot["fd"]) - except OSError: - pass + for final_snapshot in snapshots: + try: + os.close(final_snapshot["fd"]) + except OSError: + pass for stage in stages: try: os.close(stage["fd"]) diff --git a/tests/test_campaign_process_patch.py b/tests/test_campaign_process_patch.py new file mode 100644 index 000000000..170b1bc25 --- /dev/null +++ b/tests/test_campaign_process_patch.py @@ -0,0 +1,56 @@ +import os +import stat +from types import SimpleNamespace + +import pytest + +from src.tools import apply_patch as patch + + +def envelope(body): + return patch.parse_patch(f"*** Begin Patch\n{body}\n*** End Patch\n") + + +@pytest.mark.parametrize("content,body,match", [ + (b"\xff", "@@\n-old\n+new", "UTF-8"), + (b"actual\n", "@@\n-old\n+new", "context mismatch"), + (b"old\n", "*** Move to: occupied\n@@\n-old\n+new", "already exists"), +]) +def test_semantic_failure_closes_snapshot(tmp_path, monkeypatch, content, body, match): + (tmp_path / "source").write_bytes(content) + (tmp_path / "occupied").write_bytes(b"untouched") + real_open = patch._open_regular_at + captured = [] + + def record(*args): + snapshot = real_open(*args) + captured.append(snapshot["fd"]) + return snapshot + + monkeypatch.setattr(patch, "_open_regular_at", record) + with pytest.raises(patch.PatchError, match=match): + patch.apply_plan(str(tmp_path), envelope("*** Update File: source\n" + body)) + assert len(captured) == 1 + with pytest.raises(OSError): + os.fstat(captured[0]) + assert (tmp_path / "source").read_bytes() == content + + +def test_nonregular_open_is_nonblocking_and_closed(monkeypatch): + opened = [] + closed = [] + monkeypatch.setattr(patch.os, "open", lambda name, flags, **kwargs: opened.append(flags) or 9123) + monkeypatch.setattr(patch.os, "fstat", lambda fd: SimpleNamespace(st_mode=stat.S_IFIFO)) + monkeypatch.setattr(patch.os, "close", closed.append) + with pytest.raises(patch.PatchError, match="regular"): + patch._open_regular_at(7, "fifo", "fifo") + assert opened[0] & os.O_NONBLOCK + assert closed == [9123] + + +@pytest.mark.parametrize("separator", ["\u2028", "\u2029", "\v", "\f", "\x85"]) +def test_patch_preserves_unicode_content_and_mixed_endings(separator): + source = '{"text":"one' + separator + 'two"}\r\nkeep\nold\r\nlast\r' + plan = envelope("*** Update File: source\n@@\n keep\n-old\n+new\n last") + actual = patch._apply_hunks("source", source, plan["operations"][0]["hunks"]) + assert actual == source.replace("old\r\n", "new\r\n") From f07d9ef876673bc1058e726b87967dece83c0548 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:45:24 -0400 Subject: [PATCH 053/162] Preserve host quarantine, serialize test publication and enroll signing CAs (#551 #552 #553) --- src/tools/hosts/control.py | 67 ++++++++++++----- src/tools/hosts/registry.py | 23 +++++- src/tools/hosts/trust.py | 68 ++++++++++++++++++ src/web/api/hosts.py | 5 +- tests/test_campaign_process_hosts.py | 103 +++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 21 deletions(-) create mode 100644 tests/test_campaign_process_hosts.py diff --git a/src/tools/hosts/control.py b/src/tools/hosts/control.py index 77b94d1d8..fc72a3938 100644 --- a/src/tools/hosts/control.py +++ b/src/tools/hosts/control.py @@ -18,7 +18,10 @@ from ...llm.secret_scrubber import scrub_output_secrets from ..ssh import is_local_address from .registry import deterministic_host_id -from .trust import HostCandidate, HostTrustError, fingerprint_public_key, normalize_public_key +from .trust import ( + HostCandidate, HostTrustError, certificate_authority_key, + fingerprint_public_key, normalize_public_key, +) _ALIAS_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}$") _HOSTNAME_RE = re.compile( @@ -109,8 +112,9 @@ def sanitized_diagnostic(value: bytes | str, limit: int = 500) -> str: class HostEnrollmentManager: """Short-lived candidates that cannot be targeted before connection test.""" - def __init__(self, registry) -> None: + def __init__(self, registry, *, publication_lock: asyncio.Lock | None = None) -> None: self.registry = registry + self._publication_lock = publication_lock or asyncio.Lock() self._candidates: dict[str, HostCandidate] = {} async def prepare( @@ -149,7 +153,10 @@ async def prepare( if existing is None or existing.trust_mode != "legacy": raise HostTrustError("legacy trust is available only to existing legacy hosts") else: - keys = await self.scan(details["address"], details["port"]) + keys = await ( + self.scan_ca(details["address"], details["port"]) + if trust_mode == "ca" else self.scan(details["address"], details["port"]) + ) fingerprints = tuple(fingerprint_public_key(key) for key in keys) expected = body.get("expected_fingerprints") if isinstance(expected, str): @@ -236,6 +243,26 @@ async def scan(self, address: str, port: int) -> tuple[str, ...]: raise HostTrustError("host-key scan returned no supported public keys") return tuple(keys) + async def scan_ca(self, address: str, port: int) -> tuple[str, ...]: + code, output = await _run_argv( + ["ssh-keyscan", "-c", "-T", "8", "-p", str(port), address], _SCAN_TIMEOUT, + ) + if code != 0: + raise HostTrustError(f"host certificate scan failed: {sanitized_diagnostic(output)}") + keys = [] + for raw in output.decode("utf-8", "replace").splitlines(): + if not raw or raw.startswith("#"): + continue + try: + key = certificate_authority_key(raw) + except HostTrustError: + continue + if key not in keys: + keys.append(key) + if not keys: + raise HostTrustError("host certificate scan returned no supported host certificates") + return tuple(keys) + async def import_legacy(self, alias: str, host: ToolHost) -> HostCandidate: if is_local_address(host.address): raise HostTrustError("local hosts do not use SSH host-key enrollment") @@ -356,22 +383,26 @@ async def test(self, token: str) -> HostCandidate: "platform": observed, "detail": detail, } - active = self.registry.get(candidate.alias) - mismatch = not result["ok"] and _is_host_key_mismatch( - str(result.get("detail", "")) - ) - testing_active_identity = bool( - active is not None - and candidate.address == active.address - and candidate.ssh_user == active.ssh_user - and candidate.port == active.port - and candidate.trust_mode == active.trust_mode - and candidate.host_keys == active.host_keys - ) - if mismatch and testing_active_identity: - self.registry.mark_test_result( - candidate.alias, result, host_key_mismatch=True + # The SSH await must not mutate a staged inventory while its desired + # state is being persisted. Recheck identity inside the publication lock. + async with self._publication_lock: + active = self.registry.get(candidate.alias) + mismatch = not result["ok"] and _is_host_key_mismatch( + str(result.get("detail", "")) ) + testing_active_identity = bool( + active is not None + and candidate.address == active.address + and candidate.ssh_user == active.ssh_user + and candidate.os == active.os + and candidate.port == active.port + and candidate.trust_mode == active.trust_mode + and candidate.host_keys == active.host_keys + ) + if testing_active_identity and (mismatch or result["ok"]): + self.registry.mark_test_result( + candidate.alias, result, host_key_mismatch=mismatch + ) candidate = replace(candidate, tested=bool(result["ok"]), test_result=result) self._candidates[token] = candidate return candidate diff --git a/src/tools/hosts/registry.py b/src/tools/hosts/registry.py index dc1aa9b84..a6889c003 100644 --- a/src/tools/hosts/registry.py +++ b/src/tools/hosts/registry.py @@ -347,6 +347,10 @@ def _stage( diagnostic="Duplicate host identity; resolve the configured UUID collision", ) old = previous.get(alias) + if old is not None and old.trust_state == "mismatch" and self._same_identity(old, target): + target = replace( + target, targetable=False, trust_state="mismatch", last_test=old.last_test, + ) if old is not None and self._same_definition(old, target): replacements[alias] = old else: @@ -390,8 +394,15 @@ def mark_test_result( old, generation=self._generation, last_test=dict(result), - targetable=old.targetable and not host_key_mismatch, - trust_state="mismatch" if host_key_mismatch else old.trust_state, + targetable=( + old.enabled if old.trust_state == "mismatch" and result.get("ok") is True + else old.targetable + ) and not host_key_mismatch, + trust_state=( + "mismatch" if host_key_mismatch else + old.trust_mode if old.trust_state == "mismatch" and result.get("ok") is True + else old.trust_state + ), ) snapshot = dict(self._snapshot) snapshot[alias] = updated @@ -542,6 +553,14 @@ def _is_targetable(self, target: HostTarget) -> bool: event = self._revoke_events.get(target.runtime_key) return target.targetable and not (event is not None and event.is_set()) + @staticmethod + def _same_identity(left: HostTarget, right: HostTarget) -> bool: + fields = ( + "alias", "host_id", "address", "ssh_user", "os", "port", "trust_mode", + "host_keys", "key_path", "known_hosts_path", "host_key_alias", + ) + return all(getattr(left, field) == getattr(right, field) for field in fields) + @staticmethod def _same_definition(left: HostTarget, right: HostTarget) -> bool: fields = ( diff --git a/src/tools/hosts/trust.py b/src/tools/hosts/trust.py index 9f67cb112..e0020623d 100644 --- a/src/tools/hosts/trust.py +++ b/src/tools/hosts/trust.py @@ -5,6 +5,7 @@ import base64 import binascii import hashlib +import struct from dataclasses import dataclass from typing import Any @@ -57,6 +58,73 @@ def fingerprint_public_key(value: str) -> str: return f"SHA256:{digest}" +def certificate_authority_key(value: str) -> str: + """Extract the signing public key from an OpenSSH *host* certificate. + + Discovery is not trust: prepare still requires the operator's CA fingerprint + and test still verifies the certificate through StrictHostKeyChecking. + """ + parts = value.split() + index = next((i for i, part in enumerate(parts) if "-cert-v01@openssh.com" in part), -1) + try: + if index < 0: + raise ValueError("not a certificate") + raw = base64.b64decode(parts[index + 1], validate=True) + if len(raw) > 65536: + raise ValueError("certificate too large") + offset = 0 + + def take(size: int) -> bytes: + nonlocal offset + if size < 0 or offset + size > len(raw): + raise ValueError("truncated certificate") + data = raw[offset:offset + size] + offset += size + return data + + def string() -> bytes: + return take(struct.unpack(">I", take(4))[0]) + + kind = string().decode("ascii") + if kind != parts[index]: + raise ValueError("certificate type mismatch") + string() # nonce + if kind.startswith("ssh-ed25519-cert-"): + count = 1 + elif kind.startswith("ssh-rsa-cert-"): + count = 2 + elif kind.startswith("ecdsa-sha2-"): + count = 2 + elif kind.startswith("sk-ssh-ed25519-cert-"): + count = 2 + elif kind.startswith("sk-ecdsa-sha2-"): + count = 3 + else: + raise ValueError("unsupported certificate") + for _ in range(count): + string() + take(8) # serial + if struct.unpack(">I", take(4))[0] != 2: + raise ValueError("not a host certificate") + string() # key id + string() # principals + take(16) # validity + string() # critical options + string() # extensions + string() # reserved + authority = string() + string() # signature, checked by SSH during the connection test + if offset != len(raw): + raise ValueError("trailing certificate data") + size = struct.unpack(">I", authority[:4])[0] + authority_type = authority[4:4 + size].decode("ascii") + return normalize_public_key( + authority_type + " " + base64.b64encode(authority).decode("ascii") + ) + except (ValueError, IndexError, struct.error, UnicodeError) as exc: + raise HostTrustError("unsupported or malformed OpenSSH host certificate") from exc + + @dataclass(frozen=True, slots=True) class HostCandidate: token: str diff --git a/src/web/api/hosts.py b/src/web/api/hosts.py index ea3191451..9092d22d8 100644 --- a/src/web/api/hosts.py +++ b/src/web/api/hosts.py @@ -67,8 +67,11 @@ async def _drain_host_mutation(operation, *, commit_started: asyncio.Event): def register_hosts(routes: web.RouteTableDef, bot) -> None: require_admin = admin_gate(bot) registry = getattr(bot, "host_registry", None) - enrollments = HostEnrollmentManager(registry) if registry is not None else None management_lock = asyncio.Lock() + enrollments = ( + HostEnrollmentManager(registry, publication_lock=management_lock) + if registry is not None else None + ) def denied(request: web.Request) -> web.Response | None: rejection = require_admin(request) diff --git a/tests/test_campaign_process_hosts.py b/tests/test_campaign_process_hosts.py new file mode 100644 index 000000000..bfa2e0ef8 --- /dev/null +++ b/tests/test_campaign_process_hosts.py @@ -0,0 +1,103 @@ +import asyncio +import base64 +import struct +from unittest.mock import AsyncMock + +import pytest + +from src.config.schema import ToolHost +from src.tools.hosts import HostEnrollmentManager, HostRegistry, control +from src.tools.hosts.trust import HostTrustError, certificate_authority_key, fingerprint_public_key + + +def key(value=b"public"): + return "ssh-ed25519 " + base64.b64encode(value).decode() + + +def host(**kwargs): + return ToolHost(address="example.invalid", **kwargs) + + +@pytest.mark.parametrize("edit", [{}, {"description": "new"}, {"enabled": False}]) +def test_quarantine_survives_inventory_publication(tmp_path, edit): + configured = host() + registry = HostRegistry({"remote": configured}, trust_dir=tmp_path) + registry.mark_test_result("remote", {"ok": False}, host_key_mismatch=True) + registry.publish({"remote": configured.model_copy(update=edit)}) + assert registry.get("remote").trust_state == "mismatch" + assert not registry.get("remote").targetable + registry.mark_test_result("remote", {"ok": True}) + assert registry.get("remote").trust_state == "legacy" + assert registry.get("remote").targetable == edit.get("enabled", True) + + +async def test_test_publication_waits_for_management_commit(tmp_path, monkeypatch): + configured = host() + registry = HostRegistry({"remote": configured}, trust_dir=tmp_path) + lock = asyncio.Lock() + manager = HostEnrollmentManager(registry, publication_lock=lock) + candidate = await manager.prepare( + "remote", configured.model_dump(), existing=configured, allow_tofu=False, + ) + ssh_done = asyncio.Event() + + async def mismatch(*args, **kwargs): + ssh_done.set() + return 255, b"REMOTE HOST IDENTIFICATION HAS CHANGED" + + monkeypatch.setattr(control, "_run_argv", mismatch) + async with lock: + staged = registry.stage({"remote": configured.model_copy(update={"enabled": False})}) + task = asyncio.create_task(manager.test(candidate.token)) + await ssh_done.wait() + await asyncio.sleep(0) + assert not task.done() + registry.publish_staged(staged) + await task + assert not registry.get("remote").enabled + assert registry.get("remote").trust_state == "mismatch" + + +def cert(ca, *, certificate_type=2): + def string(value): + return struct.pack(">I", len(value)) + value + + kind = b"ssh-ed25519-cert-v01@openssh.com" + authority = string(b"ssh-ed25519") + string(ca) + raw = ( + string(kind) + string(b"nonce") + string(b"leaf") + + struct.pack(">QI", 1, certificate_type) + string(b"id") + string(b"") + + struct.pack(">QQ", 0, 123) + string(b"") + string(b"") + string(b"") + + string(authority) + string(b"signature") + ) + return "example.invalid " + kind.decode() + " " + base64.b64encode(raw).decode(), key(authority) + + +async def test_ca_enrollment_uses_certificate_signer_not_leaf(tmp_path, monkeypatch): + certificate, authority = cert(b"actual-ca") + runner = AsyncMock(return_value=(0, certificate.encode())) + monkeypatch.setattr(control, "_run_argv", runner) + registry = HostRegistry({}, trust_dir=tmp_path) + manager = HostEnrollmentManager(registry) + candidate = await manager.prepare("remote", { + "address": "example.invalid", "trust_mode": "ca", + "expected_fingerprints": [fingerprint_public_key(authority)], + }, allow_tofu=False) + assert candidate.host_keys == (authority,) + assert "-c" in runner.call_args.args[0] + assert certificate_authority_key(certificate) == authority + path = registry.materialize_trust(candidate.host_id, "alias", "ca", candidate.host_keys) + with open(path) as file: + assert file.read() == f"@cert-authority alias {authority}\n" + with pytest.raises(HostTrustError, match="malformed"): + certificate_authority_key(cert(b"user-ca", certificate_type=1)[0]) + + +async def test_ca_does_not_accept_leaf_only_scan(tmp_path, monkeypatch): + monkeypatch.setattr(control, "_run_argv", AsyncMock(return_value=(0, key().encode()))) + manager = HostEnrollmentManager(HostRegistry({}, trust_dir=tmp_path)) + with pytest.raises(HostTrustError, match="no supported host certificates"): + await manager.prepare("remote", { + "address": "example.invalid", "trust_mode": "ca", + "expected_fingerprints": [fingerprint_public_key(key())], + }, allow_tofu=False) From c6725e462fc129f493b8631ee37e4bb9998a2644 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:47:49 -0400 Subject: [PATCH 054/162] Retain SSH master ownership across cancelled closure (#550) --- src/tools/ssh_pool.py | 7 +++++- tests/test_campaign_process_ssh.py | 35 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/test_campaign_process_ssh.py diff --git a/src/tools/ssh_pool.py b/src/tools/ssh_pool.py index 777b822fd..107dd7cc2 100644 --- a/src/tools/ssh_pool.py +++ b/src/tools/ssh_pool.py @@ -402,7 +402,9 @@ async def close_host( key = self._key(host, ssh_user, target_id) async with self._lock(key): self._cancel_expiry(key) - master = self._masters.pop(key, None) + # Keep the direct ownership handle through every cancellable await. + # If closure is interrupted, close_all can still settle this master. + master = self._masters.get(key) had_connection = os.path.exists(socket) or master is not None if os.path.exists(socket): proc = None @@ -440,6 +442,9 @@ async def close_host( await asyncio.wait_for(master.wait(), timeout=5) except TimeoutError: await self._stop_process(master) + if master.returncode is None: + return False + self._masters.pop(key, None) try: os.unlink(socket) except OSError: diff --git a/tests/test_campaign_process_ssh.py b/tests/test_campaign_process_ssh.py new file mode 100644 index 000000000..322701036 --- /dev/null +++ b/tests/test_campaign_process_ssh.py @@ -0,0 +1,35 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.tools.ssh_pool import SSHConnectionPool + + +async def test_cancelled_close_retains_live_master_ownership(tmp_path, monkeypatch): + pool = SSHConnectionPool(socket_dir=str(tmp_path)) + host = "example.invalid" + socket = pool.get_socket_path(host, "root") + key = pool._key(host, "root") + master = SimpleNamespace(returncode=None, wait=AsyncMock()) + pool._masters[key] = master + started = asyncio.Event() + + async def communicate(): + started.set() + await asyncio.Event().wait() + + probe = SimpleNamespace(communicate=communicate) + monkeypatch.setattr("src.tools.ssh_pool.os.path.exists", lambda path: path == socket) + monkeypatch.setattr("src.tools.ssh_pool.asyncio.create_subprocess_exec", AsyncMock(return_value=probe)) + stopped = AsyncMock() + monkeypatch.setattr(pool, "_stop_process", stopped) + task = asyncio.create_task(pool.close_host(host, "root")) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert pool._masters[key] is master + assert master.returncode is None + stopped.assert_awaited_once_with(probe) From 4bd904540d495bd64b167098760c7def0ffdfa89 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:15:31 -0400 Subject: [PATCH 055/162] Verify CA endpoint principals and persisted-disable race (#552 #553) --- src/tools/hosts/control.py | 15 ++++++++--- src/tools/hosts/registry.py | 7 ++++-- tests/test_campaign_process_hosts.py | 4 +++ tests/test_hosts_api.py | 37 ++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/tools/hosts/control.py b/src/tools/hosts/control.py index fc72a3938..6eb5719c6 100644 --- a/src/tools/hosts/control.py +++ b/src/tools/hosts/control.py @@ -19,8 +19,11 @@ from ..ssh import is_local_address from .registry import deterministic_host_id from .trust import ( - HostCandidate, HostTrustError, certificate_authority_key, - fingerprint_public_key, normalize_public_key, + HostCandidate, + HostTrustError, + certificate_authority_key, + fingerprint_public_key, + normalize_public_key, ) _ALIAS_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}$") @@ -313,12 +316,16 @@ async def test(self, token: str) -> HostCandidate: argv = ["sh", "-c", "printf 'odin-host-test linux\\n'"] else: legacy = candidate.trust_mode == "legacy" + key_alias = ( + candidate.address if candidate.trust_mode == "ca" + else f"odin-{candidate.host_id}" + ) known_hosts = ( self.registry.effective_legacy_known_hosts_path if legacy else self.registry.materialize_trust( candidate.host_id, - f"odin-{candidate.host_id}", + key_alias, candidate.trust_mode, candidate.host_keys, ) @@ -351,7 +358,7 @@ async def test(self, token: str) -> HostCandidate: *( [] if legacy - else ["-o", f"HostKeyAlias=odin-{candidate.host_id}"] + else ["-o", f"HostKeyAlias={key_alias}"] ), "-o", "ConnectTimeout=10", diff --git a/src/tools/hosts/registry.py b/src/tools/hosts/registry.py index a6889c003..7a84dd54f 100644 --- a/src/tools/hosts/registry.py +++ b/src/tools/hosts/registry.py @@ -347,7 +347,8 @@ def _stage( diagnostic="Duplicate host identity; resolve the configured UUID collision", ) old = previous.get(alias) - if old is not None and old.trust_state == "mismatch" and self._same_identity(old, target): + if (old is not None and old.trust_state == "mismatch" + and self._same_identity(old, target)): target = replace( target, targetable=False, trust_state="mismatch", last_test=old.last_test, ) @@ -489,7 +490,9 @@ def _target_from_config(self, alias: str, config: ToolHost, generation: int) -> trust_state = "invalid" targetable = False else: - host_key_alias = f"odin-{host_id}" + # CA certificates authenticate endpoint principals, not Odin's + # private record UUID. Per-host files still isolate CA trust. + host_key_alias = config.address if trust_mode == "ca" else f"odin-{host_id}" known_hosts = self.materialize_trust( host_id, host_key_alias, trust_mode, keys ) diff --git a/tests/test_campaign_process_hosts.py b/tests/test_campaign_process_hosts.py index bfa2e0ef8..47743992f 100644 --- a/tests/test_campaign_process_hosts.py +++ b/tests/test_campaign_process_hosts.py @@ -89,6 +89,10 @@ async def test_ca_enrollment_uses_certificate_signer_not_leaf(tmp_path, monkeypa path = registry.materialize_trust(candidate.host_id, "alias", "ca", candidate.host_keys) with open(path) as file: assert file.read() == f"@cert-authority alias {authority}\n" + registry.publish({"remote": candidate.as_tool_host()}) + assert registry.get("remote").host_key_alias == "example.invalid" + with open(registry.get("remote").known_hosts_path) as file: + assert file.read() == f"@cert-authority example.invalid {authority}\n" with pytest.raises(HostTrustError, match="malformed"): certificate_authority_key(cert(b"user-ca", certificate_type=1)[0]) diff --git a/tests/test_hosts_api.py b/tests/test_hosts_api.py index 2f47164e5..4c6563131 100644 --- a/tests/test_hosts_api.py +++ b/tests/test_hosts_api.py @@ -105,6 +105,43 @@ async def _persist_ok(changes): return None, False +@pytest.mark.asyncio +async def test_connection_mismatch_cannot_invalidate_persisted_disable(monkeypatch, tmp_path): + bot = _bot(tmp_path) + saving, saved, scanned = asyncio.Event(), asyncio.Event(), asyncio.Event() + + async def persist(changes): + saving.set() + await saved.wait() + return None, False + + async def mismatch(*args, **kwargs): + scanned.set() + return 255, b"REMOTE HOST IDENTIFICATION HAS CHANGED" + + monkeypatch.setattr(hosts_api.config_persistence, "persist_config_paths_locked", persist) + monkeypatch.setattr(hosts_control, "_run_argv", mismatch) + async with await _client(bot) as client: + response = await client.post("/api/hosts/candidates", json={ + "alias": "alpha", **bot.config.tools.hosts["alpha"].model_dump(), + }) + candidate = (await response.json())["candidate_token"] + mutation = asyncio.create_task(client.post( + "/api/hosts/alpha/enabled", json={"enabled": False}, + )) + await saving.wait() + testing = asyncio.create_task(client.post(f"/api/hosts/candidates/{candidate}/test")) + await scanned.wait() + await asyncio.sleep(0) + assert not testing.done() + saved.set() + assert (await mutation).status == 200 + assert (await testing).status == 424 + assert bot.config.tools.hosts["alpha"].enabled is False + assert bot.host_registry.get("alpha").enabled is False + assert bot.host_registry.get("alpha").trust_state == "mismatch" + + def _candidate(alias: str, *, existing: ToolHost | None, token="candidate-1") -> HostCandidate: return HostCandidate( token=token, From f24f8dfc2926019d3b5f0037d698d92f9256284e Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:15:31 -0400 Subject: [PATCH 056/162] Fence process admission and require whole-execution cleanup proof (#432 #544 #545 #546 #547 #548 #549) --- src/tools/process_manager.py | 253 +++++++++++------ tests/test_campaign_process_lifecycle.py | 286 ++++++++++++++++++++ tests/test_process_retention_error_paths.py | 12 +- tests/test_remote_process_streaming.py | 11 +- tests/test_remote_processes.py | 15 +- 5 files changed, 481 insertions(+), 96 deletions(-) create mode 100644 tests/test_campaign_process_lifecycle.py diff --git a/src/tools/process_manager.py b/src/tools/process_manager.py index d7b97ce71..5f3e79008 100644 --- a/src/tools/process_manager.py +++ b/src/tools/process_manager.py @@ -151,7 +151,7 @@ def alive(): except (OSError,ValueError,KeyError): pass try: os.close(stdin_fd) except Exception: pass -record={"exit_code":rc,"empty":not alive(),"timed_out":timed_out,"output_truncated":output_state["truncated"],"emitted":output_state["emitted"],"finished_at":time.time(),"output_masked":output_masked} +record={"exit_code":rc,"empty":False,"group_empty":not alive(),"containment":"process_group_only","timed_out":timed_out,"output_truncated":output_state["truncated"],"emitted":output_state["emitted"],"finished_at":time.time(),"output_masked":output_masked} tmp=exit_path+".tmp" open(tmp,"w").write(json.dumps(record,separators=(",",":"))) os.replace(tmp,exit_path) @@ -236,10 +236,8 @@ def group_alive(): else: data=snapshot[cursor:cursor+limit] except FileNotFoundError: pass - if exit_record is not None and not exit_record.get("empty",False): - emit(ok=False,unknown=True,error="remote process group emptiness was not verified",output=base64.b64encode(data).decode(),cursor=cursor+len(data),size=total,ready=ready) - raise SystemExit(4) - emit(ok=True,status="exited" if exit_record is not None else "running",exit=exit_record,output=base64.b64encode(data).decode(),start=cursor,cursor=cursor+len(data),size=total,emitted=emitted,tail_withheld=tail_withheld,capture_error=capture_error,identity=identity(),ready=ready) + cleanup_unknown=exit_record is not None and not (exit_record.get("empty") is True and exit_record.get("containment")=="owned_descendants") + emit(ok=True,status="unknown" if cleanup_unknown else "exited" if exit_record is not None else "running",unknown=cleanup_unknown,cleanup_error="remote cleanup has only process-group evidence; escaped descendants are unverified" if cleanup_unknown else None,exit=exit_record,output=base64.b64encode(data).decode(),start=cursor,cursor=cursor+len(data),size=total,emitted=emitted,tail_withheld=tail_withheld,capture_error=capture_error,identity=identity(),ready=ready) elif op=="expire": record=load("exit.json") if record is None or time.time() str | None: # ------------------------------------------------------------------ async def start(self, host: str, command: str, timeout: int = 300, *, owner_id: str | None = None, host_alias: str = "", host_identity: str = "", origin_channel: str = "", scope_id: str = "", host_binding: dict | None = None, host_lease: HostLease | None = None) -> str: + running = self._active_count() + self._pending_starts + if running >= MAX_CONCURRENT: + return self._refuse_start( + host_lease, f"Cannot start: {running} processes already running (max {MAX_CONCURRENT}).", + ) + self._pending_starts += 1 + try: + return await self._start_local_reserved( + host, command, timeout, owner_id=owner_id, host_alias=host_alias, + host_identity=host_identity, origin_channel=origin_channel, + scope_id=scope_id, host_binding=host_binding, host_lease=host_lease, + ) + finally: + self._pending_starts -= 1 + + def _active_count(self) -> int: + return sum( + 1 for info in self._processes.values() + if not info.restored and ( + info.status == "running" or + (not info.session_confirmed_empty and ( + info.process is not None or info.remote_lease is not None + )) + ) + ) + + async def _start_local_reserved(self, host: str, command: str, timeout: int = 300, *, owner_id: str | None = None, host_alias: str = "", host_identity: str = "", origin_channel: str = "", scope_id: str = "", host_binding: dict | None = None, host_lease: HostLease | None = None) -> str: """Start a background process locally. Returns confirmation with PID. ``host_lease`` is the generation-bound admission evidence the handler @@ -1620,14 +1666,6 @@ async def start(self, host: str, command: str, timeout: int = 300, *, owner_id: return self._refuse_start(host_lease, "Error: host force-revoked; process not started.") revoke_epoch = self._local_revoke_epochs.get(alias, 0) - # Enforce concurrency limit (only count running) - running = sum(1 for p in self._processes.values() if p.status == "running") - if running >= MAX_CONCURRENT: - return self._refuse_start( - host_lease, - f"Cannot start: {running} processes already running (max {MAX_CONCURRENT}).", - ) - try: # start_new_session puts the shell at the head of its own process # group, so kill()/shutdown() can take out descendants @@ -1682,7 +1720,6 @@ async def start(self, host: str, command: str, timeout: int = 300, *, owner_id: ) self._processes[pid] = info self._retained_generations[info.generation] = info - self._persist_output(info) self._own_children.add(pid) # Drainage and terminal-state publication are SEPARATE tasks: @@ -1691,6 +1728,12 @@ async def start(self, host: str, command: str, timeout: int = 300, *, owner_id: try: info._reader_task = asyncio.create_task(self._read_output(info)) info._exit_task = asyncio.create_task(self._watch_exit(info)) + from ..async_utils import fire_and_forget + + info._lifetime_task = fire_and_forget( + self._enforce_lifetime(info, MAX_LIFETIME_SECONDS), name=f"process_lifetime:{pid}" + ) + self._persist_output(info) except BaseException: # The job is already spawned and recorded, so it now OWNS the # lease. Tear the process down instead of leaving an untracked @@ -1699,13 +1742,6 @@ async def start(self, host: str, command: str, timeout: int = 300, *, owner_id: await self._terminate_bound_host_job(info) raise - # Auto-kill after max lifetime - from ..async_utils import fire_and_forget - - fire_and_forget( - self._enforce_lifetime(pid, MAX_LIFETIME_SECONDS), name=f"process_lifetime:{pid}" - ) - if (revoke_epoch != self._local_revoke_epochs.get(alias, 0) or alias in self._revoking_aliases or (host_lease is not None and host_lease.revoked)): @@ -1717,7 +1753,6 @@ async def start(self, host: str, command: str, timeout: int = 300, *, owner_id: info.status = "unknown" info.finished_at = info.finished_at or time.time() info.capture_error = info.capture_error or "process cleanup could not be confirmed" - self._retire_execution_lease(info) self._persist_output(info) return ("Error: host force-revoked; process terminated." if gone else "Error: host force-revoked; process outcome unknown outcome_unknown=true.") @@ -1733,11 +1768,16 @@ async def start_remote(self, lease, command: str, *, owner_id: str | None = None lease.release() return "Error: host force-revoked; process not started." revoke_epoch = self._local_revoke_epochs.get(alias, 0) + running = self._active_count() + self._pending_starts + if running >= MAX_CONCURRENT: + lease.release() + return f"Cannot start: {running} processes already running (max {MAX_CONCURRENT})." # Reserve before dispatch, including starts still awaiting settlement. if self._spool_quota_remaining() < OUTPUT_CAPTURE_BYTES: lease.release() return "Cannot start: process retention quota exhausted." self._pending_remote_reservations += OUTPUT_CAPTURE_BYTES + self._pending_starts += 1 try: return await self._start_remote_reserved( lease, command, owner_id=owner_id, host_alias=host_alias, @@ -1747,13 +1787,10 @@ async def start_remote(self, lease, command: str, *, owner_id: str | None = None ) finally: self._pending_remote_reservations -= OUTPUT_CAPTURE_BYTES + self._pending_starts -= 1 async def _start_remote_reserved(self, lease, command: str, *, owner_id: str | None = None, host_alias: str = "", host_identity: str = "", origin_channel: str = "", scope_id: str = "", host_binding: dict | None = None, revoke_alias: str = "", revoke_epoch: int = 0) -> str: """Start a detached SSH process with remote file/FIFO-backed I/O.""" - running = sum(1 for p in self._processes.values() if p.status == "running") - if running >= MAX_CONCURRENT: - lease.release() - return f"Cannot start: {running} processes already running (max {MAX_CONCURRENT})." if self._remote_exec is None: lease.release() return "Failed to start process: remote execution is unavailable" @@ -1859,13 +1896,17 @@ async def _start_remote_reserved(self, lease, command: str, *, owner_id: str | N ) info = self._processes[handle] self._retained_generations[info.generation] = info - self._persist_output(info) from ..async_utils import fire_and_forget - fire_and_forget( - self._enforce_lifetime(handle, MAX_LIFETIME_SECONDS), - name=f"remote_process_lifetime:{handle}", - ) + try: + info._lifetime_task = fire_and_forget( + self._enforce_lifetime(info, MAX_LIFETIME_SECONDS), + name=f"remote_process_lifetime:{handle}", + ) + self._persist_output(info) + except BaseException: + await self._kill_remote(info) + raise return f"Process started (PID {handle}): {safe_text(command)}" async def poll( @@ -2014,7 +2055,7 @@ def _expire_output(self, info: ProcessInfo) -> None: if info.output_lease is not None: info.output_lease.release() info.output_lease = None - if info.host_lease is not None and info.status != "running": + if info.host_lease is not None and info.session_confirmed_empty: # Expiry only revokes EVIDENCE; it must never drop the generation # lease of a job that is STILL RUNNING (H2). That lease is exactly # what keeps the alias's reference count non-zero, which is how @@ -2133,7 +2174,7 @@ async def kill(self, pid: int, *, authorized: Callable[[ProcessInfo], bool] | No info = self._processes.get(pid) if not info: return f"No process with PID {pid}." - if info.status != "running": + if info.status != "running" and info.session_confirmed_empty: return f"Process {pid} already {info.status}." if authorized is not None and not authorized(info): return "Error: process access denied." @@ -2234,12 +2275,11 @@ async def _teardown_unsettled_remote(self, lease, root: str, token: str) -> bool "set -eu; " f"d={quoted_root}; test -f \"$d/ready.json\"; " f"python3 -c {shlex.quote(execute_controller)} " - f"{quoted_root} {shlex.quote(token)} kill '' 0; " - "rm -rf -- \"$d\"" + f"{quoted_root} {shlex.quote(token)} kill '' 0" ) try: - code, _output = await self._remote_exec(lease.target, command, 15) - return code == 0 + code, output = await self._remote_exec(lease.target, command, 15) + return code == 0 and self._remote_cleanup_proven(self._parse_remote_reply(output)) except Exception: log.warning( "Could not verify cleanup of unsettled remote process on %s", @@ -2293,8 +2333,17 @@ async def _poll_remote( info.total_output_bytes = int(reply.get("emitted", info.retained_bytes)) if reply.get("capture_error"): info.capture_error = "process output capture incomplete" + if reply.get("status") == "unknown" or reply.get("unknown"): + info.transport_unknown = True + info.status = "unknown" + exit_record = reply.get("exit") or {} + info.exit_code = exit_record.get("exit_code") if reply.get("status") == "exited": exit_record = reply.get("exit") or {} + if not (exit_record.get("empty") is True and exit_record.get("containment") == "owned_descendants"): + info.transport_unknown = True + return f"[PID {info.pid}] status=unknown outcome_unknown=true\nremote descendant cleanup unverified" + info.session_confirmed_empty = True info.exit_code = int(exit_record.get("exit_code", 1)) if info.status != "killed": info.status = "completed" if info.exit_code == 0 else "failed" @@ -2316,6 +2365,10 @@ async def _poll_remote( tail_withheld=bool(reply.get("tail_withheld", False))) def _retire_execution_lease(self, info: ProcessInfo) -> None: + task = info._lifetime_task + if task is not None and task is not asyncio.current_task(): + task.cancel() + info._lifetime_task = None if info.remote_lease is not None: info.remote_lease.release() info.remote_lease = None @@ -2339,36 +2392,51 @@ async def _write_remote(self, info: ProcessInfo, text: str) -> str: f"outcome unknown outcome_unknown=true: {safe_error(exc)}" ) reply = self._parse_remote_reply(output) - if code != 0 or reply is None or not reply.get("ok"): + if (code != 0 or reply is None or reply.get("ok") is not True + or reply.get("written") != len(text.encode("utf-8"))): info.transport_unknown = True detail = safe_error(reply.get("error", "") if reply else output) + accepted = reply.get("written") if reply else None return ( - f"Failed to write to PID {info.pid}: outcome unknown " + f"Failed to write to PID {info.pid}: accepted_bytes={accepted}; outcome unknown " f"outcome_unknown=true: {detail}" ) - return f"Wrote {len(text)} bytes to PID {info.pid}." + return f"Wrote {reply['written']} bytes to PID {info.pid}." async def _kill_remote(self, info: ProcessInfo) -> str: command = self._remote_controller_command(info, "kill") try: code, output = await self._remote_call(info, command, 15) except Exception as exc: + info.transport_unknown = True return ( f"Failed to kill PID {info.pid}: SSH transport failed; " f"outcome unknown outcome_unknown=true: {safe_error(exc)}" ) reply = self._parse_remote_reply(output) - if code != 0 or reply is None or not reply.get("ok"): + if code != 0 or not self._remote_cleanup_proven(reply): info.transport_unknown = True - detail = safe_error(reply.get("error", "") if reply else output) + detail = safe_error( + reply.get("error") or "remote owned-descendant cleanup unverified" + if reply else output + ) return ( f"Failed to kill PID {info.pid}: outcome unknown " f"outcome_unknown=true: {detail}" ) + assert reply is not None if reply.get("already_exited"): - await self._poll_remote(info, 0) + info.session_confirmed_empty = True + info.status = "completed" if (reply.get("exit") or {}).get("exit_code") == 0 else "failed" + info.exit_code = (reply.get("exit") or {}).get("exit_code") + info.finished_at = info.finished_at or time.time() + info.reserved_bytes = 0 + self._retire_execution_lease(info) + self._persist_output(info) return f"Process {info.pid} already exited; poll to collect its outcome." info.status = "killed" + info.session_confirmed_empty = True + info.reserved_bytes = 0 exit_record = reply.get("exit") or {} info.exit_code = exit_record.get("exit_code") info.finished_at = info.finished_at or float(exit_record.get("finished_at", time.time())) @@ -2376,6 +2444,13 @@ async def _kill_remote(self, info: ProcessInfo) -> str: self._persist_output(info) return f"Process {info.pid} killed." + @staticmethod + def _remote_cleanup_proven(reply: dict | None) -> bool: + return bool( + reply and reply.get("ok") is True and reply.get("empty") is True + and reply.get("containment") == "owned_descendants" + ) + async def force_revoke_host(self, alias: str) -> dict[str, int]: """Terminate every running job bound to ``alias``, then drop its output. @@ -2395,13 +2470,13 @@ async def force_revoke_host(self, alias: str) -> dict[str, int]: infos = {info.generation: info for info in self._processes.values()} infos.update(self._retained_generations) for info in infos.values(): - running = info.status == "running" and not info.restored + running = not info.session_confirmed_empty and not info.restored if info.remote and info.host == alias: if running: summary["attempted"] += 1 try: - result = await self._kill_remote(info) - proven = result.endswith("killed.") + await self._kill_remote(info) + proven = info.session_confirmed_empty except Exception: log.exception("Force-revoke remote cleanup failed for PID %d", info.pid) proven = False @@ -2426,16 +2501,12 @@ async def _terminate_bound_host_job(self, info: ProcessInfo) -> bool: """Kill one local job and report whether termination was PROVEN. Force-revoke has already fenced the generation, so the exit watcher may - be racing this teardown. The lease is therefore dropped here (it is a - reference count, not a handle to the host) before the group teardown, - and the terminal status is only published once an affirmative + be racing this teardown. Keep the lease until whole-job cleanup settles; + the terminal status is only published once an affirmative observation exists — ``_kill_group_until_gone`` returns True solely on a verified-empty scan, so a TERM-immune descendant can never be reported as killed. """ - if info.host_lease is not None: - info.host_lease.release() - info.host_lease = None if info.process is not None: from ..tools.ssh import terminate_process_tree @@ -2452,10 +2523,12 @@ async def _terminate_bound_host_job(self, info: ProcessInfo) -> bool: gone = False if gone: info.status = "killed" + info.session_confirmed_empty = True info.exit_code = ( info.process.returncode if info.process is not None else info.exit_code ) info.finished_at = info.finished_at or time.time() + self._retire_execution_lease(info) log.info("Force-revoke killed PID %d on a revoked host", info.pid) else: log.error( @@ -2494,10 +2567,9 @@ async def terminate_generation(self, generation: str) -> bool: not PID, means a recycled handle can never be aimed at another job, and an already-exited process is a settled outcome rather than a failure. - Returns True only on an affirmative observation: the record reached a - terminal status, or an owned-group teardown verified the session is - empty. False means the outcome is genuinely unknown and the caller must - say so. + Returns True only on affirmative whole-execution cleanup evidence. + Leader exit, terminal status and process-group emptiness alone cannot + establish it. False means the outcome is genuinely unknown. """ info = self._retained_generations.get(generation) if info is None: @@ -2509,14 +2581,16 @@ async def terminate_generation(self, generation: str) -> bool: # The generation is not tracked at all: nothing of ours is running # under it, which is a settled (if already-forgotten) outcome. return True - if info.status != "running" or info.restored: + if info.session_confirmed_empty: return True + if info.restored: + return False if info.remote: - result = await self._kill_remote(info) + await self._kill_remote(info) # ``_kill_remote`` reports "already exited" when the remote # supervisor confirms the job is gone — a settled outcome, not a # failure to terminate. - return info.status == "killed" or "already exited" in result + return info.session_confirmed_empty return await self._terminate_bound_host_job(info) async def shutdown(self) -> int: @@ -2542,10 +2616,10 @@ async def shutdown(self) -> int: # publishes terminal state and reaps surviving group members, # which closes the pipe and unblocks the drainer. for pid, info in list(self._processes.items()): - if info.status == "running": + if not info.session_confirmed_empty and not info.restored: try: - await self.kill(pid) - killed += 1 + if await self.terminate_generation(info.generation): + killed += 1 except Exception: log.warning("Failed to kill PID %d during shutdown", pid) # 2) Let every reader/reaper finish so no group cleanup is left pending. @@ -2582,6 +2656,10 @@ async def shutdown(self) -> int: # caller, which owns the re-exec decision. unproven: list[int] = [] for pid, info in list(self._processes.items()): + if info.remote: + if not info.session_confirmed_empty: + unproven.append(pid) + continue if info.session_confirmed_empty or info.process is None: # A local job holding its own generation lease must still # retire it before we re-exec (H2): a lease is an in-memory @@ -2598,7 +2676,7 @@ async def shutdown(self) -> int: log.exception("Final cleanup verification failed for PID %d", pid) unproven.append(pid) finally: - if info.host_lease is not None: + if info.host_lease is not None and info.session_confirmed_empty: info.host_lease.release() info.host_lease = None if killed: @@ -2627,6 +2705,7 @@ def cleanup(self) -> int: pid for pid, info in self._processes.items() if info.status != "running" + and (info.session_confirmed_empty or info.restored) and info.finished_at is not None and now >= info.finished_at + OUTPUT_RETENTION_SECONDS and all( @@ -2644,7 +2723,7 @@ def cleanup(self) -> int: expired.host_lease = None self._expire_output(expired) for generation, info in list(self._retained_generations.items()): - if info.finished_at is not None and now >= info.finished_at + OUTPUT_RETENTION_SECONDS: + if (info.session_confirmed_empty or info.restored) and info.finished_at is not None and now >= info.finished_at + OUTPUT_RETENTION_SECONDS: self._expire_output(info) self._retained_generations.pop(generation) # Adopted orphans die as zombies (nothing else will wait on them); @@ -2790,7 +2869,7 @@ async def _read_output(self, info: ProcessInfo) -> None: self._persist_output(info) async def _watch_exit(self, info: ProcessInfo) -> None: - """Publish terminal state at LEADER exit, then reap the group. + """Record leader exit, then publish terminal state after owned cleanup. Separated from stdout drainage (PR #244 round-1): ``process.wait()`` returns when the leader exits regardless of who still holds the @@ -2804,14 +2883,7 @@ async def _watch_exit(self, info: ProcessInfo) -> None: try: await _wait_leader_exit(info.process) info.exit_code = info.process.returncode - info.finished_at = time.time() - if info.status == "running": - info.status = "completed" if info.exit_code == 0 else "failed" self._persist_output(info) - # Settles the retained-output deadline AND retires the - # generation lease the job held (H2) — the job is over, so its - # lease must not keep the alias's generation pinned. - self._retire_execution_lease(info) except Exception: if info.status == "running": info.status = "unknown" @@ -2822,7 +2894,6 @@ async def _watch_exit(self, info: ProcessInfo) -> None: self._persist_output(info) except Exception: log.exception("Could not persist uncertain exit for PID %d", info.pid) - self._retire_execution_lease(info) # Reap while ownership is fresh: a non-empty group keeps the leader # pid from being recycled — but ONLY while a member survives, so a @@ -2834,6 +2905,7 @@ async def _watch_exit(self, info: ProcessInfo) -> None: if isinstance(info.process, SupervisedShell): info.session_confirmed_empty = await info.process.terminate_tree(grace=2.0) + self._publish_local_settlement(info) return info.session_confirmed_empty = await _terminate_session_until_empty( info.process.pid, @@ -2849,19 +2921,34 @@ async def _watch_exit(self, info: ProcessInfo) -> None: except Exception: info.session_confirmed_empty = False log.debug("session reap after PID %d exit failed", info.pid, exc_info=True) + self._publish_local_settlement(info) + + def _publish_local_settlement(self, info: ProcessInfo) -> None: if not info.session_confirmed_empty: + info.status = "unknown" log.error( "Could not confirm PID %d's owned session is empty after " "leader exit — shutdown will re-verify", info.pid, ) + else: + if info.status == "running": + info.status = "completed" if info.exit_code == 0 else "failed" + info.finished_at = info.finished_at or time.time() + self._retire_execution_lease(info) + try: + self._persist_output(info) + except Exception: + log.exception("Could not persist cleanup outcome for PID %d", info.pid) - async def _enforce_lifetime(self, pid: int, max_seconds: int) -> None: + async def _enforce_lifetime(self, info: ProcessInfo, max_seconds: int) -> None: """Auto-kill process after max lifetime.""" await asyncio.sleep(max_seconds) - info = self._processes.get(pid) - if info and info.status == "running": + if self._processes.get(info.pid) is not info: + return + pid = info.pid + if info.status == "running": log.warning("Auto-killing PID %d after %ds lifetime limit", pid, max_seconds) await self.kill(pid) - elif info and info.status == "unknown" and not info.remote and info.process is not None: + elif not info.session_confirmed_empty: log.warning("Retrying unverified cleanup for PID %d after %ds lifetime limit", pid, max_seconds) - await self._terminate_bound_host_job(info) + await self.terminate_generation(info.generation) diff --git a/tests/test_campaign_process_lifecycle.py b/tests/test_campaign_process_lifecycle.py new file mode 100644 index 000000000..901c690fb --- /dev/null +++ b/tests/test_campaign_process_lifecycle.py @@ -0,0 +1,286 @@ +import asyncio +import base64 +import builtins +import io +import json +import re +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.tools import process_manager as pm + + +class Lease: + def __init__(self): + self.target = SimpleNamespace(alias="remote") + self.revoked = False + self.released = False + + async def run(self, factory): + return await factory() + + def release(self): + self.released = True + + +@pytest.fixture +def registry(monkeypatch): + reg = pm.ProcessRegistry() + monkeypatch.setattr("src.async_utils.fire_and_forget", lambda coro, **kw: coro.close()) + reg._schedule_output_expiry = lambda info: None + return reg + + +def info(pid=9123, **kwargs): + return pm.ProcessInfo(pid, "harmless fixture", "localhost", 1, **kwargs) + + +async def test_mixed_pending_starts_reserve_shared_capacity(registry, monkeypatch): + monkeypatch.setattr(pm, "MAX_CONCURRENT", 1) + entered, finish = asyncio.Event(), asyncio.Event() + + async def spawn(*args, **kwargs): + entered.set() + await finish.wait() + raise OSError("fixture spawn refused") + + monkeypatch.setattr("src.tools.local_supervisor.create_supervised_shell", spawn) + first = asyncio.create_task(registry.start("localhost", "harmless fixture")) + await entered.wait() + lease = Lease() + assert "Cannot start" in await registry.start_remote(lease, "harmless fixture") + assert lease.released + assert "Cannot start" in await registry.start("localhost", "harmless fixture") + finish.set() + assert "Failed to start" in await first + assert registry._pending_starts == 0 + + +async def test_cancelled_pending_start_returns_capacity(registry, monkeypatch): + entered = asyncio.Event() + + async def spawn(*args, **kwargs): + entered.set() + await asyncio.Event().wait() + + monkeypatch.setattr("src.tools.local_supervisor.create_supervised_shell", spawn) + lease = Lease() + task = asyncio.create_task(registry.start("localhost", "fixture", host_lease=lease)) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert registry._pending_starts == 0 + assert lease.released + + +async def test_initial_local_persistence_failure_has_lifecycle_and_teardown(registry, monkeypatch): + proc = SimpleNamespace(pid=9123, returncode=None) + monkeypatch.setattr( + "src.tools.local_supervisor.create_supervised_shell", AsyncMock(return_value=proc), + ) + entered = [] + + async def lifecycle(record): + entered.append(record) + + monkeypatch.setattr(registry, "_read_output", lifecycle) + monkeypatch.setattr(registry, "_watch_exit", lifecycle) + def disk_full(record): + raise OSError("fixture disk full") + + monkeypatch.setattr(registry, "_persist_output", disk_full) + teardown = AsyncMock(return_value=False) + monkeypatch.setattr(registry, "_terminate_bound_host_job", teardown) + with pytest.raises(OSError): + await registry.start("localhost", "fixture") + record = registry._processes[9123] + assert record._reader_task is not None and record._exit_task is not None + assert 9123 in registry._own_children + teardown.assert_awaited_once_with(record) + await asyncio.gather(record._reader_task, record._exit_task) + + +async def test_initial_remote_persistence_failure_has_backstop_and_teardown(registry, monkeypatch): + async def remote(target, command, timeout): + token = re.search(r'\$d" ([^ ]+) ', command).group(1) + return 0, json.dumps(dict(token=token, pid=101, pgid=101, sid=99, start_id="123")) + + registry._remote_exec = remote + captured = [] + + def lifetime(coro, **kwargs): + captured.append(coro.cr_frame.f_locals["info"]) + coro.close() + + monkeypatch.setattr("src.async_utils.fire_and_forget", lifetime) + def disk_full(record): + raise OSError("fixture disk full") + + monkeypatch.setattr(registry, "_persist_output", disk_full) + teardown = AsyncMock(return_value="failed, fixture") + monkeypatch.setattr(registry, "_kill_remote", teardown) + with pytest.raises(OSError): + await registry.start_remote(Lease(), "fixture") + record = registry._processes[-1] + assert captured == [record] + teardown.assert_awaited_once_with(record) + assert registry._pending_starts == 0 + + +async def test_lifetime_timer_cannot_target_recycled_pid(registry, monkeypatch): + old, replacement = info(), info() + registry._processes[old.pid] = replacement + monkeypatch.setattr(pm.asyncio, "sleep", AsyncMock()) + kill = AsyncMock() + monkeypatch.setattr(registry, "kill", kill) + await registry._enforce_lifetime(old, 1) + kill.assert_not_awaited() + await registry._enforce_lifetime(replacement, 1) + kill.assert_awaited_once_with(replacement.pid) + + +async def test_leader_exit_holds_authority_until_descendant_settlement(registry, monkeypatch): + lease = Lease() + record = info(process=SimpleNamespace(pid=9123, returncode=0), host_lease=lease) + registry._processes[record.pid] = record + registry._retained_generations[record.generation] = record + monkeypatch.setattr(pm, "_wait_leader_exit", AsyncMock()) + entered, finish = asyncio.Event(), asyncio.Event() + + async def settle(*args, **kwargs): + entered.set() + await finish.wait() + return True + + monkeypatch.setattr(pm, "_terminate_session_until_empty", settle) + task = asyncio.create_task(registry._watch_exit(record)) + await entered.wait() + assert record.status == "running" + assert not lease.released + teardown = AsyncMock(return_value=False) + monkeypatch.setattr(registry, "_terminate_bound_host_job", teardown) + assert not await registry.terminate_generation(record.generation) + teardown.assert_awaited_once_with(record) + finish.set() + await task + assert record.status == "completed" and record.session_confirmed_empty + assert lease.released + + +async def test_remote_unverified_cleanup_is_not_settled_or_counted(registry, monkeypatch): + record = info(-1, remote=True, remote_lease=Lease()) + registry._processes[-1] = record + registry._retained_generations[record.generation] = record + monkeypatch.setattr(registry, "_remote_call", AsyncMock(return_value=( + 0, json.dumps({"ok": True, "already_exited": True}), + ))) + assert not await registry.terminate_generation(record.generation) + assert record.status == "running" + assert not record.remote_lease.released + with pytest.raises(pm.ProcessCleanupError): + await registry.shutdown() + assert not record.session_confirmed_empty + + +async def test_remote_proven_structured_cleanup_settles(registry, monkeypatch): + record = info(-1, remote=True, remote_lease=Lease()) + registry._processes[-1] = record + reply = { + "ok": True, "empty": True, "containment": "owned_descendants", + "already_exited": True, "exit": {"exit_code": 0}, + } + monkeypatch.setattr(registry, "_remote_call", AsyncMock(return_value=(0, json.dumps(reply)))) + await registry._kill_remote(record) + assert record.session_confirmed_empty and record.status == "completed" + assert record.remote_lease is None + + +def run_controller(monkeypatch, capsys, operation, payload, *, exit_record=None, writes=()): + ready = dict(token="fixture", pid=101, pgid=101, sid=99, start_id="") + real_open = builtins.open + + def fixture_open(path, *args, **kwargs): + if path == "/fixture/ready.json": + return io.StringIO(json.dumps(ready)) + if path == "/fixture/exit.json": + if exit_record is None: + raise FileNotFoundError + return io.StringIO(json.dumps(exit_record)) + raise AssertionError(f"unexpected file access: {path}") + + monkeypatch.setattr(builtins, "open", fixture_open) + monkeypatch.setattr(sys, "argv", ["controller", "/fixture", "fixture", operation, payload, "0"]) + monkeypatch.setattr(pm.os, "getpgid", lambda pid: 101) + monkeypatch.setattr(pm.os, "getsid", lambda pid: 99) + monkeypatch.setattr(pm.os, "killpg", lambda *args: (_ for _ in ()).throw(ProcessLookupError())) + opened, closed, accepted = [], [], [] + monkeypatch.setattr(pm.os, "open", lambda *args: opened.append(args) or 9123) + monkeypatch.setattr(pm.os, "close", closed.append) + counts = iter(writes) + + def write(fd, data): + count = next(counts) + if isinstance(count, Exception): + raise count + accepted.append(data[:count]) + return count + + monkeypatch.setattr(pm.os, "write", write) + try: + exec(compile(pm._REMOTE_CONTROLLER, "", "exec"), {}) + except SystemExit: + pass + monkeypatch.setattr(builtins, "open", real_open) + return json.loads(capsys.readouterr().out), closed, b"".join(accepted) + + +def test_remote_fifo_short_write_retries_exact_remainder(monkeypatch, capsys): + data = b"x" * 10000 + reply, closed, accepted = run_controller( + monkeypatch, capsys, "write", base64.b64encode(data).decode(), writes=[4096, 5904], + ) + assert reply["ok"] and reply["written"] == 10000 + assert accepted == data and closed == [9123] + + +def test_remote_fifo_partial_failure_is_honest_and_closes(monkeypatch, capsys): + data = b"x" * 10000 + reply, closed, accepted = run_controller( + monkeypatch, capsys, "write", base64.b64encode(data).decode(), + writes=[4096, BrokenPipeError()], + ) + assert not reply["ok"] and reply["written"] == 4096 + assert len(accepted) == 4096 and closed == [9123] + + +def test_remote_fifo_backpressure_has_bounded_partial_outcome(monkeypatch, capsys): + ticks = iter([0, 0, 0, 3]) + monkeypatch.setattr(pm.time, "monotonic", lambda: next(ticks)) + monkeypatch.setattr(pm.time, "sleep", lambda seconds: None) + reply, closed, accepted = run_controller( + monkeypatch, capsys, "write", base64.b64encode(b"abcdef").decode(), + writes=[2, BlockingIOError()], + ) + assert reply["written"] == 2 and not reply["ok"] + assert accepted == b"ab" and closed == [9123] + + +def test_remote_group_emptiness_does_not_prove_escaped_descendant_absence(monkeypatch, capsys): + reply, _, _ = run_controller( + monkeypatch, capsys, "kill", "", exit_record={"empty": True, "exit_code": 0}, + ) + assert reply["ok"] is False and reply["unknown"] is True + assert reply["group_empty"] is True + assert "escaped descendants" in reply["error"] + + +async def test_remote_write_reports_utf8_bytes_and_refuses_unverified_prefix(registry, monkeypatch): + record = info(-1, remote=True) + call = AsyncMock(return_value=(0, json.dumps({"ok": True, "written": 2}))) + monkeypatch.setattr(registry, "_remote_call", call) + assert await registry._write_remote(record, "é") == "Wrote 2 bytes to PID -1." + assert "Failed to write" in await registry._write_remote(record, "éé") diff --git a/tests/test_process_retention_error_paths.py b/tests/test_process_retention_error_paths.py index 2d5aa15a4..52862f24d 100644 --- a/tests/test_process_retention_error_paths.py +++ b/tests/test_process_retention_error_paths.py @@ -177,7 +177,10 @@ async def test_remote_reply_validates_newly_observed_expiry_and_offset(expired): remote = AsyncMock(return_value=(0, json.dumps({ "ok": True, "output": base64.b64encode(b"abc").decode(), "size": 3, "cursor": 3, "emitted": 3, "start": 0, "status": "exited", - "exit": {"exit_code": 0, "finished_at": finished}, + "exit": { + "exit_code": 0, "finished_at": finished, + "empty": True, "containment": "owned_descendants", + }, }))) reg = pm.ProcessRegistry(remote_exec=remote) lease = _Lease() @@ -209,6 +212,7 @@ async def test_failed_remote_expiry_still_revokes_local_evidence(tmp_path, caplo def test_cleanup_expires_old_generation_without_removing_reused_pid(evidence): reg, old = evidence + old.session_confirmed_empty = True old.finished_at = time.time() - pm.OUTPUT_RETENTION_SECONDS - 1 current = pm.ProcessInfo(old.pid, "new job", "localhost", time.time()) reg._processes[old.pid] = current @@ -242,7 +246,7 @@ async def test_watcher_missing_process_and_failed_wait_publish_failure(evidence, assert info.capture_error == "process exit could not be confirmed" -async def test_failed_leader_wait_retires_bound_host_lease(evidence, monkeypatch): +async def test_failed_leader_wait_retains_bound_host_lease(evidence, monkeypatch): reg, info = evidence lease = Mock() info.status = "running" @@ -252,8 +256,8 @@ async def test_failed_leader_wait_retires_bound_host_lease(evidence, monkeypatch monkeypatch.setattr(pm, "_wait_leader_exit", AsyncMock(side_effect=TimeoutError("unconfirmed"))) monkeypatch.setattr(pm, "_terminate_session_until_empty", AsyncMock(return_value=False)) await reg._watch_exit(info) - lease.release.assert_called_once_with() - assert info.host_lease is None + lease.release.assert_not_called() + assert info.host_lease is lease assert info.status == "unknown" assert info.finished_at is not None diff --git a/tests/test_remote_process_streaming.py b/tests/test_remote_process_streaming.py index 0295e78d1..2c19a4cf6 100644 --- a/tests/test_remote_process_streaming.py +++ b/tests/test_remote_process_streaming.py @@ -157,12 +157,12 @@ async def test_remote_slow_producer_streams_before_exit_and_advances_cursor(tmp_ assert await registry.write(-1, "next\n") == "Wrote 5 bytes to PID -1." terminal = await registry.poll(-1, wait_seconds=5) - assert "status=completed exit_code=0" in terminal + assert "status=unknown exit_code=0" in terminal display = terminal.split("\n[output retention] ")[0].split("\n", 1)[1] assert display == (expected + b"done\n").decode() assert info.remote_cursor == info.total_output_bytes == len(expected + b"done\n") - assert lease.release_count == 1 - assert info.remote_lease is None and info.output_lease is None + assert lease.release_count == 0 + assert info.remote_lease is lease and info.output_lease is None await asyncio.wait_for(supervisor.wait(), 5) @@ -188,13 +188,14 @@ async def test_remote_streaming_preserves_disk_cap_and_bounded_cursor_reads(tmp_ await registry.write(-1, "done\n") await asyncio.wait_for(supervisor.wait(), 5) record = json.loads((tmp_path / "exit.json").read_text()) - assert record["exit_code"] == 0 and record["empty"] + assert record["exit_code"] == 0 and not record["empty"] + assert record["group_empty"] and record["containment"] == "process_group_only" assert not record["timed_out"] and record["output_truncated"] assert record["emitted"] == cap + 65536 assert out.stat().st_size == cap assert out.read_bytes() == b"x" * cap await registry.poll(-1) - assert lease.release_count == 1 + assert lease.release_count == 0 for offset in (0, 8000): page = json.loads(await registry.poll( -1, cursor=info.generation + ":0" if offset == 0 else "", diff --git a/tests/test_remote_processes.py b/tests/test_remote_processes.py index 2f2f16bdf..f8042288c 100644 --- a/tests/test_remote_processes.py +++ b/tests/test_remote_processes.py @@ -50,6 +50,7 @@ def _remote_info( host=host, start_time=time.time() - 2, status=status, + session_confirmed_empty=status in {"completed", "failed", "killed"}, remote=True, remote_dir=remote_dir, remote_pid=101, @@ -68,6 +69,10 @@ def _start_token(command: str) -> str: def _reply(**values: object) -> tuple[int, str]: + if values.get("empty") is True: + values["containment"] = "owned_descendants" + if values.get("status") == "exited": + values["exit"] = {**values["exit"], "empty": True, "containment": "owned_descendants"} return 0, json.dumps({"ok": True, **values}) @@ -121,7 +126,7 @@ async def remote_exec(_target, _command, _timeout): assert expected in response assert lease.release_count == 1 assert len(calls) == 2 - assert "rm -rf" in calls[1] + assert calls[1].endswith("kill '' 0") @pytest.mark.asyncio @@ -159,8 +164,10 @@ async def remote_exec(_target, command, _timeout): await resume.wait() token = _start_token(command) return _reply(token=token, pid=101, pgid=101, sid=99, start_id="77") - assert " kill '' 0;" in command - return (0 if cleanup_ok else 1), "cleanup" + assert command.endswith(" kill '' 0") + return (0 if cleanup_ok else 1), json.dumps({ + "ok": cleanup_ok, "empty": cleanup_ok, "containment": "owned_descendants", + }) lease = _Lease("prod") registry = ProcessRegistry(remote_exec=remote_exec) @@ -475,6 +482,6 @@ async def kill(pid): monkeypatch.setattr("src.tools.process_manager.asyncio.sleep", no_wait) monkeypatch.setattr(registry, "kill", kill) - await registry._enforce_lifetime(-1, 1) + await registry._enforce_lifetime(registry._processes[-1], 1) assert killed == [-1] From e763acb019ce03b503965cbff87d5b5f09008ff3 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:22:27 -0400 Subject: [PATCH 057/162] Extend ownership and cancellation regression coverage with truthful remote evidence --- src/tools/process_manager.py | 13 +++- tests/test_campaign_process_hosts.py | 25 ++++++++ tests/test_campaign_process_lifecycle.py | 42 +++++++++++++ tests/test_campaign_process_patch.py | 4 +- tests/test_campaign_process_ssh.py | 24 +++++++- .../test_campaign_remote_supervisor_proof.py | 61 +++++++++++++++++++ tests/test_process_host_lease_authority.py | 6 +- tests/test_process_kill_status.py | 9 ++- tests/test_process_read_leases.py | 5 +- tests/test_process_tail_correctness.py | 6 +- tests/test_process_zero_offset.py | 5 +- 11 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 tests/test_campaign_remote_supervisor_proof.py diff --git a/src/tools/process_manager.py b/src/tools/process_manager.py index 5f3e79008..e2396072d 100644 --- a/src/tools/process_manager.py +++ b/src/tools/process_manager.py @@ -2116,6 +2116,8 @@ def render(chunk: bytes, shown_start: int) -> str: status = f"[PID {info.pid}] status={info.status}" if info.exit_code is not None: status += f" exit_code={info.exit_code}" + if info.transport_unknown: + status += " outcome_unknown=true" status += f" uptime={time.time() - info.start_time:.0f}s output_bytes={info.total_output_bytes}" return status + "\n" + (text or "(no output yet)") + "\n[output retention] " + json.dumps(meta, ensure_ascii=False, separators=(",", ":")) meta["text"] = text @@ -2366,8 +2368,13 @@ async def _poll_remote( def _retire_execution_lease(self, info: ProcessInfo) -> None: task = info._lifetime_task - if task is not None and task is not asyncio.current_task(): - task.cancel() + if task is not None: + try: + current = asyncio.current_task() + except RuntimeError: + current = None + if task is not current: + task.cancel() info._lifetime_task = None if info.remote_lease is not None: info.remote_lease.release() @@ -2931,7 +2938,7 @@ def _publish_local_settlement(self, info: ProcessInfo) -> None: "leader exit — shutdown will re-verify", info.pid, ) else: - if info.status == "running": + if info.status in {"running", "unknown"} and info.exit_code is not None: info.status = "completed" if info.exit_code == 0 else "failed" info.finished_at = info.finished_at or time.time() self._retire_execution_lease(info) diff --git a/tests/test_campaign_process_hosts.py b/tests/test_campaign_process_hosts.py index 47743992f..cbce14b16 100644 --- a/tests/test_campaign_process_hosts.py +++ b/tests/test_campaign_process_hosts.py @@ -1,6 +1,7 @@ import asyncio import base64 import struct +import subprocess from unittest.mock import AsyncMock import pytest @@ -105,3 +106,27 @@ async def test_ca_does_not_accept_leaf_only_scan(tmp_path, monkeypatch): "address": "example.invalid", "trust_mode": "ca", "expected_fingerprints": [fingerprint_public_key(key())], }, allow_tofu=False) + + +def test_ca_extraction_matches_real_openssh_certificate_signer(tmp_path): + # Offline key/certificate creation in disposable storage, no SSH server, + # trust-store mutation, network or process termination. + for name in ("authority", "leaf"): + subprocess.run([ + "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(tmp_path / name), + ], check=True, capture_output=True) + subprocess.run([ + "ssh-keygen", "-q", "-s", str(tmp_path / "authority"), "-I", "fixture", + "-h", "-n", "example.invalid", str(tmp_path / "leaf.pub"), + ], check=True, capture_output=True) + with open(tmp_path / "leaf-cert.pub") as file: + certificate = file.read() + with open(tmp_path / "authority.pub") as file: + authority = file.read() + assert fingerprint_public_key(certificate_authority_key(certificate)) == ( + fingerprint_public_key(authority) + ) + listing = subprocess.run([ + "ssh-keygen", "-L", "-f", str(tmp_path / "leaf-cert.pub"), + ], check=True, capture_output=True, text=True).stdout + assert "host certificate" in listing and "example.invalid" in listing diff --git a/tests/test_campaign_process_lifecycle.py b/tests/test_campaign_process_lifecycle.py index 901c690fb..c1229c427 100644 --- a/tests/test_campaign_process_lifecycle.py +++ b/tests/test_campaign_process_lifecycle.py @@ -77,6 +77,31 @@ async def spawn(*args, **kwargs): assert lease.released +async def test_pending_remote_reservation_blocks_local_and_remote(registry, monkeypatch): + monkeypatch.setattr(pm, "MAX_CONCURRENT", 1) + entered = asyncio.Event() + + async def pending(*args, **kwargs): + entered.set() + await asyncio.Event().wait() + + registry._remote_exec = pending + teardown = AsyncMock(return_value=False) + monkeypatch.setattr(registry, "_teardown_unsettled_remote", teardown) + first_lease = Lease() + first = asyncio.create_task(registry.start_remote(first_lease, "fixture")) + await entered.wait() + assert "Cannot start" in await registry.start("localhost", "fixture") + other = Lease() + assert "Cannot start" in await registry.start_remote(other, "fixture") + assert other.released + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + assert registry._pending_starts == registry._pending_remote_reservations == 0 + assert first_lease.released + + async def test_initial_local_persistence_failure_has_lifecycle_and_teardown(registry, monkeypatch): proc = SimpleNamespace(pid=9123, returncode=None) monkeypatch.setattr( @@ -165,6 +190,9 @@ async def settle(*args, **kwargs): monkeypatch.setattr(registry, "_terminate_bound_host_job", teardown) assert not await registry.terminate_generation(record.generation) teardown.assert_awaited_once_with(record) + revoke = await registry.force_revoke_host("localhost") + assert revoke == {"attempted": 1, "killed": 0, "unknown": 1} + assert not lease.released finish.set() await task assert record.status == "completed" and record.session_confirmed_empty @@ -284,3 +312,17 @@ async def test_remote_write_reports_utf8_bytes_and_refuses_unverified_prefix(reg monkeypatch.setattr(registry, "_remote_call", call) assert await registry._write_remote(record, "é") == "Wrote 2 bytes to PID -1." assert "Failed to write" in await registry._write_remote(record, "éé") + + +async def test_shutdown_counts_only_proven_remote_termination(registry, monkeypatch): + record = info(-1, remote=True, remote_lease=Lease()) + registry._processes[record.pid] = record + reply = {"ok": True, "empty": True, "containment": "owned_descendants", "killed": True} + monkeypatch.setattr(registry, "_remote_call", AsyncMock(return_value=(0, json.dumps(reply)))) + assert await registry.shutdown() == 1 + assert record.session_confirmed_empty + + +async def test_unsettled_cleanup_exit_zero_without_proof_is_unknown(registry): + registry._remote_exec = AsyncMock(return_value=(0, "cleanup attempted")) + assert not await registry._teardown_unsettled_remote(Lease(), "/fixture", "fixture") diff --git a/tests/test_campaign_process_patch.py b/tests/test_campaign_process_patch.py index 170b1bc25..56c7d9400 100644 --- a/tests/test_campaign_process_patch.py +++ b/tests/test_campaign_process_patch.py @@ -39,7 +39,9 @@ def record(*args): def test_nonregular_open_is_nonblocking_and_closed(monkeypatch): opened = [] closed = [] - monkeypatch.setattr(patch.os, "open", lambda name, flags, **kwargs: opened.append(flags) or 9123) + monkeypatch.setattr( + patch.os, "open", lambda name, flags, **kwargs: opened.append(flags) or 9123, + ) monkeypatch.setattr(patch.os, "fstat", lambda fd: SimpleNamespace(st_mode=stat.S_IFIFO)) monkeypatch.setattr(patch.os, "close", closed.append) with pytest.raises(patch.PatchError, match="regular"): diff --git a/tests/test_campaign_process_ssh.py b/tests/test_campaign_process_ssh.py index 322701036..fee5bf65b 100644 --- a/tests/test_campaign_process_ssh.py +++ b/tests/test_campaign_process_ssh.py @@ -22,7 +22,9 @@ async def communicate(): probe = SimpleNamespace(communicate=communicate) monkeypatch.setattr("src.tools.ssh_pool.os.path.exists", lambda path: path == socket) - monkeypatch.setattr("src.tools.ssh_pool.asyncio.create_subprocess_exec", AsyncMock(return_value=probe)) + monkeypatch.setattr( + "src.tools.ssh_pool.asyncio.create_subprocess_exec", AsyncMock(return_value=probe), + ) stopped = AsyncMock() monkeypatch.setattr(pool, "_stop_process", stopped) task = asyncio.create_task(pool.close_host(host, "root")) @@ -33,3 +35,23 @@ async def communicate(): assert pool._masters[key] is master assert master.returncode is None stopped.assert_awaited_once_with(probe) + + +async def test_cancelled_master_wait_retains_ownership(tmp_path, monkeypatch): + pool = SSHConnectionPool(socket_dir=str(tmp_path)) + key = pool._key("example.invalid", "root") + started = asyncio.Event() + + async def wait(): + started.set() + await asyncio.Event().wait() + + master = SimpleNamespace(returncode=None, wait=wait) + pool._masters[key] = master + monkeypatch.setattr("src.tools.ssh_pool.os.path.exists", lambda path: False) + task = asyncio.create_task(pool.close_host("example.invalid", "root")) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert pool._masters[key] is master diff --git a/tests/test_campaign_remote_supervisor_proof.py b/tests/test_campaign_remote_supervisor_proof.py new file mode 100644 index 000000000..4bbee8d3e --- /dev/null +++ b/tests/test_campaign_remote_supervisor_proof.py @@ -0,0 +1,61 @@ +import base64 +import builtins +import io +import json +import subprocess +import sys +import threading +from types import SimpleNamespace + +from src.tools import process_manager as pm + + +def test_real_embedded_supervisor_never_claims_escaped_descendants_empty(monkeypatch): + files = {} + + class File(io.BytesIO): + def __init__(self, path, text): + super().__init__(files.get(path, b"")) + self.path, self.text = path, text + + def write(self, value): + return super().write(value.encode() if isinstance(value, str) else value) + + def close(self): + files[self.path] = self.getvalue() + super().close() + + def read(self, *args): + value = super().read(*args) + return value.decode() if self.text else value + + def open_file(path, mode="r", **kwargs): + if path.startswith("/proc/"): + raise FileNotFoundError + return File(path, "b" not in mode) + + monkeypatch.setattr(builtins, "open", open_file) + monkeypatch.setattr(sys, "argv", [ + "supervisor", "/fixture", "fixture", base64.b64encode(b"fixture").decode(), "1", + ]) + monkeypatch.setattr(pm.os, "umask", lambda mode: None) + monkeypatch.setattr(pm.os, "setsid", lambda: None) + monkeypatch.setattr(pm.os, "open", lambda *args: 9123) + monkeypatch.setattr(pm.os, "close", lambda fd: None) + monkeypatch.setattr(pm.os, "getpgid", lambda pid: 101) + monkeypatch.setattr(pm.os, "getsid", lambda pid: 99) + monkeypatch.setattr(pm.os, "replace", lambda source, dest: files.update({dest: files[source]})) + monkeypatch.setattr(pm.os, "killpg", lambda *args: (_ for _ in ()).throw(ProcessLookupError())) + monkeypatch.setattr(pm.signal, "signal", lambda *args: None) + monkeypatch.setattr(subprocess, "check_output", lambda *args, **kwargs: b"fixture-start") + leader = SimpleNamespace(pid=101, returncode=0, poll=lambda: 0, wait=lambda **kwargs: 0) + monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: leader) + monkeypatch.setattr(threading, "Thread", lambda **kwargs: SimpleNamespace( + start=lambda: None, join=lambda **kwargs: None, + )) + exec(compile(pm._REMOTE_SUPERVISOR, "", "exec"), {}) + record = json.loads(files["/fixture/exit.json"]) + assert record["group_empty"] is True + assert record["empty"] is False + assert record["containment"] == "process_group_only" + assert record["exit_code"] == 0 diff --git a/tests/test_process_host_lease_authority.py b/tests/test_process_host_lease_authority.py index 02d1e98b9..ad3a9b642 100644 --- a/tests/test_process_host_lease_authority.py +++ b/tests/test_process_host_lease_authority.py @@ -791,13 +791,14 @@ async def test_generation_lookup_is_exact_not_positional( ) assert other_lease is other.host_lease - async def test_generation_termination_treats_terminal_and_restored_records_as_settled( + async def test_generation_termination_requires_proof_even_for_terminal_or_restored_records( self, hosts, registry, monkeypatch ): """Evidence records are not authority to kill a process again.""" terminal = ProcessInfo( pid=31338, command="(terminal fixture)", host="127.0.0.1", start_time=time.time(), status="completed", generation="a" * 32, + session_confirmed_empty=True, ) restored = ProcessInfo( pid=31339, command="(restored fixture)", host="127.0.0.1", @@ -813,7 +814,7 @@ async def must_not_terminate(_info): monkeypatch.setattr(registry, "_terminate_bound_host_job", must_not_terminate) assert await registry.terminate_generation(terminal.generation) is True - assert await registry.terminate_generation(restored.generation) is True + assert await registry.terminate_generation(restored.generation) is False async def test_remote_generation_termination_accepts_confirmed_already_exited( self, hosts, registry, monkeypatch @@ -825,6 +826,7 @@ async def test_remote_generation_termination_accepts_confirmed_already_exited( registry._processes[remote.pid] = remote async def already_exited(_info): + _info.session_confirmed_empty = True return "Process already exited; poll to collect its outcome." monkeypatch.setattr(registry, "_kill_remote", already_exited) diff --git a/tests/test_process_kill_status.py b/tests/test_process_kill_status.py index 1e5570231..a152ada51 100644 --- a/tests/test_process_kill_status.py +++ b/tests/test_process_kill_status.py @@ -17,10 +17,15 @@ async def test_remote_kill_then_poll_preserves_cause(monkeypatch, pid): registry._processes[pid] = info info.remote_lease = _Lease() registry._remote_exec = AsyncMock(return_value=(0, json.dumps({ - "ok": True, "status": "exited", "exit": {"exit_code": -15}, + "ok": True, "status": "exited", "exit": { + "exit_code": -15, "empty": True, "containment": "owned_descendants", + }, }))) monkeypatch.setattr(registry, "_remote_call", AsyncMock(side_effect=[ - (0, json.dumps({"ok": True, "killed": True, "exit": {"exit_code": -15}})), + (0, json.dumps({ + "ok": True, "killed": True, "empty": True, + "containment": "owned_descendants", "exit": {"exit_code": -15}, + })), (0, json.dumps({"ok": True, "status": "exited", "exit": {"exit_code": -15}})), ])) assert "killed" in await registry.kill(pid) diff --git a/tests/test_process_read_leases.py b/tests/test_process_read_leases.py index 3b92c9f5a..235a93c4b 100644 --- a/tests/test_process_read_leases.py +++ b/tests/test_process_read_leases.py @@ -43,7 +43,10 @@ async def remote_exec(target, command, timeout): "ok": True, "status": state.get("status", "exited"), "output": base64.b64encode(data).decode(), "cursor": len(data), "size": len(data), "emitted": len(data), "start": 0, - "exit": {"exit_code": 0, "finished_at": time.time()}, + "exit": { + "exit_code": 0, "finished_at": time.time(), + "empty": True, "containment": "owned_descendants", + }, }) registry = ProcessRegistry(remote_exec=remote_exec, retention_dir=tmp_path / "evidence") diff --git a/tests/test_process_tail_correctness.py b/tests/test_process_tail_correctness.py index 13d8ff42d..586ce4093 100644 --- a/tests/test_process_tail_correctness.py +++ b/tests/test_process_tail_correctness.py @@ -39,8 +39,10 @@ async def job(tmp_path, producer, remote, *, wait_for_exit=True): if wait_for_exit: await asyncio.wait_for(supervisor.wait(), 15) yield ex, reg, info - assert info.remote_lease is None and info.output_lease is None - assert lease.release_count == 1 + # Remote supervision currently proves the group only, not + # escaped descendants. Evidence reads do not retire authority. + assert info.remote_lease is lease and info.output_lease is None + assert lease.release_count == 0 else: result = await ex.execute("manage_process", { "action": "start", "host": "testhost", diff --git a/tests/test_process_zero_offset.py b/tests/test_process_zero_offset.py index 7caa5110b..6ebabb28e 100644 --- a/tests/test_process_zero_offset.py +++ b/tests/test_process_zero_offset.py @@ -65,5 +65,6 @@ async def assert_status(expected_status, sentinel): await assert_status("running", "RUNNING-END") assert "Wrote" in await reg.write(info.pid, "finish\n") terminal = await reg.poll(info.pid, wait_seconds=10) - assert preview(terminal)[1]["status"] == "completed" - await assert_status("completed", end) + terminal_status = "unknown" if remote else "completed" + assert preview(terminal)[1]["status"] == terminal_status + await assert_status(terminal_status, end) From 6408cd4504144b9e7e862f937fec9e24a5fa5f5c Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:24:24 -0400 Subject: [PATCH 058/162] Persist affirmative process cleanup evidence for restored generations --- src/tools/process_manager.py | 1 + tests/test_campaign_process_lifecycle.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/tools/process_manager.py b/src/tools/process_manager.py index e2396072d..f896c6c01 100644 --- a/src/tools/process_manager.py +++ b/src/tools/process_manager.py @@ -1505,6 +1505,7 @@ def _persist_output(self, info: ProcessInfo) -> None: "output_revoked", "output_masked", "origin_channel", "scope_id", "host_binding", "reserved_bytes", + "session_confirmed_empty", )} if info.output_tail_masked: record["masked_tail"] = base64.b64encode(info.output_tail).decode("ascii") diff --git a/tests/test_campaign_process_lifecycle.py b/tests/test_campaign_process_lifecycle.py index c1229c427..f4f6cd443 100644 --- a/tests/test_campaign_process_lifecycle.py +++ b/tests/test_campaign_process_lifecycle.py @@ -326,3 +326,12 @@ async def test_shutdown_counts_only_proven_remote_termination(registry, monkeypa async def test_unsettled_cleanup_exit_zero_without_proof_is_unknown(registry): registry._remote_exec = AsyncMock(return_value=(0, "cleanup attempted")) assert not await registry._teardown_unsettled_remote(Lease(), "/fixture", "fixture") + + +def test_retained_manifest_preserves_affirmative_cleanup_proof(tmp_path): + registry = pm.ProcessRegistry(retention_dir=tmp_path) + record = info(-1, remote=True, status="completed", session_confirmed_empty=True) + record.finished_at = pm.time.time() + registry._persist_output(record) + restored = pm.ProcessRegistry(retention_dir=tmp_path) + assert restored._processes[-1].session_confirmed_empty is True From 749a0f566a8cc5790d14f2332223f98a60a297db Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:14:18 -0400 Subject: [PATCH 059/162] fix(web): restore operator files on self-update exceptions --- src/web/api/self_update.py | 67 ++++++++++++-------- tests/test_self_update_campaign_rollback.py | 70 +++++++++++++++++++++ 2 files changed, 112 insertions(+), 25 deletions(-) create mode 100644 tests/test_self_update_campaign_rollback.py diff --git a/src/web/api/self_update.py b/src/web/api/self_update.py index 15a8db4af..16d3b699d 100644 --- a/src/web/api/self_update.py +++ b/src/web/api/self_update.py @@ -127,10 +127,38 @@ async def apply_update(request: web.Request) -> web.Response: if not _re.fullmatch(r"v?\d+\.\d+\.\d+", target): return web.json_response({"error": f"Invalid version format: {target}"}, status=400) + _preserve = {"config.yml", ".env"} + _backups: dict[str, bytes] = {} + prev_ref = None + mutation_started = False + + def _rollback(reason: str) -> web.Response: + errors = [] + try: + if mutation_started and prev_ref: + for cmd in ( + ["git", "-C", base, "checkout", "master"], + ["git", "-C", base, "reset", "--hard", prev_ref], + ): + try: + result = subprocess.run(cmd, capture_output=True, timeout=10) + if result.returncode != 0: + errors.append("code rollback command failed") + except Exception: + errors.append("code rollback command raised") + finally: + # A failed rollback must never skip preserved operator bytes. + for fname, contents in _backups.items(): + try: + Path(base, fname).write_bytes(contents) + except OSError: + errors.append(f"restoring {fname} failed") + if errors: + reason += "; " + "; ".join(errors) + return web.json_response({"error": reason}, status=500) + try: # Backup user-modified config files before updating - _preserve = {"config.yml", ".env"} - _backups: dict[str, bytes] = {} for fname in _preserve: fpath = os.path.join(base, fname) if os.path.exists(fpath): @@ -170,6 +198,17 @@ async def apply_update(request: web.Request) -> web.Response: ), }, status=409) + # Capture rollback authority before the first tracked-file mutation. + r = subprocess.run( + ["git", "-C", base, "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=5, + ) + if r.returncode != 0 or not r.stdout.strip(): + return web.json_response( + {"error": "Cannot capture current ref for rollback"}, status=500 + ) + prev_ref = r.stdout.strip() + mutation_started = True # Reset only the preserved config files for clean pull for fname in _preserve: subprocess.run( @@ -177,34 +216,12 @@ async def apply_update(request: web.Request) -> web.Response: capture_output=True, timeout=10, ) - # Record current ref for potential rollback - r = subprocess.run( - ["git", "-C", base, "rev-parse", "HEAD"], - capture_output=True, text=True, timeout=5, - ) - prev_ref = r.stdout.strip() if r.returncode == 0 else None - # Fetch and update master to the release tag's commit steps = [ (["git", "-C", base, "fetch", "--tags", "origin"], "fetch"), (["git", "-C", base, "checkout", "master"], "checkout master"), (["git", "-C", base, "merge", "--ff-only", target], "fast-forward to release tag"), ] - def _rollback(reason: str) -> web.Response: - if prev_ref: - subprocess.run( - ["git", "-C", base, "checkout", "master"], capture_output=True, timeout=10 - ) - subprocess.run( - ["git", "-C", base, "reset", "--hard", prev_ref], - capture_output=True, - timeout=10, - ) - # Restore config backups even on failure - for fname, data in _backups.items(): - open(os.path.join(base, fname), "wb").write(data) - return web.json_response({"error": reason}, status=500) - for cmd, label in steps: r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if r.returncode != 0: @@ -249,7 +266,7 @@ def _rollback(reason: str) -> web.Response: "message": f"Updated master to {target}. Restarting in place in 2 seconds...", }) except Exception as e: - return web.json_response({"error": str(e)}, status=500) + return _rollback(str(e)) @routes.post("/api/loops/stop-all") async def stop_all_loops(_request: web.Request) -> web.Response: diff --git a/tests/test_self_update_campaign_rollback.py b/tests/test_self_update_campaign_rollback.py new file mode 100644 index 000000000..31bf75111 --- /dev/null +++ b/tests/test_self_update_campaign_rollback.py @@ -0,0 +1,70 @@ +"""All updater side effects mocked; only disposable operator bytes are written.""" +import subprocess +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.web.api.self_update import register_self_update + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stage", ["config", "fetch", "merge", "pip"]) +@pytest.mark.parametrize("rollback_raises", [False, True]) +async def test_update_exceptions_restore_bytes_even_if_rollback_fails( + tmp_path, monkeypatch, stage, rollback_raises, +): + config = tmp_path / "config.yml" + env = tmp_path / ".env" + config.write_bytes(b"operator config\xff") + env.write_bytes(b"operator env\xfe") + pip = tmp_path / ".venv" / "bin" / "pip" + pip.parent.mkdir(parents=True) + pip.touch() + calls = [] + + def run(cmd, **kwargs): + calls.append(cmd) + if "--is-inside-work-tree" in cmd: + return SimpleNamespace(returncode=0, stdout="true", stderr="") + if "rev-parse" in cmd: + return SimpleNamespace(returncode=0, stdout="previous-ref", stderr="") + if "--" in cmd and "checkout" in cmd: + config.write_bytes(b"tracked template") + env.write_bytes(b"tracked env") + if stage == "config": + raise subprocess.TimeoutExpired("mocked", 10) + if (stage == "fetch" and "fetch" in cmd or stage == "merge" and "merge" in cmd + or stage == "pip" and "install" in cmd): + raise subprocess.TimeoutExpired("mocked", 10) + if "reset" in cmd and rollback_raises: + raise OSError("mocked rollback failure") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr("src.web.api.self_update._repo_root", lambda: str(tmp_path)) + monkeypatch.setattr( + "src.web.api.self_update._ensure_local_workspace_for_update", lambda *_: None, + ) + monkeypatch.setattr("src.web.api.self_update.subprocess.run", run) + monkeypatch.setattr("shutil.rmtree", Mock(side_effect=AssertionError("no deletion"))) + monkeypatch.setattr( + "src.web.api.self_update.os.kill", Mock(side_effect=AssertionError("no signals")), + ) + monkeypatch.setattr( + "src.web.api.self_update.restart.request_restart", + Mock(side_effect=AssertionError("no restart")), + ) + app = web.Application() + routes = web.RouteTableDef() + register_self_update(routes, SimpleNamespace()) + app.add_routes(routes) + async with TestClient(TestServer(app)) as client: + response = await client.post("/api/update/apply", json={"version": "v1.2.3"}) + assert response.status == 500 + if rollback_raises: + assert "rollback" in (await response.json())["error"] + assert config.read_bytes() == b"operator config\xff" + assert env.read_bytes() == b"operator env\xfe" + assert any("reset" in cmd for cmd in calls) From 725e229cb81af313679da5a16a15611e3477e5d8 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:14:18 -0400 Subject: [PATCH 060/162] fix(web): bind sessions to credential origin and generation --- src/config/schema.py | 8 +- src/health/server.py | 132 ++++------ src/permissions/token_manager.py | 7 + src/tools/output_authorization.py | 37 ++- src/web/api/config_admin.py | 19 +- src/web/api/security.py | 52 +--- src/web/authentication.py | 49 ++++ src/web/computer_binding.py | 7 +- src/web/websocket.py | 94 +++---- tests/test_output_authorization.py | 12 +- tests/test_web_api_security_routes.py | 6 +- tests/test_web_campaign_authorization.py | 7 +- tests/test_web_campaign_policy_races.py | 5 + tests/test_web_websocket.py | 37 ++- tests/test_webauth_campaign_regressions.py | 277 +++++++++++++++++++++ tests/test_websocket_production_stack.py | 6 +- 16 files changed, 503 insertions(+), 252 deletions(-) create mode 100644 src/web/authentication.py create mode 100644 tests/test_webauth_campaign_regressions.py diff --git a/src/config/schema.py b/src/config/schema.py index cd6b9f87d..0eb442d14 100644 --- a/src/config/schema.py +++ b/src/config/schema.py @@ -1423,12 +1423,12 @@ def _port_range(cls, v): def resolve_api_identity(self, token: str) -> ApiTokenIdentity | None: """Look up identity for an API token. Falls back to default if single token configured.""" - import hmac + from ..web.authentication import credential_equals for t in self.api_tokens: - if t.token and hmac.compare_digest(t.token, token): - return t - if self.api_token and hmac.compare_digest(self.api_token, token): + if t.token and credential_equals(t.token, token): + return t if t.tier in {"admin", "user", "guest"} else None + if self.api_token and credential_equals(self.api_token, token): return ApiTokenIdentity( token=self.api_token, user_id="api-admin", diff --git a/src/health/server.py b/src/health/server.py index bb036650f..3d9e8fc28 100644 --- a/src/health/server.py +++ b/src/health/server.py @@ -285,6 +285,7 @@ def contains(self, sid: str) -> bool: @property def active_count(self) -> int: + self.cleanup() return len(self._sessions) @property @@ -443,7 +444,6 @@ async def auth_middleware( return await handler(request) current_web_config = web_config() if callable(web_config) else web_config - configured_token = getattr(current_web_config, "api_token", "") or "" dynamic_recovery = bool( token_snapshot and getattr(token_snapshot, "credential_store_auth_required", False) is True @@ -453,8 +453,6 @@ async def auth_middleware( ) has_any_token = bool(has_any_token or dynamic_recovery) request._auth_required = has_any_token - if not has_any_token: - return await handler(request) bearer_value = "" auth_header = request.headers.get("Authorization", "") @@ -473,92 +471,33 @@ async def auth_middleware( query_tokens = request.query.getall("token", []) bearer_value = query_tokens[0] if query_tokens else "" + if not has_any_token: + if bearer_value and session_manager.contains(bearer_value): + if session_manager.get_identity(bearer_value) is not None: + session_manager.destroy(bearer_value) + return web.json_response({"error": "unauthorized"}, status=401) + return await handler(request) + if bearer_value: - if _usable_web_credential(configured_token) and hmac.compare_digest( - bearer_value, configured_token - ): - from ..config.schema import ApiTokenIdentity - - request._session_id = "api-admin" - request._api_identity = ApiTokenIdentity( - token="", - user_id="api-admin", - username="Admin", - tier="admin", - label="default", - ) - return await handler(request) + from ..web.authentication import current_session_identity, resolve_credential - identity = ( - None - if dynamic_recovery - else (token_snapshot.resolve(bearer_value) if token_snapshot else None) - ) + identity, source = resolve_credential(current_web_config, token_snapshot, bearer_value) if identity is not None: request._session_id = identity.user_id request._api_identity = identity + request._auth_source = source return await handler(request) - if hasattr(current_web_config, "resolve_api_identity"): - static_identity = current_web_config.resolve_api_identity(bearer_value) - if static_identity is not None: - request._session_id = static_identity.user_id - request._api_identity = static_identity - return await handler(request) - if session_manager.validate(bearer_value): - source = session_manager.get_auth_source(bearer_value) - if dynamic_recovery and source not in {"static", "legacy"}: - raise web.HTTPForbidden(text="API credential store requires recovery") - request._session_id = bearer_value - request._session_managed = True - session_identity = session_manager.get_identity(bearer_value) - if session_identity is not None: - user_id = getattr(session_identity, "user_id", None) - current = None - if source == "legacy" and _usable_web_credential(configured_token): - session_token = getattr(session_identity, "token", "") - if session_token and hmac.compare_digest(session_token, configured_token): - current = session_identity - elif source == "static": - session_token = getattr(session_identity, "token", "") - current = next( - ( - entry - for entry in getattr(current_web_config, "api_tokens", ()) - if entry.user_id == user_id - and session_token - and hmac.compare_digest(entry.token, session_token) - ), - None, - ) - elif source == "dynamic" and not dynamic_recovery: - current = ( - token_snapshot.get(user_id) if token_snapshot and user_id else None - ) - elif source is None and not dynamic_recovery: - # Compatibility for sessions created before provenance - # tracking; never use this path during store recovery. - current = ( - token_snapshot.get(user_id) if token_snapshot and user_id else None - ) - if current is None: - current = next( - ( - entry - for entry in getattr(current_web_config, "api_tokens", ()) - if entry.user_id == user_id - ), - None, - ) - if dynamic_recovery and current is None: - raise web.HTTPForbidden(text="API credential store requires recovery") - if current is not None: - session_identity = current - request._api_identity = session_identity - elif dynamic_recovery: - raise web.HTTPForbidden(text="API credential store requires recovery") - return await handler(request) + current = current_session_identity( + session_manager, bearer_value, current_web_config, token_snapshot + ) + if current is not None: + request._session_id = bearer_value + request._session_managed = True + request._api_identity = current + request._auth_source = session_manager.get_auth_source(bearer_value) + return await handler(request) if dynamic_recovery: raise web.HTTPForbidden(text="API credential store requires recovery") @@ -703,8 +642,10 @@ async def security_headers_middleware( ) -> web.StreamResponse: try: response = await handler(request) + except web.HTTPException as exc: + response = exc except json.JSONDecodeError: - return web.json_response({"error": "invalid JSON body"}, status=400) + response = web.json_response({"error": "invalid JSON body"}, status=400) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Content-Security-Policy"] = _CSP_POLICY @@ -875,19 +816,29 @@ def __init__( # Serve static UI files if the directory exists if self._web_config.enabled: ui_root = Path(__file__).resolve().parent.parent.parent / "ui" - # Production serves the committed Vite build; the source tree is - # the fallback so dev boxes work before their first build. + # Raw Vite sources contain bare imports and cannot run in browsers. dist_dir = ui_root / "dist" - ui_dir = dist_dir if (dist_dir / "index.html").is_file() else ui_root - if ui_dir.is_dir(): + ui_dir = dist_dir + if (ui_dir / "index.html").is_file(): self._app.router.add_get("/", self._redirect_to_ui) self._ui_dir = ui_dir # Serve static files with a fallback to index.html for SPA routing self._app.router.add_get("/ui/{path:.*}", self._serve_ui_file) self._app.router.add_get("/ui", self._redirect_to_ui) log.info("Serving web UI from %s", ui_dir) + else: + self._app.router.add_get("/", self._ui_build_unavailable) + self._app.router.add_get("/ui", self._ui_build_unavailable) + self._app.router.add_get("/ui/{path:.*}", self._ui_build_unavailable) self._runner: web.AppRunner | None = None + async def _ui_build_unavailable(self, _request: web.Request) -> web.Response: + return web.Response(status=503, text=( + "WebUI build unavailable. Build the frontend with npm ci and npm run build " + "from the checkout, or use the packaged release assets. " + "For development, use the Vite development server." + )) + def set_ready(self, ready: bool = True) -> None: self._ready = ready @@ -1258,7 +1209,8 @@ def _verify_hmac_sha256(self, body: bytes, signature: str) -> bool: log.warning("Webhook rejected: no secret configured for HMAC verification") return False expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() - return hmac.compare_digest(expected, signature) + from ..web.authentication import credential_equals + return credential_equals(expected, signature) def _verify_shared_secret(self, header_value: str) -> bool: """Verify a shared-secret header against the configured webhook secret. @@ -1271,7 +1223,8 @@ def _verify_shared_secret(self, header_value: str) -> bool: if not secret: log.warning("Webhook rejected: no secret configured for shared-secret verification") return False - return hmac.compare_digest(header_value, secret) + from ..web.authentication import credential_equals + return credential_equals(header_value, secret) def _get_channel_id(self, source: str) -> str | None: """Get the channel ID for a webhook source.""" @@ -1464,7 +1417,8 @@ async def _webhook_gitlab(self, request: web.Request) -> web.Response: if not secret: log.warning("Webhook rejected: no secret configured for GitLab verification") return web.json_response({"error": "invalid token"}, status=403) - if not hmac.compare_digest(token, secret): + from ..web.authentication import credential_equals + if not credential_equals(token, secret): return web.json_response({"error": "invalid token"}, status=403) try: diff --git a/src/permissions/token_manager.py b/src/permissions/token_manager.py index 0cc6c776a..75fee6a8c 100644 --- a/src/permissions/token_manager.py +++ b/src/permissions/token_manager.py @@ -107,6 +107,13 @@ def get(self, user_id: str) -> ApiTokenIdentity | None: return self._issuer.issue(entry) return None + def identity_is_current(self, identity: ApiTokenIdentity) -> bool: + if self.credential_store_auth_required or identity is None: + return False + entry = next((entry for entry in self._entries + if entry.identity.user_id == identity.user_id), None) + return self._issuer.matches(identity, entry) + class ApiTokenManager: """Dynamic API token management with hashed storage and HMAC-safe lookup.""" diff --git a/src/tools/output_authorization.py b/src/tools/output_authorization.py index 5fb2c28ee..c6fcd3a1f 100644 --- a/src/tools/output_authorization.py +++ b/src/tools/output_authorization.py @@ -66,34 +66,25 @@ def web_output_scope(bot, request): yield return manager = getattr(bot, "api_token_manager", None) - dynamic = manager is not None and manager.resolve(raw) is not None sessions = getattr(request, "app", {}).get("session_manager") session_managed = bool(getattr(request, "_session_managed", False)) - origin = getattr(request, "_api_identity", None) + source = getattr(request, "_auth_source", None) + if source is None and not session_managed: + from ..web.authentication import resolve_credential + _, source = resolve_credential(bot.config.web, manager, raw) def identity(): + from ..web.authentication import current_session_identity, resolve_credential + if session_managed: - if sessions is None or not sessions.validate(raw): - return None - current = getattr(bot, "api_token_manager", None) - user = getattr(origin, "user_id", "") - if current is not None: - found = current.get(user) - if found is not None: - return found - found = next((entry for entry in bot.config.web.api_tokens - if entry.user_id == user), None) - if found is not None: - return found - # Default-admin sessions have no per-token identity. Managed - # token sessions fail closed once their originating token is gone. - if origin is None or user == "api-admin": - return sessions.get_identity(raw) or origin - return None - if dynamic: current = getattr(bot, "api_token_manager", None) - return current.resolve(raw) if current else None - return bot.config.web.resolve_api_identity(raw) + snapshot = (current.auth_snapshot() + if current and hasattr(current, "auth_snapshot") else current) + return (current_session_identity(sessions, raw, bot.config.web, snapshot) + if sessions else None) + current = getattr(bot, "api_token_manager", None) + found, current_source = resolve_credential(bot.config.web, current, raw) + return found if current_source == source else None def tools(): current = identity() @@ -101,6 +92,8 @@ def tools(): return False scope = current.allowed_tools or None tier = getattr(current, "tier", "admin") + if tier not in {"admin", "user", "guest"}: + return False if tier == "guest": return set() if tier == "user": diff --git a/src/web/api/config_admin.py b/src/web/api/config_admin.py index 624efa5c4..086337637 100644 --- a/src/web/api/config_admin.py +++ b/src/web/api/config_admin.py @@ -60,9 +60,8 @@ def _listener_admin_current(request: web.Request, bot) -> bool: resolve them by user_id or infer a default credential from public labels. This sensitive action requires explicit reauthentication, including in UI. """ - import hmac - from ...health.server import _usable_web_credential + from ..authentication import resolve_credential if getattr(request, "_session_managed", False): return False @@ -72,21 +71,7 @@ def _listener_admin_current(request: web.Request, bot) -> bool: return False manager = getattr(bot, "api_token_manager", None) current = bot.config.web - if _usable_web_credential(current.api_token) and hmac.compare_digest( - current.api_token, - bearer, - ): - return True - identity = manager.resolve(bearer) if manager else None - if identity is None: - identity = next( - ( - entry - for entry in current.api_tokens - if _usable_web_credential(entry.token) and hmac.compare_digest(entry.token, bearer) - ), - None, - ) + identity, _ = resolve_credential(current, manager, bearer) return identity is not None and identity.tier == "admin" diff --git a/src/web/api/security.py b/src/web/api/security.py index c2dcac3cd..75dd41cbb 100644 --- a/src/web/api/security.py +++ b/src/web/api/security.py @@ -640,7 +640,11 @@ async def auth_login(request: web.Request) -> web.Response: except Exception: return web.json_response({"error": "invalid JSON"}, status=400) - token = (data.get("token") or "").strip() + if not isinstance(data, dict) or not isinstance(data.get("token"), str): + return web.json_response( + {"error": "token must be a string in a JSON object"}, status=400 + ) + token = data["token"] if not token: return web.json_response({"error": "token is required"}, status=400) @@ -689,16 +693,8 @@ async def auth_login(request: web.Request) -> web.Response: # Preserve dynamic-before-static collision behavior during healthy # operation. Recovery disables only the broken dynamic source. - identity = snapshot.resolve(token) if snapshot and not store_requires_recovery else None - identity_source = "dynamic" if identity is not None else "" - if identity is None: - identity = bot.config.web.resolve_api_identity(token) - if identity is not None: - identity_source = ( - "static" - if any(identity is configured for configured in bot.config.web.api_tokens) - else "legacy" - ) + from ..authentication import resolve_credential + identity, identity_source = resolve_credential(bot.config.web, snapshot, token) if store_requires_recovery and identity is None: return web.json_response( {"error": "API credential store requires recovery"}, status=403 @@ -707,7 +703,10 @@ async def auth_login(request: web.Request) -> web.Response: sm = request.app.get("session_manager") if not sm: return web.json_response({"error": "no session manager"}, status=500) - sid, timeout = sm.create(identity=identity) + bound_identity = ( + identity if identity_source == "dynamic" else identity.model_copy(deep=True) + ) + sid, timeout = sm.create(identity=bound_identity) set_source = getattr(sm, "set_auth_source", None) if callable(set_source): set_source(sid, identity_source) @@ -718,34 +717,7 @@ async def auth_login(request: web.Request) -> web.Response: } ) - # Fall back to legacy single token - import hmac as _hmac - - if api_token and not _hmac.compare_digest(token, api_token): - return web.json_response({"error": "invalid token"}, status=401) - if not api_token: - return web.json_response({"error": "invalid token"}, status=401) - - sm = request.app.get("session_manager") - if not sm: - return web.json_response({"error": "no session manager"}, status=500) - - from ...config.schema import ApiTokenIdentity - - legacy_identity = ApiTokenIdentity( - token="", - user_id="api-admin", - username="Admin", - tier="admin", - label="default", - ) - sid, timeout = sm.create(identity=legacy_identity) - return web.json_response( - { - "session_id": sid, - "timeout_seconds": timeout, - } - ) + return web.json_response({"error": "invalid token"}, status=401) @routes.post("/api/auth/logout") async def auth_logout(request: web.Request) -> web.Response: diff --git a/src/web/authentication.py b/src/web/authentication.py new file mode 100644 index 000000000..3af2ee928 --- /dev/null +++ b/src/web/authentication.py @@ -0,0 +1,49 @@ +"""Shared HTTP, login and WebSocket credential admission and provenance.""" +import hmac + + +def credential_equals(left: str, right: str) -> bool: + """Constant-time comparison supporting every configured UTF-8 credential.""" + return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8")) + + +def resolve_credential(config, snapshot, token: str): + """Dynamic, then static, then legacy: identical precedence on all carriers.""" + recovery = getattr(snapshot, "credential_store_auth_required", False) is True + resolver = getattr(snapshot, "resolve", None) + identity = resolver(token) if token and callable(resolver) and not recovery else None + if identity is not None: + return identity, "dynamic" + for entry in getattr(config, "api_tokens", ()): + if entry.token and credential_equals(entry.token, token): + # Keep old configurations loadable, but never elevate a typo. + if entry.tier not in {"admin", "user", "guest"}: + return None, "invalid" + return entry, "static" + identity = config.resolve_api_identity(token) if config is not None else None + return (identity, "legacy") if identity is not None else (None, "unknown") + + +def current_session_identity(sessions, sid, config, snapshot): + """Revalidate only the credential which issued this session, never its ID.""" + if not sessions.validate(sid, touch=False): + return None + origin = sessions.get_identity(sid) + source = sessions.get_auth_source(sid) + current = None + if source == "dynamic" and snapshot is not None: + if snapshot.identity_is_current(origin): + current = origin + elif source in {"static", "legacy"} and origin is not None: + if source == "static": + current = next((entry for entry in getattr(config, "api_tokens", ()) + if entry.user_id == origin.user_id and entry.token + and credential_equals(entry.token, origin.token) + and entry.tier in {"admin", "user", "guest"}), None) + elif getattr(config, "api_token", "") and credential_equals(config.api_token, origin.token): + from ..config.schema import ApiTokenIdentity + current = ApiTokenIdentity(token=config.api_token, user_id="api-admin", + username="Admin", tier="admin", label="default") + if current is None: + sessions.destroy(sid) + return current diff --git a/src/web/computer_binding.py b/src/web/computer_binding.py index 711f9b7ec..eae8d2e0d 100644 --- a/src/web/computer_binding.py +++ b/src/web/computer_binding.py @@ -10,6 +10,7 @@ from typing import Any from ..permissions.host_access import HostAccessManager +from .authentication import credential_equals def browser_binding(bot, request): @@ -32,7 +33,7 @@ def browser_binding(bot, request): and original is not None and tokens.identity_is_current(original)) static = next((i for i in bot.config.web.api_tokens - if credential and hmac.compare_digest(i.token, credential)), None) + if credential and credential_equals(i.token, credential)), None) legacy_digest = hashlib.sha256(bot.config.web.api_token.encode()).digest() hosts = getattr(identity, "allowed_hosts", None) hosts = None if hosts is None else tuple(hosts) @@ -51,9 +52,9 @@ def current(): return False elif static is not None: value = next((i for i in bot.config.web.api_tokens - if hmac.compare_digest(i.token, credential)), None) + if credential_equals(i.token, credential)), None) elif (owner == "api-admin" and credential and bot.config.web.api_token - and hmac.compare_digest(credential, bot.config.web.api_token)): + and credential_equals(credential, bot.config.web.api_token)): if not hmac.compare_digest( legacy_digest, hashlib.sha256(bot.config.web.api_token.encode()).digest()): return False diff --git a/src/web/websocket.py b/src/web/websocket.py index 01299a572..0918632f7 100644 --- a/src/web/websocket.py +++ b/src/web/websocket.py @@ -14,6 +14,7 @@ import binascii import hmac import json +import time from contextlib import asynccontextmanager from copy import deepcopy from dataclasses import dataclass, field @@ -26,6 +27,7 @@ from ..error_presentation import format_user_facing_error from ..odin_log import get_logger +from .authentication import credential_equals, current_session_identity, resolve_credential from .chat import MAX_CHAT_CONTENT_LEN, process_web_chat if TYPE_CHECKING: @@ -98,6 +100,7 @@ class _CredentialPolicy: generation: int legacy_digest: bytes = b"" bearer: str = field(default="", repr=False) + issued_identity: object = field(default=None, repr=False) class WebSocketManager: @@ -132,6 +135,7 @@ def __init__( # stream write. The same fence covers initial tails and event delivery. self._policy_lock = asyncio.Lock() self._policy_generations: dict[str, int] = {} + self._chat_buckets: dict[tuple[str, str], tuple[float, int]] = {} def _current_web_config(self): """Return published Web config instead of setup-time auth state.""" @@ -236,6 +240,8 @@ def _policy_authorized(self, ws: web.WebSocketResponse) -> bool: if credential.source == "dynamic": if snapshot is None: return False + if credential.issued_identity is not None: + return snapshot.identity_is_current(credential.issued_identity) current = ( snapshot.resolve(credential.bearer) if credential.bearer @@ -248,6 +254,7 @@ def _policy_authorized(self, ws: web.WebSocketResponse) -> bool: i for i in getattr(config, "api_tokens", ()) if i.user_id == credential.user_id + and _policy_fingerprint(i) == credential.fingerprint ), None, ) @@ -394,23 +401,17 @@ async def _watch_session_expiry(self, session_id: str) -> None: if self._session_expiry_tasks.get(session_id) is current: self._session_expiry_tasks.pop(session_id, None) - @staticmethod - def _chat_rate_limited(ws: web.WebSocketResponse) -> bool: + def _chat_rate_limited(self, ws: web.WebSocketResponse) -> bool: """Count every chat frame, including rejections while one is busy.""" - import time as _time - - now = _time.monotonic() - window_start = getattr(ws, "_chat_window_start", None) - if ( - window_start is None - or not isinstance(window_start, (int, float)) - or now - window_start > _WS_CHAT_RATE_WINDOW - ): - ws._chat_window_start = now # type: ignore[attr-defined] # sanctioned dynamic attr - ws._chat_count = 0 # type: ignore[attr-defined] # sanctioned dynamic attr - chat_count = getattr(ws, "_chat_count", None) - ws._chat_count = (chat_count + 1) if isinstance(chat_count, int) else 1 # type: ignore[attr-defined] # sanctioned dynamic attr - return ws._chat_count > _WS_CHAT_RATE_LIMIT # type: ignore[attr-defined] # sanctioned dynamic attr + now = time.monotonic() + self._chat_buckets = {key: bucket for key, bucket in self._chat_buckets.items() + if now - bucket[0] < _WS_CHAT_RATE_WINDOW} + identity = getattr(ws, "_odin_identity", None) + key = (getattr(identity, "user_id", "") or "development", + getattr(ws, "_odin_client_ip", "")) + window_start, count = self._chat_buckets.get(key, (now, 0)) + self._chat_buckets[key] = (window_start, count + 1) + return count + 1 > _WS_CHAT_RATE_LIMIT async def _start_chat(self, ws: web.WebSocketResponse, data: dict) -> None: if self._chat_rate_limited(ws): @@ -441,23 +442,17 @@ def _resolve_identity(self, token: str, request=None, *, token_snapshot=None): """Resolve an ApiTokenIdentity from a raw token string.""" if self._session_manager: if self._session_manager.validate(token): - identity = self._session_manager.get_identity(token) + identity = current_session_identity( + self._session_manager, token, self._current_web_config(), token_snapshot + ) if identity is not None: return identity tm = token_snapshot if tm is None: manager = request.app.get("token_manager") if request else None tm = self._auth_snapshot(manager or self._token_manager()) - if tm: - identity = tm.resolve(token) - if identity is not None: - return identity config = self._current_web_config() - if config and hasattr(config, "resolve_api_identity"): - identity = config.resolve_api_identity(token) - if identity is not None: - return identity - return None + return resolve_credential(config, tm, token)[0] async def handle(self, request: web.Request) -> web.WebSocketResponse: """Handle a WebSocket connection at /api/ws. @@ -525,7 +520,7 @@ async def handle(self, request: web.Request) -> web.WebSocketResponse: elif self._current_web_config() is None and self._usable_credential( self._api_token ): - valid = hmac.compare_digest(token, self._api_token) + valid = credential_equals(token, self._api_token) elif getattr(request, "_session_managed", False) and self._session_manager is not None: session_id = getattr(request, "_session_id", "") valid = bool(session_id and self._session_manager.validate(session_id)) @@ -543,7 +538,15 @@ async def handle(self, request: web.Request) -> web.WebSocketResponse: protocols=(offered_protocol,) if offered_protocol else (), ) ws._odin_session_id = getattr(request, "_session_id", None) or "ws-anon" # type: ignore[attr-defined] # sanctioned dynamic attr + from ..health.server import _client_ip + trusted = tuple(getattr(self._current_web_config(), "trusted_proxies", ())) + ws._odin_client_ip = _client_ip(request, trusted) # type: ignore[attr-defined] ws._odin_session_managed = bool(getattr(request, "_session_managed", False)) # type: ignore[attr-defined] # sanctioned dynamic attr + if (not ws._odin_session_managed and token and self._session_manager is not None # type: ignore[attr-defined] + and hasattr(self._session_manager, "contains") + and self._session_manager.contains(token)): + ws._odin_session_id = token # type: ignore[attr-defined] + ws._odin_session_managed = True # type: ignore[attr-defined] source = "unknown" config = self._current_web_config() legacy = getattr(config, "api_token", "") if config is not None else self._api_token @@ -557,38 +560,12 @@ async def handle(self, request: web.Request) -> web.WebSocketResponse: session_identity = self._session_manager.get_identity(ws._odin_session_id) # type: ignore[attr-defined] presented = getattr(session_identity, "token", "") identity = session_identity - # Legacy middleware has precedence over the token stores. A duplicate - # user ID must not change which credential actually authenticated us. - if legacy and hmac.compare_digest(presented, legacy): + if ws._odin_session_managed: # type: ignore[attr-defined] + source = self._session_manager.get_auth_source(ws._odin_session_id) or "unknown" # type: ignore[attr-defined] + elif config is not None: + identity, source = resolve_credential(config, token_snapshot, presented) + elif legacy and credential_equals(presented, legacy): source = "legacy" - elif identity is not None: - candidate = ( - token_snapshot.resolve(presented) - if token_snapshot and presented - else None - ) - if ( - candidate is None - and token_snapshot - and session_identity is not None - and not presented - ): - candidate = token_snapshot.get(identity.user_id) - if candidate is not None: - source = "dynamic" - identity = candidate - if ( - source == "unknown" - and config - and any( - i.user_id == identity.user_id and i.token == presented - for i in getattr(config, "api_tokens", ()) - ) - ): - source = "static" - identity = next( - i for i in getattr(config, "api_tokens", ()) if i.token == presented - ) if source == "unknown": if ( legacy @@ -618,6 +595,7 @@ async def handle(self, request: web.Request) -> web.WebSocketResponse: self._policy_generations.get(uid, 0), sha256(legacy.encode()).digest() if source == "legacy" else b"", presented if source == "dynamic" and not ws._odin_session_managed else "", # type: ignore[attr-defined] + identity if source == "dynamic" and ws._odin_session_managed else None, # type: ignore[attr-defined] ) # Do not hold the publication fence across a network handshake. Bind # provenance before suspension, then check and register under that fence. diff --git a/tests/test_output_authorization.py b/tests/test_output_authorization.py index 90ce8ae3b..b2f849885 100644 --- a/tests/test_output_authorization.py +++ b/tests/test_output_authorization.py @@ -97,12 +97,18 @@ def test_tier_and_scope_intersection(tier, scope): def test_session_tracks_identity_and_fails_closed_after_removal(): bot, request, manager, sessions = fixture(session=True) + from src.config.schema import ApiTokenIdentity + origin = ApiTokenIdentity(token="origin", user_id="reader") + sessions.get_identity.return_value = origin + sessions.get_auth_source.return_value = "static" + bot.config.web.api_tokens = [origin] with auth.web_output_scope(bot, request): assert auth.tool_scope_allows("read_file") manager.get.return_value = identity(allowed_tools=["search_history"]) - assert not auth.tool_scope_allows("read_file") + # Same-ID dynamic credentials never substitute for a static origin. + assert auth.tool_scope_allows("read_file") manager.get.return_value = None - bot.config.web.api_tokens = [identity(allowed_tools=["read_file"])] + bot.config.web.api_tokens = [origin.model_copy(update={"allowed_tools": ["read_file"]})] assert auth.tool_scope_allows("read_file") bot.config.web.api_tokens = [] assert not auth.tool_scope_allows("read_file") @@ -116,7 +122,7 @@ def test_admin_session_fallback_and_missing_manager(origin): request._api_identity = origin manager.get.return_value = None with auth.web_output_scope(bot, request): - assert auth.tool_scope_allows("read_file") + assert not auth.tool_scope_allows("read_file") request.app = {} with auth.web_output_scope(bot, request): assert not auth.tool_scope_allows("read_file") diff --git a/tests/test_web_api_security_routes.py b/tests/test_web_api_security_routes.py index d88a83a72..5862f3231 100644 --- a/tests/test_web_api_security_routes.py +++ b/tests/test_web_api_security_routes.py @@ -644,7 +644,8 @@ def _app_with_sm(self, bot, sm, identity="__admin__"): @pytest.mark.asyncio async def test_login_with_config_token(self, tmp_path): bot, sm = self._bot_with_sessions(tmp_path) - bot.config.web.resolve_api_identity = MagicMock(return_value=None) + from src.config.schema import WebConfig + bot.config.web = WebConfig(api_token="cfg-token") async with TestClient(TestServer(self._app_with_sm(bot, sm))) as c: r = await c.post("/api/auth/login", json={"token": "cfg-token"}) assert r.status == 200 and (await r.json())["session_id"] == "sess-123" @@ -741,7 +742,8 @@ async def test_login_resolves_dynamic_identity_path(self, tmp_path): bot.api_token_manager = MagicMock() bot.api_token_manager.resolve.return_value = None bot.api_token_manager.list_tokens.return_value = [{"x": 1}] # auth configured - ident = SimpleNamespace(user_id="cfgid", tier="user") + from src.config.schema import ApiTokenIdentity + ident = ApiTokenIdentity(token="cfg-token", user_id="cfgid", tier="user") bot.config.web.resolve_api_identity = MagicMock(return_value=ident) async with TestClient(TestServer(self._app_with_sm(bot, sm))) as c: r = await c.post("/api/auth/login", json={"token": "matches-config"}) diff --git a/tests/test_web_campaign_authorization.py b/tests/test_web_campaign_authorization.py index 32849df14..85ae7194b 100644 --- a/tests/test_web_campaign_authorization.py +++ b/tests/test_web_campaign_authorization.py @@ -54,9 +54,10 @@ async def test_every_registered_route_tier_matrix(monkeypatch, carrier): identities[identity.token] = identity if carrier == "session": credentials[tier], _ = server._session_manager.create(identity=identity) + server._session_manager.set_auth_source(credentials[tier], "static") else: credentials[tier] = identity.token - if carrier == "static": + if carrier in {"static", "session"}: server._web_config.api_tokens = list(identities.values()) elif carrier == "dynamic": server._app["token_manager"] = SimpleNamespace( @@ -95,8 +96,10 @@ async def boundary(request, handler): @pytest.mark.asyncio async def test_session_owner_boundary(): server, bot = production_server() - identity = ApiTokenIdentity(token="", user_id="owner", tier="user") + identity = ApiTokenIdentity(token="owner-credential", user_id="owner", tier="user") + server._web_config.api_tokens = [identity] token, _ = server._session_manager.create(identity=identity) + server._session_manager.set_auth_source(token, "static") async with TestClient(TestServer(server._app)) as client: for method, suffix in (("GET", ""), ("GET", "/export"), ("DELETE", "")): response = await client.request(method, f"/api/sessions/other{suffix}", diff --git a/tests/test_web_campaign_policy_races.py b/tests/test_web_campaign_policy_races.py index c034233a3..b7c748460 100644 --- a/tests/test_web_campaign_policy_races.py +++ b/tests/test_web_campaign_policy_races.py @@ -37,6 +37,11 @@ async def test_static_session_never_inherits_same_id_dynamic_admin( sid = (await response.json())["session_id"] if deleted: server._web_config.api_tokens = [] + with pytest.raises(aiohttp.WSServerHandshakeError) as refused: + await client.ws_connect("/api/ws", **carrier_options(sid, wire)) + assert refused.value.status == 401 + chat.assert_not_awaited() + return ws = await client.ws_connect("/api/ws", **carrier_options(sid, wire)) await ws.send_json({"type": "chat", "content": "synthetic"}) result = await ws.receive_json(timeout=1) diff --git a/tests/test_web_websocket.py b/tests/test_web_websocket.py index 16474f045..e029174b0 100644 --- a/tests/test_web_websocket.py +++ b/tests/test_web_websocket.py @@ -101,11 +101,16 @@ async def test_accepts_matching_token(self): async def test_accepts_session_manager_token(self): # web_config present (auth on), api_token empty → session_manager validates - sm = SimpleNamespace(validate=lambda t: t == "good", get_identity=lambda t: None) - client, _ = _client(web_config=SimpleNamespace(), session_manager=sm) + from src.config.schema import WebConfig + from src.health.server import SessionManager + config = WebConfig(api_token="origin") + sm = SessionManager() + sid, _ = sm.create(config.resolve_api_identity("origin")) + sm.set_auth_source(sid, "legacy") + client, _ = _client(web_config=config, session_manager=sm) async with client: async with client.ws_connect( - "/api/ws", protocols=[TestBearerSubprotocolAuth._proto("good")] + "/api/ws", protocols=[TestBearerSubprotocolAuth._proto(sid)] ) as ws: await ws.send_json({"type": "ping", "ts": 1}) assert (await ws.receive_json())["type"] == "pong" @@ -427,10 +432,15 @@ async def test_tail_no_file_is_safe(self): ws.send_json.assert_not_awaited() def test_resolve_identity_via_session_manager(self): - ident = SimpleNamespace(user_id="u1") - sm = SimpleNamespace(validate=lambda t: True, get_identity=lambda t: ident) - mgr = WebSocketManager(_bot(), session_manager=sm) - assert mgr._resolve_identity("tok") is ident + from src.config.schema import ApiTokenIdentity, WebConfig + from src.health.server import SessionManager + ident = ApiTokenIdentity(token="origin", user_id="u1") + config = WebConfig(api_tokens=[ident]) + sm = SessionManager() + sid, _ = sm.create(ident) + sm.set_auth_source(sid, "static") + mgr = WebSocketManager(_bot(), session_manager=sm, web_config=config) + assert mgr._resolve_identity(sid) is ident def test_resolve_identity_via_token_manager(self): ident = SimpleNamespace(user_id="u1") @@ -525,13 +535,18 @@ async def test_idle_managed_socket_expires_without_inbound_traffic(self): from src.health.server import SessionManager sessions = SessionManager(timeout_minutes=1 / 6000) - sid, _ = sessions.create(SimpleNamespace(user_id="u1", tier="admin")) + from src.config.schema import ApiTokenIdentity + origin = ApiTokenIdentity(token="synthetic-origin", user_id="u1") + sid, _ = sessions.create(origin) + sessions.set_auth_source(sid, "static") config = SimpleNamespace( api_token="configured", api_tokens=[], resolve_api_identity=lambda _token: None, ) + config.api_tokens = [origin] client, manager = TestProductionMiddlewareBearerAuth._stack( + # Publish the exact credential bound above, not an unbacked identity. session_manager=sessions, web_config=config, ) @@ -659,17 +674,19 @@ def _stack(*, session_manager, web_config): return TestClient(TestServer(app)), manager async def test_session_subprotocol_reaches_handler_and_binds_exact_session(self): - identity = SimpleNamespace(user_id="u1", tier="admin") + from src.config.schema import ApiTokenIdentity + identity = ApiTokenIdentity(token="origin", user_id="u1", tier="admin") sm = SimpleNamespace( validate=MagicMock(side_effect=lambda token, **_kw: token == "session-one"), get_identity=MagicMock(return_value=identity), - get_auth_source=MagicMock(return_value=None), + get_auth_source=MagicMock(return_value="static"), ) config = SimpleNamespace( api_token="configured-so-auth-is-on", api_tokens=[], resolve_api_identity=lambda _token: None, ) + config.api_tokens = [identity] client, manager = self._stack(session_manager=sm, web_config=config) async with client: ws = await client.ws_connect( diff --git a/tests/test_webauth_campaign_regressions.py b/tests/test_webauth_campaign_regressions.py new file mode 100644 index 000000000..cc6c7249e --- /dev/null +++ b/tests/test_webauth_campaign_regressions.py @@ -0,0 +1,277 @@ +"""Real middleware, login and live tool admission with disposable credentials.""" +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.config.schema import ApiTokenIdentity, WebConfig +from src.health.server import SessionManager, _make_auth_middleware +from src.permissions.token_manager import ApiTokenManager +from src.tools.output_authorization import request_host_authorizer, web_output_scope +from src.web.api.security import register_auth +from src.web.websocket import WebSocketManager, _CredentialPolicy, _policy_fingerprint + + +def composition(config, tokens=None): + bot = SimpleNamespace(config=SimpleNamespace(web=config), api_token_manager=tokens) + sm = SessionManager(timeout_minutes=1) + app = web.Application(middlewares=[_make_auth_middleware(lambda: bot.config.web, sm)]) + app["session_manager"] = sm + if tokens is not None: + app["token_manager"] = tokens + routes = web.RouteTableDef() + register_auth(routes, bot) + + @routes.get("/api/probe") + async def probe(request): + identity = getattr(request, "_api_identity", None) + with web_output_scope(bot, request): + hosts = request_host_authorizer.get() + return web.json_response({"user": getattr(identity, "user_id", None), + "host": hosts("dynamic-only") if hosts else None}) + + app.add_routes(routes) + return bot, sm, app + + +async def login(client, token): + response = await client.post("/api/auth/login", json={"token": token}) + assert response.status == 200 + return (await response.json())["session_id"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["static", "legacy", "dynamic"]) +async def test_sessions_die_after_origin_rotation(tmp_path, source): + tm = ApiTokenManager(str(tmp_path / "tokens.json")) + raw = "original" + config = WebConfig(api_token="other") + if source == "static": + config.api_tokens = [ApiTokenIdentity(token=raw, user_id="same")] + elif source == "legacy": + config.api_token = raw + else: + raw = (await tm.create_token(user_id="one", username="One", tier="admin")).token + bot, sm, app = composition(config, tm) + async with TestClient(TestServer(app)) as client: + sid = await login(client, raw) + response = await client.get("/api/probe", headers={"Authorization": f"Bearer {sid}"}) + assert response.status == 200 + if source == "static": + config.api_tokens[0].token = "replacement" + elif source == "legacy": + config.api_token = "replacement" + else: + await tm.regenerate_token(sm.get_identity(sid).user_id) + response = await client.get("/api/probe", headers={"Authorization": f"Bearer {sid}"}) + assert response.status == 401 + assert not sm.contains(sid) + + +@pytest.mark.asyncio +async def test_development_session_invalid_after_auth_enabled(): + config = WebConfig() + _, sm, app = composition(config) + async with TestClient(TestServer(app)) as client: + sid = await login(client, "development") + config.api_token = "enabled" + response = await client.get("/api/probe", headers={"Authorization": f"Bearer {sid}"}) + assert response.status == 401 + assert not sm.contains(sid) + + +@pytest.mark.asyncio +async def test_static_session_scope_never_adopts_same_id_dynamic_token(tmp_path): + tm = ApiTokenManager(str(tmp_path / "tokens.json")) + dynamic = await tm.create_token( + user_id="one", username="One", tier="admin", allowed_hosts=["dynamic-only"], + ) + config = WebConfig(api_tokens=[ApiTokenIdentity(token="static", user_id=dynamic.user_id, + allowed_hosts=["static-only"])]) + _, _, app = composition(config, tm) + async with TestClient(TestServer(app)) as client: + sid = await login(client, "static") + response = await client.get("/api/probe", headers={"Authorization": f"Bearer {sid}"}) + assert (await response.json())["host"] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", [[], None, 42, {"token": 42}, {"token": []}]) +async def test_login_invalid_json_shapes_are_bad_requests(body): + _, _, app = composition(WebConfig(api_token="valid")) + async with TestClient(TestServer(app)) as client: + assert (await client.post("/api/auth/login", json=body)).status == 400 + + +@pytest.mark.asyncio +async def test_unicode_and_whitespace_credentials_preserve_exact_bytes(): + _, _, app = composition(WebConfig(api_token=" café ")) + async with TestClient(TestServer(app)) as client: + sid = await login(client, " café ") + for value in [sid, " café "]: + response = await client.get("/api/probe", headers={"Authorization": f"Bearer {value}"}) + assert response.status == 200 + assert (await client.post("/api/auth/login", json={"token": "café"})).status == 401 + response = await client.get("/api/probe", headers={"Authorization": "Bearer 无效"}) + assert response.status == 401 + + +@pytest.mark.asyncio +async def test_invalid_static_tier_loads_but_is_never_admitted(): + config = WebConfig( + api_token="collision", api_tokens=[ApiTokenIdentity(token="collision", tier="usr")], + ) + _, _, app = composition(config) + async with TestClient(TestServer(app)) as client: + assert (await client.post("/api/auth/login", json={"token": "collision"})).status == 401 + response = await client.get("/api/probe", headers={"Authorization": "Bearer collision"}) + assert response.status == 401 + + +@pytest.mark.asyncio +async def test_legacy_collision_same_identity_on_login_http_and_websocket(): + config = WebConfig(api_token="collision", api_tokens=[ + ApiTokenIdentity(token="collision", user_id="static", tier="user"), + ]) + bot, sm, app = composition(config) + ws_manager = WebSocketManager(bot, session_manager=sm, web_config=config) + app.router.add_get("/api/ws", ws_manager.handle) + async with TestClient(TestServer(app)) as client: + sid = await login(client, "collision") + assert sm.get_identity(sid).user_id == "static" + response = await client.get("/api/probe", headers={"Authorization": "Bearer collision"}) + assert (await response.json())["user"] == "static" + ws = await client.ws_connect("/api/ws", headers={"Authorization": "Bearer collision"}) + server_ws = next(iter(ws_manager._clients)) + assert server_ws._odin_identity.user_id == "static" + assert server_ws._odin_credential_policy.source == "static" + await ws.close() + await ws_manager.close_all() + + +def test_second_static_credential_same_id_remains_ws_authorized(): + config = WebConfig(api_tokens=[ApiTokenIdentity(token="first", user_id="same"), + ApiTokenIdentity(token="second", user_id="same")]) + bot, _, _ = composition(config) + manager = WebSocketManager(bot, web_config=config) + identity = config.api_tokens[1] + ws = SimpleNamespace(_odin_identity=identity, _odin_credential_policy=_CredentialPolicy( + "static", "same", _policy_fingerprint(identity), 0)) + assert manager._policy_authorized(ws) + + +@pytest.mark.asyncio +async def test_dynamic_session_ws_generation_fenced(tmp_path): + tm = ApiTokenManager(str(tmp_path / "tokens.json")) + raw = (await tm.create_token(user_id="one", username="One", tier="admin")).token + config = WebConfig() + bot, sm, app = composition(config, tm) + manager = WebSocketManager(bot, session_manager=sm, web_config=config) + app.router.add_get("/api/ws", manager.handle) + async with TestClient(TestServer(app)) as client: + sid = await login(client, raw) + ws = await client.ws_connect("/api/ws", headers={"Authorization": f"Bearer {sid}"}) + server_ws = next(iter(manager._clients)) + assert manager._policy_authorized(server_ws) + await tm.regenerate_token(sm.get_identity(sid).user_id) + assert not manager._policy_authorized(server_ws) + await ws.close() + await manager.close_all() + + +def test_http_only_session_count_prunes_expired(monkeypatch): + clock = [0.0] + monkeypatch.setattr("src.health.server.time.monotonic", lambda: clock[0]) + sm = SessionManager(timeout_minutes=1) + sid, _ = sm.create(ApiTokenIdentity(token="one")) + sm.set_auth_source(sid, "static") + clock[0] = 60 + assert sm.active_count == 0 + assert sm.get_identity(sid) is None + assert sm.get_auth_source(sid) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["redirect", "forbidden", "notfound", "json", "returned"]) +async def test_security_headers_on_all_responses(kind): + import json + + from src.health.server import _make_security_headers_middleware + app = web.Application(middlewares=[_make_security_headers_middleware()]) + + async def response(_request): + if kind == "redirect": + raise web.HTTPFound("/other") + if kind == "forbidden": + raise web.HTTPForbidden() + if kind == "notfound": + raise web.HTTPNotFound() + if kind == "json": + raise json.JSONDecodeError("bad", "x", 0) + return web.json_response({"ok": True}) + + app.router.add_get("/", response) + async with TestClient(TestServer(app)) as client: + result = await client.get("/", allow_redirects=False) + for header in ["Content-Security-Policy", "X-Frame-Options", "Referrer-Policy"]: + assert header in result.headers + + +@pytest.mark.asyncio +async def test_missing_ui_build_refuses_raw_source(monkeypatch): + from pathlib import Path + + from tests.test_web_campaign_authorization import production_server + original = Path.is_file + monkeypatch.setattr(Path, "is_file", lambda path: False if path.name == "index.html" + and path.parent.name == "dist" else original(path)) + server, _ = production_server() + async with TestClient(TestServer(server._app)) as client: + for path in ["/", "/ui", "/ui/js/app.js"]: + response = await client.get(path) + assert response.status == 503 + assert "npm run build" in await response.text() + + +def test_chat_limit_shared_across_reconnects_and_concurrent_sockets(monkeypatch): + clock = [0.0] + monkeypatch.setattr("src.web.websocket.time.monotonic", lambda: clock[0]) + config = WebConfig(api_tokens=[ApiTokenIdentity(token="one", user_id="same")]) + bot, _, _ = composition(config) + manager = WebSocketManager(bot) + sockets = [SimpleNamespace(_odin_identity=config.api_tokens[0], _odin_client_ip="client") + for _ in range(12)] + for ws in sockets[:10]: + assert not manager._chat_rate_limited(ws) + assert manager._chat_rate_limited(sockets[10]) + assert manager._chat_rate_limited(sockets[11]) + clock[0] = 60 + assert not manager._chat_rate_limited(sockets[0]) + assert len(manager._chat_buckets) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("message,status,outcome", [ + ("Failed to kill PID 123: permission denied", 409, "failed"), + ("Error: retained process evidence is read-only.", 409, "failed"), + ("No process with PID 123.", 404, "failed"), + ("Process 123 already exited; poll to collect its outcome.", 200, "already_stopped"), + ("Process 123 killed.", 200, "killed"), +]) +async def test_process_kill_reports_acknowledged_outcome(message, status, outcome): + from unittest.mock import AsyncMock + + from src.web.api.agents_loops import register_processes + bot = SimpleNamespace(tool_executor=SimpleNamespace( + _process_registry=SimpleNamespace(kill=AsyncMock(return_value=message)))) + routes = web.RouteTableDef() + register_processes(routes, bot) + app = web.Application() + app.add_routes(routes) + async with TestClient(TestServer(app)) as client: + response = await client.delete("/api/processes/123") + assert response.status == status + data = await response.json() + assert data["outcome"] == outcome + assert data["success"] == (status == 200) diff --git a/tests/test_websocket_production_stack.py b/tests/test_websocket_production_stack.py index 7fe39942e..53a0fd5cc 100644 --- a/tests/test_websocket_production_stack.py +++ b/tests/test_websocket_production_stack.py @@ -9,7 +9,7 @@ import pytest from aiohttp.test_utils import TestClient, TestServer -from src.config.schema import WebConfig +from src.config.schema import ApiTokenIdentity, WebConfig from src.health.server import HealthServer from src.web.websocket import setup_websocket @@ -22,8 +22,10 @@ def _proto(token: str) -> str: def _production_client(): config = WebConfig(api_token="configured-admin-token") server = HealthServer(web_config=config) - identity = SimpleNamespace(user_id="browser-user", tier="admin") + identity = ApiTokenIdentity(token="browser-origin", user_id="browser-user", tier="admin") + config.api_tokens = [identity] sid, _timeout = server._session_manager.create(identity=identity) + server._session_manager.set_auth_source(sid, "static") manager = setup_websocket( server._app, SimpleNamespace(name="odin"), From 953bdd8208835f7955fc965d43d8b5b303e9f386 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:21:23 -0400 Subject: [PATCH 061/162] fix(web): report verified process termination outcomes --- scripts/check-process-kill-outcome.mjs | 45 ++++++++++++++++++++++++++ src/web/api/agents_loops.py | 12 +++++-- ui/js/pages/processes.js | 5 +-- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 scripts/check-process-kill-outcome.mjs diff --git a/scripts/check-process-kill-outcome.mjs b/scripts/check-process-kill-outcome.mjs new file mode 100644 index 000000000..d345d7adf --- /dev/null +++ b/scripts/check-process-kill-outcome.mjs @@ -0,0 +1,45 @@ +// Execute the real page setup against mocked transports. No process is killed. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import vm from 'node:vm'; + +const source = await fs.readFile(new URL('../ui/js/pages/processes.js', import.meta.url), 'utf8'); +let reply; +const successes = []; +const failures = []; +const context = vm.createContext({ + console, setInterval: () => 1, clearInterval: () => {}, +}); +const api = { get: async () => [], del: async () => reply }; +const vue = { + ref: value => ({ value }), computed: fn => ({ get value() { return fn(); } }), + onActivated() {}, onDeactivated() {}, onMounted() {}, onUnmounted() {}, watch() {}, +}; +const modules = { + '../api.js': { api, ws: { on() {}, off() {} } }, + '../toast.js': { toast: { success: text => successes.push(text), error: text => failures.push(text) } }, + '../confirm.js': { confirmDialog: async () => true }, + '../utils.js': { formatDuration: String }, + vue, +}; +const module = new vm.SourceTextModule(source, { context }); +await module.link(async specifier => { + const exports = modules[specifier]; + return new vm.SyntheticModule(Object.keys(exports), function () { + for (const [name, value] of Object.entries(exports)) this.setExport(name, value); + }, { context }); +}); +await module.evaluate(); +const page = module.namespace.default.setup(); +for (const result of [ + { success: false, result: 'Error: read-only evidence.' }, + { success: true, result: 'Process 123 already exited.', outcome: 'already_stopped' }, + { success: true, result: 'Process 123 killed.', outcome: 'killed' }, +]) { + reply = result; + await page.doKill(123); + assert.equal(page.killingPid.value, null); +} +assert.deepEqual(failures, ['Error: read-only evidence.']); +assert.deepEqual(successes, ['Process 123 already exited.', 'Process 123 killed.']); +console.log('process-kill: acknowledged outcomes only; refusals never produce a success toast'); diff --git a/src/web/api/agents_loops.py b/src/web/api/agents_loops.py index 560b91164..20252cb9b 100644 --- a/src/web/api/agents_loops.py +++ b/src/web/api/agents_loops.py @@ -548,9 +548,17 @@ async def kill_process(request: web.Request) -> web.Response: except ValueError: return web.json_response({"error": "invalid PID"}, status=400) result = await registry.kill(pid) - is_error = "no process" in result.lower() + killed = result == f"Process {pid} killed." + already_stopped = result in { + f"Process {pid} already {state}." + for state in ("completed", "exited", "killed", "failed") + } or result == f"Process {pid} already exited; poll to collect its outcome." + success = killed or already_stopped + status = 200 if success else 404 if result.startswith("No process") else 409 return web.json_response( - {"result": result}, status=404 if is_error else 200 + {"result": result, "success": success, + "outcome": "killed" if killed else "already_stopped" if already_stopped else "failed", + **({"error": result} if not success else {})}, status=status ) diff --git a/ui/js/pages/processes.js b/ui/js/pages/processes.js index 673ecde21..fdc48ea8c 100644 --- a/ui/js/pages/processes.js +++ b/ui/js/pages/processes.js @@ -175,8 +175,9 @@ export default { if (!ok) return; killingPid.value = pid; try { - await api.del(`/api/processes/${pid}`); - toast.success(`Process ${pid} killed`); + const result = await api.del(`/api/processes/${pid}`); + if (!result.success) throw new Error(result.error || result.result || 'Termination not confirmed'); + toast.success(result.result); await fetchProcesses(); } catch (e) { toast.error(e.message || 'Failed to kill process'); From a88fc7ed2c75ef070ffe7573d61adf44368f6d39 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:21:23 -0400 Subject: [PATCH 062/162] test(web): qualify auth carriers and rollback failure matrix --- src/web/api/self_update.py | 9 ++- src/web/websocket.py | 6 +- tests/test_self_update_campaign_rollback.py | 7 +- tests/test_web_campaign_streams.py | 4 +- tests/test_web_campaign_truth.py | 6 +- tests/test_webauth_campaign_regressions.py | 73 +++++++++++++++++++++ 6 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/web/api/self_update.py b/src/web/api/self_update.py index 16d3b699d..7d36c07d6 100644 --- a/src/web/api/self_update.py +++ b/src/web/api/self_update.py @@ -211,10 +211,17 @@ def _rollback(reason: str) -> web.Response: mutation_started = True # Reset only the preserved config files for clean pull for fname in _preserve: - subprocess.run( + result = subprocess.run( ["git", "-C", base, "checkout", "--", fname], capture_output=True, timeout=10, ) + # An untracked preserved file needs no reset. Other failures + # are handled by rollback rather than proceeding half-mutated. + error_text = result.stderr or "" + if isinstance(error_text, bytes): + error_text = error_text.decode(errors="replace") + if result.returncode != 0 and "pathspec" not in error_text: + return _rollback("preserved config checkout failed") # Fetch and update master to the release tag's commit steps = [ diff --git a/src/web/websocket.py b/src/web/websocket.py index 0918632f7..6de031ef7 100644 --- a/src/web/websocket.py +++ b/src/web/websocket.py @@ -573,8 +573,10 @@ async def handle(self, request: web.Request) -> web.WebSocketResponse: and (getattr(identity, "user_id", None) == "api-admin" or identity is None) ): source = "legacy" - elif ws._odin_session_managed and not presented: # type: ignore[attr-defined] - source = "session" + elif identity is None and not self._authentication_required( + token_snapshot=token_snapshot + ): + source = "development" elif ( identity is None and not legacy diff --git a/tests/test_self_update_campaign_rollback.py b/tests/test_self_update_campaign_rollback.py index 31bf75111..b6c9b39a3 100644 --- a/tests/test_self_update_campaign_rollback.py +++ b/tests/test_self_update_campaign_rollback.py @@ -13,8 +13,9 @@ @pytest.mark.asyncio @pytest.mark.parametrize("stage", ["config", "fetch", "merge", "pip"]) @pytest.mark.parametrize("rollback_raises", [False, True]) +@pytest.mark.parametrize("failure_kind", ["exception", "nonzero"]) async def test_update_exceptions_restore_bytes_even_if_rollback_fails( - tmp_path, monkeypatch, stage, rollback_raises, + tmp_path, monkeypatch, stage, rollback_raises, failure_kind, ): config = tmp_path / "config.yml" env = tmp_path / ".env" @@ -35,9 +36,13 @@ def run(cmd, **kwargs): config.write_bytes(b"tracked template") env.write_bytes(b"tracked env") if stage == "config": + if failure_kind == "nonzero": + return SimpleNamespace(returncode=1, stdout="", stderr="mocked failure") raise subprocess.TimeoutExpired("mocked", 10) if (stage == "fetch" and "fetch" in cmd or stage == "merge" and "merge" in cmd or stage == "pip" and "install" in cmd): + if failure_kind == "nonzero": + return SimpleNamespace(returncode=1, stdout="", stderr="mocked failure") raise subprocess.TimeoutExpired("mocked", 10) if "reset" in cmd and rollback_raises: raise OSError("mocked rollback failure") diff --git a/tests/test_web_campaign_streams.py b/tests/test_web_campaign_streams.py index 2bed580ca..0adc7ae14 100644 --- a/tests/test_web_campaign_streams.py +++ b/tests/test_web_campaign_streams.py @@ -15,8 +15,10 @@ @pytest.mark.parametrize("stream", ["events", "logs"]) async def test_subscription_matrix(tier, stream, monkeypatch): server, _ = production_server() - identity = ApiTokenIdentity(token="", user_id="actor", tier=tier) + identity = ApiTokenIdentity(token="stream-origin", user_id="actor", tier=tier) + server._web_config.api_tokens = [identity] token, _ = server._session_manager.create(identity=identity) + server._session_manager.set_auth_source(token, "static") # Audit filesystem is synthetic; subscription and delivery are production. path = SimpleNamespace(exists=lambda: True, read_text=lambda: "synthetic-row\n", stat=lambda: SimpleNamespace(st_size=14)) diff --git a/tests/test_web_campaign_truth.py b/tests/test_web_campaign_truth.py index f183b0a16..75fd1996b 100644 --- a/tests/test_web_campaign_truth.py +++ b/tests/test_web_campaign_truth.py @@ -11,9 +11,11 @@ @pytest.mark.asyncio async def test_session_demotion_resolves_detached_authority(): server, _ = production_server() - old = ApiTokenIdentity(token="", user_id="actor", tier="admin") + old = ApiTokenIdentity(token="origin", user_id="actor", tier="admin") current = old.model_copy(update={"tier": "guest"}) + server._web_config.api_tokens = [current] token, _ = server._session_manager.create(identity=old) + server._session_manager.set_auth_source(token, "static") server._app["token_manager"] = SimpleNamespace( get=lambda _: current, resolve=lambda _: None, list_tokens=lambda: [current], ) @@ -35,7 +37,9 @@ async def test_auth_status_authoritative_identity(carrier, kind): if kind == "legacy": token, expected = "synthetic-admin", "api-admin" elif kind == "session": + server._web_config.api_tokens = [identity] token, _ = server._session_manager.create(identity=identity) + server._session_manager.set_auth_source(token, "static") elif kind == "static": server._web_config.api_tokens = [identity] else: diff --git a/tests/test_webauth_campaign_regressions.py b/tests/test_webauth_campaign_regressions.py index cc6c7249e..ec3e49bb2 100644 --- a/tests/test_webauth_campaign_regressions.py +++ b/tests/test_webauth_campaign_regressions.py @@ -251,11 +251,84 @@ def test_chat_limit_shared_across_reconnects_and_concurrent_sockets(monkeypatch) assert len(manager._chat_buckets) == 1 +@pytest.mark.asyncio +async def test_reconnected_ws_chat_frames_share_admission(monkeypatch): + from unittest.mock import AsyncMock + + config = WebConfig(api_tokens=[ApiTokenIdentity(token="one", user_id="same")]) + bot, sm, app = composition(config) + manager = WebSocketManager(bot, session_manager=sm, web_config=config) + app.router.add_get("/api/ws", manager.handle) + chat = AsyncMock(return_value={"response": "ok", "tools_used": [], "is_error": False}) + monkeypatch.setattr("src.web.websocket.process_web_chat", chat) + async with TestClient(TestServer(app)) as client: + for _ in range(10): + ws = await client.ws_connect("/api/ws", headers={"Authorization": "Bearer one"}) + await ws.send_json({"type": "chat", "content": "test"}) + assert (await ws.receive_json())["type"] == "chat_response" + await ws.close() + ws = await client.ws_connect("/api/ws", headers={"Authorization": "Bearer one"}) + await ws.send_json({"type": "chat", "content": "test"}) + assert "rate limit exceeded" in (await ws.receive_json())["error"] + await ws.close() + assert chat.await_count == 10 + await manager.close_all() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["legacy", "static"]) +async def test_removing_last_static_credential_invalidates_existing_session(source): + config = WebConfig(api_token="one") if source == "legacy" else WebConfig( + api_tokens=[ApiTokenIdentity(token="one")]) + _, sm, app = composition(config) + async with TestClient(TestServer(app)) as client: + sid = await login(client, "one") + config.api_token = "" + config.api_tokens = [] + response = await client.get("/api/probe", headers={"Authorization": f"Bearer {sid}"}) + assert response.status == 401 + assert not sm.contains(sid) + + +def test_unicode_webhook_signatures_and_listener_reauthentication(): + from src.config.schema import WebhookConfig + from src.health.server import HealthServer + from src.web.api.config_admin import _listener_admin_current + + server = HealthServer(webhook_config=WebhookConfig(secret="café")) + assert server._verify_hmac_sha256(b"body", "sha256=无效") is False + # Shared-secret header carriers accept exact UTF-8, reject others cleanly. + assert server._verify_shared_secret("café") is True + assert server._verify_shared_secret("无效") is False + config = WebConfig(api_token="café") + bot, _, _ = composition(config) + request = SimpleNamespace(headers={"Authorization": "Bearer café"}) + assert _listener_admin_current(request, bot) + request.headers = {"Authorization": "Bearer 无效"} + assert not _listener_admin_current(request, bot) + + +def test_unicode_computer_session_binding(): + from src.web.computer_binding import browser_binding + + config = WebConfig(api_tokens=[ApiTokenIdentity(token="café", user_id="one")]) + bot, sm, app = composition(config) + sid, _ = sm.create(config.api_tokens[0].model_copy(deep=True)) + sm.set_auth_source(sid, "static") + request = SimpleNamespace(_api_identity=config.api_tokens[0], _session_managed=True, + _session_id=sid, app=app, query={}) + binding = browser_binding(bot, request) + assert binding is not None and binding[1]() + config.api_tokens[0].token = "无效" + assert not binding[1]() + + @pytest.mark.asyncio @pytest.mark.parametrize("message,status,outcome", [ ("Failed to kill PID 123: permission denied", 409, "failed"), ("Error: retained process evidence is read-only.", 409, "failed"), ("No process with PID 123.", 404, "failed"), + ("Process 123 already unknown.", 409, "failed"), ("Process 123 already exited; poll to collect its outcome.", 200, "already_stopped"), ("Process 123 killed.", 200, "killed"), ]) From db8bf16f9b24039b28e197c7de907247382550fc Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:22:48 -0400 Subject: [PATCH 063/162] test(web): use actual process registry success reply --- tests/test_web_api_agents_loops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_web_api_agents_loops.py b/tests/test_web_api_agents_loops.py index 1c5faec4c..58f6f9c1b 100644 --- a/tests/test_web_api_agents_loops.py +++ b/tests/test_web_api_agents_loops.py @@ -552,7 +552,7 @@ async def test_list_processes_no_registry(self): async def test_kill_process(self): bot = MagicMock() reg = MagicMock() - reg.kill = AsyncMock(return_value="Killed process 5.") + reg.kill = AsyncMock(return_value="Process 5 killed.") bot.tool_executor._process_registry = reg async with TestClient(TestServer(_app(register_processes, bot=bot))) as c: assert (await c.delete("/api/processes/5")).status == 200 From e3cd53269fe540cea35b8b83d3c83f9d84fcb178 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:52:21 -0400 Subject: [PATCH 064/162] Fix autonomous iteration outcomes, terminal findings and completion timing --- src/discord/tool_loop.py | 15 ++- src/tools/autonomous_loop.py | 44 +++++++-- .../characterization/test_autonomous_loop.py | 4 +- tests/test_campaign_loops.py | 95 +++++++++++++++++++ 4 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 tests/test_campaign_loops.py diff --git a/src/discord/tool_loop.py b/src/discord/tool_loop.py index e8dd85f7a..9af3cb7a0 100644 --- a/src/discord/tool_loop.py +++ b/src/discord/tool_loop.py @@ -3414,7 +3414,10 @@ async def run_autonomous( Codex + tool execution pipeline but without detection retries. """ if not self._llm_gateway.active_client: - return "LLM provider not available." + from ..tools.autonomous_loop import LoopIterationResult + + return LoopIterationResult("LLM provider not available.", is_error=True, + failure_class="provider") st = self._prepare_loop_turn(prompt, channel, prev_context, user_id, policy) @@ -3603,7 +3606,9 @@ async def _finish_loop( tool_details=st._loop_details, user_id=st.user_id, ) - return outcome_text + from ..tools.autonomous_loop import LoopIterationResult + + return LoopIterationResult(outcome_text, is_error=is_error, failure_class=failure_class) def _maybe_compress_loop(self, st: _LoopTurn, serving, config, *, budget_snapshot=None) -> None: """Loop pre-send compaction (campaign phase 4 — loops previously had @@ -4156,7 +4161,7 @@ async def _finalize_loop(self, st: _LoopTurn) -> str: # returning it as is_error=False would silently hide the cap hit (the # cap-warning path below was unreachable whenever any iteration produced # text). - if st.final_text and st.completed_naturally: + if st.completed_naturally: final_text = scrub_output_secrets(st.final_text) if len(final_text) > DISCORD_MAX_LEN: final_text = final_text[: DISCORD_MAX_LEN - 50] + "\n... (truncated)" @@ -4176,7 +4181,7 @@ async def _finalize_loop(self, st: _LoopTurn) -> str: # Cap exhausted without a tool-free response. Surface it (optionally with # the stale partial text) instead of hiding the truncation. - if st.tool_calls_made >= st.loop_cap or not st.completed_naturally: + if not st.completed_naturally: log.warning( "Loop tool-iteration cap hit (%d) after %d tool calls; " "no tool-free summary from Codex", @@ -4195,7 +4200,7 @@ async def _finalize_loop(self, st: _LoopTurn) -> str: f"Raise `tools.max_tool_iterations_loop` in config (or via the " f"web UI) if this happens repeatedly." + _partial, is_error=True, - failure_class="cancelled", + failure_class="iteration_cap", error_text=f"loop iteration cap {st.loop_cap} reached", ) diff --git a/src/tools/autonomous_loop.py b/src/tools/autonomous_loop.py index 15a54e675..427e11157 100644 --- a/src/tools/autonomous_loop.py +++ b/src/tools/autonomous_loop.py @@ -22,6 +22,24 @@ log = get_logger("autonomous_loop") +class LoopIterationResult(str): + """Visible callback text with an explicit, non-text-derived outcome. + + A str subclass preserves existing callback consumers and legacy callbacks. + Failures must not be inferred from model text (which can quote errors). + """ + + def __new__(cls, text: str, *, is_error: bool = False, failure_class: str = ""): + result = super().__new__(cls, text) + result.is_error = is_error + result.failure_class = failure_class + return result + + +class _IterationFailure(Exception): + """Internal carrier for an already formatted tool-loop failure.""" + + # Type for the LLM iteration callback: # Takes (goal_prompt, channel, iteration_context, cancel_event) -> response text # The callback should run the full Codex + tool loop internally. @@ -352,9 +370,11 @@ async def _run_loop( finally: _current_loop.reset(_loop_token) reset_turn(_turn_token) - response = scrub_output_secrets(response.strip()) if response else "" if info._cancel_event.is_set(): break + if isinstance(response, LoopIterationResult) and response.is_error: + raise _IterationFailure(str(response)) + response = scrub_output_secrets(response.strip()) if response else "" consecutive_errors = 0 # Reset on success except Exception as e: consecutive_errors += 1 @@ -378,7 +398,8 @@ async def _run_loop( # WebUI loop detail, and the channel post is user-facing # — both get the bounded formatter summary, never raw # exception text (which can carry upstream HTML pages). - err_msg = format_user_facing_error(e) + err_msg = (scrub_output_secrets(str(e)) if isinstance(e, _IterationFailure) + else format_user_facing_error(e)) info._iteration_history.append( f"Iteration {info.iteration_count}: ERROR - {err_msg}" ) @@ -406,20 +427,20 @@ async def _run_loop( backoff, consecutive_errors, ) + if info.iteration_count >= info.max_iterations: + break if await self._interruptible_wait(info, backoff): break continue + stop_requested = LOOP_STOP_SENTINEL in response + if stop_requested: + response = response.replace(LOOP_STOP_SENTINEL, "").strip() + # Store iteration result (truncated) in history summary = response[:500] if response else "(no output)" info._iteration_history.append(f"Iteration {info.iteration_count}: {summary}") - # Check for LOOP_STOP sentinel - if LOOP_STOP_SENTINEL in response: - info.status = "completed" - log.info("Loop %s stopped by LLM (LOOP_STOP)", info.id) - break - # Runaway detection: identical consecutive outputs if response == last_output and response: consecutive_identical += 1 @@ -453,6 +474,13 @@ async def _run_loop( if response and info.status == "running": await self._post_response(info, channel, response) + if stop_requested: + info.status = "completed" + log.info("Loop %s stopped by LLM (LOOP_STOP)", info.id) + break + if info.iteration_count >= info.max_iterations: + break + # Wait for interval before next iteration (interruptible by cancel). # Placed AFTER iteration so the first run executes immediately. # (Error iterations back off and `continue` above, so diff --git a/tests/characterization/test_autonomous_loop.py b/tests/characterization/test_autonomous_loop.py index d5102f51b..94d3af117 100644 --- a/tests/characterization/test_autonomous_loop.py +++ b/tests/characterization/test_autonomous_loop.py @@ -224,7 +224,7 @@ async def test_llm_error_text_sanitized_for_reflection_and_response(self): assert " Date: Tue, 29 Sep 2026 19:21:10 -0400 Subject: [PATCH 065/162] Type autonomous failure metadata and pin uninterrupted successful generations --- src/tools/autonomous_loop.py | 9 ++++++--- tests/test_campaign_loops.py | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/tools/autonomous_loop.py b/src/tools/autonomous_loop.py index 427e11157..f83d900c3 100644 --- a/src/tools/autonomous_loop.py +++ b/src/tools/autonomous_loop.py @@ -29,6 +29,9 @@ class LoopIterationResult(str): Failures must not be inferred from model text (which can quote errors). """ + is_error: bool + failure_class: str + def __new__(cls, text: str, *, is_error: bool = False, failure_class: str = ""): result = super().__new__(cls, text) result.is_error = is_error @@ -36,7 +39,7 @@ def __new__(cls, text: str, *, is_error: bool = False, failure_class: str = ""): return result -class _IterationFailure(Exception): +class _IterationError(Exception): """Internal carrier for an already formatted tool-loop failure.""" @@ -373,7 +376,7 @@ async def _run_loop( if info._cancel_event.is_set(): break if isinstance(response, LoopIterationResult) and response.is_error: - raise _IterationFailure(str(response)) + raise _IterationError(str(response)) response = scrub_output_secrets(response.strip()) if response else "" consecutive_errors = 0 # Reset on success except Exception as e: @@ -398,7 +401,7 @@ async def _run_loop( # WebUI loop detail, and the channel post is user-facing # — both get the bounded formatter summary, never raw # exception text (which can carry upstream HTML pages). - err_msg = (scrub_output_secrets(str(e)) if isinstance(e, _IterationFailure) + err_msg = (scrub_output_secrets(str(e)) if isinstance(e, _IterationError) else format_user_facing_error(e)) info._iteration_history.append( f"Iteration {info.iteration_count}: ERROR - {err_msg}" diff --git a/tests/test_campaign_loops.py b/tests/test_campaign_loops.py index be9c1123f..a4f3a49ea 100644 --- a/tests/test_campaign_loops.py +++ b/tests/test_campaign_loops.py @@ -1,4 +1,6 @@ """Real autonomous runner/manager regressions, with scripted model boundaries.""" +import asyncio + import pytest from src.tools.autonomous_loop import LoopManager @@ -93,3 +95,24 @@ async def test_empty_natural_completion_after_batched_tools_is_success(tmp_path, assert not result.is_error assert bot.turn_recorder._maybe_loop_reflect.calls[-1]["is_error"] is False assert len(fake.calls) == 2 + + +async def test_legitimate_generation_is_awaited_and_error_quotes_are_not_failures(): + manager, channel = LoopManager(), FakeChannel(id=777) + entered, release = asyncio.Event(), asyncio.Event() + + async def iteration(*args): + entered.set() + await release.wait() + return "Quoted diagnostic: ERROR provider failed. Investigation completed." + + loop_id = manager.start_loop("investigate", channel, "4242", "tester", iteration, + max_iterations=1) + task = manager._loops[loop_id]._task + await entered.wait() + await asyncio.sleep(0) + assert manager._loops[loop_id].status == "running" and not task.done() + release.set() + await task + assert manager._loops[loop_id].status == "completed" + assert "Investigation completed" in str(channel.sent) From bf7d71288a60512789d5801149c5aca26428ee51 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:21:10 -0400 Subject: [PATCH 066/162] Reserve outbound target admission and admit first events on young hosts --- src/notifications/outbound_webhooks.py | 22 ++++++++-- tests/test_campaign_outbound.py | 61 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/test_campaign_outbound.py diff --git a/src/notifications/outbound_webhooks.py b/src/notifications/outbound_webhooks.py index c1f422e4e..1ebfcf37b 100644 --- a/src/notifications/outbound_webhooks.py +++ b/src/notifications/outbound_webhooks.py @@ -331,6 +331,7 @@ class OutboundWebhookDispatcher: "_scrub", "_rate_limit_seconds", "_last_sent", + "_in_flight", ) def __init__( @@ -346,6 +347,7 @@ def __init__( self._scrub = scrub_secrets self._rate_limit_seconds = max(0.0, rate_limit_seconds) self._last_sent: dict[str, float] = {} + self._in_flight: set[str] = set() @property def stats(self) -> WebhookStats: @@ -467,8 +469,10 @@ def update( def _check_rate_limit(self, webhook_id: str) -> bool: if self._rate_limit_seconds <= 0: return True - last = self._last_sent.get(webhook_id, 0.0) - return (time.monotonic() - last) >= self._rate_limit_seconds + if webhook_id in self._in_flight: + return False + last = self._last_sent.get(webhook_id) + return last is None or (time.monotonic() - last) >= self._rate_limit_seconds def _mark_sent(self, webhook_id: str) -> None: self._last_sent[webhook_id] = time.monotonic() @@ -668,8 +672,18 @@ async def dispatch( payload_json = _truncate_payload(json.dumps(final_payload)) payload_body = payload_json.encode() - result = await self._deliver_one(target, payload_body, event_type) - self._mark_sent(target.id) + # Recheck at actual admission: earlier targets may have awaited. + # Reserve without an await, then release on every terminal path. + if not self._check_rate_limit(target.id): + continue + self._in_flight.add(target.id) + try: + result = await self._deliver_one(target, payload_body, event_type) + # Preserve the existing settled-attempt cooldown/retry policy, + # including unsuccessful DeliveryResults. + self._mark_sent(target.id) + finally: + self._in_flight.discard(target.id) self._stats.record(result) results.append(result) diff --git a/tests/test_campaign_outbound.py b/tests/test_campaign_outbound.py new file mode 100644 index 000000000..57d90229b --- /dev/null +++ b/tests/test_campaign_outbound.py @@ -0,0 +1,61 @@ +"""Admission/cooldown regressions using the actual dispatcher and HTTP boundary.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from src.notifications.outbound_webhooks import OutboundWebhookDispatcher, WebhookTarget +from tests.test_outbound_webhooks import _make_mock_session + + +@pytest.mark.parametrize("status", [200, 400]) +async def test_overlapping_delivery_reserves_target_and_preserves_settled_cooldown(status): + dispatcher = OutboundWebhookDispatcher(rate_limit_seconds=300) + dispatcher._webhooks["target"] = WebhookTarget( + id="target", name="fixture", url="https://example.com/hook") + session, response = _make_mock_session(status=status) + dispatcher._session = session + entered, release = asyncio.Event(), asyncio.Event() + + async def enter(): + entered.set() + await release.wait() + return response + + session.post.return_value.__aenter__.side_effect = enter + with patch("src.notifications.outbound_webhooks.time", SimpleNamespace(monotonic=lambda: 12)): + first = asyncio.create_task(dispatcher.dispatch("custom", {"event": 1})) + await entered.wait() + assert await dispatcher.dispatch("custom", {"event": 2}) == [] + release.set() + result = await first + assert len(result) == 1 + assert result[0].success is (status == 200) + assert await dispatcher.dispatch("custom", {"event": 3}) == [] + assert session.post.call_count == (1 if status == 200 else 3) + assert dispatcher._in_flight == set() + + +async def test_cancelled_admission_releases_reservation_without_false_settlement(): + dispatcher = OutboundWebhookDispatcher(rate_limit_seconds=300) + dispatcher._webhooks["target"] = WebhookTarget( + id="target", name="fixture", url="https://example.com/hook") + session, response = _make_mock_session() + dispatcher._session = session + entered = asyncio.Event() + + async def enter(): + entered.set() + await asyncio.Event().wait() + + session.post.return_value.__aenter__.side_effect = enter + task = asyncio.create_task(dispatcher.dispatch("custom", {})) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert dispatcher._in_flight == set() + assert dispatcher._last_sent == {} + session.post.return_value.__aenter__.side_effect = None + assert len(await dispatcher.dispatch("custom", {})) == 1 From e9236e6f5aa4ba27ffc83ff4845b80d06acd678e Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:21:10 -0400 Subject: [PATCH 067/162] Select requester-owned preserved work before explicit resume rejection --- src/discord/turn_resume.py | 17 ++++++-- src/turn_state/store.py | 5 ++- tests/test_campaign_discord_resume.py | 61 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 tests/test_campaign_discord_resume.py diff --git a/src/discord/turn_resume.py b/src/discord/turn_resume.py index 0aeedbbe1..73bddf83f 100644 --- a/src/discord/turn_resume.py +++ b/src/discord/turn_resume.py @@ -103,9 +103,12 @@ async def is_suspended(self, channel_id: str, message_id: str) -> bool: row = await asyncio.to_thread(self._store.load_resumable_sync, key) return row is not None - async def _latest_suspended_for_channel(self, channel_id: str) -> dict | None: + async def _latest_suspended_for_channel( + self, channel_id: str, user_id: str | None = None, + ) -> dict | None: rows = await asyncio.to_thread(self._store.list_suspended_sync, "discord") - candidates = [r for r in rows if r["channel_id"] == channel_id] + candidates = [r for r in rows if r["channel_id"] == channel_id + and (user_id is None or str(r.get("user_id") or "") == user_id)] if not candidates: return None return max(candidates, key=lambda r: r.get("suspended_at") or 0.0) @@ -337,9 +340,15 @@ async def try_explicit_resume(self, message: Any): # failed, so fail closed with a bounded notice (round-6 task 1). try: channel_id = str(message.channel.id) - row_summary = await self._latest_suspended_for_channel(channel_id) + row_summary = await self._latest_suspended_for_channel( + channel_id, str(message.author.id), + ) if row_summary is None: - return None # lookup succeeded: genuinely nothing to resume + # Preserve recognition/refusal when only another owner's work + # exists; never turn their resume command into fresh execution. + row_summary = await self._latest_suspended_for_channel(channel_id) + if row_summary is None: + return None # lookup succeeded: genuinely nothing to resume return await self._explicit_resume_recognized(message, row_summary) except Exception: log.exception("Explicit resume failed internally") diff --git a/src/turn_state/store.py b/src/turn_state/store.py index 50db7d40c..3a1e0c1de 100644 --- a/src/turn_state/store.py +++ b/src/turn_state/store.py @@ -1010,7 +1010,7 @@ def list_suspended_sync(self, source: str | None = None) -> list[dict]: return [] sql = ( "SELECT source, channel_id, message_id, turn_generation, " - "last_progress_at, suspended_at FROM turns WHERE status=?" + "last_progress_at, suspended_at, user_id FROM turns WHERE status=?" ) params: list = [TurnStatus.SUSPENDED] if source: @@ -1019,7 +1019,8 @@ def list_suspended_sync(self, source: str | None = None) -> list[dict]: rows = self._conn.execute(sql, params).fetchall() return [ {"source": r[0], "channel_id": r[1], "message_id": r[2], - "generation": r[3], "last_progress_at": r[4], "suspended_at": r[5]} + "generation": r[3], "last_progress_at": r[4], "suspended_at": r[5], + "user_id": r[6]} for r in rows ] diff --git a/tests/test_campaign_discord_resume.py b/tests/test_campaign_discord_resume.py new file mode 100644 index 000000000..b567ceb4b --- /dev/null +++ b/tests/test_campaign_discord_resume.py @@ -0,0 +1,61 @@ +"""Requester-first explicit resume lookup through the real persisted store.""" +import time +from types import SimpleNamespace + +from src.turn_state import TurnKey, TurnStatus +from tests.fakes import text_response +from tests.test_resume_admission import Harness, heal_capacity, resume_msg, suspend_turn + + +async def test_explicit_resume_selects_callers_older_work_not_other_newest(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + harness = Harness([], tmp_path) + keys = [] + for message_id, user in [("1", "alice"), ("2", "bob"), ("3", "alice")]: + key = TurnKey(source="discord", channel_id="42", message_id=message_id) + lease, disposition = harness.store.admit_turn_sync( + key, guild_id=None, user_id=user, content_digest=None, code_version=None, + prompt_policy_hash=None, tool_catalog_hash=None, session_snapshot=None, + ) + assert disposition == "admitted" + harness.store.suspend_sync(lease, {}) + keys.append(key) + # Bob is newest overall; Alice's most recent owned work must win. + harness.store._conn.execute( + "UPDATE turns SET suspended_at=? WHERE message_id='2'", [time.time() + 10]) + harness.store._conn.commit() + captured = [] + + async def resume(message, summary): + captured.append(summary) + return "resumed", False, False, [], False + + harness.manager._explicit_resume_recognized = resume + message = SimpleNamespace(content="resume", channel=SimpleNamespace(id="42"), + author=SimpleNamespace(id="alice")) + result = await harness.manager.try_explicit_resume(message) + assert result[0] == "resumed" + assert captured[0]["message_id"] == "3" + assert captured[0]["user_id"] == "alice" + harness.store.close() + + +async def test_owned_older_payload_actually_resumes_through_runner(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + harness, original = await suspend_turn(tmp_path) + other_key = TurnKey("discord", str(original.channel.id), "other-message") + lease, disposition = harness.store.admit_turn_sync( + other_key, guild_id=None, user_id="999999", content_digest=None, code_version=None, + prompt_policy_hash=None, tool_catalog_hash=None, session_snapshot=None, + ) + assert disposition == "admitted" + harness.store.suspend_sync(lease, {}) + heal_capacity(harness, text_response("Finished preserved work.")) + result = await harness.manager.try_explicit_resume(resume_msg(original)) + assert result is not None + assert result[0] == "Finished preserved work." + assert result[2] is False + assert result[3] == ["parse_time"] + assert harness.store.turn_status_sync(other_key) == TurnStatus.SUSPENDED + assert harness.store._conn.execute("SELECT COUNT(*) FROM operations").fetchone()[0] == 1 + harness.store.close() From 8daae356744b69c185a1751bac05c997802f57b6 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:21:10 -0400 Subject: [PATCH 068/162] Bound scheduled digest fallback sends including annotations --- src/discord/scheduled_events.py | 6 +++++- tests/test_campaign_discord_digest.py | 31 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/test_campaign_discord_digest.py diff --git a/src/discord/scheduled_events.py b/src/discord/scheduled_events.py index 1ec0ccad6..08054c2ce 100644 --- a/src/discord/scheduled_events.py +++ b/src/discord/scheduled_events.py @@ -136,7 +136,11 @@ async def _on_scheduled_digest(self, schedule: dict) -> None: labels += ", …" summary += f"\n\nCollection failed for {len(failed)} of {total} checks: {labels}" - await channel.send(scrub_response_secrets(f"**Daily Infrastructure Digest**\n\n{summary}")) + from .delivery import DISCORD_MAX_LEN + + notice = scrub_response_secrets(f"**Daily Infrastructure Digest**\n\n{summary}") + for offset in range(0, len(notice), DISCORD_MAX_LEN): + await channel.send(notice[offset:offset + DISCORD_MAX_LEN]) # Audit log the digest try: diff --git a/tests/test_campaign_discord_digest.py b/tests/test_campaign_discord_digest.py new file mode 100644 index 000000000..c2460579c --- /dev/null +++ b/tests/test_campaign_discord_digest.py @@ -0,0 +1,31 @@ +"""Bounded raw fallback through the real scheduled digest callback.""" +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.discord.scheduled_events import ScheduledEventHandlers, ScheduledEventsDeps + + +@pytest.mark.parametrize("failed_summary", [False, True]) +async def test_raw_digest_fallback_is_bounded_including_heading_and_failures(failed_summary): + sent = [] + + async def send(text): + assert len(text) <= 2000 + sent.append(text) + + channel = SimpleNamespace(send=send) + client = SimpleNamespace(chat=AsyncMock(side_effect=RuntimeError("summary unavailable"))) + handler = ScheduledEventHandlers(ScheduledEventsDeps( + get_config=lambda: SimpleNamespace(), get_channel=lambda _: channel, + get_guilds=lambda: [], tool_executor=MagicMock(), + audit=MagicMock(log_execution=AsyncMock()), + llm_gateway=SimpleNamespace(active_client=client if failed_summary else None), + tool_loop=MagicMock(), agent_task_tools=MagicMock(), + )) + handler._format_digest_raw = AsyncMock(return_value=("x" * 5000, ["disk" * 100], 2)) + await handler._on_scheduled_digest({"id": "fixture", "channel_id": "42"}) + assert len(sent) == 2 + assert "".join(sent) == ("**Daily Infrastructure Digest**\n\n" + "x" * 3000 + + "\n\nCollection failed for 1 of 2 checks: " + "disk" * 100) From 5389cae14e9c9f774a8db7517e682aad125fa551 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:21:10 -0400 Subject: [PATCH 069/162] Preserve complete authorized attachment evidence and buffered bot files --- src/discord/attachments.py | 62 ++++++--- src/discord/channel_state.py | 6 +- src/discord/intake_pipeline.py | 41 ++++-- src/discord/wiring.py | 1 + tests/test_attachments.py | 6 +- tests/test_campaign_discord_attachments.py | 139 +++++++++++++++++++++ tests/test_campaign_discord_intake.py | 49 ++++++++ 7 files changed, 274 insertions(+), 30 deletions(-) create mode 100644 tests/test_campaign_discord_attachments.py create mode 100644 tests/test_campaign_discord_intake.py diff --git a/src/discord/attachments.py b/src/discord/attachments.py index 06471daea..7d67058cd 100644 --- a/src/discord/attachments.py +++ b/src/discord/attachments.py @@ -16,6 +16,7 @@ import shutil import tarfile import time +import uuid import zipfile from dataclasses import dataclass, field from enum import Enum @@ -23,6 +24,7 @@ from typing import Any from ..odin_log import get_logger +from ..tools.media_result import BinaryAttachment log = get_logger("attachments") @@ -75,6 +77,7 @@ class AttachmentResult: saved_files: list[SavedAttachment] = field(default_factory=list) warnings: list[str] = field(default_factory=list) processing_ms: int = 0 + retained_content: list[BinaryAttachment] = field(default_factory=list, repr=False) def infer_attachment_intent( @@ -180,6 +183,19 @@ def _workspace(self, channel_id: str, message_id: str) -> Path: ws.mkdir(parents=True, exist_ok=True) return ws + def _save(self, att: Any, data: bytes, channel_id: str, message_id: str) -> Path: + """Exclusive, per-attachment paths, even after sanitization or reprocessing.""" + ws = self._workspace(channel_id, message_id) + safe = _safe_filename(att.filename) or "attachment" + while True: + path = ws / (uuid.uuid4().hex + "-" + safe) + try: + with path.open("xb") as saved: + saved.write(data) + return path + except FileExistsError: + continue + async def process( self, attachments: list[Any], @@ -205,7 +221,7 @@ async def process( # PDFs if ext == ".pdf": - await self._handle_pdf(att, text_parts, result) + await self._handle_pdf(att, channel_id, message_id, text_parts, result) continue # Archives @@ -258,7 +274,8 @@ async def _handle_image( text_parts.append(f"[Image: {att.filename} (failed: {e})]") async def _handle_pdf( - self, att: Any, text_parts: list[str], result: AttachmentResult, + self, att: Any, channel_id: str, message_id: str, + text_parts: list[str], result: AttachmentResult, ) -> None: if att.size > self.pdf_max_bytes: text_parts.append( @@ -273,6 +290,22 @@ async def _handle_pdf( pages = [f"Page {i+1}: {p.get_text()}" for i, p in enumerate(doc)] full = "\n".join(pages) preview = _preview_text(full, ".pdf", self.preview_max_chars) + if len(full) > self.preview_max_chars: + save_path = self._save(att, data, channel_id, message_id) + result.saved_files.append(SavedAttachment( + filename=att.filename, path=str(save_path), size=len(data), + sha256=_sha256(data), content_type="application/pdf", kind="pdf", + )) + result.retained_content.extend([ + BinaryAttachment(len(result.retained_content), + f"PDF source: {att.filename}", "application/pdf", data), + BinaryAttachment(len(result.retained_content) + 1, + f"PDF extracted text: {att.filename}; " + "page-labelled PyMuPDF", + "text/plain; charset=utf-8", full.encode("utf-8")), + ]) + text_parts.append(f"[Full PDF source saved: `{save_path}`. " + "Full page-labelled extraction retained with source below.]") text_parts.append( f"**Attached PDF: {att.filename}** ({doc.page_count} pages)\n" f"```\n{preview}\n```\n" @@ -298,17 +331,18 @@ async def _handle_text( intent_note = (" User requested knowledge ingestion; use ingest_document if " "appropriate.") - if att.size <= self.inline_max_bytes: + if len(data) <= self.inline_max_bytes and len(text) <= self.preview_max_chars: preview = _preview_text(text, ext, self.preview_max_chars) text_parts.append( f"**Attached file: {att.filename}**\n```\n{preview}\n```\n" f"[File read for current task.{intent_note}]" ) else: - ws = self._workspace(channel_id, message_id) - safe = _safe_filename(att.filename) - save_path = ws / safe - save_path.write_bytes(data) + save_path = self._save(att, data, channel_id, message_id) + result.retained_content.append(BinaryAttachment( + len(result.retained_content), f"Original text file: {att.filename}", + att.content_type or "text/plain", data, + )) preview = _preview_text(text, ext, self.large_preview_chars) text_parts.append( @@ -317,7 +351,7 @@ async def _handle_text( f"SHA256: `{digest[:16]}...`\n" f"```\n{preview}\n```\n" f"[Large file previewed for current task. " - f"Full file available at the saved path for shell tools.]" + f"Full file available at the saved path for read_file.{intent_note}]" ) result.saved_files.append(SavedAttachment( filename=att.filename, path=str(save_path), @@ -340,14 +374,11 @@ async def _handle_archive( try: data = await att.read() digest = _sha256(data) - ws = self._workspace(channel_id, message_id) - safe = _safe_filename(att.filename) - archive_path = ws / safe - archive_path.write_bytes(data) + archive_path = self._save(att, data, channel_id, message_id) ext = _get_ext(att.filename) manifest_lines: list[str] = [] - extract_dir = ws / safe.rsplit(".", 1)[0] + extract_dir = archive_path.with_name(archive_path.name + ".extracted") if ext == ".zip": manifest_lines, extracted = await asyncio.to_thread( @@ -498,10 +529,7 @@ async def _handle_binary( try: data = await att.read() digest = _sha256(data) - ws = self._workspace(channel_id, message_id) - safe = _safe_filename(att.filename) - save_path = ws / safe - save_path.write_bytes(data) + save_path = self._save(att, data, channel_id, message_id) text_parts.append( f"[Attachment saved: `{save_path}` " diff --git a/src/discord/channel_state.py b/src/discord/channel_state.py index 66637b4d3..f10033a86 100644 --- a/src/discord/channel_state.py +++ b/src/discord/channel_state.py @@ -25,6 +25,8 @@ from .steer_notifications import SteerNotifier, finish_steer_notifications, notify_steer if TYPE_CHECKING: + import discord + from .background_task import BackgroundTask log = get_logger("discord") @@ -96,8 +98,8 @@ def __init__( self.processed_messages: collections.OrderedDict[int, None] = collections.OrderedDict() self.processed_messages_max = processed_messages_max # Bot message buffer: accumulate rapid-fire bot messages before processing - # Key: (channel_id, author_id) → list of content strings - self.bot_msg_buffer: dict[tuple[str, str], list[str]] = {} + # Key: (channel_id, author_id) → original messages (including attachments) + self.bot_msg_buffer: dict[tuple[str, str], list[discord.Message]] = {} self.bot_msg_tasks: dict[tuple[str, str], asyncio.Task] = {} self.bot_msg_buffer_delay = bot_msg_buffer_delay # seconds to wait for more self.bot_msg_buffer_max = bot_msg_buffer_max # max messages per bot+channel diff --git a/src/discord/intake_pipeline.py b/src/discord/intake_pipeline.py index da28c598c..ef431c90f 100644 --- a/src/discord/intake_pipeline.py +++ b/src/discord/intake_pipeline.py @@ -21,9 +21,10 @@ import asyncio import io +import json from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import discord @@ -64,6 +65,7 @@ class MessageIntakeDeps: channel_state: ChannelStateRegistry sessions: SessionManager pipeline: MessagePipeline # the post-gate hand-off + tool_executor: Any = None # authorized full attachment output retention class MessageIntake: @@ -75,6 +77,7 @@ def __init__(self, deps: MessageIntakeDeps) -> None: self._channel_state = deps.channel_state self._sessions = deps.sessions self._pipeline = deps.pipeline + self._tool_executor = getattr(deps, "tool_executor", None) def is_allowed_user(self, user: discord.User | discord.Member) -> bool: if not self._get_config().discord.allowed_users: @@ -138,7 +141,22 @@ async def _process_attachments( for w in result.warnings: log.warning("Attachment warning: %s", w) - return result.inline_text, result.image_blocks + text = result.inline_text + retained = getattr(result, "retained_content", None) + if retained and self._tool_executor is not None: + try: + manifest = self._tool_executor.retain_attachments( + retained, tool_name="get_tool_output", user_id=str(message.author.id), + channel_id=str(message.channel.id), + ) + text += "\n[Full attachment contents, in labelled source order. " + text += "Retrieve via the existing authorized get_tool_output path; " + text += "decode and concatenate binary pages before ingestion.]\n" + text += json.dumps(manifest) + except Exception: + log.exception("Attachment output retention failed") + text += "\n[Full-content output retrieval unavailable; no cursor was issued.]" + return text, result.image_blocks async def handle(self, message: discord.Message) -> None: from .tool_loop_helpers import _ALLOWED_WEBHOOK_IDS @@ -279,7 +297,7 @@ async def handle(self, message: discord.Message) -> None: if len(buf) >= self._channel_state.bot_msg_buffer_max: log.warning("Bot buffer full (%d msgs) for %s, dropping oldest", len(buf), buf_key) buf.pop(0) - buf.append(scrub_output_secrets(message.content)) + buf.append(message) # Cancel previous timer for this bot+channel if buf_key in self._channel_state.bot_msg_tasks: @@ -292,7 +310,8 @@ async def _flush_bot_buffer(key, orig_msg): self._channel_state.bot_msg_tasks.pop(key, None) if not parts: return - combined = combine_bot_messages(parts) + contents = [scrub_output_secrets(part.content or "") for part in parts] + combined = combine_bot_messages(contents) log.info( "Bot buffer flushed: %d messages from %s combined", len(parts), orig_msg.author ) @@ -317,7 +336,7 @@ async def _flush_bot_buffer(key, orig_msg): return mention_str = f"<@{bot_user.id}>" mention_nick = f"<@!{bot_user.id}>" - if not any(mention_str in p or mention_nick in p for p in parts): + if not any(mention_str in p or mention_nick in p for p in contents): log.info( "Bot buffer discarded: no mention found in %d messages from %s", len(parts), @@ -328,11 +347,17 @@ async def _flush_bot_buffer(key, orig_msg): if self._get_user(): combined = combined.replace(f"<@{self._get_user().id}>", "").strip() combined = combined.replace(f"<@!{self._get_user().id}>", "").strip() - if combined: + image_blocks = [] + for part in parts: + attachment_text, images = await self._process_attachments(part, combined) + if attachment_text: + combined += "\n\n" + scrub_output_secrets(attachment_text) + image_blocks.extend(images) + if combined or image_blocks: await self._pipeline.run( orig_msg, - combined, - image_blocks=[], + combined or "(see attached image)", + image_blocks=image_blocks, from_another_bot=True, ) diff --git a/src/discord/wiring.py b/src/discord/wiring.py index 90a9a1172..130ddb875 100644 --- a/src/discord/wiring.py +++ b/src/discord/wiring.py @@ -1093,6 +1093,7 @@ def _release_chat_calibration(key) -> None: channel_state=services.channel_state, sessions=services.sessions, pipeline=pipeline, + tool_executor=services.tool_executor, ) ) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 4e0158b37..2e419416a 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -271,7 +271,7 @@ class TestPdfHandler: async def test_size_limit(self, tmp_path): p = AttachmentProcessor(temp_dir=str(tmp_path), pdf_max_bytes=10) parts: list = [] - await p._handle_pdf(_mock_attachment("big.pdf", 999), parts, AttachmentResult()) + await p._handle_pdf(_mock_attachment("big.pdf", 999), "c", "m", parts, AttachmentResult()) assert "exceeds limit" in parts[0] @pytest.mark.asyncio @@ -286,7 +286,7 @@ def close(self): parts: list = [] with patch.dict(sys.modules, {"fitz": SimpleNamespace(open=lambda **k: _Doc())}): await p._handle_pdf(_mock_attachment("doc.pdf", 4, data=b"%PDF"), - parts, AttachmentResult()) + "c", "m", parts, AttachmentResult()) assert "pdf body" in parts[0] and "1 pages" in parts[0] @pytest.mark.asyncio @@ -295,7 +295,7 @@ async def test_failure(self, tmp_path): boom = SimpleNamespace(open=lambda **k: (_ for _ in ()).throw(RuntimeError("bad"))) parts: list = [] with patch.dict(sys.modules, {"fitz": boom}): - await p._handle_pdf(_mock_attachment("doc.pdf", 4), parts, AttachmentResult()) + await p._handle_pdf(_mock_attachment("doc.pdf", 4), "c", "m", parts, AttachmentResult()) assert "failed" in parts[0] diff --git a/tests/test_campaign_discord_attachments.py b/tests/test_campaign_discord_attachments.py new file mode 100644 index 000000000..8219e7486 --- /dev/null +++ b/tests/test_campaign_discord_attachments.py @@ -0,0 +1,139 @@ +"""Saved and retained attachment evidence remains complete and caller-bound.""" +import base64 +import hashlib +import io +import json +import sys +import zipfile +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from src.discord.attachments import AttachmentIntent, AttachmentProcessor +from src.discord.intake_pipeline import MessageIntake +from src.tools.runtime_delivery import execution_delivery_scope +from tests.test_attachments import _mock_attachment +from tests.test_executor_output_retention import executor + + +@pytest.mark.parametrize("names", [("same.txt", "same.txt"), ("a?.txt", "a*.txt")]) +async def test_text_paths_do_not_collide_even_when_sanitized(tmp_path, names): + processor = AttachmentProcessor(temp_dir=str(tmp_path), inline_max_bytes=1) + data = [b"first", b"second"] + result = await processor.process([ + _mock_attachment(name, len(body), data=body) for name, body in zip(names, data, strict=True) + ], "42", "1") + assert len({saved.path for saved in result.saved_files}) == 2 + for saved, body in zip(result.saved_files, data, strict=True): + from pathlib import Path + assert Path(saved.path).read_bytes() == body + assert saved.sha256 == hashlib.sha256(body).hexdigest() + + +@pytest.mark.parametrize("archive", [False, True]) +async def test_binary_and_archive_paths_and_extractions_never_collide(tmp_path, archive): + bodies = [b"first", b"second"] + attachments = [] + for body in bodies: + if archive: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("nested.txt", body) + body = buf.getvalue() + attachments.append(_mock_attachment("same.zip" if archive else "same.bin", + len(body), data=body)) + result = await AttachmentProcessor(temp_dir=str(tmp_path)).process(attachments, "42", "1") + assert len({saved.path for saved in result.saved_files}) == 2 + from pathlib import Path + for saved, attachment in zip(result.saved_files, attachments, strict=True): + assert Path(saved.path).read_bytes() == attachment.read.return_value + if archive: + for saved, expected in zip(result.saved_files, bodies, strict=True): + extracted = Path(saved.path + ".extracted") / "nested.txt" + assert extracted.read_bytes() == expected + + +@pytest.mark.parametrize("pdf", [False, True]) +async def test_truncated_attachment_full_content_retrieval_and_owner_channel_fences( + tmp_path, monkeypatch, pdf, +): + original = b"line\r\n" * 3000 + b"\xfftail" + expected = [original] + if pdf: + original = b"%PDF fixture original bytes\x00\xff" + pages = [f"Page {i} full contents " + "x" * 40 for i in range(3)] + + class Document: + page_count = 3 + + def __iter__(self): + return iter([SimpleNamespace(get_text=lambda text=text: text) for text in pages]) + + def close(self): + pass + + def open_pdf(*, stream, filetype): + assert stream == original and filetype == "pdf" + return Document() + + monkeypatch.setitem(sys.modules, "fitz", SimpleNamespace(open=open_pdf)) + full = "\n".join(f"Page {i+1}: {text}" for i, text in enumerate(pages)) + expected = [original, full.encode()] + ex = executor(tmp_path) + cfg = SimpleNamespace( + temp_directory=str(tmp_path / "attachments"), inline_text_max_bytes=100000, + preview_max_chars=10, large_preview_chars=10, archive_max_bytes=100000, + archive_max_files=10, archive_extract_max_bytes=100000, + archive_preview_total_chars=100, archive_preview_file_max_bytes=100, + image_max_bytes=10000, pdf_max_bytes=100000, retention_hours=24, + ) + sessions = MagicMock() + sessions.get.return_value = None + intake = MessageIntake(SimpleNamespace( + get_config=lambda: SimpleNamespace(attachments=cfg), get_user=lambda: None, + channel_logger=MagicMock(), channel_config=MagicMock(), channel_state=MagicMock(), + sessions=sessions, pipeline=MagicMock(), tool_executor=ex, + )) + message = SimpleNamespace( + id=1, author=SimpleNamespace(id="owner"), channel=SimpleNamespace(id="42"), + attachments=[_mock_attachment("source.pdf" if pdf else "source.txt", + len(original), data=original)], + ) + text, _ = await intake._process_attachments(message, "ingest this file") + manifest_ref = json.loads(text[text.index('{"kind": "tool_attachment_manifest"'):]) + cursor = manifest_ref["retrieval"]["arguments"]["cursor"] + with execution_delivery_scope("owner", "42"): + assert not (await ex.execute("get_tool_output", {"cursor": cursor}, user_id="other")).ok + with execution_delivery_scope("owner", "other-channel"): + assert not (await ex.execute("get_tool_output", {"cursor": cursor}, user_id="owner")).ok + with execution_delivery_scope("owner", "42"): + manifest_result = await ex.execute("get_tool_output", {"cursor": cursor}, user_id="owner") + assert manifest_result.ok + envelope = json.loads(manifest_result.output) + metadata = json.loads(envelope["text"])["attachments"] + assert len(metadata) == len(expected) + for item, body in zip(metadata, expected, strict=True): + current = item["retrieval"]["arguments"]["cursor"] + rebuilt = b"" + while current: + result = await ex.execute("get_tool_output", {"cursor": current, "limit": 1000}, + user_id="owner") + assert result.ok + page = json.loads(result.output) + assert page["start"] == len(rebuilt) + rebuilt += base64.b64decode(page["data_base64"]) + current = page["cursor"] + assert rebuilt == body + assert item["sha256"] == hashlib.sha256(body).hexdigest() + + +async def test_inline_truncation_preserves_original_and_ingestion_intent(tmp_path): + data = b"a" * 100 + processor = AttachmentProcessor(temp_dir=str(tmp_path), inline_max_bytes=1000, + preview_max_chars=10, large_preview_chars=10) + result = await processor.process([_mock_attachment("source.txt", 100, data=data)], + "42", "1", AttachmentIntent.INGEST_KNOWLEDGE) + assert result.saved_files[0].sha256 == hashlib.sha256(data).hexdigest() + assert result.retained_content[0].data == data + assert "User requested knowledge ingestion" in result.inline_text diff --git a/tests/test_campaign_discord_intake.py b/tests/test_campaign_discord_intake.py new file mode 100644 index 000000000..806a416eb --- /dev/null +++ b/tests/test_campaign_discord_intake.py @@ -0,0 +1,49 @@ +"""Buffered authorized intake retains each original message's attachments.""" +import pytest + +from src.discord.intake_pipeline import MessageIntake +from tests.characterization.test_intake_gating import _wait_for_buffer, build +from tests.fakes import FakeAuthor, FakeChannel, FakeMessage +from tests.test_attachments import _mock_attachment + + +@pytest.mark.parametrize("content", ["mixed bot content", ""]) +async def test_buffered_bot_and_webhook_attachment_only_and_mixed(tmp_path, monkeypatch, content): + monkeypatch.chdir(tmp_path) + bot = build(discord={"respond_to_bots": True}, + attachments={"temp_directory": str(tmp_path / "attachments")}) + # Restore the production processor which the characterization helper stubs. + bot.intake._process_attachments = MessageIntake._process_attachments.__get__(bot.intake) + bot.channel_state.bot_msg_buffer_delay = 0.01 + author, channel = FakeAuthor(id=555, name="otherbot", bot=True), FakeChannel(id=99) + first = FakeMessage(content, author=author, channel=channel) + first.attachments = [_mock_attachment("report.txt", 6, data=b"report")] + second = FakeMessage("", author=author, channel=channel) + second.attachments = [_mock_attachment("image.png", 8, content_type="image/png", + data=b"\x89PNG\r\n\x1a\n")] + second.webhook_id = 1234 + await bot.on_message(first) + await bot.on_message(second) + await _wait_for_buffer(lambda: bool(bot.pipeline.run.await_args_list)) + args = bot.pipeline.run.await_args + assert "report" in args.args[1] + if content: + assert content in args.args[1] + assert len(args.kwargs["image_blocks"]) == 1 + assert args.kwargs["from_another_bot"] is True + first.attachments[0].read.assert_awaited_once() + second.attachments[0].read.assert_awaited_once() + + +async def test_required_mention_still_fences_buffered_attachment_download(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + bot = build(discord={"respond_to_bots": True, "require_mention": True}) + bot.intake._process_attachments = MessageIntake._process_attachments.__get__(bot.intake) + bot.channel_state.bot_msg_buffer_delay = 0.01 + msg = FakeMessage("", author=FakeAuthor(id=555, name="otherbot", bot=True)) + attachment = _mock_attachment("report.txt", 6, data=b"report") + msg.attachments = [attachment] + await bot.on_message(msg) + await _wait_for_buffer(lambda: not bot.channel_state.bot_msg_tasks) + attachment.read.assert_not_awaited() + bot.pipeline.run.assert_not_awaited() From 0e4f5666c24aeafa99833de3d0cb5a3ce13e3bad Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:23:24 -0400 Subject: [PATCH 070/162] Pin successful autonomous recovery resetting the consecutive failure budget --- tests/test_campaign_loops.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_campaign_loops.py b/tests/test_campaign_loops.py index a4f3a49ea..ecf82ac55 100644 --- a/tests/test_campaign_loops.py +++ b/tests/test_campaign_loops.py @@ -116,3 +116,26 @@ async def iteration(*args): await task assert manager._loops[loop_id].status == "completed" assert "Investigation completed" in str(channel.sent) + + +async def test_real_success_resets_consecutive_failed_iteration_count(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + bot, fake = build([RuntimeError("failed")] * 4 + [text_response("recovered")] + + [RuntimeError("failed again")] * 4) + manager = bot.loop_manager + + async def wait(*args): + return False + + manager._interruptible_wait = wait + + async def iteration(prompt, channel, prev, cancel_event): + return await bot.tool_loop.run_autonomous(prompt, channel, prev, "4242", + cancel_event=cancel_event) + + loop_id = manager.start_loop("check", FakeChannel(id=777), "4242", "tester", iteration, + max_iterations=9) + await manager._loops[loop_id]._task + assert manager._loops[loop_id].status == "completed" + assert manager._loops[loop_id].iteration_count == 9 + assert len(fake.calls) == 9 From 3f87548613c6261cda02d8358420856042f82fed Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:30:28 -0400 Subject: [PATCH 071/162] Exercise startup diagnostics with the real credential inventory contract --- tests/test_campaign_discord_final_coverage.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_campaign_discord_final_coverage.py b/tests/test_campaign_discord_final_coverage.py index be806efde..0ef5ceaa5 100644 --- a/tests/test_campaign_discord_final_coverage.py +++ b/tests/test_campaign_discord_final_coverage.py @@ -168,6 +168,8 @@ async def test_application_startup_completes_services_despite_nonfatal_component bot._application_shutdown = False bot.loop = __import__("asyncio").get_running_loop() bot.config = SimpleNamespace() + inventory = object() + bot.api_token_manager = SimpleNamespace(credential_inventory=inventory) bot.audit_signer = object() bot.audit = SimpleNamespace(initialize_chain=AsyncMock(side_effect=RuntimeError("bad chain"))) bot.usage_rollup = SimpleNamespace(start=AsyncMock(side_effect=RuntimeError("backfill failed"))) @@ -192,7 +194,12 @@ async def test_application_startup_completes_services_despite_nonfatal_component SimpleNamespace(passed=True, name="state", detail="ready", recommendation=None), ] ) - bot._run_startup_diagnostics = lambda *, yaml_config: report + def diagnostics(*, yaml_config, credential_inventory): + assert yaml_config is bot.config + assert credential_inventory is inventory + return report + + bot._run_startup_diagnostics = diagnostics async def start_mcp_stub(received): assert received is bot From 8a0fe989d09c554b9d5a8d50935761efde3b01df Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 18:57:56 -0400 Subject: [PATCH 072/162] fix(config): preserve startup compatibility and fence migration rewrites --- src/config/migrations.py | 42 +++++++++++-- src/config/model_retirement.py | 38 ++++++++++++ src/config/schema.py | 81 ++++++++++++++++---------- src/sessions/manager.py | 10 ++-- tests/test_config_ceiling_migration.py | 10 ++-- 5 files changed, 135 insertions(+), 46 deletions(-) diff --git a/src/config/migrations.py b/src/config/migrations.py index 75ef84cac..a2d0babc5 100644 --- a/src/config/migrations.py +++ b/src/config/migrations.py @@ -499,8 +499,11 @@ def apply_compatible_timeout_migration( if "request_timeout_seconds" not in current: changes.append((("openai_compatible", "request_timeout_seconds"), 3600)) if "stream_stall_timeout_seconds" not in current: + stall = current["timeout"] + if isinstance(legacy, int) and not 10 <= legacy <= 3600: + stall = min(3600, max(10, legacy)) changes.append(( - ("openai_compatible", "stream_stall_timeout_seconds"), current["timeout"] + ("openai_compatible", "stream_stall_timeout_seconds"), stall )) changes.append((("openai_compatible", "timeout"), DELETE_CONFIG_PATH)) _patch_config_paths(changes, path=target) @@ -661,14 +664,27 @@ def apply_legacy_ceiling_migration(data: dict, config_path: str | Path, original log.info("Upgrading round-1 legacy migration provenance at %s.", legacy_marker) if not _is_shipped_legacy_literal(original_raw): - _write_completion_pair(marker, shared_marker, "not_applicable", - "record vacuous ceiling-migration completion", config_id) + try: + _write_completion_pair(marker, shared_marker, "not_applicable", + "record vacuous ceiling-migration completion", config_id) + except MigrationCompletionError: + log.warning("No ceiling migration needed; completion storage is unavailable") return try: - from .persistence import patch_config_paths + from .persistence import _config_file_lock, _patch_config_paths + from .schema import _substitute_env_vars - patch_config_paths([(_CEILING_PATH, None)], path=Path(config_path).resolve()) + target = Path(config_path).resolve() + with _config_file_lock(target): + current_raw = target.read_text(encoding="utf-8") + if current_raw != original_raw: + current = yaml.safe_load(_substitute_env_vars(current_raw)) + data.clear() + data.update(current) + log.warning("Ceiling migration deferred: config changed since load") + return + _patch_config_paths([(_CEILING_PATH, None)], path=target) except Exception as exc: # noqa: BLE001 — boot retains safe runtime behavior _set_runtime_auto(data) log.warning( @@ -725,7 +741,6 @@ def reconcile(raw): marker = image_defaults_marker_path(target) config_id = _config_identity(target) try: - marker.parent.mkdir(parents=True, exist_ok=True) with _config_file_lock(target): with open(target, encoding="utf-8", newline="") as stream: raw = stream.read() @@ -832,6 +847,14 @@ def digest(text): "version": 1, "migration": "image_model_defaults_v1", "config_id": config_id, "state": "prepared" if edits else "completed", "after_sha256": digest(rewritten), } + if not edits: + try: + _atomic_write_marker(marker, record) + except OSError: + log.warning("No image migration needed; completion storage is unavailable") + if raw != original_raw: + reconcile(raw) + return _atomic_write_marker(marker, record) if edits: _dump_atomic(document, target, mode, raw_text=rewritten) @@ -845,6 +868,13 @@ def digest(text): except MigrationCompletionError: raise except Exception as exc: + if isinstance(exc, OSError) and exc.errno in {13, 30}: + section = data.get("image", {}).get("openai", {}) + for leaf, old in LEGACY_IMAGE_MODEL_DEFAULTS.items(): + if section.get(leaf) == old: + section[leaf] = IMAGE_MODEL_DEFAULTS[leaf] + log.warning("Image defaults migration cannot persist on read-only storage; using runtime defaults") + return raise MigrationCompletionError( "image-default migration could not commit safely; inspect config and record" ) from exc diff --git a/src/config/model_retirement.py b/src/config/model_retirement.py index 8fa9720b1..541949b32 100644 --- a/src/config/model_retirement.py +++ b/src/config/model_retirement.py @@ -51,6 +51,44 @@ def migrate(section: object, key: str, path: str, successor: str) -> None: provider = data["llm_provider"] = deepcopy(provider) migrate(provider, "model", "llm_provider.model", RETIRED_MODEL_SUCCESSOR) image = data.get("image") + agents = data.get("agents") + if isinstance(agents, dict): + agents = data["agents"] = deepcopy(agents) + + def successor_ref(value): + raw = str(value).strip() + bare = raw.removeprefix("codex:") + if bare in RETIRED_MODELS: + replacement = RETIRED_MODEL_SUCCESSOR + log.warning("agents uses retired model %s; using %s on load", raw, replacement) + return replacement + return value + + if agents.get("model"): + agents["model"] = successor_ref(agents["model"]) + entries = agents.get("auto_model_allowlist") + if isinstance(entries, list): + migrated = [] + seen = set() + for entry in entries: + if isinstance(entry, dict): + entry["model"] = successor_ref(entry.get("model", "")) + identity = entry["model"] + else: + entry = successor_ref(entry) + identity = entry + if identity not in seen: + migrated.append(entry) + seen.add(identity) + agents["auto_model_allowlist"] = migrated + hints = agents.get("model_selection_hints") + if isinstance(hints, dict): + # An explicit successor hint wins over historical retired aliases. + for key in list(hints): + replacement = successor_ref(key) + if replacement != key: + hints.setdefault(replacement, hints[key]) + del hints[key] if isinstance(image, dict): image = data["image"] = deepcopy(image) # The image path already has an established successor, distinct from diff --git a/src/config/schema.py b/src/config/schema.py index 0eb442d14..35a503501 100644 --- a/src/config/schema.py +++ b/src/config/schema.py @@ -7,7 +7,7 @@ from typing import Literal, get_args import yaml -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator from ..reasoning import compatible_reasoning_dialect from .model_defaults import ( @@ -42,13 +42,26 @@ class SessionsConfig(BaseModel): adaptive_compaction: bool = True # Session archives are retained indefinitely by default; pruned oldest-first # only past these caps (restore-on-demand depends on archives surviving). - archive_max_bytes: int = 2 * 1024**3 - archive_max_files: int = 10_000 + archive_max_bytes: int | None = 2 * 1024**3 + archive_max_files: int | None = 10_000 # Max estimated tokens of session history sent per LLM request; hot # channels can run larger windows via per-channel overrides. context_token_budget: int = 64_000 context_budget_overrides: dict[str, int] = {} + @field_validator("archive_max_bytes", "archive_max_files") + @classmethod + def _archive_caps(cls, value: int | None, info: ValidationInfo) -> int | None: + # Zero is the existing explicit retain-nothing policy. Negative means + # unset only on legacy startup, never on a new save. + if value is not None and value < 0: + if info.context and info.context.get("startup"): + from ..odin_log import get_logger + get_logger("config").warning("sessions.%s is negative; treating as unset", info.field_name) + return None + raise ValueError(f"{info.field_name} must be nonnegative") + return value + class ToolHost(BaseModel): """One managed execution target. @@ -412,6 +425,18 @@ class ToolsConfig(BaseModel): allow_host_tofu: bool = False command_timeout_seconds: int = 300 tool_timeouts: dict[str, int] = Field(default_factory=dict) + + @field_validator("tool_timeouts") + @classmethod + def _positive_tool_timeouts(cls, values: dict[str, int], info: ValidationInfo) -> dict[str, int]: + invalid = [key for key, value in values.items() if value <= 0] + if invalid: + if info.context and info.context.get("startup"): + from ..odin_log import get_logger + get_logger("config").warning("Ignoring nonpositive tool timeouts for %s; using tool defaults", ", ".join(invalid)) + return {key: value for key, value in values.items() if key not in invalid} + raise ValueError("tool_timeouts values must be positive integers") + return values skill_allowed_urls: list[str] = Field(default_factory=list) # Operator-disabled built-in tools (config-gated visibility): a disabled # tool is absent from the model catalog on every surface and rejected at @@ -894,30 +919,6 @@ def _validate_context_budget_overrides(cls, v: dict[str, int]) -> dict[str, int] canonical[key] = value return canonical - @model_validator(mode="after") - def _validate_effort_model_pairs(self): - # Load boundary (1 of 4): a persisted incompatible model/effort pair - # fails loudly at startup, exactly like any other invalid config - # value — never boot into deterministic per-request 400s. No clamp. - err = effort_incompatibility_error(self.model, self.reasoning_effort) - if err: - raise ValueError(f"openai_codex: {err}") - # The agent axes resolve to a concrete pair here only when neither - # axis is "auto" (per-spawn selection defers to the spawn-time and - # request-construction boundaries). None inherits the main setting. - if AGENT_SETTING_AUTO not in (self.agent_model, self.agent_reasoning_effort): - eff_model = self.agent_model if self.agent_model else self.model - eff_effort = ( - self.agent_reasoning_effort - if self.agent_reasoning_effort - else self.reasoning_effort - ) - err = effort_incompatibility_error(eff_model, eff_effort) - if err: - raise ValueError(f"openai_codex agent settings: {err}") - return self - - class OllamaConfig(BaseModel): model_config = ConfigDict(protected_namespaces=()) @@ -1190,6 +1191,12 @@ def _legacy_stream_timeouts(cls, value): value = dict(value) legacy = value.pop("timeout") # Explicit new fields win independently; invalid new values still fail. + # Old Kimi/compatible files accepted every integer timeout. Preserve + # their startup compatibility without relaxing explicit new fields. + if isinstance(legacy, int) and not 10 <= legacy <= 3600: + from ..odin_log import get_logger + get_logger("config").warning("Legacy compatible timeout %s is outside new bounds; using bounded timeout", legacy) + legacy = min(3600, max(10, legacy)) value.setdefault("stream_stall_timeout_seconds", legacy) value.setdefault("request_timeout_seconds", 3600) return value @@ -1783,7 +1790,7 @@ def _adapt_legacy_agent_model(cls, data): return data # Old files selected a provider separately. Preserve that selection on # first model-first load by materializing its configured model ref. - provider_cfg = data.get("llm_provider") + provider_cfg = data.get("llm_provider", {}) if isinstance(provider_cfg, dict) and "model" not in provider_cfg: active = provider_cfg.get("active_provider", "codex") data = dict(data) @@ -1825,7 +1832,7 @@ def _adapt_legacy_agent_model(cls, data): return data @model_validator(mode="after") - def _derive_active_provider_from_main_model(self): + def _derive_active_provider_from_main_model(self, info: ValidationInfo): """Keep legacy provider consumers truthful without a second selector.""" from ..llm.model_ref import parse_model_ref @@ -1833,6 +1840,20 @@ def _derive_active_provider_from_main_model(self): if ref.provider.value not in ("codex", "ollama", "compat", "kimi"): raise ValueError("main model must select a concrete serving provider") self.llm_provider.active_provider = ref.provider.value # type: ignore[assignment] + from ..tools.agent_tool_policy import configured_agent_model + + pairs = [(self.llm_provider.model, self.openai_codex.reasoning_effort)] + if self.agents.model != AGENT_SETTING_AUTO and self.openai_codex.agent_reasoning_effort != AGENT_SETTING_AUTO: + pairs.append((configured_agent_model(self), self.openai_codex.agent_reasoning_effort or self.openai_codex.reasoning_effort)) + for model, effort in pairs: + if model and not model.startswith(("compat:", "ollama:", "kimi:")): + error = effort_incompatibility_error(model.removeprefix("codex:"), effort) + if error: + if info.context and info.context.get("startup"): + from ..odin_log import get_logger + get_logger("config").warning("Effective model/effort pair: %s", error) + else: + raise ValueError(error) from ..tools.agent_tool_policy import ( validate_agent_entry_defaults, validate_agent_model_hints, @@ -1986,7 +2007,7 @@ def load_config(path: str | Path = "config.yml") -> Config: migrate_retired_codex_selections(data) try: - cfg = Config(**data) + cfg = Config.model_validate(data, context={"startup": True}) except Exception as exc: raise SystemExit( f"Config validation failed: {exc}\n" diff --git a/src/sessions/manager.py b/src/sessions/manager.py index 24a8ad7e9..02b907c16 100644 --- a/src/sessions/manager.py +++ b/src/sessions/manager.py @@ -419,8 +419,8 @@ def __init__( embedder: LocalEmbedder | None = None, token_budget: int = DEFAULT_SESSION_TOKEN_BUDGET, adaptive_compaction: bool = True, - archive_max_bytes: int = 2 * 1024**3, - archive_max_files: int = 10_000, + archive_max_bytes: int | None = 2 * 1024**3, + archive_max_files: int | None = 10_000, context_token_budget: int = CONTEXT_TOKEN_BUDGET, context_budget_overrides: dict[str, int] | None = None, ) -> None: @@ -1395,8 +1395,8 @@ def _prune_old_archives(self, archive_dir: Path) -> None: pruned = 0 def over_cap() -> bool: - return (total_bytes > self.archive_max_bytes - or len(files) > self.archive_max_files) + return ((self.archive_max_bytes is not None and total_bytes > self.archive_max_bytes) + or (self.archive_max_files is not None and len(files) > self.archive_max_files)) for f in evict_order: if not over_cap(): @@ -1407,7 +1407,7 @@ def over_cap() -> bool: pruned += 1 if pruned: log.info( - "Pruned %d archive(s) from %s (caps: %d bytes / %d files; " + "Pruned %d archive(s) from %s (caps: %s bytes / %s files; " "newest-per-channel protected)", pruned, archive_dir, self.archive_max_bytes, self.archive_max_files, ) diff --git a/tests/test_config_ceiling_migration.py b/tests/test_config_ceiling_migration.py index 6ae829a2d..d6a05a829 100644 --- a/tests/test_config_ceiling_migration.py +++ b/tests/test_config_ceiling_migration.py @@ -201,12 +201,12 @@ def test_rewrite_failure_is_this_boot_only_and_retries(self, tmp_path, caplog, m config_path.write_text(_LEGACY_YAML) import src.config.persistence as persistence - real_patch = persistence.patch_config_paths + real_patch = persistence._patch_config_paths def fail_patch(*args, **kwargs): raise OSError("config rewrite blocked") - monkeypatch.setattr(persistence, "patch_config_paths", fail_patch) + monkeypatch.setattr(persistence, "_patch_config_paths", fail_patch) with caplog.at_level(logging.WARNING, logger="odin.config"): data = _migrate(config_path) # In-memory auto so behavior is consistent this boot… @@ -216,7 +216,7 @@ def fail_patch(*args, **kwargs): assert not ceiling_marker_path(config_path).exists() assert f"max_context_chars: {LEGACY_MAX_CONTEXT_CHARS}" in config_path.read_text() - monkeypatch.setattr(persistence, "patch_config_paths", real_patch) + monkeypatch.setattr(persistence, "_patch_config_paths", real_patch) data2 = _migrate(config_path) assert data2["openai_codex"]["context_compression"]["max_context_chars"] is None assert ( @@ -636,8 +636,8 @@ def fail_write(*args, **kwargs): raise OSError("marker blocked") monkeypatch.setattr(migrations, "_atomic_write_marker", fail_write) - with pytest.raises(MigrationCompletionError, match="configuration was left unchanged"): - _migrate(path) + _migrate(path) + assert path.read_text() == "discord:\n token: t\n" def test_runtime_auto_tolerates_missing_or_non_mapping_sections(self): import src.config.migrations as migrations From 1d880628c7e56bb3e0f20021b0a65bfc88e8df24 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:26:08 -0400 Subject: [PATCH 073/162] fix(config): serialize partial settings saves and honor effective selectors --- src/config/apply_registry.py | 38 +- src/config/migrations.py | 17 +- src/config/persistence.py | 66 +++- src/config/schema.py | 49 ++- src/config/webhook_text.py | 23 +- src/sessions/manager.py | 7 +- src/web/api/agents_loops.py | 52 +-- src/web/api/config_admin.py | 18 +- src/web/api/hosts.py | 31 +- src/web/api/llm_admin.py | 99 +++-- src/web/onboarding.py | 4 +- tests/test_config_campaign_v410.py | 580 +++++++++++++++++++++++++++++ tests/test_config_persistence.py | 6 +- tests/test_max_reasoning_effort.py | 40 +- tests/test_web_api_llm_admin.py | 9 +- 15 files changed, 888 insertions(+), 151 deletions(-) create mode 100644 tests/test_config_campaign_v410.py diff --git a/src/config/apply_registry.py b/src/config/apply_registry.py index 45f1e0a1c..6d39ec440 100644 --- a/src/config/apply_registry.py +++ b/src/config/apply_registry.py @@ -160,8 +160,8 @@ class SectionSpec: "the effective respond-to-bots policy still applies.", ), "llm_provider": SectionSpec( - "live_apply", - "Active language-model provider and failover ownership.", + "live_read", + "Canonical per-generation serving-model selection and failover ownership.", owner="llm", apply_handler="POST /api/llm/switch", ), @@ -394,6 +394,16 @@ class SectionSpec: ) FIELDS: dict[str, FieldSpec] = { + "llm_provider.model": FieldSpec( + apply_mode="live_read", + description="Canonical serving model, resolved at each new generation.", + ), + "web.api_token": FieldSpec( + apply_mode="live_read", description="Live management credential policy." + ), + "web.api_tokens": FieldSpec( + apply_mode="live_read", description="Live scoped management credential policy." + ), "mcp.max_published_tools_per_server": FieldSpec( label="Published tools per server", apply_mode="live_read", @@ -865,18 +875,14 @@ class SectionSpec: "openai_codex.model": FieldSpec( apply_mode="live_apply", apply_handler="PUT /api/llm/codex/config", - description="Primary Codex model.", + description="Codex transport default and legacy startup selector. Canonical " + "llm_provider.model overrides this for main generations and inherited agents.", consumers=( Consumer( - "Chat and autonomous loops", + "Codex transport default", "live_apply", - "Requests use the live client's model, which only a Codex reload refreshes.", - ), - Consumer( - "Spawned agents inheriting the main model", - "live_read", - "Agent generations resolve the model from config at call time, " - "so agents adopt it before chat does.", + "Reload refreshes the client's default; explicit canonical request " + "selection still takes precedence.", ), ), ), @@ -905,18 +911,18 @@ class SectionSpec: "iteration reads it at call time.", ), "openai_codex.agent_model": FieldSpec( - apply_mode="live_read", - description="Model policy for spawned-agent generations; the next " - "iteration reads it at call time.", + apply_mode="dormant", + description="Legacy selector, adapted on startup only when agents.model is absent. " + "The canonical agents.model selector takes precedence.", ), "openai_compatible.reasoning_effort": FieldSpec( - apply_mode="live_apply", + apply_mode="live_read", apply_handler="PUT /api/openai-compatible/config", description="Neutral primary-chat reasoning level for the compatible provider.", consumers=( Consumer( "Compatible chat and autonomous loops", - "live_apply", + "live_read", "Each new generation resolves this neutral level through the configured " "endpoint dialect and selected model profile.", ), diff --git a/src/config/migrations.py b/src/config/migrations.py index a2d0babc5..4555df883 100644 --- a/src/config/migrations.py +++ b/src/config/migrations.py @@ -740,6 +740,7 @@ def reconcile(raw): target = Path(config_path).resolve() marker = image_defaults_marker_path(target) config_id = _config_identity(target) + updates: dict[str, str] = {} try: with _config_file_lock(target): with open(target, encoding="utf-8", newline="") as stream: @@ -847,6 +848,12 @@ def digest(text): "version": 1, "migration": "image_model_defaults_v1", "config_id": config_id, "state": "prepared" if edits else "completed", "after_sha256": digest(rewritten), } + if edits and not os.access(target.parent, os.W_OK): + reconcile(rewritten) + log.warning( + "Image defaults migration uses runtime defaults on read-only config storage" + ) + return if not edits: try: _atomic_write_marker(marker, record) @@ -870,10 +877,12 @@ def digest(text): except Exception as exc: if isinstance(exc, OSError) and exc.errno in {13, 30}: section = data.get("image", {}).get("openai", {}) - for leaf, old in LEGACY_IMAGE_MODEL_DEFAULTS.items(): - if section.get(leaf) == old: - section[leaf] = IMAGE_MODEL_DEFAULTS[leaf] - log.warning("Image defaults migration cannot persist on read-only storage; using runtime defaults") + for leaf, value in updates.items(): + section[leaf] = value + log.warning( + "Image defaults migration cannot persist on read-only storage; " + "using runtime defaults" + ) return raise MigrationCompletionError( "image-default migration could not commit safely; inspect config and record" diff --git a/src/config/persistence.py b/src/config/persistence.py index 717ba965d..8db55b092 100644 --- a/src/config/persistence.py +++ b/src/config/persistence.py @@ -154,6 +154,9 @@ def walk(new: Mapping[str, Any], known: Any, model: Any, prefix: tuple[str, ...] field = lookup.get(str(key)) canonical, nested, aliases = field or (str(key), None, ()) path = (*prefix, canonical) + if value == {"$delete": True} and model is None: + out.append((path, DELETE_CONFIG_PATH)) + continue if not isinstance(known, Mapping) or canonical not in known: # Validation dropped this path (unknown/removed field) — the # runtime ignores it, so disk must not carry it either. @@ -167,23 +170,70 @@ def walk(new: Mapping[str, Any], known: Any, model: Any, prefix: tuple[str, ...] # A nested BaseModel has schema-owned child fields, so preserve # the ordinary leaf-only walk through those children. walk(value, known_value, nested, path) - elif isinstance(value, Mapping) and isinstance(known_value, Mapping) and field is None: - # Schema-less callers retain the historical recursive helper - # behavior. Config persistence always supplies a model class. - walk(value, known_value, None, path) + elif isinstance(value, Mapping) and isinstance(known_value, Mapping): + # Dynamic mappings are partial patches too. Normalize submitted + # keys using the field validator, never copy resolved siblings. + canonical_value = dict(value) + if field is not None: + deletions = { + key: item for key, item in value.items() if item == {"$delete": True} + } + entries = {key: item for key, item in value.items() if key not in deletions} + # Partial structured entries (model profiles, presets) have + # required siblings. Validate their submitted KEY against + # the already validated merged entry, not an incomplete row. + normalized_entries = { + key: known_value.get(key, item) for key, item in entries.items() + } + canonical_value = model.model_validate( + {canonical: normalized_entries} + ).model_dump()[canonical] + for key, item in entries.items(): + if isinstance(item, Mapping) and key in canonical_value: + def submitted_shape(shape, normalized): + if not isinstance(shape, Mapping): + return normalized + return { + name: submitted_shape(child, normalized[name]) + for name, child in shape.items() if name in normalized + } + canonical_value[key] = submitted_shape(item, canonical_value[key]) + canonical_value.update(deletions) + walk(canonical_value, known_value, None, path) elif aliases: out.append((path, known_value, aliases)) else: - # A schema-owned Mapping (for example context-budget overrides) - # is ONE normalized leaf. Its validator may canonicalize keys, - # so walking raw submitted keys against the validated mapping - # would drop aliases that changed spelling during validation. out.append((path, known_value)) walk(updates, validated, model_cls, ()) return out +def remove_submitted_mapping_entries( + current: dict, updates: Mapping[str, Any], model_cls: Any +) -> None: + """Apply explicit dynamic-entry tombstones before ordinary deep merge. + + `{key: {"$delete": true}}` deletes a mapping entry, never a schema field + or an ordinary JSON null. The submitted document remains available to the + leaf writer; validation never sees control objects. + """ + lookup = _field_lookup(model_cls) + for key, value in updates.items(): + if not isinstance(value, Mapping) or key not in lookup: + continue + canonical, nested, _aliases = lookup[key] + existing = current.get(canonical) + if not isinstance(existing, dict): + continue + if nested is not None: + remove_submitted_mapping_entries(existing, value, nested) + else: + for entry, item in value.items(): + if item == {"$delete": True}: + existing.pop(entry, None) + + def _placeholder_still_accurate(existing: Any, new_value: Any) -> bool: """True when *existing* is a ``${VAR}`` scalar that already resolves to *new_value* — i.e. writing the literal would leak a resolved secret while diff --git a/src/config/schema.py b/src/config/schema.py index 35a503501..0027afcbc 100644 --- a/src/config/schema.py +++ b/src/config/schema.py @@ -7,7 +7,16 @@ from typing import Literal, get_args import yaml -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + TypeAdapter, + ValidationInfo, + field_validator, + model_validator, +) from ..reasoning import compatible_reasoning_dialect from .model_defaults import ( @@ -57,7 +66,9 @@ def _archive_caps(cls, value: int | None, info: ValidationInfo) -> int | None: if value is not None and value < 0: if info.context and info.context.get("startup"): from ..odin_log import get_logger - get_logger("config").warning("sessions.%s is negative; treating as unset", info.field_name) + get_logger("config").warning( + "sessions.%s is negative; treating as unset", info.field_name + ) return None raise ValueError(f"{info.field_name} must be nonnegative") return value @@ -428,12 +439,17 @@ class ToolsConfig(BaseModel): @field_validator("tool_timeouts") @classmethod - def _positive_tool_timeouts(cls, values: dict[str, int], info: ValidationInfo) -> dict[str, int]: + def _positive_tool_timeouts( + cls, values: dict[str, int], info: ValidationInfo + ) -> dict[str, int]: invalid = [key for key, value in values.items() if value <= 0] if invalid: if info.context and info.context.get("startup"): from ..odin_log import get_logger - get_logger("config").warning("Ignoring nonpositive tool timeouts for %s; using tool defaults", ", ".join(invalid)) + get_logger("config").warning( + "Ignoring nonpositive tool timeouts for %s; using tool defaults", + ", ".join(invalid), + ) return {key: value for key, value in values.items() if key not in invalid} raise ValueError("tool_timeouts values must be positive integers") return values @@ -1193,9 +1209,12 @@ def _legacy_stream_timeouts(cls, value): # Explicit new fields win independently; invalid new values still fail. # Old Kimi/compatible files accepted every integer timeout. Preserve # their startup compatibility without relaxing explicit new fields. - if isinstance(legacy, int) and not 10 <= legacy <= 3600: + legacy = TypeAdapter(int).validate_python(legacy) + if not 10 <= legacy <= 3600: from ..odin_log import get_logger - get_logger("config").warning("Legacy compatible timeout %s is outside new bounds; using bounded timeout", legacy) + get_logger("config").warning( + "Legacy compatible timeout %s is outside new bounds; using bounded timeout", legacy + ) legacy = min(3600, max(10, legacy)) value.setdefault("stream_stall_timeout_seconds", legacy) value.setdefault("request_timeout_seconds", 3600) @@ -1842,10 +1861,18 @@ def _derive_active_provider_from_main_model(self, info: ValidationInfo): self.llm_provider.active_provider = ref.provider.value # type: ignore[assignment] from ..tools.agent_tool_policy import configured_agent_model - pairs = [(self.llm_provider.model, self.openai_codex.reasoning_effort)] - if self.agents.model != AGENT_SETTING_AUTO and self.openai_codex.agent_reasoning_effort != AGENT_SETTING_AUTO: - pairs.append((configured_agent_model(self), self.openai_codex.agent_reasoning_effort or self.openai_codex.reasoning_effort)) - for model, effort in pairs: + pairs: list[tuple[str, str | None, str | None]] = [ + ("main", self.llm_provider.model, self.openai_codex.reasoning_effort) + ] + if ( + self.agents.model != AGENT_SETTING_AUTO + and self.openai_codex.agent_reasoning_effort != AGENT_SETTING_AUTO + ): + pairs.append(( + "agent", configured_agent_model(self), + self.openai_codex.agent_reasoning_effort or self.openai_codex.reasoning_effort, + )) + for axis, model, effort in pairs: if model and not model.startswith(("compat:", "ollama:", "kimi:")): error = effort_incompatibility_error(model.removeprefix("codex:"), effort) if error: @@ -1853,7 +1880,7 @@ def _derive_active_provider_from_main_model(self, info: ValidationInfo): from ..odin_log import get_logger get_logger("config").warning("Effective model/effort pair: %s", error) else: - raise ValueError(error) + raise ValueError(f"{axis} settings: {error}") from ..tools.agent_tool_policy import ( validate_agent_entry_defaults, validate_agent_model_hints, diff --git a/src/config/webhook_text.py b/src/config/webhook_text.py index d51df842f..9019edd15 100644 --- a/src/config/webhook_text.py +++ b/src/config/webhook_text.py @@ -113,7 +113,7 @@ def _append_entry(self, mapping: Any, key: str, value: Any) -> None: if mapping.flow_style: position = mapping.end_mark.index - 1 fragment = _render({key: value}, flow=True)[1:-1] - self._splice(position, position, (", " if mapping.value else "") + fragment) + self._splice(position, position, self._flow_separator(mapping) + fragment) else: self._insert_lines( self._line_end(_content_end(mapping)), @@ -148,7 +148,9 @@ def append(self, row: dict[str, Any]) -> None: return if sequence.flow_style: position = sequence.end_mark.index - 1 - self._splice(position, position, ", " + _render(row, flow=True)) + self._splice( + position, position, self._flow_separator(sequence) + _render(row, flow=True) + ) else: # Our emitter offsets the dash by two spaces; match the existing # dash, not the mapping's first key, for indentless sequences too. @@ -158,7 +160,24 @@ def append(self, row: dict[str, Any]) -> None: self._indented([row], indent), ) + def _flow_separator(self, node: Any) -> str: + if not node.value: + return "" + last = node.value[-1][1] if isinstance(node, MappingNode) else node.value[-1] + # Tokens distinguish a real separator from commas inside comments. + trailing = any( + isinstance(token, FlowEntryToken) + and _content_end(last) <= token.start_mark.index < node.end_mark.index - 1 + for token in _yaml().scan(self.text) + ) + return " " if trailing else ", " + def delete(self, index: int) -> None: + _, _, _, sequence = self._nodes() + if sequence.flow_style and len(sequence.value) == 1: + row = sequence.value[index] + self._splice(row.start_mark.index, sequence.end_mark.index - 1, "") + return _, _, key, sequence = self._nodes() row = sequence.value[index] if sequence.flow_style: diff --git a/src/sessions/manager.py b/src/sessions/manager.py index 02b907c16..21096f132 100644 --- a/src/sessions/manager.py +++ b/src/sessions/manager.py @@ -1395,8 +1395,11 @@ def _prune_old_archives(self, archive_dir: Path) -> None: pruned = 0 def over_cap() -> bool: - return ((self.archive_max_bytes is not None and total_bytes > self.archive_max_bytes) - or (self.archive_max_files is not None and len(files) > self.archive_max_files)) + return ( + self.archive_max_bytes is not None and total_bytes > self.archive_max_bytes + ) or ( + self.archive_max_files is not None and len(files) > self.archive_max_files + ) for f in evict_order: if not over_cap(): diff --git a/src/web/api/agents_loops.py b/src/web/api/agents_loops.py index 20252cb9b..108e01223 100644 --- a/src/web/api/agents_loops.py +++ b/src/web/api/agents_loops.py @@ -299,48 +299,22 @@ async def get_agents_model(_request: web.Request) -> web.Response: async def put_agents_model(request: web.Request) -> web.Response: try: body = await request.json() - values = bot.config.agents.model_dump() - values.update( - { - key: body[key] - for key in ( - "model", - "thinking_mode", - "auto_model_allowlist", - "model_selection_hints", - ) - if key in body - } - ) - candidate = type(bot.config.agents).model_validate(values) - from ...tools.agent_tool_policy import ( - validate_agent_entry_defaults, - validate_agent_model_hints, - ) - - defaults_error = validate_agent_entry_defaults( - bot.config, candidate.auto_model_allowlist - ) - if defaults_error: - raise ValueError(defaults_error) - hints_error = validate_agent_model_hints(bot.config, candidate) - if hints_error: - raise ValueError(hints_error) except (ValueError, ValidationError) as exc: return web.json_response({"error": str(exc)}, status=400) - changes = [] - if "model" in body: - changes.append((("agents", "model"), candidate.model)) - if "thinking_mode" in body: - changes.append((("agents", "thinking_mode"), candidate.thinking_mode)) - if "auto_model_allowlist" in body: - changes.append(( - ("agents", "auto_model_allowlist"), - candidate.model_dump(mode="json")["auto_model_allowlist"], - )) - if "model_selection_hints" in body: - changes.append((("agents", "model_selection_hints"), candidate.model_selection_hints)) async with config_transaction(): + try: + values = bot.config.model_dump() + submitted = { + key: body[key] for key in ( + "model", "thinking_mode", "auto_model_allowlist", "model_selection_hints" + ) if key in body + } + values["agents"].update(submitted) + candidate = type(bot.config).model_validate(values).agents + except (ValueError, ValidationError) as exc: + return web.json_response({"error": str(exc)}, status=400) + dumped = candidate.model_dump(mode="json") + changes = [(("agents", key), dumped[key]) for key in submitted] error, cancelled = await persist_config_paths_locked(changes) if error: if cancelled: diff --git a/src/web/api/config_admin.py b/src/web/api/config_admin.py index 086337637..1bb254cb7 100644 --- a/src/web/api/config_admin.py +++ b/src/web/api/config_admin.py @@ -821,7 +821,23 @@ async def update_config(request: web.Request) -> web.Response: # Deep merge updates into current config current = bot.config.model_dump() - _deep_merge(current, updates) + from copy import deepcopy + + from ...config.persistence import remove_submitted_mapping_entries + + remove_submitted_mapping_entries(current, updates, Config) + merge_updates = deepcopy(updates) + + def strip_tombstones(node): + for key in list(node): + value = node[key] + if value == {"$delete": True}: + del node[key] + elif isinstance(value, dict): + strip_tombstones(value) + + strip_tombstones(merge_updates) + _deep_merge(current, merge_updates) # Validate by reconstructing the config model try: diff --git a/src/web/api/hosts.py b/src/web/api/hosts.py index 9092d22d8..c9369293a 100644 --- a/src/web/api/hosts.py +++ b/src/web/api/hosts.py @@ -267,13 +267,24 @@ async def set_host_settings(request: web.Request) -> web.Response: async def operation(): async with management_lock: async with config_transaction(): + # Omitted fields are read at publication, not when this + # request queued behind another acknowledged save. + effective_default = ( + body["default_host"].strip() if "default_host" in body + else bot.config.tools.default_host + ) + effective_tofu = body.get("allow_host_tofu", bot.config.tools.allow_host_tofu) + if effective_default and effective_default not in bot.config.tools.hosts: + return web.json_response( + {"error": "default_host must name a configured host"}, status=400 + ) changes: list = [] - if default_host != bot.config.tools.default_host: - changes.append((("tools", "default_host"), default_host)) - if allow_tofu != bot.config.tools.allow_host_tofu: - changes.append((("tools", "allow_host_tofu"), allow_tofu)) + if effective_default != bot.config.tools.default_host: + changes.append((("tools", "default_host"), effective_default)) + if effective_tofu != bot.config.tools.allow_host_tofu: + changes.append((("tools", "allow_host_tofu"), effective_tofu)) staged = bot.host_registry.stage( - bot.config.tools.hosts, default_host=default_host + bot.config.tools.hosts, default_host=effective_default ) commit_started.set() persist_exc, cancelled = await config_persistence.persist_config_paths_locked( @@ -287,21 +298,21 @@ async def operation(): ) old_default = bot.config.tools.default_host old_tofu = bot.config.tools.allow_host_tofu - bot.config.tools.default_host = default_host - bot.config.tools.allow_host_tofu = allow_tofu + bot.config.tools.default_host = effective_default + bot.config.tools.allow_host_tofu = effective_tofu bot.host_registry.publish_staged(staged) diff_summary = [".".join(change[0]) for change in changes] request["_config_diff"] = "\n".join(diff_summary) await audit( request, "settings", - default_host, + effective_default, { "result": "saved", "old_default_host": old_default, - "new_default_host": default_host, + "new_default_host": effective_default, "old_allow_host_tofu": old_tofu, - "new_allow_host_tofu": allow_tofu, + "new_allow_host_tofu": effective_tofu, "registry_generation": bot.host_registry.generation, "config_diff": diff_summary, }, diff --git a/src/web/api/llm_admin.py b/src/web/api/llm_admin.py index 79eea05bf..e2be5fa1c 100644 --- a/src/web/api/llm_admin.py +++ b/src/web/api/llm_admin.py @@ -28,7 +28,6 @@ AGENT_SETTING_AUTO, CODEX_REASONING_EFFORTS, allowed_efforts_for_model, - effort_incompatibility_error, retired_codex_model_error, ) from ...llm.window_observer import WindowObserverMutationError @@ -650,6 +649,12 @@ async def llm_switch(request: web.Request) -> web.Response: # its own lock (settled before the lock releases) and restores the # prior provider on persist failure — no interleaving window. async with config_transaction(): + try: + values = bot.config.model_dump() + values["llm_provider"]["model"] = model_ref + type(bot.config).model_validate(values) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) result = await bot.llm_gateway.switch_provider( provider, persist=lambda: patch_config_paths( @@ -683,6 +688,12 @@ async def llm_main_model(request: web.Request) -> web.Response: except (ValueError, TypeError, AttributeError) as exc: return web.json_response({"error": str(exc)}, status=400) async with config_transaction(): + try: + values = bot.config.model_dump() + values["llm_provider"]["model"] = model_ref + type(bot.config).model_validate(values) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) result = await bot.llm_gateway.switch_provider( provider, persist=lambda: patch_config_paths( @@ -901,6 +912,8 @@ def register_provider_config(routes: web.RouteTableDef, bot) -> None: async def llm_codex_config(request: web.Request) -> web.Response: try: body = await request.json() + if "enabled" in body: + body["enabled"] = TypeAdapter(bool).validate_python(body["enabled"]) except Exception: return web.json_response({"error": "invalid JSON body"}, status=400) @@ -960,16 +973,9 @@ async def llm_codex_config(request: web.Request) -> web.Response: desired_model = ( str(body["model"]) if ("model" in body and body["model"]) else cfg.model ) - desired_effort = str(effort) if effort is not None else cfg.reasoning_effort - pair_err = effort_incompatibility_error(desired_model, desired_effort) - if pair_err: - return web.json_response( - { - "error": pair_err, - "allowed": sorted(allowed_efforts_for_model(desired_model)), - }, - status=400, - ) + retired = retired_codex_model_error(desired_model) + if retired: + return web.json_response({"error": retired}, status=400) desired_agent_model = agent_model if agent_model_present else cfg.agent_model retired = retired_codex_model_error(desired_agent_model) if retired: @@ -979,21 +985,6 @@ async def llm_codex_config(request: web.Request) -> web.Response: if agent_effort_present else cfg.agent_reasoning_effort ) - # "auto" on either agent axis defers to the spawn-time and - # request-construction boundaries; concrete axes resolve here - # (None inherits the main setting being saved). - if AGENT_SETTING_AUTO not in (desired_agent_model, desired_agent_effort): - eff_model = desired_agent_model if desired_agent_model else desired_model - eff_effort = desired_agent_effort if desired_agent_effort else desired_effort - pair_err = effort_incompatibility_error(eff_model, eff_effort) - if pair_err: - return web.json_response( - { - "error": f"agent settings: {pair_err}", - "allowed": sorted(allowed_efforts_for_model(eff_model)), - }, - status=400, - ) desired = { "enabled": bool(body["enabled"]) if "enabled" in body else cfg.enabled, "model": str(body["model"]) if body.get("model") else cfg.model, @@ -1007,6 +998,22 @@ async def llm_codex_config(request: web.Request) -> web.Response: ), "agent_model": agent_model if agent_model_present else cfg.agent_model, } + effective_values = bot.config.model_dump() + effective_values["openai_codex"].update(desired) + try: + type(bot.config).model_validate(effective_values) + except ValueError as exc: + from ...tools.agent_tool_policy import configured_agent_model + main = bot.config.llm_provider.model + resolved = configured_agent_model(bot.config) + pair_model = resolved if desired_agent_effort not in (None, "auto") else main + return web.json_response( + { + "error": str(exc), + "allowed": sorted(allowed_efforts_for_model(pair_model)), + }, + status=400, + ) advanced = _parse_codex_advanced(body, cfg) if isinstance(advanced, web.Response): return advanced @@ -1104,6 +1111,8 @@ async def llm_codex_config(request: web.Request) -> web.Response: async def llm_auxiliary_config(request: web.Request) -> web.Response: try: body = await request.json() + if "enabled" in body: + body["enabled"] = TypeAdapter(bool).validate_python(body["enabled"]) except Exception: return web.json_response({"error": "invalid JSON body"}, status=400) @@ -1154,6 +1163,8 @@ async def llm_auxiliary_config(request: web.Request) -> web.Response: async def llm_ollama_config(request: web.Request) -> web.Response: try: body = await request.json() + if "enabled" in body: + body["enabled"] = TypeAdapter(bool).validate_python(body["enabled"]) except Exception: return web.json_response({"error": "invalid JSON body"}, status=400) @@ -1245,6 +1256,8 @@ async def llm_ollama_config(request: web.Request) -> web.Response: async def openai_compatible_config(request: web.Request) -> web.Response: try: body = await request.json() + if "enabled" in body: + body["enabled"] = TypeAdapter(bool).validate_python(body["enabled"]) except Exception: return web.json_response({"error": "invalid JSON body"}, status=400) @@ -1951,6 +1964,40 @@ async def openrouter_select_model(request: web.Request) -> web.Response: except (ValueError, ValidationError) as exc: return web.json_response({"error": str(exc)}, status=400) async with config_transaction(): + # Remote catalogue awaits above may overlap a config rebind or a + # policy save. Rebuild against the current owner before publication. + cfg = _openrouter_config() + if cfg is None: + return web.json_response({"error": "OpenRouter configuration changed"}, status=409) + routing_values = cfg.openrouter.model_dump() + pins = dict(cfg.openrouter.model_pins) + if pin: + pins[model_id] = pin + else: + pins.pop(model_id, None) + routing_values["model_pins"] = pins + route_policy = type(cfg.openrouter).model_validate(routing_values) + profile = conservative_profile( + rows, route_policy, model=model_id, require_reasoning=True + ) + if profile is None: + return web.json_response( + {"error": "no tool-capable endpoint can provide a safe model profile"}, + status=400, + ) + profile_value = OpenAICompatibleModelProfile( + total_window_tokens=profile["total_window_tokens"], + max_output_tokens=profile["max_output_tokens"], + supports_thinking_mode=False, + supports_reasoning=bool(catalogue_model.get("supports_reasoning")), + supported_efforts=catalogue_model.get("supported_efforts") or [], + ) + derived = dict(cfg.openrouter.catalogue_profiles) + derived[model_id] = profile_value + routing_values["catalogue_profiles"] = { + name: value.model_dump() for name, value in derived.items() + } + candidate = type(cfg.openrouter).model_validate(routing_values) error, cancelled = await persist_config_paths_locked( [(("openai_compatible", "openrouter"), candidate.model_dump())] ) diff --git a/src/web/onboarding.py b/src/web/onboarding.py index 6ee74621e..1ca0758bc 100644 --- a/src/web/onboarding.py +++ b/src/web/onboarding.py @@ -198,8 +198,8 @@ async def submit( before: Any = before_config after: Any = after_config for segment in path: - before = before[segment] - after = after[segment] + before = before.get(segment) if isinstance(before, dict) else None + after = after.get(segment) if isinstance(after, dict) else None name = ".".join(path) if before != after and name not in restart_required: restart_required.append(name) diff --git a/tests/test_config_campaign_v410.py b/tests/test_config_campaign_v410.py new file mode 100644 index 000000000..c9bebccff --- /dev/null +++ b/tests/test_config_campaign_v410.py @@ -0,0 +1,580 @@ +"""Configuration campaign regressions through actual schema, writer and routes.""" +import asyncio +import json +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +import yaml +from aiohttp import web + +from src.config import migrations, persistence +from src.config.apply_registry import build_field_record +from src.config.schema import Config, load_config +from src.config.webhook_text import WebhookTextPatch + + +@pytest.fixture(autouse=True) +def isolated_config_path(monkeypatch): + from src.config import schema + monkeypatch.setattr(schema, "_ACTIVE_CONFIG_PATH", None) + monkeypatch.setattr(schema, "_LAUNCH_CONFIG_PATH", None) + + +def config(**values): + return Config(discord={"token": "fixture"}, **values) + + +@pytest.mark.parametrize("provider", [None, {}]) +def test_588_implicit_codex_preserves_explicit_model(provider): + values = {"openai_codex": {"model": "gpt-6.1-sol"}} + if provider is not None: + values["llm_provider"] = provider + cfg = config(**values) + from src.tools.agent_tool_policy import configured_agent_model + assert cfg.llm_provider.model == "gpt-6.1-sol" + cfg.agents.model = None + assert configured_agent_model(cfg) == "gpt-6.1-sol" + from src.discord.llm_gateway import LLMGateway + gateway = SimpleNamespace( + get_config=lambda: cfg, codex_client=SimpleNamespace(model="transport-default"), + compatible_client=None, ollama_client=None, + ) + assert LLMGateway.capture_serving_identity(gateway).model == "gpt-6.1-sol" + + +@pytest.mark.parametrize("agents", [ + {"model": "gpt-5.5"}, + {"auto_model_allowlist": ["gpt-5.5", "gpt-6-sol"]}, + {"auto_model_allowlist": [{"model": "gpt-5.5", "reasoning_effort": "high"}]}, + {"model_selection_hints": {"gpt-5.5": "historical", "gpt-6-sol": "current"}}, +]) +def test_459_canonical_retirement_loads_without_rewriting(tmp_path, agents): + path = tmp_path / "config.yml" + raw = yaml.safe_dump({"discord": {"token": "fixture"}, "agents": agents}) + path.write_text(raw) + cfg = load_config(path) + assert "gpt-5.5" not in json.dumps(cfg.agents.model_dump()) + assert path.read_text() == raw + if "model_selection_hints" in agents: + assert cfg.agents.model_selection_hints["gpt-6-sol"] == "current" + if isinstance(agents.get("auto_model_allowlist", [None])[0], dict): + assert cfg.agents.auto_model_allowlist[0].reasoning_effort == "high" + + +@pytest.mark.parametrize("values", [ + {"llm_provider": {"model": "gpt-6.1-sol"}, + "openai_codex": {"model": "gpt-6-luna", "reasoning_effort": "none"}}, + {"agents": {"model": "gpt-6.1-sol"}, + "openai_codex": {"agent_model": "auto", "agent_reasoning_effort": "none"}}, +]) +def test_460_canonical_pairs_reject_save_warn_startup(tmp_path, caplog, values): + with pytest.raises(ValueError, match="not supported"): + config(**values) + path = tmp_path / "config.yml" + path.write_text(yaml.safe_dump({"discord": {"token": "fixture"}, **values})) + cfg = load_config(path) + assert cfg.openai_codex.agent_reasoning_effort == values["openai_codex"].get( + "agent_reasoning_effort", "auto" + ) + assert "Effective model/effort pair" in caplog.text + + +def test_460_legacy_pairs_cannot_override_canonical_or_auto(): + cfg = config(llm_provider={"model": "gpt-6-luna"}, agents={"model": "auto"}, + openai_codex={"model": "gpt-6.1-sol", "reasoning_effort": "none"}) + assert cfg.agents.model == "auto" + + +@pytest.mark.parametrize("timeout", [0, -1]) +def test_593_nonpositive_override_reject_save_default_on_load(tmp_path, caplog, timeout): + with pytest.raises(ValueError, match="positive integers"): + config(tools={"tool_timeouts": {"fetch_url": timeout}}) + path = tmp_path / "config.yml" + path.write_text(yaml.safe_dump({ + "discord": {"token": "fixture"}, + "tools": {"tool_timeouts": {"fetch_url": timeout, "run_command": 15}}, + })) + cfg = load_config(path) + assert cfg.tools.tool_timeouts == {"run_command": 15} + assert cfg.tools.get_tool_timeout("fetch_url") > 0 + assert "using tool defaults" in caplog.text + + +@pytest.mark.parametrize("field", ["archive_max_files", "archive_max_bytes"]) +def test_594_negative_archive_cap_is_unset_on_load(tmp_path, caplog, field): + with pytest.raises(ValueError, match="nonnegative"): + config(sessions={field: -1}) + assert getattr(config(sessions={field: 0}).sessions, field) == 0 + path = tmp_path / "config.yml" + path.write_text(yaml.safe_dump({"discord": {"token": "fixture"}, "sessions": {field: -1}})) + assert getattr(load_config(path).sessions, field) is None + assert "treating as unset" in caplog.text + + +@pytest.mark.parametrize("enabled", [True, False]) +@pytest.mark.parametrize("timeout,expected", [(5, 10), (4000, 3600), ("4000", 3600)]) +def test_595_legacy_kimi_timeouts_load(tmp_path, enabled, timeout, expected): + path = tmp_path / "config.yml" + path.write_text(yaml.safe_dump({ + "discord": {"token": "fixture"}, "kimi": {"enabled": enabled, "timeout": timeout} + })) + cfg = load_config(path) + assert cfg.openai_compatible.stream_stall_timeout_seconds == expected + with pytest.raises(ValueError): + config(openai_compatible={"stream_stall_timeout_seconds": timeout}) + + +def test_458_ceiling_migration_fences_later_operator_save(tmp_path, monkeypatch): + path = tmp_path / "config.yml" + raw = ( + "discord: {token: fixture}\nopenai_codex:\n context_compression:\n" + f" max_context_chars: {migrations.LEGACY_MAX_CONTEXT_CHARS}\n" + ) + assert migrations._is_shipped_legacy_literal(raw) + path.write_text(raw) + old_lock = persistence._config_file_lock + + @contextmanager + def interleave(target): + # The newer edit is durably acknowledged before the migration locks. + persistence.patch_config_paths( + [(("openai_codex", "context_compression", "max_context_chars"), 123456)], path=target + ) + with old_lock(target): + yield + + with monkeypatch.context() as patch: + # Avoid recursively interleaving our own fixture save. + @contextmanager + def lock(target): + with patch.context() as inner: + inner.setattr(persistence, "_config_file_lock", old_lock) + with interleave(target): + yield + patch.setattr(persistence, "_config_file_lock", lock) + data = yaml.safe_load(raw) + migrations.apply_legacy_ceiling_migration(data, path, raw) + assert yaml.safe_load(path.read_text())["openai_codex"]["context_compression"] == { + "max_context_chars": 123456 + } + assert data["openai_codex"]["context_compression"]["max_context_chars"] == 123456 + + +def test_457_noop_marker_storage_failure_is_not_startup_failure(tmp_path, monkeypatch): + path = tmp_path / "config.yml" + path.write_text("discord: {token: fixture}\n") + def readonly(*_args, **_kwargs): + raise PermissionError(13, "read only fixture") + monkeypatch.setattr(migrations, "_atomic_write_marker", readonly) + assert load_config(path).discord.token == "fixture" + assert path.read_text() == "discord: {token: fixture}\n" + + +@pytest.mark.parametrize("image", [False, True]) +def test_457_unprivileged_readonly_config_with_writable_data(tmp_path, monkeypatch, image): + import os + + from src.config.image_defaults import IMAGE_MODEL_DEFAULTS, LEGACY_IMAGE_MODEL_DEFAULTS + if os.geteuid() == 0: + pytest.skip("Read-only permission evidence requires an unprivileged test runner") + monkeypatch.chdir(tmp_path) + storage = tmp_path / "config-mount" + storage.mkdir() + writable = tmp_path / "application-data" + writable.mkdir() + path = storage / "config.yml" + values = {"discord": {"token": "fixture"}, "sessions": {"persist_directory": str(writable)}} + if image: + values["image"] = {"openai": dict(LEGACY_IMAGE_MODEL_DEFAULTS)} + raw = yaml.safe_dump(values) + path.write_text(raw) + storage.chmod(0o555) + try: + cfg = load_config(path) + assert cfg.sessions.persist_directory == str(writable) + assert path.read_text() == raw + if image: + for leaf, value in IMAGE_MODEL_DEFAULTS.items(): + assert getattr(cfg.image.openai, leaf) == value + finally: + storage.chmod(0o755) + + +@pytest.mark.parametrize("raw", [ + "outbound_webhooks: {targets: [{id: a, url: 'https://a.test'},],}\n", + "outbound_webhooks: {targets: [{id: a, url: 'https://a.test',}],}\n", + "outbound_webhooks: {enabled: true,}\n", +]) +def test_591_flow_trailing_delimiters_append_and_update(raw): + patch = WebhookTextPatch(raw) + patch.append({"id": "b", "url": "https://b.test"}) + assert yaml.safe_load(patch.text)["outbound_webhooks"]["targets"][-1]["id"] == "b" + patch.update(0, {"secret": "fixture"}) + assert yaml.safe_load(patch.text)["outbound_webhooks"]["targets"][0]["secret"] == "fixture" + + +def test_591_real_webhook_persistence_accepts_flow_trailing_commas(tmp_path): + path = tmp_path / "config.yml" + raw = "outbound_webhooks: {targets: [{id: a, url: 'https://a.test',},],}\n" + path.write_text(raw) + persistence.patch_webhook_targets( + [{"id": "b", "url": "https://b.test"}], + changed_fields={"b": {"id", "url"}}, create_ids=["b"], path=path, + ) + persistence.patch_webhook_targets( + [{"id": "a", "secret": "fixture"}], changed_fields={"a": {"secret"}}, path=path, + ) + targets = yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] + assert targets == [ + {"id": "a", "url": "https://a.test", "secret": "fixture"}, + {"id": "b", "url": "https://b.test"}, + ] + + +def test_592_partial_mapping_writer_keeps_current_siblings_and_env(tmp_path, monkeypatch): + monkeypatch.setenv("FIXTURE_TIMEOUT", "900") + path = tmp_path / "config.yml" + raw = ( + "discord: {token: fixture}\ntools:\n tool_timeouts:\n" + " run_command: ${FIXTURE_TIMEOUT} # keep\n external_edit: 77\n remove_me: 20\n" + ) + path.write_text(raw) + cfg = config(tools={"tool_timeouts": {"run_command": 900, "remove_me": 20, "fetch_url": 40}}) + updates = {"tools": {"tool_timeouts": {"fetch_url": 40, "remove_me": {"$delete": True}}}} + changes = persistence.submitted_leaves(updates, cfg.model_dump(), Config) + persistence.patch_config_paths(changes, path=path) + assert "${FIXTURE_TIMEOUT} # keep" in path.read_text() + parsed = yaml.safe_load(path.read_text())["tools"]["tool_timeouts"] + assert parsed["external_edit"] == 77 + assert parsed["fetch_url"] == 40 + assert "remove_me" not in parsed + + +def test_592_partial_structured_mapping_entry_keeps_required_siblings(tmp_path): + path = tmp_path / "config.yml" + path.write_text( + "openai_compatible:\n model_profiles:\n vendor/model:\n" + " total_window_tokens: 200000 # keep\n max_output_tokens: 2000\n" + ) + cfg = config(openai_compatible={"model_profiles": { + "vendor/model": {"total_window_tokens": 200000, "max_output_tokens": 4000} + }}) + updates = {"openai_compatible": {"model_profiles": { + "vendor/model": {"max_output_tokens": 4000} + }}} + changes = persistence.submitted_leaves(updates, cfg.model_dump(), Config) + assert changes == [ + (("openai_compatible", "model_profiles", "vendor/model", "max_output_tokens"), 4000) + ] + persistence.patch_config_paths(changes, path=path) + assert "total_window_tokens: 200000 # keep" in path.read_text() + + +@pytest.mark.parametrize("path,value", [ + ("llm_provider.model", "gpt-6-sol"), + ("openai_compatible.reasoning_effort", "high"), + ("web.api_token", "fixture"), + ("web.api_tokens", []), +]) +def test_589_live_read_metadata_does_not_claim_boot_value(path, value): + record = build_field_record(path, value, boot_value="old", has_boot=True) + assert record["apply_mode"] == "live_read" + assert not record["pending_restart"] + assert "immediately" in record["save_effect"] + + +def test_589_legacy_selectors_metadata_names_canonical_precedence(): + agent = build_field_record("openai_codex.agent_model", "gpt-6-sol") + assert agent["apply_mode"] == "dormant" + assert "agents.model" in agent["description"] + model = build_field_record("openai_codex.model", "gpt-6-sol") + assert "llm_provider.model overrides" in model["description"] + + +@pytest.mark.asyncio +async def test_501_agent_partial_update_merges_after_lock(monkeypatch): + from src.web.api.agents_loops import register_agents + bot = SimpleNamespace(config=config(), tool_catalog=MagicMock()) + monkeypatch.setattr( + "src.web.api.agents_loops.persist_config_paths_locked", + AsyncMock(return_value=(None, False)), + ) + routes = web.RouteTableDef() + register_agents(routes, bot) + handler = next(row.handler for row in routes if row.method == "PUT") + request = SimpleNamespace(json=AsyncMock(return_value={"model": "gpt-6-sol"})) + async with persistence.config_transaction(): + task = asyncio.create_task(handler(request)) + await asyncio.sleep(0) + bot.config.agents.thinking_mode = "enabled" + response = await task + assert response.status == 200 + assert bot.config.agents.thinking_mode == "enabled" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("endpoint", ["codex", "ollama", "compatible", "auxiliary"]) +async def test_502_string_false_is_not_enabled(endpoint, monkeypatch): + from src.web.api.llm_admin import register_provider_config + gateway = MagicMock() + gateway.provider_lock = asyncio.Lock() + gateway.codex_client = None + gateway.ollama_client = None + gateway.compatible_client = None + gateway.auxiliary_llm_client = None + gateway.reload_codex_inner = AsyncMock() + gateway.reload_ollama_inner = AsyncMock() + gateway.reload_openai_compatible_inner = AsyncMock(return_value={"committed": True}) + gateway.reload_auxiliary = AsyncMock(return_value={"committed": True}) + bot = SimpleNamespace(config=config(), llm_gateway=gateway, tool_catalog=MagicMock()) + monkeypatch.setattr( + "src.web.api.llm_admin._persist_or_response", AsyncMock(return_value=(None, False)) + ) + routes = web.RouteTableDef() + register_provider_config(routes, bot) + url = ( + "/api/openai-compatible/config" if endpoint == "compatible" + else f"/api/llm/{endpoint}/config" + ) + handler = next(row.handler for row in routes if row.method == "PUT" and row.path == url) + response = await handler(SimpleNamespace(json=AsyncMock(return_value={"enabled": "false"}))) + assert response.status == 200, response.text + if endpoint == "auxiliary": + assert gateway.prepare_auxiliary_reload.call_args.args[0]["enabled"] is False + else: + leaf = { + "codex": "openai_codex", "ollama": "ollama", "compatible": "openai_compatible" + }[endpoint] + assert getattr(bot.config, leaf).enabled is False + + +@pytest.mark.asyncio +async def test_501_host_partial_settings_merge_after_lock(tmp_path, monkeypatch): + from aiohttp.test_utils import TestClient, TestServer + + from tests.test_hosts_api import _app, _bot, _host + bot = _bot(tmp_path, hosts={ + "alpha": _host(), "beta": _host(host_id="f461e880-cd75-4bba-8ea1-de8326d0caef") + }) + monkeypatch.setattr( + persistence, "persist_config_paths_locked", AsyncMock(return_value=(None, False)) + ) + async with TestClient(TestServer(_app(bot))) as client: + async with persistence.config_transaction(): + task = asyncio.create_task( + client.post("/api/hosts/settings", json={"allow_host_tofu": True}) + ) + await asyncio.sleep(0.02) + bot.config.tools.default_host = "beta" + response = await task + assert response.status == 200, await response.text() + assert bot.config.tools.default_host == "beta" + assert bot.host_registry.default_host == "beta" + assert bot.config.tools.allow_host_tofu is True + + +@pytest.mark.asyncio +async def test_501_openrouter_remote_await_rebuilds_current_policy(monkeypatch): + from src.web.api.llm_admin import register_openai_compatible_admin + cfg = config(openai_compatible={"preset": "openrouter", "base_url": "https://openrouter.ai/api/v1"}) + client = SimpleNamespace(openrouter_routing=None) + bot = SimpleNamespace( + config=cfg, llm_gateway=SimpleNamespace(compatible_client=client), tool_catalog=MagicMock() + ) + entered, release = asyncio.Event(), asyncio.Event() + rows = [{"tag": "provider", "context_length": 200000, "max_completion_tokens": 8000, + "supports_tools": True, "supports_reasoning": True, "quantization": "unknown"}] + async def endpoints(*args, **kwargs): + entered.set() + await release.wait() + return rows + monkeypatch.setattr("src.web.api.llm_admin._openrouter_endpoint_rows", endpoints) + monkeypatch.setattr( + "src.web.api.llm_admin._openrouter_models", + AsyncMock(return_value=([{"id": "vendor/model", "supports_reasoning": True}], False, None)), + ) + persist = AsyncMock(return_value=(None, False)) + monkeypatch.setattr("src.web.api.llm_admin.persist_config_paths_locked", persist) + routes = web.RouteTableDef() + register_openai_compatible_admin(routes, bot) + handler = next( + row.handler for row in routes if row.method == "POST" and row.path.endswith("/select") + ) + request = SimpleNamespace( + json=AsyncMock(return_value={"provider_tag": "provider"}), + match_info={"author": "vendor", "slug": "model"}, + ) + task = asyncio.create_task(handler(request)) + await entered.wait() + replacement = config(openai_compatible={ + "preset": "openrouter", "base_url": "https://openrouter.ai/api/v1", + "openrouter": {"model_pins": {"other/model": "operator"}}, + }) + bot.config = replacement + release.set() + response = await task + assert response.status == 200, response.text + assert replacement.openai_compatible.openrouter.model_pins == { + "other/model": "operator", "vendor/model": "provider" + } + assert client.openrouter_routing is replacement.openai_compatible.openrouter + assert cfg.openai_compatible.openrouter.model_pins == {} + + +def test_594_unset_caps_do_not_prune_real_archives(monkeypatch): + from src.sessions.manager import SessionManager + files = [MagicMock(stem=f"channel_{i}") for i in range(3)] + for i, item in enumerate(files): + item.stat.return_value = SimpleNamespace(st_mtime=i, st_size=100) + directory = MagicMock() + directory.glob.return_value = files + manager = SessionManager.__new__(SessionManager) + manager.archive_max_bytes = None + manager.archive_max_files = None + manager._prune_old_archives(directory) + for item in files: + item.unlink.assert_not_called() + # Explicit zero retains its old policy, with unlink replaced by inert fakes. + manager.archive_max_files = 0 + manager._prune_old_archives(directory) + for item in files: + item.unlink.assert_called_once() + + +@pytest.mark.asyncio +async def test_589_save_reaches_canonical_and_reasoning_consumer(monkeypatch): + from src.discord.llm_gateway import LLMGateway + from src.web.api.config_admin import register_discord_config + from tests.test_web_api_config_admin import _bot + bot = _bot() + bot.health_server = None + gateway = SimpleNamespace( + get_config=lambda: bot.config, + codex_client=SimpleNamespace(model="old", reasoning_effort="high"), + compatible_client=SimpleNamespace(model="old"), ollama_client=None, + ) + routes = web.RouteTableDef() + register_discord_config(routes, bot) + handler = next( + row.handler for row in routes if row.method == "PUT" and row.path == "/api/config" + ) + monkeypatch.setattr( + "src.web.api.config_admin.persist_config_paths_locked", + AsyncMock(return_value=(None, False)), + ) + body = { + "llm_provider": {"model": "compat:deepseek-v4-flash"}, + "openai_compatible": {"reasoning_effort": "high"}, + } + request = MagicMock() + request.json = AsyncMock(return_value=body) + response = await handler(request) + assert response.status == 200, response.text + identity = LLMGateway.capture_serving_identity(gateway) + assert identity.model == "deepseek-v4-flash" + assert identity.reasoning_effort == "enabled" + record = build_field_record("llm_provider.model", bot.config.llm_provider.model) + assert record["effective"] == "compat:deepseek-v4-flash" + + +@pytest.mark.asyncio +async def test_589_web_credentials_live_metadata_matches_real_auth_consumer(): + from aiohttp.test_utils import TestClient, TestServer + + from src.health.server import SessionManager, _make_auth_middleware + owner = SimpleNamespace(config=config(web={"api_token": "old-fixture"})) + app = web.Application(middlewares=[ + _make_auth_middleware(lambda: owner.config.web, SessionManager()) + ]) + async def handler(request): + return web.json_response({"user": request._api_identity.user_id}) + app.router.add_get("/api/fixture", handler) + async with TestClient(TestServer(app)) as client: + response = await client.get("/api/fixture", headers={"Authorization": "Bearer old-fixture"}) + assert response.status == 200 + owner.config = config(web={"api_tokens": [{"token": "new-fixture", "user_id": "operator"}]}) + response = await client.get("/api/fixture", headers={"Authorization": "Bearer old-fixture"}) + assert response.status == 401 + response = await client.get("/api/fixture", headers={"Authorization": "Bearer new-fixture"}) + assert response.status == 200 + assert (await response.json())["user"] == "operator" + record = build_field_record("web.api_tokens", owner.config.web.api_tokens) + assert record["apply_mode"] == "live_read" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("route", ["/api/llm/main-model", "/api/llm/switch"]) +async def test_460_canonical_selector_api_rejects_bad_pair_before_switch(route): + from src.web.api.llm_admin import register_llm_provider + gateway = SimpleNamespace(switch_provider=AsyncMock()) + bot = SimpleNamespace( + config=config(llm_provider={"model": "gpt-6-luna"}, + openai_codex={"reasoning_effort": "none"}), llm_gateway=gateway, + ) + routes = web.RouteTableDef() + register_llm_provider(routes, bot) + handler = next(row.handler for row in routes if row.path == route) + response = await handler(SimpleNamespace(json=AsyncMock(return_value={ + "model": "gpt-6.1-sol", "provider": "codex" + }))) + assert response.status == 400 + gateway.switch_provider.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_592_generic_mapping_tombstone_reaches_disk_and_runtime(tmp_path, monkeypatch): + from src.config.schema import set_active_config_path + from src.web.api.config_admin import register_discord_config + from tests.test_web_api_config_admin import _bot + monkeypatch.setenv("FIXTURE_TIMEOUT", "900") + path = tmp_path / "config.yml" + path.write_text( + "discord: {token: fixture}\ntools:\n tool_timeouts:\n" + " run_command: ${FIXTURE_TIMEOUT}\n remove_me: 10\n external_edit: 77\n" + ) + set_active_config_path(path) + bot = _bot() + bot.health_server = None + bot.config = config(tools={"tool_timeouts": {"run_command": 900, "remove_me": 10}}) + routes = web.RouteTableDef() + register_discord_config(routes, bot) + handler = next( + row.handler for row in routes if row.method == "PUT" and row.path == "/api/config" + ) + request = MagicMock() + request.json = AsyncMock(return_value={ + "tools": {"tool_timeouts": {"fetch_url": 40, "remove_me": {"$delete": True}}} + }) + response = await handler(request) + assert response.status == 200, response.text + assert bot.config.tools.tool_timeouts == {"run_command": 900, "fetch_url": 40} + assert "${FIXTURE_TIMEOUT}" in path.read_text() + assert yaml.safe_load(path.read_text())["tools"]["tool_timeouts"]["external_edit"] == 77 + assert "remove_me" not in path.read_text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", [ + {"tools": {"tool_timeouts": {"fetch_url": 0}}}, + {"sessions": {"archive_max_files": -1}}, + {"llm_provider": {"model": "gpt-6.1-sol"}, "openai_codex": {"reasoning_effort": "none"}}, +]) +async def test_460_593_594_generic_save_rejects_before_write(monkeypatch, body): + from src.web.api.config_admin import register_discord_config + from tests.test_web_api_config_admin import _bot + bot = _bot() + bot.health_server = None + write = AsyncMock() + monkeypatch.setattr("src.web.api.config_admin.persist_config_paths_locked", write) + routes = web.RouteTableDef() + register_discord_config(routes, bot) + handler = next( + row.handler for row in routes if row.method == "PUT" and row.path == "/api/config" + ) + request = MagicMock() + request.json = AsyncMock(return_value=body) + response = await handler(request) + assert response.status == 400, response.text + write.assert_not_awaited() diff --git a/tests/test_config_persistence.py b/tests/test_config_persistence.py index c27737317..761ddf885 100644 --- a/tests/test_config_persistence.py +++ b/tests/test_config_persistence.py @@ -464,7 +464,7 @@ def test_without_the_schema_an_alias_is_still_dropped(self): == [] ) - def test_schema_owned_mapping_persists_canonicalized_keys_as_one_leaf(self): + def test_schema_owned_mapping_persists_only_submitted_canonicalized_entries(self): from src.config.schema import Config current = Config(discord={"token": "test"}).model_dump() @@ -484,8 +484,8 @@ def test_schema_owned_mapping_persists_canonicalized_keys_as_one_leaf(self): ) assert leaves == [ ( - ("openai_codex", "context_budget_overrides"), - {"gpt-5.6-luna": 600_000}, + ("openai_codex", "context_budget_overrides", "gpt-5.6-luna"), + 600_000, ) ] diff --git a/tests/test_max_reasoning_effort.py b/tests/test_max_reasoning_effort.py index 1f7e6afc7..259f8b949 100644 --- a/tests/test_max_reasoning_effort.py +++ b/tests/test_max_reasoning_effort.py @@ -84,8 +84,10 @@ def test_exception_map_is_exact_known_names(self): class TestLoadBoundary: - """Config load fails loudly on a persisted incompatible pair — exactly - like any other invalid config value; never boot into per-request 400s.""" + """Effective pairs are checked at the root save boundary, not legacy axes. + + Startup uses its explicit compatibility context and warns instead. + """ def test_max_valid_on_capable_model(self): cfg = OpenAICodexConfig(model="gpt-5.6-sol", reasoning_effort="max") @@ -96,36 +98,26 @@ def test_max_valid_on_unknown_model(self): def test_main_pair_rejected(self): with pytest.raises(ValidationError, match="gpt-5.4"): - OpenAICodexConfig(model="gpt-5.4", reasoning_effort="max") + Config(discord={"token": "fixture"}, + openai_codex={"model": "gpt-5.4", "reasoning_effort": "max"}) def test_fixed_agent_pair_rejected(self): - with pytest.raises(ValidationError, match="agent settings"): - OpenAICodexConfig( - model="gpt-5.6-sol", - reasoning_effort="medium", - agent_model="gpt-5.4", - agent_reasoning_effort="max", - ) + with pytest.raises(ValidationError, match="gpt-5.4"): + Config(discord={"token": "fixture"}, agents={"model": "gpt-5.4"}, + openai_codex={"reasoning_effort": "medium", "agent_reasoning_effort": "max"}) def test_agent_effort_inheriting_bad_main_model_rejected(self): # agent_model None inherits the main gpt-5.4 → (gpt-5.4, max) - with pytest.raises(ValidationError, match="agent settings"): - OpenAICodexConfig( - model="gpt-5.4", - reasoning_effort="xhigh", - agent_model=None, # explicit inherit (default is now "auto") - agent_reasoning_effort="max", - ) + with pytest.raises(ValidationError, match="gpt-5.4"): + Config(discord={"token": "fixture"}, agents={"model": None}, + openai_codex={"model": "gpt-5.4", "reasoning_effort": "xhigh", + "agent_reasoning_effort": "max"}) def test_agent_model_inheriting_max_effort_rejected(self): # agent effort None inherits the main "max" onto a fixed gpt-5.4 - with pytest.raises(ValidationError, match="agent settings"): - OpenAICodexConfig( - model="gpt-5.6-sol", - reasoning_effort="max", - agent_model="gpt-5.4", - agent_reasoning_effort=None, # explicit inherit (default is now "auto") - ) + with pytest.raises(ValidationError, match="gpt-5.4"): + Config(discord={"token": "fixture"}, agents={"model": "gpt-5.4"}, + openai_codex={"reasoning_effort": "max", "agent_reasoning_effort": None}) def test_auto_model_axis_exempt(self): # Per-spawn selection: the spawn/request boundaries own the pair. diff --git a/tests/test_web_api_llm_admin.py b/tests/test_web_api_llm_admin.py index f173da6d8..fe2302e95 100644 --- a/tests/test_web_api_llm_admin.py +++ b/tests/test_web_api_llm_admin.py @@ -1600,6 +1600,7 @@ async def test_max_accepted_on_capable_model(self): async def test_recent_models_max_accepted_none_rejected(self, model): app, bot = _app(register_provider_config) gw = _gw(bot) + bot.config.llm_provider.model = model async with TestClient(TestServer(app)) as c: r = await c.put( "/api/llm/codex/config", json={"model": model, "reasoning_effort": "max"} @@ -1619,6 +1620,7 @@ async def test_effort_direction_rejected_with_allowed_list(self): app, bot = _app(register_provider_config) gw = _gw(bot) bot.config.openai_codex.model = "gpt-5.4" + bot.config.llm_provider.model = "gpt-5.4" async with TestClient(TestServer(app)) as c: r = await c.put("/api/llm/codex/config", json={"reasoning_effort": "max"}) assert r.status == 400 @@ -1654,15 +1656,16 @@ async def test_agent_model_direction_rejected(self): bot.config.openai_codex.agent_reasoning_effort = None # explicit inherit (default: "auto") async with TestClient(TestServer(app)) as c: r = await c.put("/api/llm/codex/config", json={"agent_model": "gpt-5.4"}) - assert r.status == 400 - assert "agent settings" in (await r.json())["error"] - assert bot.config.openai_codex.agent_model == "auto" + assert r.status == 200 + assert bot.config.openai_codex.agent_model == "gpt-5.4" + assert bot.config.agents.model == "auto" @pytest.mark.asyncio async def test_agent_effort_direction_rejected(self): app, bot = _app(register_provider_config) _gw(bot) bot.config.openai_codex.agent_model = "gpt-5.4" + bot.config.agents.model = "gpt-5.4" async with TestClient(TestServer(app)) as c: r = await c.put("/api/llm/codex/config", json={"agent_reasoning_effort": "max"}) assert r.status == 400 From 5cc6a262eac665068d6fad0dcffb7a3ae359ffe6 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:28:02 -0400 Subject: [PATCH 074/162] test(config): align agent API validation with canonical root policy --- tests/test_web_api_agents_loops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_web_api_agents_loops.py b/tests/test_web_api_agents_loops.py index 58f6f9c1b..9e4cfbe59 100644 --- a/tests/test_web_api_agents_loops.py +++ b/tests/test_web_api_agents_loops.py @@ -390,7 +390,7 @@ async def test_agent_model_policy_reports_validation_errors(self, monkeypatch, v async with TestClient(TestServer(_app(register_agents, bot=bot))) as c: response = await c.put("/api/agents/model", json={"model": "auto"}) assert response.status == 400 - assert (await response.json())["error"] == "invalid agent policy" + assert "invalid agent policy" in (await response.json())["error"] @pytest.mark.asyncio async def test_agent_model_policy_get_and_put(self, monkeypatch): From 0d2510832dcd0cadab0f60c051ce6dbb31e27a14 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:28:46 -0400 Subject: [PATCH 075/162] fix(config): tolerate read-only migration completion repairs --- src/config/migrations.py | 5 +++++ tests/test_config_campaign_v410.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/config/migrations.py b/src/config/migrations.py index 4555df883..a3d6dc61b 100644 --- a/src/config/migrations.py +++ b/src/config/migrations.py @@ -406,6 +406,11 @@ def _write_required(marker: Path, reason: str, purpose: str, config_id: str) -> try: _atomic_write_marker(marker, _completion_record(reason, config_id)) except OSError as exc: + if exc.errno in {13, 30}: + # Completion/repair is bookkeeping, not a new prerequisite for an + # existing readable config mounted without adjacent write access. + log.warning("Could not %s on read-only storage; retrying next boot", purpose) + return log.error("Could not %s at %s: %s", purpose, marker, exc) raise MigrationCompletionError( f"could not {purpose}; configuration was left unchanged" diff --git a/tests/test_config_campaign_v410.py b/tests/test_config_campaign_v410.py index c9bebccff..034f46367 100644 --- a/tests/test_config_campaign_v410.py +++ b/tests/test_config_campaign_v410.py @@ -172,6 +172,21 @@ def readonly(*_args, **_kwargs): assert path.read_text() == "discord: {token: fixture}\n" +def test_457_completed_migration_alias_repair_is_not_a_write_prerequisite(tmp_path, monkeypatch): + path = tmp_path / "config.yml" + raw = "discord: {token: fixture}\n" + path.write_text(raw) + migrations._atomic_write_marker( + migrations._shared_ceiling_marker_path(path), + migrations._completion_record("not_applicable", migrations._config_identity(path)), + ) + def readonly(*args, **kwargs): + raise PermissionError(13, "read only fixture") + monkeypatch.setattr(migrations, "_atomic_write_marker", readonly) + migrations.apply_legacy_ceiling_migration(yaml.safe_load(raw), path, raw) + assert path.read_text() == raw + + @pytest.mark.parametrize("image", [False, True]) def test_457_unprivileged_readonly_config_with_writable_data(tmp_path, monkeypatch, image): import os From eebbecc2423c53b97b9f708a9033a6bc89dea539 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:31:14 -0400 Subject: [PATCH 076/162] fix(config): adapt file-mounted image defaults without preparing a rewrite --- src/config/migrations.py | 4 +++- tests/test_config_campaign_v410.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/config/migrations.py b/src/config/migrations.py index a3d6dc61b..b70baadc0 100644 --- a/src/config/migrations.py +++ b/src/config/migrations.py @@ -853,7 +853,9 @@ def digest(text): "version": 1, "migration": "image_model_defaults_v1", "config_id": config_id, "state": "prepared" if edits else "completed", "after_sha256": digest(rewritten), } - if edits and not os.access(target.parent, os.W_OK): + if edits and ( + not os.access(target.parent, os.W_OK) or os.path.ismount(target) + ): reconcile(rewritten) log.warning( "Image defaults migration uses runtime defaults on read-only config storage" diff --git a/tests/test_config_campaign_v410.py b/tests/test_config_campaign_v410.py index 034f46367..ed4ceea0d 100644 --- a/tests/test_config_campaign_v410.py +++ b/tests/test_config_campaign_v410.py @@ -187,6 +187,22 @@ def readonly(*args, **kwargs): assert path.read_text() == raw +def test_457_file_mount_migration_does_not_prepare_impossible_rename(tmp_path, monkeypatch): + from src.config.image_defaults import IMAGE_MODEL_DEFAULTS, LEGACY_IMAGE_MODEL_DEFAULTS + path = tmp_path / "config.yml" + raw = yaml.safe_dump({ + "discord": {"token": "fixture"}, "image": {"openai": LEGACY_IMAGE_MODEL_DEFAULTS} + }) + path.write_text(raw) + # File bind mounts cannot be replaced atomically even with writable parents. + monkeypatch.setattr(migrations.os.path, "ismount", lambda target: target == path) + cfg = load_config(path) + assert path.read_text() == raw + assert not migrations.image_defaults_marker_path(path).exists() + for leaf, value in IMAGE_MODEL_DEFAULTS.items(): + assert getattr(cfg.image.openai, leaf) == value + + @pytest.mark.parametrize("image", [False, True]) def test_457_unprivileged_readonly_config_with_writable_data(tmp_path, monkeypatch, image): import os From b607eacede4e976ea0a779c4fd77813b8fee87e5 Mon Sep 17 00:00:00 2001 From: Odin Date: Tue, 29 Sep 2026 19:15:13 -0400 Subject: [PATCH 077/162] fix(webui): fence asynchronous ownership and repair operational controls --- package-lock.json | 10 + package.json | 6 +- scripts/check-mcp-webui.mjs | 28 ++- scripts/check-v410-webui-browser.mjs | 270 +++++++++++++++++++++ src/trajectories/saver.py | 20 +- src/web/api/sessions_chat.py | 8 +- tests/test_webui_selected_trace_filters.py | 56 +++++ ui/js/active-clock.js | 19 ++ ui/js/discord-identity.js | 11 +- ui/js/discord-user-combobox.js | 2 +- ui/js/pages/agents.js | 7 +- ui/js/pages/execution.js | 70 +++++- ui/js/pages/health.js | 10 +- ui/js/pages/internals.js | 4 + ui/js/pages/llm-config.js | 23 +- ui/js/pages/logs.js | 32 ++- ui/js/pages/loops.js | 7 +- ui/js/pages/mcp-servers.js | 9 +- ui/js/pages/processes.js | 10 +- ui/js/pages/resources.js | 7 + ui/js/pages/schedules.js | 65 ++++- ui/js/pages/sessions.js | 4 +- ui/js/pages/skills.js | 40 ++- ui/js/pages/traces.js | 8 +- ui/js/request-owner.js | 17 ++ 25 files changed, 657 insertions(+), 86 deletions(-) create mode 100644 scripts/check-v410-webui-browser.mjs create mode 100644 tests/test_webui_selected_trace_filters.py create mode 100644 ui/js/active-clock.js create mode 100644 ui/js/request-owner.js diff --git a/package-lock.json b/package-lock.json index d7945ed7c..d60ec73f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "dompurify": "^3.2.0", "marked": "^15.0.0", + "re2js": "^2.8.6", "vue": "^3.5.0", "vue-router": "^4.5.0" }, @@ -1983,6 +1984,15 @@ ], "license": "MIT" }, + "node_modules/re2js": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", + "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 1a3f61513..da9e75436 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "check:setup-ui": "node scripts/check-setup-ui.mjs", "check:template-bindings": "node scripts/check-template-bindings.mjs", "check:modal-race": "node scripts/check-modal-race.mjs", - "check": "npm run check:templates && npm run check:setup-ui && npm run check:computer && npm run check:schedule-availability && npm run check:codex-quota-ui && npm run check:llm-config-refresh && npm run check:llm-provider-visibility && npm run check:template-bindings && npm run check:permission-token-repair && npm run check:listener-consent && npm run check:w1-webui && npm run check:modal-race && npm run check:host-access-races && npm run check:host-enrollment && npm run check:socket-identity && npm run check:ws-lifecycle && npm run check:login-persistence && npm run check:w2-freshness && npm run check:toggle-serialization && npm run check:search-races && npm run check:config-keydown && npm run check:css-tokens && npm run check:turn-state-webui && npm run check:audit-verify && npm run check:schedule-time && npm run check:schedule-report-format && npm run check:config-health && npm run check:config-center-ui2 && npm run check:mcp-webui && npm run check:tools-webui && npm run check:learning-toggle && npm run check:config-save-boundaries && npm run check:usage-activity && npm run check:output-renderer && npm run check:live-logs && npm run check:logs-preset-search-races && npm run check:discord-identity && npm run build", + "check": "npm run check:templates && npm run check:setup-ui && npm run check:computer && npm run check:schedule-availability && npm run check:codex-quota-ui && npm run check:llm-config-refresh && npm run check:llm-provider-visibility && npm run check:template-bindings && npm run check:permission-token-repair && npm run check:listener-consent && npm run check:w1-webui && npm run check:modal-race && npm run check:host-access-races && npm run check:host-enrollment && npm run check:socket-identity && npm run check:ws-lifecycle && npm run check:login-persistence && npm run check:w2-freshness && npm run check:toggle-serialization && npm run check:search-races && npm run check:config-keydown && npm run check:css-tokens && npm run check:turn-state-webui && npm run check:audit-verify && npm run check:schedule-time && npm run check:schedule-report-format && npm run check:config-health && npm run check:config-center-ui2 && npm run check:mcp-webui && npm run check:tools-webui && npm run check:learning-toggle && npm run check:config-save-boundaries && npm run check:usage-activity && npm run check:output-renderer && npm run check:live-logs && npm run check:logs-preset-search-races && npm run check:discord-identity && npm run check:v410-webui && npm run build", "check:listener-consent": "node scripts/check-listener-consent.mjs", "check:host-access-races": "node scripts/check-host-access-races.mjs && node scripts/check-host-access-redesign.mjs && node scripts/check-host-access-modal.mjs", "check:host-enrollment": "node scripts/check-host-enrollment-browser.mjs", @@ -46,11 +46,13 @@ "check:logs-preset-search-races": "node scripts/check-logs-preset-search-races.mjs", "check:computer": "node scripts/check-computer-browser.mjs", "check:permission-token-repair": "node scripts/check-permission-token-repair.mjs", - "check:discord-identity": "node scripts/check-discord-identity.mjs" + "check:discord-identity": "node scripts/check-discord-identity.mjs", + "check:v410-webui": "node scripts/check-v410-webui-browser.mjs" }, "dependencies": { "dompurify": "^3.2.0", "marked": "^15.0.0", + "re2js": "^2.8.6", "vue": "^3.5.0", "vue-router": "^4.5.0" }, diff --git a/scripts/check-mcp-webui.mjs b/scripts/check-mcp-webui.mjs index 636f49df7..55432dcb3 100644 --- a/scripts/check-mcp-webui.mjs +++ b/scripts/check-mcp-webui.mjs @@ -313,32 +313,47 @@ globalThis.fetch = async (path, opts) => { }), }; }; -pageState.status.value = { +// Toggle publication is activation-owned. Mount the real setup rather than +// treating an unmounted component as an active page. +const { createRenderer } = await import('vue'); +const toggleRenderer = createRenderer({ + insert() {}, remove() {}, createComment() { return {}; }, + parentNode() { return null; }, nextSibling() { return null; }, +}); +let toggleState; +const toggleApp = toggleRenderer.createApp({ + setup() { toggleState = mcpServersPage.setup(); return () => null; }, +}); +toggleApp.mount({}); +await new Promise(resolve => setTimeout(resolve, 0)); +fetchCalls.length = 0; +toggleState.status.value = { enabled: true, connected_count: 1, servers: [{ name: 'dw', enabled: true, state: 'connected' }], }; -await pageState.toggleServerEnabled(pageState.status.value.servers[0], { +await toggleState.toggleServerEnabled(toggleState.status.value.servers[0], { target: { checked: false }, }); -assert.equal(fetchCalls.length, 1); +assert.equal(fetchCalls.length, 2, 'one mutation plus authoritative post-mutation read'); assert.equal(fetchCalls[0].path, '/api/mcp/servers/dw/enabled'); assert.equal(fetchCalls[0].opts.method, 'POST'); assert.deepEqual(JSON.parse(fetchCalls[0].opts.body), { enabled: false }); // The canonical returned payload is adopted — no optimistic state invented. -assert.equal(pageState.status.value.servers[0].enabled, false); -assert.equal(pageState.status.value.servers[0].state, 'disabled'); +assert.equal(toggleState.status.value.servers[0].enabled, false); +assert.equal(toggleState.status.value.servers[0].state, 'disabled'); // A failed toggle restores the prior configured switch state. globalThis.fetch = async () => { throw new Error('network down'); }; const failedInput = { checked: true }; -await pageState.toggleServerEnabled( +await toggleState.toggleServerEnabled( { name: 'dw', enabled: false, state: 'disabled' }, { target: failedInput }, ); assert.equal(failedInput.checked, false); +toggleApp.unmount(); // The two disabled causes stay textually distinguishable. pageState.status.value = { enabled: true, connected_count: 0, servers: [] }; @@ -355,7 +370,6 @@ assert.equal( // Publication limits use the real setup and mounted lifecycle. A minimal Vue // renderer supplies lifecycle only; numeric DOM constraints are checked on the // actual template AST, while every edit/save/poll action below runs page code. -const { createRenderer } = await import('vue'); const perServer = 'max_published_tools_per_server'; const globalLimit = 'max_published_tools_global'; const limitsForm = findElement(mcpTemplateAst, diff --git a/scripts/check-v410-webui-browser.mjs b/scripts/check-v410-webui-browser.mjs new file mode 100644 index 000000000..a62279487 --- /dev/null +++ b/scripts/check-v410-webui-browser.mjs @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { chromium } from 'playwright-core'; +import { createServer } from 'vite'; + +// Native Vue lifecycle and Chromium input, on an ephemeral loopback fixture. +// No installed Odin server, desktop session, or production API is contacted. +const server = await createServer({ + configFile: false, root: process.cwd(), appType: 'custom', + resolve: { alias: { vue: 'vue/dist/vue.esm-bundler.js' } }, + define: { __VUE_OPTIONS_API__: 'true', __VUE_PROD_DEVTOOLS__: 'false', __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false' }, + server: { host: '127.0.0.1', port: 0, watch: null }, +}); +const html = `
    `; +server.middlewares.use(async (req, res, next) => { + if (req.url !== '/fixture.html') return next(); + res.setHeader('Content-Type', 'text/html'); + res.end(await server.transformIndexHtml(req.url, html)); +}); +let browser; +try { + await server.listen(); + const executablePath = process.env.CHROMIUM_PATH || ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome'].find(p => fs.existsSync(p)); + browser = await chromium.launch({ executablePath, headless: true, args: ['--no-sandbox'] }); + const page = await browser.newPage(); + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/fixture.html`); + await page.waitForFunction(() => window.ready); + + // #517 latest draft drains serially, even if changed multiple times during PUT. + await page.evaluate(async () => { + await mount('LLM'); window.puts = []; const gate = defer(); + api.put = async (path, body) => { puts.push(body.model); if (puts.length === 1) await gate.promise; window.savedModel = body.model; return {}; }; + state.modelSelection.value.main = 'gpt-6-luna'; const first = state.saveMainModel(); + await flush(); state.modelSelection.value.main = 'gpt-6-sol'; await state.saveMainModel(); + state.modelSelection.value.main = 'gpt-6.1-sol'; await state.saveMainModel(); + gate.resolve(); await first; await flush(); + }); + assert.deepEqual(await page.evaluate(() => puts), ['gpt-6-luna', 'gpt-6.1-sol']); + assert.equal(await page.evaluate(() => savedModel), 'gpt-6.1-sol'); + assert.equal(await page.evaluate(() => state.modelSelection.value.main), 'gpt-6.1-sol'); + + // #518 routes, spinner and failures belong to the selected model/request. + const routeResult = await page.evaluate(async () => { + const gates = []; api.get = path => { if (path.endsWith('/endpoints')) { const g=defer(); gates.push(g); return g.promise; } return Promise.resolve(normal(path)); }; + const a=state.prepareOpenRouterModel({ id:'author/A' }); const b=state.prepareOpenRouterModel({ id:'author/B' }); + gates[1].resolve({ endpoints:[{ tag:'B' }] }); await b; + gates[0].resolve({ endpoints:[{ tag:'A' }] }); await a; + const result = [state.openRouterPendingModel.value.id, state.openRouterPendingEndpoints.value[0].tag, state.openRouterPendingLoading.value]; + const c=state.prepareOpenRouterModel({ id:'author/C' }); state.cancelOpenRouterPending(); gates[2].reject(new Error('stale')); await c; + return { result, cancelled: state.openRouterPendingModel.value === null && !state.openRouterPendingLoading.value }; + }); + assert.deepEqual(routeResult, { result:['author/B','B',false], cancelled:true }); + + // #519 an old GET cannot resurrect the server after acknowledged disable. + assert.equal(await page.evaluate(async () => { + await mount('MCP'); const old=defer(); let reads=0; + api.get=()=>++reads===1 ? old.promise : Promise.resolve(normal('/api/mcp/status')); + const poll=state.refreshAll(); api.post=async()=>normal('/api/mcp/status'); + const toggle=state.toggleServerEnabled({ name:'s', enabled:true }, { target:{checked:false} }); + await flush(); api.get=async()=>normal('/api/mcp/status'); + old.resolve({ enabled:true, servers:[{name:'s',enabled:true}] }); + await Promise.all([poll,toggle]); return state.status.value.servers[0].enabled; + }), false); + + // #520 every affected real page: reverse completions, stale errors and + // hidden completion. Lifecycle-owned requests include partial internals failures. + for (const [name, method, data, path] of [ + ['Health','fetchHealth','data','/api/health/components'], ['Resources','refresh','data','/api/resource-usage'], + ['Processes','fetchProcesses','processes','/api/processes'], ['Agents','fetchAgents','agents','/api/agents'], + ['Loops','fetchLoops','loops','/api/loops'], ['Schedules','fetchSchedules','schedules','/api/schedules'], + ['Internals','retry','startup','/api/startup'], + ]) { + const result = await page.evaluate(async ({name,method,data,path}) => { + await mount(name); const gates=[]; + if (name==='Internals') path=state.endpoints[0].path; + api.get = p => { if(p===path) {const g=defer(); gates.push(g); return g.promise;} return Promise.resolve(normal(p)); }; + const old=state[method](); const newer=state[method](); + const value = label => ['processes','agents','loops','schedules'].includes(data) ? [{ marker:label }] : {marker:label}; + gates[1].resolve(value('new')); await newer; await flush(); + gates[0].resolve(value('old')); await old; await flush(); + const marker = () => (Array.isArray(state[data].value) ? state[data].value[0] : state[data].value)?.marker; + const adopted=marker(); const errorRequest=state[method](); const successful=state[method](); + gates[3].resolve(value('latest')); await successful; await flush(); gates[2].reject(new Error('obsolete')); await errorRequest; await flush(); + const staleError=state.error.value; const hidden=state[method](); await hide(); + gates[4].resolve(value('hidden')); await hidden; await flush(); + const hiddenMarker=marker(); + await show(); gates[5].resolve(value('reactivated')); await flush(); + return { adopted, marker:hiddenMarker, reactivated:marker(), staleError, loading:state.loading.value }; + }, {name,method,data,path}); + assert.equal(result.adopted,'new',name); assert.equal(result.marker,'latest',name); + assert.equal(result.reactivated,'reactivated',name); + assert.ok(!result.staleError, `${name}: stale error ignored`); assert.equal(result.loading,false,name); + } + + // #521 props reused A -> B, B resolves first; original A is never shown with B. + const identity = await page.evaluate(async () => { + await identityMount(); await flush(); const gates={}; + api.get=path=>{const g=defer(); gates[path.split('/').pop()]=g;return g.promise;}; + identityId.value='123456789012345672'; await flush(); identityId.value='123456789012345673'; await flush(); + gates['123456789012345673'].resolve({user:{id:'123456789012345673',display_name:'B'}}); await flush(); + gates['123456789012345672'].resolve({user:{id:'123456789012345672',display_name:'A'}}); await flush(); + return document.querySelector('#app').textContent; + }); + assert.ok(identity.includes('B')); assert.ok(!identity.includes('A')); assert.ok(identity.includes('123456789012345673')); + + // #522 native Enter, Space and pointer clicks on the explicit Add button. + await page.evaluate(() => comboMount()); + for (const key of ['Enter','Space',null]) { + await page.locator('input').fill('Alice'); + if (key) { await page.getByRole('button',{name:'Add',exact:true}).focus(); await page.keyboard.press(key); } + else await page.getByRole('button',{name:'Add',exact:true}).click(); + } + assert.deepEqual(await page.evaluate(()=>selected), Array(3).fill('12345678901234567')); + + // #523 exact source text after DOM parsing, including HTML-like Python strings. + assert.equal(await page.evaluate(() => { + const code='class Demo:\n s = "str # class & hello"\n # return 123\n print(s)\n'; + const pre=document.createElement('pre'); pre.innerHTML=highlightPython(code); return pre.textContent===code; + }),true); + // #531 mixed lifecycle records count only loaded skills. + assert.equal(await page.evaluate(async()=>{await mount('Skills');state.skills.value=[{status:'loaded'},{status:'disabled'},{status:'error'}];return state.enabledCount.value;}),1); + + // #524 long pause remains capped and loss is visible; bounded resume. + const paused = await page.evaluate(async()=>{ + await mount('Logs'); state.togglePause(); + for(let i=0;i<10001;i++) emit('logs',{type:'log',line:JSON.stringify({timestamp:new Date(Date.now()).toISOString(),level:'INFO',message:'row '+i})}); + const result={buffer:state.pauseBuffer.value.length,dropped:state.pauseDropped.value}; + state.togglePause(); result.rows=state.logs.value.length; return result; + }); + assert.deepEqual(paused,{buffer:2000,dropped:8001,rows:2000}); + + // #525 idle clock invalidation, KeepAlive cleanup and activation refresh. + assert.deepEqual(await page.evaluate(async()=>{ + state.timeRange.value='last_5m'; const before=state.filteredLogs.value.length; await tick(600000); + const after=state.filteredLogs.value.length; await hide(); const hiddenTimers=clockTimerCount(); await show(); const shownTimers=clockTimerCount(); + return {before,after,hiddenTimers,shownTimers}; + }),{before:2000,after:0,hiddenTimers:0,shownTimers:1}); + assert.deepEqual(await page.evaluate(async()=>{ + await mount('Sessions'); state.sessions.value=[{channel_id:'idle',last_active:Date.now()/1000,message_count:1}]; + state.activePreset.value='active'; const before=state.filteredSessions.value.length; await tick(86400000); return [before,state.filteredSessions.value.length]; + }),[1,0]); + + // #526 run an adversarial valid pattern through actual filtering, timed by + // the browser. RE2 is linear and rejects unsupported lookaround/backrefs. + const regex = await page.evaluate(async()=>{ + await mount('Logs'); state.textFilter.value='(a+)+$'; state.useRegex.value=true; + emit('logs',{type:'log',line:JSON.stringify({message:'a'.repeat(5000)+'!',level:'INFO'})}); + const start=performance.now(); const count=state.filteredLogs.value.length; const elapsed=performance.now()-start; + state.textFilter.value='(?=a)'; const unsupported=state.regexError.value; + return {count,elapsed,unsupported}; + }); + assert.equal(regex.count,0); assert.ok(regex.elapsed<1000,JSON.stringify(regex)); assert.ok(regex.unsupported); + + // #527 selected file sends all filters to server before the result limit. + assert.equal(await page.evaluate(async()=>{ + await mount('Traces'); let requested='';api.get=async path=>{requested=path;return {entries:[{tools_used:['target'],is_error:true,channel_id:'c',user_id:'u'}]};}; + state.selectedFile.value='2026-09-29.jsonl'; state.filters.value={tool_name:'target',errors_only:true,channel_id:'c',user_id:'u',limit:1}; + await state.fetchTraces();const params=new URL(requested,location.origin).searchParams; + return params.get('tool_name')==='target' && params.get('errors_only')==='true' && params.get('channel_id')==='c' && params.get('user_id')==='u' && state.entries.value.length===1; + }),true); + + // #528 validates and submits the existing steps contract, including condition. + const workflow=await page.evaluate(async()=>{ + await mount('Schedules');let posted=null;api.post=async(path,payload)=>{posted=payload;return {};}; + Object.assign(state.form.value,{action:'workflow',description:'test',channel_id:'c',cron:'0 * * * *',steps_str:'[]'}); + await state.doCreate(); const invalid=!!state.createError.value && !posted; + state.form.value.steps_str=JSON.stringify([{tool_name:'run_command',tool_input:{host:'fixture',command:'uptime'},condition:'OK',on_failure:'continue'}]); + await state.doCreate(); return {invalid,steps:posted?.steps,error:state.createError.value}; + }); + assert.equal(workflow.invalid,true); assert.equal(workflow.error,null); assert.equal(workflow.steps[0].condition,'OK'); + + // #529 evidence gaps retire running cards as UNKNOWN, not fabricated success. + // #530 independent channels, agents and iterations reuse model-local call IDs. + const execution=await page.evaluate(async()=>{ + await mount('Execution'); + const event=(type,channel,agent='',iteration=1,extra={})=>({payload:{type,action:'run_command',channel_id:channel,call_id:'same',agent_id:agent,iteration,...extra}}); + emit('events',event('tool_start','A'));emit('events',event('tool_start','B')); + emit('events',event('tool_stream','A','',1,{tool_name:'run_command',chunk:'alpha'})); + emit('events',event('tool_stream','B','',1,{tool_name:'run_command',chunk:'beta'})); + const separate=state.activeTasks.value.map(t=>[t.channel,state.streamOutput.value[t.id]]); + emit('events',event('tool_end','A')); const terminal=state.activeTasks.value.map(t=>[t.channel,t.status]); + emit('events',event('tool_start','B','agent',1));emit('events',event('tool_start','B','agent',2)); + emit('events',event('tool_end','B','agent',1)); + const iterations=state.activeTasks.value.filter(t=>t.agentId==='agent').map(t=>[t.iteration,t.status]); + await hide();const hidden=state.recentHistory.value.map(t=>t.status);await show(); + emit('events',event('tool_start','C'));connection('reconnecting');const disconnected=state.recentHistory.value.find(t=>t.channel==='C')?.status; + return {separate,terminal,iterations,hidden,disconnected,remaining:state.activeTasks.value.filter(t=>t.status==='running').length}; + }); + assert.deepEqual(execution.separate,[['B','beta'],['A','alpha']]); + assert.deepEqual(execution.terminal,[['B','running'],['A','success']]); + assert.deepEqual(execution.iterations,[[2,'running'],[1,'success']]); + assert.ok(execution.hidden.every(status=>status==='unknown')); assert.equal(execution.disconnected,'unknown'); assert.equal(execution.remaining,0); + assert.deepEqual(errors,[]); + console.log('v410-webui: #517-531 realistic Vue/Chromium regressions passed'); +} finally { await browser?.close(); await server.close(); } diff --git a/src/trajectories/saver.py b/src/trajectories/saver.py index 8dd113458..6f536a50e 100644 --- a/src/trajectories/saver.py +++ b/src/trajectories/saver.py @@ -385,7 +385,12 @@ async def list_files(self) -> list[str]: if f.suffix == ".jsonl" and f.is_file() ) - async def read_file(self, filename: str, limit: int = 100) -> list[dict]: + async def read_file( + self, filename: str, limit: int = 100, *, channel_id: str | None = None, + user_id: str | None = None, tool_name: str | None = None, errors_only: bool = False, + ) -> list[dict]: + if limit <= 0: + return [] # Reject absolute paths and path-traversal components if filename != Path(filename).name or ".." in filename: log.warning("Rejected path-traversal attempt in trajectory read: %s", filename) @@ -402,9 +407,20 @@ async def read_file(self, filename: str, limit: int = 100) -> list[dict]: try: async for raw in _iter_jsonl_lines_reverse(handle): try: - results.append(json.loads(raw)) + entry = json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError): continue + if not isinstance(entry, dict): + continue + if channel_id and entry.get("channel_id") != channel_id: + continue + if user_id and entry.get("user_id") != user_id: + continue + if tool_name and tool_name not in entry.get("tools_used", []): + continue + if errors_only and not entry.get("is_error"): + continue + results.append(entry) if len(results) >= limit: break finally: diff --git a/src/web/api/sessions_chat.py b/src/web/api/sessions_chat.py index 55c0e6e9b..6c140d08e 100644 --- a/src/web/api/sessions_chat.py +++ b/src/web/api/sessions_chat.py @@ -461,7 +461,13 @@ async def get_trajectory_file(request: web.Request) -> web.Response: if not safe_path.is_relative_to(saver.directory.resolve()): return web.json_response({"error": "invalid filename"}, status=400) limit = _safe_int_param(request, "limit", 100, hi=500) - entries = await saver.read_file(filename, limit=limit) + filters: dict = { + key: request.query[key] for key in ("channel_id", "user_id", "tool_name") + if request.query.get(key) + } + if request.query.get("errors_only", "").lower() in ("1", "true"): + filters["errors_only"] = True + entries = await saver.read_file(filename, limit=limit, **filters) return web.json_response({"entries": entries, "count": len(entries)}) @routes.get("/api/trajectories/message/{message_id}") diff --git a/tests/test_webui_selected_trace_filters.py b/tests/test_webui_selected_trace_filters.py new file mode 100644 index 000000000..673c10c4c --- /dev/null +++ b/tests/test_webui_selected_trace_filters.py @@ -0,0 +1,56 @@ +"""Selected daily files must limit matching traces, not unfiltered rows (#527).""" +import json +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.trajectories.saver import TrajectorySaver +from src.web.api.sessions_chat import register_trajectories + + +@pytest.mark.parametrize( + "filters", + [ + {"tool_name": "wanted"}, + {"errors_only": True}, + {"channel_id": "channel"}, + {"user_id": "user"}, + {"tool_name": "wanted", "errors_only": True, "channel_id": "channel", "user_id": "user"}, + ], +) +async def test_selected_file_filters_before_limit(tmp_path, filters): + match = {"message_id": "old-match", "tools_used": ["wanted"], "is_error": True, + "channel_id": "channel", "user_id": "user"} + unrelated = {"message_id": "new-unrelated", "tools_used": ["other"], "is_error": False, + "channel_id": "other", "user_id": "other"} + path = tmp_path / "2026-09-29.jsonl" + path.write_text("\n".join(json.dumps(row) for row in [match] + [unrelated] * 1000) + "\n") + saver = TrajectorySaver(directory=str(tmp_path)) + assert await saver.read_file(path.name, limit=1, **filters) == [match] + assert await saver.read_file(path.name, limit=1) == [unrelated] + assert await saver.read_file(path.name, limit=0, **filters) == [] + + +async def test_selected_file_endpoint_forwards_filters_and_retains_path_guards(tmp_path): + match = {"message_id": "match", "tools_used": ["wanted"], "is_error": True, + "channel_id": "channel", "user_id": "user"} + unrelated = {"message_id": "new", "tools_used": ["other"]} + (tmp_path / "2026-09-29.jsonl").write_text( + json.dumps(match) + "\n" + json.dumps(unrelated) + "\n" + ) + saver = TrajectorySaver(directory=str(tmp_path)) + routes = web.RouteTableDef() + register_trajectories(routes, SimpleNamespace(trajectory_saver=saver)) + app = web.Application() + app.add_routes(routes) + async with TestClient(TestServer(app)) as client: + response = await client.get("/api/trajectories/2026-09-29.jsonl", params={ + "limit": "1", "tool_name": "wanted", "errors_only": "true", + "channel_id": "channel", "user_id": "user", + }) + assert response.status == 200 + assert (await response.json())["entries"] == [match] + response = await client.get("/api/trajectories/bad..jsonl") + assert response.status == 400 diff --git a/ui/js/active-clock.js b/ui/js/active-clock.js new file mode 100644 index 000000000..f148a0263 --- /dev/null +++ b/ui/js/active-clock.js @@ -0,0 +1,19 @@ +import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; + +export function useActiveClock() { + const now = ref(Date.now()); + let timer = null; + function arm() { + now.value = Date.now(); + if (timer === null) timer = setInterval(() => { now.value = Date.now(); }, 1000); + } + function disarm() { + if (timer !== null) clearInterval(timer); + timer = null; + } + onMounted(arm); + onActivated(arm); + onDeactivated(disarm); + onUnmounted(disarm); + return now; +} diff --git a/ui/js/discord-identity.js b/ui/js/discord-identity.js index dc66c2e26..6968fb4bf 100644 --- a/ui/js/discord-identity.js +++ b/ui/js/discord-identity.js @@ -67,13 +67,18 @@ export const DiscordIdentity = { setup(props) { const fetched = ref(null); const member = computed(() => (props.members || []).find(item => String(item.id) === String(props.userId)) || null); - const identity = computed(() => member.value || fetched.value); + const identity = computed(() => member.value || (String(fetched.value?.id) === String(props.userId) ? fetched.value : null)); const displayFallback = computed(() => DISCORD_SNOWFLAKE.test(String(props.userId)) ? props.userId : (props.fallbackLabel || props.userId)); + let resolveGeneration = 0; async function resolve() { + const generation = ++resolveGeneration; + const userId = String(props.userId); fetched.value = null; - if (DISCORD_SNOWFLAKE.test(String(props.userId)) && !member.value) - fetched.value = await lookupDiscordIdentity(props.userId); + if (DISCORD_SNOWFLAKE.test(userId) && !member.value) { + const user = await lookupDiscordIdentity(userId); + if (generation === resolveGeneration && userId === String(props.userId)) fetched.value = user; + } } watch(() => [props.userId, props.members], resolve, { deep: true }); onMounted(resolve); diff --git a/ui/js/discord-user-combobox.js b/ui/js/discord-user-combobox.js index 310aec18e..8f15876a3 100644 --- a/ui/js/discord-user-combobox.js +++ b/ui/js/discord-user-combobox.js @@ -36,7 +36,7 @@ export const DiscordUserCombobox = { @keydown.enter.prevent="selectHighlighted" @keydown.escape="closeOptions" @blur="onBlur" /> + :disabled="!selectableValue" @mousedown.prevent @click="selectHighlighted">Add
    Arguments
    - + diff --git a/ui/js/pages/health.js b/ui/js/pages/health.js index 77c8040bb..f8596f427 100644 --- a/ui/js/pages/health.js +++ b/ui/js/pages/health.js @@ -5,6 +5,7 @@ import { api } from '../api.js'; import { formatTime } from '../utils.js'; import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; +import { useRequestOwner } from '../request-owner.js'; const STATUS_COLORS = { @@ -227,17 +228,24 @@ export default { return String(n); } + const ownHealth = useRequestOwner(() => { loading.value = false; refreshing.value = false; }); async function fetchHealth() { + const current = ownHealth(); refreshing.value = true; try { - data.value = await api.get('/api/health/components'); + const next = await api.get('/api/health/components'); + if (!current()) return; + data.value = next; error.value = null; hasData.value = true; } catch (e) { + if (!current()) return; error.value = e.message; } finally { + if (current()) { loading.value = false; refreshing.value = false; + } } } diff --git a/ui/js/pages/internals.js b/ui/js/pages/internals.js index 3b944a32e..4177e8f70 100644 --- a/ui/js/pages/internals.js +++ b/ui/js/pages/internals.js @@ -6,6 +6,7 @@ import { api } from '../api.js'; import { formatAgeSeconds } from '../utils.js'; import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; +import { useRequestOwner } from '../request-owner.js'; const STATUS_COLORS = { @@ -227,8 +228,11 @@ export default { ]); let timer = null; + const ownInternals = useRequestOwner(() => { loading.value = false; }); async function fetchAll() { + const current = ownInternals(); const results = await Promise.allSettled(endpoints.map(endpoint => api.get(endpoint.path))); + if (!current()) return; const val = (i) => results[i].status === 'fulfilled' ? results[i].value : null; startup.value = val(0) || {}; const sub = val(1); diff --git a/ui/js/pages/llm-config.js b/ui/js/pages/llm-config.js index f67b9cfc4..8fdbec89e 100644 --- a/ui/js/pages/llm-config.js +++ b/ui/js/pages/llm-config.js @@ -1493,22 +1493,29 @@ export default { if (p99 && budgetMs && p99 > budgetMs) warnings.push('p99 exceeds the agent iteration budget'); return warnings.join('; '); }; + let openRouterPendingGeneration = 0; async function prepareOpenRouterModel(model) { + const generation = ++openRouterPendingGeneration; openRouterPendingModel.value = model; + openRouterPendingEndpoints.value = []; openRouterPendingTag.value = compatibleForm.value.openrouter.model_pins?.[model.id] || ''; openRouterPendingLoading.value = true; try { const [author, ...tail] = model.id.split('/'); const result = await api.get(`/api/openrouter/models/${encodeURIComponent(author)}/${encodeURIComponent(tail.join('/'))}/endpoints`); + if (generation !== openRouterPendingGeneration) return; openRouterPendingEndpoints.value = result.endpoints || []; } catch (error) { + if (generation !== openRouterPendingGeneration) return; openRouterPendingEndpoints.value = []; showToast(error.message || 'Failed to load OpenRouter provider routes', 'error'); } finally { - openRouterPendingLoading.value = false; + if (generation === openRouterPendingGeneration) openRouterPendingLoading.value = false; } } function cancelOpenRouterPending() { + ++openRouterPendingGeneration; + openRouterPendingLoading.value = false; openRouterPendingModel.value = null; openRouterPendingTag.value = ''; openRouterPendingEndpoints.value = []; @@ -1863,8 +1870,9 @@ export default { } } + let mainModelSavePending = false; async function saveMainModel() { - if (savingMainModel) return; + if (savingMainModel) { mainModelSavePending = true; return; } savingMainModel = true; const submitted = modelSelection.value.main; try { @@ -1884,7 +1892,16 @@ export default { markClean('mainModel', submitted); showToast('Main model saved'); await fetchAll(); } catch (e) { showToast(e.message || 'Failed to save main model', 'error'); await fetchLLMStatus(); } - finally { savingMainModel = false; } + finally { + savingMainModel = false; + if (mainModelSavePending) { + mainModelSavePending = false; + await saveMainModel(); + } else { + // Adopt canonical state once the latest write has settled. + await fetchLLMStatus(); + } + } } async function saveMainCapability(value) { modelSelection.value.main_capability = value; diff --git a/ui/js/pages/logs.js b/ui/js/pages/logs.js index 56fcde4c2..730006ca4 100644 --- a/ui/js/pages/logs.js +++ b/ui/js/pages/logs.js @@ -5,6 +5,8 @@ */ import { api, ws } from '../api.js'; import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'; +import { RE2JS } from 're2js'; +import { useActiveClock } from '../active-clock.js'; import ToolOutput from '../tool-output.js'; import LogRecord from '../log-record.js'; import { appendLogEntry, groupLogEntries, parseLogEntry, serializeLogRecord } from '../log-records.js'; @@ -113,6 +115,7 @@ export default { title="Toggle regex filtering">.*
    {{ regexError }}
    +
    Linear-time RE2 syntax. Lookaround and backreferences are not supported.