commit 0d6252dd7d5cde7f9c36c727dc8032b70202a853 Author: Patrick Buckley Date: Mon Mar 2 00:24:29 2026 -0800 Initial commit — turnstone multi-node AI orchestration platform. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..4e9bd020 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +.env +*.db +.git/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.hypothesis/ diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..1d04abd4 --- /dev/null +++ b/.env.example @@ -0,0 +1,85 @@ +# ============================================================================= +# Turnstone Docker Compose — Environment Configuration +# Copy to .env and fill in your values: cp .env.example .env +# ============================================================================= + +# --------------------------------------------------------------------------- +# LLM Backend +# --------------------------------------------------------------------------- +# OpenAI-compatible API URL (vLLM, llama.cpp, OpenAI, etc.) +LLM_BASE_URL=http://host.docker.internal:8000/v1 + +# API key for the LLM backend ("dummy" for local servers without auth) +OPENAI_API_KEY=dummy + +# Tavily API key for web_search tool (optional) +TAVILY_API_KEY= + +# --------------------------------------------------------------------------- +# Redis +# --------------------------------------------------------------------------- +# Redis password (leave empty for no authentication) +REDIS_PASSWORD= + +# Host port for Redis +REDIS_PORT=6379 + +# --------------------------------------------------------------------------- +# Server +# --------------------------------------------------------------------------- +# Host port for the turnstone web UI +SERVER_PORT=8080 + +# Set to any non-empty value to auto-approve all tool calls +SKIP_PERMISSIONS= + +# --------------------------------------------------------------------------- +# Bridge +# --------------------------------------------------------------------------- +# Heartbeat TTL in seconds +HEARTBEAT_TTL=60 + +# Seconds to wait for external approval responses +APPROVAL_TIMEOUT=300 + +# --------------------------------------------------------------------------- +# Console (Cluster Dashboard) +# --------------------------------------------------------------------------- +# Host port for the cluster dashboard +CONSOLE_PORT=8090 + +# Seconds between node polling cycles +CONSOLE_POLL_INTERVAL=10 + +# --------------------------------------------------------------------------- +# Auth (optional) +# --------------------------------------------------------------------------- +# Set to "1" to require Bearer token authentication +TURNSTONE_AUTH_ENABLED= + +# Bearer token for server/bridge/console authentication +TURNSTONE_AUTH_TOKEN= + +# --------------------------------------------------------------------------- +# Simulator (used with: docker compose --profile sim up) +# --------------------------------------------------------------------------- +# Number of simulated nodes +SIM_NODES=100 + +# Scenario: steady, burst, node_failure, directed, lifecycle +SIM_SCENARIO=steady + +# Scenario duration in seconds +SIM_DURATION=60 + +# Messages per second (steady scenario) +SIM_MPS=5.0 + +# Log level +SIM_LOG_LEVEL=INFO + +# Random seed for reproducibility (leave empty for random) +SIM_SEED= + +# Path to write JSON metrics report (leave empty to skip) +SIM_METRICS_FILE= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..24a8e879 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2ffcf235 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.venv/ +venv/ +.env +*.so +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +*.db +.plan.md +.plan-*.md +.hypothesis/ diff --git a/CLA.md b/CLA.md new file mode 100644 index 00000000..bdbf2115 --- /dev/null +++ b/CLA.md @@ -0,0 +1,54 @@ +# Turnstone Contributor License Agreement + +Thank you for your interest in contributing to Turnstone. This Contributor +License Agreement ("Agreement") is between you ("Contributor") and Patrick +Buckley ("Maintainer"), and applies to all Contributions submitted to the +Turnstone project at https://github.com/turnstonelabs/turnstone. + +By submitting a Contribution, you agree to the following terms: + +## 1. Definitions + +"Contribution" means any original work of authorship, including modifications +or additions to existing work, that you submit to the project via pull request, +issue, or any other means. + +## 2. Grant of Copyright License + +You grant the Maintainer a perpetual, worldwide, non-exclusive, royalty-free, +irrevocable copyright license to reproduce, prepare derivative works of, +publicly display, publicly perform, sublicense, and distribute your +Contributions and any derivative works thereof under any license, including +proprietary licenses. + +## 3. Grant of Patent License + +You grant the Maintainer a perpetual, worldwide, non-exclusive, royalty-free, +irrevocable patent license to make, have made, use, offer to sell, sell, +import, and otherwise transfer your Contributions, where such license applies +only to patent claims licensable by you that are necessarily infringed by your +Contributions alone or in combination with the project. + +## 4. Representations + +You represent that: + +- Each Contribution is your original creation and you have the legal right to + grant the above licenses. +- Your Contribution does not violate any third party's intellectual property or + other rights. +- If your employer has rights to intellectual property that you create, you have + received permission to make the Contribution on behalf of that employer, or + your employer has waived such rights. + +## 5. No Obligation + +You understand that the decision to include your Contribution in the project is +entirely at the Maintainer's discretion. This Agreement does not obligate the +Maintainer to use or incorporate your Contribution. + +## Signing + +By submitting a pull request to the Turnstone repository, and commenting +"I have read the CLA and I agree to its terms," you indicate your acceptance +of this Agreement. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..db2e518e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing to Turnstone + +Thanks for your interest in contributing! Here's what you need to know. + +## Contributor License Agreement + +All contributors must agree to the [Contributor License Agreement](CLA.md) +before their pull request can be merged. When you open your first PR, the CLA +Assistant bot will ask you to sign by commenting on the PR. This is a one-time +process. + +The CLA allows us to distribute Turnstone under both open-source and commercial +licenses. Your contributions remain your own — you are granting a license, not +transferring ownership. + +## Getting Started + +1. Fork the repository +2. Create a branch for your change +3. Make your changes +4. Run the tests: `pytest` +5. Open a pull request + +## Development Setup + +``` +python -m venv .venv +source .venv/bin/activate +pip install -e ".[test]" +``` + +## Guidelines + +- Keep pull requests focused — one change per PR +- Add tests for new functionality +- Follow existing code style and patterns +- Update documentation if your change affects user-facing behavior + +## Reporting Issues + +Open an issue at https://github.com/turnstonelabs/turnstone/issues with: + +- What you expected to happen +- What actually happened +- Steps to reproduce +- Python version and OS + +## License + +By contributing, you agree that your contributions will be licensed under the +project's [Business Source License 1.1](LICENSE). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..0c57c901 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# ============================================================================= +# Turnstone — multi-stage Docker build +# Single image for all services: server, bridge, console, sim, eval +# ============================================================================= + +# ---------------------------------------------------------------------------- +# Stage 1: Builder — build the wheel +# ---------------------------------------------------------------------------- +FROM python:3.13-slim AS builder + +WORKDIR /build + +RUN pip install --no-cache-dir hatchling + +COPY pyproject.toml README.md LICENSE ./ +COPY turnstone/ turnstone/ + +RUN pip wheel --no-deps --wheel-dir /build/wheels . + +# ---------------------------------------------------------------------------- +# Stage 2: Runtime — slim image with the installed package +# ---------------------------------------------------------------------------- +FROM python:3.13-slim + +LABEL org.opencontainers.image.title="turnstone" \ + org.opencontainers.image.description="Multi-node AI orchestration platform" + +# Non-root user +RUN useradd --create-home --shell /bin/bash turnstone + +# Install the wheel with all optional extras (redis for mq/console/sim) +COPY --from=builder /build/wheels/*.whl /tmp/wheels/ +RUN pip install --no-cache-dir "$(ls /tmp/wheels/*.whl)[mq,console,sim]" \ + && rm -rf /tmp/wheels + +# Health check script (stdlib only, no pip deps needed) +COPY docker/healthcheck.py /usr/local/bin/healthcheck.py + +# Data directory — SQLite DB is created in CWD +WORKDIR /data +RUN chown turnstone:turnstone /data + +USER turnstone + +# Default command (overridden per service in compose.yaml) +CMD ["turnstone-server", "--host", "0.0.0.0", "--port", "8080"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..2e235953 --- /dev/null +++ b/LICENSE @@ -0,0 +1,62 @@ +License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +Parameters + +Licensor: Patrick Buckley +Licensed Work: Turnstone 0.2.0. The Licensed Work is (c) 2025-2026 Patrick Buckley. +Additional Use Grant: You may make production use of the Licensed Work, provided + your use does not include providing the Licensed Work to third + parties as a hosted or managed service, where the service + provides users with access to any substantial set of the + features or functionality of the Licensed Work. +Change Date: 2030-03-01 +Change License: Apache License, Version 2.0 + +For information about alternative licensing arrangements for the Licensed Work, +please contact buckleypm@gmail.com. + +Notice + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..f48a316d --- /dev/null +++ b/README.md @@ -0,0 +1,264 @@ +# Turnstone + +Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers, driven by message queues or interactive interfaces. + +Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) — a bird that flips rocks to expose what's hiding underneath. + +## What it does + +Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports. It runs as: + +- **Interactive sessions** — terminal CLI or browser UI with parallel workstreams +- **Queue-driven agents** — trigger workstreams via message queue, stream progress, approve or auto-approve tool use +- **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server +- **Cluster dashboard** — real-time view of all nodes, workstreams, and resource utilization +- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend + +``` +External System → Message Queue → Bridge (per node) → Turnstone Server → LLM + Tools + ↓ + Pub/Sub → Progress Events → External System + ↓ + turnstone-console → Cluster Dashboard (browser) +``` + +## Quickstart + +### Interactive (terminal) + +```bash +pip install turnstone +turnstone --base-url http://localhost:8000/v1 +``` + +### Interactive (browser) + +```bash +turnstone-server --port 8080 --base-url http://localhost:8000/v1 +``` + +### Queue-driven (programmatic) + +```bash +pip install turnstone[mq] +turnstone-bridge --server-url http://localhost:8080 --redis-host localhost +``` + +```python +from turnstone.mq import TurnstoneClient + +with TurnstoneClient() as client: + # Generic — any available node picks it up + result = client.send_and_wait("Analyze the error logs", auto_approve=True) + print(result.content) + + # Directed — must run on a specific server + result = client.send_and_wait( + "Check disk I/O on this server", + target_node="server-12", + auto_approve=True, + ) +``` + +### Cluster dashboard + +```bash +pip install turnstone[console] +turnstone-console --redis-host localhost --port 8090 +``` + +Then open `http://localhost:8090` for the cluster-wide dashboard. + +### Docker + +```bash +cp .env.example .env # edit LLM_BASE_URL, OPENAI_API_KEY, etc. +docker compose up # starts redis + server + bridge + console +``` + +Console dashboard at http://localhost:8090. See [docs/docker.md](docs/docker.md) for configuration, scaling, and profiles. + +### Simulator + +Test the multi-node stack at scale without an LLM backend: + +```bash +docker compose --profile sim up redis console sim +``` + +Or standalone: + +```bash +pip install turnstone[sim] +turnstone-sim --nodes 100 --scenario steady --duration 60 --mps 10 +``` + +See [docs/simulator.md](docs/simulator.md) for scenarios, CLI reference, and metrics. + +All frontends connect to any OpenAI-compatible API (vLLM, NVIDIA NIM/NGC, llama.cpp, OpenAI, etc.) and auto-detect the model. + +## Architecture + +``` +turnstone/ +├── core/ # UI-agnostic engine +│ ├── session.py # ChatSession — multi-turn loop, tool dispatch, agents +│ ├── tools.py # Tool definitions (auto-loaded from JSON) +│ ├── workstream.py # WorkstreamManager — parallel independent sessions +│ ├── config.py # Unified TOML config (~/.config/turnstone/config.toml) +│ ├── memory.py # SQLite persistence (memories, conversations, FTS5) +│ ├── metrics.py # Prometheus-compatible metrics collector +│ ├── edit.py # File editing (fuzzy match, indentation) +│ ├── safety.py # Path validation, sandbox checks +│ ├── sandbox.py # Command sandboxing +│ └── web.py # Web fetch/search helpers +├── mq/ # Message queue integration +│ ├── protocol.py # Typed message dataclasses (JSON serialization) +│ ├── broker.py # Abstract MessageBroker + RedisBroker +│ ├── bridge.py # Bridge service (queue ↔ HTTP API, multi-node routing) +│ └── client.py # TurnstoneClient — Python API for external systems +├── console/ # Cluster dashboard +│ ├── collector.py # ClusterCollector — aggregates all nodes via Redis + HTTP +│ ├── server.py # Dashboard HTTP server + SSE +│ └── static/ # Cluster dashboard web UI +├── tools/ # Tool schemas (one JSON file per tool) +├── ui/ # Frontend assets and terminal rendering +│ └── static/ # Web UI (HTML, CSS, JS) +├── sim/ # Cluster simulator +│ ├── cluster.py # SimCluster — orchestrates N nodes + dispatchers +│ ├── node.py # SimNode + SimWorkstream — protocol-compatible node +│ ├── engine.py # LLM + tool execution simulation +│ ├── scenario.py # 5 workload scenarios (steady, burst, node_failure, …) +│ ├── metrics.py # Latency, throughput, utilization collection +│ └── cli.py # CLI entry point (turnstone-sim) +├── cli.py # Terminal frontend (+ /cluster commands for console) +├── server.py # Web frontend (HTTP + SSE) +└── eval.py # Evaluation and prompt optimization harness +docs/ +├── architecture.md # System architecture and threading model +├── api-reference.md # Web server API and SSE event reference +├── console.md # Cluster dashboard service (turnstone-console) +├── docker.md # Docker Compose deployment and configuration +├── simulator.md # Cluster simulator usage and scenarios +├── tools.md # Tool schemas, execution pipeline, approval flow +└── eval.md # Evaluation harness internals +``` + +## Multi-node routing + +Each Turnstone server runs a bridge process. Bridges share a Redis instance for coordination: + +| Redis Key | Purpose | +|-----------|---------| +| `turnstone:inbound` | Shared work queue — generic tasks, any node | +| `turnstone:inbound:{node_id}` | Per-node queue — directed tasks | +| `turnstone:ws:{ws_id}` | Workstream ownership — auto-routes follow-ups | +| `turnstone:node:{node_id}` | Node heartbeat + metadata for discovery | +| `turnstone:events:{ws_id}` | Per-workstream event pub/sub | +| `turnstone:events:global` | Global event pub/sub | +| `turnstone:events:cluster` | Cluster-wide state changes (for turnstone-console) | + +**Routing rules:** +1. Message has `target_node` → routes to that node's queue +2. Message has `ws_id` → looks up owner, routes to owning node +3. Neither → shared queue, next available bridge picks it up + +Bridges BLPOP from their per-node queue (priority) then the shared queue. Directed work always takes precedence. + +## Tools + +14 built-in tools, 2 agent tools: + +| Tool | Description | Auto-approved | +|------|-------------|:---:| +| `bash` | Execute shell commands | | +| `read_file` | Read file contents | yes | +| `write_file` | Write/create files | | +| `edit_file` | Fuzzy-match file editing | | +| `search` | Search files by name/content | yes | +| `math` | Sandboxed Python evaluation | | +| `man` | Read man pages | yes | +| `web_fetch` | Fetch URL content | | +| `web_search` | Search via Tavily API | | +| `remember` | Save persistent facts | yes | +| `recall` | Search memories and history | yes | +| `forget` | Remove a memory | yes | +| `task` | Spawn autonomous sub-agent | | +| `plan` | Explore codebase, write .plan.md | | + +## Configuration + +All entry points read `~/.config/turnstone/config.toml`. CLI flags override config values. + +```toml +[api] +base_url = "http://localhost:8000/v1" +api_key = "" +tavily_key = "" + +[model] +name = "" # empty = auto-detect +temperature = 0.5 +reasoning_effort = "medium" + +[tools] +timeout = 30 +skip_permissions = false + +[server] +host = "0.0.0.0" +port = 8080 + +[redis] +host = "localhost" +port = 6379 +password = "" + +[bridge] +server_url = "http://localhost:8080" +node_id = "" # empty = hostname_xxxx + +[console] +host = "0.0.0.0" +port = 8090 +url = "http://localhost:8090" # used by CLI /cluster commands +poll_interval = 10 +``` + +Precedence: CLI args > environment variables > config.toml > defaults. + +## Workstreams + +Parallel independent conversations, each with its own session and state: + +| Symbol | State | Meaning | +|--------|-------|---------| +| `·` | idle | Waiting for input | +| `◌` | thinking | Model is generating | +| `▸` | running | Tool execution in progress | +| `◆` | attention | Waiting for approval | +| `✖` | error | Something went wrong | + +Idle workstreams are automatically cleaned up after 2 hours (configurable). In multi-node deployments, workstream ownership is tracked in Redis — follow-up messages auto-route to the owning node. + +## Monitoring + +`/metrics` endpoint exposes Prometheus-format metrics: + +- `turnstone_tokens_total{direction}` — prompt/completion token counters +- `turnstone_tool_calls_total{tool}` — per-tool invocation counts +- `turnstone_workstream_context_ratio{ws_id}` — per-workstream context utilization +- `turnstone_http_request_duration_seconds` — request latency histogram +- `turnstone_workstreams_by_state{state}` — workstream state gauges + +Per-workstream metrics are labeled by `ws_id` (bounded to 10 max workstreams). + +## Requirements + +- Python 3.11+ +- An OpenAI-compatible API endpoint ([vLLM](https://github.com/vllm-project/vllm), [NVIDIA NIM](https://build.nvidia.com/), [llama.cpp](https://github.com/ggml-org/llama.cpp), etc.) +- Redis (for message queue bridge — `pip install turnstone[mq]`) + +## License + +[Business Source License 1.1](LICENSE) — free for all use except hosting as a managed service. Converts to Apache 2.0 on 2030-03-01. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..bb6d330b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,194 @@ +# ============================================================================= +# Turnstone Docker Compose Stack +# +# Usage: +# Full stack: docker compose up +# With simulator: docker compose --profile sim up +# Sim only: docker compose --profile sim up redis console sim +# Scale bridges: docker compose up --scale bridge=3 +# ============================================================================= + +name: turnstone + +networks: + turnstone-net: + driver: bridge + +volumes: + redis-data: + turnstone-data: + +services: + # ------------------------------------------------------------------- + # Redis — message broker, pub/sub, node registry + # ------------------------------------------------------------------- + redis: + image: redis:7-alpine + command: + - sh + - -c + - >- + redis-server + --save 60 1 + --loglevel warning + $${REDIS_PASSWORD:+--requirepass $$REDIS_PASSWORD} + ports: + - "${REDIS_PORT:-6379}:6379" + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + volumes: + - redis-data:/data + networks: + - turnstone-net + healthcheck: + test: + - CMD-SHELL + - redis-cli $${REDIS_PASSWORD:+-a $$REDIS_PASSWORD} ping | grep -q PONG + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s + restart: unless-stopped + + # ------------------------------------------------------------------- + # turnstone-server — Web UI + chat workstreams + LLM interaction + # ------------------------------------------------------------------- + server: + build: + context: . + dockerfile: Dockerfile + command: + - sh + - -c + - >- + turnstone-server + --host 0.0.0.0 + --port 8080 + --base-url "$${LLM_BASE_URL}" + --api-key "$${OPENAI_API_KEY}" + $${SKIP_PERMISSIONS:+--skip-permissions} + ports: + - "${SERVER_PORT:-8080}:8080" + volumes: + - turnstone-data:/data + environment: + - LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1} + - OPENAI_API_KEY=${OPENAI_API_KEY:-dummy} + - TAVILY_API_KEY=${TAVILY_API_KEY:-} + - SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-} + - TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-} + - TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-} + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - turnstone-net + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + restart: unless-stopped + + # ------------------------------------------------------------------- + # turnstone-bridge — Redis <-> HTTP bridge for multi-node routing + # Node ID auto-generated from container hostname (no --node-id needed) + # ------------------------------------------------------------------- + bridge: + build: + context: . + dockerfile: Dockerfile + command: + - turnstone-bridge + - --server-url=http://server:8080 + - --redis-host=redis + - --redis-port=6379 + - --heartbeat-ttl=${HEARTBEAT_TTL:-60} + - --approval-timeout=${APPROVAL_TIMEOUT:-300} + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-} + networks: + - turnstone-net + depends_on: + server: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + # ------------------------------------------------------------------- + # turnstone-console — Cluster dashboard + # ------------------------------------------------------------------- + console: + build: + context: . + dockerfile: Dockerfile + command: + - turnstone-console + - --host=0.0.0.0 + - --port=8090 + - --redis-host=redis + - --redis-port=6379 + - --poll-interval=${CONSOLE_POLL_INTERVAL:-10} + ports: + - "${CONSOLE_PORT:-8090}:8090" + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-} + - TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-} + networks: + - turnstone-net + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + restart: unless-stopped + + # ------------------------------------------------------------------- + # turnstone-sim — Multi-node cluster simulator (no LLM needed) + # Start with: docker compose --profile sim up + # ------------------------------------------------------------------- + sim: + build: + context: . + dockerfile: Dockerfile + profiles: + - sim + command: + - sh + - -c + - >- + turnstone-sim + --nodes "$${SIM_NODES}" + --scenario "$${SIM_SCENARIO}" + --duration "$${SIM_DURATION}" + --mps "$${SIM_MPS}" + --redis-host redis + --redis-port 6379 + --log-level "$${SIM_LOG_LEVEL}" + $${SIM_SEED:+--seed $$SIM_SEED} + $${SIM_METRICS_FILE:+--metrics-file $$SIM_METRICS_FILE} + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - SIM_NODES=${SIM_NODES:-100} + - SIM_SCENARIO=${SIM_SCENARIO:-steady} + - SIM_DURATION=${SIM_DURATION:-60} + - SIM_MPS=${SIM_MPS:-5.0} + - SIM_LOG_LEVEL=${SIM_LOG_LEVEL:-INFO} + - SIM_SEED=${SIM_SEED:-} + - SIM_METRICS_FILE=${SIM_METRICS_FILE:-} + networks: + - turnstone-net + depends_on: + redis: + condition: service_healthy + restart: "no" diff --git a/demo.svg b/demo.svg new file mode 100644 index 00000000..db33d64a --- /dev/null +++ b/demo.svg @@ -0,0 +1,221 @@ + + + + + + + + + + + turnstone — console + + + + + turnstone console + 6 nodes · 10 workstreams + + + + + + + 3 + ▸ RUN + + + + + 2 + ◌ THINK + + + + + 1 + ◆ ATTN + + + + + 0 + ✖ ERR + + + + + 4 + · IDLE + + + + 197k tokens · 42 tool calls + + + NODES + + + + + + + NODE + WS + RUN + ATTN + TOKENS + LOAD + + + + + + + + + + + + db-west-04 + 3 + 1 + 0 + 57.6k + + + + 30% + + + + + + + + api-east-01 + 3 + 0 + 1 + 109k + + + + 30% + + + + + + + + sre-node-03 + 2 + 1 + 0 + 64.4k + + + + 20% + + + + + + + + analytics-02 + 1 + 0 + 0 + 18.3k + + + + 10% + + + + + + + + data-ops-05 + 1 + 0 + 0 + 8.7k + + + + 10% + + + + + + + + ml-gpu-07 + 0 + 0 + 0 + 0 + + + 0% + + + + + + + + + + + db-west-04 + + + api-east-01 + + + sre-node-03 + + + analytics-02 + + + data-ops-05 + + + ml-gpu-07 + + 258k tokens · 42 calls · 12m + + + + + + diff --git a/docker/healthcheck.py b/docker/healthcheck.py new file mode 100644 index 00000000..081fc232 --- /dev/null +++ b/docker/healthcheck.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Health check for turnstone containers. + +Usage: healthcheck.py +Exit 0 if the endpoint returns {"status": "ok"}, exit 1 otherwise. +Uses only stdlib — no pip dependencies required. +""" + +import json +import sys +import urllib.request + + +def main() -> None: + if len(sys.argv) != 2: + print("Usage: healthcheck.py ", file=sys.stderr) + sys.exit(1) + + url = sys.argv[1] + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read().decode()) + if data.get("status") == "ok": + sys.exit(0) + print(f"Unhealthy: {data}", file=sys.stderr) + sys.exit(1) + except Exception as exc: + print(f"Health check failed: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 00000000..e8379dc4 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,702 @@ +# turnstone Web Server API Reference + +## Overview + +`turnstone-server` exposes a browser-based chat UI backed by a Python stdlib HTTP +server (`socketserver.ThreadingMixIn` + `http.server.HTTPServer`). The server +uses **Server-Sent Events (SSE)** for real-time streaming and **HTTP POST** for +user actions. + +All API responses use `Content-Type: application/json` unless otherwise noted. +CORS headers (`Access-Control-Allow-Origin: *`) are included on every response. + +The server supports multiple concurrent **workstreams** (tabs), each backed by +an independent `ChatSession` and event queue. + +--- + +## Endpoints + +### `GET /` + +Serves the embedded single-page application (HTML, CSS, and JavaScript inlined +in a single document). The SPA connects to the SSE and POST endpoints listed +below. + +**Response:** `text/html; charset=utf-8` + +--- + +### `GET /api/events?ws_id=` + +Opens a Server-Sent Events stream scoped to a single workstream. The connection +remains open indefinitely; the server pushes events as they occur. + +**Query parameters:** + +| Parameter | Type | Required | Description | +|-----------|--------|----------|----------------------------| +| `ws_id` | string | yes | Workstream identifier | + +**Error:** Returns `404` with `{"error": "Unknown workstream"}` if `ws_id` is +not recognized. + +#### Connection lifecycle + +1. **`connected`** -- sent immediately on connect. + +```json +{ + "type": "connected", + "model": "kappa_20b_131k", + "skip_permissions": false +} +``` + +`skip_permissions` reflects the workstream's current auto-approve state. It is +`true` if the server was started with `--skip-permissions` or if the user chose +"Always approve" via the approval prompt during the session. + +2. **`history`** -- replays the full conversation history so the client can + rebuild its UI. + +```json +{ + "type": "history", + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!", "tool_calls": null}, + {"role": "tool", "content": "..."} + ] +} +``` + +Each message in the `messages` array has: + +| Field | Type | Description | +|--------------|-------------------|-----------------------------------------------| +| `role` | string | `"user"`, `"assistant"`, or `"tool"` | +| `content` | string or null | Text content of the message | +| `tool_calls` | array or null | Present only on assistant messages with calls | + +Each entry in `tool_calls`: + +| Field | Type | Description | +|-------------|--------|------------------------------------| +| `name` | string | Function name (e.g. `"bash"`) | +| `arguments` | string | JSON-encoded argument string | + +#### Streaming events + +After the initial `connected` and `history` frames, the server streams +real-time events as the model generates a response: + +**`thinking_start`** -- the model has begun generating (shown as a spinner). + +```json +{"type": "thinking_start"} +``` + +**`thinking_stop`** -- the spinner phase is over. + +```json +{"type": "thinking_stop"} +``` + +**`reasoning`** -- a chunk of chain-of-thought reasoning text. + +```json +{"type": "reasoning", "text": "Let me think about this..."} +``` + +**`content`** -- a chunk of the assistant's visible reply. + +```json +{"type": "content", "text": "Here is the answer: "} +``` + +**`stream_end`** -- the model has finished generating. The client should +finalize any in-progress assistant message. + +```json +{"type": "stream_end"} +``` + +**`tool_info`** -- one or more tool calls that were auto-approved (no user +action required). + +```json +{ + "type": "tool_info", + "items": [ + { + "header": "bash: ls -la", + "preview": "", + "func_name": "bash", + "approval_label": "bash", + "needs_approval": false, + "error": null + } + ] +} +``` + +**`approve_request`** -- one or more tool calls that require user approval. The +client must respond via `POST /api/approve`. + +```json +{ + "type": "approve_request", + "items": [ + { + "header": "bash: rm -rf /tmp/build", + "preview": "", + "func_name": "bash", + "approval_label": "bash", + "needs_approval": true, + "error": null + } + ] +} +``` + +Each item in `items` (shared by `tool_info` and `approve_request`): + +| Field | Type | Description | +|------------------|-------------|--------------------------------------------------| +| `header` | string | Human-readable header line for the tool call | +| `preview` | string | Diff or argument preview (may be empty) | +| `func_name` | string | Function name (e.g. `"bash"`, `"edit_file"`) | +| `approval_label` | string | Display label for the approval prompt | +| `needs_approval` | bool | Whether this call requires explicit approval | +| `error` | string/null | Error description if the call was malformed | + +**`tool_result`** -- output from a completed tool execution. + +```json +{"type": "tool_result", "name": "bash", "output": "file1.py\nfile2.py\n"} +``` + +**`status`** -- token usage statistics, sent after each model turn. + +```json +{ + "type": "status", + "prompt_tokens": 1024, + "completion_tokens": 256, + "total_tokens": 1280, + "context_window": 131072, + "pct": 1.0, + "effort": "medium" +} +``` + +| Field | Type | Description | +|---------------------|--------|----------------------------------------------| +| `prompt_tokens` | int | Tokens in the prompt | +| `completion_tokens` | int | Tokens generated by the model | +| `total_tokens` | int | `prompt_tokens + completion_tokens` | +| `context_window` | int | Total context window size in tokens | +| `pct` | float | Percentage of context window used | +| `effort` | string | Reasoning effort level (`low`/`medium`/`high`) | + +**`plan_review`** -- the model is proposing a plan and wants feedback. The +client must respond via `POST /api/plan`. + +```json +{"type": "plan_review", "content": "Step 1: ...\nStep 2: ..."} +``` + +**`info`** -- an informational message (e.g. command output). + +```json +{"type": "info", "message": "Session cleared."} +``` + +**`error`** -- an error message. + +```json +{"type": "error", "message": "Error: connection timed out"} +``` + +**`busy_error`** -- sent when a new message arrives while the model is already +processing. + +```json +{"type": "busy_error", "message": "Already processing a request. Please wait."} +``` + +**`clear_ui`** -- instructs the client to clear all displayed messages (sent +after `/clear` or `/new` commands). + +```json +{"type": "clear_ui"} +``` + +#### Keepalive + +The server sends an SSE comment every 5 seconds when no events are pending: + +``` +: keepalive + +``` + +This prevents proxies and browsers from closing the connection due to +inactivity. + +#### Generation mechanism + +Each new SSE connection to a workstream increments an internal +`_sse_generation` counter. The previous SSE handler detects the generation +mismatch and exits its event loop, ensuring only one active SSE connection per +workstream at a time. The event queue is drained of stale events before the new +connection begins streaming. + +--- + +### `GET /api/events/global` + +Opens a Server-Sent Events stream that broadcasts state-change events across +all workstreams. This is used by the tab bar to display per-workstream activity +indicators. + +**Events:** + +```json +{"type": "ws_state", "ws_id": "abc123", "state": "thinking"} +``` + +| Field | Type | Description | +|---------|--------|--------------------------| +| `ws_id` | string | Workstream identifier | +| `state` | string | Current workstream state | + +Possible `state` values: + +| State | Description | +|-------------|-------------------------------------------------| +| `idle` | No active processing | +| `thinking` | Model is generating a response | +| `running` | Tool execution in progress | +| `attention` | Waiting for user input (approval or plan review)| +| `error` | An error occurred | + +**Fan-out pattern:** Each connected client receives its own bounded queue +(`maxsize=500`). A dedicated fan-out thread reads from the shared global queue +and copies each event to every client queue. If a client queue is full, the +event is silently dropped for that client. + +**Keepalive:** Same as `/api/events` -- an SSE comment every 5 seconds. + +--- + +### `GET /api/workstreams` + +Returns a list of all active workstreams. + +**Response:** + +```json +{ + "workstreams": [ + {"id": "abc123", "name": "default", "state": "idle", "session_id": "a1b2c3d4e5f6"}, + {"id": "def456", "name": "hacker-news", "state": "thinking", "session_id": "c5d6e7f8a9b0"} + ] +} +``` + +Each workstream object: + +| Field | Type | Description | +|--------------|-------------|--------------------------------------------------------| +| `id` | string | Unique workstream routing identifier | +| `name` | string | Display name (alias if set, otherwise `ws-xxxx`) | +| `state` | string | Current state (see state values above) | +| `session_id` | string/null | Session ID of the workstream's `ChatSession`, used for deduplication against `/api/sessions` | + +--- + +### `GET /api/sessions` + +Returns a list of saved sessions from the database, ordered by most recently +updated. + +**Response:** + +```json +{ + "sessions": [ + { + "session_id": "a1b2c3d4e5f6", + "alias": "refactor", + "title": "JWT Authentication Refactor", + "created": "2026-03-01 10:00:00", + "updated": "2026-03-01 11:30:00", + "message_count": 42 + } + ] +} +``` + +Each session object: + +| Field | Type | Description | +|-----------------|-------------|--------------------------------------------| +| `session_id` | string | Unique 12-char hex session identifier | +| `alias` | string/null | User-assigned short name | +| `title` | string/null | LLM-generated title | +| `created` | string | ISO timestamp of session creation | +| `updated` | string | ISO timestamp of last message | +| `message_count` | int | Number of messages in the session | + +--- + +### `POST /api/send` + +Sends a user message to a workstream. Spawns a daemon worker thread that calls +`session.send()` and streams results back via the SSE channel. + +**Request body:** + +```json +{"message": "Explain how the server works", "ws_id": "abc123"} +``` + +| Field | Type | Required | Description | +|-----------|--------|----------|-------------------------| +| `message` | string | yes | The user's message text | +| `ws_id` | string | yes | Target workstream ID | + +**Response (success):** + +```json +{"status": "ok"} +``` + +**Response (busy):** Returned if the workstream's worker thread is still alive +from a previous request. Also pushes a `busy_error` event to the SSE stream. + +```json +{"status": "busy"} +``` + +**Error responses:** + +| Status | Body | Condition | +|--------|------------------------------------|------------------------| +| 400 | `{"error": "Empty message"}` | Message is empty | +| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found | + +--- + +### `POST /api/approve` + +Responds to a tool approval request. The SSE stream must have previously sent +an `approve_request` event for the given workstream. + +**Request body:** + +```json +{"approved": true, "feedback": null, "always": false, "ws_id": "abc123"} +``` + +| Field | Type | Required | Description | +|------------|-------------|----------|--------------------------------------------------| +| `approved` | bool | yes | `true` to approve, `false` to deny | +| `feedback` | string/null | no | Optional feedback text (sent as denial reason) | +| `always` | bool | no | If `true` and `approved`, enables auto-approve | +| `ws_id` | string | yes | Target workstream ID | + +When `always` is `true` and `approved` is `true`, the workstream's WebUI +instance sets `auto_approve = True`, causing all subsequent tool calls to be +automatically approved without prompting. + +**Response:** + +```json +{"status": "ok"} +``` + +**Error:** `404` with `{"error": "Unknown workstream"}` if `ws_id` is invalid. + +--- + +### `POST /api/plan` + +Responds to a plan review dialog. The SSE stream must have previously sent a +`plan_review` event for the given workstream. + +**Request body:** + +```json +{"feedback": "", "ws_id": "abc123"} +``` + +| Field | Type | Required | Description | +|------------|--------|----------|---------------------------------------------------------| +| `feedback` | string | yes | Feedback text; empty string means approval | +| `ws_id` | string | yes | Target workstream ID | + +To approve the plan, send an empty string for `feedback`. To reject or request +changes, send a non-empty feedback string (e.g. `"reject"` or specific +revision instructions). + +**Response:** + +```json +{"status": "ok"} +``` + +**Error:** `404` with `{"error": "Unknown workstream"}` if `ws_id` is invalid. + +--- + +### `POST /api/command` + +Executes a slash command in the given workstream. + +**Request body:** + +```json +{"command": "/clear", "ws_id": "abc123"} +``` + +| Field | Type | Required | Description | +|-----------|--------|----------|------------------------------------| +| `command` | string | yes | The slash command (e.g. `/clear`) | +| `ws_id` | string | yes | Target workstream ID | + +If the command is `/clear` or `/new`, the server pushes a `clear_ui` SSE event +to instruct the client to reset its message display. If the command is +`/resume`, the server pushes `clear_ui` followed by a `history` event +containing the resumed session's messages. + +**Response:** + +```json +{"status": "ok"} +``` + +**Error responses:** + +| Status | Body | Condition | +|--------|------------------------------------|----------------------| +| 400 | `{"error": "Empty command"}` | Command is empty | +| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found | + +--- + +### `POST /api/workstreams/new` + +Creates a new workstream. The server supports up to 10 concurrent workstreams. + +**Request body:** + +```json +{} +``` + +No fields are required. The body can be empty or an empty JSON object. + +**Response (success):** + +```json +{"ws_id": "ghi789", "name": "ws-3"} +``` + +| Field | Type | Description | +|---------|--------|------------------------------------| +| `ws_id` | string | Unique ID of the new workstream | +| `name` | string | Auto-generated workstream name | + +**Error (limit reached):** + +```json +{"error": "Maximum of 10 workstreams reached"} +``` + +Status code: `400` + +--- + +### `POST /api/workstreams/close` + +Closes and removes a workstream. The last remaining workstream cannot be +closed. + +**Request body:** + +```json +{"ws_id": "abc123"} +``` + +| Field | Type | Required | Description | +|---------|--------|----------|---------------------------| +| `ws_id` | string | yes | Workstream ID to close | + +**Response (success):** + +```json +{"status": "ok"} +``` + +**Error (last workstream):** + +```json +{"error": "Cannot close last workstream"} +``` + +Status code: `400` + +--- + +### `OPTIONS` (any path) + +Handles CORS preflight requests. + +**Response headers:** + +``` +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: GET, POST, OPTIONS +Access-Control-Allow-Headers: Content-Type +``` + +Status code: `200` with an empty body. + +--- + +## Error Handling + +| Condition | Behavior | +|------------------------------------|------------------------------------------------------------| +| Malformed or unparseable JSON body | Treated as an empty dict `{}`; missing fields use defaults | +| Unknown `ws_id` | `404` with `{"error": "Unknown workstream"}` | +| Unknown path (GET or POST) | `404` with plain-text body `Not found` | +| Empty `message` on `/api/send` | `400` with `{"error": "Empty message"}` | +| Empty `command` on `/api/command` | `400` with `{"error": "Empty command"}` | + +--- + +## SSE Reconnection + +The embedded JavaScript client implements exponential backoff for SSE +reconnection: + +| Parameter | Value | +|--------------------|-------------------------------------------| +| Base delay | 1 second | +| Backoff multiplier | 2x on each consecutive failure | +| Maximum delay | 30 seconds | +| Reset | Delay resets to 1 second on first success | + +On reconnect, the server replays the full conversation history via the +`history` event, so the client can rebuild its UI state without data loss. The +same reconnection strategy applies to both the per-workstream SSE stream +(`/api/events`) and the global state stream (`/api/events/global`). + +--- + +## Observability + +### `GET /health` + +Returns server health status. Always returns `200 OK` while the server process +is running. Suitable for load-balancer health checks and Kubernetes liveness +probes. + +**Response:** `application/json` + +```json +{ + "status": "ok", + "version": "0.2.0", + "uptime_seconds": 3614.72, + "model": "llama-3.1-70b-instruct", + "workstreams": { + "total": 2, + "idle": 1, + "thinking": 1, + "running": 0, + "attention": 0, + "error": 0 + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `status` | string | Always `"ok"` while server is running | +| `version` | string | turnstone server version | +| `uptime_seconds` | number | Seconds since the server process started | +| `model` | string | Model name detected or configured at startup | +| `workstreams.total` | integer | Total active workstreams | +| `workstreams.idle` | integer | Workstreams waiting for user input | +| `workstreams.thinking` | integer | Workstreams with LLM currently streaming | +| `workstreams.running` | integer | Workstreams executing tools | +| `workstreams.attention` | integer | Workstreams blocked on approval or plan review | +| `workstreams.error` | integer | Workstreams in error state | + +--- + +### `GET /metrics` + +Returns operational metrics in **Prometheus text exposition format v0.0.4**. +Compatible with Prometheus `scrape_configs`, VictoriaMetrics, Grafana Agent, +and any other OpenMetrics-compatible collector. + +**Response:** `text/plain; version=0.0.4; charset=utf-8` + +#### Prometheus scrape config example + +```yaml +scrape_configs: + - job_name: turnstone + static_configs: + - targets: ["localhost:8080"] + metrics_path: /metrics +``` + +#### Metrics reference + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `turnstone_build_info` | gauge | `version`, `model` | Always 1; carries version/model as labels | +| `turnstone_uptime_seconds` | gauge | — | Seconds since server start | +| `turnstone_workstreams_active_total` | gauge | — | Number of active workstreams | +| `turnstone_workstreams_by_state` | gauge | `state` | Workstream count per state (`idle`, `thinking`, `running`, `attention`, `error`) | +| `turnstone_http_requests_total` | counter | `method`, `endpoint`, `status_code` | Total HTTP requests handled | +| `turnstone_http_request_duration_seconds` | histogram | `method`, `endpoint` | Request latency distribution (11 buckets: 5ms–10s) | +| `turnstone_messages_sent_total` | counter | — | User messages dispatched to the AI | +| `turnstone_tokens_total` | counter | `type` | Tokens consumed (`type="prompt"` or `type="completion"`) | +| `turnstone_tool_calls_total` | counter | `tool` | Tool executions by name (e.g. `tool="bash"`) | +| `turnstone_errors_total` | counter | — | Errors reported by workstreams | +| `turnstone_context_window_used_ratio` | gauge | — | Last known fraction of context window in use (0.0–1.0) | + +#### Example output + +``` +# HELP turnstone_build_info Server version and model info +# TYPE turnstone_build_info gauge +turnstone_build_info{version="0.2.0",model="llama-3.1-70b-instruct"} 1 +# HELP turnstone_uptime_seconds Server uptime in seconds +# TYPE turnstone_uptime_seconds gauge +turnstone_uptime_seconds 3614.72 +# HELP turnstone_workstreams_active_total Number of active workstreams +# TYPE turnstone_workstreams_active_total gauge +turnstone_workstreams_active_total 1 +# HELP turnstone_http_requests_total Total HTTP requests handled +# TYPE turnstone_http_requests_total counter +turnstone_http_requests_total{method="GET",endpoint="/health",status_code="200"} 42 +turnstone_http_requests_total{method="GET",endpoint="/metrics",status_code="200"} 7 +turnstone_http_requests_total{method="POST",endpoint="/api/send",status_code="200"} 18 +# HELP turnstone_tokens_total Total tokens consumed +# TYPE turnstone_tokens_total counter +turnstone_tokens_total{type="prompt"} 84320 +turnstone_tokens_total{type="completion"} 12150 +# HELP turnstone_tool_calls_total Total tool executions by name +# TYPE turnstone_tool_calls_total counter +turnstone_tool_calls_total{tool="bash"} 7 +turnstone_tool_calls_total{tool="read_file"} 3 +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..3decd973 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,786 @@ +# Turnstone Architecture + +Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent +memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) and +gives the model 14 tools for reading, writing, searching, planning, and +executing code. + +The core design principle is a **UI-agnostic engine with pluggable frontends**. +The engine (`ChatSession`) drives the conversation loop -- streaming, tool +dispatch, retry, compaction -- while every user-facing interaction is delegated +through the `SessionUI` protocol. Any frontend implements that protocol and +plugs in. + +## Entry Points + +| Command | Module | Frontend | Purpose | +|---------|--------|----------|---------| +| `turnstone` | `turnstone.cli` | `TerminalUI` | Interactive terminal REPL | +| `turnstone-server` | `turnstone.server` | `WebUI` | Browser-based chat (HTTP + SSE) | +| `turnstone-bridge` | `turnstone.mq.bridge` | Bridge | Message queue ↔ HTTP API bridge | +| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) | +| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization | + +--- + +## Module Map + +``` +turnstone/ + cli.py Terminal frontend (TerminalUI, WorkstreamTerminalUI, REPL) + server.py Web frontend (WebUI, HTTP handler, static-file serving) + eval.py Evaluation harness (HeadlessSession, scoring, prompt optimization) + core/ + session.py ChatSession engine, SessionUI protocol, tool dispatch + workstream.py Parallel workstream manager (WorkstreamState, Workstream, WorkstreamManager) + tools.py Tool schema loader (JSON -> OpenAI function-calling format) + memory.py SQLite persistence (conversations, memories, FTS5 search) + metrics.py Prometheus-compatible metrics collector (MetricsCollector) + edit.py File edit utilities (find_occurrences, pick_nearest) + safety.py Command safety validation (blocked patterns, sanitization) + sandbox.py Math code sandboxing (AST validation, subprocess execution) + web.py Web utilities (HTML stripping, SSRF prevention) + mq/ + protocol.py Inbound/outbound message dataclasses (JSON serialization) + broker.py Abstract MessageBroker protocol + RedisBroker + bridge.py Bridge service (queue ↔ turnstone-server HTTP API) + client.py TurnstoneClient library + TurnResult for external systems + console/ + collector.py ClusterCollector — aggregates state from all nodes via Redis + HTTP + server.py Cluster dashboard HTTP server + SSE + CLI entry point + static/ Cluster dashboard web UI (HTML, CSS, JS) + ui/ + colors.py ANSI color constants with NO_COLOR support + markdown.py Streaming terminal markdown renderer (line-buffered) + spinner.py Braille character spinner (daemon thread) + static/ + index.html Single-page app shell (links to CSS and JS) + style.css All UI styles (dark/light themes, dashboard, approval blocks) + app.js All client-side JavaScript (SSE, workstreams, dashboard, markdown) + tools/ + *.json 14 tool schemas (OpenAI function-calling format + turnstone metadata) +``` + +--- + +## Core Loop + +A user message flows through the system as follows: + +``` + User input + | + v + ChatSession.send(user_input) + | + v + _full_messages() ------------> system_messages + self.messages + | + v + _emit_state("thinking") + | + v + _create_stream_with_retry() ----> client.chat.completions.create(stream=True) + | up to 3 retries (4 total attempts), exponential backoff + v + _stream_response(stream) --------> dispatch tokens to UI: + | on_reasoning_token() / on_content_token() + | accumulate tool_calls from deltas + | track finish_reason + v + finish_reason check: + +--- "length" --> warn, discard partial tool_calls + +--- "content_filter" --> warn + v + tool_calls present? + | + +--- No ---> _print_status_line() -> _emit_state("idle") -> return + | + +--- Yes --> _emit_state("running") + | + v + _execute_tools(tool_calls) <--- three-phase pipeline (see below) + | + v + append tool results to self.messages + | + v + loop back to _full_messages() +``` + +### Tool Execution Pipeline + +Tool execution is a three-phase process: + +``` +Phase 1: PREPARE (serial) + For each tool_call: + _prepare_tool(tc) + -> parse JSON arguments (with regex fallback for malformed JSON) + -> dispatch to _prepare_{tool_name}(call_id, args) + -> validate inputs, build preview text + -> return item dict with: header, preview, needs_approval, execute fn + +Phase 2: APPROVE (serial, blocking) + _emit_state("attention") + ui.approve_tools(items) + -> display all headers and previews + -> if any need approval and not auto_approve: prompt user + -> return (approved, feedback) + _emit_state("running") + +Phase 3: EXECUTE (parallel) + if len(items) == 1: + run_one(items[0]) + else: + ThreadPoolExecutor(max_workers=4).map(run_one, items) + For plan tool: post-execution gate via ui.on_plan_review() +``` + +### State Transitions + +The engine emits state changes via `_emit_state()` which calls +`ui.on_state_change(state)`. Frontends use these to update indicators +(spinner, tab badges, status line). + +``` + send() called + | + v + "thinking" ---> streaming response + | + v + "running" ---> tool execution + | + v + "attention" ---> waiting for user approval / plan review + | + v + "running" ---> executing approved tools + | + v + "idle" ---> no more tool calls, turn complete + | + (or "error" ---> exception or KeyboardInterrupt) +``` + +--- + +## SessionUI Protocol + +Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 13 +methods. Every frontend must implement all of them. + +```python +class SessionUI(Protocol): + def on_thinking_start(self) -> None: ... + def on_thinking_stop(self) -> None: ... + def on_reasoning_token(self, text: str) -> None: ... + def on_content_token(self, text: str) -> None: ... + def on_stream_end(self) -> None: ... + def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ... + def on_tool_result(self, name: str, output: str) -> None: ... + def on_status(self, usage: dict, context_window: int, effort: str) -> None: ... + def on_plan_review(self, content: str) -> str: ... + def on_info(self, message: str) -> None: ... + def on_error(self, message: str) -> None: ... + def on_state_change(self, state: str) -> None: ... + def on_rename(self, name: str) -> None: ... # propagate alias to tab/UI label +``` + +`on_rename` is called by the `/name` command (on success) and after a successful `/resume` (if the resumed session has an alias or title). `WebUI.on_rename` broadcasts a `ws_rename` event on the global SSE channel and updates the in-memory `Workstream.name`; `TerminalUI.on_rename` is a no-op. + +### Three Implementations + +| Class | Module | Notes | +|-------|--------|-------| +| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval | +| `WebUI` | `turnstone.server` | SSE event queue per workstream, `threading.Event` for blocking on approval/plan | +| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` | + +### WorkstreamTerminalUI + +`WorkstreamTerminalUI` (in `turnstone.cli`) extends `TerminalUI` with workstream +awareness: + +- **Output buffering**: When in background (`is_foreground` is False), tokens + are appended to `_output_buffer` instead of written to stdout. When the user + switches to this workstream, `flush_buffer()` replays them. + +- **Approval blocking**: `approve_tools()` and `on_plan_review()` call + `_fg_event.wait()` when in background, blocking the worker thread until the + workstream is foregrounded. This ensures the user sees the approval prompt + in the correct context. + +- **Foreground/background toggle**: `set_foreground(bool)` sets or clears + `_fg_event` (a `threading.Event`). The manager calls this during `/ws ` + switches. + +--- + +## Workstream Architecture + +Workstreams are parallel, independent chat sessions. Each has its own +`ChatSession`, `SessionUI`, message history, and worker thread. + +### WorkstreamState + +Defined in `turnstone.core.workstream.WorkstreamState` (5 states): + +``` +IDLE waiting for user input +THINKING LLM is streaming a response +RUNNING tools are executing +ATTENTION blocked on user approval or plan review +ERROR last operation failed +``` + +### Data Model + +```python +@dataclass +class Workstream: + id: str # uuid hex, 8 chars + name: str # user-visible label + state: WorkstreamState # current state + session: ChatSession | None # the conversation engine + ui: SessionUI | None # frontend adapter + worker_thread: threading.Thread | None + error_message: str + last_active: float # time.monotonic() timestamp, updated on every state change + _lock: threading.Lock # per-workstream state lock +``` + +### WorkstreamManager + +```python +class WorkstreamManager: + MAX_WORKSTREAMS = 10 + + def __init__(self, session_factory: Callable[[SessionUI], ChatSession]): ... + def create(self, name="", ui_factory=None) -> Workstream: ... + def close(self, ws_id: str) -> bool: ... + def close_idle(self, max_age_seconds: float) -> list[str]: ... # auto-close stale IDLE workstreams + def get(self, ws_id: str) -> Workstream | None: ... + def get_active(self) -> Workstream | None: ... + def list_all(self) -> list[Workstream]: ... + def switch(self, ws_id: str) -> Workstream | None: ... + def switch_by_index(self, index: int) -> Workstream | None: ... + def set_state(self, ws_id, state, error_msg=""): ... # updates last_active +``` + +The `session_factory` pattern decouples session creation from configuration. +The factory captures shared config (client, model, temperature, etc.) and +accepts only a `SessionUI`, so the manager can create sessions without knowing +API details. + +### Idle Workstream Lifecycle + +The web server runs a background `_idle_cleanup_thread` (daemon) that calls +`WorkstreamManager.close_idle()` periodically (every `timeout / 4`, max 5 min). +Any IDLE workstream whose `last_active` is older than the configured timeout is +closed; non-IDLE workstreams (THINKING, RUNNING, ATTENTION, ERROR) are never +touched. The last workstream is always preserved even if expired. On close, a +`ws_closed` event is broadcast on the global SSE channel so browser clients +remove the tab immediately. Controlled by `--workstream-idle-timeout` (default: +120 minutes, 0 = disable). + +### CLI Workstreams + +- `/ws list` -- show all workstreams with state indicators +- `/ws new [name]` -- create a new workstream and switch to it +- `/ws ` -- switch to workstream by 1-based index +- `/ws close [N]` -- close a workstream +- `/ws rename ` -- rename the active workstream + +Background notifications: when a background workstream enters `ATTENTION` +state, `_bg_attention_notify` writes an ANSI escape sequence to stderr +(overwrites the line above the prompt) with the workstream name. + +Status line: `_print_ws_status_line()` shows a compact status of all +non-idle background workstreams above the input prompt. + +### Web Workstreams + +- **Tab bar**: Each workstream renders as a tab with a colored state indicator + (CSS `@keyframes pulse` animation per state). +- **Per-tab SSE**: `connectContentSSE(wsId)` opens + `/api/events?ws_id=` for the active tab's event stream. +- **Global SSE**: `connectGlobalSSE()` opens `/api/events/global` which + receives `ws_state` broadcasts from all workstreams, used to update tab + indicators without switching. +- **New tab / close**: POST `/api/workstreams/new`, POST `/api/workstreams/close`. + +### Thread Safety + +- `WorkstreamManager._lock`: guards `_workstreams` dict and `_order` list on + all create/close/switch/list operations. +- `Workstream._lock`: guards per-workstream state mutations in `set_state()`. +- `WorkstreamTerminalUI._print_lock`: guards `_output_buffer` access. +- `WorkstreamTerminalUI._fg_event`: `threading.Event` that blocks background + approval until the workstream is foregrounded. + +--- + +## Tool System + +### Schema Format + +Each tool is a JSON file in `turnstone/tools/`. The file contains an OpenAI +function-calling schema (`name`, `description`, `parameters`) plus optional +turnstone metadata keys: + +| Metadata Key | Type | Meaning | +|-------------|------|---------| +| `agent` | `bool` | Include this tool when running as a plan/task sub-agent | +| `task_agent` | `bool` | Include this tool when running as a task sub-agent | +| `auto_approve` | `bool` | Tool is read-only; skip user approval | +| `primary_key` | `str` | Fallback argument name for bare-string JSON recovery | + +Example (`read_file.json`): + +```json +{ + "name": "read_file", + "description": "Read the contents of a file. ...", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "..." }, + "offset": { "type": "integer", "description": "..." }, + "limit": { "type": "integer", "description": "..." } + }, + "required": ["path"] + }, + "agent": true, + "task_agent": true, + "auto_approve": true, + "primary_key": "path" +} +``` + +At import time, `turnstone.core.tools._load_tools()` strips the metadata keys +from each schema and builds: + +- `TOOLS` -- list of `{"type": "function", "function": {...}}` dicts for the API +- `AGENT_TOOLS` -- subset with `agent: true` +- `TASK_AGENT_TOOLS` -- subset with `task_agent: true` +- `AGENT_AUTO_TOOLS` / `TASK_AUTO_TOOLS` -- sets of tool names with `auto_approve: true` +- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery + +### 14 Tools by Category + +**Read-only (auto-approve)**: +- `read_file` -- read file contents with optional offset/limit +- `search` -- ripgrep-based codebase search +- `man` -- read man pages +- `recall` -- retrieve stored memories + +**Write (requires approval)**: +- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`) +- `write_file` -- create or overwrite a file +- `edit_file` -- string replacement in an existing file (requires prior `read_file`) +- `math` -- execute Python in sandboxed subprocess (via `turnstone.core.sandbox`) +- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`) +- `web_search` -- search the web via Tavily API + +**Agent (delegated sub-sessions)**: +- `task` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`) +- `plan` -- explore codebase and write a structured plan (`AGENT_TOOLS`) + +**Memory (persistent key-value store)**: +- `remember` -- save a fact +- `forget` -- delete a fact + +### Prepare / Execute Pattern + +Every tool has a `_prepare_{name}` method and a corresponding `_exec_{name}` +method on `ChatSession`: + +``` +_prepare_bash(call_id, args) -> item dict with execute=self._exec_bash +_prepare_read_file(call_id, args) -> item dict with execute=self._exec_read_file +... +``` + +The prepare method validates inputs and builds the preview. The item dict +carries the validated data and a reference to the execute function. This +separation allows the UI to show previews before any side effects occur. + +### Agent Tools + +`task` and `plan` invoke `_run_agent()`, which runs a multi-turn loop with +a subset of tools and its own system prompt. The sub-agent runs +independently, then returns the final content as the tool result. + +- **task**: uses `TASK_AGENT_TOOLS` (includes bash, read, write, edit, search) +- **plan**: uses `AGENT_TOOLS` (read-only subset for exploration). Writes output + to `.plan-.md` — unique per `ChatSession` so concurrent workstreams + don't collide. On repeat invocations the prior `plan` tool call and its result + are forwarded from `self.messages` so the agent refines the existing plan rather + than starting over. Planning instructions are passed via `model_identity` in + `chat_template_kwargs` rather than as a developer message. +- **Turn limit**: controlled by `agent_max_turns` (default: `-1`, unlimited). + When a limit is set and reached, the agent is forced to synthesize a final + response without tools. When unlimited, the loop only exits when the model + stops calling tools or hits `finish_reason: "length"`. +- **Retry**: each API call in the agent loop uses the same retry+backoff logic + as the main `_create_stream_with_retry()`. +- **Finish reason handling**: `finish_reason: "length"` stops the agent early + and returns whatever content was generated. `finish_reason: "content_filter"` + returns a placeholder. + +### Tool Output Truncation + +Tool execution results (bash, read_file, search, math, man) are truncated by +`_truncate_output()` when they exceed `tool_truncation` characters. Truncation +preserves the first half and last half of the output, with a message in +between: + +``` +... [N chars truncated — output exceeded LIMIT char limit] ... +``` + +The default limit is 50% of the context window in characters (computed as +`context_window * chars_per_token * 0.5`). For a 131K context window this is +~262K characters. Override with `--tool-truncation `. + +This truncation message is visible to the model, so it knows output was cut. + +--- + +## Persistence + +### Database + +SQLite via `turnstone.core.memory`. Database file: `.turnstone.db` in the +current working directory (overridable via `memory.db_override` for eval +isolation). + +### Tables + +```sql +memories + key TEXT PRIMARY KEY + value TEXT NOT NULL + created TEXT NOT NULL + updated TEXT NOT NULL + +sessions + session_id TEXT PRIMARY KEY + alias TEXT UNIQUE -- user-assigned short name (nullable) + title TEXT -- LLM-generated title (nullable) + created TEXT NOT NULL + updated TEXT NOT NULL -- bumped on every save_message() + +conversations + id INTEGER PRIMARY KEY AUTOINCREMENT + session_id TEXT NOT NULL + timestamp TEXT NOT NULL + role TEXT NOT NULL -- user | assistant | tool_call | tool_result + content TEXT + tool_name TEXT + tool_args TEXT + tool_call_id TEXT -- links tool_call ↔ tool_result for resume + +conversations_fts -- FTS5 virtual table + content (content=conversations, content_rowid=id) +``` + +The `tool_call_id` column was added via schema migration (`ALTER TABLE`) for +backwards compatibility with existing databases. + +### Key Functions + +| Function | Purpose | +|----------|---------| +| `open_db()` | Open/create database, run migrations, initialize tables | +| `load_memories()` | Return all `(key, value)` pairs sorted by key | +| `save_message(session_id, role, content, ...)` | Log a message to conversations (accepts `tool_call_id`) | +| `search_history(query, limit)` | Full-text search via FTS5 (falls back to LIKE) | +| `search_history_recent(limit)` | Return most recent messages | +| `register_session(session_id, title)` | Create a sessions row (no-op if exists) | +| `update_session_title(session_id, title)` | Set/update LLM-generated title | +| `set_session_alias(session_id, alias)` | Set user-friendly alias (returns False if taken) | +| `get_session_name(session_id)` | Return alias if set, else title, else None | +| `resolve_session(alias_or_id)` | Resolve alias, exact id, or id prefix to full session_id | +| `list_sessions(limit)` | List sessions with ≥1 message, ordered by updated DESC | +| `load_session_messages(session_id)` | Reconstruct OpenAI message format from DB rows | +| `delete_session(session_id)` | Delete session and all its messages | +| `prune_sessions(retention_days, log_fn)` | Remove empty sessions and old unnamed sessions; called at startup | +| `normalize_key(key)` | Normalize memory keys (`lower`, replace `-`/` ` with `_`) | +| `fts5_query(query)` | Convert plain text to safe FTS5 query (quoted terms) | + +### Session Persistence and Resume + +Each `ChatSession` generates a 12-char hex `_session_id` on creation and +registers it in the `sessions` table. Messages are saved to `conversations` +as they happen via `save_message()`. + +**Auto-titling:** After the first complete exchange (user message + assistant +response), a background thread calls the LLM with a title-generation prompt +(`reasoning_effort: "low"`, `max_completion_tokens: 200`). The generated +title (3-8 words) is stored in `sessions.title`. + +**Resume flow:** `ChatSession.resume_session(session_id)` calls +`load_session_messages()` which reconstructs the OpenAI message format from +database rows: + +- `user` and `assistant` rows map directly +- Consecutive `tool_call` rows are grouped into one assistant message's + `tool_calls` array, paired with subsequent `tool_result` rows via + `tool_call_id` (or positional matching for legacy data) +- The session adopts the old `_session_id`, so new messages continue in + the same session + +**`/clear` vs `/new`:** `/clear` wipes in-memory context but preserves +messages in the database for future resume. `/new` starts a fresh session +(new `_session_id`), leaving the old session resumable. + +**Resolution:** `resolve_session()` accepts aliases, exact session IDs, or +session ID prefixes, enabling `turnstone --resume refactor` or `/resume abc12`. + +**Session listing:** `list_sessions()` only returns sessions that have at +least one saved message (`WHERE EXISTS` on `conversations`). Sessions +registered but never used (e.g., from process startup) are invisible until +a message is sent. + +**Session pruning:** `prune_sessions(retention_days, log_fn)` runs once at +startup (CLI and server). It removes: +- Sessions with no messages (orphaned registrations) +- Unnamed sessions (`alias IS NULL`) older than `retention_days` days (default 90) + +Named (aliased) sessions are never age-pruned. Configure with +`--session-retention-days N` (0 = disable age pruning). + +--- + +## Error Handling and Retry + +### API Retry + +`ChatSession._create_stream_with_retry()` (streaming path) and the agent +`_api_call()` (non-streaming) both use the same retry pattern: + +- **Retries**: 4 total attempts (1 initial + 3 retries, `_MAX_RETRIES = 3`) +- **Backoff**: exponential, base 1 second (`delay = 1s * 2^attempt`) +- **Retryable errors**: `RateLimitError`, `APITimeoutError`, + `APIConnectionError`, `InternalServerError`, `ServiceUnavailableError`, + `APIError` (matched by class name to avoid importing backend-specific + exception hierarchies) +- On retry: `ui.on_info()` notification +- On final failure: exception propagates + +`_compact_messages()` also wraps its non-streaming API call in the same +retry loop. + +### Finish Reason Handling + +`_stream_response()` tracks `finish_reason` from the final streaming chunk: + +- **`"length"`**: warns via `ui.on_error()` that the response was truncated. + Any partial tool calls are discarded (their JSON would be malformed), + causing the `send()` loop to exit cleanly. +- **`"content_filter"`**: warns via `ui.on_error()` that the response was + blocked. + +Agent sub-sessions (`_run_agent()`) check `finish_reason` on each +non-streaming response and stop the agent early on `"length"` or +`"content_filter"`. + +`_compact_messages()` checks `finish_reason` on the compaction response and +warns if the summary was truncated. + +### State Emission on Errors + +- `send()` catches `KeyboardInterrupt` and generic `Exception`: calls + `_emit_state("error")` before re-raising +- On interrupt: partial tool results and the originating assistant message + are popped from `self.messages` to keep state consistent + +### Web UI Resilience + +- **SSE reconnect**: both `connectContentSSE()` and `connectGlobalSSE()` use + exponential backoff on `onerror` -- starting at 1 second, doubling on each + failure, capped at 30 seconds. On successful message, delay resets to 1s. +- **Disconnection indicator**: `#status-bar.disconnected` class turns the + status text red and shows "Reconnecting..." +- **Fetch error handling**: all `fetch()` calls use `.catch()` to prevent + unhandled promise rejections +- **Pending approval across tab switches**: `WebUI._pending_approval` stores + the `approve_request` event payload while the session is blocked waiting + for user response. On SSE reconnect (e.g., switching back to the tab), + the event is re-injected after history replay. `_build_history` marks the + pending tool call as `"pending": true` so `replayHistory` skips the + false `✓ approved` badge; the live approval UI is rendered by the + re-injected event instead. +- **Browser history integration**: `history.pushState` is called in + `switchTab()` with `{turnstone: 'workstream', wsId}`. The initial state is + seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The + `popstate` listener restores the correct tab or shows the dashboard, + guarded by `_historyNavigation = true` to prevent re-entrant pushState. + +### Eval Resilience + +`_run_single_test()`: wraps `session.send_headless()` in a retry loop (3 +attempts) to avoid transient API errors from poisoning evaluation scores. + +--- + +## Threading Model + +### CLI + +``` +Main thread Spinner thread (daemon) ThreadPoolExecutor ++--------------+ +------------------+ +-----------------+ +| REPL loop | | Braille animation| | Tool execution | +| input() -> | | 80ms tick to | | max_workers=4 | +| send() -> | | stderr | | parallel tools | +| stream -> | | started/stopped | | run concurrently| +| tools -> | | by TerminalUI | | | ++--------------+ +------------------+ +-----------------+ + | ^ ^ + +-- on_thinking_start/stop -------------------------+ + +-- _execute_tools ---------------------------------+ +``` + +Key constraint: `input()` blocks the main thread. The spinner writes to +stderr so it does not interfere with readline. Tool execution may use a +`ThreadPoolExecutor` with up to 4 workers for parallel tool calls. + +### Server + +``` +ThreadedHTTPServer (ThreadingMixIn + HTTPServer, daemon_threads=True) + | + +-- Thread per HTTP request + | POST /api/send -> worker thread per workstream + | POST /api/approve -> unblocks WebUI._approval_event + | POST /api/plan -> unblocks WebUI._plan_event + | POST /api/workstreams/new -> creates workstream + worker + | GET /api/events -> SSE long-poll (per workstream) + | GET /api/events/global -> SSE long-poll (fan-out) + | + +-- Worker thread per workstream + | Runs session.send() in a loop + | Blocks on WebUI._approval_event / _plan_event + | + +-- Global SSE fan-out + WebUI._global_queue shared across all WebUI instances + Global SSE endpoint drains this queue +``` + +`ThreadingMixIn` ensures each HTTP request (including long-lived SSE +connections) gets its own thread. This is necessary because SSE connections +block indefinitely, and POST requests must be handled concurrently. + +Each workstream's `WebUI` has: +- `_event_queue` (per-workstream SSE events) +- `_approval_event` / `_plan_event` (`threading.Event` for blocking) +- `_global_queue` (class variable, shared, for state broadcasts) + +### Workstream Threading (CLI) + +``` +Main thread Background workstream thread ++------------------+ +---------------------------+ +| REPL input() | | session.send() | +| /ws commands | | streams response | +| active workstream| | executes tools | +| send() inline | | approve_tools() -> | ++------------------+ | _fg_event.wait() BLOCKS | + | +---------------------------+ + | ^ + +-- /ws switch ------------->| + | old.set_foreground(False) | + | new.set_foreground(True) | + | new.flush_buffer() | + +-- _fg_event.set() unblocks --->+ +``` + +When a background workstream needs approval, its `WorkstreamTerminalUI` +calls `_fg_event.wait()`, which blocks the worker thread until the user +switches to that workstream. The `_bg_attention_notify` callback writes a +bell + status line to stderr to alert the user. + +### Message Queue Bridge + +``` +Main thread Global SSE thread Per-WS SSE threads (×N) ++------------------+ +------------------+ +-------------------+ +| Inbound loop | | GET /events/glob | | GET /events?ws_id | +| BLPOP on Redis | | Parse SSE data | | Parse SSE data | +| Dispatch to | | Forward state | | Forward content, | +| handler | | changes | | tool results | +| POST to server | | Detect turn | | Handle approval | +| Publish ACK | | completion | | forwarding | ++------------------+ +------------------+ +-------------------+ + | | | + +-- Redis inbound queue +-- Redis pub/sub +-- Redis pub/sub + (RPUSH/BLPOP) (PUBLISH) (PUBLISH) + + response queue + (BLPOP on + approval) +``` + +**Approval flow:** When a per-WS SSE thread receives an `approve_request`, it checks +the workstream's `auto_approve_tools` set. If all requested tools are in the set, the +bridge auto-approves via `POST /api/approve`. Otherwise, it publishes an +`ApprovalRequestEvent` to the outbound channel with a `request_id`, then blocks on +`BLPOP` of a Redis response queue (`turnstone:resp:{request_id}`) until the client pushes +a response or the approval timeout (default 300s) expires. + +**Completion detection:** The bridge tracks which `correlation_id` maps to which +`ws_id` for active sends. When the global SSE reports `ws_state → idle` for a tracked +workstream, the bridge emits a synthetic `TurnCompleteEvent` with the correlation ID. + +**Multi-node routing:** Each bridge has a `node_id` (defaults to hostname) and BLPOPs +from both `turnstone:inbound:{node_id}` (directed, priority) and `turnstone:inbound` (shared). +Messages with `target_node` set are pushed to the target's per-node queue. Messages +for existing workstreams are auto-routed via `turnstone:ws:{ws_id}` ownership keys in Redis. +If a bridge picks up a shared-queue message for a workstream owned by another node, it +re-routes to that node's queue (1 extra hop). Bridges publish heartbeats to +`turnstone:node:{node_id}` with configurable TTL for node discovery. + +### Cluster Console + +``` +Event subscriber Node discovery Poll loop ++------------------+ +------------------+ +-------------------+ +| SUBSCRIBE on | | SCAN node:* keys | | For each node: | +| events:cluster | | every 15 seconds | | GET /api/dash | +| Apply state | | Add/remove nodes | | GET /health | +| changes to | | Emit join/lost | | ThreadPoolExecutor| +| in-memory model | | events | | (50 workers) | ++------------------+ +------------------+ +-------------------+ + | | | + +-- Redis pub/sub +-- Redis SCAN +-- HTTP to each + (SUBSCRIBE) (every 15s) server (every 10s) +``` + +The console is read-only — it never writes to Redis queues or sends commands to servers. +Real-time events provide instant state transitions; periodic polling provides full data +consistency (tokens, context ratios, activity strings). See [docs/console.md](console.md) +for the full API reference. + +--- + +## Conversation Compaction + +When the prompt exceeds `auto_compact_pct` of the context window (default: +80%, configurable via `--auto-compact-pct`), `ChatSession` auto-compacts by +summarizing the entire conversation into a structured summary +(`_compact_messages`). The summary model call uses `compact_max_tokens` +(default: 32768, configurable via `--compact-max-tokens`). The summary +preserves: + +- Decisions made (architecture, libraries, approaches) +- Files read, created, or modified +- Exact identifiers, paths, and code snippets +- Important tool results +- Open tasks +- User preferences + +After compaction, `_read_files` is cleared to force re-reads before edits, +since file contents are no longer in the message history. diff --git a/docs/console.md b/docs/console.md new file mode 100644 index 00000000..a3324daa --- /dev/null +++ b/docs/console.md @@ -0,0 +1,235 @@ +# Cluster Dashboard (turnstone-console) + +`turnstone-console` is a standalone monitoring service that provides cluster-wide visibility across all turnstone nodes. It connects to the shared Redis broker, discovers nodes via heartbeat keys, polls each node's HTTP API for workstream data, and subscribes to a cluster event channel for real-time state changes. + +The console is read-only — it observes but does not own workstreams or drive LLM sessions. + +## Architecture + +``` +turnstone-server ──→ turnstone-bridge ──→ Redis ──→ turnstone-console ──→ Browser + (per node) (per node) (shared) (one instance) +``` + +Each bridge publishes state changes to `{prefix}:events:cluster` on Redis pub/sub. The console subscribes once to that channel for real-time updates and periodically polls each node's `GET /api/dashboard` for full workstream snapshots. + +### Data Sources + +| Source | Method | Frequency | Data | +|--------|--------|-----------|------| +| Redis heartbeats | `SCAN turnstone:node:*` | Every 15s | Node discovery (node_id, server_url, started) | +| Redis pub/sub | `SUBSCRIBE turnstone:events:cluster` | Real-time | State changes, creates, closes, renames | +| Node HTTP API | `GET {server_url}/api/dashboard` | Every 10s | Full workstream list with tokens, context, activity | +| Node HTTP API | `GET {server_url}/health` | Every 10s | Node health status | + +### Redis Key: Cluster Event Channel + +Bridges publish to `{prefix}:events:cluster` whenever a workstream state change, creation, closure, or rename occurs. Events include `node_id` so the console can attribute them to the correct node. + +Event types on the cluster channel: + +| Event | Fields | Trigger | +|-------|--------|---------| +| `cluster_state` | ws_id, state, node_id, tokens, context_ratio, activity | Workstream state transition | +| `ws_created` | ws_id, name, node_id | New workstream created | +| `ws_closed` | ws_id | Workstream closed | +| `ws_rename` | ws_id, name | Workstream renamed | + +--- + +## ClusterCollector + +The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot of all nodes and workstreams. Three daemon threads handle data acquisition: + +1. **Event subscriber** — subscribes to `{prefix}:events:cluster` via `RedisBroker.subscribe_cluster()`. Applies state changes, creates, closes, and renames to the in-memory model immediately. + +2. **Node discovery** — scans heartbeat keys every 15 seconds via `broker.list_nodes()`. Adds newly discovered nodes, removes expired ones, emits `node_joined` / `node_lost` events to SSE listeners. + +3. **Poll loop** — fetches `GET /api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data. + +### Thread Safety + +All reads and writes to the node/workstream map are protected by a single `threading.Lock`. Query methods acquire the lock, copy data, and release before returning. + +### Scale Considerations + +- **10,000 workstreams** at ~500 bytes each = ~5 MB in memory +- **1,000 nodes** polled in parallel with 50 threads at ~100ms each = ~2 second poll cycle +- **Filtering and pagination** run in-memory on the full workstream list — sub-millisecond at this scale +- **SSE fan-out** uses the same per-client queue pattern as the per-node server — backed-up clients get events dropped, not blocking + +--- + +## HTTP API + +### `GET /api/cluster/overview` + +Cluster-wide state counts and aggregate metrics. + +```json +{ + "nodes": 847, + "workstreams": 4219, + "states": {"running": 1847, "thinking": 312, "attention": 89, "idle": 1940, "error": 31}, + "aggregate": {"total_tokens": 12400000, "total_tool_calls": 34200} +} +``` + +### `GET /api/cluster/nodes?sort=activity&limit=100&offset=0` + +Paginated node list. Sort options: `activity` (default, by running+attention count), `tokens`, `name`. + +```json +{ + "nodes": [ + { + "node_id": "db-west-04", + "server_url": "http://10.0.3.4:8080", + "ws_total": 6, "ws_running": 4, "ws_thinking": 0, "ws_attention": 1, "ws_idle": 1, "ws_error": 0, + "total_tokens": 48200, + "started": 1709294400.0, + "reachable": true, + "health": {} + } + ], + "total": 847 +} +``` + +### `GET /api/cluster/workstreams?state=running&node=db-west-04&search=perf&page=1&per_page=50` + +Filtered, paginated workstream list. All query parameters are optional. `per_page` is capped at 200. + +```json +{ + "workstreams": [ + { + "id": "a1b2c3d4", "name": "perf-db-west", "state": "running", "node": "db-west-04", + "title": "Query latency analysis", "tokens": 24100, "context_ratio": 0.18, + "activity": "bash: EXPLAIN ANALYZE...", "activity_state": "tool", "tool_calls": 42 + } + ], + "total": 1847, "page": 1, "per_page": 50, "pages": 37 +} +``` + +### `GET /api/cluster/node/{node_id}` + +Single node detail with all its workstreams. + +```json +{ + "node_id": "db-west-04", + "server_url": "http://10.0.3.4:8080", + "health": {"status": "ok", "version": "0.2.0", "model": "kappa_20b_131k"}, + "workstreams": [...], + "aggregate": {"total_tokens": 48200, "total_tool_calls": 156} +} +``` + +### `GET /api/cluster/events` + +Server-Sent Events stream for real-time cluster updates. + +``` +data: {"type":"cluster_state","ws_id":"a1b2","node_id":"db-west-04","state":"running"} +data: {"type":"ws_created","ws_id":"e5f6","node_id":"api-east-01","name":"new-task"} +data: {"type":"ws_closed","ws_id":"a1b2"} +data: {"type":"node_joined","node_id":"db-west-05"} +data: {"type":"node_lost","node_id":"db-west-03"} +``` + +Keepalive comments (`: keepalive\n\n`) are sent every 5 seconds. Clients should reconnect on error with exponential backoff. + +### `GET /health` + +```json +{"status": "ok", "service": "turnstone-console", "nodes": 847, "workstreams": 4219} +``` + +--- + +## Browser Dashboard + +The web UI has three views, toggled client-side: + +### 1. Cluster Overview (landing) + +- **State cards** — 5 clickable cards (running, thinking, attention, idle, error) with count and colored top border. Clicking filters to that state. +- **Aggregate bar** — total tokens and tool calls across the cluster. +- **Node table** — columns: NODE, WS, RUN, ATTN, TOKENS, HEALTH. Sorted by activity. Clickable rows drill down to node detail. + +### 2. Node Drill-down + +Breadcrumb: `Cluster > db-west-04`. Shows the node's workstreams in a table matching the per-node dashboard layout (STATE, NAME, NODE, TASK, TOKENS, CTX) with activity sub-lines. Includes a link to the node's own dashboard (`http://{server_url}/`). + +### 3. Filtered Workstreams + +Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated workstream table. NODE column values are clickable to filter further. Pagination controls at bottom. + +All three views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators. + +--- + +## CLI Commands + +The `/cluster` command in the turnstone CLI queries the console's HTTP API. Requires `--console-url` or `[console] url` in config.toml. + +| Command | Description | +|---------|-------------| +| `/cluster status` | Cluster overview — node/workstream counts, state breakdown, aggregate stats | +| `/cluster nodes` | Node table — WS, RUN, ATTN, TOKENS per node | +| `/cluster workstreams [state] [node=X]` | Filtered workstream list with state, name, node, tokens, context | +| `/cluster node ` | Single node's workstreams with activity details | + +--- + +## Configuration + +CLI flags for `turnstone-console`: + +| Flag | Default | Description | +|------|---------|-------------| +| `--host` | `0.0.0.0` | Bind host | +| `--port` | `8090` | HTTP port | +| `--redis-host` | `localhost` | Redis host | +| `--redis-port` | `6379` | Redis port | +| `--redis-password` | `$REDIS_PASSWORD` | Redis password | +| `--redis-db` | `0` | Redis DB | +| `--poll-interval` | `10` | Node polling interval (seconds) | +| `--log-level` | `INFO` | Log level | + +Config file (`~/.config/turnstone/config.toml`): + +```toml +[console] +host = "0.0.0.0" +port = 8090 +url = "http://localhost:8090" # used by CLI /cluster commands +poll_interval = 10 + +[redis] +host = "localhost" +port = 6379 +password = "my-redis-password" +``` + +--- + +## Deployment + +```bash +# Start Redis +redis-server + +# Start turnstone servers (one per node) +turnstone-server --port 8080 + +# Start bridges (one per server) +turnstone-bridge --server-url http://localhost:8080 --node-id node-a + +# Start cluster console (one instance) +turnstone-console --redis-host localhost --port 8090 +``` + +Open `http://localhost:8090` for the cluster dashboard. diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 00000000..cd774685 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,139 @@ +# Docker Deployment + +Docker Compose stack for running the full turnstone platform or the simulator. + +## Quick Start + +```bash +# Copy and edit environment config +cp .env.example .env + +# Full stack (needs an LLM API on the host) +docker compose up + +# Simulator only (no LLM needed) +docker compose --profile sim up redis console sim +``` + +Console dashboard: http://localhost:8090 + +## Services + +| Service | Port | Profile | Description | +|---------|------|---------|-------------| +| `redis` | 6379 | default | Message broker, pub/sub, node registry | +| `server` | 8080 | default | Web UI + chat workstreams + LLM | +| `bridge` | — | default | Redis-to-HTTP bridge (multi-node routing) | +| `console` | 8090 | default | Cluster dashboard | +| `sim` | — | sim | Multi-node cluster simulator | + +## Profiles + +**Default** (no flag) — starts `redis`, `server`, `bridge`, `console`. Requires an OpenAI-compatible LLM API running on the host (default: `http://localhost:8000/v1`). + +```bash +docker compose up +``` + +**Sim** — adds the simulator. Can run alongside the full stack or standalone with just Redis and the console: + +```bash +# Sim + console (no LLM needed) +docker compose --profile sim up redis console sim + +# Everything including sim +docker compose --profile sim up +``` + +## Configuration + +All configuration is via environment variables in `.env` (copy from `.env.example`): + +### LLM Backend + +| Variable | Default | Description | +|----------|---------|-------------| +| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | OpenAI-compatible API URL | +| `OPENAI_API_KEY` | `dummy` | API key (`dummy` for local servers) | +| `TAVILY_API_KEY` | — | Web search API key (optional) | + +### Redis + +| Variable | Default | Description | +|----------|---------|-------------| +| `REDIS_PASSWORD` | — | Redis auth password (empty = no auth) | +| `REDIS_PORT` | `6379` | Host port mapping | + +### Server + +| Variable | Default | Description | +|----------|---------|-------------| +| `SERVER_PORT` | `8080` | Host port mapping | +| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tools | + +### Console + +| Variable | Default | Description | +|----------|---------|-------------| +| `CONSOLE_PORT` | `8090` | Host port mapping | +| `CONSOLE_POLL_INTERVAL` | `10` | Node polling interval (seconds) | + +### Auth + +| Variable | Default | Description | +|----------|---------|-------------| +| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require Bearer token auth | +| `TURNSTONE_AUTH_TOKEN` | — | Shared auth token for server/bridge/console | + +### Simulator + +| Variable | Default | Description | +|----------|---------|-------------| +| `SIM_NODES` | `100` | Number of simulated nodes | +| `SIM_SCENARIO` | `steady` | Scenario: `steady`, `burst`, `node_failure`, `directed`, `lifecycle` | +| `SIM_DURATION` | `60` | Duration in seconds | +| `SIM_MPS` | `5.0` | Messages per second (steady scenario) | +| `SIM_LOG_LEVEL` | `INFO` | Log verbosity | +| `SIM_SEED` | — | Random seed for reproducibility | +| `SIM_METRICS_FILE` | — | Write JSON report to file | + +## Scaling + +Scale to multiple server/bridge pairs: + +```bash +docker compose up --scale server=3 --scale bridge=3 +``` + +Each bridge auto-generates a unique node ID from its container hostname. When scaling `server`, remove the host port mapping (or use a reverse proxy) to avoid port conflicts. + +## Volumes + +| Volume | Mount | Purpose | +|--------|-------|---------| +| `redis-data` | `/data` | Redis persistence | +| `turnstone-data` | `/data` | SQLite database (`.turnstone.db`) | + +## Building + +The image uses a multi-stage Dockerfile: + +```bash +# Build all services +docker compose build + +# Rebuild without cache +docker compose build --no-cache +``` + +All five entry points are installed in a single image: `turnstone-server`, `turnstone-bridge`, `turnstone-console`, `turnstone-sim`, `turnstone-eval`. + +## Cleanup + +```bash +# Stop and remove containers +docker compose down + +# Stop, remove containers and volumes +docker compose down -v +``` diff --git a/docs/eval.md b/docs/eval.md new file mode 100644 index 00000000..17a9db1f --- /dev/null +++ b/docs/eval.md @@ -0,0 +1,337 @@ +# Evaluation and Prompt Optimization (turnstone-eval) + +`turnstone-eval` is the evaluation and prompt optimization system for turnstone. It +runs test cases against the LLM, scores tool call sequences against expected +actions, and optionally uses the model to self-optimize the developer prompt. + +Source: `turnstone/eval.py` + +--- + +## Overview + +The system works in an iterative loop: + +1. Run each test case N times against the current developer prompt. +2. Score each run by comparing the actual tool call sequence to expected actions. +3. If not all tests pass, use the model to rewrite the prompt based on failures. +4. Repeat until all tests pass or max iterations are reached. + +When optimization is disabled (`--no-optimize`), only step 1 and 2 execute +(a single iteration). + +--- + +## Test Case Format + +Test suites are JSON files with this structure: + +```json +{ + "defaults": { + "n_runs": 3 + }, + "cases": [ + { + "id": "test_name", + "user_prompt": "the prompt to send to the model", + "setup": { + "files": { + "filename.py": "file content here", + "src/utils.py": "another file" + } + }, + "expected_actions": [ + {"tool": "read_file", "args": {"path": "filename.py"}}, + {"tool": "bash", "args_pattern": {"command": "python.*test"}}, + {"tool": "edit_file"} + ], + "match_mode": "ordered_subset", + "max_turns": 10, + "n_runs": 5 + } + ] +} +``` + +### Fields + +| Field | Required | Default | Description | +|--------------------|----------|--------------------|-------------| +| `id` | yes | -- | Unique test case identifier. | +| `user_prompt` | yes | -- | The message sent to the model. | +| `setup.files` | no | `{}` | Files to create in the temp directory before running. Keys are relative paths, values are file content. | +| `expected_actions` | no | `[]` | List of expected tool calls to match against. | +| `match_mode` | no | `"ordered_subset"` | How to match actual vs expected actions (see Scoring). | +| `max_turns` | no | `10` | Maximum conversation turns before stopping. | +| `n_runs` | no | suite default or 3 | Per-case override for number of runs. | + +### Expected Action Specs + +Each entry in `expected_actions` can contain: + +- `tool` (required): The tool name to match (e.g. `"read_file"`, `"bash"`). +- `args`: Exact key-value matching. Each key in `args` must exist in the actual call with the same string value. +- `args_pattern`: Regex key-value matching. Each key's value is a regex pattern tested against the actual argument value. +- If neither `args` nor `args_pattern` is specified, only the tool name is matched. + +--- + +## Scoring + +Scoring is handled by `score_run()`, which compares a run's tool call log +against the expected actions. + +### Match Modes + +| Mode | Description | +|-------------------|-------------| +| `exact` | Tool calls must match expected actions in exact order and exact count. Extra or missing calls cause failure. | +| `ordered_subset` | Expected actions must appear in order within the actual tool log, but extra calls between them are allowed. This is the default. | +| `subset` | Expected actions must all appear somewhere in the tool log, in any order. Each actual call can only match one expected action. | +| `contains_any` | Passes if at least one expected action appears anywhere in the tool log. | + +### Action Matching (`_match_action`) + +A single actual tool call matches an expected action when: + +1. The tool names are equal. +2. If `args` is specified: every key in `args` must exist in the actual call's + arguments with the same string value (partial key matching -- extra actual + args are ignored). +3. If `args_pattern` is specified: every key's regex pattern must match the + corresponding actual argument value via `re.search()`. +4. If the actual args contain only `_raw` (unparseable JSON fallback), the + action matches only when no `args` or `args_pattern` is expected. + +### Score Calculation + +- **Score** = number of matched expected actions / total expected actions. +- **Pass** = score equals 1.0 (all expected actions matched). +- The return dict includes: `pass`, `score`, `matched` (indices), `unmatched` + (indices), `extra_tools`, and `detail` (human-readable summary). + +### JSON Dump Detection + +When a run fails and the model's final text content contains JSON that looks +like a tool call (keys like `"tool"`, `"command"`, `"path"`), the run is +flagged with `json_dump: true`. This indicates the model tried to call a tool +but emitted JSON as text instead of using the function-calling interface. + +--- + +## HeadlessSession + +`HeadlessSession` extends `ChatSession` for headless evaluation. It provides +deterministic, non-interactive execution suitable for automated testing. + +### Differences from ChatSession + +| Aspect | ChatSession | HeadlessSession | +|-----------------|-------------------------|----------------------------| +| Streaming | Streaming API | Non-streaming (`stream=False`) | +| Tool approval | User confirmation | `auto_approve = True` | +| UI | Terminal/Web UI | `NullUI` (discards output) | +| Stdout | Normal | Suppressed during execution | +| Tool logging | Display only | Structured `tool_call_log` | +| System prompt | Built-in developer prompt | Overridable via constructor | + +### NullUI + +A minimal UI adapter that satisfies the `SessionUI` protocol by discarding +all output. `approve_tools()` always returns `(True, None)`. + +### send_headless() + +```python +def send_headless( + self, + user_input: str, + max_turns: int = 10, + verbose: bool = False, + log_prefix: str = "", +) -> list[dict]: +``` + +Runs a complete multi-turn conversation: + +1. Appends the user message. +2. Calls the model API (non-streaming). +3. If tool calls are returned, executes them (with stdout suppressed) and + logs each call to `self.tool_call_log`. +4. Repeats up to `max_turns` or until the model responds without tool calls. +5. Returns the tool call log: list of dicts with keys `tool`, `args`, + `result` (truncated to 500 chars), and `turn`. + +Parallel tool calls are capped at 10 per turn to prevent degenerate repetition. + +### Retry Logic + +`send_headless()` is called inside `_run_single_test()` with retry logic: +3 attempts with exponential backoff (sleep `2^attempt` seconds) on any +exception. This prevents transient API errors from poisoning eval scores. + +--- + +## Test Execution + +Each test case runs in isolation: + +1. A fresh temp directory is created. +2. Setup files are written to the temp directory. +3. The working directory is changed to the temp directory. +4. A new `HeadlessSession` is created with the current developer prompt. +5. `send_headless()` runs the user prompt through the conversation loop. +6. The tool log is scored against expected actions. +7. The temp directory is cleaned up. + +The memory database is also isolated per test (an ephemeral SQLite database +in the temp directory) so tests do not pollute each other or the user's +real memory store. + +--- + +## Optimization Loop + +`run_optimization()` is the main entry point for iterative prompt optimization. + +### Flow + +``` +for iteration in 0..max_iterations: + 1. Run all test cases n_runs times with current prompt + 2. Score and aggregate results + 3. Save intermediate results to JSON + 4. If all tests pass -> stop + 5. Every 3 iterations (at iteration 2, 5, 8, ...): + -> Observer reviews optimizer strategy + -> Reset prompt to best-performing iteration + 6. Propose new prompt via optimizer model call + 7. If prompt unchanged -> stop + 8. Continue with new prompt +``` + +### Prompt Proposal (`_propose_prompt_modification`) + +Uses the model to rewrite the developer prompt based on test results: + +- **Input**: Current prompt, test case definitions, per-case results with + actual vs expected tool sequences, and a history of the last 3 iterations. +- **Optimizer system prompt** (`OPTIMIZER_SYSTEM`): Instructs the model to + act as a text rewriter. Key guidance includes: + - Address critical failure modes (text-only responses, write_file vs edit_file, + unnecessary search before create, missing plan calls). + - Preserve phrasing that drives 100% pass rate on passing tests. + - Use direct imperative style with concrete tool call examples. + - Stay within 130% of original prompt length. +- **Output**: The rewritten prompt text (stripped of reasoning tags and code fences). + +### Observer System (`_observe_and_update_optimizer`) + +Every 3 iterations, a meta-level "observer" reviews the optimizer's strategy: + +- Analyzes the iteration history: score trends, regressions, prompt length changes, + and diffs between iterations. +- Summarizes the optimizer's behavioral patterns (list style, header usage, length). +- Uses `OBSERVER_SYSTEM` to rewrite the optimizer's own system prompt. +- Rejects degenerate outputs (over 200% of input length). +- After updating the optimizer prompt, resets the developer prompt to the + best-performing iteration so far. + +This two-level optimization (optimizer + observer) helps the system escape +local minima and adjust its rewriting strategy. + +### Result Persistence + +After each iteration, results are written to the output JSON file. The +structure is: + +```json +{ + "meta": { + "model": "model-name", + "base_url": "http://localhost:8000/v1", + "started": "2025-01-01T00:00:00", + "test_suite": "tests.json", + "n_runs_default": 3 + }, + "iterations": [ + { + "iteration": 0, + "prompt": "the developer prompt used", + "prompt_diff": null, + "optimizer_system": "the optimizer system prompt", + "timestamp": "2025-01-01T00:01:00", + "cases": { + "test_name": { + "runs": [ + { + "pass": true, + "score": 1.0, + "matched": [0, 1], + "unmatched": [], + "extra_tools": [], + "detail": "Ordered subset: 2/2", + "tool_sequence": ["read_file", "edit_file"], + "tool_args": [{"read_file": {"path": "f.py"}}, ...], + "elapsed": 3.2 + } + ], + "pass_rate": 1.0, + "avg_score": 1.0 + } + }, + "aggregate": { + "total_cases": 5, + "total_runs": 15, + "overall_pass_rate": 0.8, + "overall_avg_score": 0.87, + "json_dumps": 0, + "per_case_pass_rates": {"test_name": 1.0, ...} + } + } + ] +} +``` + +--- + +## CLI Usage + +The entry point is `turnstone-eval` (installed as a console script) or +`python -m turnstone.eval`. + +``` +turnstone-eval tests.json # evaluate + optimize +turnstone-eval tests.json --no-optimize # evaluate only (single iteration) +turnstone-eval tests.json --n-runs 5 --max-iter 10 # more thorough evaluation +turnstone-eval tests.json --prompt custom.txt # start from a custom prompt +turnstone-eval tests.json -v # verbose per-turn logging +``` + +### All Options + +| Flag | Default | Description | +|---------------------|-------------------------------|-------------| +| `test_file` | (positional, required) | Path to test cases JSON file. | +| `--base-url` | `http://localhost:8000/v1` | API base URL. | +| `--model` | auto-detect | Model name. Auto-detected from the API if not specified. | +| `--prompt` | turnstone built-in prompt | Path to initial prompt text file. | +| `--n-runs` | from tests.json or 3 | Number of runs per test case. | +| `--max-iter` | 5 | Maximum optimization iterations. | +| `--no-optimize` | false | Run evaluation only (sets max-iter to 1). | +| `--temperature` | 0.7 | Sampling temperature. | +| `--max-tokens` | 32768 | Max completion tokens. | +| `--reasoning-effort` | `medium` | Reasoning effort: `low`, `medium`, or `high`. | +| `--context-window` | 131072 | Context window size. | +| `--output` | `eval_results.json` | Output results file path. | +| `-v`, `--verbose` | false | Show detailed per-turn logging (API calls, tool args, results). | + +### Precedence for n_runs + +The number of runs per test case is resolved in this order: + +1. Per-case `n_runs` field in the test case definition. +2. CLI `--n-runs` argument (if provided). +3. Suite-level `defaults.n_runs` in the test JSON file. +4. Code default: 3. diff --git a/docs/simulator.md b/docs/simulator.md new file mode 100644 index 00000000..7164ed27 --- /dev/null +++ b/docs/simulator.md @@ -0,0 +1,202 @@ +# Cluster Simulator + +The simulator (`turnstone-sim`) creates lightweight simulated nodes that talk to a real Redis instance using the standard turnstone protocol. External observers — `TurnstoneClient`, `turnstone-console`, real bridges — see identical behavior. No LLM backend is needed. + +## Quick Start + +```bash +pip install turnstone[sim] + +# 10 nodes, steady load, 60 seconds +turnstone-sim --nodes 10 --scenario steady --duration 60 --mps 5 + +# 100 nodes via Docker +docker compose --profile sim up redis console sim +``` + +## How It Works + +Each simulated node is an asyncio coroutine (not a thread or process), so 1000 nodes run efficiently on a single event loop. The simulator: + +1. Registers nodes via Redis heartbeats (same keys as real bridges) +2. Accepts messages from per-node and shared inbound queues +3. Simulates LLM responses with configurable latency and token generation +4. Simulates tool execution with configurable latency and failure rates +5. Publishes real protocol events (`ContentEvent`, `StateChangeEvent`, `TurnCompleteEvent`, etc.) +6. Reports latency, throughput, and utilization metrics at completion + +``` +TurnstoneClient → Redis Queue → SimNode → Redis Pub/Sub → TurnstoneClient + ↓ + turnstone-console (cluster dashboard) +``` + +## Scenarios + +| Scenario | Description | +|----------|-------------| +| `steady` | Inject messages at a constant rate (`--mps`) for `--duration` seconds | +| `burst` | Push `--burst-size` messages instantly, then wait for completion | +| `node_failure` | Steady load + periodically kill nodes to test redistribution | +| `directed` | Send messages to specific nodes via `target_node` routing | +| `lifecycle` | Create, use, and close workstreams across nodes | + +## CLI Reference + +``` +turnstone-sim [options] +``` + +### Cluster + +| Flag | Default | Description | +|------|---------|-------------| +| `--nodes` | `10` | Number of simulated nodes | + +### Scenario + +| Flag | Default | Description | +|------|---------|-------------| +| `--scenario` | `steady` | Scenario name | +| `--duration` | `60` | Duration in seconds | +| `--mps` | `5.0` | Messages per second (steady) | +| `--burst-size` | `100` | Messages to send (burst) | +| `--node-kill-interval` | `15` | Seconds between kills (node_failure) | +| `--node-kill-count` | `1` | Nodes per kill cycle | + +### Simulation + +| Flag | Default | Description | +|------|---------|-------------| +| `--llm-latency` | `2.0` | Mean LLM response latency (seconds) | +| `--tool-latency` | `0.5` | Mean tool execution latency (seconds) | +| `--tool-failure-rate` | `0.02` | Tool failure probability (0.0–1.0) | +| `--seed` | — | Random seed for reproducibility | + +### Redis + +| Flag | Default | Description | +|------|---------|-------------| +| `--redis-host` | `localhost` | Redis host | +| `--redis-port` | `6379` | Redis port | +| `--redis-password` | — | Redis password | +| `--prefix` | `turnstone` | Redis key prefix | + +### Output + +| Flag | Default | Description | +|------|---------|-------------| +| `--metrics-file` | — | Write JSON report to file | +| `--log-level` | `INFO` | Log verbosity | + +## Example: Load Testing + +```bash +# 100 nodes, high throughput, 2 minutes +turnstone-sim --nodes 100 --scenario steady --duration 120 --mps 50 + +# Burst of 500 messages across 50 nodes +turnstone-sim --nodes 50 --scenario burst --burst-size 500 --duration 60 + +# Node failure resilience (kill 2 nodes every 10 seconds) +turnstone-sim --nodes 20 --scenario node_failure --duration 120 \ + --node-kill-interval 10 --node-kill-count 2 + +# Fast simulation (low latency, no failures) +turnstone-sim --nodes 10 --scenario steady --duration 30 \ + --llm-latency 0.1 --tool-latency 0.05 --tool-failure-rate 0 --mps 10 +``` + +## Metrics Report + +The simulator prints a summary at completion: + +``` +============================================================ + SIMULATION REPORT +============================================================ + Scenario: steady + Nodes: 100 + Duration: 60.2s + Total turns: 295 + Total errors: 5 + Node kills: 0 +------------------------------------------------------------ + THROUGHPUT + Messages/sec: 4.97 + Turns/sec: 4.89 +------------------------------------------------------------ + LATENCY (seconds) + p50: 3.21 + p90: 5.44 + p99: 8.12 + mean: 3.56 + max: 12.1 +------------------------------------------------------------ + UTILIZATION + Mean ws/node: 2.3 + Max ws/node: 8 + Idle nodes: 12 +============================================================ +``` + +Use `--metrics-file report.json` to write the full report as JSON. + +## Console Integration + +The simulator's nodes appear in `turnstone-console` exactly like real nodes. Run them together to see the dashboard populate with simulated workstreams: + +```bash +# Terminal 1: start Redis and console +docker compose up redis console + +# Terminal 2: run simulator +docker compose --profile sim up sim +``` + +Or all at once: + +```bash +SIM_NODES=50 SIM_DURATION=120 docker compose --profile sim up redis console sim +``` + +Open http://localhost:8090 to see simulated nodes, workstream states, token counts, and load bars updating in real time. + +## Architecture + +``` +turnstone/sim/ +├── __init__.py # Public API: SimCluster, SimConfig +├── config.py # SimConfig — all simulation parameters +├── engine.py # SimEngine — LLM + tool execution simulation +├── node.py # SimNode + SimWorkstream — protocol-compatible node +├── cluster.py # SimCluster + InboundDispatcher + PooledBroker +├── scenario.py # 5 scenario classes +├── metrics.py # MetricsCollector — latency, throughput, utilization +└── cli.py # CLI entry point +``` + +**Key design:** The `InboundDispatcher` batches ~50 node queues into a single Redis `BLPOP` call, keeping connection count bounded at ~20 regardless of node count. All nodes share a single `ConnectionPool(max_connections=64)`. + +## Programmatic Use + +```python +import asyncio +from turnstone.sim import SimCluster, SimConfig + +async def main(): + config = SimConfig( + num_nodes=10, + scenario="steady", + duration=30, + messages_per_second=2.0, + llm_latency_mean=0.5, + ) + cluster = SimCluster(config) + await cluster.start() + await cluster.run_scenario() + print(cluster.report()) + await cluster.stop() + +asyncio.run(main()) +``` diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 00000000..bac98a1a --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,394 @@ +# Tools Reference + +turnstone exposes 14 tools to the LLM via the OpenAI function-calling interface. +Each tool is defined as a JSON file under `turnstone/tools/` and loaded at startup +by `turnstone/core/tools.py`. + +--- + +## Tool Schema Format + +Each JSON file in `turnstone/tools/` contains a standard OpenAI function-calling +schema plus turnstone-specific metadata keys: + +```json +{ + "name": "tool_name", + "description": "What the tool does.", + "parameters": { + "type": "object", + "properties": { ... }, + "required": ["param1"] + }, + "agent": true, + "task_agent": true, + "auto_approve": true, + "primary_key": "param1" +} +``` + +**Metadata keys** (stripped before sending the schema to the model): + +| Key | Type | Meaning | +|----------------|------|---------| +| `agent` | bool | Tool is available to plan/task sub-agents (read-only subset). | +| `task_agent` | bool | Tool is available to task sub-agents (broader subset). | +| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). | +| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. | + +--- + +## Derived Tool Sets + +`turnstone/core/tools.py` loads all JSON files and derives these collections: + +| Name | Description | +|---------------------|-------------| +| `TOOLS` | All 14 tool definitions (sent to the model). | +| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. | +| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. | +| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. | +| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). | +| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. | + +--- + +## Execution Pipeline + +Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools()`: + +### Phase 1: Prepare + +`_prepare_tool(tc)` is called for each tool call returned by the model. + +- Parses the JSON arguments (with fallback for malformed JSON). +- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string + to the correct parameter. +- Validates arguments and builds a preview dict containing: + - `call_id`, `func_name`, `header`, `preview` (for display) + - `needs_approval` (bool) + - `execute` (callable to run the tool) + - `error` (set if validation fails; tool will not execute) + +### Phase 2: Approve + +All prepared items are sent to the UI via `ui.approve_tools(items)`. + +- The UI displays each tool's header and preview to the user. +- Items where `needs_approval` is `False` (auto-approved tools) are shown + but do not block execution. +- Items where `needs_approval` is `True` require the user to accept or deny. +- The user can provide feedback alongside their approval (e.g. "y, use full path"). +- If `auto_approve` is `True` on the session (headless mode), all tools are + approved automatically. + +### Phase 3: Execute + +Each item's `execute` callable is invoked: + +- Single tool calls run directly on the current thread. +- Multiple tool calls run in parallel via `ThreadPoolExecutor(max_workers=4)`. +- Errored or denied items return their error/denial message without executing. +- Special post-execution gate for `plan`: the plan output is shown to the user + for review, and the user can reject or annotate it. + +--- + +## Tool Approval Flow + +**Auto-approved** (no user confirmation needed at runtime): +- `read_file` -- reads files, no side effects +- `search` -- grep-style search, no side effects +- `man` -- reads man pages, no side effects +- `remember` -- writes to persistent memory database (lightweight, always auto-approved) +- `recall` -- reads from persistent memory database +- `forget` -- deletes from persistent memory database (lightweight, always auto-approved) + +**Requires user confirmation** (write operations, network access, side effects): +- `bash` -- arbitrary command execution +- `write_file` -- creates or overwrites files +- `edit_file` -- modifies file content +- `math` -- sandboxed computation (confirmation required despite being sandboxed) +- `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests) +- `web_search` -- web search via Tavily API (makes network requests) +- `task` -- spawns an autonomous sub-agent +- `plan` -- spawns a planning sub-agent, plus post-execution review gate + +Note: The JSON schema metadata key `auto_approve` controls membership in +`AGENT_AUTO_TOOLS`/`TASK_AUTO_TOOLS` (used for agent sub-sessions). The actual +runtime approval behavior is determined by the `needs_approval` field set in +each `_prepare_*` method on `ChatSession`. These two mechanisms can differ. + +--- + +## Primary Key Fallback + +When the model sends a bare string instead of a JSON object as tool arguments +(common with smaller models), the `primary_key` mapping rescues the call: + +``` +Model sends: bash("ls -la") + raw_args = "ls -la" (not valid JSON) + +PRIMARY_KEY_MAP["bash"] = "command" +Result: args = {"command": "ls -la"} +``` + +Every tool defines a `primary_key`. The mapping is: + +| Tool | primary_key | +|--------------|-------------| +| `bash` | `command` | +| `read_file` | `path` | +| `write_file` | `content` | +| `edit_file` | `old_string`| +| `search` | `query` | +| `math` | `code` | +| `man` | `page` | +| `web_fetch` | `url` | +| `web_search` | `query` | +| `task` | `prompt` | +| `plan` | `prompt` | +| `remember` | `key` | +| `recall` | `query` | +| `forget` | `key` | + +--- + +## File Operations + +### bash + +Execute a bash command and return stdout + stderr. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `command` | string | yes | The bash command to execute. | + +- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). +- **Auto-approve**: No -- requires user confirmation. +- **Agent availability**: `task_agent` only (not available to plan sub-agents). + +--- + +### read_file + +Read the contents of a file, returning numbered lines. + +| Parameter | Type | Required | Description | +|-----------|---------|----------|-------------| +| `path` | string | yes | Absolute or relative file path. | +| `offset` | integer | no | Line number to start from (1-based, default: 1). | +| `limit` | integer | no | Maximum number of lines to read. Omit for full file. | + +- **What it does**: Reads the file and returns content with line numbers. Must be called before `edit_file` on the same path (the session tracks which files have been read). +- **Auto-approve**: Yes. +- **Agent availability**: `agent` and `task_agent`. + +--- + +### write_file + +Write content to a file, creating it if needed. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `path` | string | yes | Absolute or relative file path. | +| `content` | string | yes | The full file content to write. | + +- **What it does**: Creates or overwrites the file at the given path. Parent directories are created as needed. +- **Auto-approve**: No -- requires user confirmation. +- **Agent availability**: `task_agent` only. + +--- + +### edit_file + +Replace an exact string in a file with new content. + +| Parameter | Type | Required | Description | +|--------------|---------|----------|-------------| +| `path` | string | yes | Absolute or relative file path. | +| `old_string` | string | yes | The exact text to find and replace. | +| `new_string` | string | yes | The replacement text. | +| `near_line` | integer | no | Disambiguate when `old_string` matches multiple locations. | + +- **What it does**: Finds `old_string` in the file and replaces it with `new_string`. Fails if the string is not found or matches multiple locations (unless `near_line` is provided to pick the nearest match). Requires a prior `read_file` call on the same path. +- **Auto-approve**: No -- requires user confirmation. +- **Agent availability**: `task_agent` only. + +--- + +### search + +Search file contents for a regex pattern. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `query` | string | yes | Regex pattern (extended regex). | +| `path` | string | no | File or directory to search in (default: current directory). | + +- **What it does**: Recursively searches for the pattern using `grep -rn`. Returns matching lines with file paths and line numbers. +- **Auto-approve**: Yes. +- **Agent availability**: `agent` and `task_agent`. + +--- + +## Computation + +### math + +Execute Python code for math and computation in a sandbox. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `code` | string | yes | Python code to execute. Must use `print()` for output. | + +- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported. +- **Auto-approve**: No -- requires user confirmation. +- **Agent availability**: `agent` and `task_agent`. + +--- + +## Information + +### man + +Read a man page. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `page` | string | yes | The man page name (e.g. `grep`, `socket`, `printf`). | +| `section` | string | no | Manual section (e.g. `1` commands, `2` syscalls, `3` library). | + +- **What it does**: Returns the full formatted manual entry. Preferred over `bash('man ...')` or `web_search` for command/API documentation. +- **Auto-approve**: Yes. +- **Agent availability**: `agent` and `task_agent`. + +--- + +### web_fetch + +Fetch a URL and extract specific information from it. + +| Parameter | Type | Required | Description | +|------------|--------|----------|-------------| +| `url` | string | yes | The URL to fetch (must start with `http://` or `https://`). | +| `question` | string | yes | What to extract or answer from the page content. | + +- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Protected against SSRF (blocks private/internal IPs). +- **Auto-approve**: No -- requires user confirmation (makes network requests). +- **Agent availability**: `agent` and `task_agent`. + +--- + +### web_search + +Search the web using a text query. + +| Parameter | Type | Required | Description | +|---------------|---------|----------|-------------| +| `query` | string | yes | The search query. | +| `max_results` | integer | no | Max results to return (default 5, max 20). | +| `topic` | string | no | Search topic: `general`, `news`, or `finance` (default `general`). | + +- **What it does**: Searches the web via the Tavily API and returns ranked results with titles, URLs, and content snippets. +- **Auto-approve**: No -- requires user confirmation (makes network requests). +- **Agent availability**: `agent` and `task_agent`. + +--- + +## Agent + +### task + +Delegate a general-purpose task to an autonomous sub-agent. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `prompt` | string | yes | Complete task description for the sub-agent. | + +- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, math, man, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution. +- **Auto-approve**: No -- requires user confirmation. +- **Agent availability**: Not available to sub-agents (top-level only). + +--- + +### plan + +Plan before implementing -- an autonomous agent explores the codebase and writes a structured plan. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `prompt` | string | yes | What to plan -- the goal, constraints, and scope. | + +- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-.md` (unique per session, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan. +- **Auto-approve**: No -- requires user confirmation, plus post-execution review gate. +- **Agent availability**: Not available to sub-agents (top-level only). + +--- + +## Memory + +### remember + +Save a persistent memory that persists across sessions. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `key` | string | yes | Short identifier (e.g. `user_name`). | +| `value` | string | yes | Content to remember. | + +- **What it does**: Stores a key-value pair in the SQLite memory database. Memories persist across sessions and are included in the system prompt on startup. +- **Auto-approve**: Yes. +- **Agent availability**: Not available to sub-agents (top-level only). + +--- + +### recall + +Search memories and past conversations. + +| Parameter | Type | Required | Description | +|-----------|---------|----------|-------------| +| `query` | string | no | Search term or phrase. Omit to list all memories. | +| `limit` | integer | no | Max conversation results to return (default 20). | + +- **What it does**: With no query, lists all saved memories. With a query, searches both the memory store and conversation history using FTS5 full-text search. +- **Auto-approve**: Yes. +- **Agent availability**: Not available to sub-agents (top-level only). + +--- + +### forget + +Remove a persistent memory by key. + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| `key` | string | yes | The memory key to remove (e.g. `user_name`). | + +- **What it does**: Deletes the memory entry with the given key from the SQLite database. +- **Auto-approve**: Yes. +- **Agent availability**: Not available to sub-agents (top-level only). + +--- + +## Summary Table + +| Tool | Category | Auto-approve | agent | task_agent | primary_key | +|--------------|------------|--------------|-------|------------|-------------| +| `bash` | File Ops | No | No | Yes | `command` | +| `read_file` | File Ops | Yes | Yes | Yes | `path` | +| `write_file` | File Ops | No | No | Yes | `content` | +| `edit_file` | File Ops | No | No | Yes | `old_string`| +| `search` | File Ops | Yes | Yes | Yes | `query` | +| `math` | Compute | No | Yes | Yes | `code` | +| `man` | Info | Yes | Yes | Yes | `page` | +| `web_fetch` | Info | No | Yes | Yes | `url` | +| `web_search` | Info | No | Yes | Yes | `query` | +| `task` | Agent | No | No | No | `prompt` | +| `plan` | Agent | No | No | No | `prompt` | +| `remember` | Memory | Yes | No | No | `key` | +| `recall` | Memory | Yes | No | No | `query` | +| `forget` | Memory | Yes | No | No | `key` | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..ed88ef87 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "turnstone" +version = "0.2.0" +description = "AI chat client with tool use, agent tools, and persistent memory." +readme = "README.md" +license = "BUSL-1.1" +requires-python = ">=3.11" +dependencies = ["openai>=1.0", "httpx>=0.24"] + +[project.optional-dependencies] +test = ["pytest>=7.0"] +mq = ["redis>=5.0"] +console = ["redis>=5.0"] +sim = ["redis>=5.0"] + +[project.scripts] +turnstone = "turnstone.cli:main" +turnstone-eval = "turnstone.eval:main" +turnstone-server = "turnstone.server:main" +turnstone-bridge = "turnstone.mq.bridge:main" +turnstone-console = "turnstone.console.server:main" +turnstone-sim = "turnstone.sim.cli:main" + +[tool.hatch.build.targets.wheel] +include = [ + "turnstone/**/*.py", + "turnstone/tools/*.json", + "turnstone/ui/static/*.html", + "turnstone/ui/static/*.css", + "turnstone/ui/static/*.js", + "turnstone/console/static/*.html", + "turnstone/console/static/*.css", + "turnstone/console/static/*.js", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests.json b/tests.json new file mode 100644 index 00000000..c813cbf1 --- /dev/null +++ b/tests.json @@ -0,0 +1,180 @@ +{ + "description": "pcode behavior tests — tool selection, sequencing, and multi-step reasoning", + "defaults": { + "n_runs": 5, + "max_turns": 15 + }, + "cases": [ + { + "id": "read-before-edit", + "description": "Must read_file before edit_file on the same path", + "user_prompt": "Fix the typo in config.py — change 'recieve' to 'receive'", + "setup": { + "files": { + "config.py": "# Config module\ndef recieve_data(source):\n \"\"\"Recieve data from source.\"\"\"\n return source.read()\n" + } + }, + "expected_actions": [ + { "tool": "read_file", "args": { "path": "config.py" } }, + { "tool": "edit_file", "args": { "path": "config.py" } } + ], + "match_mode": "ordered_subset" + }, + { + "id": "write-file-not-bash", + "description": "Use write_file for file creation, not bash echo/cat", + "user_prompt": "Create a file called hello.py that prints hello world", + "expected_actions": [ + { "tool": "write_file", "args_pattern": { "path": "hello\\.py" } } + ], + "match_mode": "subset" + }, + { + "id": "bash-for-commands", + "description": "Use bash for running system commands", + "user_prompt": "What Python version is installed?", + "expected_actions": [ + { "tool": "bash", "args_pattern": { "command": "python" } } + ], + "match_mode": "subset" + }, + { + "id": "search-for-patterns", + "description": "Use search tool for finding code patterns across files", + "user_prompt": "Find all functions that start with 'test_' in the project", + "setup": { + "files": { + "tests.py": "def test_one():\n assert True\n\ndef test_two():\n assert 1 + 1 == 2\n", + "main.py": "def main():\n print('hello')\n" + } + }, + "expected_actions": [ + { "tool": "search", "args_pattern": { "query": "test_" } } + ], + "match_mode": "subset" + }, + { + "id": "multi-file-edit", + "description": "Read and edit multiple files — must read before editing each, and edit both", + "user_prompt": "Change the default port from 8000 to 9000 in both server.py and config.py", + "setup": { + "files": { + "server.py": "from config import PORT\n\ndef run():\n print(f'Listening on port {PORT}')\n", + "config.py": "PORT = 8000\nHOST = 'localhost'\n" + } + }, + "expected_actions": [ + { "tool": "read_file" }, + { "tool": "read_file" }, + { "tool": "edit_file" }, + { "tool": "edit_file" } + ], + "match_mode": "ordered_subset" + }, + { + "id": "search-then-edit", + "description": "Search to find where something is defined, then read and edit", + "user_prompt": "Find where MAX_RETRIES is defined and change it from 3 to 5", + "setup": { + "files": { + "app.py": "import os\n\ndef start():\n pass\n", + "utils.py": "import time\n\ndef helper():\n pass\n", + "config/settings.py": "# Settings\nDEBUG = True\nMAX_RETRIES = 3\nTIMEOUT = 30\n" + } + }, + "expected_actions": [ + { "tool": "search", "args_pattern": { "query": "MAX_RETRIES" } }, + { "tool": "read_file" }, + { "tool": "edit_file", "args_pattern": { "old_string": "3" } } + ], + "match_mode": "ordered_subset" + }, + { + "id": "bash-git-log", + "description": "Use bash for git commands, not other tools", + "user_prompt": "Show me the git log for the last 5 commits", + "expected_actions": [ + { "tool": "bash", "args_pattern": { "command": "git\\s+log" } } + ], + "match_mode": "subset" + }, + { + "id": "write-then-run", + "description": "Create a script and run it to verify it works", + "user_prompt": "Create a Python script called fib.py that prints the first 10 Fibonacci numbers, then run it to verify", + "expected_actions": [ + { "tool": "write_file", "args_pattern": { "path": "fib\\.py" } }, + { "tool": "bash", "args_pattern": { "command": "python" } } + ], + "match_mode": "ordered_subset" + }, + { + "id": "no-bash-for-file-write", + "description": "Should NOT use bash (echo/cat/heredoc) to create files — only write_file", + "user_prompt": "Create a new file called README.md with a title and description of this project", + "expected_actions": [ + { "tool": "write_file", "args_pattern": { "path": "README" } } + ], + "match_mode": "subset" + }, + { + "id": "plan-before-refactor", + "description": "Use the plan tool before a large refactoring task", + "user_prompt": "I need to refactor this codebase to separate the database layer from the API layer. Use the plan tool to think through the approach before making any changes.", + "setup": { + "files": { + "app.py": "import sqlite3\nfrom flask import Flask, jsonify\n\napp = Flask(__name__)\nDB = 'data.db'\n\ndef get_db():\n return sqlite3.connect(DB)\n\n@app.route('/users')\ndef list_users():\n db = get_db()\n users = db.execute('SELECT * FROM users').fetchall()\n db.close()\n return jsonify(users)\n\n@app.route('/users/')\ndef get_user(uid):\n db = get_db()\n user = db.execute('SELECT * FROM users WHERE id=?', (uid,)).fetchone()\n db.close()\n return jsonify(user)\n\nif __name__ == '__main__':\n app.run(port=8000)\n" + } + }, + "expected_actions": [{ "tool": "plan" }], + "match_mode": "subset" + }, + { + "id": "edit-not-rewrite", + "description": "Use edit_file for small changes, not write_file to rewrite the entire file", + "user_prompt": "Add a docstring to the process_data function in utils.py", + "setup": { + "files": { + "utils.py": "import os\nimport sys\n\n\ndef helper():\n \"\"\"A helper function.\"\"\"\n return 42\n\n\ndef process_data(items):\n results = []\n for item in items:\n if item > 0:\n results.append(item * 2)\n return results\n\n\ndef cleanup():\n \"\"\"Clean up resources.\"\"\"\n pass\n" + } + }, + "expected_actions": [ + { "tool": "read_file", "args": { "path": "utils.py" } }, + { "tool": "edit_file", "args": { "path": "utils.py" } } + ], + "match_mode": "ordered_subset" + }, + { + "id": "bash-run-tests", + "description": "Use bash to run a test suite", + "user_prompt": "Run the tests", + "setup": { + "files": { + "test_math.py": "def test_add():\n assert 1 + 1 == 2\n\ndef test_mul():\n assert 2 * 3 == 6\n" + } + }, + "expected_actions": [ + { "tool": "bash", "args_pattern": { "command": "pytest|python.*test" } } + ], + "match_mode": "subset" + }, + { + "id": "web-fetch-url", + "description": "Use web_fetch when asked to retrieve content from a URL", + "user_prompt": "Fetch the contents of https://example.com and summarize what's on the page", + "expected_actions": [ + { "tool": "web_fetch", "args_pattern": { "url": "example\\.com" } } + ], + "match_mode": "subset" + }, + { + "id": "man-page-lookup", + "description": "Use man tool to look up command documentation", + "user_prompt": "Look up the man page for tar and tell me what the --xattrs flag does", + "expected_actions": [ + { "tool": "man", "args_pattern": { "page": "tar" } } + ], + "match_mode": "subset" + } + ] +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..68c3ea11 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest +from unittest.mock import MagicMock + + +@pytest.fixture +def tmp_db(tmp_path, monkeypatch): + """Provide a temporary SQLite database.""" + import turnstone.core.memory as memory + + db_path = str(tmp_path / "test.db") + monkeypatch.setattr(memory, "db_override", db_path) + memory.db_initialized.discard(db_path) + yield db_path + memory.db_initialized.discard(db_path) + + +@pytest.fixture +def mock_openai_client(): + """Return a minimal mock OpenAI client.""" + client = MagicMock() + client.models.list.return_value.data = [MagicMock(id="test-model")] + return client diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 00000000..f7103c95 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,1010 @@ +"""Tests for turnstone.core.auth — bearer token authentication and cookies.""" + +import os +from unittest.mock import patch + +import pytest + +from turnstone.core.auth import ( + AuthConfig, + WRITE_PATHS, + _extract_bearer, + _extract_cookie, + check_request, + is_public_path, + load_auth_config, + make_clear_cookie, + make_set_cookie, + required_role, +) + + +# --------------------------------------------------------------------------- +# TestIsPublicPath +# --------------------------------------------------------------------------- + + +class TestIsPublicPath: + def test_root(self): + assert is_public_path("/") is True + + def test_health(self): + assert is_public_path("/health") is True + + def test_metrics(self): + assert is_public_path("/metrics") is True + + def test_static_css(self): + assert is_public_path("/static/style.css") is True + + def test_static_js(self): + assert is_public_path("/static/app.js") is True + + def test_static_subdir(self): + assert is_public_path("/static/fonts/mono.woff2") is True + + def test_api_workstreams_not_public(self): + assert is_public_path("/api/workstreams") is False + + def test_api_send_not_public(self): + assert is_public_path("/api/send") is False + + def test_api_cluster_overview_not_public(self): + assert is_public_path("/api/cluster/overview") is False + + def test_api_events_not_public(self): + assert is_public_path("/api/events") is False + + +# --------------------------------------------------------------------------- +# TestRequiredRole +# --------------------------------------------------------------------------- + + +class TestRequiredRole: + def test_get_api_needs_read(self): + assert required_role("GET", "/api/workstreams") == "read" + + def test_get_events_needs_read(self): + assert required_role("GET", "/api/events") == "read" + + def test_get_dashboard_needs_read(self): + assert required_role("GET", "/api/dashboard") == "read" + + def test_post_send_needs_full(self): + assert required_role("POST", "/api/send") == "full" + + def test_post_approve_needs_full(self): + assert required_role("POST", "/api/approve") == "full" + + def test_post_plan_needs_full(self): + assert required_role("POST", "/api/plan") == "full" + + def test_post_command_needs_full(self): + assert required_role("POST", "/api/command") == "full" + + def test_post_workstreams_new_needs_full(self): + assert required_role("POST", "/api/workstreams/new") == "full" + + def test_post_workstreams_close_needs_full(self): + assert required_role("POST", "/api/workstreams/close") == "full" + + def test_all_write_paths_need_full(self): + for path in WRITE_PATHS: + assert required_role("POST", path) == "full" + + def test_post_unknown_path_needs_read(self): + assert required_role("POST", "/api/unknown") == "read" + + +# --------------------------------------------------------------------------- +# TestAuthConfig +# --------------------------------------------------------------------------- + + +class TestAuthConfig: + def test_check_valid_full_token(self): + cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"}) + assert cfg.check("tok_full") == "full" + + def test_check_valid_read_token(self): + cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"}) + assert cfg.check("tok_read") == "read" + + def test_check_invalid_token(self): + cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"}) + assert cfg.check("wrong") is None + + def test_check_none_token(self): + cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"}) + assert cfg.check(None) is None + + def test_check_empty_token(self): + cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"}) + assert cfg.check("") is None + + def test_check_no_tokens(self): + cfg = AuthConfig(enabled=True, tokens={}) + assert cfg.check("anything") is None + + +# --------------------------------------------------------------------------- +# TestExtractBearer +# --------------------------------------------------------------------------- + + +class TestExtractBearer: + def test_valid_bearer(self): + assert _extract_bearer("Bearer tok_abc123") == "tok_abc123" + + def test_case_insensitive(self): + assert _extract_bearer("bearer tok_abc123") == "tok_abc123" + + def test_mixed_case(self): + assert _extract_bearer("BEARER tok_abc123") == "tok_abc123" + + def test_no_bearer_prefix(self): + assert _extract_bearer("tok_abc123") is None + + def test_basic_auth_ignored(self): + assert _extract_bearer("Basic dXNlcjpwYXNz") is None + + def test_none(self): + assert _extract_bearer(None) is None + + def test_empty(self): + assert _extract_bearer("") is None + + def test_bearer_only_no_token(self): + assert _extract_bearer("Bearer") is None + + def test_token_with_spaces(self): + # Only the first space separates scheme from token + assert _extract_bearer("Bearer tok with spaces") == "tok with spaces" + + +# --------------------------------------------------------------------------- +# TestExtractCookie +# --------------------------------------------------------------------------- + + +class TestExtractCookie: + def test_single_cookie(self): + assert _extract_cookie("turnstone_auth=tok_abc", "turnstone_auth") == "tok_abc" + + def test_multiple_cookies(self): + header = "theme=dark; turnstone_auth=tok_abc; other=val" + assert _extract_cookie(header, "turnstone_auth") == "tok_abc" + + def test_missing_cookie(self): + assert _extract_cookie("theme=dark; other=val", "turnstone_auth") is None + + def test_none_header(self): + assert _extract_cookie(None, "turnstone_auth") is None + + def test_empty_header(self): + assert _extract_cookie("", "turnstone_auth") is None + + def test_spaces_around_value(self): + assert ( + _extract_cookie("turnstone_auth = tok_abc ", "turnstone_auth") == "tok_abc" + ) + + def test_no_equals(self): + assert _extract_cookie("malformed", "turnstone_auth") is None + + +# --------------------------------------------------------------------------- +# TestMakeSetCookie / TestMakeClearCookie +# --------------------------------------------------------------------------- + + +class TestMakeSetCookie: + def test_contains_token(self): + val = make_set_cookie("tok_abc") + assert "turnstone_auth=tok_abc" in val + + def test_httponly(self): + assert "HttpOnly" in make_set_cookie("tok_abc") + + def test_samesite_lax(self): + assert "SameSite=Lax" in make_set_cookie("tok_abc") + + def test_path(self): + assert "Path=/" in make_set_cookie("tok_abc") + + def test_max_age_default(self): + val = make_set_cookie("tok_abc") + assert "Max-Age=2592000" in val # 30 days + + def test_max_age_custom(self): + val = make_set_cookie("tok_abc", max_age=3600) + assert "Max-Age=3600" in val + + +class TestMakeClearCookie: + def test_max_age_zero(self): + assert "Max-Age=0" in make_clear_cookie() + + def test_empty_value(self): + assert "turnstone_auth=;" in make_clear_cookie() + + def test_httponly(self): + assert "HttpOnly" in make_clear_cookie() + + +# --------------------------------------------------------------------------- +# TestCheckRequest +# --------------------------------------------------------------------------- + + +class TestCheckRequest: + """Tests for the main check_request() entry point.""" + + @pytest.fixture() + def disabled(self): + return AuthConfig(enabled=False) + + @pytest.fixture() + def enabled(self): + return AuthConfig( + enabled=True, + tokens={"tok_full": "full", "tok_read": "read"}, + ) + + def test_disabled_allows_all(self, disabled): + allowed, status, msg = check_request(disabled, "POST", "/api/send", None) + assert allowed is True + assert status == 200 + + def test_disabled_allows_no_header(self, disabled): + allowed, status, msg = check_request(disabled, "GET", "/api/workstreams", None) + assert allowed is True + + def test_public_path_no_token_ok(self, enabled): + allowed, status, msg = check_request(enabled, "GET", "/health", None) + assert allowed is True + assert status == 200 + + def test_public_root_no_token_ok(self, enabled): + allowed, status, msg = check_request(enabled, "GET", "/", None) + assert allowed is True + + def test_public_static_no_token_ok(self, enabled): + allowed, status, msg = check_request(enabled, "GET", "/static/style.css", None) + assert allowed is True + + def test_api_no_token_401(self, enabled): + allowed, status, msg = check_request(enabled, "GET", "/api/workstreams", None) + assert allowed is False + assert status == 401 + assert "Unauthorized" in msg + + def test_api_invalid_token_401(self, enabled): + allowed, status, msg = check_request( + enabled, "GET", "/api/workstreams", "Bearer wrong_token" + ) + assert allowed is False + assert status == 401 + + def test_api_read_token_ok(self, enabled): + allowed, status, msg = check_request( + enabled, "GET", "/api/workstreams", "Bearer tok_read" + ) + assert allowed is True + assert status == 200 + + def test_api_full_token_ok(self, enabled): + allowed, status, msg = check_request( + enabled, "GET", "/api/workstreams", "Bearer tok_full" + ) + assert allowed is True + + def test_write_read_token_403(self, enabled): + allowed, status, msg = check_request( + enabled, "POST", "/api/send", "Bearer tok_read" + ) + assert allowed is False + assert status == 403 + assert "Forbidden" in msg + + def test_write_full_token_ok(self, enabled): + allowed, status, msg = check_request( + enabled, "POST", "/api/send", "Bearer tok_full" + ) + assert allowed is True + assert status == 200 + + def test_approve_read_token_403(self, enabled): + allowed, status, msg = check_request( + enabled, "POST", "/api/approve", "Bearer tok_read" + ) + assert allowed is False + assert status == 403 + + def test_approve_full_token_ok(self, enabled): + allowed, status, msg = check_request( + enabled, "POST", "/api/approve", "Bearer tok_full" + ) + assert allowed is True + + def test_no_auth_header_string(self, enabled): + allowed, status, msg = check_request(enabled, "GET", "/api/dashboard", "") + assert allowed is False + assert status == 401 + + +# --------------------------------------------------------------------------- +# TestCheckRequestWithCookie +# --------------------------------------------------------------------------- + + +class TestCheckRequestWithCookie: + """Tests for cookie-based auth fallback in check_request.""" + + @pytest.fixture() + def enabled(self): + return AuthConfig( + enabled=True, + tokens={"tok_full": "full", "tok_read": "read"}, + ) + + def test_cookie_fallback_when_no_bearer(self, enabled): + allowed, status, _ = check_request( + enabled, + "GET", + "/api/workstreams", + None, + cookie_header="turnstone_auth=tok_read", + ) + assert allowed is True + assert status == 200 + + def test_bearer_takes_precedence_over_cookie(self, enabled): + # Bearer is full, cookie is read — Bearer should win + allowed, status, _ = check_request( + enabled, + "POST", + "/api/send", + "Bearer tok_full", + cookie_header="turnstone_auth=tok_read", + ) + assert allowed is True + + def test_invalid_cookie_401(self, enabled): + allowed, status, _ = check_request( + enabled, + "GET", + "/api/workstreams", + None, + cookie_header="turnstone_auth=wrong_token", + ) + assert allowed is False + assert status == 401 + + def test_cookie_read_on_write_403(self, enabled): + allowed, status, _ = check_request( + enabled, + "POST", + "/api/send", + None, + cookie_header="turnstone_auth=tok_read", + ) + assert allowed is False + assert status == 403 + + def test_cookie_full_on_write_ok(self, enabled): + allowed, status, _ = check_request( + enabled, + "POST", + "/api/send", + None, + cookie_header="turnstone_auth=tok_full", + ) + assert allowed is True + + def test_no_cookie_no_bearer_401(self, enabled): + allowed, status, _ = check_request( + enabled, + "GET", + "/api/workstreams", + None, + cookie_header=None, + ) + assert allowed is False + assert status == 401 + + def test_login_path_public(self, enabled): + allowed, status, _ = check_request( + enabled, + "POST", + "/api/auth/login", + None, + ) + assert allowed is True + + def test_logout_path_public(self, enabled): + allowed, status, _ = check_request( + enabled, + "POST", + "/api/auth/logout", + None, + ) + assert allowed is True + + +# --------------------------------------------------------------------------- +# TestLoadAuthConfig +# --------------------------------------------------------------------------- + + +class TestLoadAuthConfig: + """Tests for load_auth_config with mocked config + env vars.""" + + def test_default_disabled(self): + with patch("turnstone.core.config.load_config", return_value={}): + cfg = load_auth_config() + assert cfg.enabled is False + assert cfg.tokens == {} + + def test_config_file_tokens(self): + mock_cfg = { + "enabled": True, + "tokens": [ + {"value": "tok_a", "role": "full"}, + {"value": "tok_b", "role": "read"}, + ], + } + with ( + patch("turnstone.core.config.load_config", return_value=mock_cfg), + patch.dict(os.environ, {}, clear=True), + ): + cfg = load_auth_config() + assert cfg.enabled is True + assert cfg.tokens == {"tok_a": "full", "tok_b": "read"} + + def test_env_var_enabled(self): + with ( + patch("turnstone.core.config.load_config", return_value={}), + patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "1"}, clear=False), + ): + cfg = load_auth_config() + assert cfg.enabled is True + + def test_env_var_token(self): + with ( + patch("turnstone.core.config.load_config", return_value={}), + patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False), + ): + cfg = load_auth_config() + assert "tok_env" in cfg.tokens + assert cfg.tokens["tok_env"] == "full" + + def test_config_plus_env_merge(self): + mock_cfg = { + "enabled": True, + "tokens": [{"value": "tok_cfg", "role": "read"}], + } + with ( + patch("turnstone.core.config.load_config", return_value=mock_cfg), + patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False), + ): + cfg = load_auth_config() + assert cfg.tokens["tok_cfg"] == "read" + assert cfg.tokens["tok_env"] == "full" + + def test_invalid_role_skipped(self): + mock_cfg = { + "enabled": True, + "tokens": [ + {"value": "tok_ok", "role": "full"}, + {"value": "tok_bad", "role": "admin"}, + ], + } + with ( + patch("turnstone.core.config.load_config", return_value=mock_cfg), + patch.dict(os.environ, {}, clear=True), + ): + cfg = load_auth_config() + assert "tok_ok" in cfg.tokens + assert "tok_bad" not in cfg.tokens + + def test_empty_value_skipped(self): + mock_cfg = { + "enabled": True, + "tokens": [{"value": "", "role": "full"}], + } + with ( + patch("turnstone.core.config.load_config", return_value=mock_cfg), + patch.dict(os.environ, {}, clear=True), + ): + cfg = load_auth_config() + assert len(cfg.tokens) == 0 + + def test_non_dict_token_entry_skipped(self): + mock_cfg = { + "enabled": True, + "tokens": ["not_a_dict", {"value": "tok_ok", "role": "full"}], + } + with ( + patch("turnstone.core.config.load_config", return_value=mock_cfg), + patch.dict(os.environ, {}, clear=True), + ): + cfg = load_auth_config() + assert cfg.tokens == {"tok_ok": "full"} + + def test_env_enabled_true(self): + with ( + patch("turnstone.core.config.load_config", return_value={}), + patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "true"}, clear=False), + ): + cfg = load_auth_config() + assert cfg.enabled is True + + def test_env_enabled_yes(self): + with ( + patch("turnstone.core.config.load_config", return_value={}), + patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "yes"}, clear=False), + ): + cfg = load_auth_config() + assert cfg.enabled is True + + +# --------------------------------------------------------------------------- +# Integration tests — actual HTTP server with auth enabled +# --------------------------------------------------------------------------- + + +class TestServerAuth: + """Spin up a real turnstone-server with auth enabled and test endpoints.""" + + @classmethod + def setup_class(cls): + import queue + import threading + from unittest.mock import MagicMock + + import turnstone.server as srv_mod + from turnstone.core.metrics import MetricsCollector + from turnstone.core.workstream import WorkstreamState + + srv_mod._metrics = MetricsCollector() + srv_mod._metrics.model = "test-model" + + mock_ws = MagicMock() + mock_ws.state = WorkstreamState.IDLE + mock_mgr = MagicMock() + mock_mgr.list_all.return_value = [mock_ws] + + cls.server = srv_mod.ThreadedHTTPServer( + ("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler + ) + cls.server.workstreams = mock_mgr + cls.server.skip_permissions = False + cls.server.global_listeners = [] + cls.server.global_queue = queue.Queue() + cls.server.global_listeners_lock = threading.Lock() + cls.server.auth_config = AuthConfig( + enabled=True, + tokens={"tok_full": "full", "tok_read": "read"}, + ) + + port = cls.server.server_address[1] + cls.base = f"http://127.0.0.1:{port}" + + cls._thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls._thread.start() + + @classmethod + def teardown_class(cls): + cls.server.shutdown() + cls._thread.join(timeout=5) + + def test_health_no_token_200(self): + import httpx + + resp = httpx.get(f"{self.base}/health", timeout=5) + assert resp.status_code == 200 + + def test_metrics_no_token_passes_auth(self): + import httpx + + try: + resp = httpx.get(f"{self.base}/metrics", timeout=5) + # Public path — should never be 401/403 + assert resp.status_code not in (401, 403) + except httpx.RemoteProtocolError: + # Server crashes in metrics handler due to MagicMock — + # the important thing is auth didn't reject it (no 401/403 before crash) + pass + + def test_root_no_token_200(self): + import httpx + + resp = httpx.get(f"{self.base}/", timeout=5) + assert resp.status_code == 200 + + def test_static_css_no_token_200(self): + import httpx + + resp = httpx.get(f"{self.base}/static/style.css", timeout=5) + assert resp.status_code == 200 + + def test_api_workstreams_no_token_401(self): + import httpx + + resp = httpx.get(f"{self.base}/api/workstreams", timeout=5) + assert resp.status_code == 401 + assert "Unauthorized" in resp.json().get("error", "") + + def test_api_workstreams_read_token_200(self): + import httpx + + resp = httpx.get( + f"{self.base}/api/workstreams", + headers={"Authorization": "Bearer tok_read"}, + timeout=5, + ) + assert resp.status_code == 200 + + def test_api_workstreams_full_token_200(self): + import httpx + + resp = httpx.get( + f"{self.base}/api/workstreams", + headers={"Authorization": "Bearer tok_full"}, + timeout=5, + ) + assert resp.status_code == 200 + + def test_api_send_read_token_403(self): + import httpx + + resp = httpx.post( + f"{self.base}/api/send", + headers={"Authorization": "Bearer tok_read"}, + json={"message": "hello", "ws_id": "x"}, + timeout=5, + ) + assert resp.status_code == 403 + assert "Forbidden" in resp.json().get("error", "") + + def test_api_send_full_token_passes_auth(self): + import httpx + + resp = httpx.post( + f"{self.base}/api/send", + headers={"Authorization": "Bearer tok_full"}, + json={"message": "hello", "ws_id": "nonexistent"}, + timeout=5, + ) + # Should get 404 (unknown workstream), not 401/403 + assert resp.status_code not in (401, 403) + + def test_api_send_no_token_401(self): + import httpx + + resp = httpx.post( + f"{self.base}/api/send", + json={"message": "hello", "ws_id": "x"}, + timeout=5, + ) + assert resp.status_code == 401 + + def test_invalid_token_401(self): + import httpx + + resp = httpx.get( + f"{self.base}/api/workstreams", + headers={"Authorization": "Bearer wrong_token"}, + timeout=5, + ) + assert resp.status_code == 401 + + def test_options_no_auth_required(self): + import httpx + + resp = httpx.options(f"{self.base}/api/send", timeout=5) + assert resp.status_code == 200 + allowed = resp.headers.get("access-control-allow-headers", "") + assert "Authorization" in allowed + + def test_cors_includes_authorization(self): + import httpx + + resp = httpx.options(f"{self.base}/api/workstreams", timeout=5) + allowed = resp.headers.get("access-control-allow-headers", "") + assert "Authorization" in allowed + + +class TestConsoleAuth: + """Spin up a console server with auth enabled and test endpoints.""" + + @classmethod + def setup_class(cls): + import threading + from unittest.mock import MagicMock + + from turnstone.console.collector import ClusterCollector + from turnstone.console.server import ( + ConsoleHTTPHandler, + ThreadedHTTPServer, + _load_static, + ) + + _load_static() + + mock_collector = MagicMock(spec=ClusterCollector) + mock_collector.get_overview.return_value = { + "nodes": 1, + "workstreams": 2, + "states": {"running": 1, "idle": 1}, + "aggregate": {"total_tokens": 100}, + } + + cls.server = ThreadedHTTPServer(("127.0.0.1", 0), ConsoleHTTPHandler) + cls.server.collector = mock_collector + cls.server.auth_config = AuthConfig( + enabled=True, + tokens={"tok_full": "full", "tok_read": "read"}, + ) + + port = cls.server.server_address[1] + cls.base = f"http://127.0.0.1:{port}" + + cls._thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls._thread.start() + + @classmethod + def teardown_class(cls): + cls.server.shutdown() + cls._thread.join(timeout=5) + + def test_health_no_token_200(self): + import httpx + + resp = httpx.get(f"{self.base}/health", timeout=5) + assert resp.status_code == 200 + + def test_root_no_token_200(self): + import httpx + + resp = httpx.get(f"{self.base}/", timeout=5) + assert resp.status_code == 200 + + def test_api_overview_no_token_401(self): + import httpx + + resp = httpx.get(f"{self.base}/api/cluster/overview", timeout=5) + assert resp.status_code == 401 + + def test_api_overview_read_token_200(self): + import httpx + + resp = httpx.get( + f"{self.base}/api/cluster/overview", + headers={"Authorization": "Bearer tok_read"}, + timeout=5, + ) + assert resp.status_code == 200 + + def test_api_overview_full_token_200(self): + import httpx + + resp = httpx.get( + f"{self.base}/api/cluster/overview", + headers={"Authorization": "Bearer tok_full"}, + timeout=5, + ) + assert resp.status_code == 200 + + def test_invalid_token_401(self): + import httpx + + resp = httpx.get( + f"{self.base}/api/cluster/overview", + headers={"Authorization": "Bearer wrong"}, + timeout=5, + ) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Login / Logout integration tests +# --------------------------------------------------------------------------- + + +class TestServerLogin: + """Test login/logout cookie flow on turnstone-server.""" + + @classmethod + def setup_class(cls): + import queue + import threading + from unittest.mock import MagicMock + + import turnstone.server as srv_mod + from turnstone.core.metrics import MetricsCollector + from turnstone.core.workstream import WorkstreamState + + srv_mod._metrics = MetricsCollector() + srv_mod._metrics.model = "test-model" + + mock_ws = MagicMock() + mock_ws.state = WorkstreamState.IDLE + mock_mgr = MagicMock() + mock_mgr.list_all.return_value = [mock_ws] + + cls.server = srv_mod.ThreadedHTTPServer( + ("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler + ) + cls.server.workstreams = mock_mgr + cls.server.skip_permissions = False + cls.server.global_listeners = [] + cls.server.global_queue = queue.Queue() + cls.server.global_listeners_lock = threading.Lock() + cls.server.auth_config = AuthConfig( + enabled=True, + tokens={"tok_full": "full", "tok_read": "read"}, + ) + + port = cls.server.server_address[1] + cls.base = f"http://127.0.0.1:{port}" + + cls._thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls._thread.start() + + @classmethod + def teardown_class(cls): + cls.server.shutdown() + cls._thread.join(timeout=5) + + def test_login_valid_token_sets_cookie(self): + import httpx + + resp = httpx.post( + f"{self.base}/api/auth/login", + json={"token": "tok_full"}, + timeout=5, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["role"] == "full" + cookie = resp.headers.get("set-cookie", "") + assert "turnstone_auth=tok_full" in cookie + assert "HttpOnly" in cookie + + def test_login_invalid_token_401(self): + import httpx + + resp = httpx.post( + f"{self.base}/api/auth/login", + json={"token": "wrong"}, + timeout=5, + ) + assert resp.status_code == 401 + + def test_login_no_auth_required(self): + import httpx + + # /api/auth/login is public — shouldn't require auth itself + resp = httpx.post( + f"{self.base}/api/auth/login", + json={"token": "tok_read"}, + timeout=5, + ) + assert resp.status_code == 200 + + def test_cookie_auth_on_api(self): + import httpx + + # Login to get cookie + client = httpx.Client(base_url=self.base, timeout=5) + login_resp = client.post("/api/auth/login", json={"token": "tok_read"}) + assert login_resp.status_code == 200 + + # Use cookie to access API + resp = client.get("/api/workstreams") + assert resp.status_code == 200 + client.close() + + def test_logout_clears_cookie(self): + import httpx + + client = httpx.Client(base_url=self.base, timeout=5) + client.post("/api/auth/login", json={"token": "tok_read"}) + + # Logout + logout_resp = client.post("/api/auth/logout") + assert logout_resp.status_code == 200 + cookie = logout_resp.headers.get("set-cookie", "") + assert "Max-Age=0" in cookie + + # API should now fail + resp = client.get("/api/workstreams") + assert resp.status_code == 401 + client.close() + + +class TestConsoleLogin: + """Test login/logout cookie flow on turnstone-console.""" + + @classmethod + def setup_class(cls): + import threading + from unittest.mock import MagicMock + + from turnstone.console.collector import ClusterCollector + from turnstone.console.server import ( + ConsoleHTTPHandler, + ThreadedHTTPServer, + _load_static, + ) + + _load_static() + + mock_collector = MagicMock(spec=ClusterCollector) + mock_collector.get_overview.return_value = { + "nodes": 1, + "workstreams": 2, + "states": {"running": 1, "idle": 1}, + "aggregate": {"total_tokens": 100}, + } + + cls.server = ThreadedHTTPServer(("127.0.0.1", 0), ConsoleHTTPHandler) + cls.server.collector = mock_collector + cls.server.auth_config = AuthConfig( + enabled=True, + tokens={"tok_full": "full", "tok_read": "read"}, + ) + + port = cls.server.server_address[1] + cls.base = f"http://127.0.0.1:{port}" + + cls._thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls._thread.start() + + @classmethod + def teardown_class(cls): + cls.server.shutdown() + cls._thread.join(timeout=5) + + def test_login_valid_token(self): + import httpx + + resp = httpx.post( + f"{self.base}/api/auth/login", + json={"token": "tok_read"}, + timeout=5, + ) + assert resp.status_code == 200 + assert "turnstone_auth" in resp.headers.get("set-cookie", "") + + def test_login_invalid_token(self): + import httpx + + resp = httpx.post( + f"{self.base}/api/auth/login", + json={"token": "wrong"}, + timeout=5, + ) + assert resp.status_code == 401 + + def test_cookie_auth_on_api(self): + import httpx + + client = httpx.Client(base_url=self.base, timeout=5) + client.post("/api/auth/login", json={"token": "tok_read"}) + resp = client.get("/api/cluster/overview") + assert resp.status_code == 200 + client.close() + + def test_logout_then_api_fails(self): + import httpx + + client = httpx.Client(base_url=self.base, timeout=5) + client.post("/api/auth/login", json={"token": "tok_read"}) + client.post("/api/auth/logout") + resp = client.get("/api/cluster/overview") + assert resp.status_code == 401 + client.close() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..7c91e298 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,185 @@ +"""Tests for turnstone.core.config — unified TOML config loading.""" + +import argparse + +import turnstone.core.config as config_mod +from turnstone.core.config import apply_config, load_config + + +def _reset_cache(): + """Clear the module-level config cache between tests.""" + config_mod._cache = None + + +def test_load_config_missing_file(tmp_path, monkeypatch): + _reset_cache() + monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "nope.toml") + assert load_config() == {} + + +def test_load_config_valid_toml(tmp_path, monkeypatch): + _reset_cache() + cfg = tmp_path / "config.toml" + cfg.write_text('[redis]\nhost = "10.0.0.1"\nport = 6380\npassword = "secret"\n') + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + result = load_config() + assert result["redis"]["host"] == "10.0.0.1" + assert result["redis"]["port"] == 6380 + assert result["redis"]["password"] == "secret" + + +def test_load_config_section(tmp_path, monkeypatch): + _reset_cache() + cfg = tmp_path / "config.toml" + cfg.write_text('[api]\nbase_url = "http://x:8000/v1"\n[redis]\nhost = "y"\n') + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + assert load_config("redis") == {"host": "y"} + assert load_config("api") == {"base_url": "http://x:8000/v1"} + assert load_config("nonexistent") == {} + + +def test_load_config_invalid_toml(tmp_path, monkeypatch): + _reset_cache() + cfg = tmp_path / "config.toml" + cfg.write_text("this is not valid toml [[[") + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + assert load_config() == {} + + +def test_load_config_caches(tmp_path, monkeypatch): + _reset_cache() + cfg = tmp_path / "config.toml" + cfg.write_text('[api]\nbase_url = "http://first"\n') + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + first = load_config() + assert first["api"]["base_url"] == "http://first" + + # Change file — should NOT be re-read (cached) + cfg.write_text('[api]\nbase_url = "http://second"\n') + second = load_config() + assert second["api"]["base_url"] == "http://first" + + +def test_apply_config_sets_defaults(tmp_path, monkeypatch): + _reset_cache() + cfg = tmp_path / "config.toml" + cfg.write_text( + '[redis]\nhost = "redis.local"\nport = 7777\npassword = "pw"\n' + '[bridge]\nserver_url = "http://bridge:9090"\n' + ) + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + + parser = argparse.ArgumentParser() + parser.add_argument("--redis-host", default="localhost") + parser.add_argument("--redis-port", type=int, default=6379) + parser.add_argument("--redis-password", default=None) + parser.add_argument("--server-url", default="http://localhost:8080") + + apply_config(parser, ["redis", "bridge"]) + args = parser.parse_args([]) + + assert args.redis_host == "redis.local" + assert args.redis_port == 7777 + assert args.redis_password == "pw" + assert args.server_url == "http://bridge:9090" + + +def test_apply_config_cli_overrides(tmp_path, monkeypatch): + _reset_cache() + cfg = tmp_path / "config.toml" + cfg.write_text('[redis]\nhost = "config-host"\nport = 7777\n') + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + + parser = argparse.ArgumentParser() + parser.add_argument("--redis-host", default="localhost") + parser.add_argument("--redis-port", type=int, default=6379) + + apply_config(parser, ["redis"]) + # CLI flag overrides config + args = parser.parse_args(["--redis-host", "cli-host"]) + + assert args.redis_host == "cli-host" # CLI wins + assert args.redis_port == 7777 # config wins (no CLI override) + + +def test_apply_config_missing_keys_keep_defaults(tmp_path, monkeypatch): + _reset_cache() + cfg = tmp_path / "config.toml" + cfg.write_text('[redis]\nhost = "only-host"\n') # no port, no password + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + + parser = argparse.ArgumentParser() + parser.add_argument("--redis-host", default="localhost") + parser.add_argument("--redis-port", type=int, default=6379) + parser.add_argument("--redis-password", default=None) + + apply_config(parser, ["redis"]) + args = parser.parse_args([]) + + assert args.redis_host == "only-host" + assert args.redis_port == 6379 # original default kept + assert args.redis_password is None # original default kept + + +def test_apply_config_no_file(tmp_path, monkeypatch): + _reset_cache() + monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "nope.toml") + + parser = argparse.ArgumentParser() + parser.add_argument("--redis-host", default="localhost") + + apply_config(parser, ["redis"]) + args = parser.parse_args([]) + assert args.redis_host == "localhost" + + +def test_apply_config_model_section(tmp_path, monkeypatch): + _reset_cache() + cfg = tmp_path / "config.toml" + cfg.write_text('[model]\nname = "qwen-72b"\ntemperature = 0.3\n') + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + + parser = argparse.ArgumentParser() + parser.add_argument("--model", default=None) + parser.add_argument("--temperature", type=float, default=0.5) + + apply_config(parser, ["model"]) + args = parser.parse_args([]) + + assert args.model == "qwen-72b" + assert args.temperature == 0.3 + + +def test_tavily_key_from_config(tmp_path, monkeypatch): + """get_tavily_key() reads from config.toml [api] tavily_key.""" + _reset_cache() + import turnstone.core.memory as mem + + mem._tavily_key = None + mem._tavily_key_loaded = False + + cfg = tmp_path / "config.toml" + cfg.write_text('[api]\ntavily_key = "tvly-from-config"\n') + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + + key = mem.get_tavily_key() + assert key == "tvly-from-config" + + +def test_tavily_key_fallback_to_env(tmp_path, monkeypatch): + """get_tavily_key() falls back to $TAVILY_API_KEY env var.""" + _reset_cache() + import turnstone.core.memory as mem + + mem._tavily_key = None + mem._tavily_key_loaded = False + + # Config exists but no tavily_key in it + cfg = tmp_path / "config.toml" + cfg.write_text("[api]\n") + monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg) + monkeypatch.setenv("TAVILY_API_KEY", "tvly-from-env") + + key = mem.get_tavily_key() + assert key == "tvly-from-env" diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 00000000..ca14e03b --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,661 @@ +"""Tests for turnstone.console — collector and HTTP server.""" + +import json +import queue +import threading +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from turnstone.console.collector import ClusterCollector, NodeSnapshot +from turnstone.mq.protocol import ( + ClusterStateEvent, + WorkstreamClosedEvent, + WorkstreamCreatedEvent, + WorkstreamRenameEvent, +) + + +# --------------------------------------------------------------------------- +# Mock broker for collector tests +# --------------------------------------------------------------------------- + + +class MockBroker: + """Minimal broker mock that records calls and stores nodes.""" + + def __init__(self): + self.nodes: list[dict] = [] + self._subscriptions: dict[str, list] = {} + + def list_nodes(self) -> list[dict]: + return list(self.nodes) + + def subscribe_outbound(self, channel, callback): + self._subscriptions.setdefault(channel, []).append(callback) + + def publish_outbound(self, channel, event): + for cb in self._subscriptions.get(channel, []): + cb(event) + + def subscribe_cluster(self, callback): + channel = "turnstone:events:cluster" + self.subscribe_outbound(channel, callback) + + def close(self): + pass + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_collector(broker=None, poll_interval=999, discovery_interval=999): + """Create a collector with long intervals so threads don't auto-fire.""" + b = broker or MockBroker() + return ClusterCollector( + broker=b, + poll_interval=poll_interval, + discovery_interval=discovery_interval, + ) + + +def _dashboard_response(workstreams=None, aggregate=None): + """Build a /api/dashboard-style response dict.""" + return { + "workstreams": workstreams or [], + "aggregate": aggregate + or { + "total_tokens": 0, + "total_tool_calls": 0, + "active_count": 0, + "total_count": 0, + "uptime_seconds": 0, + "node": "local", + }, + } + + +# --------------------------------------------------------------------------- +# ClusterCollector — unit tests +# --------------------------------------------------------------------------- + + +class TestCollectorDiscovery: + """Node discovery from heartbeat keys.""" + + def test_discover_new_nodes(self): + broker = MockBroker() + broker.nodes = [ + {"node_id": "node-a", "server_url": "http://a:8080"}, + {"node_id": "node-b", "server_url": "http://b:8080"}, + ] + c = _make_collector(broker) + c._discover_nodes() + + overview = c.get_overview() + assert overview["nodes"] == 2 + + def test_discover_removes_lost_nodes(self): + broker = MockBroker() + broker.nodes = [{"node_id": "node-a", "server_url": "http://a:8080"}] + c = _make_collector(broker) + c._discover_nodes() + assert c.get_overview()["nodes"] == 1 + + # Node disappears + broker.nodes = [] + c._discover_nodes() + assert c.get_overview()["nodes"] == 0 + + def test_discover_updates_server_url(self): + broker = MockBroker() + broker.nodes = [{"node_id": "node-a", "server_url": "http://a:8080"}] + c = _make_collector(broker) + c._discover_nodes() + + broker.nodes = [{"node_id": "node-a", "server_url": "http://a:9090"}] + c._discover_nodes() + + detail = c.get_node_detail("node-a") + assert detail["server_url"] == "http://a:9090" + + def test_discover_emits_node_joined_event(self): + broker = MockBroker() + c = _make_collector(broker) + events = [] + q = queue.Queue() + c.register_listener(q) + + broker.nodes = [{"node_id": "node-a", "server_url": "http://a:8080"}] + c._discover_nodes() + + event = q.get_nowait() + assert event["type"] == "node_joined" + assert event["node_id"] == "node-a" + + def test_discover_emits_node_lost_event(self): + broker = MockBroker() + broker.nodes = [{"node_id": "node-a", "server_url": "http://a:8080"}] + c = _make_collector(broker) + c._discover_nodes() + + q = queue.Queue() + c.register_listener(q) + + broker.nodes = [] + c._discover_nodes() + + event = q.get_nowait() + assert event["type"] == "node_lost" + assert event["node_id"] == "node-a" + + +class TestCollectorPolling: + """Polling /api/dashboard from nodes.""" + + def test_apply_poll_populates_workstreams(self): + c = _make_collector() + c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080") + + dashboard = _dashboard_response( + workstreams=[ + { + "id": "ws1", + "name": "test", + "state": "running", + "tokens": 1000, + "context_ratio": 0.15, + "activity": "bash: ls", + "activity_state": "tool", + "tool_calls": 3, + "title": "My task", + }, + ], + aggregate={"total_tokens": 1000, "total_tool_calls": 3}, + ) + c._apply_poll("node-a", dashboard, {"status": "ok"}) + + detail = c.get_node_detail("node-a") + assert len(detail["workstreams"]) == 1 + assert detail["workstreams"][0]["name"] == "test" + assert detail["workstreams"][0]["node"] == "node-a" + assert detail["health"]["status"] == "ok" + + def test_apply_poll_replaces_stale_workstreams(self): + c = _make_collector() + c._nodes["node-a"] = NodeSnapshot( + node_id="node-a", + server_url="http://a:8080", + workstreams={"old-ws": {"id": "old-ws", "name": "old", "state": "idle"}}, + ) + + dashboard = _dashboard_response( + workstreams=[{"id": "new-ws", "name": "new", "state": "running"}] + ) + c._apply_poll("node-a", dashboard, {}) + + detail = c.get_node_detail("node-a") + assert len(detail["workstreams"]) == 1 + assert detail["workstreams"][0]["id"] == "new-ws" + + def test_apply_poll_ignores_unknown_node(self): + c = _make_collector() + # Should not raise + c._apply_poll("unknown", _dashboard_response(), {}) + + +class TestCollectorEvents: + """Real-time event handling from cluster channel.""" + + def test_cluster_state_event_updates_workstream(self): + c = _make_collector() + c._nodes["node-a"] = NodeSnapshot( + node_id="node-a", + server_url="http://a:8080", + workstreams={ + "ws1": {"id": "ws1", "name": "test", "state": "idle", "node": "node-a"} + }, + ) + + event = ClusterStateEvent( + ws_id="ws1", + state="running", + node_id="node-a", + tokens=5000, + context_ratio=0.25, + activity="bash: echo hi", + activity_state="tool", + ) + c._on_cluster_event(event.to_json()) + + ws = c._nodes["node-a"].workstreams["ws1"] + assert ws["state"] == "running" + assert ws["tokens"] == 5000 + assert ws["activity"] == "bash: echo hi" + + def test_ws_created_event_adds_workstream(self): + c = _make_collector() + c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080") + + event_json = json.dumps( + { + "type": "ws_created", + "ws_id": "ws-new", + "name": "new-task", + "node_id": "node-a", + "correlation_id": "abc", + } + ) + c._on_cluster_event(event_json) + + assert "ws-new" in c._nodes["node-a"].workstreams + assert c._nodes["node-a"].workstreams["ws-new"]["name"] == "new-task" + + def test_ws_closed_event_removes_workstream(self): + c = _make_collector() + c._nodes["node-a"] = NodeSnapshot( + node_id="node-a", + workstreams={"ws1": {"id": "ws1", "state": "idle"}}, + ) + + event_json = json.dumps({"type": "ws_closed", "ws_id": "ws1"}) + c._on_cluster_event(event_json) + + assert "ws1" not in c._nodes["node-a"].workstreams + + def test_ws_rename_event_updates_name(self): + c = _make_collector() + c._nodes["node-a"] = NodeSnapshot( + node_id="node-a", + workstreams={"ws1": {"id": "ws1", "name": "old-name", "state": "idle"}}, + ) + + event_json = json.dumps( + {"type": "ws_rename", "ws_id": "ws1", "name": "new-name"} + ) + c._on_cluster_event(event_json) + + assert c._nodes["node-a"].workstreams["ws1"]["name"] == "new-name" + + def test_event_fans_out_to_listeners(self): + c = _make_collector() + c._nodes["node-a"] = NodeSnapshot( + node_id="node-a", + workstreams={"ws1": {"id": "ws1", "state": "idle", "node": "node-a"}}, + ) + + q = queue.Queue() + c.register_listener(q) + + event = ClusterStateEvent(ws_id="ws1", state="running", node_id="node-a") + c._on_cluster_event(event.to_json()) + + fan_event = q.get_nowait() + assert fan_event["type"] == "cluster_state" + assert fan_event["ws_id"] == "ws1" + + def test_unregister_listener_stops_fanout(self): + c = _make_collector() + c._nodes["node-a"] = NodeSnapshot(node_id="node-a") + + q = queue.Queue() + c.register_listener(q) + c.unregister_listener(q) + + c._fanout({"type": "test"}) + assert q.empty() + + def test_invalid_json_event_ignored(self): + c = _make_collector() + # Should not raise + c._on_cluster_event("not valid json {{{") + c._on_cluster_event("") + + +class TestCollectorQueries: + """Query methods: get_overview, get_nodes, get_workstreams, get_node_detail.""" + + @pytest.fixture() + def populated_collector(self): + c = _make_collector() + c._nodes["node-a"] = NodeSnapshot( + node_id="node-a", + server_url="http://a:8080", + workstreams={ + "ws1": { + "id": "ws1", + "name": "alpha", + "state": "running", + "node": "node-a", + "title": "Task A", + "tokens": 5000, + "context_ratio": 0.2, + "activity": "", + "activity_state": "", + "tool_calls": 10, + }, + "ws2": { + "id": "ws2", + "name": "beta", + "state": "idle", + "node": "node-a", + "title": "Task B", + "tokens": 2000, + "context_ratio": 0.1, + "activity": "", + "activity_state": "", + "tool_calls": 5, + }, + }, + aggregate={"total_tokens": 7000, "total_tool_calls": 15}, + ) + c._nodes["node-b"] = NodeSnapshot( + node_id="node-b", + server_url="http://b:8080", + workstreams={ + "ws3": { + "id": "ws3", + "name": "gamma", + "state": "attention", + "node": "node-b", + "title": "Task C", + "tokens": 10000, + "context_ratio": 0.5, + "activity": "awaiting approval", + "activity_state": "approval", + "tool_calls": 20, + }, + }, + aggregate={"total_tokens": 10000, "total_tool_calls": 20}, + ) + return c + + def test_get_overview(self, populated_collector): + o = populated_collector.get_overview() + assert o["nodes"] == 2 + assert o["workstreams"] == 3 + assert o["states"]["running"] == 1 + assert o["states"]["idle"] == 1 + assert o["states"]["attention"] == 1 + assert o["aggregate"]["total_tokens"] == 17000 + assert o["aggregate"]["total_tool_calls"] == 35 + + def test_get_nodes_sorted_by_activity(self, populated_collector): + nodes, total = populated_collector.get_nodes(sort_by="activity") + assert total == 2 + # node-a has 1 running, node-b has 1 attention — both have activity=1 + # order depends on tie-breaking but both should be present + ids = [n["node_id"] for n in nodes] + assert "node-a" in ids + assert "node-b" in ids + + def test_get_nodes_pagination(self, populated_collector): + nodes, total = populated_collector.get_nodes(limit=1, offset=0) + assert len(nodes) == 1 + assert total == 2 + + nodes2, _ = populated_collector.get_nodes(limit=1, offset=1) + assert len(nodes2) == 1 + assert nodes2[0]["node_id"] != nodes[0]["node_id"] + + def test_get_workstreams_no_filter(self, populated_collector): + ws, total = populated_collector.get_workstreams() + assert total == 3 + assert len(ws) == 3 + + def test_get_workstreams_filter_by_state(self, populated_collector): + ws, total = populated_collector.get_workstreams(state="running") + assert total == 1 + assert ws[0]["name"] == "alpha" + + def test_get_workstreams_filter_by_node(self, populated_collector): + ws, total = populated_collector.get_workstreams(node="node-b") + assert total == 1 + assert ws[0]["name"] == "gamma" + + def test_get_workstreams_filter_by_search(self, populated_collector): + ws, total = populated_collector.get_workstreams(search="Task C") + assert total == 1 + assert ws[0]["name"] == "gamma" + + def test_get_workstreams_search_case_insensitive(self, populated_collector): + ws, total = populated_collector.get_workstreams(search="task c") + assert total == 1 + + def test_get_workstreams_pagination(self, populated_collector): + ws, total = populated_collector.get_workstreams(page=1, per_page=2) + assert len(ws) == 2 + assert total == 3 + + ws2, _ = populated_collector.get_workstreams(page=2, per_page=2) + assert len(ws2) == 1 + + def test_get_workstreams_sorted_by_state(self, populated_collector): + ws, _ = populated_collector.get_workstreams(sort_by="state") + states = [w["state"] for w in ws] + # running before attention before idle + assert ( + states.index("running") < states.index("attention") < states.index("idle") + ) + + def test_get_workstreams_combined_filters(self, populated_collector): + ws, total = populated_collector.get_workstreams(state="idle", node="node-a") + assert total == 1 + assert ws[0]["name"] == "beta" + + def test_get_node_detail_found(self, populated_collector): + detail = populated_collector.get_node_detail("node-a") + assert detail is not None + assert detail["node_id"] == "node-a" + assert len(detail["workstreams"]) == 2 + + def test_get_node_detail_not_found(self, populated_collector): + assert populated_collector.get_node_detail("nonexistent") is None + + +# --------------------------------------------------------------------------- +# ClusterStateEvent protocol tests +# --------------------------------------------------------------------------- + + +class TestClusterStateEventProtocol: + """Ensure ClusterStateEvent round-trips through JSON correctly.""" + + def test_round_trip(self): + event = ClusterStateEvent( + ws_id="ws1", + state="running", + node_id="node-a", + tokens=5000, + context_ratio=0.25, + activity="bash: ls", + activity_state="tool", + ) + raw = event.to_json() + data = json.loads(raw) + assert data["type"] == "cluster_state" + assert data["ws_id"] == "ws1" + assert data["node_id"] == "node-a" + assert data["tokens"] == 5000 + assert data["context_ratio"] == 0.25 + + def test_from_json(self): + from turnstone.mq.protocol import OutboundEvent + + raw = json.dumps( + { + "type": "cluster_state", + "ws_id": "ws1", + "state": "running", + "node_id": "node-a", + "tokens": 5000, + } + ) + event = OutboundEvent.from_json(raw) + assert isinstance(event, ClusterStateEvent) + assert event.node_id == "node-a" + assert event.tokens == 5000 + + +# --------------------------------------------------------------------------- +# Console HTTP server tests +# --------------------------------------------------------------------------- + + +class TestConsoleHTTPEndpoints: + """Test console HTTP API endpoints with a mock collector.""" + + @pytest.fixture() + def mock_collector(self): + collector = MagicMock(spec=ClusterCollector) + collector.get_overview.return_value = { + "nodes": 3, + "workstreams": 15, + "states": { + "running": 5, + "thinking": 2, + "attention": 1, + "idle": 6, + "error": 1, + }, + "aggregate": {"total_tokens": 50000, "total_tool_calls": 200}, + } + collector.get_nodes.return_value = ( + [ + { + "node_id": "node-a", + "ws_total": 5, + "ws_running": 3, + "total_tokens": 20000, + } + ], + 1, + ) + collector.get_workstreams.return_value = ( + [{"id": "ws1", "name": "test", "state": "running", "node": "node-a"}], + 1, + ) + collector.get_node_detail.return_value = { + "node_id": "node-a", + "server_url": "http://a:8080", + "health": {}, + "workstreams": [], + "aggregate": {}, + } + return collector + + @pytest.fixture() + def server(self, mock_collector): + from turnstone.console.server import ( + ConsoleHTTPHandler, + ThreadedHTTPServer, + _load_static, + ) + + _load_static() + + from turnstone.core.auth import AuthConfig + + httpd = ThreadedHTTPServer(("127.0.0.1", 0), ConsoleHTTPHandler) + httpd.collector = mock_collector + httpd.auth_config = AuthConfig() # auth disabled by default + port = httpd.server_address[1] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{port}" + httpd.shutdown() + + def _get(self, server, path): + resp = httpx.get(f"{server}{path}", timeout=5) + return resp.status_code, resp.json() + + def _get_raw(self, server, path): + resp = httpx.get(f"{server}{path}", timeout=5) + return resp.status_code, resp.text, resp.headers.get("content-type") + + def test_get_overview(self, server, mock_collector): + status, data = self._get(server, "/api/cluster/overview") + assert status == 200 + assert data["nodes"] == 3 + assert data["workstreams"] == 15 + assert data["states"]["running"] == 5 + mock_collector.get_overview.assert_called_once() + + def test_get_nodes(self, server, mock_collector): + status, data = self._get( + server, "/api/cluster/nodes?sort=activity&limit=10&offset=0" + ) + assert status == 200 + assert len(data["nodes"]) == 1 + assert data["total"] == 1 + mock_collector.get_nodes.assert_called_once_with( + sort_by="activity", limit=10, offset=0 + ) + + def test_get_workstreams(self, server, mock_collector): + status, data = self._get( + server, "/api/cluster/workstreams?state=running&page=1&per_page=25" + ) + assert status == 200 + assert len(data["workstreams"]) == 1 + assert data["total"] == 1 + assert data["page"] == 1 + assert data["pages"] == 1 + mock_collector.get_workstreams.assert_called_once_with( + state="running", + node=None, + search=None, + sort_by="state", + page=1, + per_page=25, + ) + + def test_get_workstreams_per_page_capped(self, server, mock_collector): + self._get(server, "/api/cluster/workstreams?per_page=999") + call_kwargs = mock_collector.get_workstreams.call_args + assert call_kwargs.kwargs["per_page"] == 200 + + def test_get_node_detail(self, server, mock_collector): + status, data = self._get(server, "/api/cluster/node/node-a") + assert status == 200 + assert data["node_id"] == "node-a" + mock_collector.get_node_detail.assert_called_once_with("node-a") + + def test_get_node_detail_not_found(self, server, mock_collector): + mock_collector.get_node_detail.return_value = None + status, data = self._get(server, "/api/cluster/node/nonexistent") + assert status == 404 + assert "error" in data + + def test_health_endpoint(self, server, mock_collector): + status, data = self._get(server, "/health") + assert status == 200 + assert data["status"] == "ok" + assert data["service"] == "turnstone-console" + assert data["nodes"] == 3 + + def test_index_html(self, server): + status, body, ct = self._get_raw(server, "/") + assert status == 200 + assert "text/html" in ct + assert "turnstone console" in body + + def test_static_css(self, server): + status, body, ct = self._get_raw(server, "/static/style.css") + assert status == 200 + assert "text/css" in ct + + def test_static_js(self, server): + status, body, ct = self._get_raw(server, "/static/app.js") + assert status == 200 + assert "javascript" in ct + + def test_404(self, server): + resp = httpx.get(f"{server}/nonexistent", timeout=5) + assert resp.status_code == 404 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..0caea4ba --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,87 @@ +"""Tests for turnstone.core.memory — database operations.""" + +import turnstone.core.memory as memory +from turnstone.core.memory import ( + open_db, + save_message, + search_history, + search_history_recent, + normalize_key, +) + + +class TestOpenDb: + def test_creates_tables(self, tmp_db): + conn = open_db() + try: + # Check memories table exists + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='memories'" + ).fetchall() + assert len(rows) == 1 + + # Check conversations table exists + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='conversations'" + ).fetchall() + assert len(rows) == 1 + finally: + conn.close() + + def test_idempotent_open(self, tmp_db): + # Opening twice should not raise + conn1 = open_db() + conn1.close() + conn2 = open_db() + conn2.close() + + +class TestSaveAndSearchHistory: + def test_save_and_search_roundtrip(self, tmp_db): + save_message("sess1", "user", "hello world test message") + results = search_history("hello") + assert len(results) >= 1 + # Result tuple: (timestamp, session_id, role, content, tool_name) + found = any(r[3] == "hello world test message" for r in results) + assert found + + def test_search_empty_query_returns_empty(self, tmp_db): + save_message("sess1", "user", "something") + assert search_history("") == [] + assert search_history(" ") == [] + + def test_search_no_match(self, tmp_db): + save_message("sess1", "user", "hello world") + results = search_history("zzzznotfound") + assert results == [] + + +class TestSearchHistoryRecent: + def test_returns_recent_messages(self, tmp_db): + save_message("sess1", "user", "first message") + save_message("sess1", "assistant", "second message") + results = search_history_recent(limit=10) + assert len(results) == 2 + + def test_respects_limit(self, tmp_db): + for i in range(5): + save_message("sess1", "user", f"message {i}") + results = search_history_recent(limit=3) + assert len(results) == 3 + + +class TestNormalizeKey: + def test_lowercase(self): + assert normalize_key("Hello") == "hello" + + def test_hyphens_to_underscores(self): + assert normalize_key("my-key") == "my_key" + + def test_spaces_to_underscores(self): + assert normalize_key("my key") == "my_key" + + def test_combined(self): + assert normalize_key("My-Key Name") == "my_key_name" + + def test_already_normalized(self): + assert normalize_key("my_key") == "my_key" diff --git a/tests/test_edit.py b/tests/test_edit.py new file mode 100644 index 00000000..721d3c84 --- /dev/null +++ b/tests/test_edit.py @@ -0,0 +1,60 @@ +"""Tests for turnstone.core.edit — find_occurrences and pick_nearest.""" + +from turnstone.core.edit import find_occurrences, pick_nearest + + +class TestFindOccurrences: + def test_empty_old_string_returns_empty(self): + assert find_occurrences("hello world", "") == [] + + def test_no_match_returns_empty(self): + assert find_occurrences("hello world", "xyz") == [] + + def test_single_occurrence(self): + content = "line one\nline two\nline three" + assert find_occurrences(content, "two") == [2] + + def test_multiple_occurrences(self): + content = "aaa\nbbb\naaa\nccc\naaa" + assert find_occurrences(content, "aaa") == [1, 3, 5] + + def test_occurrence_on_first_line(self): + content = "hello\nworld" + assert find_occurrences(content, "hello") == [1] + + def test_multiline_match(self): + content = "start\nfoo\nbar\nend" + assert find_occurrences(content, "foo\nbar") == [2] + + def test_overlapping_positions(self): + content = "aaa" + # "aa" occurs at index 0 (line 1) and index 1 (line 1) + assert find_occurrences(content, "aa") == [1, 1] + + def test_empty_content(self): + assert find_occurrences("", "hello") == [] + + +class TestPickNearest: + def test_returns_char_index(self): + content = "aaa\nbbb\nccc" + idx = pick_nearest(content, "bbb", 2) + assert idx == 4 # "bbb" starts at char index 4 + + def test_picks_nearest_to_target_line(self): + content = "xxx\naaa\nbbb\nccc\naaa\nddd" + # "aaa" on line 2 (idx=4) and line 5 (idx=16) + # Near line 5 should pick second occurrence + idx = pick_nearest(content, "aaa", 5) + assert content[idx : idx + 3] == "aaa" + assert idx == 16 + + def test_picks_nearest_first_occurrence(self): + content = "xxx\naaa\nbbb\nccc\naaa\nddd" + # Near line 1 should pick first occurrence + idx = pick_nearest(content, "aaa", 1) + assert idx == 4 + + def test_no_match_returns_negative(self): + content = "hello world" + assert pick_nearest(content, "xyz", 1) == -1 diff --git a/tests/test_fts5.py b/tests/test_fts5.py new file mode 100644 index 00000000..7f0cc4f0 --- /dev/null +++ b/tests/test_fts5.py @@ -0,0 +1,50 @@ +"""Tests for turnstone.core.memory — fts5_query and escape_like.""" + +from turnstone.core.memory import fts5_query, escape_like + + +class TestFts5Query: + def test_single_word(self): + result = fts5_query("hello") + assert result == '"hello"' + + def test_multiple_words_joined_with_and(self): + result = fts5_query("hello world") + # Each word is quoted; space between = implicit AND + assert result == '"hello" "world"' + + def test_special_chars_safely_quoted(self): + result = fts5_query("test*") + assert result == '"test*"' + + def test_dash_safely_quoted(self): + result = fts5_query("-negative") + assert result == '"-negative"' + + def test_embedded_double_quotes(self): + # Double quotes inside a term are doubled per FTS5 convention + result = fts5_query('say"hello') + assert result == '"say""hello"' + + def test_empty_query(self): + assert fts5_query("") == "" + + def test_whitespace_only(self): + assert fts5_query(" ") == "" + + +class TestEscapeLike: + def test_percent_escaped(self): + assert escape_like("100%") == "100\\%" + + def test_underscore_escaped(self): + assert escape_like("a_b") == "a\\_b" + + def test_backslash_escaped(self): + assert escape_like("a\\b") == "a\\\\b" + + def test_no_metacharacters(self): + assert escape_like("hello") == "hello" + + def test_combined(self): + assert escape_like("50%_off\\sale") == "50\\%\\_off\\\\sale" diff --git a/tests/test_html.py b/tests/test_html.py new file mode 100644 index 00000000..f941008d --- /dev/null +++ b/tests/test_html.py @@ -0,0 +1,39 @@ +"""Tests for turnstone.core.web — strip_html.""" + +from turnstone.core.web import strip_html + + +class TestStripHtml: + def test_removes_tags(self): + assert strip_html("hello") == "hello" + + def test_removes_nested_tags(self): + assert strip_html("

text

") == "text" + + def test_decodes_entities(self): + assert strip_html("& < >") == "& < >" + + def test_collapses_whitespace(self): + result = strip_html("hello world") + assert result == "hello world" + + def test_collapses_blank_lines(self): + result = strip_html("a\n\n\n\n\nb") + assert result == "a\n\nb" + + def test_empty_string(self): + assert strip_html("") == "" + + def test_strips_leading_trailing_whitespace(self): + assert strip_html(" hello ") == "hello" + + def test_complex_html(self): + html = "

Title

Some & text

" + result = strip_html(html) + assert "Title" in result + assert "Some & text" in result + assert "<" not in result + + def test_self_closing_tags(self): + result = strip_html("hello
world") + assert result == "helloworld" diff --git a/tests/test_markdown.py b/tests/test_markdown.py new file mode 100644 index 00000000..407cd609 --- /dev/null +++ b/tests/test_markdown.py @@ -0,0 +1,80 @@ +"""Tests for turnstone.ui.markdown — MarkdownRenderer.""" + +from turnstone.ui.markdown import MarkdownRenderer +from turnstone.ui.colors import BOLD, MAGENTA, CYAN, DIM, ITALIC, RESET + + +class TestMarkdownRenderer: + def setup_method(self): + self.r = MarkdownRenderer() + + def test_header_rendering(self): + result = self.r.feed("# Hello\n") + assert BOLD in result + assert MAGENTA in result + assert "Hello" in result + + def test_h2_header(self): + result = self.r.feed("## Sub\n") + assert BOLD in result + assert MAGENTA in result + assert "Sub" in result + + def test_bold_text(self): + result = self.r.feed("some **bold** text\n") + assert BOLD in result + assert "bold" in result + + def test_underscore_bold(self): + result = self.r.feed("some __bold__ text\n") + assert BOLD in result + assert "bold" in result + + def test_inline_code(self): + result = self.r.feed("use `code` here\n") + assert CYAN in result + assert "code" in result + + def test_code_block_toggle(self): + # Opening fence + result = self.r.feed("```python\n") + assert DIM in result + assert self.r.in_code_block is True + + # Content inside code block + result = self.r.feed("x = 1\n") + assert CYAN in result + + # Closing fence + result = self.r.feed("```\n") + assert DIM in result + assert self.r.in_code_block is False + + def test_bullet_list_cyan(self): + result = self.r.feed("- item one\n") + assert CYAN in result + + def test_asterisk_bullet_list(self): + result = self.r.feed("* item one\n") + assert CYAN in result + + def test_numbered_list_cyan(self): + result = self.r.feed("1. first\n") + assert CYAN in result + + def test_flush_returns_remaining_buffer(self): + # Feed text without a newline + result = self.r.feed("no newline yet") + assert result == "" # No complete line yet + + # Flush should return the buffered content + result = self.r.flush() + assert "no newline yet" in result + + def test_flush_empty_buffer(self): + assert self.r.flush() == "" + + def test_italic_text(self): + result = self.r.feed("some *italic* text\n") + assert ITALIC in result + assert "italic" in result diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 00000000..fe88b7f9 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,224 @@ +"""Tests for turnstone.mq.protocol message serialization.""" + +import json + +import pytest + +from turnstone.mq.protocol import ( + AckEvent, + ApprovalRequestEvent, + ApproveMessage, + CloseWorkstreamMessage, + CommandMessage, + ContentEvent, + CreateWorkstreamMessage, + ErrorEvent, + HealthMessage, + HealthResponseEvent, + InboundMessage, + InfoEvent, + ListNodesMessage, + ListWorkstreamsMessage, + NodeListEvent, + OutboundEvent, + PlanFeedbackMessage, + PlanReviewEvent, + ReasoningEvent, + SendMessage, + StateChangeEvent, + StatusEvent, + StreamEndEvent, + ToolInfoEvent, + ToolResultEvent, + TurnCompleteEvent, + WorkstreamClosedEvent, + WorkstreamCreatedEvent, + WorkstreamListEvent, + WorkstreamRenameEvent, +) + + +# --------------------------------------------------------------------------- +# Inbound message round-trip tests +# --------------------------------------------------------------------------- + +INBOUND_TYPES = [ + ( + SendMessage, + { + "message": "hello", + "ws_id": "abc", + "auto_approve": True, + "auto_approve_tools": ["bash"], + }, + ), + ( + ApproveMessage, + {"ws_id": "abc", "request_id": "r1", "approved": True, "feedback": "ok"}, + ), + ( + PlanFeedbackMessage, + {"ws_id": "abc", "request_id": "r2", "feedback": "looks good"}, + ), + (CommandMessage, {"ws_id": "abc", "command": "/clear"}), + ( + CreateWorkstreamMessage, + {"name": "test-ws", "auto_approve": False, "auto_approve_tools": ["read_file"]}, + ), + (CloseWorkstreamMessage, {"ws_id": "abc"}), + (ListWorkstreamsMessage, {}), + (HealthMessage, {}), + (ListNodesMessage, {}), +] + + +@pytest.mark.parametrize("cls,kwargs", INBOUND_TYPES) +def test_inbound_round_trip(cls, kwargs): + msg = cls(**kwargs) + raw = msg.to_json() + parsed = json.loads(raw) + + # type field matches + assert parsed["type"] == msg.type + + # correlation_id auto-generated + assert len(msg.correlation_id) == 12 + assert parsed["correlation_id"] == msg.correlation_id + + # timestamp present + assert msg.timestamp > 0 + + # Deserialize back + restored = InboundMessage.from_json(raw) + assert type(restored) is cls + assert restored.type == msg.type + assert restored.correlation_id == msg.correlation_id + + # Check custom fields + for k, v in kwargs.items(): + assert getattr(restored, k) == v + + +def test_inbound_unknown_type(): + with pytest.raises(ValueError, match="Unknown inbound"): + InboundMessage.from_json('{"type": "nonexistent"}') + + +def test_inbound_extra_fields_ignored(): + raw = json.dumps({"type": "send", "message": "hi", "extra_field": 42}) + msg = InboundMessage.from_json(raw) + assert isinstance(msg, SendMessage) + assert msg.message == "hi" + assert not hasattr(msg, "extra_field") + + +# --------------------------------------------------------------------------- +# Outbound event round-trip tests +# --------------------------------------------------------------------------- + +OUTBOUND_TYPES = [ + (AckEvent, {"status": "ok", "detail": "done"}), + (ContentEvent, {"text": "hello world"}), + (ReasoningEvent, {"text": "thinking..."}), + (ToolInfoEvent, {"items": [{"name": "bash", "preview": "ls"}]}), + (ApprovalRequestEvent, {"items": [{"name": "bash", "needs_approval": True}]}), + (ToolResultEvent, {"name": "bash", "output": "file.txt"}), + (PlanReviewEvent, {"content": "# Plan\n\nStep 1: ..."}), + (StatusEvent, {"prompt_tokens": 100, "completion_tokens": 50, "pct": 0.42}), + (StateChangeEvent, {"state": "thinking"}), + (TurnCompleteEvent, {}), + (StreamEndEvent, {}), + (WorkstreamCreatedEvent, {"name": "test-ws"}), + (WorkstreamClosedEvent, {}), + (WorkstreamListEvent, {"workstreams": [{"id": "abc", "name": "ws"}]}), + (WorkstreamRenameEvent, {"name": "renamed"}), + (HealthResponseEvent, {"data": {"status": "ok"}}), + (ErrorEvent, {"message": "something broke"}), + (InfoEvent, {"message": "heads up"}), + ( + NodeListEvent, + {"nodes": [{"node_id": "server-12", "server_url": "http://x:8080"}]}, + ), +] + + +@pytest.mark.parametrize("cls,kwargs", OUTBOUND_TYPES) +def test_outbound_round_trip(cls, kwargs): + event = cls(ws_id="ws1", correlation_id="c1", **kwargs) + raw = event.to_json() + parsed = json.loads(raw) + + assert parsed["type"] == event.type + assert parsed["ws_id"] == "ws1" + assert parsed["correlation_id"] == "c1" + + restored = OutboundEvent.from_json(raw) + assert type(restored) is cls + assert restored.ws_id == "ws1" + assert restored.correlation_id == "c1" + + for k, v in kwargs.items(): + assert getattr(restored, k) == v + + +def test_outbound_unknown_type_falls_back(): + raw = json.dumps({"type": "future_event", "ws_id": "x"}) + event = OutboundEvent.from_json(raw) + assert isinstance(event, OutboundEvent) + assert event.ws_id == "x" + + +def test_send_message_defaults(): + msg = SendMessage(message="hello") + assert msg.ws_id == "" + assert msg.auto_approve is False + assert msg.auto_approve_tools == [] + assert msg.name == "" + assert msg.target_node == "" + assert len(msg.correlation_id) == 12 + + +def test_create_workstream_with_tools(): + msg = CreateWorkstreamMessage( + name="ci-runner", + auto_approve=False, + auto_approve_tools=["bash", "read_file", "search"], + ) + raw = msg.to_json() + restored = InboundMessage.from_json(raw) + assert restored.auto_approve_tools == ["bash", "read_file", "search"] + assert restored.name == "ci-runner" + + +def test_send_message_target_node(): + msg = SendMessage(message="check disk", target_node="server-12") + raw = msg.to_json() + restored = InboundMessage.from_json(raw) + assert isinstance(restored, SendMessage) + assert restored.target_node == "server-12" + assert restored.message == "check disk" + + +def test_create_workstream_target_node(): + msg = CreateWorkstreamMessage(name="debug-ws", target_node="gpu-node-3") + raw = msg.to_json() + restored = InboundMessage.from_json(raw) + assert isinstance(restored, CreateWorkstreamMessage) + assert restored.target_node == "gpu-node-3" + assert restored.name == "debug-ws" + + +def test_list_nodes_round_trip(): + msg = ListNodesMessage() + raw = msg.to_json() + restored = InboundMessage.from_json(raw) + assert isinstance(restored, ListNodesMessage) + + +def test_node_list_event_round_trip(): + nodes = [{"node_id": "a", "server_url": "http://a:8080"}] + event = NodeListEvent(nodes=nodes, correlation_id="c1") + raw = event.to_json() + restored = OutboundEvent.from_json(raw) + assert isinstance(restored, NodeListEvent) + assert restored.nodes == nodes diff --git a/tests/test_safety.py b/tests/test_safety.py new file mode 100644 index 00000000..09067421 --- /dev/null +++ b/tests/test_safety.py @@ -0,0 +1,69 @@ +"""Tests for turnstone.core.safety — is_command_blocked and sanitize_command.""" + +from turnstone.core.safety import is_command_blocked, sanitize_command + + +class TestIsCommandBlocked: + def test_rm_rf_root_blocked(self): + result = is_command_blocked("rm -rf /") + assert result is not None + assert "Blocked" in result + + def test_rm_rf_star_blocked(self): + result = is_command_blocked("rm -rf /*") + assert result is not None + + def test_mkfs_blocked(self): + result = is_command_blocked("mkfs /dev/sda") + assert result is not None + + def test_shutdown_blocked(self): + result = is_command_blocked("shutdown -h now") + assert result is not None + + def test_fork_bomb_blocked(self): + result = is_command_blocked(":(){ :|:& };:") + assert result is not None + + def test_dd_if_blocked(self): + result = is_command_blocked("dd if=/dev/zero of=/dev/sda") + assert result is not None + + def test_safe_ls_returns_none(self): + assert is_command_blocked("ls -la") is None + + def test_safe_git_returns_none(self): + assert is_command_blocked("git status") is None + + def test_safe_python_returns_none(self): + assert is_command_blocked("python script.py") is None + + def test_safe_rm_specific_file(self): + assert is_command_blocked("rm file.txt") is None + + def test_whitespace_preserved(self): + # Leading/trailing whitespace is stripped before checking + result = is_command_blocked(" rm -rf / ") + assert result is not None + + +class TestSanitizeCommand: + def test_left_single_curly_quote_replaced(self): + assert sanitize_command("\u2018hello\u2019") == "'hello'" + + def test_double_curly_quotes_replaced(self): + assert sanitize_command("\u201chello\u201d") == '"hello"' + + def test_en_dash_replaced(self): + assert sanitize_command("ls \u2013la") == "ls -la" + + def test_em_dash_replaced(self): + assert sanitize_command("cmd \u2014flag") == "cmd -flag" + + def test_plain_ascii_unchanged(self): + cmd = "git commit -m 'test'" + assert sanitize_command(cmd) == cmd + + def test_mixed_replacements(self): + cmd = "\u201ctest\u201d \u2013flag" + assert sanitize_command(cmd) == '"test" -flag' diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 00000000..68389b36 --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,99 @@ +"""Tests for turnstone.core.sandbox — validate_math_code and auto_print_wrap.""" + +from turnstone.core.sandbox import validate_math_code, auto_print_wrap + + +class TestValidateMathCode: + def test_safe_code_no_errors(self): + assert validate_math_code("x = 1 + 2\nprint(x)") == [] + + def test_safe_math_import(self): + assert validate_math_code("import math\nprint(math.pi)") == [] + + def test_blocked_import_os(self): + errors = validate_math_code("import os") + assert len(errors) == 1 + assert "os" in errors[0] + + def test_blocked_import_sys(self): + errors = validate_math_code("import sys") + assert len(errors) == 1 + assert "sys" in errors[0] + + def test_blocked_import_subprocess(self): + errors = validate_math_code("import subprocess") + assert len(errors) == 1 + assert "subprocess" in errors[0] + + def test_blocked_from_import(self): + errors = validate_math_code("from os.path import join") + assert len(errors) == 1 + assert "os" in errors[0] + + def test_blocked_builtin_exec(self): + errors = validate_math_code("exec('print(1)')") + assert len(errors) == 1 + assert "exec" in errors[0] + + def test_blocked_builtin_eval(self): + errors = validate_math_code("eval('1+1')") + assert len(errors) == 1 + assert "eval" in errors[0] + + def test_blocked_builtin_open(self): + errors = validate_math_code("open('file.txt')") + assert len(errors) == 1 + assert "open" in errors[0] + + def test_blocked_dunder_access(self): + errors = validate_math_code("x.__dict__") + assert len(errors) == 1 + assert "__dict__" in errors[0] + + def test_allowed_dunder_name(self): + # __name__, __doc__, __class__ are allowed + assert validate_math_code("print(int.__name__)") == [] + + def test_syntax_error_caught(self): + errors = validate_math_code("def f(\n") + assert len(errors) == 1 + assert "Syntax error" in errors[0] + + def test_multiple_violations(self): + code = "import os\nimport sys\nexec('x')" + errors = validate_math_code(code) + assert len(errors) == 3 + + +class TestAutoPrintWrap: + def test_bare_expression_wrapped(self): + result = auto_print_wrap("1 + 2") + assert "print(" in result + assert "1 + 2" in result + + def test_assignment_not_wrapped(self): + code = "x = 1 + 2" + assert auto_print_wrap(code) == code + + def test_code_with_print_not_wrapped(self): + code = "x = 1\nprint(x)" + assert auto_print_wrap(code) == code + + def test_code_with_result_assignment_not_wrapped(self): + code = "result = 42" + assert auto_print_wrap(code) == code + + def test_multiline_with_bare_expression_last(self): + code = "x = 2\ny = 3\nx + y" + result = auto_print_wrap(code) + assert "print(" in result + # The assignments should still be there + assert "x = 2" in result + assert "y = 3" in result + + def test_empty_code(self): + assert auto_print_wrap("") == "" + + def test_syntax_error_returns_original(self): + code = "def f(\n" + assert auto_print_wrap(code) == code diff --git a/tests/test_scoring.py b/tests/test_scoring.py new file mode 100644 index 00000000..abfeb174 --- /dev/null +++ b/tests/test_scoring.py @@ -0,0 +1,166 @@ +"""Tests for turnstone.eval — score_run and _match_action.""" + +from turnstone.eval import score_run, _match_action + + +class TestMatchAction: + def test_tool_name_match(self): + actual = {"tool": "bash", "args": {"command": "ls"}} + expected = {"tool": "bash"} + assert _match_action(actual, expected) is True + + def test_tool_name_mismatch(self): + actual = {"tool": "bash", "args": {"command": "ls"}} + expected = {"tool": "read_file"} + assert _match_action(actual, expected) is False + + def test_exact_args_match(self): + actual = {"tool": "bash", "args": {"command": "ls -la"}} + expected = {"tool": "bash", "args": {"command": "ls -la"}} + assert _match_action(actual, expected) is True + + def test_partial_key_matching(self): + # Expected only specifies a subset of actual args + actual = {"tool": "bash", "args": {"command": "ls", "extra": "val"}} + expected = {"tool": "bash", "args": {"command": "ls"}} + assert _match_action(actual, expected) is True + + def test_args_value_mismatch(self): + actual = {"tool": "bash", "args": {"command": "ls"}} + expected = {"tool": "bash", "args": {"command": "pwd"}} + assert _match_action(actual, expected) is False + + def test_args_missing_key(self): + actual = {"tool": "bash", "args": {"command": "ls"}} + expected = {"tool": "bash", "args": {"path": "/tmp"}} + assert _match_action(actual, expected) is False + + def test_args_pattern_regex_match(self): + actual = {"tool": "bash", "args": {"command": "git log -5"}} + expected = {"tool": "bash", "args_pattern": {"command": r"git\s+log"}} + assert _match_action(actual, expected) is True + + def test_args_pattern_regex_mismatch(self): + actual = {"tool": "bash", "args": {"command": "ls -la"}} + expected = {"tool": "bash", "args_pattern": {"command": r"^git"}} + assert _match_action(actual, expected) is False + + def test_raw_fallback_no_expected_args(self): + actual = {"tool": "bash", "args": {"_raw": "something"}} + expected = {"tool": "bash"} + assert _match_action(actual, expected) is True + + def test_raw_fallback_with_expected_args(self): + actual = {"tool": "bash", "args": {"_raw": "something"}} + expected = {"tool": "bash", "args": {"command": "ls"}} + assert _match_action(actual, expected) is False + + +class TestScoreRun: + def test_empty_expected_actions_passes(self): + result = score_run([{"tool": "bash", "args": {}}], []) + assert result["pass"] is True + assert result["score"] == 1.0 + + def test_ordered_subset_all_match(self): + tool_log = [ + {"tool": "read_file", "args": {"path": "a.py"}}, + {"tool": "bash", "args": {"command": "ls"}}, + {"tool": "edit_file", "args": {"path": "a.py"}}, + ] + expected = [ + {"tool": "read_file"}, + {"tool": "edit_file"}, + ] + result = score_run(tool_log, expected, match_mode="ordered_subset") + assert result["pass"] is True + assert result["score"] == 1.0 + + def test_ordered_subset_wrong_order(self): + tool_log = [ + {"tool": "edit_file", "args": {"path": "a.py"}}, + {"tool": "read_file", "args": {"path": "a.py"}}, + ] + expected = [ + {"tool": "read_file"}, + {"tool": "edit_file"}, + ] + result = score_run(tool_log, expected, match_mode="ordered_subset") + # edit_file comes before read_file, so only one can match + assert result["pass"] is False + assert result["score"] == 0.5 + + def test_exact_mode_pass(self): + tool_log = [ + {"tool": "bash", "args": {"command": "ls"}}, + {"tool": "read_file", "args": {"path": "a.py"}}, + ] + expected = [ + {"tool": "bash"}, + {"tool": "read_file"}, + ] + result = score_run(tool_log, expected, match_mode="exact") + assert result["pass"] is True + assert result["score"] == 1.0 + + def test_exact_mode_length_mismatch(self): + tool_log = [ + {"tool": "bash", "args": {"command": "ls"}}, + {"tool": "read_file", "args": {"path": "a.py"}}, + {"tool": "edit_file", "args": {"path": "a.py"}}, + ] + expected = [ + {"tool": "bash"}, + {"tool": "read_file"}, + ] + result = score_run(tool_log, expected, match_mode="exact") + # Length mismatch: 3 vs 2, so pass=False even though first 2 match + assert result["pass"] is False + + def test_subset_mode_unordered(self): + tool_log = [ + {"tool": "edit_file", "args": {"path": "a.py"}}, + {"tool": "read_file", "args": {"path": "a.py"}}, + ] + expected = [ + {"tool": "read_file"}, + {"tool": "edit_file"}, + ] + result = score_run(tool_log, expected, match_mode="subset") + assert result["pass"] is True + assert result["score"] == 1.0 + + def test_contains_any_mode_pass(self): + tool_log = [ + {"tool": "bash", "args": {"command": "ls"}}, + {"tool": "read_file", "args": {"path": "a.py"}}, + ] + expected = [ + {"tool": "read_file"}, + ] + result = score_run(tool_log, expected, match_mode="contains_any") + assert result["pass"] is True + assert result["score"] == 1.0 + + def test_contains_any_mode_fail(self): + tool_log = [ + {"tool": "bash", "args": {"command": "ls"}}, + ] + expected = [ + {"tool": "read_file"}, + ] + result = score_run(tool_log, expected, match_mode="contains_any") + assert result["pass"] is False + assert result["score"] == 0.0 + + def test_score_partial(self): + tool_log = [ + {"tool": "bash", "args": {"command": "ls"}}, + ] + expected = [ + {"tool": "bash"}, + {"tool": "read_file"}, + ] + result = score_run(tool_log, expected, match_mode="ordered_subset") + assert result["score"] == 0.5 + assert len(result["unmatched"]) == 1 diff --git a/tests/test_server_live.py b/tests/test_server_live.py new file mode 100644 index 00000000..046a2a73 --- /dev/null +++ b/tests/test_server_live.py @@ -0,0 +1,521 @@ +"""Integration tests against a live llama.cpp backend on port 8000. + +These tests use turnstone's HeadlessSession to run actual LLM inference +and tool execution against the backend. They verify end-to-end behavior: +model connectivity, tool calling, response quality, and session mechanics. + +Requires: llama-server (or compatible OpenAI API) running on localhost:8000. + +Run with: pytest tests/test_server_live.py -v --timeout=120 + +The TestServerHealthMetrics class does NOT require a live LLM and can be run +independently: pytest tests/test_server_live.py::TestServerHealthMetrics -v +""" + +import json +import os +import queue +import tempfile +import threading +import time +import httpx +import pytest +from openai import OpenAI + +from turnstone.core.session import ChatSession +from turnstone.core.tools import TOOLS +import turnstone.core.memory as _memory_module + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +BASE_URL = os.environ.get("TURNSTONE_TEST_BASE_URL", "http://localhost:8000/v1") + + +@pytest.fixture(scope="module") +def client(): + """Create an OpenAI client pointed at the local backend.""" + return OpenAI( + base_url=BASE_URL, + api_key=os.environ.get("TURNSTONE_TEST_API_KEY", "not-needed"), + ) + + +@pytest.fixture(scope="module") +def model_id(client): + """Auto-detect the model name from the backend.""" + models = client.models.list() + ids = [m.id for m in models.data] + assert len(ids) > 0, "No models found on the backend" + return ids[0] + + +class RecordingUI: + """Minimal SessionUI that captures events for assertions.""" + + def __init__(self): + self.events: list[tuple[str, ...]] = [] + self.content_tokens: list[str] = [] + self.reasoning_tokens: list[str] = [] + self.tool_results: list[tuple[str, str]] = [] + self.errors: list[str] = [] + self.infos: list[str] = [] + + def on_thinking_start(self): + self.events.append(("thinking_start",)) + + def on_thinking_stop(self): + self.events.append(("thinking_stop",)) + + def on_reasoning_token(self, text): + self.reasoning_tokens.append(text) + + def on_content_token(self, text): + self.content_tokens.append(text) + + def on_stream_end(self): + self.events.append(("stream_end",)) + + def approve_tools(self, items): + return True, None # auto-approve everything + + def on_tool_result(self, name, output): + self.tool_results.append((name, output)) + + def on_status(self, usage, context_window, effort): + self.events.append(("status",)) + + def on_plan_review(self, content): + return "" + + def on_info(self, message): + self.infos.append(message) + + def on_error(self, message): + self.errors.append(message) + + def on_state_change(self, state): + self.events.append(("state_change", state)) + + def on_rename(self, name: str): + self.events.append(("rename", name)) + + @property + def full_content(self) -> str: + return "".join(self.content_tokens) + + @property + def full_reasoning(self) -> str: + return "".join(self.reasoning_tokens) + + +@pytest.fixture +def tmp_db(): + """Temp DB to avoid polluting real conversation history.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + old = _memory_module.db_override + _memory_module.db_override = path + _memory_module.db_initialized.discard(path) + yield path + _memory_module.db_override = old + _memory_module.db_initialized.discard(path) + os.unlink(path) + + +def _make_session( + client, model_id, tmp_db, **kwargs +) -> tuple[ChatSession, RecordingUI]: + """Create a ChatSession with RecordingUI and sensible test defaults.""" + ui = RecordingUI() + defaults = dict( + client=client, + model=model_id, + ui=ui, + persona=None, + instructions=None, + temperature=0.3, + max_tokens=2048, + tool_timeout=30, + reasoning_effort="low", + ) + defaults.update(kwargs) + session = ChatSession(**defaults) + session.auto_approve = True + return session, ui + + +# --------------------------------------------------------------------------- +# Tests — Backend connectivity +# --------------------------------------------------------------------------- + + +class TestBackendConnectivity: + """Verify the LLM backend is reachable and returns valid responses.""" + + def test_models_endpoint(self, client): + models = client.models.list() + assert len(models.data) > 0 + + def test_model_id_detected(self, model_id): + assert isinstance(model_id, str) + assert len(model_id) > 0 + + def test_basic_completion(self, client, model_id): + """Raw API call — no turnstone involved.""" + resp = client.chat.completions.create( + model=model_id, + messages=[{"role": "user", "content": "Say 'hello'"}], + max_completion_tokens=200, + temperature=0.0, + stream=False, + ) + assert ( + resp.choices[0].message.content or resp.choices[0].message.reasoning_content + ) + assert resp.usage.total_tokens > 0 + + +# --------------------------------------------------------------------------- +# Tests — Streaming session +# --------------------------------------------------------------------------- + + +class TestStreamingSession: + """Test ChatSession.send() with streaming against the live backend.""" + + def test_simple_response(self, client, model_id, tmp_db): + """Model responds to a basic prompt via streaming.""" + session, ui = _make_session(client, model_id, tmp_db) + session.send("Reply with exactly: PONG") + + # Should have gotten some content or reasoning + total = ui.full_content + ui.full_reasoning + assert len(total) > 0, "No output from model" + + def test_reasoning_tokens_appear(self, client, model_id, tmp_db): + """Model produces reasoning tokens (extended thinking).""" + session, ui = _make_session(client, model_id, tmp_db) + session.send("What is 7 * 8?") + + # This model uses reasoning_content, so we expect reasoning tokens + assert len(ui.reasoning_tokens) > 0, "No reasoning tokens received" + + def test_stream_end_event(self, client, model_id, tmp_db): + """stream_end event is emitted after response.""" + session, ui = _make_session(client, model_id, tmp_db) + session.send("Say hi") + + event_types = [e[0] for e in ui.events] + assert "stream_end" in event_types + + def test_thinking_lifecycle(self, client, model_id, tmp_db): + """thinking_start and thinking_stop bracket the response.""" + session, ui = _make_session(client, model_id, tmp_db) + session.send("Say hi") + + event_types = [e[0] for e in ui.events] + assert "thinking_start" in event_types + assert "thinking_stop" in event_types + # thinking_start should come before thinking_stop + start_idx = event_types.index("thinking_start") + stop_idx = event_types.index("thinking_stop") + assert start_idx < stop_idx + + +# --------------------------------------------------------------------------- +# Tests — Tool calling +# --------------------------------------------------------------------------- + + +class TestToolCalling: + """Test that the model can invoke tools and turnstone executes them.""" + + def test_math_tool(self, client, model_id, tmp_db): + """Model uses the math tool for computation.""" + session, ui = _make_session( + client, + model_id, + tmp_db, + instructions="You have tools. Use the math tool to compute results. Always use tools when asked to calculate.", + ) + session.send("Use the math tool to calculate: 17 * 23. Report the result.") + + # Check if math tool was invoked + math_results = [r for r in ui.tool_results if r[0] == "math"] + if math_results: + # Verify the result contains 391 + assert "391" in math_results[0][1], ( + f"Expected 391, got: {math_results[0][1]}" + ) + else: + # Model may have answered directly — check content + total = ui.full_content + ui.full_reasoning + assert "391" in total, f"Expected 391 somewhere in output" + + def test_bash_tool(self, client, model_id, tmp_db): + """Model uses bash to answer a system question.""" + session, ui = _make_session( + client, + model_id, + tmp_db, + instructions="You have tools. Use the bash tool to run commands. Always use bash when asked about system info.", + ) + session.send( + "Use the bash tool to run 'echo hello_from_test' and report what it prints." + ) + + bash_results = [r for r in ui.tool_results if r[0] == "bash"] + if bash_results: + assert "hello_from_test" in bash_results[0][1] + else: + total = ui.full_content + ui.full_reasoning + assert "hello_from_test" in total, "Expected bash output in response" + + def test_read_file_tool(self, client, model_id, tmp_db): + """Model uses read_file to read a known file.""" + # Create a temp file for the model to read + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("SECRET_CONTENT_42\n") + path = f.name + + try: + session, ui = _make_session( + client, + model_id, + tmp_db, + instructions="You have tools. Use the read_file tool to read files. Always use read_file when asked to read a file.", + ) + session.send( + f"Use the read_file tool to read {path} and tell me what it says." + ) + + # read_file was invoked (UI gets a summary like "1 lines") + read_results = [r for r in ui.tool_results if r[0] == "read_file"] + assert len(read_results) > 0, "read_file tool was not called" + + # The model sees the actual file content and should relay it + total = ui.full_content + ui.full_reasoning + assert "SECRET_CONTENT_42" in total, ( + f"Model didn't relay file content. Got: {total[:500]}" + ) + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# Tests — Multi-turn conversation +# --------------------------------------------------------------------------- + + +class TestMultiTurn: + """Test multi-turn conversation state.""" + + def test_context_retained(self, client, model_id, tmp_db): + """Second message can reference the first.""" + session, ui = _make_session(client, model_id, tmp_db, max_tokens=1024) + session.send("My name is Zephyr. Remember it.") + + # Reset UI tracking for second turn + ui.content_tokens.clear() + ui.reasoning_tokens.clear() + + session.send("What is my name?") + + total = ui.full_content + ui.full_reasoning + assert "zephyr" in total.lower(), f"Model forgot the name. Got: {total[:300]}" + + def test_message_list_grows(self, client, model_id, tmp_db): + """Each send adds user + assistant messages.""" + session, ui = _make_session(client, model_id, tmp_db, max_tokens=512) + + initial_count = len(session.messages) + session.send("Hello") + + # Should have at least user + assistant + assert len(session.messages) >= initial_count + 2 + + +# --------------------------------------------------------------------------- +# Tests — Session configuration +# --------------------------------------------------------------------------- + + +class TestSessionConfig: + """Test session construction and configuration.""" + + def test_creative_mode_no_tools(self, client, model_id, tmp_db): + """In creative mode, tools are not sent to the API.""" + session, ui = _make_session(client, model_id, tmp_db, max_tokens=256) + session.creative_mode = True + session.send("Write a haiku about code.") + + # Should get content back without tool calls + total = ui.full_content + ui.full_reasoning + assert len(total) > 0 + assert len(ui.tool_results) == 0 + + def test_custom_instructions(self, client, model_id, tmp_db): + """Custom instructions are included in the session.""" + session, ui = _make_session( + client, + model_id, + tmp_db, + instructions="Always end your response with ENDMARKER.", + max_tokens=512, + ) + session.send("Say hello briefly.") + + total = ui.full_content + # We can't strictly guarantee the model follows instructions, + # but we verify the session didn't error out + assert len(ui.errors) == 0 + + +# --------------------------------------------------------------------------- +# Tests — /health and /metrics endpoints (no live LLM required) +# --------------------------------------------------------------------------- + + +class TestServerHealthMetrics: + """Verify /health and /metrics endpoints using an in-process HTTP server. + + These tests spin up a real ThreadedHTTPServer with a mock WorkstreamManager + so no live LLM backend is required. Run them independently with: + + pytest tests/test_server_live.py::TestServerHealthMetrics -v + """ + + @classmethod + def setup_class(cls): + from unittest.mock import MagicMock + import turnstone.server as srv_mod + from turnstone.core.workstream import WorkstreamState + + # Reset module-level metrics so each test run starts fresh + srv_mod._metrics = srv_mod.MetricsCollector() + srv_mod._metrics.model = "test-model" + + # Mock WorkstreamManager.list_all() to return one idle workstream + mock_ws = MagicMock() + mock_ws.state = WorkstreamState.IDLE + mock_mgr = MagicMock() + mock_mgr.list_all.return_value = [mock_ws] + + # Start a server on a random port (port 0 → OS assigns free port) + cls.server = srv_mod.ThreadedHTTPServer( + ("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler + ) + from turnstone.core.auth import AuthConfig + + cls.server.workstreams = mock_mgr + cls.server.skip_permissions = False + cls.server.global_listeners = [] + cls.server.global_queue = queue.Queue() + cls.server.global_listeners_lock = threading.Lock() + cls.server.auth_config = AuthConfig() # auth disabled by default + + port = cls.server.server_address[1] + cls.base = f"http://127.0.0.1:{port}" + + cls._thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls._thread.start() + + @classmethod + def teardown_class(cls): + cls.server.shutdown() + cls._thread.join(timeout=5) + + def _get(self, path) -> tuple[int, str, dict]: + """Make a GET request; return (status, content_type, body_str).""" + url = self.base + path + resp = httpx.get(url, timeout=5) + ct = resp.headers.get("content-type", "") + return resp.status_code, ct, resp.text + + def test_health_returns_200(self): + status, _, _ = self._get("/health") + assert status == 200 + + def test_health_content_type_json(self): + _, ct, _ = self._get("/health") + assert "application/json" in ct + + def test_health_response_structure(self): + _, _, body = self._get("/health") + data = json.loads(body) + assert data["status"] == "ok" + assert "version" in data + assert "uptime_seconds" in data + assert "model" in data + assert "workstreams" in data + + def test_health_model_field(self): + _, _, body = self._get("/health") + data = json.loads(body) + assert data["model"] == "test-model" + + def test_health_workstream_counts(self): + _, _, body = self._get("/health") + data = json.loads(body) + wss = data["workstreams"] + assert wss["total"] == 1 + assert wss["idle"] == 1 + + def test_health_uptime_positive(self): + _, _, body = self._get("/health") + data = json.loads(body) + assert data["uptime_seconds"] >= 0 + + def test_metrics_returns_200(self): + status, _, _ = self._get("/metrics") + assert status == 200 + + def test_metrics_content_type_prometheus(self): + _, ct, _ = self._get("/metrics") + assert "text/plain" in ct + assert "version=0.0.4" in ct + + def test_metrics_contains_uptime(self): + _, _, body = self._get("/metrics") + assert "turnstone_uptime_seconds" in body + + def test_metrics_contains_build_info(self): + _, _, body = self._get("/metrics") + assert "turnstone_build_info" in body + assert 'model="test-model"' in body + + def test_metrics_contains_workstreams(self): + _, _, body = self._get("/metrics") + assert "turnstone_workstreams_active_total" in body + assert "turnstone_workstreams_by_state" in body + + def test_metrics_contains_token_counters(self): + _, _, body = self._get("/metrics") + assert "turnstone_tokens_total" in body + assert 'type="prompt"' in body + assert 'type="completion"' in body + + def test_metrics_contains_http_requests(self): + _, _, body = self._get("/metrics") + assert "turnstone_http_requests_total" in body + + def test_metrics_request_counter_increments(self): + """Hitting /health increments the HTTP request counter.""" + # Make a known request to /health + self._get("/health") + _, _, body = self._get("/metrics") + # Counter should mention /health endpoint + assert 'endpoint="/health"' in body + + def test_metrics_histogram_present(self): + _, _, body = self._get("/metrics") + assert "turnstone_http_request_duration_seconds" in body + assert 'le="' in body + assert 'le="+Inf"' in body + + def test_unknown_endpoint_returns_404(self): + status, _, _ = self._get("/does-not-exist") + assert status == 404 diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 00000000..0689840c --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,275 @@ +"""Tests for turnstone.core.session — ChatSession construction.""" + +import json +import os +from unittest.mock import MagicMock, patch + +from turnstone.core.session import ChatSession + + +class NullUI: + """UI adapter that discards all output. Used for testing.""" + + def on_thinking_start(self): + pass + + def on_thinking_stop(self): + pass + + def on_reasoning_token(self, text): + pass + + def on_content_token(self, text): + pass + + def on_stream_end(self): + pass + + def approve_tools(self, items): + return True, None + + def on_tool_result(self, name, output): + pass + + def on_status(self, usage, context_window, effort): + pass + + def on_plan_review(self, content): + return "" + + def on_info(self, message): + pass + + def on_error(self, message): + pass + + def on_state_change(self, state): + pass + + +def _make_session( + mock_openai_client=None, + persona=None, + instructions=None, + **kwargs, +): + """Helper to construct a ChatSession with minimal setup.""" + client = mock_openai_client or MagicMock() + defaults = dict( + client=client, + model="test-model", + ui=NullUI(), + persona=persona, + instructions=instructions, + temperature=0.5, + max_tokens=4096, + tool_timeout=30, + ) + defaults.update(kwargs) + return ChatSession(**defaults) + + +class TestChatSessionConstruction: + def test_system_messages_created(self, tmp_db): + session = _make_session() + assert len(session.system_messages) >= 1 + # At least one developer message + roles = [m["role"] for m in session.system_messages] + assert "developer" in roles + + def test_persona_injected_into_chat_template_kwargs(self, tmp_db): + session = _make_session(persona="Helpful assistant") + assert "model_identity" in session._chat_template_kwargs + assert "Helpful assistant" in session._chat_template_kwargs["model_identity"] + + def test_no_persona_no_model_identity(self, tmp_db): + session = _make_session(persona=None) + assert "model_identity" not in session._chat_template_kwargs + + def test_instructions_appended_to_developer_message(self, tmp_db): + session = _make_session(instructions="Always be concise.") + dev_msgs = [m for m in session.system_messages if m["role"] == "developer"] + assert len(dev_msgs) >= 1 + assert "Always be concise." in dev_msgs[0]["content"] + + def test_full_messages_returns_system_plus_conversation(self, tmp_db): + session = _make_session() + # Initially no conversation messages + full = session._full_messages() + assert len(full) == len(session.system_messages) + + # Add a user message + session.messages.append({"role": "user", "content": "hello"}) + full = session._full_messages() + assert len(full) == len(session.system_messages) + 1 + assert full[-1]["role"] == "user" + + def test_msg_char_count_content_only(self, tmp_db): + session = _make_session() + msg = {"role": "assistant", "content": "hello world"} + assert session._msg_char_count(msg) == 11 + + def test_msg_char_count_with_tool_calls(self, tmp_db): + session = _make_session() + msg = { + "role": "assistant", + "content": "hi", + "tool_calls": [ + { + "id": "tc_1", + "function": { + "name": "bash", + "arguments": '{"command": "ls"}', + }, + } + ], + } + # "hi" (2) + "bash" (4) + '{"command": "ls"}' (17) = 23 + assert session._msg_char_count(msg) == 23 + + def test_msg_char_count_none_content(self, tmp_db): + session = _make_session() + msg = {"role": "assistant", "content": None} + assert session._msg_char_count(msg) == 0 + + def test_reasoning_effort_stored(self, tmp_db): + session = _make_session(reasoning_effort="high") + assert session.reasoning_effort == "high" + + def test_default_reasoning_effort(self, tmp_db): + session = _make_session() + assert session.reasoning_effort == "medium" + + +# --------------------------------------------------------------------------- +# Tests — _exec_plan (session-scoped plan files + existing-plan re-read) +# --------------------------------------------------------------------------- + + +class TestPlanExec: + """Tests for _exec_plan: unique session-scoped plan file and existing-plan injection.""" + + def _run_plan(self, session, prompt, agent_return="# Plan\n\nDo the thing."): + """Invoke _exec_plan with _run_agent patched to avoid LLM calls. + + Returns (call_id_returned, content_returned, captured_messages) where + captured_messages is the agent_messages list passed to _run_agent. + """ + captured = {} + + def fake_run_agent(messages, **kwargs): + captured["messages"] = list(messages) + return agent_return + + item = {"call_id": "test-call-1", "prompt": prompt} + with patch.object(session, "_run_agent", side_effect=fake_run_agent): + call_id, content = session._exec_plan(item) + + return call_id, content, captured.get("messages", []) + + def test_plan_file_uses_session_id(self, tmp_db, tmp_path, monkeypatch): + """Plan file is named .plan-.md, not .plan.md.""" + monkeypatch.chdir(tmp_path) + session = _make_session() + self._run_plan(session, "add feature") + expected = tmp_path / f".plan-{session._session_id}.md" + assert expected.exists(), f"Expected {expected} to be created" + assert not (tmp_path / ".plan.md").exists() + + def test_plan_file_contains_agent_output(self, tmp_db, tmp_path, monkeypatch): + """Written plan file contains the agent's output verbatim.""" + monkeypatch.chdir(tmp_path) + session = _make_session() + plan_content = "## Goal\n\nAdd a new endpoint." + self._run_plan(session, "add endpoint", agent_return=plan_content) + plan_file = tmp_path / f".plan-{session._session_id}.md" + assert plan_file.read_text() == plan_content + + def test_two_sessions_produce_different_files(self, tmp_db, tmp_path, monkeypatch): + """Two ChatSession instances never collide on the same plan file.""" + monkeypatch.chdir(tmp_path) + s1 = _make_session() + s2 = _make_session() + assert s1._session_id != s2._session_id + self._run_plan(s1, "feature A") + self._run_plan(s2, "feature B") + files = list(tmp_path.glob(".plan-*.md")) + assert len(files) == 2 + + def _seed_prior_plan(self, session, prior_prompt, prior_content): + """Simulate a completed plan tool call in session.messages.""" + tc_id = "call_prior_plan" + session.messages.append( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": tc_id, + "type": "function", + "function": { + "name": "plan", + "arguments": json.dumps({"prompt": prior_prompt}), + }, + } + ], + } + ) + session.messages.append( + { + "role": "tool", + "tool_call_id": tc_id, + "content": prior_content, + } + ) + + def test_no_prior_plan_no_extra_messages(self, tmp_db, tmp_path, monkeypatch): + """First invocation: no prior plan in history, agent gets no tool pair.""" + monkeypatch.chdir(tmp_path) + session = _make_session() + _, _, messages = self._run_plan(session, "build something") + roles = [m["role"] for m in messages] + assert "tool" not in roles + + def test_prior_plan_from_messages_injected(self, tmp_db, tmp_path, monkeypatch): + """Second invocation: prior plan from session.messages arrives as real tool result.""" + monkeypatch.chdir(tmp_path) + session = _make_session() + self._seed_prior_plan(session, "build feature X", "## Goal\n\nOriginal plan.") + + _, _, messages = self._run_plan(session, "also handle edge case Y") + + # The real assistant tool_calls message is forwarded + assistant_with_tc = [ + m for m in messages if m["role"] == "assistant" and m.get("tool_calls") + ] + assert len(assistant_with_tc) == 1 + assert assistant_with_tc[0]["tool_calls"][0]["function"]["name"] == "plan" + + # The real tool result is forwarded with its original content + tool_msgs = [m for m in messages if m["role"] == "tool"] + assert len(tool_msgs) == 1 + assert "Original plan." in tool_msgs[0]["content"] + + def test_prior_plan_appears_before_user_prompt(self, tmp_db, tmp_path, monkeypatch): + """The prior plan tool pair appears before the new user prompt.""" + monkeypatch.chdir(tmp_path) + session = _make_session() + self._seed_prior_plan(session, "original", "Old plan.") + + _, _, messages = self._run_plan(session, "refinement prompt") + + tool_idx = next(i for i, m in enumerate(messages) if m["role"] == "tool") + user_idx = next(i for i, m in enumerate(messages) if m["role"] == "user") + assert tool_idx < user_idx + + def test_exec_plan_returns_content(self, tmp_db, tmp_path, monkeypatch): + """_exec_plan returns (call_id, agent_output).""" + monkeypatch.chdir(tmp_path) + session = _make_session() + agent_output = "## Goal\n\nBuild it." + call_id, content, _ = self._run_plan( + session, "do stuff", agent_return=agent_output + ) + assert call_id == "test-call-1" + assert content == agent_output diff --git a/tests/test_sessions.py b/tests/test_sessions.py new file mode 100644 index 00000000..c9fd7b96 --- /dev/null +++ b/tests/test_sessions.py @@ -0,0 +1,361 @@ +"""Tests for session persistence and resume functionality.""" + +from unittest.mock import MagicMock, patch + +import turnstone.core.memory as memory +from turnstone.core.memory import ( + register_session, + update_session_title, + set_session_alias, + resolve_session, + list_sessions, + load_session_messages, + delete_session, + save_message, + open_db, +) +from turnstone.core.session import ChatSession + + +# ── Session registration ────────────────────────────────────────────── + + +class TestRegisterSession: + def test_register_creates_row(self, tmp_db): + register_session("abc123") + # Session exists in DB (resolve works) even without messages + assert resolve_session("abc123") == "abc123" + + def test_register_with_title(self, tmp_db): + register_session("abc123", title="My Session") + save_message("abc123", "user", "hello") + rows = list_sessions() + assert rows[0][2] == "My Session" # title + + def test_register_idempotent(self, tmp_db): + register_session("abc123", title="First") + register_session("abc123", title="Second") # should be ignored + save_message("abc123", "user", "hello") + rows = list_sessions() + assert len(rows) == 1 + assert rows[0][2] == "First" # original title preserved + + def test_update_title(self, tmp_db): + register_session("abc123") + update_session_title("abc123", "New Title") + save_message("abc123", "user", "hello") + rows = list_sessions() + assert rows[0][2] == "New Title" + + +# ── Session alias ───────────────────────────────────────────────────── + + +class TestSessionAlias: + def test_set_alias(self, tmp_db): + register_session("abc123") + assert set_session_alias("abc123", "my-session") is True + save_message("abc123", "user", "hello") + rows = list_sessions() + assert rows[0][1] == "my-session" # alias + + def test_alias_conflict(self, tmp_db): + register_session("abc123") + register_session("def456") + set_session_alias("abc123", "taken") + assert set_session_alias("def456", "taken") is False + + def test_alias_same_session_ok(self, tmp_db): + register_session("abc123") + set_session_alias("abc123", "mine") + assert set_session_alias("abc123", "mine") is True # no-op, same session + + +# ── Session resolution ──────────────────────────────────────────────── + + +class TestResolveSession: + def test_resolve_by_alias(self, tmp_db): + register_session("abc123") + set_session_alias("abc123", "my-alias") + assert resolve_session("my-alias") == "abc123" + + def test_resolve_by_exact_id(self, tmp_db): + register_session("abc123def456") + assert resolve_session("abc123def456") == "abc123def456" + + def test_resolve_by_prefix(self, tmp_db): + register_session("abc123def456") + assert resolve_session("abc123") == "abc123def456" + + def test_resolve_prefix_ambiguous(self, tmp_db): + register_session("abc123aaaaaa") + register_session("abc123bbbbbb") + # Ambiguous prefix should return None + assert resolve_session("abc123") is None + + def test_resolve_not_found(self, tmp_db): + assert resolve_session("nonexistent") is None + + def test_resolve_legacy_session(self, tmp_db): + """Sessions that exist only in conversations (pre-migration) should auto-register.""" + save_message("legacy123456", "user", "old message") + result = resolve_session("legacy123456") + assert result == "legacy123456" + # Should now appear in sessions list + rows = list_sessions() + assert any(r[0] == "legacy123456" for r in rows) + + +# ── List sessions ───────────────────────────────────────────────────── + + +class TestListSessions: + def test_empty(self, tmp_db): + assert list_sessions() == [] + + def test_ordered_by_updated(self, tmp_db): + register_session("first") + save_message("first", "user", "hello") + register_session("second") + save_message("second", "user", "hello") + # second is more recent + rows = list_sessions() + assert rows[0][0] == "second" + assert rows[1][0] == "first" + + def test_includes_message_count(self, tmp_db): + register_session("sess1") + save_message("sess1", "user", "hello") + save_message("sess1", "assistant", "hi") + rows = list_sessions() + assert rows[0][5] == 2 # msg_count + + def test_respects_limit(self, tmp_db): + for i in range(5): + register_session(f"sess{i}") + save_message(f"sess{i}", "user", "hello") + rows = list_sessions(limit=3) + assert len(rows) == 3 + + +# ── Load session messages ───────────────────────────────────────────── + + +class TestLoadSessionMessages: + def test_simple_user_assistant(self, tmp_db): + save_message("s1", "user", "hello") + save_message("s1", "assistant", "hi there") + msgs = load_session_messages("s1") + assert len(msgs) == 2 + assert msgs[0] == {"role": "user", "content": "hello"} + assert msgs[1] == {"role": "assistant", "content": "hi there"} + + def test_tool_calls_with_ids(self, tmp_db): + save_message("s1", "user", "run ls") + save_message("s1", "assistant", "Let me check.") + save_message( + "s1", "tool_call", None, "bash", '{"command":"ls"}', tool_call_id="call_abc" + ) + save_message( + "s1", "tool_result", "file1.txt\nfile2.txt", "bash", tool_call_id="call_abc" + ) + msgs = load_session_messages("s1") + assert len(msgs) == 3 # user, assistant+tool_calls, tool + # Assistant should have content merged with tool_calls + assert msgs[1]["role"] == "assistant" + assert msgs[1]["content"] == "Let me check." + assert len(msgs[1]["tool_calls"]) == 1 + assert msgs[1]["tool_calls"][0]["id"] == "call_abc" + assert msgs[1]["tool_calls"][0]["function"]["name"] == "bash" + # Tool result + assert msgs[2]["role"] == "tool" + assert msgs[2]["tool_call_id"] == "call_abc" + assert msgs[2]["content"] == "file1.txt\nfile2.txt" + + def test_tool_calls_without_ids_positional(self, tmp_db): + """Legacy data without tool_call_id uses positional matching.""" + save_message("s1", "user", "do stuff") + save_message("s1", "tool_call", None, "bash", '{"command":"ls"}') + save_message("s1", "tool_result", "output", "bash") + msgs = load_session_messages("s1") + assert len(msgs) == 3 + # Synthetic IDs should match + tc_id = msgs[1]["tool_calls"][0]["id"] + assert msgs[2]["tool_call_id"] == tc_id + + def test_parallel_tool_calls(self, tmp_db): + save_message("s1", "user", "search two things") + save_message( + "s1", "tool_call", None, "search", '{"query":"a"}', tool_call_id="call_1" + ) + save_message( + "s1", "tool_call", None, "search", '{"query":"b"}', tool_call_id="call_2" + ) + save_message("s1", "tool_result", "result a", "search", tool_call_id="call_1") + save_message("s1", "tool_result", "result b", "search", tool_call_id="call_2") + msgs = load_session_messages("s1") + assert len(msgs) == 4 # user, assistant+2 tool_calls, 2 tool results + assert len(msgs[1]["tool_calls"]) == 2 + assert msgs[2]["tool_call_id"] == "call_1" + assert msgs[3]["tool_call_id"] == "call_2" + + def test_empty_session(self, tmp_db): + assert load_session_messages("nonexistent") == [] + + def test_orphaned_tool_result_skipped(self, tmp_db): + save_message("s1", "user", "hello") + save_message("s1", "tool_result", "orphan", "bash") + msgs = load_session_messages("s1") + assert len(msgs) == 1 # only the user message + + +# ── Delete session ──────────────────────────────────────────────────── + + +class TestDeleteSession: + def test_delete_removes_session_and_messages(self, tmp_db): + register_session("abc123") + save_message("abc123", "user", "hello") + save_message("abc123", "assistant", "hi") + assert delete_session("abc123") is True + assert list_sessions() == [] + assert load_session_messages("abc123") == [] + + def test_delete_nonexistent(self, tmp_db): + assert delete_session("nonexistent") is True # no-op, still returns True + + +# ── save_message with tool_call_id ──────────────────────────────────── + + +class TestSaveMessageToolCallId: + def test_tool_call_id_stored(self, tmp_db): + save_message( + "s1", "tool_call", None, "bash", '{"cmd":"ls"}', tool_call_id="call_xyz" + ) + conn = open_db() + try: + row = conn.execute( + "SELECT tool_call_id FROM conversations WHERE session_id = 's1'" + ).fetchone() + assert row[0] == "call_xyz" + finally: + conn.close() + + def test_tool_call_id_none_by_default(self, tmp_db): + save_message("s1", "user", "hello") + conn = open_db() + try: + row = conn.execute( + "SELECT tool_call_id FROM conversations WHERE session_id = 's1'" + ).fetchone() + assert row[0] is None + finally: + conn.close() + + +# ── Sessions table creation ─────────────────────────────────────────── + + +class TestSessionsTable: + def test_sessions_table_exists(self, tmp_db): + conn = open_db() + try: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'" + ).fetchall() + assert len(rows) == 1 + finally: + conn.close() + + def test_tool_call_id_column_exists(self, tmp_db): + conn = open_db() + try: + # Should not raise + conn.execute("SELECT tool_call_id FROM conversations LIMIT 0") + finally: + conn.close() + + +# ── ChatSession.resume_session ──────────────────────────────────────── + + +class TestResumeSession: + def test_resume_loads_messages(self, tmp_db, mock_openai_client): + # Set up a session with messages in DB + register_session("old_sess_123") + save_message("old_sess_123", "user", "hello world") + save_message("old_sess_123", "assistant", "hi there") + + # Create a new session and resume + session = ChatSession( + client=mock_openai_client, + model="test-model", + ui=MagicMock(), + persona=None, + instructions=None, + temperature=0.5, + max_tokens=1000, + tool_timeout=10, + ) + original_id = session._session_id + assert original_id != "old_sess_123" + + result = session.resume_session("old_sess_123") + assert result is True + assert session._session_id == "old_sess_123" + assert len(session.messages) == 2 + assert session.messages[0]["content"] == "hello world" + assert session._title_generated is True + + def test_resume_nonexistent_returns_false(self, tmp_db, mock_openai_client): + session = ChatSession( + client=mock_openai_client, + model="test-model", + ui=MagicMock(), + persona=None, + instructions=None, + temperature=0.5, + max_tokens=1000, + tool_timeout=10, + ) + assert session.resume_session("nonexistent") is False + + def test_session_registered_on_init(self, tmp_db, mock_openai_client): + session = ChatSession( + client=mock_openai_client, + model="test-model", + ui=MagicMock(), + persona=None, + instructions=None, + temperature=0.5, + max_tokens=1000, + tool_timeout=10, + ) + # Session is registered in DB (resolvable) even before any messages + assert resolve_session(session._session_id) == session._session_id + # But does not appear in list_sessions until a message is saved + assert not any(r[0] == session._session_id for r in list_sessions()) + + +# ── save_message updates sessions.updated ───────────────────────────── + + +class TestSaveMessageUpdatesSession: + def test_updated_timestamp_bumped(self, tmp_db): + register_session("s1") + save_message("s1", "user", "first") + rows = list_sessions() + original_updated = rows[0][4] + + import time + + time.sleep(0.01) # ensure different timestamp + save_message("s1", "user", "hello") + + rows = list_sessions() + new_updated = rows[0][4] + # updated should be same or later (sqlite datetime resolution is seconds, + # so they may be equal in fast tests — just verify no error) + assert new_updated is not None diff --git a/tests/test_sim.py b/tests/test_sim.py new file mode 100644 index 00000000..418b2198 --- /dev/null +++ b/tests/test_sim.py @@ -0,0 +1,355 @@ +"""Tests for the turnstone cluster simulator.""" + +from __future__ import annotations + +import asyncio +import random +from unittest.mock import MagicMock, call + +import pytest + +from turnstone.mq.protocol import ( + ContentEvent, + InboundMessage, + OutboundEvent, + SendMessage, + StateChangeEvent, + TurnCompleteEvent, + WorkstreamCreatedEvent, +) +from turnstone.sim.config import SimConfig +from turnstone.sim.engine import SimEngine, ToolSimulationError +from turnstone.sim.metrics import MetricsCollector +from turnstone.sim.node import SimNode, SimWorkstream + + +def _run(coro): + """Run an async coroutine synchronously.""" + return asyncio.run(coro) + + +# --------------------------------------------------------------------------- +# SimConfig +# --------------------------------------------------------------------------- + + +class TestSimConfig: + def test_defaults(self): + cfg = SimConfig() + assert cfg.num_nodes == 10 + assert cfg.scenario == "steady" + assert cfg.llm_latency_mean == 2.0 + assert cfg.tool_failure_rate == 0.02 + + def test_frozen(self): + cfg = SimConfig() + with pytest.raises(AttributeError): + cfg.num_nodes = 5 # type: ignore[misc] + + def test_custom_values(self): + cfg = SimConfig(num_nodes=100, scenario="burst", seed=42) + assert cfg.num_nodes == 100 + assert cfg.scenario == "burst" + assert cfg.seed == 42 + + +# --------------------------------------------------------------------------- +# SimEngine +# --------------------------------------------------------------------------- + + +class TestSimEngine: + @pytest.fixture + def fast_config(self): + return SimConfig( + llm_latency_mean=0.01, + llm_latency_stddev=0.001, + llm_tokens_mean=20, + llm_tokens_stddev=5, + tool_latency_mean=0.01, + tool_latency_stddev=0.001, + tool_failure_rate=0.0, + seed=42, + ) + + @pytest.fixture + def engine(self, fast_config): + return SimEngine(fast_config) + + def test_llm_response_returns_content(self, engine): + async def _test(): + content, tool_calls = await engine.simulate_llm_response(True, 1) + assert isinstance(content, str) + assert len(content) > 0 + assert isinstance(tool_calls, list) + + _run(_test()) + + def test_llm_response_reproducible_with_seed(self, fast_config): + async def _test(): + e1 = SimEngine(fast_config, rng=random.Random(123)) + e2 = SimEngine(fast_config, rng=random.Random(123)) + c1, t1 = await e1.simulate_llm_response(True, 1) + c2, t2 = await e2.simulate_llm_response(True, 1) + assert c1 == c2 + assert len(t1) == len(t2) + + _run(_test()) + + def test_tool_execution_success(self, engine): + async def _test(): + result = await engine.simulate_tool_execution("bash") + assert "bash" in result + assert "completed" in result + + _run(_test()) + + def test_tool_execution_failure(self, fast_config): + cfg = SimConfig( + llm_latency_mean=0.01, + tool_latency_mean=0.01, + tool_latency_stddev=0.001, + tool_failure_rate=1.0, # always fail + seed=42, + ) + engine = SimEngine(cfg) + + async def _test(): + with pytest.raises(ToolSimulationError, match="Simulated bash failure"): + await engine.simulate_tool_execution("bash") + + _run(_test()) + + def test_generate_content(self, engine): + content = engine._generate_content(10) + words = content.split() + assert len(words) == 10 + + +# --------------------------------------------------------------------------- +# MetricsCollector +# --------------------------------------------------------------------------- + + +class TestMetricsCollector: + def test_record_and_summary(self): + m = MetricsCollector() + m.record_inject() + m.record_turn("ws1", "node-0", 1.5) + m.record_turn("ws2", "node-0", 2.5) + m.record_turn("ws3", "node-1", 3.0) + m.record_error("node-0", "test error") + + report = m.summary() + assert report["total_turns"] == 3 + assert report["total_errors"] == 1 + assert report["latency"]["p50"] == 2.5 + assert report["latency"]["max"] == 3.0 + assert report["turns_per_node"]["node-0"] == 2 + assert report["turns_per_node"]["node-1"] == 1 + + def test_empty_summary(self): + m = MetricsCollector() + report = m.summary() + assert report["total_turns"] == 0 + assert report["latency"]["p50"] == 0 + + def test_node_kill_tracking(self): + m = MetricsCollector() + m.record_node_kill("node-0") + m.record_node_kill("node-1") + report = m.summary() + assert report["node_kills"] == 2 + + def test_utilization_snapshot(self): + m = MetricsCollector() + m.snapshot_utilization({"node-0": 3, "node-1": 5, "node-2": 0}) + report = m.summary() + assert report["utilization"]["mean_ws_per_node"] == pytest.approx(8 / 3) + assert report["utilization"]["max_ws_per_node"] == 5 + assert report["utilization"]["nodes_with_zero_ws"] == 1 + + +# --------------------------------------------------------------------------- +# SimNode — message dispatch +# --------------------------------------------------------------------------- + + +class TestSimNode: + @pytest.fixture + def fast_config(self): + return SimConfig( + llm_latency_mean=0.01, + llm_latency_stddev=0.001, + llm_tokens_mean=10, + llm_tokens_stddev=2, + llm_token_rate=1000, + tool_latency_mean=0.01, + tool_latency_stddev=0.001, + tool_failure_rate=0.0, + max_tool_rounds=0, # no tool calls — fast turn + seed=42, + ) + + @pytest.fixture + def mock_broker(self): + broker = MagicMock() + broker.list_nodes.return_value = [] + return broker + + @pytest.fixture + def node(self, fast_config, mock_broker): + metrics = MetricsCollector() + return SimNode("test-node", mock_broker, fast_config, metrics) + + def test_handle_send_creates_workstream(self, node, mock_broker): + async def _test(): + msg = SendMessage(message="hello", auto_approve=True) + await node.handle_message(msg.to_json()) + + assert node.workstream_count == 1 + mock_broker.set_ws_owner.assert_called_once() + assert mock_broker.publish_outbound.call_count > 0 + + _run(_test()) + + def test_handle_send_reuses_existing_ws(self, node, mock_broker): + async def _test(): + msg1 = SendMessage(message="hello", auto_approve=True) + await node.handle_message(msg1.to_json()) + assert node.workstream_count == 1 + + ws_id = list(node._workstreams.keys())[0] + + msg2 = SendMessage(message="world", ws_id=ws_id, auto_approve=True) + await node.handle_message(msg2.to_json()) + assert node.workstream_count == 1 + + _run(_test()) + + def test_published_events_are_valid_protocol(self, node, mock_broker): + async def _test(): + msg = SendMessage(message="test", auto_approve=True) + await node.handle_message(msg.to_json()) + + for c in mock_broker.publish_outbound.call_args_list: + _channel, event_json = c[0] + event = OutboundEvent.from_json(event_json) + assert event.type != "" + + _run(_test()) + + def test_state_transitions(self, node, mock_broker): + async def _test(): + msg = SendMessage(message="test", auto_approve=True) + await node.handle_message(msg.to_json()) + + states = [] + for c in mock_broker.publish_outbound.call_args_list: + channel, event_json = c[0] + event = OutboundEvent.from_json(event_json) + if isinstance(event, StateChangeEvent): + states.append(event.state) + + assert "thinking" in states + assert "idle" in states + assert states.index("thinking") < states.index("idle") + + _run(_test()) + + def test_turn_complete_published(self, node, mock_broker): + async def _test(): + msg = SendMessage(message="test", auto_approve=True) + await node.handle_message(msg.to_json()) + + turn_completes = [ + OutboundEvent.from_json(c[0][1]) + for c in mock_broker.publish_outbound.call_args_list + if '"turn_complete"' in c[0][1] + ] + assert len(turn_completes) >= 1 + + _run(_test()) + + def test_close_workstream(self, node, mock_broker): + async def _test(): + msg = SendMessage(message="hello", auto_approve=True) + await node.handle_message(msg.to_json()) + ws_id = list(node._workstreams.keys())[0] + + from turnstone.mq.protocol import CloseWorkstreamMessage + + close_msg = CloseWorkstreamMessage(ws_id=ws_id) + await node.handle_message(close_msg.to_json()) + + assert node.workstream_count == 0 + mock_broker.del_ws_owner.assert_called_with(ws_id) + + _run(_test()) + + def test_stop_cleans_up(self, node, mock_broker): + # Add a fake workstream + node._workstreams["fake"] = MagicMock() + mock_broker.set_ws_owner("fake", "test-node") + + node.stop() + assert not node._running + assert node.workstream_count == 0 + mock_broker.del_ws_owner.assert_called() + + def test_heartbeat_once(self, node, mock_broker): + node.heartbeat_once() + mock_broker.register_node.assert_called_once() + args = mock_broker.register_node.call_args + assert args[0][0] == "test-node" + assert args[0][1]["sim"] is True + + +# --------------------------------------------------------------------------- +# SimWorkstream — state machine +# --------------------------------------------------------------------------- + + +class TestSimWorkstream: + @pytest.fixture + def fast_config(self): + return SimConfig( + llm_latency_mean=0.01, + llm_latency_stddev=0.001, + llm_tokens_mean=10, + llm_tokens_stddev=2, + llm_token_rate=1000, + tool_latency_mean=0.01, + tool_latency_stddev=0.001, + tool_failure_rate=0.0, + max_tool_rounds=0, + seed=42, + ) + + def test_turn_ends_in_idle(self, fast_config): + async def _test(): + broker = MagicMock() + metrics = MetricsCollector() + node = SimNode("test", broker, fast_config, metrics) + engine = SimEngine(fast_config) + ws = SimWorkstream("ws1", "test-ws", node, engine, fast_config) + + await ws.process_turn("hello", "cid-123") + assert ws.state == "idle" + + _run(_test()) + + def test_turn_records_metrics(self, fast_config): + async def _test(): + broker = MagicMock() + metrics = MetricsCollector() + node = SimNode("test", broker, fast_config, metrics) + engine = SimEngine(fast_config) + ws = SimWorkstream("ws1", "test-ws", node, engine, fast_config) + + await ws.process_turn("hello", "cid-123") + report = metrics.summary() + assert report["total_turns"] == 1 + assert report["turns_per_node"]["test"] == 1 + + _run(_test()) diff --git a/tests/test_tools_schema.py b/tests/test_tools_schema.py new file mode 100644 index 00000000..429bbb06 --- /dev/null +++ b/tests/test_tools_schema.py @@ -0,0 +1,121 @@ +"""Tests for turnstone.core.tools — JSON auto-loading and schema validation.""" + +from turnstone.core.tools import ( + TOOLS, + AGENT_TOOLS, + TASK_AGENT_TOOLS, + AGENT_AUTO_TOOLS, + TASK_AUTO_TOOLS, + PRIMARY_KEY_MAP, + _META, +) + + +class TestToolsSchema: + def test_all_tools_have_function_type(self): + for tool in TOOLS: + assert tool["type"] == "function", f"Tool missing type='function': {tool}" + + def test_all_tools_have_name(self): + for tool in TOOLS: + assert "name" in tool["function"], f"Tool missing name: {tool}" + assert isinstance(tool["function"]["name"], str) + + def test_all_tools_have_description(self): + for tool in TOOLS: + assert "description" in tool["function"], ( + f"Tool missing description: {tool}" + ) + assert len(tool["function"]["description"]) > 0 + + def test_all_tools_have_parameters(self): + for tool in TOOLS: + params = tool["function"]["parameters"] + assert params["type"] == "object" + assert "properties" in params + + def test_required_fields_exist_in_properties(self): + for tool in TOOLS: + func = tool["function"] + params = func["parameters"] + required = params.get("required", []) + properties = params["properties"] + for field in required: + assert field in properties, ( + f"Tool '{func['name']}': required field '{field}' not in properties" + ) + + def test_tool_names_unique(self): + names = [t["function"]["name"] for t in TOOLS] + assert len(names) == len(set(names)), f"Duplicate tool names: {names}" + + def test_agent_tools_subset(self): + tool_names = {t["function"]["name"] for t in TOOLS} + agent_names = {t["function"]["name"] for t in AGENT_TOOLS} + assert agent_names.issubset(tool_names), ( + f"AGENT_TOOLS has names not in TOOLS: {agent_names - tool_names}" + ) + + def test_task_agent_tools_subset(self): + tool_names = {t["function"]["name"] for t in TOOLS} + task_names = {t["function"]["name"] for t in TASK_AGENT_TOOLS} + assert task_names.issubset(tool_names), ( + f"TASK_AGENT_TOOLS has names not in TOOLS: {task_names - tool_names}" + ) + + def test_agent_tools_not_empty(self): + assert len(AGENT_TOOLS) > 0 + + def test_task_agent_tools_not_empty(self): + assert len(TASK_AGENT_TOOLS) > 0 + + +class TestToolsMetadata: + """Validate the metadata extracted from JSON files.""" + + def test_tool_count(self): + assert len(TOOLS) == 14 + + def test_agent_tools_count(self): + assert len(AGENT_TOOLS) == 6 + + def test_task_agent_tools_count(self): + assert len(TASK_AGENT_TOOLS) == 9 + + def test_auto_approve_sets_match(self): + expected = {"read_file", "search", "math", "man", "web_fetch", "web_search"} + assert AGENT_AUTO_TOOLS == expected + assert TASK_AUTO_TOOLS == expected + + def test_primary_key_map(self): + expected = { + "bash": "command", + "math": "code", + "read_file": "path", + "search": "query", + "write_file": "content", + "edit_file": "old_string", + "man": "page", + "web_fetch": "url", + "web_search": "query", + "task": "prompt", + "plan": "prompt", + "remember": "key", + "recall": "query", + "forget": "key", + } + assert PRIMARY_KEY_MAP == expected + + def test_no_metadata_in_function_dicts(self): + """Ensure turnstone metadata keys are stripped from the OpenAI schema.""" + meta_keys = {"agent", "task_agent", "auto_approve", "primary_key"} + for tool in TOOLS: + func = tool["function"] + leaked = meta_keys & set(func) + assert not leaked, ( + f"Tool '{func['name']}' leaks metadata into function dict: {leaked}" + ) + + def test_meta_has_all_tools(self): + tool_names = {t["function"]["name"] for t in TOOLS} + assert set(_META.keys()) == tool_names diff --git a/tests/test_workstream.py b/tests/test_workstream.py new file mode 100644 index 00000000..060993c6 --- /dev/null +++ b/tests/test_workstream.py @@ -0,0 +1,760 @@ +"""Tests for turnstone.core.workstream — WorkstreamManager, state management, and UI adapters.""" + +import threading +import time + +import pytest +from unittest.mock import MagicMock + +from turnstone.core.workstream import WorkstreamManager, WorkstreamState, Workstream + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeSession: + """Minimal stand-in for ChatSession in workstream tests.""" + + def __init__(self): + self.model = "test-model" + self.messages = [] + + +def _fake_factory(ui): + return FakeSession() + + +class FakeUI: + """Minimal SessionUI that tracks state changes.""" + + def __init__(self, ws_id=""): + self.ws_id = ws_id + self.state_changes = [] + self.auto_approve = False + + def on_state_change(self, state): + self.state_changes.append(state) + + # Stubs for the rest of the protocol + def on_thinking_start(self): + pass + + def on_thinking_stop(self): + pass + + def on_reasoning_token(self, text): + pass + + def on_content_token(self, text): + pass + + def on_stream_end(self): + pass + + def approve_tools(self, items): + return True, None + + def on_tool_result(self, name, output): + pass + + def on_status(self, usage, context_window, effort): + pass + + def on_plan_review(self, content): + return "" + + def on_info(self, message): + pass + + def on_error(self, message): + pass + + +# --------------------------------------------------------------------------- +# WorkstreamState enum +# --------------------------------------------------------------------------- + + +class TestWorkstreamState: + def test_values(self): + assert WorkstreamState.IDLE.value == "idle" + assert WorkstreamState.THINKING.value == "thinking" + assert WorkstreamState.RUNNING.value == "running" + assert WorkstreamState.ATTENTION.value == "attention" + assert WorkstreamState.ERROR.value == "error" + + def test_from_string(self): + assert WorkstreamState("idle") == WorkstreamState.IDLE + assert WorkstreamState("attention") == WorkstreamState.ATTENTION + + +# --------------------------------------------------------------------------- +# Workstream dataclass +# --------------------------------------------------------------------------- + + +class TestWorkstream: + def test_default_name(self): + ws = Workstream() + assert ws.name.startswith("ws-") + assert len(ws.name) == 7 # "ws-" + 4 hex chars + + def test_custom_name(self): + ws = Workstream(name="my-stream") + assert ws.name == "my-stream" + + def test_default_state(self): + ws = Workstream() + assert ws.state == WorkstreamState.IDLE + + def test_id_uniqueness(self): + ws1 = Workstream() + ws2 = Workstream() + assert ws1.id != ws2.id + + +# --------------------------------------------------------------------------- +# WorkstreamManager — creation and lookup +# --------------------------------------------------------------------------- + + +class TestManagerCreation: + def test_create_first_sets_active(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.active_id == ws.id + assert mgr.get_active() is ws + + def test_create_second_does_not_change_active(self): + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.active_id == ws1.id + + def test_create_assigns_session(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert isinstance(ws.session, FakeSession) + + def test_create_assigns_ui(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert isinstance(ws.ui, FakeUI) + assert ws.ui.ws_id == ws.id + + def test_create_custom_name(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(name="research", ui_factory=lambda wid: FakeUI(wid)) + assert ws.name == "research" + + def test_create_default_name(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert ws.name.startswith("ws-") + + def test_create_max_workstreams(self): + mgr = WorkstreamManager(_fake_factory) + mgr.MAX_WORKSTREAMS = 3 + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + with pytest.raises(RuntimeError, match="Maximum"): + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + + +class TestManagerLookup: + def test_get_existing(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.get(ws.id) is ws + + def test_get_nonexistent(self): + mgr = WorkstreamManager(_fake_factory) + assert mgr.get("no-such-id") is None + + def test_list_all_creation_order(self): + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(name="a", ui_factory=lambda wid: FakeUI(wid)) + ws2 = mgr.create(name="b", ui_factory=lambda wid: FakeUI(wid)) + ws3 = mgr.create(name="c", ui_factory=lambda wid: FakeUI(wid)) + result = mgr.list_all() + assert [w.name for w in result] == ["a", "b", "c"] + + def test_index_of(self): + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.index_of(ws1.id) == 1 + assert mgr.index_of(ws2.id) == 2 + assert mgr.index_of("nonexistent") == 0 + + def test_count(self): + mgr = WorkstreamManager(_fake_factory) + assert mgr.count == 0 + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.count == 1 + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.count == 2 + + +# --------------------------------------------------------------------------- +# WorkstreamManager — switching +# --------------------------------------------------------------------------- + + +class TestManagerSwitching: + def test_switch_by_id(self): + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.active_id == ws1.id + + result = mgr.switch(ws2.id) + assert result is ws2 + assert mgr.active_id == ws2.id + + def test_switch_nonexistent_returns_none(self): + mgr = WorkstreamManager(_fake_factory) + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.switch("bad-id") is None + + def test_switch_by_index(self): + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + + result = mgr.switch_by_index(2) + assert result is ws2 + assert mgr.active_id == ws2.id + + def test_switch_by_index_out_of_range(self): + mgr = WorkstreamManager(_fake_factory) + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.switch_by_index(0) is None + assert mgr.switch_by_index(5) is None + + +# --------------------------------------------------------------------------- +# WorkstreamManager — closing +# --------------------------------------------------------------------------- + + +class TestManagerClose: + def test_close_removes_workstream(self): + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + + assert mgr.close(ws2.id) is True + assert mgr.count == 1 + assert mgr.get(ws2.id) is None + + def test_close_last_returns_false(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.close(ws.id) is False + assert mgr.count == 1 + + def test_close_nonexistent_returns_false(self): + mgr = WorkstreamManager(_fake_factory) + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert mgr.close("nonexistent") is False + + def test_close_active_switches_to_first(self): + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + ws2 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + mgr.switch(ws2.id) + + mgr.close(ws2.id) + assert mgr.active_id == ws1.id + + def test_close_updates_order(self): + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(name="a", ui_factory=lambda wid: FakeUI(wid)) + ws2 = mgr.create(name="b", ui_factory=lambda wid: FakeUI(wid)) + ws3 = mgr.create(name="c", ui_factory=lambda wid: FakeUI(wid)) + + mgr.close(ws2.id) + names = [w.name for w in mgr.list_all()] + assert names == ["a", "c"] + + def test_close_unblocks_approval_event(self): + """Closing a workstream whose UI has a pending approval should unblock it.""" + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + + # Create a workstream with a WebUI-like approval mechanism + from turnstone.server import WebUI + + ws2 = mgr.create(ui_factory=lambda wid: WebUI(ws_id=wid)) + ws2.ui._approval_event.clear() # simulate pending approval + + mgr.close(ws2.id) + # The approval event should be set (unblocked) + assert ws2.ui._approval_event.is_set() + + def test_close_unblocks_plan_event(self): + """Closing a workstream with pending plan review should unblock it.""" + mgr = WorkstreamManager(_fake_factory) + ws1 = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + + from turnstone.server import WebUI + + ws2 = mgr.create(ui_factory=lambda wid: WebUI(ws_id=wid)) + ws2.ui._plan_event.clear() + + mgr.close(ws2.id) + assert ws2.ui._plan_event.is_set() + assert ws2.ui._plan_result == "reject" + + +# --------------------------------------------------------------------------- +# WorkstreamManager — state management +# --------------------------------------------------------------------------- + + +class TestManagerState: + def test_set_state(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + assert ws.state == WorkstreamState.IDLE + + mgr.set_state(ws.id, WorkstreamState.THINKING) + assert ws.state == WorkstreamState.THINKING + + def test_set_state_with_error(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + + mgr.set_state(ws.id, WorkstreamState.ERROR, error_msg="API timeout") + assert ws.state == WorkstreamState.ERROR + assert ws.error_message == "API timeout" + + def test_set_state_nonexistent_is_noop(self): + mgr = WorkstreamManager(_fake_factory) + mgr.set_state("no-such-id", WorkstreamState.THINKING) # should not raise + + def test_on_state_change_callback(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + + changes = [] + mgr._on_state_change = lambda wid, state: changes.append((wid, state)) + + mgr.set_state(ws.id, WorkstreamState.RUNNING) + assert changes == [(ws.id, WorkstreamState.RUNNING)] + + +# --------------------------------------------------------------------------- +# WorkstreamManager — thread safety +# --------------------------------------------------------------------------- + + +class TestManagerThreadSafety: + def test_concurrent_create_respects_max(self): + """Multiple threads creating workstreams should not exceed MAX.""" + mgr = WorkstreamManager(_fake_factory) + mgr.MAX_WORKSTREAMS = 5 + errors = [] + created = [] + + def do_create(): + try: + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + created.append(ws.id) + except RuntimeError: + errors.append(True) + + threads = [threading.Thread(target=do_create) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert mgr.count == 5 + assert len(errors) == 5 + + def test_concurrent_switch(self): + """Concurrent switches should not corrupt state.""" + mgr = WorkstreamManager(_fake_factory) + ids = [] + for _ in range(5): + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + ids.append(ws.id) + + def do_switch(wid): + for _ in range(20): + mgr.switch(wid) + + threads = [threading.Thread(target=do_switch, args=(wid,)) for wid in ids] + for t in threads: + t.start() + for t in threads: + t.join() + + # active_id should be one of the valid ids + assert mgr.active_id in ids + + def test_concurrent_close_and_list(self): + """close() and list_all() running concurrently should not crash.""" + mgr = WorkstreamManager(_fake_factory) + # Keep one alive to prevent closing the last + anchor = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + targets = [] + for _ in range(5): + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + targets.append(ws.id) + + def do_close(): + for wid in targets: + mgr.close(wid) + + def do_list(): + for _ in range(50): + mgr.list_all() + + t1 = threading.Thread(target=do_close) + t2 = threading.Thread(target=do_list) + t1.start() + t2.start() + t1.join() + t2.join() + + assert mgr.count == 1 + assert mgr.get(anchor.id) is not None + + +# --------------------------------------------------------------------------- +# WorkstreamTerminalUI +# --------------------------------------------------------------------------- + + +class TestWorkstreamTerminalUI: + def test_foreground_detection(self): + from turnstone.cli import WorkstreamTerminalUI + + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + assert ws.ui.is_foreground is True + + ws2 = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + # ws1 is still active, so ws2 is not foreground + assert ws2.ui.is_foreground is False + + def test_state_change_updates_manager(self): + from turnstone.cli import WorkstreamTerminalUI + + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + + ws.ui.on_state_change("thinking") + assert ws.state == WorkstreamState.THINKING + + ws.ui.on_state_change("idle") + assert ws.state == WorkstreamState.IDLE + + def test_invalid_state_change_ignored(self): + from turnstone.cli import WorkstreamTerminalUI + + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + ws.ui.on_state_change("not_a_real_state") # should not raise + assert ws.state == WorkstreamState.IDLE # unchanged + + def _make_background_ws(self): + """Create a manager with two workstreams; switch to the second so the first is background.""" + from turnstone.cli import WorkstreamTerminalUI + + mgr = WorkstreamManager(_fake_factory) + bg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + fg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + mgr.switch(fg.id) + bg.ui.set_foreground(False) + fg.ui.set_foreground(True) + return mgr, bg, fg + + def test_background_buffers_content(self): + mgr, bg, fg = self._make_background_ws() + assert bg.ui.is_foreground is False + + bg.ui.on_content_token("hello ") + bg.ui.on_content_token("world") + bg.ui.on_stream_end() + + assert len(bg.ui._output_buffer) == 3 + assert bg.ui._output_buffer[0] == ("content", "hello ") + assert bg.ui._output_buffer[1] == ("content", "world") + assert bg.ui._output_buffer[2] == ("stream_end", "") + + def test_flush_buffer_clears(self): + mgr, bg, fg = self._make_background_ws() + + bg.ui.on_content_token("test") + bg.ui.on_stream_end() + assert len(bg.ui._output_buffer) == 2 + + mgr.switch(bg.id) + bg.ui.set_foreground(True) + bg.ui.flush_buffer() + assert len(bg.ui._output_buffer) == 0 + + def test_background_buffers_info_and_error(self): + mgr, bg, fg = self._make_background_ws() + + bg.ui.on_info("info msg") + bg.ui.on_error("error msg") + + assert ("info", "info msg") in bg.ui._output_buffer + assert ("error", "error msg") in bg.ui._output_buffer + + def test_fg_event_blocks_approval_in_background(self): + """approve_tools should block until foregrounded.""" + mgr, bg, fg = self._make_background_ws() + bg.ui.auto_approve = True # so we don't need actual input() + + result = [None] + + def call_approve(): + result[0] = bg.ui.approve_tools( + [{"needs_approval": True, "header": "test", "func_name": "bash"}] + ) + + t = threading.Thread(target=call_approve) + t.start() + time.sleep(0.1) + assert t.is_alive(), "approve_tools should be blocking" + + # Bring to foreground — should unblock + mgr.switch(bg.id) + bg.ui.set_foreground(True) + t.join(timeout=2) + assert not t.is_alive() + assert result[0] == (True, None) # auto-approved + + +# --------------------------------------------------------------------------- +# WebUI workstream support +# --------------------------------------------------------------------------- + + +class TestWebUI: + def test_ws_id_assigned(self): + from turnstone.server import WebUI + + ui = WebUI(ws_id="test-123") + assert ui.ws_id == "test-123" + + def test_on_state_change_broadcasts(self): + """on_state_change should put an event on the global queue.""" + import queue + from turnstone.server import WebUI + + gq = queue.Queue() + old = WebUI._global_queue + WebUI._global_queue = gq + try: + ui = WebUI(ws_id="abc") + ui.on_state_change("thinking") + + event = gq.get_nowait() + assert event["type"] == "ws_state" + assert event["ws_id"] == "abc" + assert event["state"] == "thinking" + finally: + WebUI._global_queue = old + + def test_on_state_change_no_global_queue(self): + """on_state_change should not crash if no global queue is set.""" + from turnstone.server import WebUI + + old = WebUI._global_queue + WebUI._global_queue = None + try: + ui = WebUI(ws_id="xyz") + ui.on_state_change("running") # should not raise + finally: + WebUI._global_queue = old + + def test_resolve_approval(self): + from turnstone.server import WebUI + + ui = WebUI(ws_id="test") + ui._approval_event.clear() + + # Resolve in a thread + def resolve(): + time.sleep(0.05) + ui.resolve_approval(True, "looks good") + + t = threading.Thread(target=resolve) + t.start() + + ui._approval_event.wait(timeout=2) + assert ui._approval_result == (True, "looks good") + t.join() + + def test_resolve_plan(self): + from turnstone.server import WebUI + + ui = WebUI(ws_id="test") + ui._plan_event.clear() + + def resolve(): + time.sleep(0.05) + ui.resolve_plan("approved") + + t = threading.Thread(target=resolve) + t.start() + + ui._plan_event.wait(timeout=2) + assert ui._plan_result == "approved" + t.join() + + +# --------------------------------------------------------------------------- +# Integration: WorkstreamManager + session state transitions +# --------------------------------------------------------------------------- + + +class TestStateTransitions: + def test_full_lifecycle(self): + """Verify the expected state transition sequence.""" + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + + # Simulate the state transitions that ChatSession.send() would emit + mgr.set_state(ws.id, WorkstreamState.THINKING) + assert ws.state == WorkstreamState.THINKING + + mgr.set_state(ws.id, WorkstreamState.RUNNING) + assert ws.state == WorkstreamState.RUNNING + + mgr.set_state(ws.id, WorkstreamState.ATTENTION) + assert ws.state == WorkstreamState.ATTENTION + + mgr.set_state(ws.id, WorkstreamState.RUNNING) + assert ws.state == WorkstreamState.RUNNING + + mgr.set_state(ws.id, WorkstreamState.IDLE) + assert ws.state == WorkstreamState.IDLE + + def test_error_recovery(self): + """After an error, sending again should transition back to thinking.""" + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=lambda wid: FakeUI(wid)) + + mgr.set_state(ws.id, WorkstreamState.ERROR, "API failed") + assert ws.state == WorkstreamState.ERROR + assert ws.error_message == "API failed" + + mgr.set_state(ws.id, WorkstreamState.THINKING) + assert ws.state == WorkstreamState.THINKING + assert ws.error_message == "" + + +# --------------------------------------------------------------------------- +# Design polish: thread-safe buffer, approval context, NO_COLOR +# --------------------------------------------------------------------------- + + +class TestBufferThreadSafety: + """Verify that _buffer() uses the lock and flush_buffer copies under lock.""" + + def test_concurrent_buffer_and_flush(self): + """Simultaneous buffering and flushing should not lose or corrupt events.""" + from turnstone.cli import WorkstreamTerminalUI + + mgr = WorkstreamManager(_fake_factory) + bg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + fg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + mgr.switch(fg.id) + bg.ui.set_foreground(False) + + n_events = 200 + done = threading.Event() + + def do_buffer(): + for i in range(n_events): + bg.ui._buffer("content", f"token-{i}") + done.set() + + t = threading.Thread(target=do_buffer) + t.start() + done.wait() + + # All events should be in the buffer + with bg.ui._print_lock: + count = len(bg.ui._output_buffer) + assert count == n_events + t.join() + + +class TestApprovalContextMessage: + """Verify approval in background buffers a context message.""" + + def test_approval_buffers_tool_names(self): + from turnstone.cli import WorkstreamTerminalUI + + mgr = WorkstreamManager(_fake_factory) + bg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + fg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr)) + mgr.switch(fg.id) + bg.ui.set_foreground(False) + bg.ui.auto_approve = True + + result = [None] + + def call_approve(): + result[0] = bg.ui.approve_tools( + [ + { + "needs_approval": True, + "header": "test", + "func_name": "bash", + "approval_label": "bash: ls", + }, + ] + ) + + t = threading.Thread(target=call_approve) + t.start() + time.sleep(0.1) + + # Should have a waiting-for-approval message in the buffer + with bg.ui._print_lock: + info_msgs = [text for ev, text in bg.ui._output_buffer if ev == "info"] + assert any("bash: ls" in msg for msg in info_msgs) + + # Unblock + mgr.switch(bg.id) + bg.ui.set_foreground(True) + t.join(timeout=2) + assert result[0] == (True, None) + + +class TestNoColor: + """Verify NO_COLOR support in colors module.""" + + def test_no_color_env_disables_ansi(self): + import importlib + import os + import turnstone.ui.colors as colors_mod + + old_env = os.environ.get("NO_COLOR") + try: + os.environ["NO_COLOR"] = "1" + importlib.reload(colors_mod) + assert colors_mod.RESET == "" + assert colors_mod.BOLD == "" + assert colors_mod.RED == "" + assert colors_mod.red("test") == "test" + finally: + if old_env is None: + os.environ.pop("NO_COLOR", None) + else: + os.environ["NO_COLOR"] = old_env + importlib.reload(colors_mod) diff --git a/turnstone/__init__.py b/turnstone/__init__.py new file mode 100644 index 00000000..cbe83c0f --- /dev/null +++ b/turnstone/__init__.py @@ -0,0 +1,3 @@ +"""turnstone - Single-file AI chat client with tool use.""" + +__version__ = "0.1.0" diff --git a/turnstone/chat.py b/turnstone/chat.py new file mode 100755 index 00000000..60abdf91 --- /dev/null +++ b/turnstone/chat.py @@ -0,0 +1,51 @@ +"""chat.py — Backward-compatibility shim. + +All functionality has been moved to submodules: + - turnstone.core.session: ChatSession, SessionUI + - turnstone.core.tools: TOOLS, AGENT_TOOLS, TASK_AGENT_TOOLS + - turnstone.core.edit: find_occurrences, pick_nearest + - turnstone.core.sandbox: validate_math_code, execute_math_sandboxed + - turnstone.core.safety: is_command_blocked, sanitize_command + - turnstone.core.web: strip_html, check_ssrf + - turnstone.core.memory: open_db, load_memories, save_message, etc. + - turnstone.ui.colors: ANSI constants and helpers + - turnstone.ui.markdown: MarkdownRenderer + - turnstone.ui.spinner: Spinner + - turnstone.cli: TerminalUI, main, detect_model +""" + +# Re-export public API for backward compatibility +from turnstone.core.session import ChatSession, SessionUI # noqa: F401 +from turnstone.core.tools import TOOLS, AGENT_TOOLS, TASK_AGENT_TOOLS # noqa: F401 +from turnstone.core.edit import ( + find_occurrences as _find_occurrences, + pick_nearest as _pick_nearest, +) # noqa: F401 +from turnstone.core.sandbox import ( + validate_math_code as _validate_math_code, + auto_print_wrap as _auto_print_wrap, + execute_math_sandboxed as _execute_math_sandboxed, +) # noqa: F401 +from turnstone.core.safety import ( + is_command_blocked, + sanitize_command as _sanitize_command, + BLOCKED_PATTERNS, +) # noqa: F401 +from turnstone.core.web import strip_html as _strip_html # noqa: F401 +from turnstone.core.memory import ( # noqa: F401 + open_db as _open_db, + load_memories as _load_memories, + save_message as _save_message, + normalize_key as _normalize_key, + search_history as _search_history, + search_history_recent as _search_history_recent, + escape_like as _escape_like, + fts5_query as _fts5_query, + get_tavily_key as _get_tavily_key, + db_override as _db_override, + db_initialized as _db_initialized, +) +from turnstone.ui.colors import * # noqa: F401, F403 +from turnstone.ui.markdown import MarkdownRenderer # noqa: F401 +from turnstone.ui.spinner import Spinner # noqa: F401 +from turnstone.cli import main, detect_model # noqa: F401 diff --git a/turnstone/cli.py b/turnstone/cli.py new file mode 100644 index 00000000..ec859a70 --- /dev/null +++ b/turnstone/cli.py @@ -0,0 +1,987 @@ +"""Terminal CLI frontend for turnstone. + +Provides TerminalUI (implementing the SessionUI protocol), readline setup, +model auto-detection, workstream management, and the main() REPL entry point. +""" + +import argparse +import os +import readline +import sys +import textwrap +import threading + +from openai import OpenAI + +from turnstone.core.session import ChatSession, SessionUI +from turnstone.core.tools import TOOLS +from turnstone.core.workstream import WorkstreamManager, WorkstreamState +from turnstone.ui.colors import ( + BOLD, + CYAN, + DIM, + GRAY, + GREEN, + RED, + RESET, + YELLOW, + bold, + cyan, + dim, + green, + red, + yellow, +) +from turnstone.ui.markdown import MarkdownRenderer +from turnstone.ui.spinner import Spinner + + +# ─── Readline ───────────────────────────────────────────────────────────── + +SLASH_COMMANDS = [ + "/persona", + "/instructions", + "/clear", + "/new", + "/sessions", + "/resume", + "/name", + "/delete", + "/history", + "/model", + "/raw", + "/reason", + "/compact", + "/creative", + "/debug", + "/help", + "/exit", + "/quit", + "/q", + "/ws", + "/cluster", +] + + +def _completer(text, state): + """Tab-complete slash commands.""" + if text.startswith("/"): + matches = [c for c in SLASH_COMMANDS if c.startswith(text)] + else: + matches = [] + if state < len(matches): + return matches[state] + " " + return None + + +def setup_readline(): + """Set up readline with tab completion.""" + readline.set_history_length(1000) + readline.set_completer(_completer) + readline.set_completer_delims("") # treat entire line as completion input + readline.parse_and_bind("tab: complete") + + +# ─── TerminalUI ─────────────────────────────────────────────────────────── + + +class TerminalUI(SessionUI): + """Terminal-based UI using ANSI colors, MarkdownRenderer, and Spinner.""" + + def __init__(self): + self.md = MarkdownRenderer() + self.spinner = None + self._print_lock = threading.Lock() + self.auto_approve = False + + def on_thinking_start(self): + self.spinner = Spinner("Thinking") + self.spinner.start() + + def on_thinking_stop(self): + if self.spinner: + self.spinner.stop() + self.spinner = None + + def on_reasoning_token(self, text): + sys.stdout.write(f"{DIM}{text}{RESET}") + sys.stdout.flush() + + def on_content_token(self, text): + rendered = self.md.feed(text) + if rendered: + sys.stdout.write(rendered) + sys.stdout.flush() + + def on_stream_end(self): + remainder = self.md.flush() + if remainder: + sys.stdout.write(remainder) + self.md.in_code_block = False + sys.stdout.write("\n") + sys.stdout.flush() + + def approve_tools(self, items): + """Display tool previews and prompt for batch approval. + + Returns (approved: bool, feedback: str | None). + """ + pending = [ + it for it in items if it.get("needs_approval") and not it.get("error") + ] + + with self._print_lock: + # Print all headers and previews + for item in items: + if item.get("error"): + sys.stdout.write(f" {red(item['header'])}\n") + else: + sys.stdout.write(f" {yellow(item['header'])}\n") + if item.get("preview"): + sys.stdout.write(item["preview"] + "\n") + sys.stdout.flush() + + if not pending or self.auto_approve: + return True, None + + # Prompt + try: + if len(pending) == 1: + label = pending[0].get("approval_label", pending[0]["func_name"]) + prompt_text = ( + f" \001{BOLD}\002Allow {label}?\001{RESET}\002 " + f"\001{DIM}\002[y/n/a(lways), optional message]\001{RESET}\002 " + ) + else: + labels = ", ".join( + it.get("approval_label", it["func_name"]) for it in pending + ) + prompt_text = ( + f" \001{BOLD}\002Allow {len(pending)} tools ({labels})?\001{RESET}\002 " + f"\001{DIM}\002[y/n/a(lways), optional message]\001{RESET}\002 " + ) + resp = input(prompt_text).strip() + except (EOFError, KeyboardInterrupt): + sys.stdout.write("\n") + resp = "n" + + # Parse decision and optional feedback: "y, use absolute path" + decision = resp.lower() + feedback = None + for sep in (",", " "): + if sep in resp: + decision = resp[: resp.index(sep)].strip().lower() + feedback = resp[resp.index(sep) + 1 :].strip() or None + break + + if decision in ("a", "always"): + self.auto_approve = True + return True, feedback + elif decision in ("y", "yes"): + return True, feedback + else: + denial_msg = "Denied by user" + if feedback: + denial_msg += f": {feedback}" + for item in pending: + item["denied"] = True + item["denial_msg"] = denial_msg + return False, None + + def on_tool_result(self, name, output): + pass # Optional: display summary + + def on_status(self, usage, context_window, effort): + total_tok = usage["prompt_tokens"] + usage["completion_tokens"] + pct = total_tok / context_window * 100 if context_window > 0 else 0 + parts = [f"{total_tok:,} / {context_window:,} tokens ({pct:.0f}%)"] + if effort != "medium": + parts.append(f"reasoning: {effort}") + sys.stdout.write(f"\n {DIM}[{' · '.join(parts)}]{RESET}\n") + sys.stdout.flush() + + def on_plan_review(self, content): + sys.stdout.write(f"\n{DIM}{'─' * 60}{RESET}\n") + for line in content.splitlines(): + sys.stdout.write(f" {line}\n") + sys.stdout.write(f"{DIM}{'─' * 60}{RESET}\n") + sys.stdout.flush() + try: + prompt_text = ( + f" \001{BOLD}\002Plan ready.\001{RESET}\002 " + f"\001{DIM}\002[enter to approve, or give feedback]\001{RESET}\002 " + ) + resp = input(prompt_text).strip() + except EOFError: + resp = "" + except KeyboardInterrupt: + resp = "reject" + return resp + + def on_info(self, message): + print(message) + + def on_error(self, message): + sys.stdout.write(f"{RED}{message}{RESET}\n") + sys.stdout.flush() + + def on_state_change(self, state): + pass # base TerminalUI ignores state changes + + def on_rename(self, name: str): + pass # base TerminalUI ignores renames + + +# ─── WorkstreamTerminalUI ───────────────────────────────────────────────── + + +# State display config: (symbol, color_fn, label) +_STATE_DISPLAY = { + WorkstreamState.IDLE: ("·", dim, "idle"), + WorkstreamState.THINKING: ("◌", cyan, "thinking"), + WorkstreamState.RUNNING: ("▸", green, "running"), + WorkstreamState.ATTENTION: ("◆", yellow, "attention"), + WorkstreamState.ERROR: ("✖", red, "error"), +} + + +class WorkstreamTerminalUI(TerminalUI): + """TerminalUI with workstream awareness: buffers output when in background, + blocks on approval until foregrounded.""" + + def __init__(self, ws_id: str, manager: WorkstreamManager): + super().__init__() + self.ws_id = ws_id + self.manager = manager + self._output_buffer: list[tuple[str, str]] = [] # (event_type, text) + self._fg_event = threading.Event() + self._fg_event.set() # starts as foreground + + @property + def is_foreground(self) -> bool: + return self.manager.active_id == self.ws_id + + def set_foreground(self, fg: bool): + if fg: + self._fg_event.set() + else: + self._fg_event.clear() + + def on_state_change(self, state: str): + try: + ws_state = WorkstreamState(state) + except ValueError: + return + self.manager.set_state(self.ws_id, ws_state) + + # -- output buffering when in background -------------------------------- + + def on_thinking_start(self): + if self.is_foreground: + super().on_thinking_start() + + def on_thinking_stop(self): + if self.is_foreground: + super().on_thinking_stop() + elif self.spinner: + self.spinner.stop() + self.spinner = None + + def _buffer(self, event_type: str, text: str): + with self._print_lock: + self._output_buffer.append((event_type, text)) + + def on_reasoning_token(self, text): + if self.is_foreground: + super().on_reasoning_token(text) + else: + self._buffer("reasoning", text) + + def on_content_token(self, text): + if self.is_foreground: + super().on_content_token(text) + else: + self._buffer("content", text) + + def on_stream_end(self): + if self.is_foreground: + super().on_stream_end() + else: + self._buffer("stream_end", "") + + def on_status(self, usage, context_window, effort): + if self.is_foreground: + super().on_status(usage, context_window, effort) + # silently drop status for background streams + + def on_info(self, message): + if self.is_foreground: + super().on_info(message) + else: + self._buffer("info", message) + + def on_error(self, message): + if self.is_foreground: + super().on_error(message) + else: + self._buffer("error", message) + + def on_tool_result(self, name, output): + if self.is_foreground: + super().on_tool_result(name, output) + + def on_plan_review(self, content): + # Must wait until foregrounded to show plan review + if not self.is_foreground: + self._buffer( + "info", + f"{YELLOW}[Plan ready — switch to this workstream to review]{RESET}", + ) + self._fg_event.wait() + return super().on_plan_review(content) + + def approve_tools(self, items): + """Block until foregrounded if in background, then show approval prompt.""" + if not self.is_foreground: + tool_names = ", ".join( + it.get("approval_label", it.get("func_name", "?")) + for it in items + if it.get("needs_approval") and not it.get("error") + ) + if tool_names: + self._buffer( + "info", f"{YELLOW}Waiting for approval: {tool_names}{RESET}" + ) + self._fg_event.wait() + return super().approve_tools(items) + + def flush_buffer(self): + """Replay buffered output when switching to foreground.""" + with self._print_lock: + if not self._output_buffer: + return + buf = list(self._output_buffer) + self._output_buffer.clear() + sys.stdout.write( + f"\n {DIM}--- buffered output ({len(buf)} events) ---{RESET}\n" + ) + replay_md = MarkdownRenderer() + for event_type, text in buf: + if event_type == "reasoning": + sys.stdout.write(f"{DIM}{text}{RESET}") + elif event_type == "content": + rendered = replay_md.feed(text) + if rendered: + sys.stdout.write(rendered) + elif event_type == "stream_end": + remainder = replay_md.flush() + if remainder: + sys.stdout.write(remainder) + replay_md.in_code_block = False + sys.stdout.write("\n") + elif event_type == "info": + sys.stdout.write(f"{text}\n") + elif event_type == "error": + sys.stdout.write(f"{RED}{text}{RESET}\n") + sys.stdout.write(f" {DIM}--- end buffered output ---{RESET}\n\n") + sys.stdout.flush() + + +# ─── Workstream commands ────────────────────────────────────────────────── + + +def _print_ws_status_line(manager: WorkstreamManager): + """Print a one-line status of background workstreams that are active.""" + active_id = manager.active_id + parts = [] + for ws in manager.list_all(): + if ws.id == active_id: + continue + if ws.state in (WorkstreamState.IDLE,): + continue + sym, color_fn, label = _STATE_DISPLAY[ws.state] + idx = manager.index_of(ws.id) + parts.append(color_fn(f"{sym} {idx}:{ws.name} ({label})")) + if parts: + sys.stderr.write(f" {' '.join(parts)}\n") + sys.stderr.flush() + + +def _handle_ws_command( + manager: WorkstreamManager, + cmd_line: str, + skip_permissions: bool, +): + """Handle /ws subcommands. Returns (switched: bool).""" + parts = cmd_line.strip().split() + sub = parts[1] if len(parts) > 1 else "list" + + if sub == "list": + active_id = manager.active_id + all_ws = manager.list_all() + max_name = max((len(ws.name) for ws in all_ws), default=0) + for ws in all_ws: + idx = manager.index_of(ws.id) + sym, color_fn, label = _STATE_DISPLAY[ws.state] + marker = " *" if ws.id == active_id else " " + padded = ws.name.ljust(max_name) + print(f" {marker}{idx}. {color_fn(f'{sym} {padded}')} {dim(label)}") + return False + + elif sub == "new": + name = parts[2] if len(parts) > 2 else "" + try: + ws = manager.create( + name=name, + ui_factory=lambda wid: WorkstreamTerminalUI(wid, manager), + ) + except RuntimeError as e: + print(red(str(e))) + return False + if skip_permissions: + ws.ui.auto_approve = True + # Mark old active as background + old = manager.get_active() + if old and old.ui and hasattr(old.ui, "set_foreground"): + old.ui.set_foreground(False) + manager.switch(ws.id) + ws.ui.set_foreground(True) + print(f"Created workstream {cyan(ws.name)} (#{manager.index_of(ws.id)})") + return True + + elif sub.isdigit(): + idx = int(sub) + old = manager.get_active() + ws = manager.switch_by_index(idx) + if ws: + if old and old.ui and hasattr(old.ui, "set_foreground"): + old.ui.set_foreground(False) + if hasattr(ws.ui, "set_foreground"): + ws.ui.set_foreground(True) + if hasattr(ws.ui, "flush_buffer"): + ws.ui.flush_buffer() + print(f"Switched to {cyan(ws.name)}") + return True + else: + print(red(f"No workstream #{idx}")) + return False + + elif sub == "close": + target_idx = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else None + if target_idx is not None: + all_ws = manager.list_all() + if 1 <= target_idx <= len(all_ws): + ws_id = all_ws[target_idx - 1].id + else: + print(red(f"No workstream #{target_idx}")) + return False + else: + ws_id = manager.active_id + + ws_name = manager.get(ws_id).name if manager.get(ws_id) else "?" + if manager.close(ws_id): + print(f"Closed workstream {ws_name}") + # Ensure new active is foregrounded + new_active = manager.get_active() + if new_active and hasattr(new_active.ui, "set_foreground"): + new_active.ui.set_foreground(True) + return True + else: + print(red("Cannot close the last workstream")) + return False + + elif sub == "rename": + new_name = " ".join(parts[2:]) if len(parts) > 2 else "" + if not new_name: + print(red("Usage: /ws rename ")) + return False + ws = manager.get_active() + if ws: + old_name = ws.name + ws.name = new_name + print(f"Renamed {old_name} -> {cyan(new_name)}") + return False + + else: + print(f"Unknown /ws subcommand: {sub}") + print(f"Usage: /ws [list|new [name]||close [N]|rename ]") + return False + + +# ─── Cluster commands ───────────────────────────────────────────────────── + + +def _handle_cluster_command( + cmd_line: str, console_url: str | None, auth_token: str = "" +): + """Handle /cluster subcommands querying the turnstone-console API.""" + import httpx + + if not console_url: + print( + red( + "No console URL configured. Use --console-url or set [console] url in config." + ) + ) + return + + headers: dict[str, str] = {} + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + + parts = cmd_line.strip().split() + sub = parts[1] if len(parts) > 1 else "status" + + try: + if sub == "status": + resp = httpx.get( + f"{console_url}/api/cluster/overview", timeout=5, headers=headers + ) + data = resp.json() + states = data.get("states", {}) + agg = data.get("aggregate", {}) + print(f"\n {bold('Cluster Overview')}") + print( + f" Nodes: {cyan(str(data.get('nodes', 0)))} " + f"Workstreams: {cyan(str(data.get('workstreams', 0)))}" + ) + print() + for state_name in ["running", "thinking", "attention", "idle", "error"]: + count = states.get(state_name, 0) + sym, color_fn, label = _STATE_DISPLAY.get( + { + "running": WorkstreamState.RUNNING, + "thinking": WorkstreamState.THINKING, + "attention": WorkstreamState.ATTENTION, + "idle": WorkstreamState.IDLE, + "error": WorkstreamState.ERROR, + }[state_name], + ("?", dim, state_name), + ) + print(f" {color_fn(f'{sym} {label}')}: {count}") + print() + tokens = agg.get("total_tokens", 0) + tools = agg.get("total_tool_calls", 0) + tok_str = f"{tokens / 1000:.1f}k" if tokens >= 1000 else str(tokens) + print(f" {dim(f'{tok_str} tokens · {tools} tool calls')}") + print() + + elif sub == "nodes": + limit = 20 + resp = httpx.get( + f"{console_url}/api/cluster/nodes?sort=activity&limit={limit}", + timeout=5, + headers=headers, + ) + data = resp.json() + nodes = data.get("nodes", []) + total = data.get("total", len(nodes)) + if not nodes: + print(dim(" No nodes discovered.")) + return + # Column widths + max_name = max(len(n["node_id"]) for n in nodes) + print( + f"\n {'NODE'.ljust(max_name)} {'WS':>4} {'RUN':>4} {'ATTN':>4} {'TOKENS':>8}" + ) + print( + f" {'-' * max_name} {'----':>4} {'----':>4} {'----':>4} {'--------':>8}" + ) + for n in nodes: + name = n["node_id"].ljust(max_name) + ws = str(n.get("ws_total", 0)) + run = n.get("ws_running", 0) + attn = n.get("ws_attention", 0) + tok = n.get("total_tokens", 0) + tok_str = f"{tok / 1000:.1f}k" if tok >= 1000 else str(tok) + run_str = green(str(run)) if run else dim("0") + attn_str = yellow(str(attn)) if attn else dim("0") + print( + f" {cyan(name)} {ws:>4} {run_str:>4} {attn_str:>4} {dim(tok_str):>8}" + ) + if total > len(nodes): + print(dim(f"\n Showing {len(nodes)} of {total} nodes")) + print() + + elif sub == "workstreams" or sub == "ws": + params = "sort=state&per_page=20" + # Parse optional filters: /cluster ws running, /cluster ws node=X + for arg in parts[2:]: + if "=" in arg: + key, val = arg.split("=", 1) + if key in ("state", "node", "search"): + params += f"&{key}={val}" + else: + params += f"&state={arg}" + resp = httpx.get( + f"{console_url}/api/cluster/workstreams?{params}", + timeout=5, + headers=headers, + ) + data = resp.json() + ws_list = data.get("workstreams", []) + total = data.get("total", len(ws_list)) + if not ws_list: + print(dim(" No matching workstreams.")) + return + max_name = max(len(w.get("name", "")[:20]) for w in ws_list) + max_node = max(len(w.get("node", "")[:16]) for w in ws_list) + print( + f"\n {'STATE':<8} {'NAME'.ljust(max_name)} {'NODE'.ljust(max_node)} {'TOKENS':>8} {'CTX':>4}" + ) + for w in ws_list: + state = w.get("state", "idle") + ws_state = { + "running": WorkstreamState.RUNNING, + "thinking": WorkstreamState.THINKING, + "attention": WorkstreamState.ATTENTION, + "idle": WorkstreamState.IDLE, + "error": WorkstreamState.ERROR, + }.get(state, WorkstreamState.IDLE) + sym, color_fn, label = _STATE_DISPLAY[ws_state] + name = w.get("name", "")[:20].ljust(max_name) + node = w.get("node", "")[:16].ljust(max_node) + tok = w.get("tokens", 0) + tok_str = f"{tok / 1000:.1f}k" if tok >= 1000 else str(tok) + ctx = w.get("context_ratio", 0) + ctx_str = f"{int(ctx * 100)}%" if ctx > 0 else "" + print( + f" {color_fn(f'{sym} {label:<5}')} {bold(name)} {dim(node)} {dim(tok_str):>8} {ctx_str:>4}" + ) + if total > len(ws_list): + print(dim(f"\n Showing {len(ws_list)} of {total} workstreams")) + print() + + elif sub == "node": + if len(parts) < 3: + print(red("Usage: /cluster node ")) + return + node_id = parts[2] + resp = httpx.get( + f"{console_url}/api/cluster/node/{node_id}", timeout=5, headers=headers + ) + data = resp.json() + if "error" in data: + print(red(data["error"])) + return + ws_list = data.get("workstreams", []) + print(f"\n {bold(node_id)} ({data.get('server_url', '')})") + if not ws_list: + print(dim(" No workstreams.")) + return + for w in ws_list: + state = w.get("state", "idle") + ws_state = { + "running": WorkstreamState.RUNNING, + "thinking": WorkstreamState.THINKING, + "attention": WorkstreamState.ATTENTION, + "idle": WorkstreamState.IDLE, + "error": WorkstreamState.ERROR, + }.get(state, WorkstreamState.IDLE) + sym, color_fn, _ = _STATE_DISPLAY[ws_state] + name = w.get("name", "") + title = w.get("title", "") + activity = w.get("activity", "") + print(f" {color_fn(sym)} {bold(name)} {dim(title)}") + if activity: + print(f" {dim(activity)}") + print() + + else: + print(f"Unknown /cluster subcommand: {sub}") + print("Usage: /cluster [status|nodes|workstreams [state|node=X]|node ]") + + except httpx.ConnectError: + print(red(f"Cannot connect to console at {console_url}")) + except Exception as e: + print(red(f"Cluster command failed: {e}")) + + +# ─── Model auto-detection ───────────────────────────────────────────────── + + +def detect_model(client: OpenAI) -> str: + """Auto-detect the model from vLLM's /v1/models endpoint.""" + try: + models = client.models.list() + model_ids = [m.id for m in models.data] + if not model_ids: + print(red("No models found at server. Use --model to specify.")) + sys.exit(1) + if len(model_ids) == 1: + return model_ids[0] + # Multiple models -- pick first, but inform user + print(f"Available models: {', '.join(model_ids)}") + print(f"Using: {bold(model_ids[0])} (override with --model)") + return model_ids[0] + except Exception as e: + print(red(f"Could not connect to server: {e}")) + print("Is vLLM running? Start it or use --base-url to point elsewhere.") + sys.exit(1) + + +# ─── Main ────────────────────────────────────────────────────────────────── + + +def main(): + parser = argparse.ArgumentParser( + description="Interactive CLI for vLLM models with tool calling.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + Examples: + python3 chat.py # auto-detect model + python3 chat.py --persona lawful_evil # with persona + python3 chat.py --model kappa_20b_131k # explicit model + python3 chat.py --temperature 0.7 # lower temperature + """), + ) + parser.add_argument( + "--base-url", + default="http://localhost:8000/v1", + help="vLLM API base URL (default: http://localhost:8000/v1)", + ) + parser.add_argument( + "--model", + default=None, + help="Model name (default: auto-detect from server)", + ) + parser.add_argument( + "--persona", + default=None, + help="Persona name injected as system message", + ) + parser.add_argument( + "--instructions", + default=None, + help="Developer instructions injected as developer message", + ) + parser.add_argument( + "--temperature", + type=float, + default=0.5, + help="Sampling temperature (default: 0.5)", + ) + parser.add_argument( + "--max-tokens", + type=int, + default=32768, + help="Max completion tokens (default: 32768)", + ) + parser.add_argument( + "--tool-timeout", + type=int, + default=30, + help="Bash command timeout in seconds (default: 30)", + ) + parser.add_argument( + "--reasoning-effort", + default="medium", + choices=["low", "medium", "high"], + help="Reasoning effort level (default: medium)", + ) + parser.add_argument( + "--context-window", + type=int, + default=131072, + help="Context window size in tokens (default: 131072)", + ) + parser.add_argument( + "--compact-max-tokens", + type=int, + default=32768, + help="Max tokens for compaction summary (default: 32768)", + ) + parser.add_argument( + "--auto-compact-pct", + type=float, + default=0.8, + help="Auto-compact when prompt exceeds this fraction of context window (default: 0.8)", + ) + parser.add_argument( + "--agent-max-turns", + type=int, + default=-1, + help="Max tool turns for agent sub-sessions, -1 for unlimited (default: -1)", + ) + parser.add_argument( + "--tool-truncation", + type=int, + default=0, + help="Tool output truncation limit in chars, 0 for auto (50%% of context window) (default: 0)", + ) + parser.add_argument( + "--resume", + default=None, + metavar="SESSION", + help="Resume a previous session by alias or session_id", + ) + parser.add_argument( + "--skip-permissions", + action="store_true", + help="Auto-approve all tool calls (no confirmation prompts)", + ) + parser.add_argument( + "--api-key", + default=None, + help="API key (default: $OPENAI_API_KEY, or 'dummy' for local servers)", + ) + parser.add_argument( + "--session-retention-days", + type=int, + default=90, + metavar="DAYS", + help="Delete unnamed sessions older than DAYS days on startup, 0 to disable (default: 90)", + ) + parser.add_argument( + "--console-url", + default=None, + help="Turnstone console URL for /cluster commands (e.g., http://localhost:8090)", + ) + parser.add_argument( + "--auth-token", + default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""), + help="Bearer token for authenticating to turnstone services (default: $TURNSTONE_AUTH_TOKEN)", + ) + from turnstone.core.config import apply_config + + apply_config(parser, ["api", "model", "session", "tools", "console", "auth"]) + args = parser.parse_args() + + # Prune stale / empty sessions on startup + from turnstone.core.memory import prune_sessions + + prune_sessions(retention_days=args.session_retention_days, log_fn=print) + + # Set up readline + setup_readline() + + # Create client + api_key = args.api_key or os.environ.get("OPENAI_API_KEY") or "dummy" + client = OpenAI( + base_url=args.base_url, + api_key=api_key, + ) + + # Detect or use provided model + if args.model: + model = args.model + else: + model = detect_model(client) + + # Session factory — captures shared config for creating workstream sessions + def session_factory(ui): + return ChatSession( + client=client, + model=model, + ui=ui, + persona=args.persona, + instructions=args.instructions, + temperature=args.temperature, + max_tokens=args.max_tokens, + tool_timeout=args.tool_timeout, + reasoning_effort=args.reasoning_effort, + context_window=args.context_window, + compact_max_tokens=args.compact_max_tokens, + auto_compact_pct=args.auto_compact_pct, + agent_max_turns=args.agent_max_turns, + tool_truncation=args.tool_truncation, + ) + + # Create workstream manager and initial workstream + manager = WorkstreamManager(session_factory) + ws = manager.create( + ui_factory=lambda wid: WorkstreamTerminalUI(wid, manager), + ) + if args.skip_permissions: + ws.ui.auto_approve = True + + # Handle --resume + if args.resume: + from turnstone.core.memory import resolve_session + + target_id = resolve_session(args.resume) + if not target_id: + print(red(f"Session not found: {args.resume}")) + sys.exit(1) + if not ws.session.resume_session(target_id): + print(red(f"Session '{args.resume}' has no messages.")) + sys.exit(1) + print( + f"Resumed session {bold(target_id)} ({len(ws.session.messages)} messages)" + ) + + # Background attention notification — write to stderr while user types + def _bg_attention_notify(ws_id, state): + if state == WorkstreamState.ATTENTION and ws_id != manager.active_id: + bg_ws = manager.get(ws_id) + if bg_ws: + idx = manager.index_of(ws_id) + sys.stderr.write( + f"\a\r\033[s\033[1A\033[K" + f" {YELLOW}\u25c6 {idx}:{bg_ws.name} needs attention{RESET}" + f"\033[u" + ) + sys.stderr.flush() + + manager._on_state_change = _bg_attention_notify + + # Print banner + print(f"\n{bold('Chat')} with {cyan(model)}") + if args.persona: + print(f"Persona: {cyan(args.persona)}") + print(f"Type /help for commands, /ws for workstreams, /exit or Ctrl+D to quit.\n") + + # Prompt string -- use a short display name + display_name = model.split("/")[-1] # strip path prefixes if any + if len(display_name) > 30: + display_name = display_name[:27] + "..." + + # Main loop + while True: + try: + # Show background workstream status if any need attention + if manager.count > 1: + _print_ws_status_line(manager) + + # Build prompt with workstream info + active = manager.get_active() + if manager.count > 1: + idx = manager.index_of(active.id) + prompt_str = f"\001{BOLD}\002{idx}:{active.name}\001{RESET}\002 > " + else: + prompt_str = f"\001{BOLD}\002[{display_name}]\001{RESET}\002 > " + user_input = input(prompt_str) + except (EOFError, KeyboardInterrupt): + print() + break + + user_input = user_input.strip() + if not user_input: + continue + + if user_input.startswith("/ws"): + _handle_ws_command(manager, user_input, args.skip_permissions) + continue + + if user_input.startswith("/cluster"): + _handle_cluster_command(user_input, args.console_url, args.auth_token) + continue + + active = manager.get_active() + if user_input.startswith("/"): + should_exit = active.session.handle_command(user_input) + if should_exit: + break + else: + try: + active.session.send(user_input) + except KeyboardInterrupt: + print(f"\n{yellow('Interrupted.')}") + except Exception as e: + print(f"\n{red(f'Error: {e}')}") + + print("Goodbye.") + + +if __name__ == "__main__": + main() diff --git a/turnstone/console/__init__.py b/turnstone/console/__init__.py new file mode 100644 index 00000000..4fec1180 --- /dev/null +++ b/turnstone/console/__init__.py @@ -0,0 +1 @@ +"""Cluster dashboard service for turnstone.""" diff --git a/turnstone/console/collector.py b/turnstone/console/collector.py new file mode 100644 index 00000000..a5e38f8e --- /dev/null +++ b/turnstone/console/collector.py @@ -0,0 +1,448 @@ +"""Cluster state collector — aggregates data from all turnstone nodes. + +Discovers nodes via Redis heartbeat keys, polls each node's /api/dashboard +endpoint for workstream data, and subscribes to the cluster event channel +for real-time state changes. +""" + +from __future__ import annotations + +import json +import logging +import queue +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field + +import httpx + +from turnstone.mq.broker import RedisBroker + +log = logging.getLogger("turnstone.console.collector") + + +@dataclass +class NodeSnapshot: + """In-memory snapshot of a single node's state.""" + + node_id: str = "" + server_url: str = "" + started: float = 0.0 + last_seen: float = 0.0 # monotonic time of last successful poll + max_ws: int = 10 # max workstreams (capacity) + workstreams: dict[str, dict] = field(default_factory=dict) + health: dict = field(default_factory=dict) + aggregate: dict = field(default_factory=dict) + reachable: bool = True + + +class ClusterCollector: + """Aggregates cluster state from Redis and per-node HTTP APIs. + + Three daemon threads: + 1. Event subscriber — real-time state changes from {prefix}:events:cluster + 2. Node discovery — scans heartbeat keys every ``discovery_interval`` seconds + 3. Poll loop — fetches /api/dashboard from each node every ``poll_interval`` seconds + """ + + def __init__( + self, + broker: RedisBroker, + prefix: str = "turnstone", + poll_interval: float = 10.0, + discovery_interval: float = 15.0, + max_poll_workers: int = 50, + http_timeout: float = 5.0, + auth_token: str = "", + ): + self._broker = broker + self._prefix = prefix + self._poll_interval = poll_interval + self._discovery_interval = discovery_interval + self._max_poll_workers = max_poll_workers + self._http_timeout = http_timeout + + self._lock = threading.Lock() + self._nodes: dict[str, NodeSnapshot] = {} + self._running = False + self._threads: list[threading.Thread] = [] + self._poll_pool = ThreadPoolExecutor(max_workers=max_poll_workers) + headers = {} + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + self._http_client = httpx.Client(timeout=http_timeout, headers=headers) + + # SSE fan-out to browser clients + self._listeners: list[queue.Queue] = [] + self._listeners_lock = threading.Lock() + + # -- lifecycle ----------------------------------------------------------- + + def start(self) -> None: + """Start background threads.""" + self._running = True + for target, name in [ + (self._event_loop, "console-events"), + (self._discovery_loop, "console-discovery"), + (self._poll_loop, "console-poll"), + ]: + t = threading.Thread(target=target, name=name, daemon=True) + t.start() + self._threads.append(t) + log.info("ClusterCollector started") + + def stop(self) -> None: + """Stop all threads and clean up resources.""" + self._running = False + self._poll_pool.shutdown(wait=False) + self._http_client.close() + log.info("ClusterCollector stopped") + + # -- event subscription -------------------------------------------------- + + def _event_loop(self) -> None: + """Subscribe to cluster events for real-time updates.""" + while self._running: + try: + self._broker.subscribe_cluster(self._on_cluster_event) + while self._running: + time.sleep(1) + except Exception: + log.exception("Cluster subscription error, reconnecting in 5s") + time.sleep(5) + + def _on_cluster_event(self, raw: str) -> None: + """Handle a cluster event from Redis pub/sub.""" + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return + + etype = data.get("type", "") + ws_id = data.get("ws_id", "") + node_id = data.get("node_id", "") + + with self._lock: + if etype == "cluster_state" and node_id in self._nodes: + node = self._nodes[node_id] + if ws_id in node.workstreams: + ws = node.workstreams[ws_id] + ws["state"] = data.get("state", ws.get("state", "idle")) + if "tokens" in data: + ws["tokens"] = data["tokens"] + if "context_ratio" in data: + ws["context_ratio"] = data["context_ratio"] + if "activity" in data: + ws["activity"] = data["activity"] + if "activity_state" in data: + ws["activity_state"] = data["activity_state"] + + elif etype == "ws_created" and node_id: + if node_id in self._nodes: + self._nodes[node_id].workstreams[ws_id] = { + "id": ws_id, + "name": data.get("name", ""), + "state": "idle", + "node": node_id, + "title": "", + "tokens": 0, + "context_ratio": 0.0, + "activity": "", + "activity_state": "", + "tool_calls": 0, + } + + elif etype == "ws_closed": + for node in self._nodes.values(): + node.workstreams.pop(ws_id, None) + + elif etype == "ws_rename": + for node in self._nodes.values(): + if ws_id in node.workstreams: + node.workstreams[ws_id]["name"] = data.get("name", "") + + # Fan out to SSE listeners + self._fanout(data) + + def _fanout(self, event: dict) -> None: + """Copy an event to all registered SSE listener queues.""" + with self._listeners_lock: + for q in self._listeners: + try: + q.put_nowait(event) + except queue.Full: + pass + + # -- node discovery ------------------------------------------------------ + + def _discovery_loop(self) -> None: + """Periodically scan Redis for active nodes.""" + while self._running: + try: + self._discover_nodes() + except Exception: + log.exception("Node discovery error") + time.sleep(self._discovery_interval) + + def _discover_nodes(self) -> None: + """Scan heartbeat keys and update the node map.""" + active = self._broker.list_nodes() + active_ids = set() + pending_events = [] + with self._lock: + for meta in active: + nid = meta.get("node_id", "") + if not nid: + continue + active_ids.add(nid) + if nid not in self._nodes: + self._nodes[nid] = NodeSnapshot( + node_id=nid, + server_url=meta.get("server_url", ""), + started=meta.get("started", 0.0), + max_ws=meta.get("max_ws", 10), + ) + pending_events.append({"type": "node_joined", "node_id": nid}) + log.info("Discovered node: %s", nid) + else: + self._nodes[nid].server_url = meta.get( + "server_url", self._nodes[nid].server_url + ) + + # Remove nodes whose heartbeats expired + lost = [nid for nid in self._nodes if nid not in active_ids] + for nid in lost: + del self._nodes[nid] + pending_events.append({"type": "node_lost", "node_id": nid}) + log.info("Lost node: %s", nid) + for event in pending_events: + self._fanout(event) + + # -- polling ------------------------------------------------------------- + + def _poll_loop(self) -> None: + """Periodically fetch /api/dashboard from each node.""" + while self._running: + try: + self._poll_all_nodes() + except Exception: + log.exception("Poll loop error") + time.sleep(self._poll_interval) + + def _poll_all_nodes(self) -> None: + """Fetch dashboard data from all known nodes in parallel.""" + with self._lock: + targets = [ + (n.node_id, n.server_url) + for n in self._nodes.values() + if n.server_url and n.server_url.startswith("http") + ] + + if not targets: + return + + futures = { + self._poll_pool.submit(self._fetch_node, nid, url): nid + for nid, url in targets + } + for future in as_completed(futures): + nid = futures[future] + try: + dashboard, health = future.result() + self._apply_poll(nid, dashboard, health) + except Exception: + log.debug("Failed to poll node %s", nid) + with self._lock: + if nid in self._nodes: + self._nodes[nid].reachable = False + + def _fetch_node(self, node_id: str, server_url: str) -> tuple[dict, dict]: + """Fetch /api/dashboard and /health from a single node.""" + base = server_url.rstrip("/") + dash_resp = self._http_client.get(f"{base}/api/dashboard") + dash_data = dash_resp.json() + try: + health_resp = self._http_client.get(f"{base}/health") + health_data = health_resp.json() + except Exception: + health_data = {} + return dash_data, health_data + + def _apply_poll(self, node_id: str, dashboard: dict, health: dict) -> None: + """Apply polled data to the in-memory node snapshot.""" + ws_list = dashboard.get("workstreams", []) + aggregate = dashboard.get("aggregate", {}) + with self._lock: + node = self._nodes.get(node_id) + if not node: + return + node.last_seen = time.monotonic() + node.reachable = True + node.health = health + node.aggregate = aggregate + # Replace workstreams entirely from the authoritative poll + node.workstreams = {} + for ws in ws_list: + ws["node"] = node_id + node.workstreams[ws.get("id", "")] = ws + + # -- query methods (thread-safe) ----------------------------------------- + + def get_overview(self) -> dict: + """Return cluster overview: state counts, totals, aggregate stats.""" + states = {"running": 0, "thinking": 0, "attention": 0, "idle": 0, "error": 0} + total_tokens = 0 + total_tool_calls = 0 + total_ws = 0 + with self._lock: + for node in self._nodes.values(): + for ws in node.workstreams.values(): + state = ws.get("state", "idle") + states[state] = states.get(state, 0) + 1 + total_ws += 1 + total_tokens += node.aggregate.get("total_tokens", 0) + total_tool_calls += node.aggregate.get("total_tool_calls", 0) + node_count = len(self._nodes) + return { + "nodes": node_count, + "workstreams": total_ws, + "states": states, + "aggregate": { + "total_tokens": total_tokens, + "total_tool_calls": total_tool_calls, + }, + } + + def get_nodes( + self, sort_by: str = "activity", limit: int = 100, offset: int = 0 + ) -> tuple[list[dict], int]: + """Return sorted, paginated node list with per-node counts.""" + with self._lock: + items = [] + for node in self._nodes.values(): + ws_states = { + "running": 0, + "thinking": 0, + "attention": 0, + "idle": 0, + "error": 0, + } + for ws in node.workstreams.values(): + s = ws.get("state", "idle") + ws_states[s] = ws_states.get(s, 0) + 1 + # Use aggregate tokens if available, else sum from workstreams + agg_tokens = node.aggregate.get("total_tokens", 0) + if not agg_tokens: + agg_tokens = sum( + ws.get("tokens", 0) for ws in node.workstreams.values() + ) + items.append( + { + "node_id": node.node_id, + "server_url": node.server_url, + "ws_total": len(node.workstreams), + "ws_running": ws_states["running"], + "ws_thinking": ws_states["thinking"], + "ws_attention": ws_states["attention"], + "ws_idle": ws_states["idle"], + "ws_error": ws_states["error"], + "total_tokens": agg_tokens, + "ws_tokens": agg_tokens, + "max_ws": node.max_ws, + "started": node.started, + "last_seen": node.last_seen, + "reachable": node.reachable, + "health": node.health, + } + ) + total = len(items) + + # Sort + if sort_by == "activity": + items.sort(key=lambda n: n["ws_running"] + n["ws_attention"], reverse=True) + elif sort_by == "tokens": + items.sort(key=lambda n: n["total_tokens"], reverse=True) + elif sort_by == "name": + items.sort(key=lambda n: n["node_id"]) + + return items[offset : offset + limit], total + + def get_workstreams( + self, + state: str | None = None, + node: str | None = None, + search: str | None = None, + sort_by: str = "state", + page: int = 1, + per_page: int = 50, + ) -> tuple[list[dict], int]: + """Return filtered, sorted, paginated workstreams + total count.""" + with self._lock: + all_ws = [] + for n in self._nodes.values(): + for ws in n.workstreams.values(): + all_ws.append(dict(ws)) + + # Filter + if state: + all_ws = [ws for ws in all_ws if ws.get("state") == state] + if node: + all_ws = [ws for ws in all_ws if ws.get("node") == node] + if search: + q = search.lower() + all_ws = [ + ws + for ws in all_ws + if q in ws.get("name", "").lower() + or q in ws.get("title", "").lower() + or q in ws.get("node", "").lower() + ] + + # Sort + state_order = { + "running": 0, + "thinking": 1, + "attention": 2, + "error": 3, + "idle": 4, + } + if sort_by == "state": + all_ws.sort(key=lambda ws: state_order.get(ws.get("state", "idle"), 9)) + elif sort_by == "tokens": + all_ws.sort(key=lambda ws: ws.get("tokens", 0), reverse=True) + elif sort_by == "name": + all_ws.sort(key=lambda ws: ws.get("name", "")) + + total = len(all_ws) + start = (page - 1) * per_page + page_ws = all_ws[start : start + per_page] + return page_ws, total + + def get_node_detail(self, node_id: str) -> dict | None: + """Return a single node's workstreams and health.""" + with self._lock: + node = self._nodes.get(node_id) + if not node: + return None + return { + "node_id": node.node_id, + "server_url": node.server_url, + "health": dict(node.health), + "workstreams": [dict(ws) for ws in node.workstreams.values()], + "aggregate": dict(node.aggregate), + "reachable": node.reachable, + } + + # -- SSE listener management --------------------------------------------- + + def register_listener(self, q: queue.Queue) -> None: + """Register a queue for SSE event fan-out.""" + with self._listeners_lock: + self._listeners.append(q) + + def unregister_listener(self, q: queue.Queue) -> None: + """Unregister a queue from SSE event fan-out.""" + with self._listeners_lock: + if q in self._listeners: + self._listeners.remove(q) diff --git a/turnstone/console/server.py b/turnstone/console/server.py new file mode 100644 index 00000000..bac3df33 --- /dev/null +++ b/turnstone/console/server.py @@ -0,0 +1,395 @@ +"""Cluster dashboard HTTP server for turnstone. + +Serves the cluster-level dashboard UI and provides REST/SSE APIs +backed by the ClusterCollector. +""" + +import argparse +import json +import logging +import math +import os +import queue +import textwrap +import threading +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from socketserver import ThreadingMixIn +from urllib.parse import urlparse, parse_qs + +from turnstone.console.collector import ClusterCollector +from turnstone.mq.broker import RedisBroker + +log = logging.getLogger("turnstone.console.server") + +# --------------------------------------------------------------------------- +# Static assets — loaded once at startup +# --------------------------------------------------------------------------- + +_STATIC_DIR = Path(__file__).parent / "static" +_HTML = "" +_CSS = "" +_JS = "" + + +def _load_static() -> None: + global _HTML, _CSS, _JS + _HTML = (_STATIC_DIR / "index.html").read_text(encoding="utf-8") + _CSS = (_STATIC_DIR / "style.css").read_text(encoding="utf-8") + _JS = (_STATIC_DIR / "app.js").read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# HTTP handler +# --------------------------------------------------------------------------- + + +class ConsoleHTTPHandler(BaseHTTPRequestHandler): + """HTTP handler for the cluster dashboard.""" + + def log_message(self, format, *args): + pass # suppress default logging + + def _set_headers(self, status=200, content_type="application/json"): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + def _send_json(self, data: dict, status=200): + self._set_headers(status, "application/json") + self.wfile.write(json.dumps(data).encode("utf-8")) + + def _check_auth(self, method: str, path: str) -> bool: + """Return True if authorized. Sends 401/403 and returns False otherwise.""" + from turnstone.core.auth import check_request + + auth_config = self.server.auth_config # type: ignore[attr-defined] + auth_header = self.headers.get("Authorization") + cookie_header = self.headers.get("Cookie") + allowed, status, msg = check_request( + auth_config, method, path, auth_header, cookie_header + ) + if not allowed: + self._send_json({"error": msg}, status) + return allowed + + def _read_body(self) -> dict: + length = int(self.headers.get("Content-Length", 0)) + if length == 0: + return {} + raw = self.rfile.read(length) + try: + return json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + return {} + + def do_POST(self): + # Login/logout pass through _check_auth because they are in PUBLIC_PATHS. + if not self._check_auth("POST", self.path): + return + if self.path == "/api/auth/login": + from turnstone.core.auth import make_set_cookie + + body = self._read_body() + token = body.get("token", "") + auth_config = self.server.auth_config # type: ignore[attr-defined] + role = auth_config.check(token) + if role: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Set-Cookie", make_set_cookie(token)) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write( + json.dumps({"status": "ok", "role": role}).encode("utf-8") + ) + else: + self._send_json({"error": "Invalid token"}, 401) + + elif self.path == "/api/auth/logout": + from turnstone.core.auth import make_clear_cookie + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Set-Cookie", make_clear_cookie()) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(b'{"status":"ok"}') + + else: + self._send_json({"error": "Not found"}, 404) + + def do_GET(self): + parsed = urlparse(self.path) + try: + if not self._check_auth("GET", parsed.path): + return + self._do_GET(parsed) + except Exception: + log.exception("Error handling GET %s", self.path) + self._send_json({"error": "Internal server error"}, 500) + + @staticmethod + def _parse_int( + qs: dict, name: str, default: int, minimum: int = 0, maximum: int = 10000 + ) -> int: + try: + val = int(qs.get(name, [str(default)])[0]) + except (ValueError, IndexError): + val = default + return max(minimum, min(val, maximum)) + + def _do_GET(self, parsed): + collector: ClusterCollector = self.server.collector # type: ignore[attr-defined] + + if parsed.path == "/": + self._set_headers(200, "text/html; charset=utf-8") + self.wfile.write(_HTML.encode("utf-8")) + + elif parsed.path == "/static/style.css": + self.send_response(200) + self.send_header("Content-Type", "text/css; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(_CSS.encode("utf-8")) + + elif parsed.path == "/static/app.js": + self.send_response(200) + self.send_header("Content-Type", "application/javascript; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(_JS.encode("utf-8")) + + elif parsed.path == "/api/cluster/overview": + self._send_json(collector.get_overview()) + + elif parsed.path == "/api/cluster/nodes": + qs = parse_qs(parsed.query) + sort_by = qs.get("sort", ["activity"])[0] + limit = self._parse_int(qs, "limit", 100, minimum=1, maximum=1000) + offset = self._parse_int(qs, "offset", 0) + nodes, total = collector.get_nodes( + sort_by=sort_by, limit=limit, offset=offset + ) + self._send_json({"nodes": nodes, "total": total}) + + elif parsed.path == "/api/cluster/workstreams": + qs = parse_qs(parsed.query) + state = qs.get("state", [None])[0] + node = qs.get("node", [None])[0] + search = qs.get("search", [None])[0] + sort_by = qs.get("sort", ["state"])[0] + page = self._parse_int(qs, "page", 1, minimum=1) + per_page = self._parse_int(qs, "per_page", 50, minimum=1, maximum=200) + ws_list, total = collector.get_workstreams( + state=state, + node=node, + search=search, + sort_by=sort_by, + page=page, + per_page=per_page, + ) + pages = math.ceil(total / per_page) if per_page > 0 else 0 + self._send_json( + { + "workstreams": ws_list, + "total": total, + "page": page, + "per_page": per_page, + "pages": pages, + } + ) + + elif parsed.path.startswith("/api/cluster/node/"): + node_id = parsed.path[len("/api/cluster/node/") :] + if not node_id or "/" in node_id or len(node_id) > 256: + self._send_json({"error": "Invalid node ID"}, 400) + else: + detail = collector.get_node_detail(node_id) + if detail: + self._send_json(detail) + else: + self._send_json({"error": "Node not found"}, 404) + + elif parsed.path == "/api/cluster/events": + self._handle_sse(collector) + + elif parsed.path == "/health": + overview = collector.get_overview() + self._send_json( + { + "status": "ok", + "service": "turnstone-console", + "nodes": overview["nodes"], + "workstreams": overview["workstreams"], + } + ) + + else: + self._set_headers(404, "text/plain") + self.wfile.write(b"Not found") + + def _handle_sse(self, collector: ClusterCollector): + """Server-Sent Events stream for cluster updates.""" + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + client_queue: queue.Queue = queue.Queue(maxsize=500) + collector.register_listener(client_queue) + try: + while True: + try: + event = client_queue.get(timeout=5) + data = json.dumps(event) + self.wfile.write(f"data: {data}\n\n".encode("utf-8")) + self.wfile.flush() + except queue.Empty: + self.wfile.write(b": keepalive\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, OSError): + pass + finally: + collector.unregister_listener(client_queue) + + def do_OPTIONS(self): + """Handle CORS preflight.""" + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.end_headers() + + +# --------------------------------------------------------------------------- +# Threaded HTTP server +# --------------------------------------------------------------------------- + + +class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser( + description="turnstone console — cluster dashboard service.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + Examples: + turnstone-console # default Redis on localhost + turnstone-console --port 9090 # custom port + turnstone-console --redis-host redis.internal # remote Redis + """), + ) + parser.add_argument( + "--host", + default="0.0.0.0", + help="Host to bind to (default: 0.0.0.0)", + ) + parser.add_argument( + "--port", + type=int, + default=8090, + help="Port to listen on (default: 8090)", + ) + parser.add_argument( + "--redis-host", + default="localhost", + help="Redis host (default: localhost)", + ) + parser.add_argument( + "--redis-port", + type=int, + default=6379, + help="Redis port (default: 6379)", + ) + parser.add_argument( + "--redis-password", + default=os.environ.get("REDIS_PASSWORD"), + help="Redis password (default: $REDIS_PASSWORD)", + ) + parser.add_argument( + "--redis-db", + type=int, + default=0, + help="Redis DB number (default: 0)", + ) + parser.add_argument( + "--poll-interval", + type=float, + default=10.0, + help="Node polling interval in seconds (default: 10)", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="Log level (default: INFO)", + ) + parser.add_argument( + "--auth-token", + default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""), + help="Bearer token for polling turnstone-server nodes (default: $TURNSTONE_AUTH_TOKEN)", + ) + + from turnstone.core.config import apply_config + + apply_config(parser, ["console", "redis", "auth"]) + args = parser.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) + + broker = RedisBroker( + host=args.redis_host, + port=args.redis_port, + db=args.redis_db, + password=args.redis_password, + ) + + collector = ClusterCollector( + broker=broker, + poll_interval=args.poll_interval, + auth_token=args.auth_token, + ) + collector.start() + + _load_static() + + from turnstone.core.auth import load_auth_config + + auth_config = load_auth_config() + + server = ThreadedHTTPServer((args.host, args.port), ConsoleHTTPHandler) + server.collector = collector # type: ignore[attr-defined] + server.auth_config = auth_config # type: ignore[attr-defined] + + print(f"turnstone console running on http://{args.host}:{args.port}") + if auth_config.enabled: + print(f"Auth: enabled ({len(auth_config.tokens)} token(s) configured)") + print("Press Ctrl+C to stop.") + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.") + collector.stop() + broker.close() + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js new file mode 100644 index 00000000..87dc3fe9 --- /dev/null +++ b/turnstone/console/static/app.js @@ -0,0 +1,794 @@ +// --- Theme --- +function toggleTheme() { + var next = document.documentElement.dataset.theme === "light" ? "" : "light"; + document.documentElement.dataset.theme = next; + localStorage.setItem("turnstone-theme", next || "dark"); + var btn = document.getElementById("theme-toggle"); + if (btn) btn.textContent = next === "light" ? "\u2600" : "\u263E"; +} +(function initTheme() { + var stored = localStorage.getItem("turnstone-theme"); + if (stored === "light") { + document.documentElement.dataset.theme = "light"; + } else if ( + !stored && + window.matchMedia && + window.matchMedia("(prefers-color-scheme: light)").matches + ) { + document.documentElement.dataset.theme = "light"; + } + var btn = document.getElementById("theme-toggle"); + if (btn) + btn.textContent = + document.documentElement.dataset.theme === "light" ? "\u2600" : "\u263E"; +})(); + +/* Auth-aware fetch — shows login overlay on 401 */ +function authFetch(url, opts) { + return fetch(url, opts).then(function (r) { + if (r.status === 401) { + showLogin(); + throw new Error("auth"); + } + return r; + }); +} + +// --- State --- +var currentView = "overview"; // "overview" | "node" | "filtered" +var currentNodeId = null; +var currentFilter = { state: null, node: null, page: 1, per_page: 50 }; +var evtSource = null; +var retryDelay = 1000; + +// --- Constants --- +var STATE_DISPLAY = { + running: { symbol: "\u25b8", label: "run" }, + thinking: { symbol: "\u25cc", label: "think" }, + attention: { symbol: "\u25c6", label: "attn" }, + idle: { symbol: "\u00b7", label: "idle" }, + error: { symbol: "\u2716", label: "err" }, +}; +var STATE_ORDER = ["running", "thinking", "attention", "error", "idle"]; + +// --- Helpers --- +function escapeHtml(s) { + var el = document.createElement("span"); + el.textContent = s; + return el.innerHTML; +} +function formatTokens(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + return String(n || 0); +} +function ctxClass(ratio) { + if (ratio <= 0) return "ctx-idle"; + var pct = ratio * 100; + if (pct < 30) return "ctx-low"; + if (pct < 50) return "ctx-mid"; + if (pct < 80) return "ctx-high"; + return "ctx-danger"; +} +function formatUptime(seconds) { + if (!seconds) return ""; + if (seconds < 60) return seconds + "s"; + var min = Math.floor(seconds / 60); + if (min < 60) return min + "m"; + var hr = Math.floor(min / 60); + return hr + "h " + (min % 60) + "m"; +} +function formatCount(n) { + if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + return String(n); +} + +// --- SSE Connection --- +function connectSSE() { + if (evtSource) { + evtSource.close(); + evtSource = null; + } + evtSource = new EventSource("/api/cluster/events"); + var statusBar = document.getElementById("status-bar"); + evtSource.onmessage = function (e) { + retryDelay = 1000; + statusBar.classList.remove("disconnected"); + statusBar.textContent = ""; + try { + var data = JSON.parse(e.data); + handleClusterEvent(data); + } catch (err) { + /* ignore malformed SSE */ + } + }; + evtSource.onerror = function () { + evtSource.close(); + evtSource = null; + statusBar.textContent = "Reconnecting\u2026"; + statusBar.classList.add("disconnected"); + // Raw fetch (not authFetch) — need to inspect status before throwing + fetch("/api/cluster/overview") + .then(function (r) { + if (r.status === 401) { + showLogin(); + return; + } + setTimeout(connectSSE, retryDelay); + retryDelay = Math.min(retryDelay * 2, 30000); + }) + .catch(function () { + setTimeout(connectSSE, retryDelay); + retryDelay = Math.min(retryDelay * 2, 30000); + }); + }; +} + +var _refreshTimer = null; +function scheduleRefresh() { + if (_refreshTimer) return; + _refreshTimer = setTimeout(function () { + _refreshTimer = null; + if (currentView === "overview") loadOverview(); + else if (currentView === "node" && currentNodeId) + loadNodeDetail(currentNodeId); + else if (currentView === "filtered") loadFilteredWorkstreams(); + }, 250); +} + +function handleClusterEvent(data) { + if ( + data.type === "cluster_state" || + data.type === "ws_created" || + data.type === "ws_closed" || + data.type === "ws_rename" || + data.type === "node_joined" || + data.type === "node_lost" + ) { + scheduleRefresh(); + } +} + +// --- Overview View --- +function showOverview() { + currentView = "overview"; + currentNodeId = null; + currentFilter = { state: null, node: null, page: 1, per_page: 50 }; + document.getElementById("view-overview").style.display = ""; + document.getElementById("view-node").style.display = "none"; + document.getElementById("view-filtered").style.display = "none"; + document.getElementById("breadcrumb").style.display = "none"; + document.getElementById("main").scrollTop = 0; + loadOverview(); + history.pushState({ view: "overview" }, ""); +} + +function loadOverview() { + var overviewP = authFetch("/api/cluster/overview").then(function (r) { + return r.json(); + }); + var nodesP = authFetch("/api/cluster/nodes?sort=activity&limit=50").then( + function (r) { + return r.json(); + }, + ); + Promise.all([overviewP, nodesP]) + .then(function (res) { + renderStateCards(res[0].states); + renderAggregateBar(res[0]); + renderNodeTable(res[1].nodes, res[1].total); + document.getElementById("cluster-summary").textContent = + res[0].nodes + + " nodes \u00b7 " + + formatCount(res[0].workstreams) + + " workstreams"; + }) + .catch(function () { + document.getElementById("node-table").innerHTML = + '
Failed to load
'; + }); +} + +function renderStateCards(states) { + var container = document.getElementById("state-cards"); + container.innerHTML = ""; + STATE_ORDER.forEach(function (state) { + var count = states[state] || 0; + var sd = STATE_DISPLAY[state] || STATE_DISPLAY.idle; + var card = document.createElement("div"); + card.className = "state-card"; + card.dataset.state = state; + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-label", sd.label + ": " + count + " workstreams"); + card.innerHTML = + '
' + + formatCount(count) + + "
" + + '
' + + sd.symbol + + " " + + sd.label + + "
"; + card.onclick = function () { + drillDownByState(state); + }; + card.onkeydown = function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + drillDownByState(state); + } + }; + container.appendChild(card); + }); +} + +function renderAggregateBar(overview) { + var agg = overview.aggregate || {}; + var parts = []; + if (agg.total_tokens) parts.push(formatTokens(agg.total_tokens) + " tokens"); + if (agg.total_tool_calls) + parts.push(formatCount(agg.total_tool_calls) + " tool calls"); + document.getElementById("aggregate-bar").textContent = parts.join(" \u00b7 "); +} + +function renderNodeTable(nodes, total) { + var table = document.getElementById("node-table"); + table.innerHTML = ""; + if (!nodes.length) { + table.innerHTML = '
No nodes discovered
'; + return; + } + nodes.forEach(function (node) { + var row = document.createElement("div"); + row.className = "node-row"; + if (node.ws_attention > 0) row.classList.add("has-attention"); + else if (node.ws_running > 0) row.classList.add("has-running"); + else if (node.ws_thinking > 0) row.classList.add("has-thinking"); + else if (node.ws_error > 0) row.classList.add("has-error"); + row.setAttribute("role", "button"); + row.setAttribute("tabindex", "0"); + row.setAttribute( + "aria-label", + node.node_id + + ": " + + node.ws_total + + " workstreams, " + + node.ws_running + + " running, " + + node.ws_attention + + " attention, " + + formatTokens(node.total_tokens) + + " tokens", + ); + + var dotClass = node.reachable ? "node-dot" : "node-dot unreachable"; + + // Use aggregate tokens, fall back to summed workstream tokens + var displayTokens = node.total_tokens || node.ws_tokens || 0; + + // Load = workstream count / max capacity + var maxWs = node.max_ws || 10; + var healthPct = Math.round((node.ws_total / maxWs) * 100); + var healthFillClass = + healthPct < 50 ? "low" : healthPct < 80 ? "mid" : "high"; + var healthFillHtml = + healthPct > 0 + ? '' + : ""; + + row.innerHTML = + '' + + escapeHtml(node.node_id) + + "" + + '' + + node.ws_total + + "" + + '' + + node.ws_running + + "" + + '' + + node.ws_attention + + "" + + '' + + formatTokens(displayTokens) + + "" + + '' + + '' + + healthFillHtml + + "" + + " " + + healthPct + + "%" + + ""; + + row.onclick = function () { + drillDownToNode(node.node_id, node.server_url); + }; + row.onkeydown = function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + drillDownToNode(node.node_id, node.server_url); + } + }; + table.appendChild(row); + }); + + // Pagination hint + var pag = document.getElementById("node-pagination"); + pag.innerHTML = ""; + if (total > nodes.length) { + pag.textContent = "Showing " + nodes.length + " of " + total + " nodes"; + } +} + +// --- Drill-down: Node --- +function drillDownToNode(nodeId, serverUrl) { + currentView = "node"; + currentNodeId = nodeId; + document.getElementById("view-overview").style.display = "none"; + document.getElementById("view-node").style.display = ""; + document.getElementById("view-filtered").style.display = "none"; + document.getElementById("breadcrumb").style.display = ""; + document.getElementById("breadcrumb-label").textContent = nodeId; + if (serverUrl) { + var link = document.getElementById("node-link"); + link.href = serverUrl; + link.style.display = ""; + } + document.getElementById("main").scrollTop = 0; + loadNodeDetail(nodeId); + document.getElementById("breadcrumb-home").focus(); + history.pushState({ view: "node", nodeId: nodeId, serverUrl: serverUrl }, ""); +} + +function loadNodeDetail(nodeId) { + authFetch("/api/cluster/node/" + encodeURIComponent(nodeId)) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data.error) { + document.getElementById("node-ws-table").innerHTML = + '
' + escapeHtml(data.error) + "
"; + return; + } + var ws = data.workstreams || []; + var active = ws.filter(function (w) { + return w.state !== "idle"; + }).length; + document.getElementById("node-ws-summary").textContent = + active + " active \u00b7 " + ws.length + " total"; + renderWsTable(document.getElementById("node-ws-table"), ws); + }); +} + +// --- Drill-down: Filtered --- +function drillDownByState(state) { + currentView = "filtered"; + currentFilter = { state: state, node: null, page: 1, per_page: 50 }; + document.getElementById("view-overview").style.display = "none"; + document.getElementById("view-node").style.display = "none"; + document.getElementById("view-filtered").style.display = ""; + document.getElementById("breadcrumb").style.display = ""; + var sd = STATE_DISPLAY[state] || STATE_DISPLAY.idle; + document.getElementById("breadcrumb-label").textContent = + sd.symbol + " " + sd.label; + document.getElementById("filtered-title").textContent = + "WORKSTREAMS — " + sd.label.toUpperCase(); + document.getElementById("main").scrollTop = 0; + loadFilteredWorkstreams(); + document.getElementById("breadcrumb-home").focus(); + history.pushState({ view: "filtered", filter: currentFilter }, ""); +} + +function drillDownByNode(nodeId) { + currentView = "filtered"; + currentFilter = { state: null, node: nodeId, page: 1, per_page: 50 }; + document.getElementById("view-overview").style.display = "none"; + document.getElementById("view-node").style.display = "none"; + document.getElementById("view-filtered").style.display = ""; + document.getElementById("breadcrumb").style.display = ""; + document.getElementById("breadcrumb-label").textContent = nodeId; + document.getElementById("filtered-title").textContent = + "WORKSTREAMS — " + nodeId; + document.getElementById("main").scrollTop = 0; + loadFilteredWorkstreams(); + document.getElementById("breadcrumb-home").focus(); + history.pushState({ view: "filtered", filter: currentFilter }, ""); +} + +function loadFilteredWorkstreams() { + var params = + "page=" + currentFilter.page + "&per_page=" + currentFilter.per_page; + if (currentFilter.state) + params += "&state=" + encodeURIComponent(currentFilter.state); + if (currentFilter.node) + params += "&node=" + encodeURIComponent(currentFilter.node); + authFetch("/api/cluster/workstreams?" + params) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + document.getElementById("main").scrollTop = 0; + document.getElementById("filtered-summary").textContent = + "Page " + + data.page + + " of " + + data.pages + + " (" + + data.total + + " total)"; + renderWsTable( + document.getElementById("filtered-ws-table"), + data.workstreams, + ); + renderPagination( + document.getElementById("filtered-pagination"), + data.page, + data.pages, + ); + }) + .catch(function () { + document.getElementById("filtered-ws-table").innerHTML = + '
Failed to load
'; + }); +} + +function renderPagination(container, page, pages) { + container.innerHTML = ""; + if (pages <= 1) return; + var prev = document.createElement("button"); + prev.textContent = "\u25c4 Prev"; + prev.disabled = page <= 1; + prev.onclick = function () { + currentFilter.page--; + loadFilteredWorkstreams(); + }; + container.appendChild(prev); + var info = document.createElement("span"); + info.textContent = page + " / " + pages; + container.appendChild(info); + var next = document.createElement("button"); + next.textContent = "Next \u25ba"; + next.disabled = page >= pages; + next.onclick = function () { + currentFilter.page++; + loadFilteredWorkstreams(); + }; + container.appendChild(next); +} + +// --- Workstream table renderer (shared) --- +function renderWsTable(container, wsList) { + container.innerHTML = ""; + if (!wsList.length) { + container.innerHTML = '
No workstreams
'; + return; + } + wsList.forEach(function (ws) { + var state = ws.state || "idle"; + var sd = STATE_DISPLAY[state] || STATE_DISPLAY.idle; + + var row = document.createElement("div"); + row.className = "dash-row"; + row.dataset.wsId = ws.id || ""; + row.dataset.state = state; + row.setAttribute("tabindex", "0"); + row.setAttribute("role", "button"); + var ariaLabel = sd.label + ": " + (ws.name || ws.id || "unnamed"); + if (ws.node) ariaLabel += " on " + ws.node; + if (ws.title) ariaLabel += ", task: " + ws.title; + if (ws.tokens) ariaLabel += ", " + formatTokens(ws.tokens) + " tokens"; + if (ws.context_ratio > 0) + ariaLabel += ", " + Math.round(ws.context_ratio * 100) + "% context"; + row.setAttribute("aria-label", ariaLabel); + + var main = document.createElement("div"); + main.className = "dash-row-main"; + + // STATE + var stateCell = document.createElement("span"); + stateCell.className = "dash-cell-state"; + stateCell.innerHTML = + '' + + '' + + sd.symbol + + " " + + sd.label + + ""; + main.appendChild(stateCell); + + // NAME + var nameCell = document.createElement("span"); + nameCell.className = "dash-cell-name"; + nameCell.textContent = ws.name || ws.id || ""; + main.appendChild(nameCell); + + // NODE (clickable) + var nodeCell = document.createElement("span"); + nodeCell.className = "dash-cell-node"; + nodeCell.textContent = ws.node || ""; + nodeCell.onclick = function (e) { + e.stopPropagation(); + if (ws.node) drillDownByNode(ws.node); + }; + main.appendChild(nodeCell); + + // TASK + var taskCell = document.createElement("span"); + taskCell.className = "dash-cell-task"; + taskCell.textContent = ws.title || ""; + main.appendChild(taskCell); + + // TOKENS + var tokensCell = document.createElement("span"); + tokensCell.className = "dash-cell-tokens"; + tokensCell.textContent = ws.tokens ? formatTokens(ws.tokens) : ""; + main.appendChild(tokensCell); + + // CTX + var ctxCell = document.createElement("span"); + ctxCell.className = "dash-cell-ctx " + ctxClass(ws.context_ratio || 0); + ctxCell.textContent = + ws.context_ratio > 0 ? Math.round(ws.context_ratio * 100) + "%" : ""; + main.appendChild(ctxCell); + + row.appendChild(main); + + // Sub-line + var sub = document.createElement("div"); + sub.className = "dash-row-sub"; + if (ws.activity_state === "approval") sub.classList.add("sub-attention"); + sub.textContent = ws.activity || ""; + row.appendChild(sub); + + container.appendChild(row); + }); +} + +// --- Login Overlay --- +var _loginTrapHandler = null; +var _loginBusy = false; + +function initLogin() { + var overlay = document.createElement("div"); + overlay.id = "login-overlay"; + overlay.style.display = "none"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-labelledby", "login-title"); + overlay.innerHTML = + '
' + + '

turnstone console

' + + '' + + '' + + '' + + '' + + "
"; + document.body.appendChild(overlay); + document.getElementById("login-submit").onclick = submitLogin; + document + .getElementById("login-token") + .addEventListener("keydown", function (e) { + if (e.key === "Enter") submitLogin(); + if (e.key === "Escape") { + var errEl = document.getElementById("login-error"); + if (errEl && errEl.style.display !== "none") { + errEl.style.display = "none"; + errEl.textContent = ""; + } + } + }); +} + +function showLogin() { + var overlay = document.getElementById("login-overlay"); + if (!overlay) return; + overlay.style.display = "flex"; + document.body.style.overflow = "hidden"; + var logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) logoutBtn.style.display = "none"; + var errEl = document.getElementById("login-error"); + if (errEl) { + errEl.style.display = "none"; + errEl.textContent = ""; + } + setTimeout(function () { + var inp = document.getElementById("login-token"); + if (inp) { + inp.value = ""; + inp.focus(); + } + }, 50); + // Focus trap + if (_loginTrapHandler) + document.removeEventListener("keydown", _loginTrapHandler); + _loginTrapHandler = function (e) { + if (e.key === "Tab") { + var box = document.getElementById("login-box"); + var focusable = box.querySelectorAll("input, button"); + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; + document.addEventListener("keydown", _loginTrapHandler); +} + +function hideLogin() { + var overlay = document.getElementById("login-overlay"); + if (overlay) overlay.style.display = "none"; + document.body.style.overflow = ""; + if (_loginTrapHandler) { + document.removeEventListener("keydown", _loginTrapHandler); + _loginTrapHandler = null; + } +} + +function submitLogin() { + if (_loginBusy) return; + var token = (document.getElementById("login-token").value || "").trim(); + if (!token) { + var errEl = document.getElementById("login-error"); + if (errEl) { + errEl.textContent = "Token is required"; + errEl.style.display = "block"; + } + document.getElementById("login-token").focus(); + return; + } + + _loginBusy = true; + var btn = document.getElementById("login-submit"); + var inp = document.getElementById("login-token"); + btn.disabled = true; + btn.textContent = "Signing in\u2026"; + inp.disabled = true; + + fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: token }), + }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("invalid"); + if (!r.ok) throw new Error("server"); + return r.json(); + }) + .then(function () { + _loginBusy = false; + btn.disabled = false; + btn.textContent = "Sign in"; + inp.disabled = false; + hideLogin(); + document.getElementById("logout-btn").style.display = ""; + connectSSE(); + if (currentView === "overview") loadOverview(); + else if (currentView === "node") drillDownToNode(currentNodeId); + else if (currentView === "filtered") loadFilteredWorkstreams(); + }) + .catch(function (err) { + _loginBusy = false; + btn.disabled = false; + btn.textContent = "Sign in"; + inp.disabled = false; + var errEl = document.getElementById("login-error"); + if (errEl) { + errEl.textContent = + err.message === "invalid" + ? "Invalid token" + : "Connection failed \u2014 try again"; + errEl.style.display = "block"; + } + }); +} + +function logout() { + fetch("/api/auth/logout", { method: "POST" }).then(function () { + if (evtSource) { + evtSource.close(); + evtSource = null; + } + showLogin(); + }); +} + +// --- Navigation --- +window.addEventListener("popstate", function (e) { + var overlay = document.getElementById("login-overlay"); + if (overlay && overlay.style.display !== "none") return; + if (!e.state) { + showOverview(); + return; + } + if (e.state.view === "overview") showOverview(); + else if (e.state.view === "node" && e.state.nodeId) + drillDownToNode(e.state.nodeId, e.state.serverUrl); + else if (e.state.view === "filtered" && e.state.filter) { + currentFilter = e.state.filter; + if (currentFilter.state) drillDownByState(currentFilter.state); + else if (currentFilter.node) drillDownByNode(currentFilter.node); + } +}); + +// --- Keyboard shortcuts help --- +function showKbHelp() { + var existing = document.getElementById("kb-overlay"); + if (existing) { + existing.remove(); + } + var overlay = document.createElement("div"); + overlay.id = "kb-overlay"; + overlay.innerHTML = + '"; + overlay.onclick = function (e) { + if (e.target === overlay) hideKbHelp(); + }; + document.body.appendChild(overlay); + document.getElementById("kb-box").focus(); +} +function hideKbHelp() { + var el = document.getElementById("kb-overlay"); + if (el) el.remove(); +} +document.addEventListener("keydown", function (e) { + // Don't trigger when typing in inputs or when login overlay is open + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return; + var login = document.getElementById("login-overlay"); + if (login && login.style.display !== "none") return; + if (e.key === "?" && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + showKbHelp(); + } + if (e.key === "Escape") { + var kb = document.getElementById("kb-overlay"); + if (kb) { + e.preventDefault(); + hideKbHelp(); + } + } +}); + +// --- Init --- +history.replaceState({ view: "overview" }, ""); +initLogin(); +connectSSE(); +loadOverview(); +// Try loading — if auth required, login overlay will show diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html new file mode 100644 index 00000000..24d906af --- /dev/null +++ b/turnstone/console/static/index.html @@ -0,0 +1,83 @@ + + + + + +turnstone console + + + + + + + +
+ +
+
+
+
NODES
+ +
+
Loading cluster data...
+
+ +
+ + + + + + +
+ + + + diff --git a/turnstone/console/static/style.css b/turnstone/console/static/style.css new file mode 100644 index 00000000..25c4408b --- /dev/null +++ b/turnstone/console/static/style.css @@ -0,0 +1,205 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +:root { + --bg: #1a1b26; --bg-surface: #24283b; --bg-highlight: #292e42; + --fg: #c8d1f5; --fg-dim: #828db5; --fg-bright: #a9b1d6; + --accent: #7aa2f7; --green: #9ece6a; --red: #f7768e; + --yellow: #e0af68; --cyan: #7dcfff; --magenta: #bb9af7; + --border: #3b4261; --code-bg: #1f2335; + --radius: 8px; + --dash-grid: 72px 120px 100px 1fr 60px 48px; +} +[data-theme="light"] { + --bg: #f5f5f5; --bg-surface: #ffffff; --bg-highlight: #e8e8ec; + --fg: #1a1a2e; --fg-dim: #4b5563; --fg-bright: #374151; + --accent: #1d4ed8; --green: #15803d; --red: #b91c1c; + --yellow: #92400e; --cyan: #0e7490; --magenta: #7e22ce; + --border: #d1d5db; --code-bg: #eaeaef; +} +html, body { height: 100%; background: var(--bg); color: var(--fg); font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 14px; } +body { display: flex; flex-direction: column; } + +/* Header */ +#header { padding: 8px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; flex-shrink: 0; } +#header h1 { font-size: 16px; color: var(--accent); font-weight: 600; } +#cluster-summary { font-size: 12px; color: var(--fg-dim); } +#status-bar { font-size: 12px; color: var(--fg-dim); margin-left: auto; } +#status-bar.disconnected { color: var(--red); } + +/* Main content */ +#main { flex: 1; overflow-y: auto; padding: 16px; max-width: 1100px; margin: 0 auto; width: 100%; } + +/* Breadcrumb */ +.breadcrumb { padding: 8px 16px; font-size: 12px; color: var(--fg-dim); background: var(--bg-surface); border-bottom: 1px solid var(--border); } +.breadcrumb a { color: var(--accent); text-decoration: none; } +.breadcrumb a:hover { text-decoration: underline; } +.breadcrumb-sep { margin: 0 6px; color: var(--fg-dim); } + +/* State cards */ +.state-cards { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } +.state-card { flex: 1; min-width: 100px; background: var(--bg-surface); border: 1px solid var(--border); border-top: 3px solid var(--fg-dim); border-radius: var(--radius); padding: 12px 14px; cursor: pointer; transition: border-color 0.15s, background 0.15s; text-align: center; } +.state-card:hover { background: var(--bg-highlight); border-color: var(--accent); } +.state-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.state-card[data-state="running"] { border-top-color: var(--green); } +.state-card[data-state="thinking"] { border-top-color: var(--accent); } +.state-card[data-state="attention"] { border-top-color: var(--yellow); } +.state-card[data-state="idle"] { border-top-color: var(--fg-dim); } +.state-card[data-state="error"] { border-top-color: var(--red); } +.state-card-count { font-size: 24px; font-weight: bold; color: var(--fg-bright); margin-bottom: 2px; } +.state-card-label { font-size: 11px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.05em; } + +/* Aggregate bar */ +.aggregate-bar { font-size: 11px; color: var(--fg-dim); margin-bottom: 20px; } + +/* Section header */ +.section-header { font-size: 12px; font-weight: bold; color: var(--accent); letter-spacing: 0.05em; margin-bottom: 8px; } + +/* Node table */ +.node-colheaders { display: grid; grid-template-columns: 1fr 50px 50px 50px 70px 140px; padding: 4px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); font-size: 11px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.03em; position: sticky; top: 0; z-index: 10; } +.ncol-ws, .ncol-run, .ncol-attn, .ncol-tokens { text-align: right; } +.ncol-health, .node-cell-health { padding-left: 10px; } + +.node-row { display: grid; grid-template-columns: 1fr 50px 50px 50px 70px 140px; padding: 8px 16px; cursor: pointer; transition: background 0.15s; border-left: 3px solid transparent; } +.node-row:nth-child(odd) { background: var(--bg); } +.node-row:nth-child(even) { background: var(--bg-surface); } +.node-row:hover { background: var(--bg-highlight); box-shadow: inset 0 0 0 1px var(--border); } +.node-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.node-row.has-attention { border-left-color: var(--yellow); } +.node-row.has-running { border-left-color: var(--green); } +.node-row.has-thinking { border-left-color: var(--accent); } +.node-row.has-error { border-left-color: var(--red); } + +.node-cell { font-size: 12px; display: flex; align-items: center; } +.node-cell-name { color: var(--fg-bright); font-weight: bold; gap: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.node-cell-name .node-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); flex-shrink: 0; } +.node-cell-name .node-dot.unreachable { background: var(--red); } +.node-cell-num { color: var(--fg-dim); font-size: 11px; justify-content: flex-end; } +.node-cell-num.has-value { color: var(--fg-bright); } +.node-cell-health { gap: 6px; font-size: 11px; color: var(--fg-dim); } +.health-bar { width: 80px; height: 6px; background: var(--bg-highlight); border-radius: 3px; overflow: hidden; } +.health-bar-fill { display: block; height: 100%; min-width: 4px; border-radius: 3px; transition: width 0.3s; } +.health-bar-fill.low { background: var(--green); } +.health-bar-fill.mid { background: var(--yellow); } +.health-bar-fill.high { background: var(--red); } + +/* Dashboard table (reused from per-node dashboard) */ +.dash-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: var(--code-bg); border-radius: var(--radius) var(--radius) 0 0; } +.dash-header-title { color: var(--accent); font-size: 12px; font-weight: bold; letter-spacing: 0.05em; } +.dash-header-summary { color: var(--fg-dim); font-size: 11px; } + +.dash-colheaders { display: grid; grid-template-columns: var(--dash-grid); padding: 4px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); font-size: 11px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.03em; position: sticky; top: 0; z-index: 10; } +.dash-col-tokens, .dash-col-ctx { text-align: right; } + +.dash-table { min-height: 40px; } +.dash-row { position: relative; border-left: 3px solid transparent; cursor: pointer; transition: background 0.15s; } +.dash-row:nth-child(odd) { background: var(--bg); } +.dash-row:nth-child(even) { background: var(--bg-surface); } +.dash-row:hover { background: var(--bg-highlight); box-shadow: inset 0 0 0 1px var(--border); } +.dash-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.dash-row[data-state="running"] { border-left-color: var(--green); } +.dash-row[data-state="thinking"] { border-left-color: var(--accent); } +.dash-row[data-state="attention"] { border-left-color: var(--yellow); } +.dash-row[data-state="idle"] { border-left-color: var(--fg-dim); opacity: 0.7; } +.dash-row[data-state="error"] { border-left-color: var(--red); } +.dash-row-main { display: grid; grid-template-columns: var(--dash-grid); padding: 8px 16px 2px; align-items: center; font-size: 12px; } +.dash-row-sub { padding: 0 16px 8px 88px; font-size: 11px; color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dash-row-sub.sub-attention { color: var(--yellow); } + +.dash-cell-state { display: flex; align-items: center; gap: 6px; font-size: 11px; } +.dash-state-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } +.dash-state-dot[data-state="running"] { background: var(--green); border-radius: 2px; animation: pulse 2s infinite; } +.dash-state-dot[data-state="thinking"] { background: var(--accent); animation: pulse 2.2s infinite; } +.dash-state-dot[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); animation: pulse 1.8s infinite; } +.dash-state-dot[data-state="idle"] { background: var(--fg-dim); } +.dash-state-dot[data-state="error"] { background: var(--red); border-radius: 0; } +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + +.dash-state-label { white-space: nowrap; } +.dash-state-label[data-state="running"] { color: var(--green); } +.dash-state-label[data-state="thinking"] { color: var(--accent); } +.dash-state-label[data-state="attention"] { color: var(--yellow); } +.dash-state-label[data-state="idle"] { color: var(--fg-dim); } +.dash-state-label[data-state="error"] { color: var(--red); } + +.dash-cell-name { font-weight: bold; color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dash-row[data-state="idle"] .dash-cell-name { color: var(--fg-dim); } +.dash-cell-node { color: var(--accent); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.dash-cell-node:hover { text-decoration: underline; } +.dash-cell-task { color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dash-row[data-state="idle"] .dash-cell-task { color: var(--fg-dim); } +.dash-cell-tokens { text-align: right; color: var(--fg-dim); font-size: 11px; } +.dash-cell-ctx { text-align: right; font-size: 11px; } +.dash-cell-ctx.ctx-low { color: var(--green); } +.dash-cell-ctx.ctx-mid { color: var(--yellow); } +.dash-cell-ctx.ctx-high { color: var(--red); } +.dash-cell-ctx.ctx-danger { color: var(--red); font-weight: bold; } +.dash-cell-ctx.ctx-idle { color: var(--fg-dim); } + +/* Node link */ +.node-link { display: inline-block; margin-top: 12px; color: var(--accent); font-size: 12px; text-decoration: none; } +.node-link:hover { text-decoration: underline; } + +/* Pagination */ +.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 0; font-size: 12px; color: var(--fg-dim); } +.pagination button { background: var(--bg-surface); border: 1px solid var(--border); color: var(--fg-bright); border-radius: 4px; padding: 4px 10px; font: inherit; font-size: 12px; cursor: pointer; } +.pagination button:hover { background: var(--bg-highlight); } +.pagination button:disabled { opacity: 0.3; cursor: not-allowed; } + +/* Empty state */ +.dashboard-empty { color: var(--fg-dim); font-size: 13px; padding: 16px 0; text-align: center; } + +/* Focus */ +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Scrollbar */ +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .dash-state-dot[data-state="running"], + .dash-state-dot[data-state="thinking"], + .dash-state-dot[data-state="attention"] { animation: none; opacity: 1; } +} + +/* Responsive */ +@media (max-width: 700px) { + :root { --dash-grid: 68px 110px 1fr 56px 44px; } + .dash-col-node, .dash-cell-node { display: none; } + .node-colheaders, .node-row { grid-template-columns: 1fr 40px 40px 40px 60px; } + .ncol-health, .node-cell-health { display: none; } +} +@media (max-width: 480px) { + :root { --dash-grid: 50px 1fr 50px; } + .dash-col-node, .dash-cell-node, .dash-col-task, .dash-cell-task, .dash-col-ctx, .dash-cell-ctx { display: none; } + .state-cards { flex-wrap: nowrap; overflow-x: auto; gap: 8px; } + .state-card { min-width: 70px; flex: 0 0 auto; padding: 8px 10px; } + .state-card-count { font-size: 18px; } + .state-card-label { font-size: 10px; } +} + +/* Login overlay */ +#login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; } +#login-box { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; width: 320px; max-width: 90vw; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } +#login-box h2 { color: var(--accent); font-size: 16px; margin-bottom: 16px; } +#login-box input { width: 100%; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); font: inherit; font-size: 13px; margin-bottom: 12px; } +#login-box input:focus-visible { border-color: var(--accent); outline: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); } +#login-box input::placeholder { color: var(--fg-dim); } +#login-box button { width: 100%; padding: 12px; background: var(--accent); color: var(--bg); border: none; border-radius: 4px; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } +#login-box button:hover { opacity: 0.9; } +#login-box button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; } +#login-box button:disabled { opacity: 0.5; cursor: not-allowed; } +#login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +@media (max-width: 380px) { #login-box { padding: 24px 20px; } } + +/* Keyboard shortcuts overlay */ +#kb-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 999; } +#kb-box { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px 28px; width: 360px; max-width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } +#kb-box h2 { color: var(--accent); font-size: 14px; margin-bottom: 14px; } +.kb-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; } +.kb-key { color: var(--fg-bright); background: var(--bg-highlight); border: 1px solid var(--border); border-radius: 3px; padding: 1px 6px; font-family: inherit; font-size: 11px; white-space: nowrap; } +.kb-desc { color: var(--fg-dim); } +.kb-section { color: var(--fg-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 12px; margin-bottom: 4px; } +.kb-section:first-child { margin-top: 0; } +#kb-box .kb-hint { color: var(--fg-dim); font-size: 11px; text-align: center; margin-top: 14px; } diff --git a/turnstone/core/__init__.py b/turnstone/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py new file mode 100644 index 00000000..211fc8c0 --- /dev/null +++ b/turnstone/core/auth.py @@ -0,0 +1,232 @@ +"""Bearer token authentication and authorization for turnstone HTTP servers. + +Opt-in via the ``[auth]`` section in ``config.toml``. When auth is disabled +(the default), all requests pass through unchecked. When enabled, API +requests must include a valid ``Authorization: Bearer `` header or +a ``turnstone_auth`` cookie (set via the ``/api/auth/login`` endpoint). +Each token has a role: ``"read"`` or ``"full"``. + +Public paths (``/``, ``/static/*``, ``/health``, ``/metrics``, +``/api/auth/login``, ``/api/auth/logout``) are always accessible +without authentication. +""" + +from __future__ import annotations + +import hmac +import logging +import os +from dataclasses import dataclass, field + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Public / write path classification +# --------------------------------------------------------------------------- + +AUTH_COOKIE = "turnstone_auth" + +PUBLIC_PATHS: frozenset[str] = frozenset( + {"/", "/health", "/metrics", "/api/auth/login", "/api/auth/logout"} +) +PUBLIC_PREFIXES: tuple[str, ...] = ("/static/",) + +WRITE_PATHS: frozenset[str] = frozenset( + { + "/api/send", + "/api/approve", + "/api/plan", + "/api/command", + "/api/workstreams/new", + "/api/workstreams/close", + } +) + + +# --------------------------------------------------------------------------- +# AuthConfig +# --------------------------------------------------------------------------- + + +@dataclass +class AuthConfig: + """Auth configuration loaded once at startup (not modified after creation).""" + + enabled: bool = False + tokens: dict[str, str] = field(default_factory=dict) # token_value → role + + def check(self, token: str | None) -> str | None: + """Return the role (``"read"`` or ``"full"``) for a valid token, or *None*.""" + if not token: + return None + for known_token, role in self.tokens.items(): + if hmac.compare_digest(token, known_token): + return role + return None + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + + +def load_auth_config() -> AuthConfig: + """Build :class:`AuthConfig` from ``config.toml`` ``[auth]`` + env vars. + + Config format:: + + [auth] + enabled = true + + [[auth.tokens]] + value = "tok_abc123" + role = "full" + + Environment variable fallbacks: + + - ``TURNSTONE_AUTH_ENABLED=1`` — enables auth + - ``TURNSTONE_AUTH_TOKEN=`` — registers a single full-access token + """ + from turnstone.core.config import load_config + + auth_cfg = load_config("auth") + enabled = bool(auth_cfg.get("enabled", False)) + tokens: dict[str, str] = {} + + # Tokens from config file (TOML array-of-tables) + for entry in auth_cfg.get("tokens", []): + value = entry.get("value", "") if isinstance(entry, dict) else "" + role = entry.get("role", "read") if isinstance(entry, dict) else "" + if value and role in ("read", "full"): + tokens[value] = role + + # Environment variable fallbacks + if os.environ.get("TURNSTONE_AUTH_ENABLED", "").strip() in ("1", "true", "yes"): + enabled = True + + env_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "").strip() + if env_token: + tokens[env_token] = "full" + + if enabled and not tokens: + log.warning( + "Auth enabled but no tokens configured — all API requests will be rejected" + ) + + return AuthConfig(enabled=enabled, tokens=tokens) + + +# --------------------------------------------------------------------------- +# Path helpers +# --------------------------------------------------------------------------- + + +def is_public_path(path: str) -> bool: + """Return *True* if the path should be accessible without authentication.""" + if path in PUBLIC_PATHS: + return True + for prefix in PUBLIC_PREFIXES: + if path.startswith(prefix): + return True + return False + + +def required_role(method: str, path: str) -> str: + """Return the minimum role needed for *method* + *path*. + + Returns ``"full"`` for state-modifying POST endpoints, ``"read"`` otherwise. + """ + if method == "POST" and path in WRITE_PATHS: + return "full" + return "read" + + +# --------------------------------------------------------------------------- +# Request checking — single entry point for HTTP handlers +# --------------------------------------------------------------------------- + + +def check_request( + auth_config: AuthConfig, + method: str, + path: str, + auth_header: str | None, + cookie_header: str | None = None, +) -> tuple[bool, int, str]: + """Validate a request against the auth config. + + Checks ``Authorization: Bearer `` first, then falls back to the + ``turnstone_auth`` cookie (set by ``/api/auth/login``). + + Returns ``(allowed, status_code, message)``. + On success: ``(True, 200, "")``. + On failure: ``(False, 401|403, "error message")``. + """ + if not auth_config.enabled: + return True, 200, "" + + if is_public_path(path): + return True, 200, "" + + # Try Bearer header first, then cookie + token = _extract_bearer(auth_header) + if token is None: + token = _extract_cookie(cookie_header, AUTH_COOKIE) + role = auth_config.check(token) + + if role is None: + return False, 401, "Unauthorized: missing or invalid token" + + needed = required_role(method, path) + if needed == "full" and role != "full": + return False, 403, "Forbidden: read-only token cannot access this endpoint" + + return True, 200, "" + + +def _extract_bearer(header: str | None) -> str | None: + """Extract the token from ``Bearer `` header value.""" + if not header: + return None + parts = header.split(None, 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] + return None + + +def _extract_cookie(cookie_header: str | None, name: str) -> str | None: + """Extract a named value from a ``Cookie`` header. + + Assumes token values are simple ASCII (no URL-encoding). + """ + if not cookie_header: + return None + for pair in cookie_header.split(";"): + pair = pair.strip() + if "=" in pair: + k, v = pair.split("=", 1) + if k.strip() == name: + return v.strip() + return None + + +# --------------------------------------------------------------------------- +# Cookie helpers for login/logout endpoints +# --------------------------------------------------------------------------- + + +def make_set_cookie(token: str, max_age: int = 86400 * 30, secure: bool = False) -> str: + """Return a ``Set-Cookie`` header value that stores the auth token. + + Set *secure* to ``True`` when serving over HTTPS to add the ``Secure`` + flag (prevents cookie from being sent over plain HTTP). + """ + val = f"{AUTH_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}" + if secure: + val += "; Secure" + return val + + +def make_clear_cookie() -> str: + """Return a ``Set-Cookie`` header value that expires the auth cookie.""" + return f"{AUTH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" diff --git a/turnstone/core/config.py b/turnstone/core/config.py new file mode 100644 index 00000000..a02acaed --- /dev/null +++ b/turnstone/core/config.py @@ -0,0 +1,120 @@ +"""Unified configuration for turnstone. + +Loads ``~/.config/turnstone/config.toml`` and applies values as argparse defaults. +Precedence: CLI args > env vars > config file > hardcoded defaults. +""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path + +import tomllib + +log = logging.getLogger(__name__) + +CONFIG_DIR = Path("~/.config/turnstone").expanduser() +CONFIG_PATH = CONFIG_DIR / "config.toml" + +# Cache: None = not loaded yet, {} = loaded but empty/missing +_cache: dict | None = None + + +def load_config(section: str | None = None) -> dict: + """Load config.toml and return the full dict or a specific section. + + Returns empty dict if file doesn't exist or can't be parsed. + Result is cached after first call. + """ + global _cache + if _cache is None: + _cache = {} + if CONFIG_PATH.is_file(): + try: + _cache = tomllib.loads(CONFIG_PATH.read_text(encoding="utf-8")) + except Exception as exc: + log.warning("Failed to parse %s: %s", CONFIG_PATH, exc) + if section: + return _cache.get(section, {}) + return _cache + + +# --------------------------------------------------------------------------- +# Section → {config_key: argparse_dest} +# --------------------------------------------------------------------------- + +_CONFIG_MAP: dict[str, dict[str, str]] = { + "api": { + "base_url": "base_url", + "api_key": "api_key", + }, + "model": { + "name": "model", + "temperature": "temperature", + "max_tokens": "max_tokens", + "reasoning_effort": "reasoning_effort", + "context_window": "context_window", + }, + "session": { + "persona": "persona", + "instructions": "instructions", + "retention_days": "session_retention_days", + "compact_max_tokens": "compact_max_tokens", + "auto_compact_pct": "auto_compact_pct", + }, + "tools": { + "timeout": "tool_timeout", + "truncation": "tool_truncation", + "agent_max_turns": "agent_max_turns", + "skip_permissions": "skip_permissions", + }, + "server": { + "host": "host", + "port": "port", + "workstream_idle_timeout": "workstream_idle_timeout", + }, + "bridge": { + "server_url": "server_url", + "node_id": "node_id", + "approval_timeout": "approval_timeout", + "heartbeat_ttl": "heartbeat_ttl", + "log_level": "log_level", + }, + "redis": { + "host": "redis_host", + "port": "redis_port", + "password": "redis_password", + "db": "redis_db", + }, + "console": { + "host": "host", + "port": "port", + "url": "console_url", + "poll_interval": "poll_interval", + "log_level": "log_level", + }, + "auth": { + "token": "auth_token", + }, +} + + +def apply_config(parser: argparse.ArgumentParser, sections: list[str]) -> None: + """Set argparse defaults from config file. + + Only sets defaults for keys present in the config file. + Called before ``parse_args()`` so CLI flags still override. + """ + cfg = load_config() + if not cfg: + return + defaults: dict[str, object] = {} + for section in sections: + mapping = _CONFIG_MAP.get(section, {}) + section_data = cfg.get(section, {}) + for config_key, argparse_dest in mapping.items(): + if config_key in section_data: + defaults[argparse_dest] = section_data[config_key] + if defaults: + parser.set_defaults(**defaults) diff --git a/turnstone/core/edit.py b/turnstone/core/edit.py new file mode 100644 index 00000000..1749f9ef --- /dev/null +++ b/turnstone/core/edit.py @@ -0,0 +1,60 @@ +"""Edit helpers for precise string replacement in files.""" + + +def find_occurrences(content: str, old_string: str) -> list[int]: + """Return 1-based line numbers where each occurrence of old_string starts.""" + if not old_string: + return [] + # Build a prefix-sum of line starts for O(1) line-number lookup. + line_starts = [0] + for i, ch in enumerate(content): + if ch == "\n": + line_starts.append(i + 1) + results = [] + start = 0 + while True: + idx = content.find(old_string, start) + if idx == -1: + break + # bisect: find the line containing idx + lo, hi = 0, len(line_starts) - 1 + while lo < hi: + mid = (lo + hi + 1) // 2 + if line_starts[mid] <= idx: + lo = mid + else: + hi = mid - 1 + results.append(lo + 1) # 1-based + start = idx + 1 + return results + + +def pick_nearest(content: str, old_string: str, near_line: int) -> int: + """Return the char index of the occurrence of old_string nearest to near_line.""" + line_starts = [0] + for i, ch in enumerate(content): + if ch == "\n": + line_starts.append(i + 1) + + best_idx = -1 + best_dist = float("inf") + start = 0 + while True: + idx = content.find(old_string, start) + if idx == -1: + break + # Find line number for this occurrence + lo, hi = 0, len(line_starts) - 1 + while lo < hi: + mid = (lo + hi + 1) // 2 + if line_starts[mid] <= idx: + lo = mid + else: + hi = mid - 1 + line_num = lo + 1 + dist = abs(line_num - near_line) + if dist < best_dist: + best_dist = dist + best_idx = idx + start = idx + 1 + return best_idx diff --git a/turnstone/core/memory.py b/turnstone/core/memory.py new file mode 100644 index 00000000..9a83e884 --- /dev/null +++ b/turnstone/core/memory.py @@ -0,0 +1,541 @@ +"""SQLite database for persistent memories and conversation history.""" + +import os +import sqlite3 +from datetime import datetime, timedelta + +TURNSTONE_DB = os.path.join(os.getcwd(), ".turnstone.db") +db_override: str | None = None +db_initialized: set[str] = set() +_fts5_available: bool = False + +_tavily_key: str | None = None +_tavily_key_loaded: bool = False + + +def get_tavily_key() -> str | None: + """Load Tavily API key (cached after first call). + + Precedence: config.toml [api] tavily_key → $TAVILY_API_KEY + """ + global _tavily_key, _tavily_key_loaded + if _tavily_key_loaded: + return _tavily_key + _tavily_key_loaded = True + from turnstone.core.config import load_config + + cfg_key = load_config("api").get("tavily_key", "").strip() + if cfg_key: + _tavily_key = cfg_key + return _tavily_key + env_key = os.environ.get("TAVILY_API_KEY", "").strip() + if env_key: + _tavily_key = env_key + return _tavily_key + + +def open_db() -> sqlite3.Connection: + """Open the turnstone database, creating tables on first use per path.""" + global _fts5_available + path = db_override or TURNSTONE_DB + conn = sqlite3.connect(path) + if path not in db_initialized: + conn.execute( + "CREATE TABLE IF NOT EXISTS memories " + "(key TEXT PRIMARY KEY, value TEXT NOT NULL, " + "created TEXT NOT NULL, updated TEXT NOT NULL)" + ) + conn.execute( + "CREATE TABLE IF NOT EXISTS conversations " + "(id INTEGER PRIMARY KEY AUTOINCREMENT, " + "session_id TEXT NOT NULL, timestamp TEXT NOT NULL, " + "role TEXT NOT NULL, content TEXT, " + "tool_name TEXT, tool_args TEXT)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_conv_session ON conversations(session_id)" + ) + # Migration: add tool_call_id column if missing (for session resume) + try: + conn.execute("SELECT tool_call_id FROM conversations LIMIT 0") + except sqlite3.OperationalError: + conn.execute("ALTER TABLE conversations ADD COLUMN tool_call_id TEXT") + conn.commit() + # Sessions table — maps session_id to human-friendly alias/title + conn.execute( + "CREATE TABLE IF NOT EXISTS sessions " + "(session_id TEXT PRIMARY KEY, alias TEXT UNIQUE, " + "title TEXT, created TEXT NOT NULL, updated TEXT NOT NULL)" + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(alias)") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated)" + ) + try: + # Check if FTS table already exists + fts_exists = conn.execute( + "SELECT 1 FROM sqlite_master " + "WHERE type='table' AND name='conversations_fts'" + ).fetchone() + if not fts_exists: + conn.execute( + "CREATE VIRTUAL TABLE conversations_fts " + "USING fts5(content, content=conversations, content_rowid=id)" + ) + conn.execute( + "INSERT INTO conversations_fts(conversations_fts) VALUES('rebuild')" + ) + conn.commit() + _fts5_available = True + except Exception: + _fts5_available = False + db_initialized.add(path) + return conn + + +def normalize_key(key: str) -> str: + """Normalize a memory key for consistent lookup.""" + return key.lower().replace("-", "_").replace(" ", "_") + + +def load_memories() -> list[tuple[str, str]]: + """Return all (key, value) pairs sorted by key.""" + try: + conn = open_db() + try: + return conn.execute( + "SELECT key, value FROM memories ORDER BY key" + ).fetchall() + finally: + conn.close() + except Exception: + return [] + + +def save_message( + session_id: str, + role: str, + content: str | None, + tool_name: str | None = None, + tool_args: str | None = None, + tool_call_id: str | None = None, +) -> None: + """Log a message to the conversations table.""" + global _fts5_available + try: + conn = open_db() + try: + conn.execute( + "INSERT INTO conversations (session_id, timestamp, role, content, " + "tool_name, tool_args, tool_call_id) " + "VALUES (?, datetime('now'), ?, ?, ?, ?, ?)", + (session_id, role, content, tool_name, tool_args, tool_call_id), + ) + if _fts5_available and content: + try: + rowid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute( + "INSERT INTO conversations_fts(rowid, content) VALUES (?, ?)", + (rowid, content), + ) + except Exception: + _fts5_available = False # degrade to LIKE for rest of session + # Bump session updated timestamp + conn.execute( + "UPDATE sessions SET updated = datetime('now') WHERE session_id = ?", + (session_id,), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass # Don't let logging failures break the session + + +def escape_like(s: str) -> str: + """Escape LIKE metacharacters for use with ESCAPE '\\\\'.""" + return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def fts5_query(query: str) -> str: + """Convert a plain search string into a safe FTS5 query. + + Quotes each term so FTS5 special characters (*, -, etc.) are treated + as literals, then joins with implicit AND. Embedded double quotes + are doubled per FTS5 quoting convention. + """ + terms = query.split() + safe = [] + for t in terms: + if t: + safe.append(f'"{t.replace(chr(34), chr(34) + chr(34))}"') + return " ".join(safe) + + +def search_history(query: str, limit: int = 20) -> list[tuple]: + """Search conversation history. Returns (timestamp, session_id, role, content, tool_name).""" + if not query or not query.strip(): + return [] + try: + conn = open_db() + try: + if _fts5_available: + return conn.execute( + "SELECT c.timestamp, c.session_id, c.role, c.content, c.tool_name " + "FROM conversations_fts f " + "JOIN conversations c ON c.id = f.rowid " + "WHERE conversations_fts MATCH ? " + "ORDER BY f.rank ASC LIMIT ?", + (fts5_query(query), min(limit, 100)), + ).fetchall() + return conn.execute( + "SELECT timestamp, session_id, role, content, tool_name " + "FROM conversations WHERE content LIKE ? ESCAPE '\\' " + "ORDER BY timestamp DESC LIMIT ?", + (f"%{escape_like(query)}%", min(limit, 100)), + ).fetchall() + finally: + conn.close() + except Exception: + return [] + + +def search_history_recent(limit: int = 20) -> list[tuple]: + """Return most recent conversation messages.""" + try: + conn = open_db() + try: + return conn.execute( + "SELECT timestamp, session_id, role, content, tool_name " + "FROM conversations ORDER BY timestamp DESC LIMIT ?", + (min(limit, 100),), + ).fetchall() + finally: + conn.close() + except Exception: + return [] + + +# ── Session management ──────────────────────────────────────────────── + + +def register_session(session_id: str, title: str | None = None) -> None: + """Create a sessions row for a new session (no-op if already exists).""" + try: + conn = open_db() + try: + conn.execute( + "INSERT OR IGNORE INTO sessions " + "(session_id, title, created, updated) " + "VALUES (?, ?, datetime('now'), datetime('now'))", + (session_id, title), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass + + +def update_session_title(session_id: str, title: str) -> None: + """Set or update the auto-generated title for a session.""" + try: + conn = open_db() + try: + conn.execute( + "UPDATE sessions SET title = ? WHERE session_id = ?", + (title, session_id), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass + + +def set_session_alias(session_id: str, alias: str) -> bool: + """Set a human-friendly alias for a session. Returns False if alias is taken.""" + try: + conn = open_db() + try: + existing = conn.execute( + "SELECT session_id FROM sessions WHERE alias = ?", (alias,) + ).fetchone() + if existing and existing[0] != session_id: + return False + conn.execute( + "UPDATE sessions SET alias = ? WHERE session_id = ?", + (alias, session_id), + ) + conn.commit() + return True + finally: + conn.close() + except Exception: + return False + + +def get_session_name(session_id: str) -> str | None: + """Return the alias (or title if no alias) for a session, or None if unset.""" + try: + conn = open_db() + try: + row = conn.execute( + "SELECT alias, title FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + if row: + return row[0] or row[1] or None + finally: + conn.close() + except Exception: + pass + return None + + +def resolve_session(alias_or_id: str) -> str | None: + """Resolve an alias or session_id (or prefix) to a full session_id.""" + try: + conn = open_db() + try: + # 1. Exact alias match + row = conn.execute( + "SELECT session_id FROM sessions WHERE alias = ?", + (alias_or_id,), + ).fetchone() + if row: + return row[0] + # 2. Exact session_id match + row = conn.execute( + "SELECT session_id FROM sessions WHERE session_id = ?", + (alias_or_id,), + ).fetchone() + if row: + return row[0] + # 3. Session_id prefix match + rows = conn.execute( + "SELECT session_id FROM sessions WHERE session_id LIKE ?", + (alias_or_id + "%",), + ).fetchall() + if len(rows) == 1: + return rows[0][0] + # 4. Fallback: check conversations table for legacy sessions + row = conn.execute( + "SELECT DISTINCT session_id FROM conversations " + "WHERE session_id = ? LIMIT 1", + (alias_or_id,), + ).fetchone() + if row: + # Auto-register legacy session + conn.execute( + "INSERT OR IGNORE INTO sessions " + "(session_id, created, updated) VALUES (" + "?, " + "(SELECT MIN(timestamp) FROM conversations WHERE session_id = ?), " + "(SELECT MAX(timestamp) FROM conversations WHERE session_id = ?))", + (row[0], row[0], row[0]), + ) + conn.commit() + return row[0] + return None + finally: + conn.close() + except Exception: + return None + + +def prune_sessions( + retention_days: int = 90, + log_fn=None, +) -> tuple[int, int]: + """Prune orphaned and stale sessions. + + Removes: + - Sessions with no messages (orphaned registrations from process startup). + - Sessions whose ``updated`` timestamp is older than ``retention_days`` + **and** that have no alias (named sessions are kept indefinitely). + + Pass ``retention_days=0`` to skip age-based pruning (only orphans removed). + + Returns: + (orphans_removed, stale_removed) + """ + orphans = stale = 0 + try: + conn = open_db() + try: + # 1. Remove sessions that have no messages at all. + cur = conn.execute( + "DELETE FROM sessions " + "WHERE NOT EXISTS " + " (SELECT 1 FROM conversations c WHERE c.session_id = sessions.session_id)" + ) + orphans = cur.rowcount + + # 2. Remove old unnamed sessions. + if retention_days > 0: + cutoff = (datetime.utcnow() - timedelta(days=retention_days)).strftime( + "%Y-%m-%dT%H:%M:%S" + ) + cur = conn.execute( + "DELETE FROM sessions WHERE alias IS NULL AND updated < ?", + (cutoff,), + ) + stale = cur.rowcount + + conn.commit() + finally: + conn.close() + except Exception: + return (0, 0) + + if log_fn and (orphans or stale): + parts = [] + if orphans: + parts.append(f"{orphans} empty session{'s' if orphans != 1 else ''}") + if stale: + parts.append( + f"{stale} session{'s' if stale != 1 else ''} " + f"older than {retention_days} days" + ) + log_fn(f"[turnstone] Session cleanup: removed {', '.join(parts)}.") + + return (orphans, stale) + + +def list_sessions(limit: int = 20) -> list[tuple]: + """List recent sessions. + + Returns (session_id, alias, title, created, updated, msg_count) + ordered by updated DESC. + """ + try: + conn = open_db() + try: + return conn.execute( + "SELECT s.session_id, s.alias, s.title, s.created, s.updated, " + "(SELECT COUNT(*) FROM conversations c " + " WHERE c.session_id = s.session_id) " + "FROM sessions s " + "WHERE EXISTS " + " (SELECT 1 FROM conversations c WHERE c.session_id = s.session_id) " + "ORDER BY s.updated DESC LIMIT ?", + (limit,), + ).fetchall() + finally: + conn.close() + except Exception: + return [] + + +def load_session_messages(session_id: str) -> list[dict]: + """Load messages for a session and reconstruct OpenAI message format. + + Handles tool_call / tool_result rows by grouping consecutive tool_call + rows into one assistant message with tool_calls, then pairing subsequent + tool_result rows as tool messages. + """ + try: + conn = open_db() + try: + rows = conn.execute( + "SELECT role, content, tool_name, tool_args, tool_call_id " + "FROM conversations WHERE session_id = ? ORDER BY id", + (session_id,), + ).fetchall() + finally: + conn.close() + except Exception: + return [] + + messages: list[dict] = [] + i = 0 + while i < len(rows): + role, content, tool_name, tool_args, tc_id = rows[i] + + if role == "user": + messages.append({"role": "user", "content": content or ""}) + i += 1 + + elif role == "assistant": + messages.append({"role": "assistant", "content": content}) + i += 1 + + elif role == "tool_call": + # Collect consecutive tool_call rows into one assistant message. + # If the previous message was an assistant with content (text + + # tool calls in the same turn), merge tool_calls into it. + assistant_msg: dict = { + "role": "assistant", + "content": None, + "tool_calls": [], + } + if ( + messages + and messages[-1]["role"] == "assistant" + and not messages[-1].get("tool_calls") + ): + assistant_msg = messages.pop() + assistant_msg["tool_calls"] = [] + + while i < len(rows) and rows[i][0] == "tool_call": + _, _, tn, ta, stored_tc_id = rows[i] + call_id = stored_tc_id or f"call_{session_id}_{i}" + assistant_msg["tool_calls"].append( + { + "id": call_id, + "type": "function", + "function": {"name": tn or "", "arguments": ta or ""}, + } + ) + i += 1 + messages.append(assistant_msg) + + # Consume matching tool_result rows + result_idx = 0 + while i < len(rows) and rows[i][0] == "tool_result": + _, result_content, _, _, result_tc_id = rows[i] + if result_tc_id: + tc_id_to_use = result_tc_id + elif result_idx < len(assistant_msg["tool_calls"]): + tc_id_to_use = assistant_msg["tool_calls"][result_idx]["id"] + else: + tc_id_to_use = f"call_orphan_{i}" + messages.append( + { + "role": "tool", + "tool_call_id": tc_id_to_use, + "content": result_content or "", + } + ) + result_idx += 1 + i += 1 + + elif role == "tool_result": + # Orphaned tool_result (no preceding tool_call) — skip + i += 1 + else: + i += 1 + + return messages + + +def delete_session(session_id: str) -> bool: + """Delete a session and all its messages. Returns True on success.""" + try: + conn = open_db() + try: + conn.execute( + "DELETE FROM conversations WHERE session_id = ?", + (session_id,), + ) + conn.execute( + "DELETE FROM sessions WHERE session_id = ?", + (session_id,), + ) + conn.commit() + return True + finally: + conn.close() + except Exception: + return False diff --git a/turnstone/core/metrics.py b/turnstone/core/metrics.py new file mode 100644 index 00000000..d8af6ab6 --- /dev/null +++ b/turnstone/core/metrics.py @@ -0,0 +1,270 @@ +"""Thread-safe Prometheus-compatible metrics collector for the turnstone web server.""" + +import threading +import time +from collections import defaultdict + + +class MetricsCollector: + """Collects server metrics and generates Prometheus text exposition format.""" + + BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] + + def __init__(self): + self._lock = threading.Lock() + self.start_time = time.monotonic() + self.model: str = "" + # counters + self._req_total: dict = defaultdict(int) # (method, endpoint, status) -> int + self._tokens: dict = defaultdict(int) # ("prompt"|"completion") -> int + self._messages: int = 0 + self._tool_calls: dict = defaultdict(int) # tool_name -> int + self._errors: int = 0 + # histograms: (method, endpoint) -> {buckets: [count…], sum: float, count: int} + self._req_duration: dict = {} + # gauge + self._context_ratio: float = 0.0 + + def record_request(self, method: str, endpoint: str, status: int, duration: float): + with self._lock: + self._req_total[(method, endpoint, str(status))] += 1 + key = (method, endpoint) + if key not in self._req_duration: + self._req_duration[key] = { + "buckets": [0] * len(self.BUCKETS), + "sum": 0.0, + "count": 0, + } + h = self._req_duration[key] + for i, b in enumerate(self.BUCKETS): + if duration <= b: + h["buckets"][i] += 1 + h["sum"] += duration + h["count"] += 1 + + def record_tokens(self, prompt: int, completion: int): + with self._lock: + self._tokens["prompt"] += prompt + self._tokens["completion"] += completion + + def record_tool_call(self, tool_name: str): + with self._lock: + self._tool_calls[tool_name] += 1 + + def record_error(self): + with self._lock: + self._errors += 1 + + def record_message_sent(self): + with self._lock: + self._messages += 1 + + def record_context_ratio(self, ratio: float): + with self._lock: + self._context_ratio = ratio + + def generate_text( + self, + workstream_states: dict, + total_workstreams: int, + workstream_metrics: list[dict] | None = None, + ) -> str: + """Return Prometheus text exposition format (v0.0.4).""" + lines: list[str] = [] + + def gauge(name, help_text, value, labels=None): + lstr = _fmt_labels(labels) + lines.append(f"# HELP {name} {help_text}") + lines.append(f"# TYPE {name} gauge") + lines.append(f"{name}{lstr} {_fmt_value(value)}") + + def counter(name, help_text, value, labels=None): + lstr = _fmt_labels(labels) + lines.append(f"# HELP {name} {help_text}") + lines.append(f"# TYPE {name} counter") + lines.append(f"{name}{lstr} {_fmt_value(value)}") + + with self._lock: + uptime = time.monotonic() - self.start_time + model = self.model + req_total = dict(self._req_total) + tokens = dict(self._tokens) + messages = self._messages + tool_calls = dict(self._tool_calls) + errors = self._errors + req_duration = {k: dict(v) for k, v in self._req_duration.items()} + context_ratio = self._context_ratio + + # turnstone_build_info + lines.append("# HELP turnstone_build_info Server version and model info") + lines.append("# TYPE turnstone_build_info gauge") + lines.append(f'turnstone_build_info{{version="0.2.0",model="{model}"}} 1') + + # turnstone_uptime_seconds + gauge("turnstone_uptime_seconds", "Server uptime in seconds", uptime) + + # turnstone_workstreams_active_total + gauge( + "turnstone_workstreams_active_total", + "Number of active workstreams", + total_workstreams, + ) + + # turnstone_workstreams_by_state + lines.append("# HELP turnstone_workstreams_by_state Workstreams grouped by state") + lines.append("# TYPE turnstone_workstreams_by_state gauge") + for state, count in sorted(workstream_states.items()): + lines.append(f'turnstone_workstreams_by_state{{state="{state}"}} {count}') + + # turnstone_http_requests_total + lines.append("# HELP turnstone_http_requests_total Total HTTP requests handled") + lines.append("# TYPE turnstone_http_requests_total counter") + for (method, endpoint, status), count in sorted(req_total.items()): + lines.append( + f'turnstone_http_requests_total{{method="{method}",' + f'endpoint="{endpoint}",status_code="{status}"}} {count}' + ) + + # turnstone_http_request_duration_seconds (histogram) + lines.append( + "# HELP turnstone_http_request_duration_seconds HTTP request duration in seconds" + ) + lines.append("# TYPE turnstone_http_request_duration_seconds histogram") + for (method, endpoint), h in sorted(req_duration.items()): + prefix = ( + f'turnstone_http_request_duration_seconds{{method="{method}",' + f'endpoint="{endpoint}"' + ) + for i, b in enumerate(self.BUCKETS): + lines.append(f'{prefix},le="{b}"}} {h["buckets"][i]}') + lines.append(f'{prefix},le="+Inf"}} {h["count"]}') + lines.append( + f'turnstone_http_request_duration_seconds_sum{{method="{method}",' + f'endpoint="{endpoint}"}} {_fmt_value(h["sum"])}' + ) + lines.append( + f'turnstone_http_request_duration_seconds_count{{method="{method}",' + f'endpoint="{endpoint}"}} {h["count"]}' + ) + + # turnstone_messages_sent_total + counter("turnstone_messages_sent_total", "Total user messages sent to AI", messages) + + # turnstone_tokens_total + lines.append("# HELP turnstone_tokens_total Total tokens consumed") + lines.append("# TYPE turnstone_tokens_total counter") + for tok_type in ("prompt", "completion"): + lines.append( + f'turnstone_tokens_total{{type="{tok_type}"}} {tokens.get(tok_type, 0)}' + ) + + # turnstone_tool_calls_total + lines.append("# HELP turnstone_tool_calls_total Total tool executions by name") + lines.append("# TYPE turnstone_tool_calls_total counter") + for tool, count in sorted(tool_calls.items()): + lines.append(f'turnstone_tool_calls_total{{tool="{tool}"}} {count}') + + # turnstone_errors_total + counter("turnstone_errors_total", "Total errors reported by workstreams", errors) + + # turnstone_context_window_used_ratio + gauge( + "turnstone_context_window_used_ratio", + "Fraction of context window currently used (0.0 - 1.0)", + context_ratio, + ) + + # Per-workstream metrics (only when data is provided) + if workstream_metrics: + # turnstone_workstream_info — exposes session_id as a label for joining, + # without propagating that high-cardinality label to counters. + lines.append( + "# HELP turnstone_workstream_info Workstream metadata" + " (join on session_id for per-session queries)" + ) + lines.append("# TYPE turnstone_workstream_info gauge") + for wm in workstream_metrics: + lstr = _fmt_labels( + { + "ws_id": wm["ws_id"], + "name": wm["name"], + "session_id": wm["session_id"], + } + ) + lines.append(f"turnstone_workstream_info{lstr} 1") + + lines.append( + "# HELP turnstone_workstream_prompt_tokens_total" + " Prompt tokens consumed per workstream (lifetime of workstream)" + ) + lines.append("# TYPE turnstone_workstream_prompt_tokens_total counter") + for wm in workstream_metrics: + lstr = _fmt_labels({"ws_id": wm["ws_id"], "name": wm["name"]}) + lines.append( + f"turnstone_workstream_prompt_tokens_total{lstr} {wm['prompt_tokens']}" + ) + + lines.append( + "# HELP turnstone_workstream_completion_tokens_total" + " Completion tokens generated per workstream (lifetime of workstream)" + ) + lines.append("# TYPE turnstone_workstream_completion_tokens_total counter") + for wm in workstream_metrics: + lstr = _fmt_labels({"ws_id": wm["ws_id"], "name": wm["name"]}) + lines.append( + f"turnstone_workstream_completion_tokens_total{lstr}" + f" {wm['completion_tokens']}" + ) + + lines.append( + "# HELP turnstone_workstream_messages_total" + " User messages sent per workstream (lifetime of workstream)" + ) + lines.append("# TYPE turnstone_workstream_messages_total counter") + for wm in workstream_metrics: + lstr = _fmt_labels({"ws_id": wm["ws_id"], "name": wm["name"]}) + lines.append(f"turnstone_workstream_messages_total{lstr} {wm['messages']}") + + lines.append( + "# HELP turnstone_workstream_tool_calls_total" + " Tool executions per workstream per tool (lifetime of workstream)" + ) + lines.append("# TYPE turnstone_workstream_tool_calls_total counter") + for wm in workstream_metrics: + for tool, cnt in sorted(wm["tool_calls"].items()): + lstr = _fmt_labels( + {"ws_id": wm["ws_id"], "name": wm["name"], "tool": tool} + ) + lines.append(f"turnstone_workstream_tool_calls_total{lstr} {cnt}") + + lines.append( + "# HELP turnstone_workstream_context_ratio" + " Current context window utilisation per workstream (0.0-1.0)" + ) + lines.append("# TYPE turnstone_workstream_context_ratio gauge") + for wm in workstream_metrics: + lstr = _fmt_labels({"ws_id": wm["ws_id"], "name": wm["name"]}) + lines.append( + f"turnstone_workstream_context_ratio{lstr} {_fmt_value(wm['context_ratio'])}" + ) + + lines.append("") # trailing newline + return "\n".join(lines) + + +def _fmt_labels(labels: dict | None) -> str: + if not labels: + return "" + parts = [f'{k}="{v}"' for k, v in labels.items()] + return "{" + ",".join(parts) + "}" + + +def _fmt_value(v: float) -> str: + if isinstance(v, int): + return str(v) + # Use full precision but strip trailing zeros + return f"{v:.6g}" + + +# Module-level metrics instance — shared across all requests and WebUI instances. +metrics = MetricsCollector() diff --git a/turnstone/core/safety.py b/turnstone/core/safety.py new file mode 100644 index 00000000..f6b35480 --- /dev/null +++ b/turnstone/core/safety.py @@ -0,0 +1,41 @@ +"""Command safety guards — soft guardrails against destructive commands.""" + +# Soft guardrail — catches common accidental destructive commands but is +# trivially bypassable (e.g. extra spaces, shell variable expansion). +# The user approval prompt is the primary security boundary. +BLOCKED_PATTERNS = [ + "rm -rf /", + "rm -rf /*", + "mkfs", + "shutdown", + "reboot", + "halt", + "poweroff", + "dd if=", + ":(){ :|:& };:", # fork bomb + "> /dev/sda", + "mv / ", + "chmod -R 777 /", + "chown -R ", +] + + +def sanitize_command(cmd: str) -> str: + """Replace common unicode look-alikes that break the shell.""" + return ( + cmd.replace("\u2018", "'") # left single curly quote + .replace("\u2019", "'") # right single curly quote + .replace("\u201c", '"') # left double curly quote + .replace("\u201d", '"') # right double curly quote + .replace("\u2013", "-") # en dash + .replace("\u2014", "-") # em dash + ) + + +def is_command_blocked(cmd: str) -> str | None: + """Return reason string if command is blocked, None otherwise.""" + cmd_stripped = cmd.strip() + for pattern in BLOCKED_PATTERNS: + if pattern in cmd_stripped: + return f"Blocked: command matches dangerous pattern '{pattern}'" + return None diff --git a/turnstone/core/sandbox.py b/turnstone/core/sandbox.py new file mode 100644 index 00000000..6e639be9 --- /dev/null +++ b/turnstone/core/sandbox.py @@ -0,0 +1,308 @@ +"""Sandboxed Python executor for the math tool.""" + +import ast +import multiprocessing +import re +import traceback + +_MATH_BLOCKED_BUILTINS = { + "open", + "exec", + "eval", + "compile", + "input", + "breakpoint", + "memoryview", + "globals", + "locals", + "vars", +} + +_MATH_BLOCKED_MODULES = { + "os", + "sys", + "subprocess", + "shutil", + "pathlib", + "socket", + "http", + "urllib", + "requests", + "pickle", + "marshal", + "shelve", + "dbm", + "sqlite3", + "ctypes", + "multiprocessing", + "threading", + "asyncio", + "concurrent", + "signal", + "pty", + "tty", + "termios", + "fcntl", + "resource", + "syslog", + "tempfile", + "io", + "builtins", + "__builtin__", + "importlib", +} + + +class _ASTValidator(ast.NodeVisitor): + """Validates AST for dangerous constructs.""" + + def __init__(self): + self.errors: list[str] = [] + + def visit_Import(self, node): + for alias in node.names: + if alias.name.split(".")[0] in _MATH_BLOCKED_MODULES: + self.errors.append(f"Import of '{alias.name}' is not allowed") + self.generic_visit(node) + + def visit_ImportFrom(self, node): + if node.module and node.module.split(".")[0] in _MATH_BLOCKED_MODULES: + self.errors.append(f"Import from '{node.module}' is not allowed") + self.generic_visit(node) + + def visit_Call(self, node): + if isinstance(node.func, ast.Name) and node.func.id in _MATH_BLOCKED_BUILTINS: + self.errors.append(f"Call to '{node.func.id}' is not allowed") + self.generic_visit(node) + + def visit_Attribute(self, node): + if node.attr.startswith("__") and node.attr.endswith("__"): + if node.attr not in {"__name__", "__doc__", "__class__"}: + self.errors.append(f"Access to '{node.attr}' is not allowed") + self.generic_visit(node) + + +def validate_math_code(code: str) -> list[str]: + """Validate code for dangerous constructs. Returns list of errors.""" + try: + tree = ast.parse(code) + except SyntaxError as e: + lines = code.split("\n") + msg = f"Syntax error on line {e.lineno}: {e.msg}" + if e.lineno and e.lineno <= len(lines): + msg += f"\n {e.lineno}: {lines[e.lineno - 1]}" + if e.offset: + msg += f"\n {' ' * (e.offset - 1)}^" + return [msg] + except (ValueError, UnicodeError) as e: + return [f"Code contains invalid characters: {e}"] + v = _ASTValidator() + v.visit(tree) + return v.errors + + +def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue): + """Execute code in a subprocess, put (status, output) in queue.""" + import signal as _signal + import sys as _sys + from io import StringIO + + _signal.signal(_signal.SIGTERM, _signal.SIG_DFL) + _signal.signal(_signal.SIGINT, _signal.SIG_DFL) + _sys.set_int_max_str_digits(100_000) + + try: + captured = StringIO() + _sys.stdout = captured + + def _safe_import(name, *args, **kwargs): + if name.split(".")[0] in _MATH_BLOCKED_MODULES: + raise ImportError(f"Import of '{name}' is blocked") + return original_import(name, *args, **kwargs) + + original_import = ( + __builtins__["__import__"] + if isinstance(__builtins__, dict) + else __builtins__.__import__ + ) + safe_builtins = ( + {k: v for k, v in __builtins__.items() if k not in _MATH_BLOCKED_BUILTINS} + if isinstance(__builtins__, dict) + else { + k: getattr(__builtins__, k) + for k in dir(__builtins__) + if k not in _MATH_BLOCKED_BUILTINS and not k.startswith("_") + } + ) + safe_builtins["__import__"] = _safe_import + + # Pre-import safe modules + import math, fractions, itertools, functools, operator + import collections, decimal, random, re, string + + ns: dict = { + "__builtins__": safe_builtins, + "math": math, + "fractions": fractions, + "Fraction": fractions.Fraction, + "itertools": itertools, + "functools": functools, + "operator": operator, + "collections": collections, + "decimal": decimal, + "Decimal": decimal.Decimal, + "random": random, + "re": re, + "string": string, + } + + try: + import sympy + + ns["sympy"] = sympy + for name in ( + "symbols", + "Symbol", + "solve", + "simplify", + "expand", + "factor", + "Eq", + "sqrt", + "Rational", + "pi", + "E", + "I", + "oo", + "sin", + "cos", + "tan", + "exp", + "log", + "factorial", + "binomial", + "gcd", + "lcm", + "prime", + "isprime", + "factorint", + "divisors", + "totient", + "mod_inverse", + "Matrix", + "integrate", + "diff", + "limit", + "series", + "Sum", + "Product", + "floor", + "ceiling", + "Abs", + ): + ns[name] = getattr(sympy, name) + except ImportError: + pass + + try: + import numpy as _np + + ns["np"] = ns["numpy"] = _np + except ImportError: + pass + + try: + import scipy, scipy.special, scipy.optimize, scipy.integrate, scipy.linalg + + ns["scipy"] = scipy + ns["special"] = scipy.special + ns["optimize"] = scipy.optimize + ns["comb"] = scipy.special.comb + ns["perm"] = scipy.special.perm + ns["gamma"] = scipy.special.gamma + ns["beta"] = scipy.special.beta + except ImportError: + pass + + exec(code, ns) + + _sys.stdout = _sys.__stdout__ + printed = captured.getvalue() + result_var = ns.get("result") + if result_var is not None: + out = ( + f"{printed.rstrip()}\nresult = {result_var}" + if printed + else str(result_var) + ) + elif printed: + out = printed.rstrip() + else: + out = "No output. Add print() to see results." + result_queue.put(("success", out)) + + except Exception as e: + _sys.stdout = _sys.__stdout__ + result_queue.put( + ("error", f"{type(e).__name__}: {e}\n{traceback.format_exc()}") + ) + + +def auto_print_wrap(code: str) -> str: + """If code has no print/result and the last statement is an expression, wrap it in print().""" + # Skip if code already has print() or assigns to 'result' + if "print(" in code or re.search(r"\bresult\s*=", code): + return code + try: + tree = ast.parse(code) + except SyntaxError: + return code + if not tree.body: + return code + last = tree.body[-1] + if isinstance(last, ast.Expr): + # Get the source of the last expression and wrap in print() + lines = code.split("\n") + last_line_start = last.lineno - 1 # 0-based + last_line_end = last.end_lineno # 1-based, exclusive after slicing + expr_lines = lines[last_line_start:last_line_end] + expr_text = "\n".join(expr_lines) + prefix = lines[:last_line_start] + wrapped = prefix + [f"print({expr_text})"] + return "\n".join(wrapped) + return code + + +def execute_math_sandboxed(code: str, timeout: float = 30.0) -> tuple[str, bool]: + """Execute Python code in a sandboxed subprocess. Returns (output, is_error).""" + code = auto_print_wrap(code) + errors = validate_math_code(code) + if errors: + return "Validation errors:\n" + "\n".join(f"- {e}" for e in errors), True + + result_queue: multiprocessing.Queue = multiprocessing.Queue() + proc = multiprocessing.Process( + target=_math_exec_in_process, args=(code, result_queue) + ) + proc.start() + proc.join(timeout=timeout) + + if proc.is_alive(): + proc.terminate() + proc.join(timeout=1.0) + if proc.is_alive(): + proc.kill() + proc.join() + result_queue.close() + result_queue.join_thread() + return f"Execution timed out after {timeout}s", True + + if result_queue.empty(): + result_queue.close() + result_queue.join_thread() + return "Execution failed with no output", True + + status, output = result_queue.get() + result_queue.close() + result_queue.join_thread() + return output, status == "error" diff --git a/turnstone/core/session.py b/turnstone/core/session.py new file mode 100644 index 00000000..ae4b3489 --- /dev/null +++ b/turnstone/core/session.py @@ -0,0 +1,2743 @@ +"""Core chat session — UI-agnostic engine for multi-turn LLM interaction. + +The ChatSession class drives the conversation loop (send, stream, tool +execution) while delegating all user-facing I/O through the SessionUI +protocol. Any frontend (terminal, web, test harness) implements SessionUI +to receive events and handle approval prompts. +""" + +from __future__ import annotations + +import concurrent.futures +import ipaddress +import json +import os +import re +import socket +import subprocess +import tempfile +import textwrap +import threading +import time +import uuid +from typing import Protocol +from urllib.parse import urlparse + +import httpx + +from openai import OpenAI + +from turnstone.core.tools import ( + TOOLS, + AGENT_TOOLS, + TASK_AGENT_TOOLS, + AGENT_AUTO_TOOLS, + TASK_AUTO_TOOLS, + PRIMARY_KEY_MAP, +) +from turnstone.core.edit import find_occurrences, pick_nearest +from turnstone.core.sandbox import execute_math_sandboxed +from turnstone.core.safety import is_command_blocked, sanitize_command +from turnstone.core.web import strip_html, check_ssrf +from turnstone.core.memory import ( + open_db, + load_memories, + save_message, + normalize_key, + search_history, + search_history_recent, + get_tavily_key, + escape_like, + fts5_query, + register_session, + update_session_title, + set_session_alias, + resolve_session, + get_session_name, + list_sessions, + load_session_messages, + delete_session, +) +from turnstone.ui.colors import * # noqa: F401, F403 — ANSI constants and helpers + + +# --------------------------------------------------------------------------- +# SessionUI protocol — the contract every frontend must implement +# --------------------------------------------------------------------------- + + +class SessionUI(Protocol): + def on_thinking_start(self) -> None: ... + def on_thinking_stop(self) -> None: ... + def on_reasoning_token(self, text: str) -> None: ... + def on_content_token(self, text: str) -> None: ... + def on_stream_end(self) -> None: ... + def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ... + def on_tool_result(self, name: str, output: str) -> None: ... + def on_status(self, usage: dict, context_window: int, effort: str) -> None: ... + def on_plan_review(self, content: str) -> str: ... + def on_info(self, message: str) -> None: ... + def on_error(self, message: str) -> None: ... + def on_state_change(self, state: str) -> None: ... + def on_rename(self, name: str) -> None: ... + + +# --------------------------------------------------------------------------- +# ChatSession — the core engine +# --------------------------------------------------------------------------- + + +class ChatSession: + def __init__( + self, + client: OpenAI, + model: str, + ui: SessionUI, + persona: str | None, + instructions: str | None, + temperature: float, + max_tokens: int, + tool_timeout: int, + reasoning_effort: str = "medium", + context_window: int = 131072, + compact_max_tokens: int = 32768, + auto_compact_pct: float = 0.8, + agent_max_turns: int = -1, + tool_truncation: int = 0, + ): + self.client = client + self.model = model + self.ui = ui + self.persona = persona + self.instructions = instructions + self.temperature = temperature + self.max_tokens = max_tokens + self.tool_timeout = tool_timeout + self.reasoning_effort = reasoning_effort + self.context_window = context_window + self.compact_max_tokens = compact_max_tokens + self.auto_compact_pct = auto_compact_pct + self.agent_max_turns = agent_max_turns + self._chars_per_token = 4.0 # calibrated from API usage + # Tool output truncation: 0 means auto (50% of context_window in chars) + if tool_truncation > 0: + self.tool_truncation = tool_truncation + else: + self.tool_truncation = int(context_window * self._chars_per_token * 0.5) + self.show_reasoning = True + self.debug = False + self.auto_approve = False + self._session_id = uuid.uuid4().hex[:12] + self._title_generated = False + register_session(self._session_id) + self._read_files: set[str] = set() + self.messages: list[dict] = [] + self._last_usage: dict[str, int] | None = None + self._msg_tokens: list[int] = [] # parallel to self.messages + self._system_tokens = 0 # tokens for system_messages + self._assistant_pending_tokens = 0 + self.creative_mode = False + self._init_system_messages() + + @property + def session_id(self) -> str: + return self._session_id + + def _truncate_output(self, output: str) -> str: + """Truncate tool output to self.tool_truncation chars, keeping head + tail.""" + limit = self.tool_truncation + if len(output) <= limit: + return output + half = limit // 2 + omitted = len(output) - limit + return ( + output[:half] + + f"\n\n... [{omitted} chars truncated — output exceeded " + + f"{limit} char limit] ...\n\n" + + output[-half:] + ) + + def _generate_title(self): + """Generate a short title for this session via a background LLM call.""" + try: + # Gather first user message and first assistant reply + user_msg = "" + asst_msg = "" + for m in self.messages: + if m["role"] == "user" and not user_msg: + user_msg = (m.get("content") or "")[:300] + elif m["role"] == "assistant" and not asst_msg: + asst_msg = (m.get("content") or "")[:200] + if user_msg and asst_msg: + break + if not user_msg: + return + snippet = f"Generate a title for this conversation:\n\nUser: {user_msg}" + if asst_msg: + snippet += f"\nAssistant: {asst_msg}" + snippet += "\n\nTitle:" + response = self.client.chat.completions.create( + model=self.model, + messages=[ + { + "role": "developer", + "content": ( + "# Instructions\n\n" + "You are a conversation title generator. " + "The user will show you the opening of a conversation. " + "Respond with ONLY a short title (3-8 words). " + "Do NOT answer the conversation. Do NOT explain. " + "Output ONLY the title text, nothing else." + ), + }, + {"role": "user", "content": snippet}, + ], + max_completion_tokens=200, + temperature=0.3, + extra_body={ + "chat_template_kwargs": { + **self._chat_template_kwargs_base, + "reasoning_effort": "low", + } + }, + ) + raw = (response.choices[0].message.content or "").strip() + # Take first line, strip quotes + title = raw.split("\n")[0].strip().strip('"').strip("'") + if title: + update_session_title(self._session_id, title[:80]) + except Exception: + pass # Title generation is non-critical + + def resume_session(self, session_id: str) -> bool: + """Load messages from a previous session and resume it. + + Replaces the current conversation with the loaded messages, + adopting the old session_id so new messages continue in the same + session. Returns True on success. + """ + messages = load_session_messages(session_id) + if not messages: + return False + self._session_id = session_id + self.messages = messages + self._read_files.clear() + self._last_usage = None + self._title_generated = True # don't re-title resumed sessions + self._msg_tokens = [ + max(1, int(self._msg_char_count(m) / self._chars_per_token)) + for m in self.messages + ] + return True + + def _init_system_messages(self): + """Build the system/developer prefix messages. + + System message format matches training distribution: + Persona: X (optional) + Knowledge cutoff: X (from base model pretraining) + Current date: X (from base model pretraining) + Reasoning: X (low/medium/high) + # Valid channels: ... (dynamic based on tools/reasoning) + Calls to these tools... (only when tools present) + + Developer message uses # Instructions header when combined + with tool definitions (tool defs appended by chat template). + """ + from datetime import date + + self.system_messages = [] + today = date.today().strftime("%Y-%m-%d") # noqa: F841 + has_tools = not self.creative_mode # noqa: F841 + + # -- Chat template kwargs -- + self._chat_template_kwargs_base = { + "reasoning_effort": self.reasoning_effort, + } + self._chat_template_kwargs = dict(self._chat_template_kwargs_base) + if self.persona: + self._chat_template_kwargs["model_identity"] = f"Persona: {self.persona}" + + # -- Developer message -- + if self.creative_mode: + dev_parts = [ + "# Instructions", + "", + "You are a creative writing partner. Use the analysis channel to " + "think through structure, voice, and intent before drafting.", + "", + "Craft principles:", + "- Ground scenes in concrete sensory detail — what is seen, heard, felt.", + "- Vary rhythm. Short sentences hit hard. Longer ones carry the reader " + "through texture and nuance, building toward something.", + "- Dialogue should do at least two things: reveal character AND advance " + "plot or tension. Cut anything that's just exchanging information.", + "- Earn your abstractions. Don't say 'she felt sad' — show the thing " + "that makes the reader feel it.", + "- Trust subtext. Leave room for the reader.", + "", + "Match the user's genre and tone. If they want literary fiction, write " + "literary fiction. If they want pulp, write pulp with conviction. " + "Never condescend to the form.", + ] + else: + dev_parts = [ + "Always respond with tool calls, not just text.\n\n" + "TOOL PATTERNS:\n\n" + "Modify existing file → read_file then edit_file:\n" + " read_file(path='config.py') → " + "edit_file(path='config.py')\n\n" + "Create new file → write_file:\n" + " write_file(path='hello.py', content='...')\n\n" + "Find something across files → search:\n" + " search(query='test_')\n\n" + "Complex or multi-step task → plan first:\n" + " plan(prompt='refactor database from API')\n\n" + "Run a command, git, or tests → bash:\n" + " bash(command='git log -5')\n" + " bash(command='pytest')\n\n" + "Retrieve a URL → web_fetch:\n" + " web_fetch(url='https://example.com')\n\n" + "Look up documentation → man:\n" + " man(page='tar')", + ] + if self.instructions: + dev_parts.append("") + dev_parts.append(self.instructions) + memories = load_memories() + if memories: + dev_parts.append("") + dev_parts.append( + f"REMINDER: You currently have {len(memories)} memories stored. " + "Use recall to see them." + ) + self.system_messages.append( + {"role": "developer", "content": "\n".join(dev_parts)} + ) + # Agent prefix: system + developer only (no memories) + self._agent_system_messages = list(self.system_messages) + + def _full_messages(self) -> list[dict]: + """System messages + conversation history.""" + return self.system_messages + self.messages + + def _emit_state(self, state: str): + """Notify UI of a workstream state transition.""" + self.ui.on_state_change(state) + + # Transient error types that warrant automatic retry (checked by class name + # so we don't need to import backend-specific exception hierarchies). + _RETRYABLE_ERRORS = frozenset( + { + "RateLimitError", + "APITimeoutError", + "APIConnectionError", + "InternalServerError", + "ServiceUnavailableError", + "APIError", + } + ) + _MAX_RETRIES = 3 + _RETRY_BASE_DELAY = 1.0 # seconds + + def _create_stream_with_retry(self, msgs): + """Call chat.completions.create with retry on transient errors.""" + last_err = None + for attempt in range(self._MAX_RETRIES + 1): + try: + return self.client.chat.completions.create( + model=self.model, + messages=msgs, + **({"tools": TOOLS} if not self.creative_mode else {}), + max_completion_tokens=self.max_tokens, + temperature=self.temperature, + stream=True, + stream_options={"include_usage": True}, + extra_body={ + "chat_template_kwargs": self._chat_template_kwargs, + }, + ) + except Exception as e: + ename = type(e).__name__ + if ename not in self._RETRYABLE_ERRORS or attempt == self._MAX_RETRIES: + raise + last_err = e + delay = self._RETRY_BASE_DELAY * (2**attempt) + self.ui.on_info(f"[Retrying in {delay:.0f}s: {ename}]") + time.sleep(delay) + raise last_err # unreachable, but satisfies type checker + + def send(self, user_input: str): + """Send user input and handle the response loop (including tool calls).""" + self.messages.append({"role": "user", "content": user_input}) + self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token))) + save_message(self._session_id, "user", user_input) + + try: + while True: + msgs = self._full_messages() + + if self.debug: + self._debug_print_request(msgs) + + self._emit_state("thinking") + self.ui.on_thinking_start() + try: + stream = self._create_stream_with_retry(msgs) + assistant_msg = self._stream_response(stream) + finally: + self.ui.on_thinking_stop() + + self._update_token_table(assistant_msg) + self.messages.append(assistant_msg) + self._msg_tokens.append( + self._assistant_pending_tokens + or max( + 1, + int( + self._msg_char_count(assistant_msg) / self._chars_per_token + ), + ) + ) + + # Log assistant message to conversation history + content = assistant_msg.get("content", "") + tc = assistant_msg.get("tool_calls") + if content: + save_message(self._session_id, "assistant", content) + if tc: + for call in tc: + fn = call.get("function", {}) + name = fn.get("name", "") + if name not in ( + "remember", + "forget", + "recall", + ): + save_message( + self._session_id, + "tool_call", + None, + name, + fn.get("arguments", ""), + tool_call_id=call.get("id"), + ) + + tool_calls = assistant_msg.get("tool_calls") + if not tool_calls: + self._print_status_line() + # Auto-compact when prompt exceeds threshold + if ( + self._last_usage + and self._last_usage["prompt_tokens"] + > self.context_window * self.auto_compact_pct + ): + pct_display = int(self.auto_compact_pct * 100) + self.ui.on_info( + f"\n[Auto-compacting: prompt exceeds {pct_display}% of context window]" + ) + self._compact_messages(auto=True) + # Auto-title session after first exchange + if not self._title_generated: + self._title_generated = True + threading.Thread( + target=self._generate_title, daemon=True + ).start() + self._emit_state("idle") + break + + # Execute tool calls (potentially in parallel) + self._emit_state("running") + results, user_feedback = self._execute_tools(tool_calls) + # Map tool_call_id → tool name for logging + _tc_names = { + c["id"]: c.get("function", {}).get("name", "") for c in tool_calls + } + for tc_id, output in results: + tool_msg = { + "role": "tool", + "tool_call_id": tc_id, + "content": output, + } + self.messages.append(tool_msg) + self._msg_tokens.append( + max(1, int(len(output) / self._chars_per_token)) + ) + # Log tool result (skip memory tools to avoid noise) + _tname = _tc_names.get(tc_id, "") + if _tname not in ( + "remember", + "forget", + "recall", + ): + save_message( + self._session_id, + "tool_result", + output[:2000], + _tname, + tool_call_id=tc_id, + ) + # Inject user feedback from approval prompt (e.g. "y, use full path") + if user_feedback: + self.messages.append({"role": "user", "content": user_feedback}) + self._msg_tokens.append( + max(1, int(len(user_feedback) / self._chars_per_token)) + ) + except KeyboardInterrupt: + # Remove any partial tool results, then the originating assistant + # message with unanswered tool_calls — keep _msg_tokens in sync + while self.messages and self.messages[-1]["role"] == "tool": + self.messages.pop() + if self._msg_tokens: + self._msg_tokens.pop() + while ( + self.messages + and self.messages[-1]["role"] == "assistant" + and self.messages[-1].get("tool_calls") + ): + self.messages.pop() + if self._msg_tokens: + self._msg_tokens.pop() + self._emit_state("error") + raise + except Exception: + self._emit_state("error") + raise + + @staticmethod + def _strip_reasoning(text: str) -> str: + """Remove / tags and their content.""" + for open_t, close_t in [ + ("", ""), + ("", ""), + ]: + while open_t in text: + start = text.find(open_t) + end = text.find(close_t, start) + if end != -1: + text = text[:start] + text[end + len(close_t) :] + else: + text = text[:start] + return text.strip() + + # Tags that delimit reasoning blocks in content stream. + # Checked in order; first match wins. + _THINK_OPEN_TAGS = ("", "") + _THINK_CLOSE_TAGS = ("", "") + _MAX_TAG_LEN = max(len(t) for t in _THINK_OPEN_TAGS + _THINK_CLOSE_TAGS) + + def _stream_response(self, stream) -> dict: + """Stream response, dispatching tokens to the UI as they arrive. + + Handles two reasoning delivery mechanisms: + 1. vLLM's `reasoning_content` field (when --reasoning-parser is set) + 2. ... tags in regular content (common default) + + Calls self.ui.on_thinking_stop() on the first received delta. + + Returns the complete assistant message as a dict suitable for + appending to self.messages. + """ + content_parts: list[str] = [] + reasoning_parts: list[str] = [] + tool_calls_acc: dict[int, dict] = {} + first_token = True + in_think = False # inside a ... block + path1_reasoning = False # last reasoning came via reasoning_content field + pending = "" # buffer for partial tag detection + + def _flush_text(text: str, is_reasoning: bool): + """Dispatch text to the appropriate UI callback.""" + if not text: + return + if is_reasoning: + reasoning_parts.append(text) + if self.show_reasoning: + self.ui.on_reasoning_token(text) + else: + content_parts.append(text) + self.ui.on_content_token(text) + + def _drain_pending(): + """Process the pending buffer, flushing content and detecting tags.""" + nonlocal pending, in_think + + while pending: + if in_think: + # Look for any close tag + best_idx, best_tag = None, None + for tag in self._THINK_CLOSE_TAGS: + idx = pending.find(tag) + if idx != -1 and (best_idx is None or idx < best_idx): + best_idx, best_tag = idx, tag + + if best_idx is not None: + _flush_text(pending[:best_idx], True) + pending = pending[best_idx + len(best_tag) :] + in_think = False + continue + + # No close tag found — check if tail could be a partial tag + safe = len(pending) - self._MAX_TAG_LEN + if safe > 0: + _flush_text(pending[:safe], True) + pending = pending[safe:] + break + else: + # Look for any open tag + best_idx, best_tag = None, None + for tag in self._THINK_OPEN_TAGS: + idx = pending.find(tag) + if idx != -1 and (best_idx is None or idx < best_idx): + best_idx, best_tag = idx, tag + + if best_idx is not None: + _flush_text(pending[:best_idx], False) + pending = pending[best_idx + len(best_tag) :] + in_think = True + continue + + # No open tag found — flush all but potential partial tag + safe = len(pending) - self._MAX_TAG_LEN + if safe > 0: + _flush_text(pending[:safe], False) + pending = pending[safe:] + break + + def _stop_spinner_once(): + """Stop the spinner on first real content. Call is idempotent.""" + nonlocal first_token + if first_token: + self.ui.on_thinking_stop() + first_token = False + + finish_reason = None + for chunk in stream: + # Track finish_reason (e.g. "stop", "length", "tool_calls") + if chunk.choices and chunk.choices[0].finish_reason: + finish_reason = chunk.choices[0].finish_reason + + # Capture usage from final chunk (stream_options.include_usage) + if hasattr(chunk, "usage") and chunk.usage is not None: + u = chunk.usage + pt = getattr(u, "prompt_tokens", None) + ct = getattr(u, "completion_tokens", None) + tt = getattr(u, "total_tokens", None) + if pt is not None and ct is not None: + self._last_usage = { + "prompt_tokens": pt, + "completion_tokens": ct, + "total_tokens": tt or (pt + ct), + } + if not chunk.choices: + continue + delta = chunk.choices[0].delta + + if self.debug: + extras = dict(delta.model_extra) if delta.model_extra else {} + parts = [] + if delta.role: + parts.append(f"role={delta.role}") + if delta.content: + parts.append(f"content={delta.content!r}") + if delta.tool_calls: + parts.append(f"tool_calls=...") + for k, v in extras.items(): + if v is not None: + parts.append(f"{k}={v!r}") + if parts: + self.ui.on_info(f"{GRAY}[delta: {', '.join(parts)}]{RESET}") + + # Path 1: reasoning field (vLLM sends as "reasoning" or "reasoning_content") + rc = getattr(delta, "reasoning", None) or getattr( + delta, "reasoning_content", None + ) + if rc: + _stop_spinner_once() + reasoning_parts.append(rc) + in_think = True + path1_reasoning = True + if self.show_reasoning: + self.ui.on_reasoning_token(rc) + continue + + # Path 2: regular content (may contain tags) + if delta.content: + _stop_spinner_once() + # Close reasoning if transitioning from Path 1 reasoning + if path1_reasoning: + path1_reasoning = False + in_think = False + pending += delta.content + _drain_pending() + + # Handle tool call deltas + if delta.tool_calls: + _stop_spinner_once() + # Close reasoning if transitioning from reasoning + if in_think: + in_think = False + for tc_delta in delta.tool_calls: + idx = tc_delta.index + if idx not in tool_calls_acc: + tool_calls_acc[idx] = { + "id": "", + "type": "function", + "function": {"name": "", "arguments": ""}, + } + tc = tool_calls_acc[idx] + if tc_delta.id: + tc["id"] = tc_delta.id + if tc_delta.function: + if tc_delta.function.name: + tc["function"]["name"] = tc_delta.function.name + if tc_delta.function.arguments: + tc["function"]["arguments"] += tc_delta.function.arguments + + # Flush any remaining buffered text + if pending: + _flush_text(pending, in_think) + + # Warn on non-standard finish reasons + if finish_reason == "length": + self.ui.on_error( + f"Warning: response truncated (hit {self.max_tokens} token limit). " + f"Use --max-tokens to increase, or /compact to free context." + ) + # Drop partial tool calls — they'll have malformed JSON + if tool_calls_acc: + self.ui.on_error( + "Discarding partial tool calls from truncated response." + ) + tool_calls_acc.clear() + elif finish_reason == "content_filter": + self.ui.on_error("Warning: response blocked by content filter.") + + # Signal end of stream to the UI + self.ui.on_stream_end() + + # Build assistant message dict + msg: dict = {"role": "assistant"} + + content = "".join(content_parts) + if content: + msg["content"] = content + else: + msg["content"] = None + + if tool_calls_acc: + msg["tool_calls"] = [tool_calls_acc[i] for i in sorted(tool_calls_acc)] + + return msg + + _print_lock = threading.Lock() + + # -- Debug ---------------------------------------------------------------- + + def _debug_print_request(self, msgs: list[dict]): + """Print the full API request payload when debug mode is on.""" + lines = [] + lines.append(f"\n{GRAY}{'=' * 60}{RESET}") + lines.append( + f"{GRAY}[request] model={self.model} " + f"max_tokens={self.max_tokens} temp={self.temperature} " + f"reasoning={self.reasoning_effort} " + f"tools={0 if self.creative_mode else len(TOOLS)}{RESET}" + ) + lines.append(f"{GRAY}[request] {len(msgs)} messages:{RESET}") + for i, m in enumerate(msgs): + role = m["role"] + content = m.get("content") or "" + tool_calls = m.get("tool_calls") + tc_id = m.get("tool_call_id") + + # Truncate long content for readability + if len(content) > 300: + display = ( + content[:200] + f"...({len(content)} chars)..." + content[-50:] + ) + else: + display = content + # Escape newlines for compact display + display = display.replace("\n", "\\n") + + header = f" [{i}] {role}" + if tc_id: + header += f" (tool_call_id={tc_id})" + + lines.append(f"{GRAY}{header}: {display}{RESET}") + + if tool_calls: + for tc in tool_calls: + name = tc.get("function", {}).get("name", "?") + args = tc.get("function", {}).get("arguments", "") + if len(args) > 200: + args = args[:150] + f"...({len(args)} chars)" + lines.append(f"{GRAY} -> {name}({args}){RESET}") + + lines.append(f"{GRAY}{'=' * 60}{RESET}") + self.ui.on_info("\n".join(lines)) + + # -- Token tracking & status ---------------------------------------------- + + def _msg_char_count(self, msg: dict) -> int: + """Count characters in a message, including tool call arguments.""" + n = len(msg.get("content") or "") + for tc in msg.get("tool_calls", []): + n += len(tc.get("function", {}).get("name", "")) + n += len(tc.get("function", {}).get("arguments", "")) + return n + + def _update_token_table(self, assistant_msg: dict): + """Update per-message token estimates using API usage data.""" + if not self._last_usage: + return + + prompt_tok = self._last_usage["prompt_tokens"] + compl_tok = self._last_usage["completion_tokens"] + + # Calibrate chars_per_token ratio from actual usage. + all_msgs = self._full_messages() # system + self.messages (before append) + tool_def_chars = sum(len(json.dumps(t)) for t in TOOLS) + total_chars = sum(self._msg_char_count(m) for m in all_msgs) + tool_def_chars + if total_chars > 0 and prompt_tok > 0: + self._chars_per_token = total_chars / prompt_tok + + # Compute system_tokens (stable after first call) + sys_chars = sum(self._msg_char_count(m) for m in self.system_messages) + self._system_tokens = max(1, int(sys_chars / self._chars_per_token)) + + # Re-estimate all message token counts with calibrated ratio + self._msg_tokens = [ + max(1, int(self._msg_char_count(m) / self._chars_per_token)) + for m in self.messages + ] + + # Stash completion_tokens for the assistant message about to be appended + self._assistant_pending_tokens = compl_tok + + def _print_status_line(self): + """Emit status info via the UI.""" + if not self._last_usage: + return + self.ui.on_status(self._last_usage, self.context_window, self.reasoning_effort) + + # -- Conversation compaction ------------------------------------------------ + + def _format_messages_for_summary(self, messages: list[dict]) -> str: + """Format messages into a readable string for the summarization prompt.""" + # Build tool_call_id → tool_name lookup for labeling tool results + tc_names: dict[str, str] = {} + for m in messages: + for tc in m.get("tool_calls", []): + tc_id = tc.get("id", "") + tc_name = tc.get("function", {}).get("name", "unknown") + if tc_id: + tc_names[tc_id] = tc_name + + parts = [] + for m in messages: + role = m["role"].upper() + content = m.get("content") or "" + + if m.get("tool_calls"): + calls = [] + for tc in m["tool_calls"]: + name = tc.get("function", {}).get("name", "?") + args = tc.get("function", {}).get("arguments", "") + calls.append(f"{name}({args})") + content += "\n[Called: " + ", ".join(calls) + "]" + + # Label tool results with the tool name + if role == "TOOL": + tc_id = m.get("tool_call_id", "") + name = tc_names.get(tc_id, "tool") + role = f"TOOL[{name}]" + + if content: + if len(content) > 2000: + content = content[:1000] + "\n...[truncated]...\n" + content[-500:] + parts.append(f"{role}: {content}") + return "\n\n".join(parts) + + def _compact_messages(self, auto: bool = False): + """Compact conversation history by summarizing all messages. + + Summarizes the entire conversation via a separate model call, + budget-fitted to 80% of the context window. + + When auto=True (triggered by context limit), appends a continuation + hint with the last user message so the model can resume seamlessly. + """ + if len(self.messages) < 2: + self.ui.on_info("Not enough messages to compact.") + return + + # Find the last user message for the continuation hint + last_user_content = None + if auto: + for m in reversed(self.messages): + if m["role"] == "user": + last_user_content = m.get("content") or "" + break + + to_summarize = self.messages + + # Budget: fit as many messages as possible into summary request + summary_max_tokens = self.compact_max_tokens + prompt_budget = ( + int(self.context_window * self.auto_compact_pct) + - summary_max_tokens + - self._system_tokens + ) + selected = [] + running = 0 + for i, msg in enumerate(to_summarize): + msg_tok = ( + self._msg_tokens[i] + if i < len(self._msg_tokens) + else max(1, int(self._msg_char_count(msg) / self._chars_per_token)) + ) + if running + msg_tok > prompt_budget: + break + selected.append(msg) + running += msg_tok + + if not selected: + self.ui.on_info("Messages too large to fit in summary context.") + return + + # Build summary prompt and call model + formatted = self._format_messages_for_summary(selected) + summary_msgs = [ + { + "role": "developer", + "content": ( + "# Conversation Compactor\n\n" + "Your output REPLACES the conversation history — the assistant " + "will continue from your summary with no access to the original messages.\n\n" + "1. **Output format** — use these exact sections, omit any that are empty:\n" + " - **## Decisions**: Choices made (architecture, libraries, approaches).\n" + " - **## Files**: Files read, created, or modified, with brief notes.\n" + " - **## Key code**: Exact function names, class names, variable names, " + "and short code snippets the assistant will need. " + "Preserve identifiers verbatim — do NOT paraphrase.\n" + " - **## Tool results**: Important tool outputs (errors, search matches, " + "file contents) that inform ongoing work.\n" + " - **## Open tasks**: What the user asked for that is not yet done, " + "with enough context to continue.\n" + " - **## User preferences**: Workflow preferences, constraints, or " + "instructions the user stated.\n\n" + "2. **Density rules:**\n" + " - Every token should carry information.\n" + " - Preserve exact paths, identifiers, and numbers — never paraphrase these.\n" + " - Omit pleasantries, acknowledgments, and reasoning that led to dead ends.\n" + " - If a tool call's result was an error that was later resolved, " + "keep only the resolution.\n\n" + "3. **Common mistakes to avoid:**\n" + " - Paraphrasing file paths, function names, or variable names\n" + " - Including dead-end explorations or superseded decisions\n" + " - Omitting the open tasks section when work remains\n" + " - Being verbose — this is a summary, not a transcript" + ), + }, + { + "role": "user", + "content": ("Compact the following conversation:\n\n" + formatted), + }, + ] + + self.ui.on_thinking_start() + try: + last_err = None + for attempt in range(self._MAX_RETRIES + 1): + try: + response = self.client.chat.completions.create( + model=self.model, + messages=summary_msgs, + max_completion_tokens=summary_max_tokens, + temperature=0.3, + stream=False, + extra_body={ + "chat_template_kwargs": { + **self._chat_template_kwargs_base, + "reasoning_effort": "low", + } + }, + ) + break + except Exception as e: + ename = type(e).__name__ + if ( + ename not in self._RETRYABLE_ERRORS + or attempt == self._MAX_RETRIES + ): + raise + last_err = e + delay = self._RETRY_BASE_DELAY * (2**attempt) + self.ui.on_info(f"[Compact retrying in {delay:.0f}s: {ename}]") + time.sleep(delay) + choice = response.choices[0] + summary = choice.message.content or "" + # Strip any / tags the summarizer may emit + summary = self._strip_reasoning(summary) + if choice.finish_reason == "length": + self.ui.on_info("[Warning: compaction summary was truncated]") + except Exception as e: + self.ui.on_error(f"Compaction failed: {e}") + return + finally: + self.ui.on_thinking_stop() + + # Append continuation hint for auto-compact + if last_user_content: + # Truncate very long user messages + if len(last_user_content) > 500: + last_user_content = last_user_content[:400] + "..." + summary += ( + f"\n\n## Continue\n" + f"The user's last message was: {last_user_content}\n" + f"Continue assisting from where we left off." + ) + + # Replace messages + before_tokens = self._system_tokens + sum(self._msg_tokens) + summary_user = {"role": "user", "content": "[Conversation summary]"} + summary_asst = {"role": "assistant", "content": summary} + self.messages = [summary_user, summary_asst] + # File contents are gone after compaction — force re-read before edit_file + self._read_files.clear() + + # Rebuild token table + su_tok = max(1, int(self._msg_char_count(summary_user) / self._chars_per_token)) + sa_tok = max(1, int(self._msg_char_count(summary_asst) / self._chars_per_token)) + self._msg_tokens = [su_tok, sa_tok] + after_tokens = self._system_tokens + sum(self._msg_tokens) + + self.ui.on_info(f"[compacted: ~{before_tokens:,} -> ~{after_tokens:,} tokens]") + separator = "\u2500" * 60 + lines = [separator] + for line in summary.splitlines(): + lines.append(f" {line}") + lines.append(separator) + self.ui.on_info("\n".join(lines)) + + # -- Two-phase tool execution ----------------------------------------------- + # + # Phase 1 — prepare: parse args, validate, build preview text (serial) + # Phase 2 — approve: display all previews, single prompt (serial) + # Phase 3 — execute: run approved tools (parallel if multiple) + + def _execute_tools( + self, tool_calls: list[dict] + ) -> tuple[list[tuple[str, str]], str | None]: + """Execute tool calls with batch preview and approval. + + Returns (results, user_feedback) where user_feedback is an optional + message the user typed alongside their approval (e.g. "y, use full path"). + """ + # Phase 1: prepare all tool calls + items = [self._prepare_tool(tc) for tc in tool_calls] + + # Phase 2: approve via UI + self._emit_state("attention") + approved, user_feedback = self.ui.approve_tools(items) + self._emit_state("running") + if not approved: + # Mark all pending items as denied + for item in items: + if item.get("needs_approval") and not item.get("error"): + item["denied"] = True + item["denial_msg"] = user_feedback or "Denied by user" + user_feedback = None # feedback is in the denial_msg + + # Phase 3: execute + def run_one(item: dict) -> tuple[str, str]: + if item.get("error"): + return item["call_id"], item["error"] + if item.get("denied"): + return item["call_id"], item.get("denial_msg", "Denied by user") + return item["execute"](item) + + if len(items) == 1: + results = [run_one(items[0])] + else: + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(run_one, items)) + + # Post-plan gate: prompt user on main thread after plan completes + for i, item in enumerate(items): + if ( + item.get("func_name") == "plan" + and not item.get("error") + and not item.get("denied") + and not self.auto_approve + ): + cid, output = results[i] + # Let the UI present the plan for review + self._emit_state("attention") + resp = self.ui.on_plan_review(output) + self._emit_state("running") + if resp.lower() in ("n", "no", "reject"): + output += ( + "\n\n---\nUser REJECTED this plan. Do not proceed " + "with implementation. Ask the user what they want instead." + ) + elif resp: + output += f"\n\n---\nUser feedback on this plan: {resp}" + results[i] = (cid, output) + + return results, user_feedback + + def _prepare_tool(self, tc: dict) -> dict: + """Parse a tool call and prepare preview info for display.""" + call_id = tc["id"] + func_name = tc["function"]["name"] + raw_args = tc["function"]["arguments"] + + try: + args = json.loads(raw_args) + except json.JSONDecodeError as exc: + args = None + # Fallback 1: regex-extract a known key from malformed JSON + for key in ( + "command", + "code", + "content", + "page", + "path", + "pattern", + "prompt", + "query", + "url", + ): + m = re.search(rf'"{key}"\s*:\s*"((?:[^"\\]|\\.)*)"', raw_args) + if m: + try: + val = json.loads('"' + m.group(1) + '"') + except (json.JSONDecodeError, Exception): + val = m.group(1) + args = {key: val} + break + # Fallback 2: bare string (no JSON wrapper at all) + if ( + args is None + and raw_args.strip() + and not raw_args.strip().startswith("{") + ): + pk = PRIMARY_KEY_MAP.get(func_name) + if pk: + args = {pk: raw_args} + if args is None: + preview = raw_args[:4000] + ("..." if len(raw_args) > 4000 else "") + return { + "call_id": call_id, + "func_name": func_name, + "header": f"\u2717 {func_name}: {exc}", + "preview": f" {RED}{preview}{RESET}", + "needs_approval": False, + "error": f"JSON parse error: {exc}\nRaw arguments: {raw_args[:500]}", + } + + preparers = { + "bash": self._prepare_bash, + "read_file": self._prepare_read_file, + "search": self._prepare_search, + "write_file": self._prepare_write_file, + "edit_file": self._prepare_edit_file, + "math": self._prepare_math, + "man": self._prepare_man, + "web_fetch": self._prepare_web_fetch, + "web_search": self._prepare_web_search, + "task": self._prepare_task, + "plan": self._prepare_plan, + "remember": self._prepare_remember, + "recall": self._prepare_recall, + "forget": self._prepare_forget, + } + preparer = preparers.get(func_name) + if not preparer: + return { + "call_id": call_id, + "func_name": func_name, + "header": f"\u2717 Unknown tool: {func_name}", + "preview": "", + "needs_approval": False, + "error": f"Unknown tool: {func_name}", + } + return preparer(call_id, args) + + # -- Prepare methods (build preview, validate, no side effects) ------------ + + def _prepare_bash(self, call_id: str, args: dict) -> dict: + command = sanitize_command(args.get("command", "")) + if not command: + return { + "call_id": call_id, + "func_name": "bash", + "header": "\u2717 bash: empty command", + "preview": "", + "needs_approval": False, + "error": "Error: empty command", + } + blocked = is_command_blocked(command) + if blocked: + return { + "call_id": call_id, + "func_name": "bash", + "header": f"\u2717 {blocked}", + "preview": "", + "needs_approval": False, + "error": blocked, + } + display_cmd = command.split("\n")[0] + if "\n" in command: + display_cmd += f" ... ({command.count(chr(10))} more lines)" + return { + "call_id": call_id, + "func_name": "bash", + "header": f"\u2699 bash: {display_cmd}", + "preview": "", + "needs_approval": True, + "approval_label": "bash", + "execute": self._exec_bash, + "command": command, + } + + def _prepare_read_file(self, call_id: str, args: dict) -> dict: + path = args.get("path", "") + if not path: + return { + "call_id": call_id, + "func_name": "read_file", + "header": "\u2717 read_file: missing path", + "preview": "", + "needs_approval": False, + "error": "Error: missing path", + } + path = os.path.expanduser(path) + resolved = os.path.realpath(path) + offset = args.get("offset") # 1-based line number, or None + limit = args.get("limit") # max lines, or None + # Coerce to int safely (model may send strings or floats) + try: + if offset is not None: + offset = int(offset) + if limit is not None: + limit = int(limit) + except (ValueError, TypeError): + return { + "call_id": call_id, + "func_name": "read_file", + "header": "\u2717 read_file: invalid offset/limit", + "preview": "", + "needs_approval": False, + "error": ( + f"Error: offset/limit must be integers " + f"(got offset={args.get('offset')!r}, " + f"limit={args.get('limit')!r})" + ), + } + if offset is not None and offset < 1: + return { + "call_id": call_id, + "func_name": "read_file", + "header": "\u2717 read_file: offset must be >= 1", + "preview": "", + "needs_approval": False, + "error": f"Error: offset must be >= 1 (got {offset})", + } + if limit is not None and limit < 1: + return { + "call_id": call_id, + "func_name": "read_file", + "header": "\u2717 read_file: limit must be >= 1", + "preview": "", + "needs_approval": False, + "error": f"Error: limit must be >= 1 (got {limit})", + } + # Register early so a same-batch edit_file can pass the read guard. + self._read_files.add(resolved) + # Build header showing range if specified + header = f"\u2699 read_file: {path}" + if offset is not None or limit is not None: + start = offset or 1 + if limit is not None: + header += f" (lines {start}-{start + limit - 1})" + else: + header += f" (from line {start})" + return { + "call_id": call_id, + "func_name": "read_file", + "header": header, + "preview": "", + "needs_approval": False, + "execute": self._exec_read_file, + "path": path, + "offset": offset, + "limit": limit, + } + + def _prepare_search(self, call_id: str, args: dict) -> dict: + pattern = args.get("query", "") + if not pattern: + return { + "call_id": call_id, + "func_name": "search", + "header": "\u2717 search: missing query", + "preview": "", + "needs_approval": False, + "error": "Error: missing query", + } + path = os.path.expanduser(args.get("path", "") or ".") + preview = f" {DIM}/{pattern}/ in {path}{RESET}" + return { + "call_id": call_id, + "func_name": "search", + "header": f"\u2699 search: /{pattern}/ in {path}", + "preview": preview, + "needs_approval": False, + "execute": self._exec_search, + "pattern": pattern, + "path": path, + } + + def _prepare_write_file(self, call_id: str, args: dict) -> dict: + path = args.get("path", "") + content = args.get("content", "") + if not path: + return { + "call_id": call_id, + "func_name": "write_file", + "header": "\u2717 write_file: missing path", + "preview": "", + "needs_approval": False, + "error": "Error: missing path", + } + path = os.path.expanduser(path) + resolved = os.path.realpath(path) + exists = os.path.exists(resolved) + is_overwrite = exists and resolved not in self._read_files + + # Build preview + preview_parts = [] + if is_overwrite: + preview_parts.append( + f" {YELLOW}Warning: overwriting existing file not previously read{RESET}" + ) + text = content[:500] + if len(content) > 500: + text += f"\n... ({len(content)} chars total)" + preview_parts.append(f"{DIM}{textwrap.indent(text, ' ')}{RESET}") + + return { + "call_id": call_id, + "func_name": "write_file", + "header": f"\u2699 write_file: {path} ({len(content)} chars)", + "preview": "\n".join(preview_parts), + "needs_approval": True, + "approval_label": "overwrite_file" if is_overwrite else "write_file", + "execute": self._exec_write_file, + "path": path, + "resolved": resolved, + "content": content, + } + + def _prepare_edit_file(self, call_id: str, args: dict) -> dict: + path = args.get("path", "") + old_string = args.get("old_string", "") + new_string = args.get("new_string", "") + near_line = args.get("near_line") + if isinstance(near_line, str): + try: + near_line = int(near_line) + except ValueError: + near_line = None + if not path: + return { + "call_id": call_id, + "func_name": "edit_file", + "header": "\u2717 edit_file: missing path", + "preview": "", + "needs_approval": False, + "error": "Error: missing path", + } + if not old_string: + return { + "call_id": call_id, + "func_name": "edit_file", + "header": "\u2717 edit_file: missing old_string", + "preview": "", + "needs_approval": False, + "error": "Error: missing old_string", + } + path = os.path.expanduser(path) + resolved = os.path.realpath(path) + + if resolved not in self._read_files: + return { + "call_id": call_id, + "func_name": "edit_file", + "header": f"\u2717 edit_file: {path}", + "preview": "", + "needs_approval": False, + "error": f"Error: must read_file {path} before editing it", + } + + # Pre-read to validate and build diff preview + try: + with open(path, "r") as f: + content = f.read() + occurrences = find_occurrences(content, old_string) + if len(occurrences) == 0: + return { + "call_id": call_id, + "func_name": "edit_file", + "header": f"\u2717 edit_file: {path}", + "preview": "", + "needs_approval": False, + "error": f"Error: old_string not found in {path}", + } + if len(occurrences) > 1 and near_line is None: + line_list = ", ".join(str(ln) for ln in occurrences) + return { + "call_id": call_id, + "func_name": "edit_file", + "header": f"\u2717 edit_file: {path}", + "preview": "", + "needs_approval": False, + "error": ( + f"Error: old_string found {len(occurrences)} times " + f"at lines {line_list} — use near_line to pick one" + ), + } + except FileNotFoundError: + return { + "call_id": call_id, + "func_name": "edit_file", + "header": f"\u2717 edit_file: {path}", + "preview": "", + "needs_approval": False, + "error": f"Error: {path} not found", + } + except Exception as e: + return { + "call_id": call_id, + "func_name": "edit_file", + "header": f"\u2717 edit_file: {path}", + "preview": "", + "needs_approval": False, + "error": f"Error editing {path}: {e}", + } + + # Build diff preview + preview_parts = [] + old_preview = old_string[:200] + ("..." if len(old_string) > 200 else "") + new_preview = new_string[:200] + ("..." if len(new_string) > 200 else "") + for line in old_preview.splitlines(): + preview_parts.append(f" {RED}- {line}{RESET}") + if new_string: + for line in new_preview.splitlines(): + preview_parts.append(f" {GREEN}+ {line}{RESET}") + else: + preview_parts.append( + f" {YELLOW}(deletion — {len(old_string)} chars removed){RESET}" + ) + + return { + "call_id": call_id, + "func_name": "edit_file", + "header": f"\u2699 edit_file: {path}", + "preview": "\n".join(preview_parts), + "needs_approval": True, + "approval_label": "edit_file", + "execute": self._exec_edit_file, + "path": path, + "resolved": resolved, + "old_string": old_string, + "new_string": new_string, + "near_line": near_line, + } + + def _prepare_math(self, call_id: str, args: dict) -> dict: + code = args.get("code", "") + if isinstance(code, list): + code = "\n".join(code) + if not code: + return { + "call_id": call_id, + "func_name": "math", + "header": "\u2717 math: empty code", + "preview": "", + "needs_approval": False, + "error": "Error: no code provided", + } + # Show code preview + display = code[:300] + if len(code) > 300: + display += f"\n... ({len(code)} chars total)" + preview = f"{DIM}{textwrap.indent(display, ' ')}{RESET}" + return { + "call_id": call_id, + "func_name": "math", + "header": f"\u2699 math: ({len(code)} chars)", + "preview": preview, + "needs_approval": True, + "approval_label": "math", + "execute": self._exec_math, + "code": code, + } + + def _prepare_man(self, call_id: str, args: dict) -> dict: + """Prepare a man/info page lookup.""" + page = (args.get("page") or "").strip() + if not page: + return { + "call_id": call_id, + "func_name": "man", + "header": "\u2717 man: empty page", + "preview": "", + "needs_approval": False, + "error": "Error: no page name provided", + } + # Sanitize: only allow alphanumeric, dash, underscore, dot + if not re.match(r"^[a-zA-Z0-9._-]+$", page): + return { + "call_id": call_id, + "func_name": "man", + "header": "\u2717 man: invalid page name", + "preview": f" {RED}{page}{RESET}", + "needs_approval": False, + "error": f"Error: invalid page name {page!r}", + } + section = (args.get("section") or "").strip() + if section and not re.match(r"^[1-9][a-z]?$", section): + section = "" + label = f"{page}({section})" if section else page + preview = f" {DIM}{label}{RESET}" + return { + "call_id": call_id, + "func_name": "man", + "header": f"\u2699 man: {label}", + "preview": preview, + "needs_approval": False, + "execute": self._exec_man, + "page": page, + "section": section, + } + + def _prepare_web_fetch(self, call_id: str, args: dict) -> dict: + url = args.get("url", "").strip() + question = args.get("question", "").strip() + if not url: + return { + "call_id": call_id, + "func_name": "web_fetch", + "header": "\u2717 web_fetch: empty url", + "preview": "", + "needs_approval": False, + "error": "Error: no URL provided", + } + if not question: + return { + "call_id": call_id, + "func_name": "web_fetch", + "header": "\u2717 web_fetch: empty question", + "preview": "", + "needs_approval": False, + "error": "Error: no question provided", + } + if not url.startswith(("http://", "https://")): + return { + "call_id": call_id, + "func_name": "web_fetch", + "header": "\u2717 web_fetch: invalid url", + "preview": f" {RED}{url}{RESET}", + "needs_approval": False, + "error": f"Error: URL must start with http:// or https:// (got {url!r})", + } + # SSRF protection: reject private/link-local/metadata IPs + ssrf_err = check_ssrf(url) + if ssrf_err: + return { + "call_id": call_id, + "func_name": "web_fetch", + "header": "\u2717 web_fetch: blocked (private network)", + "preview": f" {RED}{url}{RESET}", + "needs_approval": False, + "error": f"Error: {ssrf_err}", + } + q_preview = question[:200] + ("..." if len(question) > 200 else "") + preview = f" {DIM}{url}\n Q: {q_preview}{RESET}" + return { + "call_id": call_id, + "func_name": "web_fetch", + "header": f"\u2699 web_fetch: {url[:80]}", + "preview": preview, + "needs_approval": True, + "approval_label": "web_fetch", + "execute": self._exec_web_fetch, + "url": url, + "question": question, + } + + def _prepare_web_search(self, call_id: str, args: dict) -> dict: + """Prepare a web search via Tavily for approval.""" + query = (args.get("query") or "").strip() + if not query: + return { + "call_id": call_id, + "func_name": "web_search", + "header": "\u2717 web_search: empty query", + "preview": "", + "needs_approval": False, + "error": "Error: no query provided", + } + if not get_tavily_key(): + return { + "call_id": call_id, + "func_name": "web_search", + "header": "\u2717 web_search: no API key", + "preview": "", + "needs_approval": False, + "error": ( + "Error: Tavily API key not configured. " + "Set it in ~/.config/turnstone/tavily_key or $TAVILY_API_KEY. " + "Use web_fetch with a direct URL as an alternative." + ), + } + try: + max_results = min(max(int(args.get("max_results") or 5), 1), 20) + except (ValueError, TypeError): + max_results = 5 + topic = args.get("topic", "general") or "general" + if topic not in ("general", "news", "finance"): + topic = "general" + q_preview = query[:200] + ("..." if len(query) > 200 else "") + preview = f" {DIM}{q_preview}{RESET}" + return { + "call_id": call_id, + "func_name": "web_search", + "header": f"\u2699 web_search: {query[:80]}", + "preview": preview, + "needs_approval": True, + "approval_label": "web_search", + "execute": self._exec_web_search, + "query": query, + "max_results": max_results, + "topic": topic, + } + + def _prepare_task(self, call_id: str, args: dict) -> dict: + """Prepare a general-purpose sub-agent task for approval.""" + prompt = (args.get("prompt") or "").strip() + if not prompt: + return { + "call_id": call_id, + "func_name": "task", + "header": "\u2717 task: empty prompt", + "preview": "", + "needs_approval": False, + "error": "Error: empty prompt", + } + preview_text = prompt[:300] + ("..." if len(prompt) > 300 else "") + return { + "call_id": call_id, + "func_name": "task", + "header": "\u2699 task (autonomous agent)", + "preview": f" {DIM}{preview_text}{RESET}", + "needs_approval": True, + "approval_label": "task", + "execute": self._exec_task, + "prompt": prompt, + } + + def _prepare_plan(self, call_id: str, args: dict) -> dict: + """Prepare a planning agent for approval.""" + prompt = (args.get("prompt") or "").strip() + if not prompt: + return { + "call_id": call_id, + "func_name": "plan", + "header": "\u2717 plan: empty prompt", + "preview": "", + "needs_approval": False, + "error": "Error: empty prompt", + } + preview_text = prompt[:300] + ("..." if len(prompt) > 300 else "") + return { + "call_id": call_id, + "func_name": "plan", + "header": "\u2699 plan (planning agent)", + "preview": f" {DIM}{preview_text}{RESET}", + "needs_approval": True, + "approval_label": "plan", + "execute": self._exec_plan, + "prompt": prompt, + } + + def _prepare_remember(self, call_id: str, args: dict) -> dict: + """Prepare a remember (save memory) action.""" + key = normalize_key((args.get("key") or "").strip()) + value = (args.get("value") or "").strip() + if not key or not value: + return { + "call_id": call_id, + "func_name": "remember", + "header": "\u2717 remember: requires key and value", + "preview": "", + "needs_approval": False, + "error": "Error: both 'key' and 'value' are required", + } + return { + "call_id": call_id, + "func_name": "remember", + "header": f"\u2699 remember: {key}", + "preview": "", + "needs_approval": False, + "execute": self._exec_remember, + "key": key, + "value": value, + } + + def _prepare_forget(self, call_id: str, args: dict) -> dict: + """Prepare a forget (delete memory) action.""" + key = normalize_key((args.get("key") or "").strip()) + if not key: + return { + "call_id": call_id, + "func_name": "forget", + "header": "\u2717 forget: empty key", + "preview": "", + "needs_approval": False, + "error": "Error: key is required", + } + return { + "call_id": call_id, + "func_name": "forget", + "header": f"\u2699 forget: {key}", + "preview": "", + "needs_approval": False, + "execute": self._exec_forget, + "key": key, + } + + def _prepare_recall(self, call_id: str, args: dict) -> dict: + """Prepare a recall action.""" + query = (args.get("query") or "").strip() + limit = args.get("limit", 20) + if isinstance(limit, str): + try: + limit = int(limit) + except ValueError: + limit = 20 + return { + "call_id": call_id, + "func_name": "recall", + "header": f"\u2699 recall{': ' + query[:80] if query else ''}", + "preview": "", + "needs_approval": False, + "execute": self._exec_recall, + "query": query, + "limit": min(limit, 50), + } + + # -- Execute methods (do the work, report output via UI) ------------------- + + def _exec_bash(self, item: dict) -> tuple[str, str]: + """Execute a bash command via temp script.""" + call_id, command = item["call_id"], item["command"] + try: + with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f: + f.write(command) + script_path = f.name + try: + result = subprocess.run( + ["bash", script_path], + capture_output=True, + text=True, + timeout=self.tool_timeout, + ) + finally: + os.unlink(script_path) + output = result.stdout + if result.stderr: + output += ("\n" if output else "") + result.stderr + output = output.strip() + output = self._truncate_output(output) + + self.ui.on_tool_result("bash", output) + + if result.returncode != 0: + output += f"\n[exit code: {result.returncode}]" + + return call_id, output if output else "(no output)" + + except subprocess.TimeoutExpired: + msg = f"Command timed out after {self.tool_timeout}s" + self.ui.on_error(msg) + return call_id, msg + except Exception as e: + msg = f"Error executing command: {e}" + self.ui.on_error(msg) + return call_id, msg + + def _exec_read_file(self, item: dict) -> tuple[str, str]: + """Read a file and return numbered lines, optionally sliced.""" + call_id, path = item["call_id"], item["path"] + offset = item.get("offset") # 1-based, or None + limit = item.get("limit") # max lines, or None + resolved = os.path.realpath(path) + + try: + with open(path, "r") as f: + all_lines = f.readlines() + except FileNotFoundError: + self._read_files.discard(resolved) + return call_id, f"Error: {path} not found" + except Exception as e: + self._read_files.discard(resolved) + return call_id, f"Error reading {path}: {e}" + + self._read_files.add(resolved) + total_lines = len(all_lines) + + # Slice if offset/limit specified + start = max(1, offset or 1) + if limit is not None: + lines = all_lines[start - 1 : start - 1 + limit] + else: + lines = all_lines[start - 1 :] + + numbered = [] + for i, line in enumerate(lines, start=start): + numbered.append(f"{i:>4}\t{line.rstrip()}") + output = "\n".join(numbered) + output = self._truncate_output(output) + + desc = f"{len(lines)} lines" + if offset is not None or limit is not None: + end = start + len(lines) - 1 + desc += f" (lines {start}-{end} of {total_lines})" + self.ui.on_tool_result("read_file", desc) + + return call_id, output if output else "(empty file)" + + def _exec_search(self, item: dict) -> tuple[str, str]: + """Search file contents for a regex pattern using grep.""" + call_id = item["call_id"] + pattern, path = item["pattern"], item["path"] + try: + result = subprocess.run( + [ + "grep", + "-rn", + "-I", + "-E", + "-m", + "200", # max matches per file + "--color=never", # no ANSI codes in output + "--", + pattern, + path, # -- prevents pattern as flag + ], + capture_output=True, + text=True, + timeout=self.tool_timeout, + ) + output = result.stdout.strip() + if result.returncode == 1: + output = "(no matches)" + elif result.returncode > 1: + output = ( + result.stderr.strip() or f"grep error (exit {result.returncode})" + ) + + # Count matches BEFORE truncation + match_count = ( + output.count("\n") + 1 if result.returncode == 0 and output else 0 + ) + + original_len = len(output) + output = self._truncate_output(output) + + desc = f"{match_count} matches" if match_count else "no matches" + if original_len > 500: + desc += f" ({original_len} chars)" + self.ui.on_tool_result("search", desc) + + return call_id, output + + except subprocess.TimeoutExpired: + msg = f"Search timed out after {self.tool_timeout}s" + self.ui.on_error(msg) + return call_id, msg + except Exception as e: + msg = f"Search error: {e}" + self.ui.on_error(msg) + return call_id, msg + + # Tools the agent can auto-execute without user approval (read-only). + _AGENT_AUTO_TOOLS = AGENT_AUTO_TOOLS + _TASK_AUTO_TOOLS = TASK_AUTO_TOOLS + + def _run_agent( + self, + agent_messages: list[dict], + label: str = "agent", + tools: list[dict] | None = None, + auto_tools: set[str] | None = None, + reasoning_effort: str | None = None, + model_identity: str | None = None, + ) -> str: + """Run an autonomous agent loop. + + Args: + agent_messages: Pre-built message list (system + developer + user). + label: Display prefix for progress lines ("agent" or "plan"). + tools: Tool definitions to send to the API. Defaults to AGENT_TOOLS (read-only). + auto_tools: Set of tool names the agent may execute. Defaults to _AGENT_AUTO_TOOLS. + reasoning_effort: Override reasoning effort for this agent. + model_identity: Optional persona/identity string passed via chat_template_kwargs. + + Returns: + Final content string from the agent. + """ + if tools is None: + tools = AGENT_TOOLS + if auto_tools is None: + auto_tools = self._AGENT_AUTO_TOOLS + max_tool_turns = self.agent_max_turns + + kwargs = dict(self._chat_template_kwargs_base) + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort + if model_identity: + kwargs["model_identity"] = model_identity + + def _api_call(messages, _tools=tools): + last_err = None + for attempt in range(self._MAX_RETRIES + 1): + try: + return self.client.chat.completions.create( + model=self.model, + messages=messages, + tools=_tools, + max_completion_tokens=self.max_tokens, + temperature=self.temperature, + extra_body={ + "chat_template_kwargs": kwargs, + }, + ) + except Exception as e: + ename = type(e).__name__ + if ( + ename not in self._RETRYABLE_ERRORS + or attempt == self._MAX_RETRIES + ): + raise + last_err = e + delay = self._RETRY_BASE_DELAY * (2**attempt) + self.ui.on_info(f"[{label} retrying in {delay:.0f}s: {ename}]") + time.sleep(delay) + raise last_err # unreachable + + turn = 0 + while max_tool_turns < 0 or turn < max_tool_turns: + response = _api_call(agent_messages) + choice = response.choices[0] + assistant_msg = choice.message + + # Handle truncation or content filter — stop agent early + if choice.finish_reason == "length": + self.ui.on_info(f"[{label}] response truncated, stopping early") + return assistant_msg.content or "(truncated)" + if choice.finish_reason == "content_filter": + self.ui.on_info(f"[{label}] blocked by content filter") + return "(content filter)" + + # Build message dict for agent history + msg_dict = { + "role": "assistant", + "content": assistant_msg.content or "", + } + if assistant_msg.tool_calls: + msg_dict["tool_calls"] = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in assistant_msg.tool_calls + ] + agent_messages.append(msg_dict) + + if not assistant_msg.tool_calls: + content = assistant_msg.content or "(no output)" + self.ui.on_info(f"[{label} done] {len(content)} chars") + return content + + # Execute tools sequentially (not parallel) to avoid + # concurrent _read_files mutation from worker threads. + tool_names = {t["function"]["name"] for t in tools} + for tc in assistant_msg.tool_calls: + tool_name = tc.function.name + + # Guard 1: block recursive agent calls. + if tool_name in ("task", "plan"): + output = "Error: agents cannot spawn further agents" + # Guard 2: tool not in this agent's API tool list. + elif tool_name not in tool_names: + output = ( + f"Error: tool '{tool_name}' is not available in " + f"agent mode. " + f"Available: {', '.join(sorted(tool_names))}" + ) + else: + tc_dict = { + "id": tc.id, + "type": "function", + "function": { + "name": tool_name, + "arguments": tc.function.arguments, + }, + } + prepared = self._prepare_tool(tc_dict) + + lbl = prepared.get("header", tool_name) + self.ui.on_info(f"[{label} turn {turn + 1}] {lbl}") + + if prepared.get("error"): + output = prepared["error"] + # Auto-execute tools in the auto_tools set. + elif tool_name in auto_tools: + _, output = prepared["execute"](prepared) + # Tools not in auto_tools require user approval. + elif "execute" in prepared: + approved, _ = self.ui.approve_tools([prepared]) + if not approved: + prepared["denied"] = True + prepared["denial_msg"] = "Denied by user" + if prepared.get("denied"): + output = prepared.get("denial_msg", "Denied by user") + else: + _, output = prepared["execute"](prepared) + else: + output = f"Unknown tool: {tool_name}" + + agent_messages.append( + { + "role": "tool", + "tool_call_id": tc.id, + "content": output, + } + ) + turn += 1 + + # Exhausted tool turns — force a final synthesis response. + self.ui.on_info(f"[{label}] turn limit reached, requesting synthesis...") + agent_messages.append( + { + "role": "user", + "content": ( + "You have reached the tool call limit. " + "Provide your complete response now using " + "the information you have gathered so far." + ), + } + ) + response = _api_call(agent_messages, _tools=[]) + content = response.choices[0].message.content or "(no output)" + self.ui.on_info(f"[{label} done] {len(content)} chars") + return content + + def _exec_task(self, item: dict) -> tuple[str, str]: + """Delegate to a general-purpose autonomous sub-agent.""" + call_id, prompt = item["call_id"], item["prompt"] + task_instruction = { + "role": "developer", + "content": ( + "# Task Agent\n\n" + "You are an autonomous task agent with full tool access. " + "You can use bash, read_file, write_file, edit_file, search, " + "math, web_fetch, and web_search.\n\n" + "1. **Follow through on actions:** Do not describe changes — " + "use the tools to make them. After read_file, call edit_file " + "or write_file.\n\n" + "2. **Tool selection:**\n" + " - Use read_file before edit_file on existing files.\n" + " - Use write_file for new files (not bash).\n" + " - Use bash for shell commands (git, python, tests).\n" + " - Use search to find code across files.\n\n" + "3. **Complete the task fully.** Do not ask follow-up " + "questions — execute the work as described in the prompt." + ), + } + agent_messages = list(self._agent_system_messages) + [ + task_instruction, + {"role": "user", "content": prompt}, + ] + try: + return call_id, self._run_agent( + agent_messages, + label="task", + tools=TASK_AGENT_TOOLS, + auto_tools=self._TASK_AUTO_TOOLS, + ) + except KeyboardInterrupt: + return call_id, "(task interrupted by user)" + except Exception as e: + self.ui.on_info(f"[task error] {e}") + return call_id, f"Task error: {e}" + + _PLAN_IDENTITY = ( + "You are a planning agent. Explore the codebase with read_file and search, " + "then write a plan with these sections: " + "## Goal (1-2 sentences), " + "## Current State (files/line numbers found), " + "## Plan (numbered steps naming exact files and functions), " + "## Risks (edge cases and unknowns). " + "Never guess at structure — verify first. Be specific: name files, line numbers, " + "and functions in every step." + ) + + def _exec_plan(self, item: dict) -> tuple[str, str]: + """Run a planning agent and write the result to .plan-.md.""" + call_id, prompt = item["call_id"], item["prompt"] + plan_path = f".plan-{self._session_id}.md" + + # If plan was called before in this session, the previous assistant + # tool_call + tool result are already in self.messages — pass them + # directly to the inner agent so it refines rather than restarts. + prior_plan_msgs: list[dict] = [] + for i, msg in enumerate(self.messages): + if msg.get("role") == "assistant" and msg.get("tool_calls"): + for tc in msg["tool_calls"]: + if tc.get("function", {}).get("name") == "plan": + tc_id = tc["id"] + for j in range(i + 1, len(self.messages)): + if ( + self.messages[j].get("role") == "tool" + and self.messages[j].get("tool_call_id") == tc_id + ): + prior_plan_msgs = [msg, self.messages[j]] + break + + agent_messages = list(self._agent_system_messages) + agent_messages.extend(prior_plan_msgs) + agent_messages.append({"role": "user", "content": prompt}) + + try: + content = self._run_agent( + agent_messages, + label="plan", + reasoning_effort="high", + model_identity=self._PLAN_IDENTITY, + ) + except KeyboardInterrupt: + return call_id, "(plan interrupted by user)" + except Exception as e: + self.ui.on_info(f"[plan error] {e}") + return call_id, f"Plan error: {e}" + + # Write to file separately — always return content even if write fails + try: + with open(plan_path, "w") as f: + f.write(content) + self.ui.on_info(f"Plan written to {plan_path}") + except OSError as e: + self.ui.on_info(f"[plan] could not write {plan_path}: {e}") + + return call_id, content + + def _exec_remember(self, item: dict) -> tuple[str, str]: + """Save a persistent memory.""" + call_id, key, value = item["call_id"], item["key"], item["value"] + try: + conn = open_db() + try: + existing = conn.execute( + "SELECT value FROM memories WHERE key = ?", (key,) + ).fetchone() + conn.execute( + "INSERT OR REPLACE INTO memories (key, value, created, updated) " + "VALUES (?, ?, COALESCE(" + " (SELECT created FROM memories WHERE key = ?), " + " datetime('now')" + "), datetime('now'))", + (key, value, key), + ) + conn.commit() + self._init_system_messages() + if existing: + msg = f"Updated memory: {key} = {value} (was: {existing[0]})" + else: + msg = f"Saved memory: {key} = {value}" + self.ui.on_tool_result("remember", msg) + return call_id, msg + finally: + conn.close() + except Exception as e: + return call_id, f"Error: {e}" + + def _exec_forget(self, item: dict) -> tuple[str, str]: + """Remove a persistent memory by key.""" + call_id, key = item["call_id"], item["key"] + try: + conn = open_db() + try: + cursor = conn.execute("DELETE FROM memories WHERE key = ?", (key,)) + conn.commit() + if cursor.rowcount == 0: + msg = f"Error: memory '{key}' not found" + else: + self._init_system_messages() + msg = f"Forgot: {key}" + self.ui.on_tool_result("forget", msg) + return call_id, msg + finally: + conn.close() + except Exception as e: + return call_id, f"Error: {e}" + + def _exec_recall(self, item: dict) -> tuple[str, str]: + """Search memories and conversation history.""" + call_id = item["call_id"] + query, limit = item["query"], item["limit"] + parts: list[str] = [] + + # Memories: list all (no query) or search (with query) + try: + conn = open_db() + try: + if not query: + rows = conn.execute( + "SELECT key, value FROM memories ORDER BY key" + ).fetchall() + else: + terms = query.split() + clauses = [] + params: list[str] = [] + for t in terms: + escaped = escape_like(t) + clauses.append( + "(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')" + ) + params.extend([f"%{escaped}%", f"%{escaped}%"]) + rows = conn.execute( + "SELECT key, value FROM memories WHERE " + + " AND ".join(clauses) + + " ORDER BY key", + params, + ).fetchall() + if rows: + parts.append( + "Memories:\n" + "\n".join(f" {k}={v}" for k, v in rows) + ) + elif not query: + parts.append("No memories stored.") + finally: + conn.close() + except Exception: + pass + + # Conversations: only when a query is provided + if query: + conv_rows = search_history(query, limit) + if conv_rows: + lines = [] + for ts, sid, role, content, tool_name in conv_rows: + label = f"{role}({tool_name})" if tool_name else role + text = (content or "")[:500] + if content and len(content) > 500: + text += "..." + lines.append(f"[{ts} {sid}] {label}: {text}") + parts.append( + f"Conversations ({len(conv_rows)} matches):\n" + "\n".join(lines) + ) + + output = "\n\n".join(parts) if parts else f"No results for '{query}'." + self.ui.on_tool_result("recall", output) + return call_id, output + + def _exec_write_file(self, item: dict) -> tuple[str, str]: + """Write content to a file, creating parent directories as needed.""" + call_id = item["call_id"] + path, content, resolved = item["path"], item["content"], item["resolved"] + try: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w") as f: + f.write(content) + self._read_files.add(resolved) + return call_id, f"Wrote {len(content)} chars to {path}" + except Exception as e: + return call_id, f"Error writing {path}: {e}" + + def _exec_edit_file(self, item: dict) -> tuple[str, str]: + """Replace an exact string in a file (re-reads to avoid TOCTOU). + + When near_line is set, picks the occurrence nearest that line + instead of requiring uniqueness. + """ + call_id = item["call_id"] + path, old_string, new_string = ( + item["path"], + item["old_string"], + item["new_string"], + ) + near_line = item.get("near_line") + try: + with open(path, "r") as f: + content = f.read() + occurrences = find_occurrences(content, old_string) + if len(occurrences) == 0: + return ( + call_id, + f"Error: old_string no longer found in {path} (file changed)", + ) + if len(occurrences) > 1 and near_line is None: + line_list = ", ".join(str(ln) for ln in occurrences) + return ( + call_id, + f"Error: old_string found {len(occurrences)} times " + f"at lines {line_list} (file changed)", + ) + if near_line is not None and len(occurrences) > 1: + # Replace only the occurrence nearest to near_line + idx = pick_nearest(content, old_string, near_line) + content = content[:idx] + new_string + content[idx + len(old_string) :] + else: + content = content.replace(old_string, new_string, 1) + with open(path, "w") as f: + f.write(content) + return call_id, f"Edited {path}: replaced 1 occurrence" + except Exception as e: + return call_id, f"Error writing {path}: {e}" + + def _exec_math(self, item: dict) -> tuple[str, str]: + """Execute Python code in sandboxed subprocess.""" + call_id, code = item["call_id"], item["code"] + output, is_error = execute_math_sandboxed(code, timeout=self.tool_timeout) + output = self._truncate_output(output) + + self.ui.on_tool_result("math", output) + + if is_error: + return call_id, f"Error:\n{output}" + return call_id, output if output else "(no output)" + + def _exec_man(self, item: dict) -> tuple[str, str]: + """Look up a man or info page.""" + call_id = item["call_id"] + page = item["page"] + section = item.get("section", "") + + # Try man first, fall back to info + cmd = ["man"] + if section: + cmd.append(section) + cmd.append(page) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + env={**os.environ, "MANWIDTH": "80", "MAN_KEEP_FORMATTING": "0"}, + ) + if result.returncode == 0 and result.stdout.strip(): + # Strip formatting: backspace overstrikes and ANSI escapes + text = re.sub(r".\x08", "", result.stdout) + text = re.sub(r"\x1b\[[0-9;]*m", "", text) + else: + # Fall back to info + result = subprocess.run( + ["info", page], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + text = result.stdout + else: + msg = f"No man or info page found for '{page}'" + self.ui.on_tool_result("man", msg) + return call_id, msg + except FileNotFoundError: + return call_id, "Error: man command not available" + except subprocess.TimeoutExpired: + return call_id, "Error: man page lookup timed out" + + text = self._truncate_output(text) + + self.ui.on_tool_result("man", f"{len(text)} chars") + + return call_id, text + + def _exec_web_fetch(self, item: dict) -> tuple[str, str]: + """Fetch a URL, then summarize/extract using an API call.""" + call_id, url = item["call_id"], item["url"] + question = item.get("question", "Summarize the key content of this page.") + + # Phase 1: fetch the URL + try: + resp = httpx.get( + url, + headers={"User-Agent": "turnstone/1.0"}, + timeout=self.tool_timeout, + follow_redirects=True, + ) + resp.raise_for_status() + ct = resp.headers.get("content-type", "") + text = resp.text + if "html" in ct: + text = strip_html(text) + # Cap at 10 MB + if len(text) > 10 * 1024 * 1024: + text = text[: 10 * 1024 * 1024] + + except httpx.HTTPStatusError as e: + msg = f"Fetch failed: HTTP {e.response.status_code}" + self.ui.on_error(msg) + return call_id, msg + except (httpx.RequestError, ValueError) as e: + msg = f"Fetch failed: {e}" + self.ui.on_error(msg) + return call_id, msg + except Exception as e: + msg = f"Error fetching URL: {e}" + self.ui.on_error(msg) + return call_id, msg + + if not text.strip(): + return call_id, "(empty response from URL)" + + original_len = len(text) + self.ui.on_info(f"fetched {original_len} chars, extracting...") + + # Phase 2: truncate for summarization context + max_content = 50_000 + if len(text) > max_content: + text = ( + text[: max_content // 2] + + f"\n\n... [{len(text) - max_content} chars omitted] ...\n\n" + + text[-(max_content // 2) :] + ) + + # Phase 3: summarization API call + try: + response = self.client.chat.completions.create( + model=self.model, + messages=[ + { + "role": "system", + "content": ( + "You are a web content extraction assistant. " + "Answer the user's question using ONLY the " + "provided page content. Be concise and factual. " + "If the content doesn't contain the answer, say so." + ), + }, + { + "role": "user", + "content": ( + f"Page URL: {url}\n" + f"Page content ({original_len} chars):\n\n" + f"{text}\n\n---\n" + f"Question: {question}" + ), + }, + ], + max_completion_tokens=2000, + temperature=0.2, + ) + answer = response.choices[0].message.content or "(no answer)" + except Exception as e: + answer = ( + f"Extraction failed (page was fetched but summarization errored): {e}" + ) + + self.ui.on_tool_result("web_fetch", answer) + + return call_id, answer + + def _exec_web_search(self, item: dict) -> tuple[str, str]: + """Search the web via Tavily API.""" + call_id = item["call_id"] + query = item["query"] + max_results = item.get("max_results", 5) + topic = item.get("topic", "general") + api_key = get_tavily_key() + + try: + resp = httpx.post( + "https://api.tavily.com/search", + json={ + "query": query, + "max_results": max_results, + "topic": topic, + "include_answer": True, + }, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=self.tool_timeout, + ) + resp.raise_for_status() + data = resp.json() + except Exception as e: + msg = f"Tavily search failed: {e}" + self.ui.on_error(msg) + return call_id, msg + + parts: list[str] = [] + answer = (data.get("answer") or "").strip() + if answer: + parts.append(f"Answer: {answer}") + + results = data.get("results") or [] + if results: + lines = [] + for i, r in enumerate(results, 1): + title = r.get("title", "") + url = r.get("url", "") + content = (r.get("content") or "")[:500] + lines.append(f"{i}. [{title}]({url})\n {content}") + parts.append("\n".join(lines)) + + output = "\n\n".join(parts) if parts else f"No results for '{query}'." + + self.ui.on_tool_result("web_search", output) + + return call_id, output + + def handle_command(self, cmd_line: str) -> bool: + """Handle slash commands. Returns True if should exit.""" + parts = cmd_line.strip().split(None, 1) + cmd = parts[0].lower() + arg = parts[1] if len(parts) > 1 else "" + + if cmd in ("/exit", "/quit", "/q"): + return True + + elif cmd == "/persona": + if not arg: + if self.persona: + self.ui.on_info(f"Current persona: {cyan(self.persona)}") + else: + self.ui.on_info("No persona set. Usage: /persona ") + else: + self.persona = arg.strip() + self._init_system_messages() + self.ui.on_info(f"Switched persona to {cyan(self.persona)}") + + elif cmd == "/instructions": + if not arg: + if self.instructions: + self.ui.on_info( + f"Current instructions: {self.instructions[:100]}..." + ) + else: + self.ui.on_info("No instructions set. Usage: /instructions ") + else: + self.instructions = arg.strip() + self._init_system_messages() + self.ui.on_info("Instructions updated.") + + elif cmd == "/clear": + self.messages.clear() + self._read_files.clear() + self._last_usage = None + self._msg_tokens = [] + self.ui.on_info("Context cleared (session preserved in database).") + + elif cmd == "/new": + self.messages.clear() + self._read_files.clear() + self._last_usage = None + self._msg_tokens = [] + self._session_id = uuid.uuid4().hex[:12] + self._title_generated = False + register_session(self._session_id) + self.ui.on_info("New session started.") + + elif cmd == "/sessions": + rows = list_sessions(limit=20) + if not rows: + self.ui.on_info("No saved sessions.") + else: + lines = ["Sessions:\n"] + for sid, alias, title, created, updated, count in rows: + display_name = alias or sid + display_title = f" {title}" if title else "" + marker = " *" if sid == self._session_id else " " + lines.append( + f" {marker} {bold(display_name)}{display_title} " + f"{dim(f'{count} msgs, {updated}')}" + ) + self.ui.on_info("\n".join(lines)) + + elif cmd == "/resume": + if not arg: + self.ui.on_info( + "Usage: /resume \n" + "Use /sessions to list available sessions." + ) + else: + target_id = resolve_session(arg.strip()) + if not target_id: + self.ui.on_info(f"Session not found: {arg.strip()}") + elif target_id == self._session_id: + self.ui.on_info("Already in that session.") + elif self.resume_session(target_id): + self.ui.on_info( + f"Resumed session {bold(target_id)} " + f"({len(self.messages)} messages loaded)" + ) + name = get_session_name(target_id) + if name: + self.ui.on_rename(name) + else: + self.ui.on_info(f"Session {arg.strip()} has no messages.") + + elif cmd == "/name": + if not arg: + self.ui.on_info(f"Current session: {self._session_id}") + elif set_session_alias(self._session_id, arg.strip()): + self.ui.on_info(f"Session named: {bold(arg.strip())}") + self.ui.on_rename(arg.strip()) + else: + self.ui.on_info(f"Alias '{arg.strip()}' is already in use.") + + elif cmd == "/delete": + if not arg: + self.ui.on_info( + "Usage: /delete \n" + "Use /sessions to list sessions." + ) + else: + target_id = resolve_session(arg.strip()) + if not target_id: + self.ui.on_info(f"Session not found: {arg.strip()}") + elif target_id == self._session_id: + self.ui.on_info("Cannot delete the active session.") + elif delete_session(target_id): + self.ui.on_info(f"Deleted session {arg.strip()}") + else: + self.ui.on_info(f"Failed to delete session {arg.strip()}") + + elif cmd == "/history": + query = arg.strip() if arg else None + if query: + rows = search_history(query, limit=20) + if not rows: + self.ui.on_info(f"No results for {query!r}") + else: + lines = [f"Found {len(rows)} result(s) for {query!r}:\n"] + for ts, sid, role, content, tool_name in rows: + label = tool_name if tool_name else role + text = (content or "")[:200] + lines.append(f" {dim(ts)} {dim(sid)} {bold(label)}: {text}") + self.ui.on_info("\n".join(lines)) + else: + # Show recent conversations (last 20 messages) + rows = search_history_recent(limit=20) + if not rows: + self.ui.on_info("No conversation history yet.") + else: + lines = ["Recent history:\n"] + for ts, sid, role, content, tool_name in rows: + label = tool_name if tool_name else role + text = (content or "")[:200] + lines.append(f" {dim(ts)} {dim(sid)} {bold(label)}: {text}") + self.ui.on_info("\n".join(lines)) + + elif cmd == "/model": + self.ui.on_info(f"Model: {cyan(self.model)}") + + elif cmd == "/raw": + self.show_reasoning = not self.show_reasoning + state = "on" if self.show_reasoning else "off" + self.ui.on_info(f"Reasoning display: {bold(state)}") + + elif cmd == "/reason": + valid = ("low", "medium", "high") + aliases = {"med": "medium", "lo": "low", "hi": "high"} + if not arg: + self.ui.on_info(f"Reasoning effort: {cyan(self.reasoning_effort)}") + else: + value = aliases.get(arg.lower(), arg.lower()) + if value in valid: + self.reasoning_effort = value + self._init_system_messages() + self.ui.on_info( + f"Reasoning effort set to {cyan(self.reasoning_effort)}" + ) + else: + self.ui.on_info(f"Invalid. Choose from: {', '.join(valid)}") + + elif cmd == "/compact": + self._compact_messages() + + elif cmd == "/creative": + self.creative_mode = not self.creative_mode + self._init_system_messages() + # Clear history when toggling ON if it contains tool messages, + # because the API rejects tool-call history without tool definitions + if self.creative_mode and any( + m.get("tool_calls") or m.get("role") == "tool" for m in self.messages + ): + self.messages.clear() + self._read_files.clear() + self._msg_tokens.clear() + self.ui.on_info( + "[history cleared — creative mode is incompatible with tool history]" + ) + state = "on" if self.creative_mode else "off" + self.ui.on_info( + f"Creative mode: {bold(state)} (tools {'disabled' if self.creative_mode else 'enabled'})" + ) + + elif cmd == "/debug": + self.debug = not self.debug + state = "on" if self.debug else "off" + self.ui.on_info(f"Debug mode: {bold(state)} (prints raw SSE deltas)") + + elif cmd == "/help": + self.ui.on_info( + "\n".join( + [ + "── Slash Commands ─────────────────────────────────────", + " /persona Set persona (system message)", + " /instructions Set developer instructions", + " /clear Clear context (session preserved in database)", + " /new Start a new session (old session stays resumable)", + "", + " /sessions List saved sessions", + " /resume Resume a previous session", + " /name Name the current session", + " /delete Delete a saved session", + "", + " /history [query] Search conversation history (or show recent)", + " /compact Compact conversation (summarize old messages)", + "", + " /model Show current model", + " /raw Toggle reasoning content display", + " /reason [low|med|high] Set/show reasoning effort", + " /creative Toggle creative writing mode (no tools)", + " /debug Toggle raw SSE delta logging", + " /help Show this help", + " /exit Exit (also: Ctrl+D)", + "────────────────────────────────────────────────────────", + ] + ) + ) + + else: + self.ui.on_info( + f"Unknown command: {cmd}. Type /help for available commands." + ) + + return False diff --git a/turnstone/core/tools.py b/turnstone/core/tools.py new file mode 100644 index 00000000..9b9fb3ab --- /dev/null +++ b/turnstone/core/tools.py @@ -0,0 +1,36 @@ +"""Tool definitions — auto-loaded from turnstone/tools/*.json.""" + +import json +from pathlib import Path + +_TOOLS_DIR = Path(__file__).resolve().parent.parent / "tools" +_META_KEYS = {"agent", "task_agent", "auto_approve", "primary_key"} + + +def _load_tools() -> tuple[list[dict], dict]: + """Load all .json files from the tools directory. + + Returns (tool_defs, metadata) where: + - tool_defs: list of OpenAI function-calling dicts + - metadata: dict mapping tool_name -> {agent, task_agent, auto_approve, primary_key} + """ + tools = [] + meta = {} + for path in sorted(_TOOLS_DIR.glob("*.json")): + with open(path) as f: + raw = json.load(f) + name = raw["name"] + # Extract turnstone metadata, leave only OpenAI schema fields + tool_meta = {k: raw.pop(k) for k in list(raw) if k in _META_KEYS} + meta[name] = tool_meta + tools.append({"type": "function", "function": raw}) + return tools, meta + + +TOOLS, _META = _load_tools() + +AGENT_TOOLS = [t for t in TOOLS if _META[t["function"]["name"]].get("agent")] +TASK_AGENT_TOOLS = [t for t in TOOLS if _META[t["function"]["name"]].get("task_agent")] +AGENT_AUTO_TOOLS = {n for n, m in _META.items() if m.get("auto_approve")} +TASK_AUTO_TOOLS = {n for n, m in _META.items() if m.get("auto_approve")} +PRIMARY_KEY_MAP = {n: m["primary_key"] for n, m in _META.items() if "primary_key" in m} diff --git a/turnstone/core/web.py b/turnstone/core/web.py new file mode 100644 index 00000000..ee503fad --- /dev/null +++ b/turnstone/core/web.py @@ -0,0 +1,36 @@ +"""Web utilities — HTML stripping and SSRF protection.""" + +import ipaddress +import re +import socket +from html import unescape as _html_unescape +from urllib.parse import urlparse + +_RE_TAGS = re.compile(r"<[^>]+>") +_RE_WS = re.compile(r"[ \t]+") +_RE_BLANKLINES = re.compile(r"\n{3,}") + + +def strip_html(html: str) -> str: + """Convert HTML to plain text: strip tags, decode entities, collapse whitespace.""" + text = _RE_TAGS.sub("", html) + text = _html_unescape(text) + text = _RE_WS.sub(" ", text) + text = _RE_BLANKLINES.sub("\n\n", text) + return text.strip() + + +def check_ssrf(url: str) -> str | None: + """Return error string if URL resolves to a private/link-local address, else None.""" + try: + parsed = urlparse(url) + hostname = parsed.hostname + if not hostname: + return "Invalid URL: no hostname" + addr = socket.gethostbyname(hostname) + ip = ipaddress.ip_address(addr) + if ip.is_private or ip.is_loopback or ip.is_link_local: + return f"Blocked: URL resolves to private/internal address ({addr})" + except (socket.gaierror, ValueError): + pass # DNS failure or invalid IP — let the actual fetch handle it + return None diff --git a/turnstone/core/workstream.py b/turnstone/core/workstream.py new file mode 100644 index 00000000..e270f3a7 --- /dev/null +++ b/turnstone/core/workstream.py @@ -0,0 +1,225 @@ +"""Workstream manager — concurrent independent chat sessions. + +A workstream is an independent conversation with its own ChatSession and UI +adapter. The WorkstreamManager coordinates multiple workstreams, tracks their +states, and lets frontends (CLI, Web) multiplex user attention across them. +""" + +from __future__ import annotations + +import enum +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from turnstone.core.session import ChatSession, SessionUI + + +# --------------------------------------------------------------------------- +# State enum +# --------------------------------------------------------------------------- + + +class WorkstreamState(enum.Enum): + IDLE = "idle" # waiting for user input + THINKING = "thinking" # LLM is streaming + RUNNING = "running" # tools executing + ATTENTION = "attention" # blocked on approval / plan review + ERROR = "error" # last operation failed + + +# --------------------------------------------------------------------------- +# Workstream dataclass +# --------------------------------------------------------------------------- + + +@dataclass +class Workstream: + id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) + name: str = "" + state: WorkstreamState = WorkstreamState.IDLE + session: ChatSession | None = None + ui: SessionUI | None = None + worker_thread: threading.Thread | None = None + error_message: str = "" + last_active: float = field(default_factory=time.monotonic, repr=False) + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def __post_init__(self): + if not self.name: + self.name = f"ws-{self.id[:4]}" + + +# --------------------------------------------------------------------------- +# Manager +# --------------------------------------------------------------------------- + + +class WorkstreamManager: + """Manages multiple concurrent workstreams, each with its own ChatSession.""" + + MAX_WORKSTREAMS = 10 + + def __init__( + self, + session_factory: Callable[[SessionUI], ChatSession], + ): + """ + Args: + session_factory: callable(ui) -> ChatSession. Captures shared + config (client, model, temperature, …) so the manager can + create sessions without knowing those details. + """ + self._session_factory = session_factory + self._workstreams: dict[str, Workstream] = {} + self._order: list[str] = [] # creation order + self._active_id: str | None = None + self._lock = threading.Lock() + self._on_state_change: Callable[[str, WorkstreamState], None] | None = None + + # -- creation / destruction --------------------------------------------- + + def create( + self, + name: str = "", + ui_factory: Callable[..., SessionUI] | None = None, + ) -> Workstream: + """Create a new workstream. Returns the new ws.""" + ws = Workstream(name=name) + if ui_factory: + ws.ui = ui_factory(ws.id) + ws.session = self._session_factory(ws.ui) + with self._lock: + if len(self._workstreams) >= self.MAX_WORKSTREAMS: + raise RuntimeError( + f"Maximum of {self.MAX_WORKSTREAMS} workstreams reached" + ) + self._workstreams[ws.id] = ws + self._order.append(ws.id) + if self._active_id is None: + self._active_id = ws.id + return ws + + def close(self, ws_id: str) -> bool: + """Close a workstream. Returns False if it's the last one.""" + with self._lock: + if len(self._workstreams) <= 1: + return False + ws = self._workstreams.pop(ws_id, None) + if ws is None: + return False + self._order.remove(ws_id) + if self._active_id == ws_id: + self._active_id = self._order[0] + # Unblock any waiting approval/plan events so worker thread can exit + if ws.ui: + if hasattr(ws.ui, "_approval_event"): + ws.ui._approval_result = (False, None) + ws.ui._approval_event.set() + if hasattr(ws.ui, "_plan_event"): + ws.ui._plan_result = "reject" + ws.ui._plan_event.set() + if hasattr(ws.ui, "_fg_event"): + ws.ui._fg_event.set() + return True + + # -- lookup ------------------------------------------------------------- + + def get(self, ws_id: str) -> Workstream | None: + with self._lock: + return self._workstreams.get(ws_id) + + @property + def active_id(self) -> str | None: + return self._active_id + + def get_active(self) -> Workstream | None: + with self._lock: + return self._workstreams.get(self._active_id) if self._active_id else None + + def list_all(self) -> list[Workstream]: + """Return workstreams in creation order.""" + with self._lock: + return [ + self._workstreams[wid] + for wid in self._order + if wid in self._workstreams + ] + + def index_of(self, ws_id: str) -> int: + """1-based index of a workstream, or 0 if not found.""" + with self._lock: + try: + return self._order.index(ws_id) + 1 + except ValueError: + return 0 + + @property + def count(self) -> int: + with self._lock: + return len(self._workstreams) + + # -- switching ---------------------------------------------------------- + + def switch(self, ws_id: str) -> Workstream | None: + """Switch active workstream. Returns new active or None.""" + with self._lock: + if ws_id in self._workstreams: + self._active_id = ws_id + return self._workstreams[ws_id] + return None + + def switch_by_index(self, index: int) -> Workstream | None: + """Switch by 1-based index (creation order).""" + with self._lock: + if 1 <= index <= len(self._order): + ws_id = self._order[index - 1] + self._active_id = ws_id + return self._workstreams.get(ws_id) + return None + + # -- state management --------------------------------------------------- + + def set_state(self, ws_id: str, state: WorkstreamState, error_msg: str = ""): + """Update a workstream's state. Called by UI adapters.""" + ws = self._workstreams.get(ws_id) + if ws: + with ws._lock: + ws.state = state + ws.last_active = time.monotonic() + ws.error_message = error_msg + if self._on_state_change: + self._on_state_change(ws_id, state) + + def close_idle(self, max_age_seconds: float) -> list[str]: + """Close IDLE workstreams inactive for more than *max_age_seconds*. + + Skips the last workstream and any workstream not in IDLE state. + Returns a list of closed ws_ids. + """ + now = time.monotonic() + with self._lock: + snapshot = list(self._workstreams.values()) + expired = sorted( + [ + ws + for ws in snapshot + if ws.state == WorkstreamState.IDLE + and (now - ws.last_active) > max_age_seconds + ], + key=lambda ws: ws.last_active, # oldest first + ) + # Never leave zero workstreams + max_closeable = max(0, len(snapshot) - 1) + to_close = [ws.id for ws in expired[:max_closeable]] + + closed = [] + for ws_id in to_close: + ws = self._workstreams.get(ws_id) + # Re-check state to guard against race between collection and close + if ws and ws.state == WorkstreamState.IDLE and self.close(ws_id): + closed.append(ws_id) + return closed diff --git a/turnstone/eval.py b/turnstone/eval.py new file mode 100644 index 00000000..b4e3712e --- /dev/null +++ b/turnstone/eval.py @@ -0,0 +1,1249 @@ +#!/usr/bin/env python3 +"""eval.py — Prompt optimization and evaluation for turnstone. + +Iteratively evaluates and optimizes the turnstone developer prompt by running +test cases, scoring tool call sequences against expected actions, and using +the model to self-modify the prompt based on results. + +Usage: + python -m turnstone.eval tests.json + python -m turnstone.eval tests.json --no-optimize + python -m turnstone.eval tests.json --prompt prompt.txt --n-runs 5 --max-iter 10 +""" + +import argparse +import contextlib +import difflib +import io +import json +import os +import re +import shutil +import sys +import tempfile +import textwrap +import time +from datetime import datetime + +from openai import OpenAI + +from turnstone.core.session import ChatSession +from turnstone.core.tools import TOOLS, PRIMARY_KEY_MAP +import turnstone.core.memory as _memory_module + +# ─── ANSI & logging helpers ─────────────────────────────────────────────────── + +DIM = "\033[2m" +RESET = "\033[0m" +GREEN = "\033[32m" +RED = "\033[31m" +YELLOW = "\033[33m" +CYAN = "\033[36m" +BOLD = "\033[1m" + + +class NullUI: + """UI adapter that discards all output. Used by HeadlessSession.""" + + def on_thinking_start(self): + pass + + def on_thinking_stop(self): + pass + + def on_reasoning_token(self, text): + pass + + def on_content_token(self, text): + pass + + def on_stream_end(self): + pass + + def approve_tools(self, items): + return True, None + + def on_tool_result(self, name, output): + pass + + def on_status(self, usage, context_window, effort): + pass + + def on_plan_review(self, content): + return "" + + def on_info(self, message): + pass + + def on_error(self, message): + pass + + def on_state_change(self, state): + pass + + +def _log(msg: str, dim: bool = False): + """Print a log line with optional dim styling.""" + if dim: + sys.stderr.write(f"{DIM}{msg}{RESET}\n") + else: + sys.stderr.write(f"{msg}\n") + sys.stderr.flush() + + +def _fmt_args(args: dict, max_len: int = 80) -> str: + """Format tool args as a compact one-line summary.""" + parts = [] + for k, v in args.items(): + sv = str(v) + if len(sv) > 40: + sv = sv[:37] + "..." + parts.append(f"{k}={sv!r}") + out = ", ".join(parts) + if len(out) > max_len: + out = out[: max_len - 3] + "..." + return out + + +# ─── Stdout suppression ────────────────────────────────────────────────────── + + +@contextlib.contextmanager +def _suppress_stdout(): + """Redirect stdout to devnull temporarily.""" + old = sys.stdout + sys.stdout = io.StringIO() + try: + yield + finally: + sys.stdout = old + + +# ─── Headless session ──────────────────────────────────────────────────────── + + +class HeadlessSession(ChatSession): + """ChatSession subclass for headless evaluation. + + Differences from ChatSession: + - auto_approve is always True + - Tool calls are recorded into a structured log + - All stdout output is suppressed + - send_headless() uses non-streaming API + """ + + def __init__(self, client, model, system_prompt_override=None, **kwargs): + kwargs.setdefault("ui", NullUI()) + super().__init__(client=client, model=model, **kwargs) + self.tool_call_log: list[dict] = [] + self.auto_approve = True + if system_prompt_override is not None: + self._override_system_prompt(system_prompt_override) + + def _override_system_prompt(self, content: str): + """Replace the developer message content with a custom prompt.""" + for i, msg in enumerate(self.system_messages): + if msg["role"] == "developer": + self.system_messages[i] = {"role": "developer", "content": content} + return + self.system_messages.append({"role": "developer", "content": content}) + + def send_headless( + self, + user_input: str, + max_turns: int = 10, + verbose: bool = False, + log_prefix: str = "", + ) -> list[dict]: + """Run a complete conversation turn headlessly. + + Uses non-streaming API calls. Captures all tool calls into + self.tool_call_log. + + Returns the tool call log: list of dicts with keys: + tool: str, args: dict, result: str (truncated), turn: int + """ + self.tool_call_log = [] + self.messages.append({"role": "user", "content": user_input}) + self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token))) + + for turn in range(max_turns): + if verbose: + _log(f"{log_prefix} turn {turn}: calling API...", dim=True) + + t0 = time.monotonic() + msgs = self._full_messages() + + response = self.client.chat.completions.create( + model=self.model, + messages=msgs, + tools=TOOLS, + max_completion_tokens=self.max_tokens, + temperature=self.temperature, + stream=False, + extra_body={ + "chat_template_kwargs": { + "reasoning_effort": self.reasoning_effort, + } + }, + ) + elapsed = time.monotonic() - t0 + + choice = response.choices[0] + assistant_msg: dict = { + "role": "assistant", + "content": choice.message.content or None, + } + + if choice.message.tool_calls: + # Cap parallel tool calls to prevent degenerate repetition + calls = choice.message.tool_calls[:10] + assistant_msg["tool_calls"] = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in calls + ] + + self.messages.append(assistant_msg) + msg_len = len(assistant_msg.get("content") or "") + self._msg_tokens.append(max(1, int(msg_len / self._chars_per_token))) + + # Log usage and content + usage = getattr(response, "usage", None) + if verbose: + toks = "" + if usage: + toks = f" [{usage.prompt_tokens}p/{usage.completion_tokens}c tok]" + _log( + f"{log_prefix} turn {turn}: response in {elapsed:.1f}s{toks}", + dim=True, + ) + if assistant_msg["content"]: + text = assistant_msg["content"][:200] + if len(assistant_msg["content"]) > 200: + text += "..." + _log(f"{log_prefix} content: {text}", dim=True) + + if not choice.message.tool_calls: + if verbose: + _log(f"{log_prefix} turn {turn}: no tool calls, done", dim=True) + break + + # Log tool calls + if verbose: + names = [tc.function.name for tc in choice.message.tool_calls] + _log(f"{log_prefix} turn {turn}: tools -> {names}") + + # Execute tools with stdout suppressed + with _suppress_stdout(): + results, _ = self._execute_tools(assistant_msg["tool_calls"]) + + for tc, (tc_id, output) in zip(assistant_msg["tool_calls"], results): + func_name = tc["function"]["name"] + try: + args = json.loads(tc["function"]["arguments"]) + except json.JSONDecodeError: + raw = tc["function"]["arguments"] + # Map bare strings to the primary arg key + pk = PRIMARY_KEY_MAP.get(func_name) + if pk and raw.strip() and not raw.strip().startswith("{"): + args = {pk: raw} + else: + args = {"_raw": raw} + + self.tool_call_log.append( + { + "tool": func_name, + "args": args, + "result": output[:500], + "turn": turn, + } + ) + + if verbose: + # Show compact args summary + arg_summary = _fmt_args(args) + result_preview = output[:120].replace("\n", "\\n") + if len(output) > 120: + result_preview += "..." + _log(f"{log_prefix} {func_name}({arg_summary})", dim=False) + _log(f"{log_prefix} -> {result_preview}", dim=True) + + tool_msg = { + "role": "tool", + "tool_call_id": tc_id, + "content": output, + } + self.messages.append(tool_msg) + self._msg_tokens.append( + max(1, int(len(output) / self._chars_per_token)) + ) + + return self.tool_call_log + + +# ─── Test runner ───────────────────────────────────────────────────────────── + + +def _run_single_test( + client: OpenAI, + model: str, + system_prompt: str, + case: dict, + temperature: float, + max_tokens: int, + reasoning_effort: str, + context_window: int, + verbose: bool = False, + log_prefix: str = "", +) -> dict: + """Run a single test case once in an isolated temp directory. + + Must be called serially — uses os.chdir which is process-global. + + Returns dict with keys: tool_log, final_content, message_count, elapsed. + """ + workdir = tempfile.mkdtemp(prefix="turnstone_eval_") + original_cwd = os.getcwd() + eval_db = os.path.join(workdir, ".turnstone_eval.db") + _memory_module.db_override = eval_db + t0 = time.monotonic() + + try: + # Write setup files + setup_files = list(case.get("setup", {}).get("files", {}).items()) + for path, content in setup_files: + full = os.path.join(workdir, path) + os.makedirs(os.path.dirname(full) or workdir, exist_ok=True) + with open(full, "w") as f: + f.write(content) + + if verbose and setup_files: + _log( + f"{log_prefix} setup: created {[p for p, _ in setup_files]}", dim=True + ) + + os.chdir(workdir) + + session = HeadlessSession( + client=client, + model=model, + system_prompt_override=system_prompt, + persona=None, + instructions=None, + temperature=temperature, + max_tokens=max_tokens, + tool_timeout=30, + reasoning_effort=reasoning_effort, + context_window=context_window, + ) + + max_turns = case.get("max_turns", 10) + # Retry on transient API errors to avoid poisoning eval scores + _last_err = None + for _attempt in range(3): + try: + tool_log = session.send_headless( + case["user_prompt"], + max_turns=max_turns, + verbose=verbose, + log_prefix=log_prefix, + ) + break + except Exception as _e: + _last_err = _e + if _attempt < 2: + import time as _time + + _time.sleep(2**_attempt) + else: + raise _last_err + + final_content = "" + for msg in reversed(session.messages): + if msg["role"] == "assistant" and msg.get("content"): + final_content = msg["content"] + break + + elapsed = time.monotonic() - t0 + return { + "tool_log": tool_log, + "final_content": final_content, + "message_count": len(session.messages), + "elapsed": round(elapsed, 1), + } + finally: + _memory_module.db_override = None + _memory_module.db_initialized.discard(eval_db) + os.chdir(original_cwd) + shutil.rmtree(workdir, ignore_errors=True) + + +# ─── Scoring ───────────────────────────────────────────────────────────────── + + +def _match_action(actual: dict, expected: dict) -> bool: + """Check if a single actual tool call matches an expected action spec.""" + if actual["tool"] != expected["tool"]: + return False + + actual_args = actual["args"] + + # If args were unparseable (_raw fallback), can only match on tool name + if "_raw" in actual_args and len(actual_args) == 1: + return "args" not in expected and "args_pattern" not in expected + + # Check exact args (partial key matching) + if "args" in expected: + for key, expected_val in expected["args"].items(): + actual_val = actual_args.get(key) + if actual_val is None: + return False + if str(actual_val) != str(expected_val): + return False + + # Check regex args_pattern + if "args_pattern" in expected: + for key, pattern in expected["args_pattern"].items(): + actual_val = str(actual_args.get(key, "")) + if not re.search(pattern, actual_val): + return False + + return True + + +def score_run( + tool_log: list[dict], + expected_actions: list[dict], + match_mode: str = "ordered_subset", +) -> dict: + """Score a single run's tool log against expected actions. + + Returns dict with: pass, score, matched, unmatched, extra_tools, detail. + """ + if not expected_actions: + return { + "pass": True, + "score": 1.0, + "matched": [], + "unmatched": [], + "extra_tools": [], + "detail": "No expected actions defined", + } + + n_expected = len(expected_actions) + + if match_mode == "exact": + matched = [] + for i, (actual, expected) in enumerate(zip(tool_log, expected_actions)): + if _match_action(actual, expected): + matched.append(i) + score = len(matched) / n_expected + length_ok = len(tool_log) == n_expected + detail = f"Exact: {len(matched)}/{n_expected} matched" + if not length_ok: + detail += f" (length {len(tool_log)} vs {n_expected})" + return { + "pass": length_ok and len(matched) == n_expected, + "score": score, + "matched": matched, + "unmatched": [i for i in range(n_expected) if i not in matched], + "extra_tools": [t["tool"] for t in tool_log[n_expected:]], + "detail": detail, + } + + elif match_mode == "ordered_subset": + matched = [] + search_from = 0 + for ei, expected in enumerate(expected_actions): + for ai in range(search_from, len(tool_log)): + if _match_action(tool_log[ai], expected): + matched.append(ei) + search_from = ai + 1 + break + score = len(matched) / n_expected + unmatched = [i for i in range(n_expected) if i not in matched] + return { + "pass": len(matched) == n_expected, + "score": score, + "matched": matched, + "unmatched": unmatched, + "extra_tools": [], + "detail": f"Ordered subset: {len(matched)}/{n_expected}", + } + + elif match_mode == "subset": + matched = [] + used = set() + for ei, expected in enumerate(expected_actions): + for ai, actual in enumerate(tool_log): + if ai not in used and _match_action(actual, expected): + matched.append(ei) + used.add(ai) + break + score = len(matched) / n_expected + unmatched = [i for i in range(n_expected) if i not in matched] + return { + "pass": len(matched) == n_expected, + "score": score, + "matched": matched, + "unmatched": unmatched, + "extra_tools": [], + "detail": f"Subset: {len(matched)}/{n_expected}", + } + + elif match_mode == "contains_any": + for ei, expected in enumerate(expected_actions): + for actual in tool_log: + if _match_action(actual, expected): + return { + "pass": True, + "score": 1.0, + "matched": [ei], + "unmatched": [], + "extra_tools": [], + "detail": "Contains at least one match", + } + return { + "pass": False, + "score": 0.0, + "matched": [], + "unmatched": list(range(n_expected)), + "extra_tools": [t["tool"] for t in tool_log], + "detail": "None of the expected actions were found", + } + + else: + return { + "pass": False, + "score": 0.0, + "matched": [], + "unmatched": list(range(n_expected)), + "extra_tools": [], + "detail": f"Unknown match_mode: {match_mode}", + } + + +# ─── Iteration runner ──────────────────────────────────────────────────────── + + +def _run_iteration( + client: OpenAI, + model: str, + system_prompt: str, + cases: list[dict], + n_runs: int, + temperature: float, + max_tokens: int, + reasoning_effort: str, + context_window: int, + verbose: bool = False, +) -> dict: + """Run all test cases n_runs times and score them.""" + case_results = {} + + for ci, case in enumerate(cases): + case_id = case["id"] + case_n = case.get("n_runs", n_runs) + runs = [] + + print( + f"\n {CYAN}[{ci + 1}/{len(cases)}]{RESET} {BOLD}{case_id}{RESET} ({case_n} runs)" + ) + if verbose: + _log(f" prompt: {case['user_prompt']}", dim=True) + + for run_idx in range(case_n): + log_prefix = f" [{run_idx + 1}/{case_n}]" + + try: + run_result = _run_single_test( + client=client, + model=model, + system_prompt=system_prompt, + case=case, + temperature=temperature, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + context_window=context_window, + verbose=verbose, + log_prefix=log_prefix, + ) + + score_result = score_run( + tool_log=run_result["tool_log"], + expected_actions=case.get("expected_actions", []), + match_mode=case.get("match_mode", "ordered_subset"), + ) + + score_result["tool_sequence"] = [ + t["tool"] for t in run_result["tool_log"] + ] + score_result["tool_args"] = [ + {t["tool"]: t["args"]} for t in run_result["tool_log"] + ] + score_result["elapsed"] = run_result.get("elapsed", 0) + + # Detect JSON dumped into final channel (tool call not made) + fc = (run_result.get("final_content") or "").strip() + if fc and not score_result["pass"]: + has_json = bool( + re.search( + r'\{\s*"(tool|function|name|arguments|command|query|path|url)"', + fc, + ) + ) + if has_json: + score_result["json_dump"] = True + + except Exception as e: + score_result = { + "pass": False, + "score": 0.0, + "matched": [], + "unmatched": list(range(len(case.get("expected_actions", [])))), + "extra_tools": [], + "detail": f"Error: {e}", + "tool_sequence": [], + "elapsed": 0, + } + + passed = score_result["pass"] + status_color = GREEN if passed else RED + status_label = "PASS" if passed else "FAIL" + tools = score_result.get("tool_sequence", []) + elapsed = score_result.get("elapsed", 0) + json_flag = ( + f" {YELLOW}[JSON_DUMP]{RESET}" if score_result.get("json_dump") else "" + ) + print( + f" Run {run_idx + 1}: " + f"{status_color}[{status_label}]{RESET} " + f"score={score_result['score']:.2f} " + f"tools={tools}" + f"{json_flag}" + f" {DIM}({elapsed:.1f}s){RESET}" + ) + + runs.append(score_result) + + pass_count = sum(1 for r in runs if r["pass"]) + case_results[case_id] = { + "runs": runs, + "pass_rate": pass_count / len(runs) if runs else 0, + "avg_score": (sum(r["score"] for r in runs) / len(runs) if runs else 0), + } + + # Aggregate + total_runs = sum(len(cr["runs"]) for cr in case_results.values()) + total_passes = sum( + sum(1 for r in cr["runs"] if r["pass"]) for cr in case_results.values() + ) + total_json_dumps = sum( + sum(1 for r in cr["runs"] if r.get("json_dump")) for cr in case_results.values() + ) + + return { + "cases": case_results, + "aggregate": { + "total_cases": len(cases), + "total_runs": total_runs, + "overall_pass_rate": total_passes / total_runs if total_runs else 0, + "json_dumps": total_json_dumps, + "overall_avg_score": ( + sum(cr["avg_score"] for cr in case_results.values()) / len(case_results) + if case_results + else 0 + ), + "per_case_pass_rates": { + cid: cr["pass_rate"] for cid, cr in case_results.items() + }, + }, + } + + +# ─── Prompt optimizer ──────────────────────────────────────────────────────── + + +OPTIMIZER_SYSTEM = """\ +You are a text rewriter. You receive a developer prompt (instructions \ +for a coding assistant on how to use its tools) and test results \ +showing how well the assistant followed them. Rewrite the prompt so \ +the assistant picks the right tools more often. + +Context: Tests score whether the assistant calls specific tools in \ +the right order. The critical failure modes to address: \ +(1) responding with only text when a tool call is needed — the \ +assistant must ALWAYS call a tool, (2) using write_file to rewrite \ +an entire file instead of edit_file for small changes, (3) not calling \ +plan(prompt='...') when asked to think through a complex task, \ +(4) searching for a file before creating it with write_file. When a \ +test shows 100%, preserve whatever phrasing drove that behavior. + +Style: direct imperative instructions organized with newlines. Short \ +sentences. Concrete tool call examples like bash(command='git log -5'). + +Length: no longer than 130% of the original prompt's length. + +Output ONLY the rewritten prompt. No commentary, no fences.\ +""" + + +OBSERVER_SYSTEM = """\ +You edit the rewriter's instructions shown below. The rewriter takes a \ +paragraph and test results, then rewrites the paragraph to score higher. \ +Your job: tune the rewriter's instructions so it does a better job. + +Your output replaces the rewriter's instructions. It must stay at the \ +same level — telling the rewriter HOW to rewrite, not doing the \ +rewriting yourself. + +Example of the right level (abbreviated): +\"\"\" +You are a text rewriter. You receive a paragraph and test results... +Style: flowing prose, no bullet points... +Length: aim for 600-1200 chars... +Be bold — reword, restructure... +\"\"\" + +Make 2-3 targeted edits based on the iteration history. Remove guidance \ +that isn't working. Stay under 150% of the input length. + +Output ONLY the modified rewriter instructions.\ +""" + + +def _observe_and_update_optimizer( + client: OpenAI, + model: str, + optimizer_system: str, + iterations: list[dict], +) -> str: + """Analyze optimizer behavior and return a modified OPTIMIZER_SYSTEM.""" + parts = [] + for i in range(1, len(iterations)): + prev, curr = iterations[i - 1], iterations[i] + prev_agg = prev.get("aggregate", {}) + curr_agg = curr.get("aggregate", {}) + prev_len = len(prev.get("prompt", "")) + curr_len = len(curr.get("prompt", "")) + len_delta = curr_len - prev_len + score_prev = prev_agg.get("overall_pass_rate", 0) + score_curr = curr_agg.get("overall_pass_rate", 0) + score_delta = score_curr - score_prev + + part = f"Iteration {i - 1} → {i}:\n" + part += f" Prompt: {len_delta:+d} chars ({prev_len} → {curr_len})\n" + part += f" Score: {score_prev:.0%} → {score_curr:.0%} ({score_delta:+.0%})\n" + + prev_rates = prev_agg.get("per_case_pass_rates", {}) + curr_rates = curr_agg.get("per_case_pass_rates", {}) + improved = [] + regressed = [] + for case_id in set(prev_rates) | set(curr_rates): + p = prev_rates.get(case_id, 0) + c = curr_rates.get(case_id, 0) + if c > p: + improved.append(f"{case_id} ({p:.0%}→{c:.0%})") + elif c < p: + regressed.append(f"{case_id} ({p:.0%}→{c:.0%})") + if improved: + part += f" Improved: {', '.join(improved)}\n" + if regressed: + part += f" Regressed: {', '.join(regressed)}\n" + if curr.get("prompt_diff"): + diff_text = curr["prompt_diff"][:500] + part += f" Diff:\n{diff_text}\n" + parts.append(part) + + # Summarize what the optimizer's output looked like (without showing + # full developer messages, which cause the observer to mimic them) + behavior_notes = [] + for it in iterations[-3:]: + idx = it.get("iteration", "?") + prompt = it.get("prompt", "") + score = it.get("aggregate", {}).get("overall_pass_rate", 0) + has_bullets = "- " in prompt or "* " in prompt + has_numbers = bool(re.search(r"^\d+\.", prompt, re.MULTILINE)) + has_headers = "**" in prompt or "##" in prompt + notes = [] + if has_bullets or has_numbers: + notes.append("used bullet/numbered lists") + if has_headers: + notes.append("used bold headers") + if len(prompt) > 1200: + notes.append(f"length={len(prompt)} chars (over 1200)") + elif len(prompt) < 600: + notes.append(f"length={len(prompt)} chars (under 600)") + else: + notes.append(f"length={len(prompt)} chars") + style = ", ".join(notes) if notes else "prose style" + behavior_notes.append(f"Iteration {idx} ({score:.0%}): {style}") + + user_content = ( + f"## Rewriter Instructions (edit these)\n" + f"```\n{optimizer_system}\n```\n\n" + f"## What the Rewriter Produced (do NOT mimic this)\n" + + "\n".join(behavior_notes) + + f"\n\n## Iteration History\n" + + "\n".join(parts) + ) + + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "developer", "content": OBSERVER_SYSTEM}, + {"role": "user", "content": user_content}, + ], + max_completion_tokens=2048, + temperature=0.3, + stream=False, + ) + + result = response.choices[0].message.content or optimizer_system + result = re.sub( + r"<(?:think|reasoning)>.*?", + "", + result, + flags=re.DOTALL, + ).strip() + + # Strip markdown code fences if wrapped + fence_match = re.search(r"```[^\n]*\n(.*?)```", result, re.DOTALL) + if fence_match: + result = fence_match.group(1).strip() + + # Reject degenerate outputs (>200% of input length) + if len(result) > len(optimizer_system) * 2.0: + _log( + f" Observer output too long ({len(result)} vs {len(optimizer_system)}), " + "keeping current optimizer system", + dim=True, + ) + return optimizer_system + + return result + + +def _propose_prompt_modification( + client: OpenAI, + model: str, + current_prompt: str, + test_cases: list[dict], + iteration_result: dict, + history: list[dict], + optimizer_system: str = OPTIMIZER_SYSTEM, +) -> str: + """Use the model to propose a new prompt based on evaluation results.""" + # Build summary of results + summary_parts = [] + for case_id, case_result in iteration_result["cases"].items(): + case_def = next((c for c in test_cases if c["id"] == case_id), None) + if not case_def: + continue + pr = case_result["pass_rate"] + status = "PASS" if pr == 1.0 else "WEAK" if pr >= 0.5 else "FAIL" + summary_parts.append( + f"[{status}] {case_id} (pass_rate={case_result['pass_rate']:.0%})\n" + f" User prompt: {case_def['user_prompt']}\n" + f" Expected: {json.dumps(case_def['expected_actions'])}\n" + f" Actual sequences: " + f"{[r.get('tool_sequence', []) for r in case_result['runs']]}" + ) + + # Build history summary (last 3 iterations) + history_parts = [] + for h in history[-3:]: + agg = h.get("aggregate", {}) + history_parts.append( + f"Iteration {h['iteration']}: " + f"overall_pass_rate={agg.get('overall_pass_rate', 0):.0%}, " + f"per_case={agg.get('per_case_pass_rates', {})}" + ) + + history_text = "\n".join(history_parts) if history_parts else "(first iteration)" + + user_content = ( + f"## Current Prompt\n```\n{current_prompt}\n```\n\n" + f"## Test Results (iteration {iteration_result.get('iteration', '?')})\n" + + "\n\n".join(summary_parts) + + f"\n\n## Score History\n{history_text}" + + "\n\nPropose an improved prompt. Output ONLY the new prompt text." + ) + + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "developer", "content": optimizer_system}, + {"role": "user", "content": user_content}, + ], + max_completion_tokens=16384, + temperature=0.6, + stream=False, + ) + + new_prompt = response.choices[0].message.content or current_prompt + + # Strip reasoning tags if present + new_prompt = re.sub( + r"<(?:think|reasoning)>.*?", + "", + new_prompt, + flags=re.DOTALL, + ).strip() + + # Strip markdown code fences if the model wrapped the prompt. + # Also discard any explanation text outside the fences. + fence_match = re.search(r"```[^\n]*\n(.*?)```", new_prompt, re.DOTALL) + if fence_match: + new_prompt = fence_match.group(1).strip() + elif new_prompt.startswith("```"): + # Opening fence without closing — strip just the first line + new_prompt = "\n".join(new_prompt.split("\n")[1:]).strip() + + return new_prompt + + +def _simple_diff(old: str, new: str) -> str: + """Generate a simple line-level diff between two prompts.""" + old_lines = old.splitlines(keepends=True) + new_lines = new.splitlines(keepends=True) + diff = difflib.unified_diff(old_lines, new_lines, fromfile="before", tofile="after") + return "".join(diff) + + +# ─── Main optimization loop ───────────────────────────────────────────────── + + +def run_optimization( + base_url: str, + model: str | None, + test_file: str, + initial_prompt: str | None = None, + n_runs: int = 3, + max_iterations: int = 5, + temperature: float = 0.7, + max_tokens: int = 32768, + reasoning_effort: str = "medium", + output_file: str = "eval_results.json", + context_window: int = 131072, + verbose: bool = False, +): + """Main optimization loop.""" + client = OpenAI( + base_url=base_url, + api_key=os.environ.get("OPENAI_API_KEY", "dummy"), + ) + + if not model: + model = _detect_model(client) + + # Load test cases + with open(test_file) as f: + suite = json.load(f) + + cases = suite["cases"] + for i, case in enumerate(cases): + if "id" not in case: + raise SystemExit(f"Test case {i} missing required 'id' field") + if "user_prompt" not in case: + raise SystemExit(f"Test case '{case.get('id', i)}' missing 'user_prompt'") + defaults = suite.get("defaults", {}) + # Precedence: CLI arg (non-None) > tests.json defaults > code default (3) + if n_runs is None: + n_runs = defaults.get("n_runs", 3) + + # Get initial prompt + if initial_prompt is None: + # Extract the default developer message from a temporary ChatSession + tmp = ChatSession( + client=client, + model=model, + persona=None, + instructions=None, + temperature=temperature, + max_tokens=max_tokens, + tool_timeout=30, + reasoning_effort=reasoning_effort, + context_window=context_window, + ) + initial_prompt = next( + m["content"] for m in tmp.system_messages if m["role"] == "developer" + ) + # Strip memory reminder — it's a runtime artifact, not part of the prompt + initial_prompt = re.sub( + r"\n*REMINDER: You currently have \d+ memories stored\..*$", + "", + initial_prompt, + ).strip() + + current_prompt = initial_prompt + results = { + "meta": { + "model": model, + "base_url": base_url, + "started": datetime.now().isoformat(), + "test_suite": test_file, + "n_runs_default": n_runs, + }, + "iterations": [], + } + + current_optimizer_system = OPTIMIZER_SYSTEM + + for iteration in range(max_iterations): + print(f"\n{'=' * 60}") + print(f"Iteration {iteration}") + print(f"{'=' * 60}") + + iter_result = _run_iteration( + client=client, + model=model, + system_prompt=current_prompt, + cases=cases, + n_runs=n_runs, + temperature=temperature, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + context_window=context_window, + verbose=verbose, + ) + iter_result["iteration"] = iteration + iter_result["prompt"] = current_prompt + iter_result["prompt_diff"] = None + iter_result["optimizer_system"] = current_optimizer_system + iter_result["timestamp"] = datetime.now().isoformat() + + results["iterations"].append(iter_result) + + # Write intermediate results + with open(output_file, "w") as f: + json.dump(results, f, indent=2) + + # Print summary + agg = iter_result["aggregate"] + json_dumps = agg.get("json_dumps", 0) + jd_str = f" {YELLOW}json_dumps={json_dumps}{RESET}" if json_dumps else "" + print(f"\nOverall pass rate: {agg['overall_pass_rate']:.0%}{jd_str}") + print(f"Overall avg score: {agg['overall_avg_score']:.2f}") + for case_id, rate in agg["per_case_pass_rates"].items(): + status = "PASS" if rate == 1.0 else "FAIL" + print(f" [{status}] {case_id}: {rate:.0%}") + + # Check if all passing + if agg["overall_pass_rate"] == 1.0: + print("\nAll test cases passing! Stopping.") + break + + # Propose new prompt (skip on last iteration) + if iteration < max_iterations - 1: + # Observer: update optimizer developer prompt every 3 iterations + if iteration >= 2 and iteration % 3 == 2: + _log(" Observer updating optimizer prompt...", dim=True) + try: + new_opt = _observe_and_update_optimizer( + client, + model, + current_optimizer_system, + results["iterations"], + ) + if new_opt != current_optimizer_system: + opt_diff = _simple_diff(current_optimizer_system, new_opt) + _log(f" Observer diff:\n{opt_diff}", dim=True) + current_optimizer_system = new_opt + # Reset to the best-performing developer prompt so far. + best_iter = max( + results["iterations"], + key=lambda it: it["aggregate"]["overall_pass_rate"], + ) + best_rate = best_iter["aggregate"]["overall_pass_rate"] + best_idx = best_iter["iteration"] + current_prompt = best_iter["prompt"] + _log( + f" Observer changed strategy → reset developer prompt " + f"to best (iter {best_idx}, {best_rate:.0%})", + dim=True, + ) + else: + _log(" Observer: no changes", dim=True) + except Exception as e: + _log(f" Observer error: {e}", dim=True) + + print("\nOptimizing prompt...") + try: + new_prompt = _propose_prompt_modification( + client=client, + model=model, + current_prompt=current_prompt, + test_cases=cases, + iteration_result=iter_result, + history=results["iterations"], + optimizer_system=current_optimizer_system, + ) + except Exception as e: + _log(f" Prompt modification failed: {e}", dim=True) + continue + + if new_prompt != current_prompt: + diff = _simple_diff(current_prompt, new_prompt) + print( + f"Prompt modified " + f"({len(current_prompt)} -> {len(new_prompt)} chars)" + ) + if diff: + print(diff) + iter_result["prompt_diff"] = diff + # Re-write with the diff included + with open(output_file, "w") as f: + json.dump(results, f, indent=2) + current_prompt = new_prompt + else: + print("Optimizer returned identical prompt. Stopping.") + break + + print(f"\nResults written to {output_file}") + return results + + +def _detect_model(client: OpenAI) -> str: + """Auto-detect the model from the API.""" + try: + models = client.models.list() + model_ids = [m.id for m in models.data] + if model_ids: + return model_ids[0] + except Exception: + pass + raise SystemExit("Could not auto-detect model. Use --model to specify.") + + +# ─── CLI ───────────────────────────────────────────────────────────────────── + + +def main(): + parser = argparse.ArgumentParser( + description="Prompt optimization and evaluation for turnstone", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + Examples: + # Run evaluation with default prompt + turnstone-eval tests.json + + # Run with custom initial prompt from file + turnstone-eval tests.json --prompt prompt.txt + + # Single evaluation pass (no optimization) + turnstone-eval tests.json --no-optimize + + # Configure runs and iterations + turnstone-eval tests.json --n-runs 5 --max-iter 10 + """), + ) + parser.add_argument( + "test_file", + help="Path to test cases JSON file", + ) + parser.add_argument( + "--base-url", + default="http://localhost:8000/v1", + help="API base URL (default: http://localhost:8000/v1)", + ) + parser.add_argument( + "--model", + default=None, + help="Model name (default: auto-detect)", + ) + parser.add_argument( + "--prompt", + default=None, + help="Path to initial prompt text file (default: use turnstone's built-in)", + ) + parser.add_argument( + "--n-runs", + type=int, + default=None, + help="Number of runs per test case (default: from tests.json or 3)", + ) + parser.add_argument( + "--max-iter", + type=int, + default=5, + help="Maximum optimization iterations (default: 5)", + ) + parser.add_argument( + "--no-optimize", + action="store_true", + help="Run evaluation only, no prompt optimization", + ) + parser.add_argument( + "--temperature", + type=float, + default=0.7, + help="Sampling temperature (default: 0.7)", + ) + parser.add_argument( + "--max-tokens", + type=int, + default=32768, + help="Max completion tokens (default: 32768)", + ) + parser.add_argument( + "--reasoning-effort", + default="medium", + choices=["low", "medium", "high"], + help="Reasoning effort (default: medium)", + ) + parser.add_argument( + "--context-window", + type=int, + default=131072, + help="Context window size (default: 131072)", + ) + parser.add_argument( + "--output", + default="eval_results.json", + help="Output results file (default: eval_results.json)", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Show detailed per-turn logging (API calls, tool args, results)", + ) + from turnstone.core.config import apply_config + + apply_config(parser, ["api", "model"]) + args = parser.parse_args() + + # Load initial prompt if provided + initial_prompt = None + if args.prompt: + with open(args.prompt) as f: + initial_prompt = f.read() + + max_iter = 1 if args.no_optimize else args.max_iter + + run_optimization( + base_url=args.base_url, + model=args.model, + test_file=args.test_file, + initial_prompt=initial_prompt, + n_runs=args.n_runs, + max_iterations=max_iter, + temperature=args.temperature, + max_tokens=args.max_tokens, + reasoning_effort=args.reasoning_effort, + output_file=args.output, + context_window=args.context_window, + verbose=args.verbose, + ) + + +if __name__ == "__main__": + main() diff --git a/turnstone/mq/__init__.py b/turnstone/mq/__init__.py new file mode 100644 index 00000000..51389488 --- /dev/null +++ b/turnstone/mq/__init__.py @@ -0,0 +1,11 @@ +"""Message queue integration for turnstone. + +Provides a bridge service (turnstone-bridge) that connects message queues to the +turnstone-server HTTP API, and a client library for external systems to publish +commands and subscribe to progress. +""" + +from turnstone.mq.broker import MessageBroker, RedisBroker +from turnstone.mq.client import TurnstoneClient, TurnResult + +__all__ = ["MessageBroker", "RedisBroker", "TurnstoneClient", "TurnResult"] diff --git a/turnstone/mq/bridge.py b/turnstone/mq/bridge.py new file mode 100644 index 00000000..d71b51ee --- /dev/null +++ b/turnstone/mq/bridge.py @@ -0,0 +1,777 @@ +"""Bridge service — connects message queues to turnstone-server's HTTP API. + +The bridge (listener+speaker) reads commands from an inbound queue, drives +workstreams on the turnstone-server via HTTP, consumes SSE for progress, and +publishes events to outbound pub/sub channels. + +Run as: ``turnstone-bridge --server-url http://localhost:8080`` +""" + +from __future__ import annotations + +import json +import logging +import os +import socket +import threading +import time +import uuid +from collections.abc import Callable, Iterator + +import httpx + +from turnstone.mq.broker import RedisBroker +from turnstone.mq.protocol import ( + AckEvent, + ApprovalRequestEvent, + ClusterStateEvent, + ContentEvent, + ErrorEvent, + HealthResponseEvent, + InboundMessage, + InfoEvent, + NodeListEvent, + OutboundEvent, + PlanReviewEvent, + ReasoningEvent, + StateChangeEvent, + StatusEvent, + StreamEndEvent, + ToolInfoEvent, + ToolResultEvent, + TurnCompleteEvent, + WorkstreamClosedEvent, + WorkstreamCreatedEvent, + WorkstreamListEvent, + WorkstreamRenameEvent, +) + +log = logging.getLogger("turnstone.mq.bridge") + +# Server's default safe tools (auto-approved without user confirmation) +DEFAULT_SAFE_TOOLS = frozenset( + ["read_file", "search", "man", "remember", "recall", "forget"] +) + + +def _default_node_id() -> str: + """Generate a default node_id: ``{hostname}_{4hex}``, or a UUID on failure.""" + suffix = uuid.uuid4().hex[:4] + try: + host = socket.gethostname() + if host and host != "localhost": + return f"{host}_{suffix}" + except OSError: + pass + return uuid.uuid4().hex[:12] + + +class Bridge: + """Connects a message broker to turnstone-server's HTTP API. + + Threading model:: + + Main Thread: Inbound loop (BLPOP on broker) + Global SSE Thread: GET /api/events/global + Per-WS SSE Thread × N: GET /api/events?ws_id=X + """ + + def __init__( + self, + server_url: str = "http://localhost:8080", + broker: RedisBroker | None = None, + approval_timeout: float = 300, + prefix: str = "turnstone", + node_id: str = "", + heartbeat_ttl: int = 60, + auth_token: str = "", + ): + self._server_url = server_url.rstrip("/") + self._broker = broker or RedisBroker() + self._approval_timeout = approval_timeout + self._prefix = prefix + self._node_id = node_id or _default_node_id() + self._heartbeat_ttl = heartbeat_ttl + self._started_at = time.time() + self._auth_token = auth_token + + # Shared httpx client for short-lived POST requests (main thread only) + headers: dict[str, str] = {} + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + self._http = httpx.Client( + base_url=self._server_url, timeout=30, headers=headers + ) + + # Protected by _lock — accessed from main, global SSE, and per-ws SSE threads + self._lock = threading.Lock() + self._ws_threads: dict[str, threading.Thread] = {} + self._ws_auto_approve: dict[str, bool] = {} + self._ws_approve_tools: dict[str, set[str]] = {} + self._active_sends: dict[str, str] = {} # ws_id → correlation_id + self._running = True + + # -- public entry point -------------------------------------------------- + + def run(self) -> None: + """Block until shutdown (KeyboardInterrupt).""" + log.info("Bridge starting — node=%s server=%s", self._node_id, self._server_url) + self._recover_workstreams() + + heartbeat_t = threading.Thread(target=self._heartbeat_loop, daemon=True) + heartbeat_t.start() + + global_t = threading.Thread(target=self._global_sse_loop, daemon=True) + global_t.start() + + try: + self._inbound_loop() + except KeyboardInterrupt: + log.info("Bridge shutting down") + finally: + self._running = False + self._http.close() + self._broker.close() + + # -- recovery ------------------------------------------------------------ + + def _recover_workstreams(self) -> None: + """Discover active workstreams on startup and register ownership.""" + try: + resp = self._http.get("/api/workstreams") + data = resp.json() + for ws in data.get("workstreams", []): + ws_id = ws["id"] + log.info("Recovered workstream %s (%s)", ws_id, ws.get("name", "")) + self._broker.set_ws_owner(ws_id, self._node_id) + self._start_ws_sse(ws_id) + except Exception as exc: + log.warning("Could not recover workstreams: %s", exc) + + # -- inbound loop -------------------------------------------------------- + + def _inbound_loop(self) -> None: + while self._running: + raw = self._broker.pop_inbound(timeout=5, node_id=self._node_id) + if raw is None: + continue + try: + msg = InboundMessage.from_json(raw) + self._dispatch(msg) + except Exception as exc: + log.error("Failed to process inbound message: %s", exc) + self._publish_global( + ErrorEvent(message=f"Failed to process message: {exc}") + ) + + def _dispatch(self, msg: InboundMessage) -> None: + # Messages that need routing (have ws_id or target_node) + routed_handlers = { + "send": self._handle_send, + "command": self._handle_command, + "create_workstream": self._handle_create_ws, + "close_workstream": self._handle_close_ws, + } + # Messages that are always local (no routing needed) + local_handlers = { + "approve": self._handle_approve, + "plan_feedback": self._handle_plan_feedback, + "list_workstreams": self._handle_list_ws, + "health": self._handle_health, + "list_nodes": self._handle_list_nodes, + } + + if msg.type in routed_handlers: + self._route_or_process(msg, routed_handlers[msg.type]) + elif msg.type in local_handlers: + local_handlers[msg.type](msg) + else: + self._publish_global( + ErrorEvent( + correlation_id=msg.correlation_id, + message=f"Unknown message type: {msg.type!r}", + ) + ) + + def _route_or_process( + self, msg: InboundMessage, handler: Callable[[InboundMessage], None] + ) -> None: + """Route a message to the correct node, or process locally.""" + target = getattr(msg, "target_node", "") + ws_id = getattr(msg, "ws_id", "") + + # Directed to a different node? + if target and target != self._node_id: + log.debug("Routing to node %s: %s", target, msg.type) + self._broker.push_inbound(msg.to_json(), node_id=target) + return + + # Existing workstream owned by another node? + if ws_id: + owner = self._broker.get_ws_owner(ws_id) + if owner and owner != self._node_id: + log.debug("Re-routing to owner %s for ws %s", owner, ws_id) + self._broker.push_inbound(msg.to_json(), node_id=owner) + return + + handler(msg) + + # -- handlers ------------------------------------------------------------ + + def _handle_send(self, msg: InboundMessage) -> None: + ws_id = getattr(msg, "ws_id", "") + message = getattr(msg, "message", "") + auto_approve = getattr(msg, "auto_approve", False) + auto_approve_tools = getattr(msg, "auto_approve_tools", []) + name = getattr(msg, "name", "") + + # Auto-create workstream if needed + if not ws_id: + ws_id = self._create_ws_on_server( + name=name, + auto_approve=auto_approve, + auto_approve_tools=auto_approve_tools, + correlation_id=msg.correlation_id, + ) + if not ws_id: + return # error already published + else: + # Update approval settings for existing workstream + with self._lock: + if auto_approve: + self._ws_auto_approve[ws_id] = True + if auto_approve_tools: + self._ws_approve_tools[ws_id] = set(auto_approve_tools) + + with self._lock: + self._active_sends[ws_id] = msg.correlation_id + + resp = self._http.post("/api/send", json={"message": message, "ws_id": ws_id}) + data = resp.json() + + self._publish_ws( + ws_id, + AckEvent( + ws_id=ws_id, + correlation_id=msg.correlation_id, + status="ok" if data.get("status") == "ok" else "error", + detail=data.get("error", ""), + ), + ) + + def _handle_approve(self, msg: InboundMessage) -> None: + request_id = getattr(msg, "request_id", "") + if request_id: + self._broker.push_response(request_id, msg.to_json()) + + def _handle_plan_feedback(self, msg: InboundMessage) -> None: + request_id = getattr(msg, "request_id", "") + if request_id: + self._broker.push_response(request_id, msg.to_json()) + + def _handle_command(self, msg: InboundMessage) -> None: + ws_id = getattr(msg, "ws_id", "") + command = getattr(msg, "command", "") + resp = self._http.post( + "/api/command", json={"command": command, "ws_id": ws_id} + ) + data = resp.json() + self._publish_ws( + ws_id, + AckEvent( + ws_id=ws_id, + correlation_id=msg.correlation_id, + status="ok" if data.get("status") == "ok" else "error", + detail=data.get("error", ""), + ), + ) + + def _handle_create_ws(self, msg: InboundMessage) -> None: + name = getattr(msg, "name", "") + auto_approve = getattr(msg, "auto_approve", False) + auto_approve_tools = getattr(msg, "auto_approve_tools", []) + self._create_ws_on_server( + name=name, + auto_approve=auto_approve, + auto_approve_tools=auto_approve_tools, + correlation_id=msg.correlation_id, + ) + + def _handle_close_ws(self, msg: InboundMessage) -> None: + ws_id = getattr(msg, "ws_id", "") + resp = self._http.post("/api/workstreams/close", json={"ws_id": ws_id}) + data = resp.json() + self._publish_ws( + ws_id, + AckEvent( + ws_id=ws_id, + correlation_id=msg.correlation_id, + status="ok" if data.get("status") == "ok" else "error", + detail=data.get("error", ""), + ), + ) + + def _handle_list_ws(self, msg: InboundMessage) -> None: + resp = self._http.get("/api/workstreams") + data = resp.json() + self._publish_global( + WorkstreamListEvent( + correlation_id=msg.correlation_id, + workstreams=data.get("workstreams", []), + ) + ) + + def _handle_health(self, msg: InboundMessage) -> None: + resp = self._http.get("/health") + data = resp.json() + self._publish_global( + HealthResponseEvent( + correlation_id=msg.correlation_id, + data=data, + ) + ) + + # -- workstream creation helper ------------------------------------------ + + def _create_ws_on_server( + self, + name: str, + auto_approve: bool, + auto_approve_tools: list[str], + correlation_id: str, + ) -> str: + """Create a workstream on the server. Returns ws_id or empty on error.""" + try: + resp = self._http.post( + "/api/workstreams/new", + json={"name": name, "auto_approve": auto_approve}, + ) + data = resp.json() + if "error" in data: + self._publish_global( + AckEvent( + correlation_id=correlation_id, + status="error", + detail=data["error"], + ) + ) + return "" + ws_id = data["ws_id"] + ws_name = data.get("name", "") + + self._broker.set_ws_owner(ws_id, self._node_id) + + with self._lock: + if auto_approve: + self._ws_auto_approve[ws_id] = True + if auto_approve_tools: + self._ws_approve_tools[ws_id] = set(auto_approve_tools) + + self._start_ws_sse(ws_id) + + self._publish_global( + WorkstreamCreatedEvent( + ws_id=ws_id, + name=ws_name, + correlation_id=correlation_id, + ) + ) + self._publish_cluster( + WorkstreamCreatedEvent( + ws_id=ws_id, + name=ws_name, + correlation_id=correlation_id, + ) + ) + return ws_id + except Exception as exc: + self._publish_global( + AckEvent( + correlation_id=correlation_id, + status="error", + detail=str(exc), + ) + ) + return "" + + # -- SSE consumption ----------------------------------------------------- + + def _start_ws_sse(self, ws_id: str) -> None: + with self._lock: + if ws_id in self._ws_threads and self._ws_threads[ws_id].is_alive(): + return + t = threading.Thread(target=self._ws_sse_loop, args=(ws_id,), daemon=True) + self._ws_threads[ws_id] = t + t.start() + + def _ws_sse_loop(self, ws_id: str) -> None: + """Consume per-workstream SSE and forward events.""" + # Each SSE thread gets its own httpx client (not thread-safe to share) + sse_headers: dict[str, str] = {} + if self._auth_token: + sse_headers["Authorization"] = f"Bearer {self._auth_token}" + with httpx.Client( + base_url=self._server_url, timeout=None, headers=sse_headers + ) as sse_client: + while self._running: + try: + with sse_client.stream("GET", f"/api/events?ws_id={ws_id}") as resp: + for data in _iter_sse_data(resp): + if not self._running: + break + self._handle_ws_event(ws_id, data) + except Exception as exc: + if self._running: + log.debug("WS SSE reconnecting (%s): %s", ws_id, exc) + time.sleep(2) + + def _handle_ws_event(self, ws_id: str, data: dict) -> None: + etype = data.get("type", "") + + if etype == "content": + self._publish_ws( + ws_id, ContentEvent(ws_id=ws_id, text=data.get("text", "")) + ) + elif etype == "reasoning": + self._publish_ws( + ws_id, ReasoningEvent(ws_id=ws_id, text=data.get("text", "")) + ) + elif etype == "tool_info": + self._publish_ws( + ws_id, ToolInfoEvent(ws_id=ws_id, items=data.get("items", [])) + ) + elif etype == "approve_request": + self._handle_approval(ws_id, data) + elif etype == "plan_review": + self._handle_plan_review(ws_id, data) + elif etype == "tool_result": + self._publish_ws( + ws_id, + ToolResultEvent( + ws_id=ws_id, + name=data.get("name", ""), + output=data.get("output", ""), + ), + ) + elif etype == "status": + self._publish_ws( + ws_id, + StatusEvent( + ws_id=ws_id, + prompt_tokens=data.get("prompt_tokens", 0), + completion_tokens=data.get("completion_tokens", 0), + total_tokens=data.get("total_tokens", 0), + context_window=data.get("context_window", 0), + pct=data.get("pct", 0), + effort=data.get("effort", ""), + ), + ) + elif etype == "error": + self._publish_ws( + ws_id, ErrorEvent(ws_id=ws_id, message=data.get("message", "")) + ) + elif etype == "info": + self._publish_ws( + ws_id, InfoEvent(ws_id=ws_id, message=data.get("message", "")) + ) + elif etype == "stream_end": + self._publish_ws(ws_id, StreamEndEvent(ws_id=ws_id)) + + def _handle_approval(self, ws_id: str, data: dict) -> None: + """Handle an approval request — auto-approve or forward to client.""" + items = data.get("items", []) + + # Check if all tools can be auto-approved + with self._lock: + if self._ws_auto_approve.get(ws_id): + self._api_approve(ws_id, approved=True) + return + approve_set = self._ws_approve_tools.get(ws_id, DEFAULT_SAFE_TOOLS) + + tool_names = {it.get("name", "") for it in items if it.get("needs_approval")} + + if tool_names and tool_names.issubset(approve_set): + self._api_approve(ws_id, approved=True) + return + + # Forward to client — spawn a thread so we don't block SSE consumption + request_id = uuid.uuid4().hex[:12] + self._publish_ws( + ws_id, + ApprovalRequestEvent( + ws_id=ws_id, + correlation_id=request_id, + items=items, + ), + ) + + def _wait_approval() -> None: + raw_resp = self._broker.pop_response( + request_id, timeout=self._approval_timeout + ) + if raw_resp: + resp_msg = InboundMessage.from_json(raw_resp) + approved = getattr(resp_msg, "approved", False) + feedback = getattr(resp_msg, "feedback", None) + always = getattr(resp_msg, "always", False) + self._api_approve(ws_id, approved=approved, feedback=feedback) + if always: + with self._lock: + self._ws_auto_approve[ws_id] = True + else: + log.warning("Approval timeout for ws %s — denying", ws_id) + self._api_approve(ws_id, approved=False, feedback="Approval timed out") + + threading.Thread(target=_wait_approval, daemon=True).start() + + def _handle_plan_review(self, ws_id: str, data: dict) -> None: + """Handle a plan review request — auto-approve or forward to client.""" + with self._lock: + if self._ws_auto_approve.get(ws_id): + self._http.post("/api/plan", json={"feedback": "", "ws_id": ws_id}) + return + + request_id = uuid.uuid4().hex[:12] + self._publish_ws( + ws_id, + PlanReviewEvent( + ws_id=ws_id, + correlation_id=request_id, + content=data.get("content", ""), + ), + ) + + def _wait_plan() -> None: + raw_resp = self._broker.pop_response( + request_id, timeout=self._approval_timeout + ) + if raw_resp: + resp_msg = InboundMessage.from_json(raw_resp) + feedback = getattr(resp_msg, "feedback", "") + self._http.post( + "/api/plan", json={"feedback": feedback, "ws_id": ws_id} + ) + else: + log.warning("Plan review timeout for ws %s — rejecting", ws_id) + self._http.post( + "/api/plan", json={"feedback": "reject", "ws_id": ws_id} + ) + + threading.Thread(target=_wait_plan, daemon=True).start() + + def _api_approve( + self, + ws_id: str, + approved: bool, + feedback: str | None = None, + ) -> None: + body: dict = {"approved": approved, "ws_id": ws_id} + if feedback: + body["feedback"] = feedback + self._http.post("/api/approve", json=body) + + # -- global SSE ---------------------------------------------------------- + + def _global_sse_loop(self) -> None: + # Own httpx client for the long-lived SSE connection + sse_headers: dict[str, str] = {} + if self._auth_token: + sse_headers["Authorization"] = f"Bearer {self._auth_token}" + with httpx.Client( + base_url=self._server_url, timeout=None, headers=sse_headers + ) as sse_client: + while self._running: + try: + with sse_client.stream("GET", "/api/events/global") as resp: + for data in _iter_sse_data(resp): + if not self._running: + break + self._handle_global_event(data) + except Exception as exc: + if self._running: + log.debug("Global SSE reconnecting: %s", exc) + time.sleep(2) + + def _handle_global_event(self, data: dict) -> None: + etype = data.get("type", "") + ws_id = data.get("ws_id", "") + + if etype == "ws_state": + state = data.get("state", "") + self._publish_ws(ws_id, StateChangeEvent(ws_id=ws_id, state=state)) + self._publish_global(StateChangeEvent(ws_id=ws_id, state=state)) + self._publish_cluster( + ClusterStateEvent( + ws_id=ws_id, + state=state, + node_id=self._node_id, + tokens=data.get("tokens", 0), + context_ratio=data.get("context_ratio", 0.0), + activity=data.get("activity", ""), + activity_state=data.get("activity_state", ""), + ) + ) + + # Completion detection + if state == "idle": + with self._lock: + cid = self._active_sends.pop(ws_id, None) + if cid: + self._publish_ws( + ws_id, TurnCompleteEvent(ws_id=ws_id, correlation_id=cid) + ) + + elif etype == "ws_rename": + self._publish_global( + WorkstreamRenameEvent(ws_id=ws_id, name=data.get("name", "")) + ) + self._publish_cluster( + WorkstreamRenameEvent(ws_id=ws_id, name=data.get("name", "")) + ) + + elif etype == "ws_closed": + self._publish_global(WorkstreamClosedEvent(ws_id=ws_id)) + self._publish_cluster(WorkstreamClosedEvent(ws_id=ws_id)) + self._broker.del_ws_owner(ws_id) + with self._lock: + self._ws_threads.pop(ws_id, None) + self._ws_auto_approve.pop(ws_id, None) + self._ws_approve_tools.pop(ws_id, None) + self._active_sends.pop(ws_id, None) + + # -- heartbeat ----------------------------------------------------------- + + def _heartbeat_loop(self) -> None: + """Periodically register this node in the broker.""" + while self._running: + self._broker.register_node( + self._node_id, + {"server_url": self._server_url, "started": self._started_at}, + ttl=self._heartbeat_ttl, + ) + time.sleep(self._heartbeat_ttl / 2) + + # -- node listing -------------------------------------------------------- + + def _handle_list_nodes(self, msg: InboundMessage) -> None: + nodes = self._broker.list_nodes() + self._publish_global( + NodeListEvent(correlation_id=msg.correlation_id, nodes=nodes) + ) + + # -- publish helpers ----------------------------------------------------- + + def _publish_ws(self, ws_id: str, event: OutboundEvent) -> None: + channel = f"{self._prefix}:events:{ws_id}" + self._broker.publish_outbound(channel, event.to_json()) + + def _publish_global(self, event: OutboundEvent) -> None: + self._broker.publish_outbound(f"{self._prefix}:events:global", event.to_json()) + + def _publish_cluster(self, event: OutboundEvent) -> None: + self._broker.publish_outbound(f"{self._prefix}:events:cluster", event.to_json()) + + +# --------------------------------------------------------------------------- +# SSE parsing helper +# --------------------------------------------------------------------------- + + +def _iter_sse_data(resp: httpx.Response) -> Iterator[dict]: + """Yield parsed JSON dicts from an SSE stream.""" + for line in resp.iter_lines(): + if line.startswith("data: "): + try: + yield json.loads(line[6:]) + except json.JSONDecodeError: + pass + # SSE keepalive comments (lines starting with ':') are ignored + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser( + description="turnstone message queue bridge — connects Redis queues to turnstone-server" + ) + parser.add_argument( + "--server-url", + default="http://localhost:8080", + help="turnstone-server URL (default: %(default)s)", + ) + parser.add_argument( + "--redis-host", default="localhost", help="Redis host (default: %(default)s)" + ) + parser.add_argument( + "--redis-port", type=int, default=6379, help="Redis port (default: %(default)s)" + ) + parser.add_argument( + "--redis-password", + default=os.environ.get("REDIS_PASSWORD"), + help="Redis password (default: $REDIS_PASSWORD)", + ) + parser.add_argument( + "--redis-db", type=int, default=0, help="Redis DB number (default: %(default)s)" + ) + parser.add_argument( + "--approval-timeout", + type=float, + default=300, + help="Seconds to wait for approval responses (default: %(default)s)", + ) + parser.add_argument( + "--node-id", + default="", + help="Node identifier for multi-node routing (default: hostname)", + ) + parser.add_argument( + "--heartbeat-ttl", + type=int, + default=60, + help="Heartbeat TTL in seconds (default: %(default)s)", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="Log level (default: %(default)s)", + ) + parser.add_argument( + "--auth-token", + default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""), + help="Bearer token for authenticating to turnstone-server (default: $TURNSTONE_AUTH_TOKEN)", + ) + from turnstone.core.config import apply_config + + apply_config(parser, ["bridge", "redis", "auth"]) + args = parser.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) + + broker = RedisBroker( + host=args.redis_host, + port=args.redis_port, + db=args.redis_db, + password=args.redis_password, + ) + bridge = Bridge( + server_url=args.server_url, + broker=broker, + approval_timeout=args.approval_timeout, + node_id=args.node_id, + heartbeat_ttl=args.heartbeat_ttl, + auth_token=args.auth_token, + ) + bridge.run() + + +if __name__ == "__main__": + main() diff --git a/turnstone/mq/broker.py b/turnstone/mq/broker.py new file mode 100644 index 00000000..e531c62c --- /dev/null +++ b/turnstone/mq/broker.py @@ -0,0 +1,237 @@ +"""Abstract message broker and Redis implementation. + +The MessageBroker protocol defines the interface for inbound queuing, outbound +pub/sub, per-request response queues, and multi-node routing primitives. +RedisBroker is the default provider. +""" + +from __future__ import annotations + +import json +import threading +from typing import Callable, Protocol + + +class MessageBroker(Protocol): + """Abstract message broker for inbound/outbound communication. + + Implementations must provide: + - Reliable inbound queue (FIFO, at-least-once delivery) + - Outbound pub/sub channels (fan-out to all subscribers) + - Per-request response queues for approval request/response correlation + - Workstream ownership tracking (ws_id → node_id) + - Node registry with heartbeat + """ + + def push_inbound(self, message: str, node_id: str = "") -> None: + """Push a message onto the inbound queue. + + If *node_id* is set, pushes to the per-node queue for directed + routing. Otherwise pushes to the shared queue. + """ + ... + + def pop_inbound(self, timeout: float = 0, node_id: str = "") -> str | None: + """Pop next message from the inbound queue (bridge side). + + If *node_id* is set, BLPOPs from both the per-node queue (priority) + and the shared queue. Otherwise BLPOPs from the shared queue only. + Returns None on timeout. + """ + ... + + def publish_outbound(self, channel: str, event: str) -> None: + """Publish an event to an outbound channel.""" + ... + + def subscribe_outbound(self, channel: str, callback: Callable[[str], None]) -> None: + """Subscribe to an outbound channel.""" + ... + + def unsubscribe_outbound(self, channel: str) -> None: + """Unsubscribe from an outbound channel.""" + ... + + def push_response(self, queue_name: str, message: str) -> None: + """Push a response onto a named response queue.""" + ... + + def pop_response(self, queue_name: str, timeout: float = 300) -> str | None: + """Pop from a named response queue. Returns None on timeout.""" + ... + + # -- routing primitives -------------------------------------------------- + + def set_ws_owner(self, ws_id: str, node_id: str, ttl: int = 0) -> None: + """Register which node owns a workstream.""" + ... + + def get_ws_owner(self, ws_id: str) -> str | None: + """Look up the node that owns a workstream. Returns None if unowned.""" + ... + + def del_ws_owner(self, ws_id: str) -> None: + """Remove workstream ownership (on close).""" + ... + + def register_node(self, node_id: str, metadata: dict, ttl: int = 60) -> None: + """Register or refresh a node's heartbeat with metadata.""" + ... + + def list_nodes(self) -> list[dict]: + """List all active nodes (those with unexpired heartbeats).""" + ... + + def subscribe_cluster(self, callback: Callable[[str], None]) -> None: + """Subscribe to the cluster-wide event channel.""" + ... + + def close(self) -> None: + """Clean up connections.""" + ... + + +class RedisBroker: + """Redis-backed MessageBroker using lists (queues) and pub/sub (events). + + Queue keys: + ``{prefix}:inbound`` — shared inbound command queue + ``{prefix}:inbound:{node_id}`` — per-node directed queue + ``{prefix}:resp:{request_id}`` — per-request response queues + + Routing keys: + ``{prefix}:ws:{ws_id}`` — workstream ownership (string) + ``{prefix}:node:{node_id}`` — node heartbeat + metadata (string/JSON) + + Pub/sub channels: + ``{prefix}:events:global`` — global event channel + ``{prefix}:events:{ws_id}`` — per-workstream event channel + ``{prefix}:events:cluster`` — cluster-wide state changes + """ + + def __init__( + self, + host: str = "localhost", + port: int = 6379, + db: int = 0, + prefix: str = "turnstone", + password: str | None = None, + response_ttl: int = 600, + ): + import redis + + self._prefix = prefix + self._response_ttl = response_ttl + self._pool = redis.ConnectionPool( + host=host, + port=port, + db=db, + password=password, + decode_responses=True, + retry_on_timeout=True, + ) + self._redis = redis.Redis(connection_pool=self._pool) + self._pubsub = self._redis.pubsub(ignore_subscribe_messages=True) + self._listener_thread: threading.Thread | None = None + self._running = True + + # -- inbound queue ------------------------------------------------------- + + def push_inbound(self, message: str, node_id: str = "") -> None: + if node_id: + self._redis.rpush(f"{self._prefix}:inbound:{node_id}", message) + else: + self._redis.rpush(f"{self._prefix}:inbound", message) + + def pop_inbound(self, timeout: float = 0, node_id: str = "") -> str | None: + t = int(timeout) if timeout > 0 else 0 + if node_id: + # Per-node queue first (priority), then shared queue + result = self._redis.blpop( + [f"{self._prefix}:inbound:{node_id}", f"{self._prefix}:inbound"], + timeout=t, + ) + else: + result = self._redis.blpop(f"{self._prefix}:inbound", timeout=t) + return result[1] if result else None + + # -- outbound pub/sub ---------------------------------------------------- + + def publish_outbound(self, channel: str, event: str) -> None: + self._redis.publish(channel, event) + + def subscribe_outbound(self, channel: str, callback: Callable[[str], None]) -> None: + self._pubsub.subscribe(**{channel: lambda msg: callback(msg["data"])}) + if self._listener_thread is None or not self._listener_thread.is_alive(): + self._listener_thread = self._pubsub.run_in_thread( + sleep_time=0.1, daemon=True + ) + + def unsubscribe_outbound(self, channel: str) -> None: + self._pubsub.unsubscribe(channel) + + # -- response queues ----------------------------------------------------- + + def push_response(self, queue_name: str, message: str) -> None: + key = f"{self._prefix}:resp:{queue_name}" + self._redis.rpush(key, message) + self._redis.expire(key, self._response_ttl) + + def pop_response(self, queue_name: str, timeout: float = 300) -> str | None: + key = f"{self._prefix}:resp:{queue_name}" + result = self._redis.blpop(key, timeout=int(timeout)) + return result[1] if result else None + + # -- routing primitives -------------------------------------------------- + + def set_ws_owner(self, ws_id: str, node_id: str, ttl: int = 0) -> None: + key = f"{self._prefix}:ws:{ws_id}" + if ttl > 0: + self._redis.set(key, node_id, ex=ttl) + else: + self._redis.set(key, node_id) + + def get_ws_owner(self, ws_id: str) -> str | None: + return self._redis.get(f"{self._prefix}:ws:{ws_id}") + + def del_ws_owner(self, ws_id: str) -> None: + self._redis.delete(f"{self._prefix}:ws:{ws_id}") + + def register_node(self, node_id: str, metadata: dict, ttl: int = 60) -> None: + key = f"{self._prefix}:node:{node_id}" + self._redis.set(key, json.dumps(metadata), ex=ttl) + + def list_nodes(self) -> list[dict]: + pattern = f"{self._prefix}:node:*" + prefix_len = len(f"{self._prefix}:node:") + nodes = [] + for key in self._redis.scan_iter(match=pattern, count=100): + raw = self._redis.get(key) + if raw: + try: + meta = json.loads(raw) + except json.JSONDecodeError: + meta = {} + meta["node_id"] = key[prefix_len:] + nodes.append(meta) + return nodes + + # -- cluster event channel ----------------------------------------------- + + def subscribe_cluster(self, callback: Callable[[str], None]) -> None: + """Subscribe to the cluster-wide event channel.""" + channel = f"{self._prefix}:events:cluster" + self.subscribe_outbound(channel, callback) + + # -- lifecycle ----------------------------------------------------------- + + def close(self) -> None: + self._running = False + if self._listener_thread is not None: + self._listener_thread.stop() + self._listener_thread = None + try: + self._pubsub.close() + except Exception: + pass + self._pool.disconnect() diff --git a/turnstone/mq/client.py b/turnstone/mq/client.py new file mode 100644 index 00000000..712c5046 --- /dev/null +++ b/turnstone/mq/client.py @@ -0,0 +1,322 @@ +"""Client library for interacting with turnstone through a message broker. + +Usage:: + + from turnstone.mq.client import TurnstoneClient + + client = TurnstoneClient() + result = client.send_and_wait( + "What files are in the current directory?", + auto_approve=True, + ) + print(result.content) + client.close() +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from typing import Callable + +from turnstone.mq.broker import MessageBroker, RedisBroker +from turnstone.mq.protocol import ( + ApproveMessage, + CloseWorkstreamMessage, + CommandMessage, + ContentEvent, + CreateWorkstreamMessage, + ErrorEvent, + HealthMessage, + ListWorkstreamsMessage, + OutboundEvent, + PlanFeedbackMessage, + ReasoningEvent, + SendMessage, + ToolResultEvent, + TurnCompleteEvent, + WorkstreamCreatedEvent, +) + + +@dataclass +class TurnResult: + """Aggregated result of a send_and_wait call.""" + + correlation_id: str = "" + ws_id: str = "" + content_parts: list[str] = field(default_factory=list) + reasoning_parts: list[str] = field(default_factory=list) + tool_results: list[tuple[str, str]] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + timed_out: bool = False + + @property + def content(self) -> str: + return "".join(self.content_parts) + + @property + def reasoning(self) -> str: + return "".join(self.reasoning_parts) + + @property + def ok(self) -> bool: + return not self.timed_out and not self.errors + + +class TurnstoneClient: + """Client library for turnstone message queue integration. + + All methods are synchronous. The broker handles background threads + for pub/sub subscriptions. + """ + + def __init__( + self, + broker: MessageBroker | None = None, + prefix: str = "turnstone", + **redis_kwargs: object, + ): + """Create a client. + + Pass ``broker`` for a custom broker, or provide Redis kwargs + (``host``, ``port``, ``db``, ``password``) to use the default + RedisBroker. + """ + self._broker: MessageBroker = broker or RedisBroker(**redis_kwargs) # type: ignore[arg-type] + self._prefix = prefix + + # -- fire-and-forget commands ------------------------------------------- + + def send( + self, + message: str, + ws_id: str = "", + name: str = "", + auto_approve: bool = False, + auto_approve_tools: list[str] | None = None, + target_node: str = "", + ) -> str: + """Send a message. Returns correlation_id for tracking. + + If *target_node* is set, the message is pushed to that node's + dedicated queue. If *ws_id* is set and *target_node* is not, + the client looks up the workstream's owning node and routes + accordingly. + """ + msg = SendMessage( + message=message, + ws_id=ws_id, + name=name, + auto_approve=auto_approve, + auto_approve_tools=auto_approve_tools or [], + target_node=target_node, + ) + node = target_node or (self._broker.get_ws_owner(ws_id) if ws_id else "") + self._broker.push_inbound(msg.to_json(), node_id=node) + return msg.correlation_id + + def create_workstream( + self, + name: str = "", + auto_approve: bool = False, + auto_approve_tools: list[str] | None = None, + target_node: str = "", + ) -> str: + """Create a workstream. Returns correlation_id.""" + msg = CreateWorkstreamMessage( + name=name, + auto_approve=auto_approve, + auto_approve_tools=auto_approve_tools or [], + target_node=target_node, + ) + self._broker.push_inbound(msg.to_json(), node_id=target_node) + return msg.correlation_id + + def close_workstream(self, ws_id: str) -> str: + """Close a workstream. Returns correlation_id.""" + msg = CloseWorkstreamMessage(ws_id=ws_id) + self._broker.push_inbound(msg.to_json()) + return msg.correlation_id + + def command(self, ws_id: str, command: str) -> str: + """Execute a slash command. Returns correlation_id.""" + msg = CommandMessage(ws_id=ws_id, command=command) + self._broker.push_inbound(msg.to_json()) + return msg.correlation_id + + def list_workstreams(self) -> str: + """Request workstream list. Returns correlation_id.""" + msg = ListWorkstreamsMessage() + self._broker.push_inbound(msg.to_json()) + return msg.correlation_id + + def health(self) -> str: + """Request health status. Returns correlation_id.""" + msg = HealthMessage() + self._broker.push_inbound(msg.to_json()) + return msg.correlation_id + + def list_nodes(self) -> list[dict]: + """List active bridge nodes (reads directly from broker).""" + return self._broker.list_nodes() + + # -- approval / plan response ------------------------------------------- + + def approve( + self, + request_id: str, + ws_id: str = "", + approved: bool = True, + feedback: str | None = None, + always: bool = False, + ) -> None: + """Respond to a tool approval request.""" + msg = ApproveMessage( + ws_id=ws_id, + request_id=request_id, + approved=approved, + feedback=feedback, + always=always, + ) + self._broker.push_response(request_id, msg.to_json()) + + def plan_feedback( + self, + request_id: str, + ws_id: str = "", + feedback: str = "", + ) -> None: + """Respond to a plan review request.""" + msg = PlanFeedbackMessage( + ws_id=ws_id, + request_id=request_id, + feedback=feedback, + ) + self._broker.push_response(request_id, msg.to_json()) + + # -- blocking send ------------------------------------------------------- + + def send_and_wait( + self, + message: str, + ws_id: str = "", + name: str = "", + auto_approve: bool = True, + auto_approve_tools: list[str] | None = None, + target_node: str = "", + timeout: float = 600, + on_event: Callable[[OutboundEvent], None] | None = None, + ) -> TurnResult: + """Send a message and block until the turn completes. + + Returns a TurnResult with aggregated content, tool results, etc. + """ + # Build the message but don't send yet — subscribe first to avoid + # a race where the bridge processes the message before we subscribe. + msg = SendMessage( + message=message, + ws_id=ws_id, + name=name, + auto_approve=auto_approve, + auto_approve_tools=auto_approve_tools or [], + target_node=target_node, + ) + cid = msg.correlation_id + + result = TurnResult(correlation_id=cid, ws_id=ws_id) + done = threading.Event() + actual_ws_id = ws_id + + def _on_global(raw: str) -> None: + nonlocal actual_ws_id + event = OutboundEvent.from_json(raw) + if on_event: + on_event(event) + + if ( + isinstance(event, WorkstreamCreatedEvent) + and event.correlation_id == cid + ): + actual_ws_id = event.ws_id + result.ws_id = event.ws_id + self._broker.subscribe_outbound( + f"{self._prefix}:events:{actual_ws_id}", _on_ws + ) + + def _on_ws(raw: str) -> None: + event = OutboundEvent.from_json(raw) + if on_event: + on_event(event) + + if isinstance(event, ContentEvent): + result.content_parts.append(event.text) + elif isinstance(event, ReasoningEvent): + result.reasoning_parts.append(event.text) + elif isinstance(event, ToolResultEvent): + result.tool_results.append((event.name, event.output)) + elif isinstance(event, ErrorEvent): + result.errors.append(event.message) + elif isinstance(event, TurnCompleteEvent) and event.correlation_id == cid: + done.set() + + # Subscribe BEFORE pushing — ensures we don't miss early events + self._broker.subscribe_outbound(f"{self._prefix}:events:global", _on_global) + if actual_ws_id: + self._broker.subscribe_outbound( + f"{self._prefix}:events:{actual_ws_id}", _on_ws + ) + + # Now push the message (route to target node or ws owner if known) + node = target_node or (self._broker.get_ws_owner(ws_id) if ws_id else "") + self._broker.push_inbound(msg.to_json(), node_id=node) + + done.wait(timeout=timeout) + + # Cleanup + self._broker.unsubscribe_outbound(f"{self._prefix}:events:global") + if actual_ws_id: + self._broker.unsubscribe_outbound(f"{self._prefix}:events:{actual_ws_id}") + + result.ws_id = actual_ws_id + result.timed_out = not done.is_set() + return result + + # -- subscription -------------------------------------------------------- + + def subscribe( + self, + callback: Callable[[OutboundEvent], None], + ws_id: str = "", + ) -> None: + """Subscribe to events for a specific workstream or global events.""" + if ws_id: + channel = f"{self._prefix}:events:{ws_id}" + else: + channel = f"{self._prefix}:events:global" + + def _cb(raw: str) -> None: + event = OutboundEvent.from_json(raw) + callback(event) + + self._broker.subscribe_outbound(channel, _cb) + + def unsubscribe(self, ws_id: str = "") -> None: + """Unsubscribe from a workstream or global channel.""" + if ws_id: + channel = f"{self._prefix}:events:{ws_id}" + else: + channel = f"{self._prefix}:events:global" + self._broker.unsubscribe_outbound(channel) + + # -- lifecycle ----------------------------------------------------------- + + def close(self) -> None: + """Clean up broker connection.""" + self._broker.close() + + def __enter__(self) -> TurnstoneClient: + return self + + def __exit__(self, *exc: object) -> None: + self.close() diff --git a/turnstone/mq/protocol.py b/turnstone/mq/protocol.py new file mode 100644 index 00000000..7e9384c7 --- /dev/null +++ b/turnstone/mq/protocol.py @@ -0,0 +1,378 @@ +"""Message protocol for turnstone message queue integration. + +Defines all structured message types exchanged between the client and bridge. +Inbound messages flow from client → bridge via a reliable queue. +Outbound events flow from bridge → client via pub/sub channels. +""" + +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import asdict, dataclass, field + + +# --------------------------------------------------------------------------- +# Inbound messages (client → bridge) +# --------------------------------------------------------------------------- + + +@dataclass +class InboundMessage: + """Base for all messages sent by clients to the bridge.""" + + type: str = "" + correlation_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) + timestamp: float = field(default_factory=time.time) + + def to_json(self) -> str: + return json.dumps(asdict(self)) + + @classmethod + def from_json(cls, raw: str) -> InboundMessage: + data = json.loads(raw) + msg_type = data.get("type", "") + klass = _INBOUND_REGISTRY.get(msg_type) + if klass is None: + raise ValueError(f"Unknown inbound message type: {msg_type!r}") + valid = {f for f in klass.__dataclass_fields__} + return klass(**{k: v for k, v in data.items() if k in valid}) + + +@dataclass +class SendMessage(InboundMessage): + """Send a user message to a workstream.""" + + type: str = "send" + ws_id: str = "" + message: str = "" + auto_approve: bool = False + auto_approve_tools: list[str] = field(default_factory=list) + name: str = "" + target_node: str = "" + + +@dataclass +class ApproveMessage(InboundMessage): + """Respond to a tool approval request.""" + + type: str = "approve" + ws_id: str = "" + request_id: str = "" + approved: bool = True + feedback: str | None = None + always: bool = False + + +@dataclass +class PlanFeedbackMessage(InboundMessage): + """Respond to a plan review request.""" + + type: str = "plan_feedback" + ws_id: str = "" + request_id: str = "" + feedback: str = "" + + +@dataclass +class CommandMessage(InboundMessage): + """Execute a slash command.""" + + type: str = "command" + ws_id: str = "" + command: str = "" + + +@dataclass +class CreateWorkstreamMessage(InboundMessage): + """Create a new workstream.""" + + type: str = "create_workstream" + name: str = "" + auto_approve: bool = False + auto_approve_tools: list[str] = field(default_factory=list) + target_node: str = "" + + +@dataclass +class CloseWorkstreamMessage(InboundMessage): + """Close a workstream.""" + + type: str = "close_workstream" + ws_id: str = "" + + +@dataclass +class ListWorkstreamsMessage(InboundMessage): + """Request the list of active workstreams.""" + + type: str = "list_workstreams" + + +@dataclass +class HealthMessage(InboundMessage): + """Request health status.""" + + type: str = "health" + + +@dataclass +class ListNodesMessage(InboundMessage): + """Request the list of active bridge nodes.""" + + type: str = "list_nodes" + + +# --------------------------------------------------------------------------- +# Outbound events (bridge → client) +# --------------------------------------------------------------------------- + + +@dataclass +class OutboundEvent: + """Base for all events published by the bridge.""" + + type: str = "" + ws_id: str = "" + correlation_id: str = "" + timestamp: float = field(default_factory=time.time) + + def to_json(self) -> str: + return json.dumps(asdict(self)) + + @classmethod + def from_json(cls, raw: str) -> OutboundEvent: + data = json.loads(raw) + msg_type = data.get("type", "") + klass = _OUTBOUND_REGISTRY.get(msg_type, OutboundEvent) + valid = {f for f in klass.__dataclass_fields__} + return klass(**{k: v for k, v in data.items() if k in valid}) + + +@dataclass +class AckEvent(OutboundEvent): + """Acknowledgment that an inbound message was received.""" + + type: str = "ack" + status: str = "ok" + detail: str = "" + + +@dataclass +class ContentEvent(OutboundEvent): + """Streamed content token from the assistant.""" + + type: str = "content" + text: str = "" + + +@dataclass +class ReasoningEvent(OutboundEvent): + """Streamed reasoning token.""" + + type: str = "reasoning" + text: str = "" + + +@dataclass +class ToolInfoEvent(OutboundEvent): + """Tool call info (auto-approved tools).""" + + type: str = "tool_info" + items: list = field(default_factory=list) + + +@dataclass +class ApprovalRequestEvent(OutboundEvent): + """Tool approval request forwarded from the server. + + The client must respond with an ApproveMessage whose + request_id matches this event's correlation_id. + """ + + type: str = "approval_request" + items: list = field(default_factory=list) + + +@dataclass +class ToolResultEvent(OutboundEvent): + """Tool execution result.""" + + type: str = "tool_result" + name: str = "" + output: str = "" + + +@dataclass +class PlanReviewEvent(OutboundEvent): + """Plan review request forwarded from the server. + + The client must respond with a PlanFeedbackMessage whose + request_id matches this event's correlation_id. + """ + + type: str = "plan_review" + content: str = "" + + +@dataclass +class StatusEvent(OutboundEvent): + """Token usage status update.""" + + type: str = "status" + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + context_window: int = 0 + pct: float = 0.0 + effort: str = "" + + +@dataclass +class StateChangeEvent(OutboundEvent): + """Workstream state transition.""" + + type: str = "state_change" + state: str = "" + + +@dataclass +class TurnCompleteEvent(OutboundEvent): + """Emitted when a workstream finishes processing (returns to IDLE). + + This is a synthetic event produced by the bridge when it detects + the ws_state transition to 'idle' after a send. + """ + + type: str = "turn_complete" + + +@dataclass +class StreamEndEvent(OutboundEvent): + """LLM stream ended.""" + + type: str = "stream_end" + + +@dataclass +class WorkstreamCreatedEvent(OutboundEvent): + """New workstream created.""" + + type: str = "ws_created" + name: str = "" + + +@dataclass +class WorkstreamClosedEvent(OutboundEvent): + """Workstream closed.""" + + type: str = "ws_closed" + + +@dataclass +class WorkstreamListEvent(OutboundEvent): + """Workstream list response.""" + + type: str = "ws_list" + workstreams: list = field(default_factory=list) + + +@dataclass +class WorkstreamRenameEvent(OutboundEvent): + """Workstream renamed.""" + + type: str = "ws_rename" + name: str = "" + + +@dataclass +class HealthResponseEvent(OutboundEvent): + """Health status response.""" + + type: str = "health_response" + data: dict = field(default_factory=dict) + + +@dataclass +class ErrorEvent(OutboundEvent): + """Error event.""" + + type: str = "error" + message: str = "" + + +@dataclass +class InfoEvent(OutboundEvent): + """Informational event.""" + + type: str = "info" + message: str = "" + + +@dataclass +class NodeListEvent(OutboundEvent): + """List of active bridge nodes.""" + + type: str = "node_list" + nodes: list = field(default_factory=list) + + +@dataclass +class ClusterStateEvent(OutboundEvent): + """Workstream state change with node attribution for cluster dashboard.""" + + type: str = "cluster_state" + ws_id: str = "" + state: str = "" + node_id: str = "" + tokens: int = 0 + context_ratio: float = 0.0 + activity: str = "" + activity_state: str = "" + + +# --------------------------------------------------------------------------- +# Type registries (built after all classes are defined) +# --------------------------------------------------------------------------- + +_INBOUND_REGISTRY: dict[str, type[InboundMessage]] = { + cls.__dataclass_fields__["type"].default: cls + for cls in [ + SendMessage, + ApproveMessage, + PlanFeedbackMessage, + CommandMessage, + CreateWorkstreamMessage, + CloseWorkstreamMessage, + ListWorkstreamsMessage, + HealthMessage, + ListNodesMessage, + ] +} + +_OUTBOUND_REGISTRY: dict[str, type[OutboundEvent]] = { + cls.__dataclass_fields__["type"].default: cls + for cls in [ + AckEvent, + ContentEvent, + ReasoningEvent, + ToolInfoEvent, + ApprovalRequestEvent, + ToolResultEvent, + PlanReviewEvent, + StatusEvent, + StateChangeEvent, + TurnCompleteEvent, + StreamEndEvent, + WorkstreamCreatedEvent, + WorkstreamClosedEvent, + WorkstreamListEvent, + WorkstreamRenameEvent, + HealthResponseEvent, + ErrorEvent, + InfoEvent, + NodeListEvent, + ClusterStateEvent, + ] +} diff --git a/turnstone/server.py b/turnstone/server.py new file mode 100644 index 00000000..43fbd18d --- /dev/null +++ b/turnstone/server.py @@ -0,0 +1,1172 @@ +"""Web server frontend for turnstone. + +Provides a browser-based chat UI that mirrors the terminal CLI experience. +Uses only Python stdlib (http.server, json, threading, queue) for the server, +communicating with the browser via Server-Sent Events (SSE) for streaming +and HTTP POST for user actions. + +Supports multiple concurrent workstreams (tabs), each with independent +ChatSession and event streams. +""" + +import argparse +import json +import os +import queue +import sys +import textwrap +import threading +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from socketserver import ThreadingMixIn +from urllib.parse import urlparse, parse_qs + +from openai import OpenAI + +from turnstone.core.metrics import metrics as _metrics +from turnstone.core.session import ChatSession, SessionUI # noqa: F401 +from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection +from turnstone.core.workstream import WorkstreamManager, WorkstreamState + +# --------------------------------------------------------------------------- +# Static assets — loaded once at startup from turnstone/ui/static/ +# --------------------------------------------------------------------------- + +_STATIC_DIR = Path(__file__).parent / "ui" / "static" +_HTML = (_STATIC_DIR / "index.html").read_text(encoding="utf-8") +_CSS = (_STATIC_DIR / "style.css").read_text(encoding="utf-8") +_JS = (_STATIC_DIR / "app.js").read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# WebUI — implements SessionUI for browser-based interaction +# --------------------------------------------------------------------------- + + +class WebUI: + """Browser-based UI using SSE for streaming and HTTP POST for actions. + + Implements the SessionUI protocol from turnstone.core.session. + Each workstream gets its own WebUI instance. + """ + + # Shared global event queue for state-change broadcasts across all + # workstreams. Set by main() before any WebUI instances are created. + _global_queue: queue.Queue | None = None + + def __init__(self, ws_id: str = ""): + self.ws_id = ws_id + self._event_queue: queue.Queue = queue.Queue() + self._sse_generation = 0 # incremented on each new SSE connection + self._approval_event = threading.Event() + self._approval_result: tuple[bool, str | None] = (False, None) + self._pending_approval: dict | None = None # re-sent on SSE reconnect + self._plan_event = threading.Event() + self._plan_result: str = "" + self.auto_approve = False + # Per-workstream metrics accumulators (written by worker thread, read by metrics handler) + self._ws_lock = threading.Lock() + self._ws_prompt_tokens: int = 0 + self._ws_completion_tokens: int = 0 + self._ws_messages: int = 0 + self._ws_tool_calls: dict[str, int] = {} + self._ws_context_ratio: float = 0.0 + # Activity tracking for dashboard (current tool / thinking / approval) + self._ws_current_activity: str = "" + self._ws_activity_state: str = "" # "tool" | "approval" | "thinking" | "" + + def _enqueue(self, data: dict): + self._event_queue.put(data) + + def _broadcast_state(self, state: str): + """Send a state-change event to the global SSE channel.""" + if WebUI._global_queue is not None: + with self._ws_lock: + tokens = self._ws_prompt_tokens + self._ws_completion_tokens + ctx = self._ws_context_ratio + activity = self._ws_current_activity + activity_state = self._ws_activity_state + WebUI._global_queue.put( + { + "type": "ws_state", + "ws_id": self.ws_id, + "state": state, + "tokens": tokens, + "context_ratio": ctx, + "activity": activity, + "activity_state": activity_state, + } + ) + + def _broadcast_activity(self): + """Send an activity-change event to the global SSE channel.""" + if WebUI._global_queue is not None: + with self._ws_lock: + activity = self._ws_current_activity + activity_state = self._ws_activity_state + WebUI._global_queue.put( + { + "type": "ws_activity", + "ws_id": self.ws_id, + "activity": activity, + "activity_state": activity_state, + } + ) + + # --- SessionUI protocol --- + + def on_thinking_start(self): + with self._ws_lock: + self._ws_current_activity = "Thinking\u2026" + self._ws_activity_state = "thinking" + self._broadcast_activity() + self._enqueue({"type": "thinking_start"}) + + def on_thinking_stop(self): + self._enqueue({"type": "thinking_stop"}) + + def on_reasoning_token(self, text: str): + self._enqueue({"type": "reasoning", "text": text}) + + def on_content_token(self, text: str): + self._enqueue({"type": "content", "text": text}) + + def on_stream_end(self): + with self._ws_lock: + self._ws_current_activity = "" + self._ws_activity_state = "" + self._broadcast_activity() + self._enqueue({"type": "stream_end"}) + + def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: + pending = [ + it for it in items if it.get("needs_approval") and not it.get("error") + ] + + # Always send tool info to the browser + serialized = [] + for item in items: + serialized.append( + { + "header": item.get("header", ""), + "preview": item.get("preview", ""), + "func_name": item.get("func_name", ""), + "approval_label": item.get( + "approval_label", item.get("func_name", "") + ), + "needs_approval": item.get("needs_approval", False), + "error": item.get("error"), + } + ) + + if not pending or self.auto_approve: + # Track auto-approved tool activity + first = items[0] if items else {} + label = first.get("func_name", "") + preview = first.get("preview", "")[:80] + with self._ws_lock: + self._ws_current_activity = ( + f"\u2699 {label}: {preview}" if label else "" + ) + self._ws_activity_state = "tool" if label else "" + self._broadcast_activity() + self._enqueue({"type": "tool_info", "items": serialized}) + return True, None + + # Track pending approval activity + first_pending = pending[0] + label = first_pending.get("func_name", "") + preview = first_pending.get("preview", "")[:60] + with self._ws_lock: + self._ws_current_activity = ( + f"\u23f3 Awaiting approval: {label} \u2014 {preview}" + ) + self._ws_activity_state = "approval" + self._broadcast_activity() + + # Send approval request and block + self._approval_event.clear() + self._pending_approval = {"type": "approve_request", "items": serialized} + self._enqueue(self._pending_approval) + self._approval_event.wait() + self._pending_approval = None + approved, feedback = self._approval_result + + if not approved: + denial_msg = "Denied by user" + if feedback: + denial_msg += f": {feedback}" + for item in pending: + item["denied"] = True + item["denial_msg"] = denial_msg + + return approved, feedback + + def on_tool_result(self, name: str, output: str): + _metrics.record_tool_call(name) + with self._ws_lock: + self._ws_tool_calls[name] = self._ws_tool_calls.get(name, 0) + 1 + self._ws_current_activity = "" + self._ws_activity_state = "" + self._broadcast_activity() + self._enqueue({"type": "tool_result", "name": name, "output": output}) + + def on_status(self, usage: dict, context_window: int, effort: str): + total_tok = usage["prompt_tokens"] + usage["completion_tokens"] + pct = total_tok / context_window * 100 if context_window > 0 else 0 + _metrics.record_tokens(usage["prompt_tokens"], usage["completion_tokens"]) + _metrics.record_context_ratio( + total_tok / context_window if context_window > 0 else 0.0 + ) + with self._ws_lock: + self._ws_prompt_tokens += usage["prompt_tokens"] + self._ws_completion_tokens += usage["completion_tokens"] + self._ws_context_ratio = ( + total_tok / context_window if context_window > 0 else 0.0 + ) + self._enqueue( + { + "type": "status", + "prompt_tokens": usage["prompt_tokens"], + "completion_tokens": usage["completion_tokens"], + "total_tokens": total_tok, + "context_window": context_window, + "pct": round(pct, 1), + "effort": effort, + } + ) + + def on_plan_review(self, content: str) -> str: + self._plan_event.clear() + self._enqueue({"type": "plan_review", "content": content}) + self._plan_event.wait() + return self._plan_result + + def on_info(self, message: str): + self._enqueue({"type": "info", "message": message}) + + def on_error(self, message: str): + _metrics.record_error() + self._enqueue({"type": "error", "message": message}) + + def on_state_change(self, state: str): + self._broadcast_state(state) + + def on_rename(self, name: str): + """Update the workstream's display name and broadcast to all clients.""" + if WebUI._global_queue is not None: + WebUI._global_queue.put( + {"type": "ws_rename", "ws_id": self.ws_id, "name": name} + ) + + def resolve_approval(self, approved: bool, feedback: str | None = None): + """Called by the HTTP handler when the user approves/denies.""" + self._approval_result = (approved, feedback) + self._approval_event.set() + + def resolve_plan(self, feedback: str): + """Called by the HTTP handler when the user responds to a plan.""" + self._plan_result = feedback + self._plan_event.set() + + +# --------------------------------------------------------------------------- +# HTTP handler +# --------------------------------------------------------------------------- + + +def _build_history(session, has_pending_approval: bool = False) -> list[dict]: + """Build a history replay list from session messages. + + When ``has_pending_approval`` is True, the last assistant entry's + tool_calls are marked ``"pending": True`` so the client renders them + as awaiting approval rather than as already-approved. + """ + history = [] + for msg in session.messages: + entry = {"role": msg["role"], "content": msg.get("content")} + if msg.get("tool_calls"): + entry["tool_calls"] = [ + { + "name": tc["function"]["name"], + "arguments": tc["function"].get("arguments", ""), + } + for tc in msg["tool_calls"] + ] + history.append(entry) + # Mark last assistant tool call as pending if approval is outstanding. + if has_pending_approval: + for entry in reversed(history): + if entry.get("tool_calls"): + entry["pending"] = True + break + return history + + +class TurnstoneHTTPHandler(BaseHTTPRequestHandler): + """HTTP handler for the turnstone web server. + + Serves the embedded HTML client and provides API endpoints for + SSE streaming, message sending, tool approval, and workstream management. + """ + + # Suppress default logging to stderr + def log_message(self, format, *args): + pass + + def _set_headers(self, status=200, content_type="application/json"): + self._response_status = status + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + def _read_body(self) -> dict: + length = int(self.headers.get("Content-Length", 0)) + if length == 0: + return {} + raw = self.rfile.read(length) + try: + return json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + return {} + + def _send_json(self, data: dict, status=200): + self._set_headers(status, "application/json") + self.wfile.write(json.dumps(data).encode("utf-8")) + + def _get_ws(self, ws_id: str | None): + """Look up workstream by id. Returns (Workstream, WebUI) or (None, None).""" + if not ws_id: + return None, None + mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] + ws = mgr.get(ws_id) + if ws: + return ws, ws.ui + return None, None + + def _check_auth(self, method: str, path: str) -> bool: + """Return True if authorized. Sends 401/403 and returns False otherwise.""" + from turnstone.core.auth import check_request + + auth_config = self.server.auth_config # type: ignore[attr-defined] + auth_header = self.headers.get("Authorization") + cookie_header = self.headers.get("Cookie") + allowed, status, msg = check_request( + auth_config, method, path, auth_header, cookie_header + ) + if not allowed: + self._send_json({"error": msg}, status) + return allowed + + def do_GET(self): + _t0 = time.monotonic() + self._response_status = 200 + parsed = urlparse(self.path) + try: + if not self._check_auth("GET", parsed.path): + return + self._do_GET(parsed) + finally: + _metrics.record_request( + "GET", parsed.path, self._response_status, time.monotonic() - _t0 + ) + + def _do_GET(self, parsed): + if parsed.path == "/": + self._set_headers(200, "text/html; charset=utf-8") + self.wfile.write(_HTML.encode("utf-8")) + + elif parsed.path == "/static/style.css": + self.send_response(200) + self.send_header("Content-Type", "text/css; charset=utf-8") + self.send_header("Cache-Control", "max-age=3600") + self.end_headers() + self.wfile.write(_CSS.encode("utf-8")) + + elif parsed.path == "/static/app.js": + self.send_response(200) + self.send_header("Content-Type", "application/javascript; charset=utf-8") + self.send_header("Cache-Control", "max-age=3600") + self.end_headers() + self.wfile.write(_JS.encode("utf-8")) + + elif parsed.path == "/api/events": + qs = parse_qs(parsed.query) + ws_id = qs.get("ws_id", [None])[0] + ws, ui = self._get_ws(ws_id) + if not ws or not ui: + self._send_json({"error": "Unknown workstream"}, 404) + return + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + # Bump generation so any previous SSE handler exits + ui._sse_generation += 1 + my_gen = ui._sse_generation + + # Drain stale events from the queue + while not ui._event_queue.empty(): + try: + ui._event_queue.get_nowait() + except queue.Empty: + break + + # Send connected event with model info + session: ChatSession = ws.session + connected_data = json.dumps( + { + "type": "connected", + "model": session.model, + "skip_permissions": ui.auto_approve, + } + ) + self.wfile.write(f"data: {connected_data}\n\n".encode("utf-8")) + self.wfile.flush() + + # Send conversation history for replay + history = _build_history( + session, has_pending_approval=ui._pending_approval is not None + ) + if history: + history_data = json.dumps({"type": "history", "messages": history}) + self.wfile.write(f"data: {history_data}\n\n".encode("utf-8")) + self.wfile.flush() + + # Re-inject a pending approval request if one was interrupted by a tab switch. + if ui._pending_approval is not None: + pa_data = json.dumps(ui._pending_approval) + self.wfile.write(f"data: {pa_data}\n\n".encode("utf-8")) + self.wfile.flush() + + # Long-running SSE loop + try: + while my_gen == ui._sse_generation: + try: + event = ui._event_queue.get(timeout=5) + data = json.dumps(event) + self.wfile.write(f"data: {data}\n\n".encode("utf-8")) + self.wfile.flush() + except queue.Empty: + # Send keepalive comment to prevent timeout + self.wfile.write(b": keepalive\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, OSError): + pass # Client disconnected + + elif parsed.path == "/api/events/global": + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + gq: queue.Queue = self.server.global_queue # type: ignore[attr-defined] + + # Each global SSE client gets its own consumer queue + # (since queue.Queue is single-consumer, we fan out via a listener list) + client_queue: queue.Queue = queue.Queue(maxsize=500) + listeners: list = self.server.global_listeners # type: ignore[attr-defined] + listeners_lock: threading.Lock = self.server.global_listeners_lock # type: ignore[attr-defined] + with listeners_lock: + listeners.append(client_queue) + + try: + while True: + try: + event = client_queue.get(timeout=5) + data = json.dumps(event) + self.wfile.write(f"data: {data}\n\n".encode("utf-8")) + self.wfile.flush() + except queue.Empty: + self.wfile.write(b": keepalive\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, OSError): + pass + finally: + with listeners_lock: + if client_queue in listeners: + listeners.remove(client_queue) + + elif parsed.path == "/api/workstreams": + mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] + result = [] + for ws in mgr.list_all(): + result.append( + { + "id": ws.id, + "name": ws.name, + "state": ws.state.value, + "session_id": ws.session.session_id if ws.session else None, + } + ) + self._send_json({"workstreams": result}) + + elif parsed.path == "/api/dashboard": + self._handle_dashboard() + + elif parsed.path == "/api/sessions": + from turnstone.core.memory import list_sessions + + rows = list_sessions(limit=50) + sessions = [ + { + "session_id": sid, + "alias": alias, + "title": title, + "created": created, + "updated": updated, + "message_count": count, + } + for sid, alias, title, created, updated, count in rows + ] + self._send_json({"sessions": sessions}) + + elif parsed.path == "/health": + self._handle_health() + + elif parsed.path == "/metrics": + self._handle_metrics() + + else: + self._set_headers(404, "text/plain") + self.wfile.write(b"Not found") + + def do_POST(self): + _t0 = time.monotonic() + self._response_status = 200 + try: + if not self._check_auth("POST", self.path): + return + self._do_POST() + finally: + _metrics.record_request( + "POST", self.path, self._response_status, time.monotonic() - _t0 + ) + + def _do_POST(self): + if self.path == "/api/send": + body = self._read_body() + message = body.get("message", "").strip() + ws_id = body.get("ws_id") + if not message: + self._send_json({"error": "Empty message"}, 400) + return + + ws, ui = self._get_ws(ws_id) + if not ws or not ui: + self._send_json({"error": "Unknown workstream"}, 404) + return + + # Check if already processing + if ws.worker_thread and ws.worker_thread.is_alive(): + ui._enqueue( + { + "type": "busy_error", + "message": "Already processing a request. Please wait.", + } + ) + self._send_json({"status": "busy"}) + return + + def run(): + try: + ws.session.send(message) + except Exception as e: + ui.on_error(f"Error: {e}") + ui._enqueue({"type": "stream_end"}) + ui.on_state_change("error") + + t = threading.Thread(target=run, daemon=True) + ws.worker_thread = t + t.start() + _metrics.record_message_sent() + with ui._ws_lock: + ui._ws_messages += 1 + self._send_json({"status": "ok"}) + + elif self.path == "/api/approve": + body = self._read_body() + approved = body.get("approved", False) + feedback = body.get("feedback") + always = body.get("always", False) + ws_id = body.get("ws_id") + + ws, ui = self._get_ws(ws_id) + if not ws or not ui: + self._send_json({"error": "Unknown workstream"}, 404) + return + if always and approved: + ui.auto_approve = True + ui.resolve_approval(approved, feedback) + self._send_json({"status": "ok"}) + + elif self.path == "/api/plan": + body = self._read_body() + feedback = body.get("feedback", "") + ws_id = body.get("ws_id") + + ws, ui = self._get_ws(ws_id) + if not ws or not ui: + self._send_json({"error": "Unknown workstream"}, 404) + return + ui.resolve_plan(feedback) + self._send_json({"status": "ok"}) + + elif self.path == "/api/command": + body = self._read_body() + command = body.get("command", "").strip() + ws_id = body.get("ws_id") + if not command: + self._send_json({"error": "Empty command"}, 400) + return + + ws, ui = self._get_ws(ws_id) + if not ws or not ui: + self._send_json({"error": "Unknown workstream"}, 404) + return + + try: + should_exit = ws.session.handle_command(command) + if should_exit: + ui.on_info("Session ended. You can close this tab.") + # Handle UI updates for session-changing commands + cmd_word = command.strip().split(None, 1)[0].lower() + if cmd_word in ("/clear", "/new"): + ui._enqueue({"type": "clear_ui"}) + elif cmd_word == "/resume": + ui._enqueue({"type": "clear_ui"}) + history = _build_history(ws.session) + if history: + ui._enqueue({"type": "history", "messages": history}) + # Sync in-memory workstream name after any command that can change it. + # This ensures /api/workstreams and future page loads see the right name. + if cmd_word in ("/name", "/resume"): + from turnstone.core.memory import get_session_name + + updated_name = get_session_name(ws.session.session_id) + if updated_name: + ws.name = updated_name + except Exception as e: + ui.on_error(f"Command error: {e}") + + self._send_json({"status": "ok"}) + + elif self.path == "/api/workstreams/new": + body = self._read_body() + mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] + skip: bool = self.server.skip_permissions # type: ignore[attr-defined] + try: + ws = mgr.create( + name=body.get("name", ""), + ui_factory=lambda wid: WebUI(ws_id=wid), + ) + if skip or body.get("auto_approve", False): + ws.ui.auto_approve = True + self._send_json({"ws_id": ws.id, "name": ws.name}) + except RuntimeError as e: + self._send_json({"error": str(e)}, 400) + + elif self.path == "/api/workstreams/close": + body = self._read_body() + ws_id = body.get("ws_id") + mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] + if mgr.close(ws_id): + self._send_json({"status": "ok"}) + else: + self._send_json({"error": "Cannot close last workstream"}, 400) + + elif self.path == "/api/auth/login": + from turnstone.core.auth import make_set_cookie + + body = self._read_body() + token = body.get("token", "") + auth_config = self.server.auth_config # type: ignore[attr-defined] + role = auth_config.check(token) + if role: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Set-Cookie", make_set_cookie(token)) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write( + json.dumps({"status": "ok", "role": role}).encode("utf-8") + ) + else: + self._send_json({"error": "Invalid token"}, 401) + + elif self.path == "/api/auth/logout": + from turnstone.core.auth import make_clear_cookie + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Set-Cookie", make_clear_cookie()) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(b'{"status":"ok"}') + + else: + self._set_headers(404, "text/plain") + self.wfile.write(b"Not found") + + def _handle_health(self): + """Return server health status as JSON.""" + mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] + wss = mgr.list_all() + states: dict = { + "idle": 0, + "thinking": 0, + "running": 0, + "attention": 0, + "error": 0, + } + for ws in wss: + state = ws.state.value + states[state] = states.get(state, 0) + 1 + data = { + "status": "ok", + "version": "0.2.0", + "uptime_seconds": round(time.monotonic() - _metrics.start_time, 2), + "model": _metrics.model, + "workstreams": {"total": len(wss), **states}, + } + self._send_json(data) + + def _handle_dashboard(self): + """Return enriched workstream data + aggregate stats for the dashboard.""" + from turnstone.core.memory import get_session_name + + mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] + wss = mgr.list_all() + total_tokens = 0 + total_tool_calls = 0 + active_count = 0 + ws_list = [] + for ws in wss: + ui: WebUI = ws.ui # type: ignore[assignment] + with ui._ws_lock: + tok = ui._ws_prompt_tokens + ui._ws_completion_tokens + tc = sum(ui._ws_tool_calls.values()) + ctx = ui._ws_context_ratio + activity = ui._ws_current_activity + activity_state = ui._ws_activity_state + total_tokens += tok + total_tool_calls += tc + if ws.state.value != "idle": + active_count += 1 + title = "" + if ws.session: + title = get_session_name(ws.session.session_id) or "" + ws_list.append( + { + "id": ws.id, + "name": ws.name, + "state": ws.state.value, + "session_id": ws.session.session_id if ws.session else None, + "title": title, + "tokens": tok, + "context_ratio": round(ctx, 3), + "activity": activity, + "activity_state": activity_state, + "tool_calls": tc, + "node": "local", + } + ) + uptime_sec = round(time.monotonic() - _metrics.start_time) + self._send_json( + { + "workstreams": ws_list, + "aggregate": { + "total_tokens": total_tokens, + "total_tool_calls": total_tool_calls, + "active_count": active_count, + "total_count": len(ws_list), + "uptime_seconds": uptime_sec, + "node": "local", + }, + } + ) + + def _handle_metrics(self): + """Return Prometheus text exposition format metrics.""" + mgr: WorkstreamManager = self.server.workstreams # type: ignore[attr-defined] + wss = mgr.list_all() + states: dict = { + "idle": 0, + "thinking": 0, + "running": 0, + "attention": 0, + "error": 0, + } + ws_data = [] + for ws in wss: + state = ws.state.value + states[state] = states.get(state, 0) + 1 + ui: WebUI = ws.ui # type: ignore[assignment] + with ui._ws_lock: + ws_data.append( + { + "ws_id": ws.id, + "name": ws.name, + "session_id": ws.session.session_id if ws.session else "", + "prompt_tokens": ui._ws_prompt_tokens, + "completion_tokens": ui._ws_completion_tokens, + "messages": ui._ws_messages, + "tool_calls": dict(ui._ws_tool_calls), + "context_ratio": ui._ws_context_ratio, + } + ) + content = _metrics.generate_text( + workstream_states=states, + total_workstreams=len(wss), + workstream_metrics=ws_data, + ) + self._set_headers(200, "text/plain; version=0.0.4; charset=utf-8") + self.wfile.write(content.encode("utf-8")) + + def do_OPTIONS(self): + """Handle CORS preflight.""" + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.end_headers() + + +# --------------------------------------------------------------------------- +# Threaded HTTP server (module-level so tests can import it) +# --------------------------------------------------------------------------- + + +class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): + """HTTP server that handles each request in a separate thread. + + Required for SSE (long-polling GET) and concurrent POST handlers to work + simultaneously. + """ + + daemon_threads = True + + +# --------------------------------------------------------------------------- +# Model auto-detection (shared with cli.py) +# --------------------------------------------------------------------------- + + +def detect_model(client: OpenAI) -> str: + """Auto-detect the model from vLLM's /v1/models endpoint.""" + try: + models = client.models.list() + model_ids = [m.id for m in models.data] + if not model_ids: + print("Error: No models found at server. Use --model to specify.") + sys.exit(1) + if len(model_ids) == 1: + return model_ids[0] + print(f"Available models: {', '.join(model_ids)}") + print(f"Using: {model_ids[0]} (override with --model)") + return model_ids[0] + except Exception as e: + print(f"Error: Could not connect to server: {e}") + print("Is vLLM running? Start it or use --base-url to point elsewhere.") + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Global SSE fan-out +# --------------------------------------------------------------------------- + + +def _idle_cleanup_thread( + mgr: WorkstreamManager, timeout_sec: float, global_queue: queue.Queue +): + """Periodically close IDLE workstreams that have been inactive too long.""" + check_every = min(300.0, timeout_sec / 4) # check at ¼ of timeout, max 5 min + while True: + time.sleep(check_every) + closed = mgr.close_idle(timeout_sec) + for ws_id in closed: + try: + global_queue.put_nowait({"type": "ws_closed", "ws_id": ws_id}) + except queue.Full: + pass + + +def _global_fanout_thread( + source_queue: queue.Queue, listeners: list, lock: threading.Lock +): + """Reads events from the source queue and copies them to all listener queues.""" + while True: + try: + event = source_queue.get() + with lock: + snapshot = list(listeners) + for lq in snapshot: + try: + lq.put_nowait(event) + except queue.Full: + pass # drop if a listener is backed up + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser( + description="turnstone web server — browser-based chat UI.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + Examples: + turnstone-server # auto-detect model, serve on :8080 + turnstone-server --port 3000 # custom port + turnstone-server --model kappa_20b_131k # explicit model + turnstone-server --skip-permissions # auto-approve all tools + """), + ) + parser.add_argument( + "--base-url", + default="http://localhost:8000/v1", + help="vLLM API base URL (default: http://localhost:8000/v1)", + ) + parser.add_argument( + "--model", + default=None, + help="Model name (default: auto-detect from server)", + ) + parser.add_argument( + "--persona", + default=None, + help="Persona name injected as system message", + ) + parser.add_argument( + "--instructions", + default=None, + help="Developer instructions injected as developer message", + ) + parser.add_argument( + "--temperature", + type=float, + default=0.5, + help="Sampling temperature (default: 0.5)", + ) + parser.add_argument( + "--max-tokens", + type=int, + default=32768, + help="Max completion tokens (default: 32768)", + ) + parser.add_argument( + "--tool-timeout", + type=int, + default=30, + help="Bash command timeout in seconds (default: 30)", + ) + parser.add_argument( + "--reasoning-effort", + default="medium", + choices=["low", "medium", "high"], + help="Reasoning effort level (default: medium)", + ) + parser.add_argument( + "--context-window", + type=int, + default=131072, + help="Context window size in tokens (default: 131072)", + ) + parser.add_argument( + "--compact-max-tokens", + type=int, + default=32768, + help="Max tokens for compaction summary (default: 32768)", + ) + parser.add_argument( + "--auto-compact-pct", + type=float, + default=0.8, + help="Auto-compact when prompt exceeds this fraction of context window (default: 0.8)", + ) + parser.add_argument( + "--agent-max-turns", + type=int, + default=-1, + help="Max tool turns for agent sub-sessions, -1 for unlimited (default: -1)", + ) + parser.add_argument( + "--tool-truncation", + type=int, + default=0, + help="Tool output truncation limit in chars, 0 for auto (50%% of context window) (default: 0)", + ) + parser.add_argument( + "--resume", + default=None, + metavar="SESSION", + help="Resume a previous session by alias or session_id", + ) + parser.add_argument( + "--skip-permissions", + action="store_true", + help="Auto-approve all tool calls (no confirmation prompts)", + ) + parser.add_argument( + "--api-key", + default=None, + help="API key (default: $OPENAI_API_KEY, or 'dummy' for local servers)", + ) + parser.add_argument( + "--host", + default="0.0.0.0", + help="Host to bind to (default: 0.0.0.0)", + ) + parser.add_argument( + "--port", + type=int, + default=8080, + help="Port to listen on (default: 8080)", + ) + parser.add_argument( + "--session-retention-days", + type=int, + default=90, + metavar="DAYS", + help="Delete unnamed sessions older than DAYS days on startup, 0 to disable (default: 90)", + ) + parser.add_argument( + "--workstream-idle-timeout", + type=int, + default=120, + metavar="MINUTES", + help="Close IDLE workstreams after MINUTES of inactivity, 0 to disable (default: 120)", + ) + from turnstone.core.config import apply_config + + apply_config(parser, ["api", "model", "session", "tools", "server"]) + args = parser.parse_args() + + # Prune stale / empty sessions on startup + from turnstone.core.memory import prune_sessions + + prune_sessions(retention_days=args.session_retention_days, log_fn=print) + + # Create OpenAI client + api_key = args.api_key or os.environ.get("OPENAI_API_KEY") or "dummy" + client = OpenAI( + base_url=args.base_url, + api_key=api_key, + ) + + # Detect or use provided model + if args.model: + model = args.model + else: + model = detect_model(client) + + # Set up global event queue for state-change broadcasts + global_queue: queue.Queue = queue.Queue() + global_listeners: list = [] + global_listeners_lock = threading.Lock() + WebUI._global_queue = global_queue + + # Session factory — captures shared config + def session_factory(ui): + return ChatSession( + client=client, + model=model, + ui=ui, + persona=args.persona, + instructions=args.instructions, + temperature=args.temperature, + max_tokens=args.max_tokens, + tool_timeout=args.tool_timeout, + reasoning_effort=args.reasoning_effort, + context_window=args.context_window, + compact_max_tokens=args.compact_max_tokens, + auto_compact_pct=args.auto_compact_pct, + agent_max_turns=args.agent_max_turns, + tool_truncation=args.tool_truncation, + ) + + # Create workstream manager and initial workstream + manager = WorkstreamManager(session_factory) + ws = manager.create( + name="default", + ui_factory=lambda wid: WebUI(ws_id=wid), + ) + if args.skip_permissions: + ws.ui.auto_approve = True + + # Handle --resume + if args.resume: + from turnstone.core.memory import resolve_session + + target_id = resolve_session(args.resume) + if not target_id: + print(f"Session not found: {args.resume}") + sys.exit(1) + if not ws.session.resume_session(target_id): + print(f"Session '{args.resume}' has no messages.") + sys.exit(1) + print(f"Resumed session {target_id} ({len(ws.session.messages)} messages)") + + # Record detected model in metrics + _metrics.model = model + + # Create and configure threaded HTTP server (SSE needs a persistent + # connection, so POSTs must be handled on separate threads). + server = ThreadedHTTPServer((args.host, args.port), TurnstoneHTTPHandler) + server.workstreams = manager # type: ignore[attr-defined] + server.global_queue = global_queue # type: ignore[attr-defined] + server.global_listeners = global_listeners # type: ignore[attr-defined] + server.global_listeners_lock = global_listeners_lock # type: ignore[attr-defined] + server.skip_permissions = args.skip_permissions # type: ignore[attr-defined] + + from turnstone.core.auth import load_auth_config + + auth_config = load_auth_config() + server.auth_config = auth_config # type: ignore[attr-defined] + if auth_config.enabled: + print(f"Auth: enabled ({len(auth_config.tokens)} token(s) configured)") + + # Start global event fan-out thread + fanout = threading.Thread( + target=_global_fanout_thread, + args=(global_queue, global_listeners, global_listeners_lock), + daemon=True, + ) + fanout.start() + + if args.workstream_idle_timeout > 0: + cleanup = threading.Thread( + target=_idle_cleanup_thread, + args=(manager, args.workstream_idle_timeout * 60, global_queue), + daemon=True, + ) + cleanup.start() + + print(f"turnstone web server running on http://{args.host}:{args.port}") + print(f"Model: {model}") + if args.persona: + print(f"Persona: {args.persona}") + print("Press Ctrl+C to stop.") + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.") + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/turnstone/sim/__init__.py b/turnstone/sim/__init__.py new file mode 100644 index 00000000..b07a025d --- /dev/null +++ b/turnstone/sim/__init__.py @@ -0,0 +1,6 @@ +"""Turnstone cluster simulator.""" + +from turnstone.sim.cluster import SimCluster +from turnstone.sim.config import SimConfig + +__all__ = ["SimCluster", "SimConfig"] diff --git a/turnstone/sim/cli.py b/turnstone/sim/cli.py new file mode 100644 index 00000000..99707b40 --- /dev/null +++ b/turnstone/sim/cli.py @@ -0,0 +1,186 @@ +"""CLI entry point for turnstone-sim.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import sys + +from turnstone.sim.cluster import SimCluster +from turnstone.sim.config import SimConfig +from turnstone.sim.scenario import SCENARIOS + +log = logging.getLogger("turnstone.sim") + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="turnstone-sim", + description="Turnstone multi-node cluster simulator", + ) + parser.add_argument( + "--nodes", + type=int, + default=10, + help="Number of simulated nodes (default: 10)", + ) + parser.add_argument( + "--scenario", + choices=list(SCENARIOS.keys()), + default="steady", + help="Scenario to run (default: steady)", + ) + parser.add_argument( + "--duration", + type=int, + default=60, + help="Scenario duration in seconds (default: 60)", + ) + parser.add_argument( + "--mps", + type=float, + default=5.0, + help="Messages per second for steady scenario (default: 5.0)", + ) + parser.add_argument( + "--burst-size", + type=int, + default=100, + help="Message count for burst scenario (default: 100)", + ) + parser.add_argument( + "--llm-latency", + type=float, + default=2.0, + help="Mean LLM response latency in seconds (default: 2.0)", + ) + parser.add_argument( + "--tool-latency", + type=float, + default=0.5, + help="Mean tool execution latency in seconds (default: 0.5)", + ) + parser.add_argument( + "--tool-failure-rate", + type=float, + default=0.02, + help="Tool failure probability 0.0-1.0 (default: 0.02)", + ) + parser.add_argument( + "--node-kill-interval", + type=float, + default=15.0, + help="Seconds between node kills for node_failure scenario (default: 15)", + ) + parser.add_argument( + "--node-kill-count", + type=int, + default=1, + help="Nodes to kill per interval (default: 1)", + ) + parser.add_argument("--redis-host", default="localhost") + parser.add_argument("--redis-port", type=int, default=6379) + parser.add_argument("--redis-password", default=None) + parser.add_argument("--redis-db", type=int, default=0) + parser.add_argument("--prefix", default="turnstone") + parser.add_argument( + "--seed", type=int, default=None, help="Random seed for reproducibility" + ) + parser.add_argument("--metrics-file", default="", help="Write JSON metrics to file") + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + ) + + args = parser.parse_args() + + config = SimConfig( + num_nodes=args.nodes, + scenario=args.scenario, + duration=args.duration, + messages_per_second=args.mps, + burst_size=args.burst_size, + llm_latency_mean=args.llm_latency, + tool_latency_mean=args.tool_latency, + tool_failure_rate=args.tool_failure_rate, + node_kill_interval=args.node_kill_interval, + node_kill_count=args.node_kill_count, + redis_host=args.redis_host, + redis_port=args.redis_port, + redis_password=args.redis_password, + redis_db=args.redis_db, + prefix=args.prefix, + seed=args.seed, + metrics_file=args.metrics_file, + ) + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) + + try: + asyncio.run(_run(config)) + except KeyboardInterrupt: + log.info("Interrupted") + sys.exit(0) + + +async def _run(config: SimConfig) -> None: + cluster = SimCluster(config) + try: + await cluster.start() + log.info( + "Running scenario=%s nodes=%d duration=%ds", + config.scenario, + config.num_nodes, + config.duration, + ) + await cluster.run_scenario() + + report = cluster.report() + _print_report(report, config) + + if config.metrics_file: + with open(config.metrics_file, "w") as f: + json.dump(report, f, indent=2) + log.info("Metrics written to %s", config.metrics_file) + finally: + await cluster.stop() + + +def _print_report(report: dict, config: SimConfig) -> None: + lat = report.get("latency", {}) + tp = report.get("throughput", {}) + util = report.get("utilization", {}) + + print("\n" + "=" * 60) + print(" SIMULATION REPORT") + print("=" * 60) + print(f" Scenario: {config.scenario}") + print(f" Nodes: {config.num_nodes}") + print(f" Duration: {report['duration_seconds']}s") + print(f" Total turns: {report['total_turns']}") + print(f" Total errors: {report['total_errors']}") + print(f" Node kills: {report['node_kills']}") + print("-" * 60) + print(" THROUGHPUT") + print(f" Messages/sec: {tp.get('messages_per_sec', 0)}") + print(f" Turns/sec: {tp.get('turns_per_sec', 0)}") + print("-" * 60) + print(" LATENCY (seconds)") + print(f" p50: {lat.get('p50', 0)}") + print(f" p90: {lat.get('p90', 0)}") + print(f" p99: {lat.get('p99', 0)}") + print(f" mean: {lat.get('mean', 0)}") + print(f" max: {lat.get('max', 0)}") + if util: + print("-" * 60) + print(" UTILIZATION") + print(f" Mean ws/node: {util.get('mean_ws_per_node', 0):.1f}") + print(f" Max ws/node: {util.get('max_ws_per_node', 0)}") + print(f" Idle nodes: {util.get('nodes_with_zero_ws', 0)}") + print("=" * 60 + "\n") diff --git a/turnstone/sim/cluster.py b/turnstone/sim/cluster.py new file mode 100644 index 00000000..3bf617a6 --- /dev/null +++ b/turnstone/sim/cluster.py @@ -0,0 +1,319 @@ +"""Cluster orchestration — manages N SimNodes, dispatchers, and metrics.""" + +from __future__ import annotations + +import asyncio +import logging +import math +import time +from concurrent.futures import ThreadPoolExecutor + +import redis + +from turnstone.mq.broker import RedisBroker +from turnstone.sim.config import SimConfig +from turnstone.sim.metrics import MetricsCollector +from turnstone.sim.node import SimNode + +log = logging.getLogger("turnstone.sim.cluster") + +# How many node queues a single dispatcher watches via one BLPOP call. +NODES_PER_DISPATCHER = 50 + + +class PooledBroker(RedisBroker): + """RedisBroker that uses a shared external ConnectionPool.""" + + def __init__( + self, + pool: redis.ConnectionPool, + prefix: str = "turnstone", + response_ttl: int = 600, + ): + # Bypass RedisBroker.__init__ — set up manually with the shared pool. + import threading + + self._prefix = prefix + self._response_ttl = response_ttl + self._pool = pool + self._redis = redis.Redis(connection_pool=pool) + self._pubsub = self._redis.pubsub(ignore_subscribe_messages=True) + self._listener_thread: threading.Thread | None = None + self._running = True + + def close(self) -> None: + """No-op — the shared pool is managed by SimCluster.""" + self._running = False + + +class InboundDispatcher: + """Watches batches of node queues via a single BLPOP call. + + Instead of one BLPOP per node (which would exhaust Redis connections at + 1000 nodes), a dispatcher batches ~50 node queues into a single BLPOP + on multiple keys. This keeps total Redis connections bounded. + """ + + def __init__( + self, + redis_client: redis.Redis, + node_ids: list[str], + nodes: dict[str, SimNode], + prefix: str, + ): + self._redis = redis_client + self._node_ids = node_ids + self._nodes = nodes + self._prefix = prefix + self._running = True + + # Build BLPOP key list: per-node queues first (priority), shared last + self._keys = [f"{prefix}:inbound:{nid}" for nid in node_ids] + self._keys.append(f"{prefix}:inbound") + + # Pre-compute key → node_id mapping + self._key_to_node: dict[str, str] = { + f"{prefix}:inbound:{nid}": nid for nid in node_ids + } + + async def run(self) -> None: + while self._running: + # Snapshot keys to avoid race with remove_node() during BLPOP + keys = list(self._keys) + if not keys: + await asyncio.sleep(0.5) + continue + result = await asyncio.to_thread( + self._redis.blpop, + keys, + timeout=1, + ) + if result is None: + continue + + queue_key, raw = result + if isinstance(queue_key, bytes): + queue_key = queue_key.decode() + if isinstance(raw, bytes): + raw = raw.decode() + + node = self._resolve_target(queue_key) + if node and node._running: + await node.handle_message(raw) + + def _resolve_target(self, queue_key: str) -> SimNode | None: + """Determine which SimNode should handle this message.""" + node_id = self._key_to_node.get(queue_key) + if node_id: + return self._nodes.get(node_id) + + # Shared queue — pick running node with fewest workstreams and capacity + if self._nodes: + candidates = [ + n + for n in self._nodes.values() + if n._running and n.workstream_count < n._config.max_ws_per_node + ] + if candidates: + return min(candidates, key=lambda n: n.workstream_count) + # Fall back to any running node if all at capacity + running = [n for n in self._nodes.values() if n._running] + if running: + return min(running, key=lambda n: n.workstream_count) + return None + + def stop(self) -> None: + self._running = False + + def remove_node(self, node_id: str) -> None: + """Remove a node from this dispatcher (for kill simulation).""" + self._nodes.pop(node_id, None) + key = f"{self._prefix}:inbound:{node_id}" + self._key_to_node.pop(key, None) + if key in self._keys: + self._keys.remove(key) + + +class SimCluster: + """Orchestrates N SimNodes, dispatchers, heartbeats, and metrics. + + Usage:: + + cluster = SimCluster(config) + await cluster.start() + await cluster.run_scenario() + report = cluster.report() + await cluster.stop() + """ + + def __init__(self, config: SimConfig): + self._config = config + self._metrics = MetricsCollector() + self._nodes: dict[str, SimNode] = {} + self._node_order: list[str] = [] + self._dispatchers: list[InboundDispatcher] = [] + self._tasks: list[asyncio.Task] = [] + self._pool: redis.ConnectionPool | None = None + self._redis_client: redis.Redis | None = None + self._running = True + + @property + def metrics(self) -> MetricsCollector: + return self._metrics + + @property + def nodes(self) -> dict[str, SimNode]: + return self._nodes + + @property + def config(self) -> SimConfig: + return self._config + + async def start(self) -> None: + """Create connection pool, nodes, dispatchers; start all tasks.""" + self._executor = ThreadPoolExecutor(max_workers=64) + + # Shared Redis pool + self._pool = redis.ConnectionPool( + host=self._config.redis_host, + port=self._config.redis_port, + db=self._config.redis_db, + password=self._config.redis_password, + decode_responses=True, + retry_on_timeout=True, + max_connections=64, + ) + self._redis_client = redis.Redis(connection_pool=self._pool) + + # Create nodes + for i in range(self._config.num_nodes): + node_id = f"sim-{i:04d}" + broker = PooledBroker( + self._pool, + prefix=self._config.prefix, + ) + node = SimNode(node_id, broker, self._config, self._metrics) + self._nodes[node_id] = node + self._node_order.append(node_id) + + # Create dispatchers (batches of NODES_PER_DISPATCHER) + all_ids = list(self._nodes.keys()) + num_dispatchers = max(1, math.ceil(len(all_ids) / NODES_PER_DISPATCHER)) + for i in range(num_dispatchers): + start = i * NODES_PER_DISPATCHER + batch_ids = all_ids[start : start + NODES_PER_DISPATCHER] + # Each dispatcher gets its own Redis client from the shared pool + client = redis.Redis(connection_pool=self._pool) + dispatcher = InboundDispatcher( + client, + batch_ids, + dict(self._nodes), + self._config.prefix, + ) + self._dispatchers.append(dispatcher) + self._tasks.append(asyncio.create_task(dispatcher.run())) + + # Start heartbeat task + self._tasks.append(asyncio.create_task(self._heartbeat_loop())) + + # Start utilization snapshot task + self._tasks.append(asyncio.create_task(self._utilization_loop())) + + # Wait for all nodes to register + await self._wait_for_nodes() + log.info( + "Cluster started: %d nodes, %d dispatchers", + len(self._nodes), + len(self._dispatchers), + ) + + async def _heartbeat_loop(self) -> None: + """Register heartbeats for all running nodes concurrently.""" + interval = max(1, self._config.heartbeat_ttl // 2) + loop = asyncio.get_running_loop() + while self._running: + tasks = [ + loop.run_in_executor(self._executor, node.heartbeat_once) + for node in self._nodes.values() + if node._running + ] + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.sleep(interval) + + async def _utilization_loop(self) -> None: + """Periodically snapshot workstream utilization.""" + while self._running: + await asyncio.sleep(self._config.metrics_interval) + counts = { + nid: node.workstream_count + for nid, node in self._nodes.items() + if node._running + } + self._metrics.snapshot_utilization(counts) + + async def _wait_for_nodes(self) -> None: + """Do an initial heartbeat and confirm registration.""" + loop = asyncio.get_running_loop() + tasks = [ + loop.run_in_executor(self._executor, node.heartbeat_once) + for node in self._nodes.values() + ] + await asyncio.gather(*tasks, return_exceptions=True) + + registered = 0 + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + keys = await loop.run_in_executor( + self._executor, + self._redis_client.keys, + f"{self._config.prefix}:node:sim-*", + ) + registered = len(keys) + if registered >= self._config.num_nodes: + return + await asyncio.sleep(0.5) + raise TimeoutError( + f"Only {registered}/{self._config.num_nodes} nodes registered", + ) + + async def run_scenario(self) -> None: + """Run the configured scenario.""" + from turnstone.sim.scenario import SCENARIOS + + scenario_cls = SCENARIOS.get(self._config.scenario) + if scenario_cls is None: + raise ValueError(f"Unknown scenario: {self._config.scenario!r}") + scenario = scenario_cls() + await scenario.run(self, self._config, self._metrics) + + async def kill_node(self, node_id: str) -> None: + """Simulate a node failure: stop heartbeat, stop processing.""" + node = self._nodes.get(node_id) + if node and node._running: + node.stop() + self._metrics.record_node_kill(node_id) + # Remove from dispatchers + for d in self._dispatchers: + d.remove_node(node_id) + log.info("Killed node %s", node_id) + + def report(self) -> dict: + """Generate final metrics report.""" + return self._metrics.summary() + + async def stop(self) -> None: + """Shutdown all nodes and cancel tasks.""" + self._running = False + for node in self._nodes.values(): + node.stop() + for d in self._dispatchers: + d.stop() + for task in self._tasks: + task.cancel() + await asyncio.gather(*self._tasks, return_exceptions=True) + if hasattr(self, "_executor"): + self._executor.shutdown(wait=False) + if self._pool: + self._pool.disconnect() + log.info("Cluster stopped") diff --git a/turnstone/sim/config.py b/turnstone/sim/config.py new file mode 100644 index 00000000..5bd2da03 --- /dev/null +++ b/turnstone/sim/config.py @@ -0,0 +1,55 @@ +"""Simulation configuration.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SimConfig: + """All parameters controlling a simulation run.""" + + # -- cluster -- + num_nodes: int = 10 + max_ws_per_node: int = 10 + + # -- redis -- + redis_host: str = "localhost" + redis_port: int = 6379 + redis_db: int = 0 + redis_password: str | None = None + prefix: str = "turnstone" + + # -- heartbeat -- + heartbeat_ttl: int = 60 + + # -- LLM simulation -- + llm_latency_mean: float = 2.0 + llm_latency_stddev: float = 0.5 + llm_tokens_mean: int = 200 + llm_tokens_stddev: int = 50 + llm_token_rate: float = 50.0 # tokens/sec streaming speed + context_window: int = 131072 # for computing context ratio + + # -- tool simulation -- + tool_latency_mean: float = 0.5 + tool_latency_stddev: float = 0.2 + tool_failure_rate: float = 0.02 + tool_calls_per_turn_mean: float = 1.5 + tool_calls_per_turn_max: int = 4 + max_tool_rounds: int = 3 + + # -- scenario -- + scenario: str = "steady" + duration: int = 60 + messages_per_second: float = 5.0 + burst_size: int = 100 + node_kill_interval: float = 15.0 + node_kill_count: int = 1 + + # -- metrics -- + metrics_interval: float = 5.0 + metrics_file: str = "" + + # -- reproducibility -- + seed: int | None = None diff --git a/turnstone/sim/engine.py b/turnstone/sim/engine.py new file mode 100644 index 00000000..2963ecbb --- /dev/null +++ b/turnstone/sim/engine.py @@ -0,0 +1,139 @@ +"""LLM and tool execution simulation engine.""" + +from __future__ import annotations + +import asyncio +import random + +from turnstone.sim.config import SimConfig + +_WORD_POOL = [ + "the", + "result", + "shows", + "that", + "this", + "file", + "contains", + "function", + "data", + "analysis", + "implementation", + "code", + "completed", + "successfully", + "reviewed", + "output", + "processing", + "module", + "system", + "request", + "response", + "value", + "config", + "status", + "running", + "checked", + "verified", + "found", + "done", +] + +_TOOL_NAMES = [ + "bash", + "read_file", + "search", + "edit_file", + "write_file", + "math", + "web_fetch", +] + + +class ToolSimulationError(Exception): + """Raised when a simulated tool execution fails.""" + + +class SimEngine: + """Simulates LLM responses and tool execution with configurable distributions. + + Stateless — safe to share across workstreams on the same node. + """ + + def __init__(self, config: SimConfig, rng: random.Random | None = None): + self._config = config + self._rng = rng or random.Random(config.seed) + + async def simulate_llm_response( + self, first_round: bool, turn_number: int + ) -> tuple[str, list[dict]]: + """Simulate an LLM response. + + Returns ``(content_text, tool_calls)`` where *tool_calls* may be + empty (final answer) or a list of ``{"name": ..., "arguments": ...}`` + dicts. + """ + latency = max( + 0.05, + self._rng.gauss( + self._config.llm_latency_mean, + self._config.llm_latency_stddev, + ), + ) + await asyncio.sleep(latency) + + num_tokens = max( + 10, + int( + self._rng.gauss( + self._config.llm_tokens_mean, + self._config.llm_tokens_stddev, + ) + ), + ) + content = self._generate_content(num_tokens) + + # First round has a higher chance of tool calls; decreasing per round + tool_prob = 0.6 if first_round else 0.3 + if self._rng.random() < tool_prob: + num_calls = min( + max( + 1, + int( + self._rng.expovariate( + 1.0 / self._config.tool_calls_per_turn_mean, + ) + ), + ), + self._config.tool_calls_per_turn_max, + ) + calls = [ + { + "name": self._rng.choice(_TOOL_NAMES), + "arguments": '{"simulated": true}', + } + for _ in range(num_calls) + ] + return content, calls + + return content, [] + + async def simulate_tool_execution(self, tool_name: str) -> str: + """Simulate tool execution with latency and possible failure.""" + latency = max( + 0.01, + self._rng.gauss( + self._config.tool_latency_mean, + self._config.tool_latency_stddev, + ), + ) + await asyncio.sleep(latency) + + if self._rng.random() < self._config.tool_failure_rate: + raise ToolSimulationError(f"Simulated {tool_name} failure") + + return f"[sim] {tool_name} completed successfully" + + def _generate_content(self, num_tokens: int) -> str: + """Generate placeholder content of approximately *num_tokens* tokens.""" + return " ".join(self._rng.choices(_WORD_POOL, k=num_tokens)) diff --git a/turnstone/sim/metrics.py b/turnstone/sim/metrics.py new file mode 100644 index 00000000..47d768ce --- /dev/null +++ b/turnstone/sim/metrics.py @@ -0,0 +1,110 @@ +"""Simulation metrics collection.""" + +from __future__ import annotations + +import threading +import time +from collections import defaultdict + + +class MetricsCollector: + """Thread-safe metrics collector for simulation runs. + + Uses ``threading.Lock`` so it works from both sync and async contexts. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._turn_latencies: list[float] = [] + self._inject_times: list[float] = [] + self._complete_times: list[float] = [] + self._errors: int = 0 + self._error_details: list[tuple[float, str, str]] = [] + self._node_kills: list[tuple[float, str]] = [] + self._turns_per_node: dict[str, int] = defaultdict(int) + self._ws_counts: list[dict[str, int]] = [] # utilization snapshots + + def record_turn(self, ws_id: str, node_id: str, latency: float) -> None: + with self._lock: + self._turn_latencies.append(latency) + self._complete_times.append(time.monotonic()) + self._turns_per_node[node_id] += 1 + + def record_inject(self) -> None: + with self._lock: + self._inject_times.append(time.monotonic()) + + def record_error(self, node_id: str, message: str) -> None: + with self._lock: + self._errors += 1 + self._error_details.append((time.monotonic(), node_id, message)) + + def record_node_kill(self, node_id: str) -> None: + with self._lock: + self._node_kills.append((time.monotonic(), node_id)) + + def snapshot_utilization(self, ws_counts: dict[str, int]) -> None: + """Record workstream-per-node counts at a point in time.""" + with self._lock: + self._ws_counts.append(dict(ws_counts)) + + def summary(self) -> dict: + """Generate final metrics report with percentiles and aggregates.""" + with self._lock: + latencies = sorted(self._turn_latencies) + n = len(latencies) + + if n > 0 and self._inject_times and self._complete_times: + duration = self._complete_times[-1] - self._inject_times[0] + else: + duration = 0.0 + + # Utilization from latest snapshot + util: dict = {} + if self._ws_counts: + last = self._ws_counts[-1] + counts = list(last.values()) + if counts: + util = { + "mean_ws_per_node": sum(counts) / len(counts), + "max_ws_per_node": max(counts), + "nodes_with_zero_ws": sum(1 for c in counts if c == 0), + } + + return { + "total_turns": n, + "total_errors": self._errors, + "duration_seconds": round(duration, 2), + "throughput": { + "messages_per_sec": round( + len(self._inject_times) / duration, + 2, + ) + if duration > 0 + else 0, + "turns_per_sec": round( + n / duration, + 2, + ) + if duration > 0 + else 0, + }, + "latency": { + "p50": _percentile(latencies, 0.50), + "p90": _percentile(latencies, 0.90), + "p99": _percentile(latencies, 0.99), + "mean": round(sum(latencies) / n, 4) if n else 0, + "max": round(latencies[-1], 4) if n else 0, + }, + "utilization": util, + "node_kills": len(self._node_kills), + "turns_per_node": dict(self._turns_per_node), + } + + +def _percentile(sorted_values: list[float], pct: float) -> float: + if not sorted_values: + return 0.0 + idx = int(len(sorted_values) * pct) + idx = min(idx, len(sorted_values) - 1) + return round(sorted_values[idx], 4) diff --git a/turnstone/sim/node.py b/turnstone/sim/node.py new file mode 100644 index 00000000..ddf1d7a5 --- /dev/null +++ b/turnstone/sim/node.py @@ -0,0 +1,439 @@ +"""Simulated turnstone node. + +A SimNode replaces Bridge + Server + ChatSession with a lightweight async +coroutine that talks directly to Redis via the real RedisBroker. External +observers (TurnstoneClient, turnstone-console) see identical protocol +behaviour. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +import uuid + +from turnstone.mq.protocol import ( + AckEvent, + ClusterStateEvent, + ContentEvent, + ErrorEvent, + HealthResponseEvent, + InboundMessage, + NodeListEvent, + StateChangeEvent, + StatusEvent, + StreamEndEvent, + ToolResultEvent, + TurnCompleteEvent, + WorkstreamClosedEvent, + WorkstreamCreatedEvent, + WorkstreamListEvent, +) +from turnstone.sim.config import SimConfig +from turnstone.sim.engine import SimEngine, ToolSimulationError +from turnstone.sim.metrics import MetricsCollector + +log = logging.getLogger("turnstone.sim.node") + + +class SimWorkstream: + """Lightweight workstream state machine.""" + + def __init__( + self, + ws_id: str, + name: str, + node: SimNode, + engine: SimEngine, + config: SimConfig, + ): + self.ws_id = ws_id + self.name = name + self.state = "idle" + self._node = node + self._engine = engine + self._config = config + self._turn_count = 0 + self._total_tokens = 0 # accumulated across turns + + async def process_turn(self, message: str, correlation_id: str) -> None: + """Simulate a complete turn: LLM stream -> optional tools -> final.""" + t_start = time.monotonic() + self._turn_count += 1 + + try: + rounds = 0 + while True: + # LLM thinking + streaming + self._set_state("thinking", correlation_id) + content, tool_calls = await self._engine.simulate_llm_response( + rounds == 0, + self._turn_count, + ) + await self._stream_content(content, correlation_id) + + if not tool_calls or rounds >= self._config.max_tool_rounds: + break + + # Tool execution + self._set_state("running", correlation_id) + for tc in tool_calls: + name = tc["name"] + try: + output = await self._engine.simulate_tool_execution(name) + except ToolSimulationError as exc: + output = f"Error: {exc}" + self._node._metrics.record_error( + self._node.node_id, + str(exc), + ) + self._node._publish_ws( + self.ws_id, + ToolResultEvent( + ws_id=self.ws_id, + correlation_id=correlation_id, + name=name, + output=output, + ), + ) + + rounds += 1 + + # Finished — publish status, idle, turn complete + self._publish_status(correlation_id) + self._set_state("idle", correlation_id) + self._node._publish_ws( + self.ws_id, + TurnCompleteEvent( + ws_id=self.ws_id, + correlation_id=correlation_id, + ), + ) + self._node._publish_global( + TurnCompleteEvent( + ws_id=self.ws_id, + correlation_id=correlation_id, + ), + ) + + except Exception as exc: + self._set_state("error", correlation_id) + self._node._publish_ws( + self.ws_id, + ErrorEvent( + ws_id=self.ws_id, + correlation_id=correlation_id, + message=str(exc), + ), + ) + self._node._metrics.record_error(self._node.node_id, str(exc)) + + finally: + latency = time.monotonic() - t_start + self._node._metrics.record_turn( + self.ws_id, + self._node.node_id, + latency, + ) + + async def _stream_content(self, text: str, correlation_id: str) -> None: + """Simulate token-by-token streaming.""" + if not text: + return + # Count tokens (~1 token per word) and accumulate + self._total_tokens += len(text.split()) + chunk_size = max(1, len(text) // 8) + token_delay = 1.0 / max(1, self._config.llm_token_rate) + for i in range(0, len(text), chunk_size): + chunk = text[i : i + chunk_size] + self._node._publish_ws( + self.ws_id, + ContentEvent( + ws_id=self.ws_id, + correlation_id=correlation_id, + text=chunk, + ), + ) + await asyncio.sleep(token_delay * len(chunk.split())) + self._node._publish_ws( + self.ws_id, + StreamEndEvent(ws_id=self.ws_id, correlation_id=correlation_id), + ) + + def _set_state(self, state: str, correlation_id: str) -> None: + self.state = state + self._node._publish_global( + StateChangeEvent( + ws_id=self.ws_id, + correlation_id=correlation_id, + state=state, + ), + ) + # prompt tokens ~= 2x completion tokens for a realistic ratio + total = self._total_tokens * 3 + ctx_ratio = round(total / self._config.context_window, 3) if total else 0.0 + self._node._publish_cluster( + ClusterStateEvent( + ws_id=self.ws_id, + state=state, + node_id=self._node.node_id, + tokens=total, + context_ratio=ctx_ratio, + ), + ) + + def _publish_status(self, correlation_id: str) -> None: + total = self._total_tokens * 3 # prompt ~= 2x completion + cw = self._config.context_window + self._node._publish_ws( + self.ws_id, + StatusEvent( + ws_id=self.ws_id, + correlation_id=correlation_id, + prompt_tokens=self._total_tokens * 2, + completion_tokens=self._total_tokens, + total_tokens=total, + context_window=cw, + pct=round(total / cw, 3) if cw else 0, + effort="medium", + ), + ) + + +class SimNode: + """A lightweight simulated turnstone node. + + Replaces Bridge + Server + ChatSession with direct Redis protocol + interaction. + """ + + def __init__( + self, + node_id: str, + broker: object, + config: SimConfig, + metrics: MetricsCollector, + ): + self.node_id = node_id + self._broker = broker + self._config = config + self._metrics = metrics + # Derive per-node seed so each node has unique RNG sequences + import random + + node_seed = None + if config.seed is not None: + node_seed = hash((config.seed, node_id)) + self._engine = SimEngine(config, rng=random.Random(node_seed)) + self._workstreams: dict[str, SimWorkstream] = {} + self._running = True + self._started_at = time.time() + self._prefix = config.prefix + + @property + def workstream_count(self) -> int: + return len(self._workstreams) + + # -- message handling ---------------------------------------------------- + + async def handle_message(self, raw: str) -> None: + """Parse and dispatch an inbound message.""" + try: + msg = InboundMessage.from_json(raw) + await self._dispatch(msg) + except Exception as exc: + log.error("SimNode %s dispatch error: %s", self.node_id, exc) + self._publish_global(ErrorEvent(message=f"SimNode error: {exc}")) + + async def _dispatch(self, msg: InboundMessage) -> None: + handlers = { + "send": self._handle_send, + "create_workstream": self._handle_create_ws, + "close_workstream": self._handle_close_ws, + "list_workstreams": self._handle_list_ws, + "health": self._handle_health, + "list_nodes": self._handle_list_nodes, + } + handler = handlers.get(msg.type) + if handler: + await handler(msg) + else: + log.debug("SimNode %s ignoring message type: %s", self.node_id, msg.type) + + async def _handle_send(self, msg: InboundMessage) -> None: + ws_id = getattr(msg, "ws_id", "") + message = getattr(msg, "message", "") + cid = msg.correlation_id + + # Find or create workstream + if ws_id and ws_id in self._workstreams: + ws = self._workstreams[ws_id] + elif len(self._workstreams) >= self._config.max_ws_per_node: + self._publish_global( + ErrorEvent( + correlation_id=cid, + message=f"Node {self.node_id} at capacity ({self._config.max_ws_per_node} ws)", + ), + ) + return + else: + ws = self._create_workstream( + name=getattr(msg, "name", ""), + correlation_id=cid, + ) + + self._publish_ws( + ws.ws_id, + AckEvent(ws_id=ws.ws_id, correlation_id=cid, status="ok"), + ) + await ws.process_turn(message, cid) + + async def _handle_create_ws(self, msg: InboundMessage) -> None: + if len(self._workstreams) >= self._config.max_ws_per_node: + self._publish_global( + ErrorEvent( + correlation_id=msg.correlation_id, + message=f"Node {self.node_id} at capacity ({self._config.max_ws_per_node} ws)", + ), + ) + return + name = getattr(msg, "name", "") + ws = self._create_workstream(name=name, correlation_id=msg.correlation_id) + self._publish_ws( + ws.ws_id, + AckEvent(ws_id=ws.ws_id, correlation_id=msg.correlation_id, status="ok"), + ) + + async def _handle_close_ws(self, msg: InboundMessage) -> None: + ws_id = getattr(msg, "ws_id", "") + ws = self._workstreams.pop(ws_id, None) + if ws: + self._broker.del_ws_owner(ws_id) + event = WorkstreamClosedEvent( + ws_id=ws_id, + correlation_id=msg.correlation_id, + ) + self._publish_global(event) + self._publish_cluster(event) + + async def _handle_list_ws(self, msg: InboundMessage) -> None: + ws_list = [ + {"id": ws.ws_id, "name": ws.name, "state": ws.state} + for ws in self._workstreams.values() + ] + self._publish_global( + WorkstreamListEvent( + correlation_id=msg.correlation_id, + workstreams=ws_list, + ), + ) + + async def _handle_health(self, msg: InboundMessage) -> None: + self._publish_global( + HealthResponseEvent( + correlation_id=msg.correlation_id, + data={ + "status": "ok", + "node_id": self.node_id, + "sim": True, + "workstreams": len(self._workstreams), + }, + ), + ) + + async def _handle_list_nodes(self, msg: InboundMessage) -> None: + nodes = self._broker.list_nodes() + self._publish_global( + NodeListEvent(correlation_id=msg.correlation_id, nodes=nodes), + ) + + # -- workstream lifecycle ------------------------------------------------ + + def _create_workstream( + self, + name: str = "", + correlation_id: str = "", + ) -> SimWorkstream: + ws_id = uuid.uuid4().hex[:8] + if not name: + name = f"sim-ws-{ws_id[:4]}" + ws = SimWorkstream(ws_id, name, self, self._engine, self._config) + self._workstreams[ws_id] = ws + self._broker.set_ws_owner(ws_id, self.node_id) + event = WorkstreamCreatedEvent( + ws_id=ws_id, + correlation_id=correlation_id, + name=name, + ) + self._publish_global(event) + # Also publish to cluster channel so the console discovers the ws. + # Include node_id (the console collector keys on it). + self._publish_cluster( + ClusterStateEvent( + ws_id=ws_id, + state="idle", + node_id=self.node_id, + ), + ) + # The cluster channel expects a ws_created with node_id for the + # collector's _on_cluster_event handler. + self._broker.publish_outbound( + f"{self._prefix}:events:cluster", + json.dumps( + { + "type": "ws_created", + "ws_id": ws_id, + "name": name, + "node_id": self.node_id, + "correlation_id": correlation_id, + } + ), + ) + return ws + + # -- heartbeat ----------------------------------------------------------- + + def heartbeat_once(self) -> None: + """Register a single heartbeat with the broker.""" + self._broker.register_node( + self.node_id, + { + "server_url": f"sim://{self.node_id}", + "started": self._started_at, + "sim": True, + "workstreams": len(self._workstreams), + "max_ws": self._config.max_ws_per_node, + }, + ttl=self._config.heartbeat_ttl, + ) + + # -- shutdown ------------------------------------------------------------ + + def stop(self) -> None: + """Mark node as stopped and clean up ownership keys.""" + self._running = False + for ws_id in list(self._workstreams): + self._broker.del_ws_owner(ws_id) + self._workstreams.clear() + + # -- event publishing helpers -------------------------------------------- + + def _publish_global(self, event: object) -> None: + self._broker.publish_outbound( + f"{self._prefix}:events:global", + event.to_json(), + ) + + def _publish_ws(self, ws_id: str, event: object) -> None: + self._broker.publish_outbound( + f"{self._prefix}:events:{ws_id}", + event.to_json(), + ) + + def _publish_cluster(self, event: object) -> None: + self._broker.publish_outbound( + f"{self._prefix}:events:cluster", + event.to_json(), + ) diff --git a/turnstone/sim/scenario.py b/turnstone/sim/scenario.py new file mode 100644 index 00000000..f619e796 --- /dev/null +++ b/turnstone/sim/scenario.py @@ -0,0 +1,234 @@ +"""Simulation scenarios — workload patterns for cluster testing.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import TYPE_CHECKING, Protocol + +from turnstone.mq.broker import RedisBroker +from turnstone.mq.protocol import SendMessage +from turnstone.sim.config import SimConfig +from turnstone.sim.metrics import MetricsCollector + +if TYPE_CHECKING: + from turnstone.sim.cluster import SimCluster + +log = logging.getLogger("turnstone.sim.scenario") + + +class Scenario(Protocol): + async def run( + self, + cluster: SimCluster, + config: SimConfig, + metrics: MetricsCollector, + ) -> None: ... + + +class SteadyStateScenario: + """Inject messages at a constant rate for the configured duration.""" + + async def run( + self, + cluster: SimCluster, + config: SimConfig, + metrics: MetricsCollector, + ) -> None: + broker = _make_broker(config) + interval = 1.0 / max(0.01, config.messages_per_second) + deadline = time.monotonic() + config.duration + count = 0 + + try: + while time.monotonic() < deadline: + count += 1 + msg = SendMessage( + message=f"Steady-state message {count}", + auto_approve=True, + ) + broker.push_inbound(msg.to_json()) + metrics.record_inject() + await asyncio.sleep(interval) + finally: + # Allow in-flight turns to finish + await asyncio.sleep(min(10, config.llm_latency_mean * 3)) + broker.close() + log.info("Steady-state scenario complete: %d messages injected", count) + + +class BurstScenario: + """Inject burst_size messages as fast as possible, then wait.""" + + async def run( + self, + cluster: SimCluster, + config: SimConfig, + metrics: MetricsCollector, + ) -> None: + broker = _make_broker(config) + + try: + for i in range(config.burst_size): + msg = SendMessage( + message=f"Burst message {i}", + auto_approve=True, + ) + broker.push_inbound(msg.to_json()) + metrics.record_inject() + + log.info("Burst injected: %d messages", config.burst_size) + # Wait for processing to complete + await asyncio.sleep(config.duration) + finally: + broker.close() + + +class NodeFailureScenario: + """Steady-state load with periodic node kills.""" + + async def run( + self, + cluster: SimCluster, + config: SimConfig, + metrics: MetricsCollector, + ) -> None: + # Start steady injection in background + steady = SteadyStateScenario() + load_task = asyncio.create_task(steady.run(cluster, config, metrics)) + + # Periodically kill nodes + killed = 0 + max_kills = config.num_nodes // 2 # never kill more than half + node_ids = list(cluster.nodes.keys()) + + try: + while killed < max_kills: + await asyncio.sleep(config.node_kill_interval) + for _ in range(config.node_kill_count): + if killed < len(node_ids): + await cluster.kill_node(node_ids[killed]) + killed += 1 + finally: + await load_task + log.info("Node-failure scenario complete: %d nodes killed", killed) + + +class DirectedScenario: + """Send messages targeted to specific nodes.""" + + async def run( + self, + cluster: SimCluster, + config: SimConfig, + metrics: MetricsCollector, + ) -> None: + broker = _make_broker(config) + node_ids = list(cluster.nodes.keys()) + count = min(config.burst_size, len(node_ids)) + + try: + for i in range(count): + target = node_ids[i % len(node_ids)] + msg = SendMessage( + message=f"Directed message to {target}", + auto_approve=True, + target_node=target, + ) + broker.push_inbound(msg.to_json(), node_id=target) + metrics.record_inject() + + log.info("Directed scenario: %d messages sent to specific nodes", count) + await asyncio.sleep(config.duration) + finally: + broker.close() + + +class LifecycleScenario: + """Create, use, and close workstreams across nodes.""" + + async def run( + self, + cluster: SimCluster, + config: SimConfig, + metrics: MetricsCollector, + ) -> None: + from turnstone.mq.protocol import ( + CloseWorkstreamMessage, + CreateWorkstreamMessage, + ) + + broker = _make_broker(config) + ws_ids: list[str] = [] + + try: + # Phase 1: Create workstreams + create_count = min(50, config.num_nodes * 2) + for i in range(create_count): + msg = CreateWorkstreamMessage( + name=f"lifecycle-ws-{i}", + auto_approve=True, + ) + broker.push_inbound(msg.to_json()) + metrics.record_inject() + await asyncio.sleep(0.05) + + # Let creations settle + await asyncio.sleep(3) + + # Phase 2: Send messages to shared queue (will be routed to nodes + # that own workstreams) + for i in range(create_count): + msg = SendMessage( + message=f"Lifecycle message {i}", + auto_approve=True, + ) + broker.push_inbound(msg.to_json()) + metrics.record_inject() + await asyncio.sleep(0.1) + + # Let turns complete + await asyncio.sleep(min(15, config.llm_latency_mean * 5)) + + # Phase 3: Close half the workstreams + # Collect ws_ids from nodes + for node in cluster.nodes.values(): + for ws_id in list(node._workstreams.keys()): + ws_ids.append(ws_id) + + close_count = len(ws_ids) // 2 + for ws_id in ws_ids[:close_count]: + owner = broker.get_ws_owner(ws_id) + msg = CloseWorkstreamMessage(ws_id=ws_id) + broker.push_inbound(msg.to_json(), node_id=owner or "") + await asyncio.sleep(0.05) + + await asyncio.sleep(2) + log.info( + "Lifecycle scenario complete: created %d, closed %d", + create_count, + close_count, + ) + finally: + broker.close() + + +def _make_broker(config: SimConfig) -> RedisBroker: + """Create a RedisBroker for scenario message injection.""" + return RedisBroker( + host=config.redis_host, + port=config.redis_port, + db=config.redis_db, + prefix=config.prefix, + password=config.redis_password, + ) + + +SCENARIOS: dict[str, type] = { + "steady": SteadyStateScenario, + "burst": BurstScenario, + "node_failure": NodeFailureScenario, + "directed": DirectedScenario, + "lifecycle": LifecycleScenario, +} diff --git a/turnstone/tools/bash.json b/turnstone/tools/bash.json new file mode 100644 index 00000000..1f1ab776 --- /dev/null +++ b/turnstone/tools/bash.json @@ -0,0 +1,16 @@ +{ + "name": "bash", + "description": "Execute a bash command and return stdout + stderr. Use this tool freely for any task: checking time, reading files, running programs, system info, etc.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + } + }, + "required": ["command"] + }, + "task_agent": true, + "primary_key": "command" +} diff --git a/turnstone/tools/edit_file.json b/turnstone/tools/edit_file.json new file mode 100644 index 00000000..06bc4f11 --- /dev/null +++ b/turnstone/tools/edit_file.json @@ -0,0 +1,28 @@ +{ + "name": "edit_file", + "description": "Replace an exact string in a file with new content. Fails if old_string is not found or matches multiple locations (use near_line to disambiguate). Requires read_file on the same path first.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative file path." + }, + "old_string": { + "type": "string", + "description": "The exact text to find and replace." + }, + "new_string": { + "type": "string", + "description": "The replacement text." + }, + "near_line": { + "type": "integer", + "description": "When old_string matches multiple locations, pick the one nearest this line number." + } + }, + "required": ["path", "old_string", "new_string"] + }, + "task_agent": true, + "primary_key": "old_string" +} diff --git a/turnstone/tools/forget.json b/turnstone/tools/forget.json new file mode 100644 index 00000000..74a88448 --- /dev/null +++ b/turnstone/tools/forget.json @@ -0,0 +1,15 @@ +{ + "name": "forget", + "description": "Remove a persistent memory by key. Use when the user asks to forget, remove, or delete a stored memory.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The memory key to remove (e.g. 'user_name')." + } + }, + "required": ["key"] + }, + "primary_key": "key" +} diff --git a/turnstone/tools/man.json b/turnstone/tools/man.json new file mode 100644 index 00000000..8cb7346e --- /dev/null +++ b/turnstone/tools/man.json @@ -0,0 +1,22 @@ +{ + "name": "man", + "description": "Read a man page. Use this instead of bash('man ...') or web_search. Returns the full formatted manual entry.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "string", + "description": "The man page name (e.g. 'grep', 'socket', 'printf')." + }, + "section": { + "type": "string", + "description": "Manual section (e.g. '1' commands, '2' syscalls, '3' library). Optional." + } + }, + "required": ["page"] + }, + "agent": true, + "task_agent": true, + "auto_approve": true, + "primary_key": "page" +} diff --git a/turnstone/tools/math.json b/turnstone/tools/math.json new file mode 100644 index 00000000..7c258d91 --- /dev/null +++ b/turnstone/tools/math.json @@ -0,0 +1,18 @@ +{ + "name": "math", + "description": "Execute Python code for math/computation. Code MUST use print() to produce output. Available: sympy, numpy, scipy (with scipy.special, scipy.optimize, scipy.integrate, scipy.linalg), math, fractions, itertools, functools, collections, decimal, operator, random, re, string. Common sympy names (symbols, solve, simplify, expand, factor, sqrt, Rational, Matrix, integrate, diff, etc.) are pre-imported. Example: x = symbols('x'); print(solve(x**2 - 4, x))", + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python code to execute. Must use print() for output." + } + }, + "required": ["code"] + }, + "agent": true, + "task_agent": true, + "auto_approve": true, + "primary_key": "code" +} diff --git a/turnstone/tools/plan.json b/turnstone/tools/plan.json new file mode 100644 index 00000000..832ffcb9 --- /dev/null +++ b/turnstone/tools/plan.json @@ -0,0 +1,15 @@ +{ + "name": "plan", + "description": "Plan before implementing. An autonomous agent explores the codebase and writes a structured plan to .plan-.md (unique per session to avoid workstream collisions). If a plan for this session already exists it is re-read and refined rather than overwritten from scratch. Use plan BEFORE writing code — when the user asks to build, add, refactor, or change something that touches multiple files or has unclear scope. The plan identifies files to modify, existing patterns to reuse, and risks to consider.", + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "What to plan — the goal, constraints, and scope." + } + }, + "required": ["prompt"] + }, + "primary_key": "prompt" +} diff --git a/turnstone/tools/read_file.json b/turnstone/tools/read_file.json new file mode 100644 index 00000000..4c51f125 --- /dev/null +++ b/turnstone/tools/read_file.json @@ -0,0 +1,26 @@ +{ + "name": "read_file", + "description": "Read the contents of a file. Returns numbered lines. Must be called before edit_file on the same path.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative file path." + }, + "offset": { + "type": "integer", + "description": "Line number to start reading from (1-based, default: 1)." + }, + "limit": { + "type": "integer", + "description": "Maximum number of lines to read. Omit to read entire file." + } + }, + "required": ["path"] + }, + "agent": true, + "task_agent": true, + "auto_approve": true, + "primary_key": "path" +} diff --git a/turnstone/tools/recall.json b/turnstone/tools/recall.json new file mode 100644 index 00000000..3be6fbf9 --- /dev/null +++ b/turnstone/tools/recall.json @@ -0,0 +1,18 @@ +{ + "name": "recall", + "description": "Search memories and past conversations. With no query, lists all saved memories. With a query, searches both memories and conversation history.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search term or phrase. Omit to list all memories." + }, + "limit": { + "type": "integer", + "description": "Max conversation results to return (default 20)." + } + } + }, + "primary_key": "query" +} diff --git a/turnstone/tools/remember.json b/turnstone/tools/remember.json new file mode 100644 index 00000000..1693e3f1 --- /dev/null +++ b/turnstone/tools/remember.json @@ -0,0 +1,19 @@ +{ + "name": "remember", + "description": "Save a persistent memory. Memories persist across sessions. Use to remember IPs, paths, commands, conventions, or any fact worth recalling later.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Short identifier (e.g. 'user_name')." + }, + "value": { + "type": "string", + "description": "Content to remember." + } + }, + "required": ["key", "value"] + }, + "primary_key": "key" +} diff --git a/turnstone/tools/search.json b/turnstone/tools/search.json new file mode 100644 index 00000000..f3049fec --- /dev/null +++ b/turnstone/tools/search.json @@ -0,0 +1,22 @@ +{ + "name": "search", + "description": "Search file contents for a regex pattern. Returns matching lines with file paths and line numbers. Searches recursively when path is a directory.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Regex pattern to search for (extended regex)." + }, + "path": { + "type": "string", + "description": "File or directory to search in (default: current directory)." + } + }, + "required": ["query"] + }, + "agent": true, + "task_agent": true, + "auto_approve": true, + "primary_key": "query" +} diff --git a/turnstone/tools/task.json b/turnstone/tools/task.json new file mode 100644 index 00000000..eb81b5c3 --- /dev/null +++ b/turnstone/tools/task.json @@ -0,0 +1,15 @@ +{ + "name": "task", + "description": "Delegate a general-purpose task to an autonomous sub-agent. The agent inherits all tools and can read, write, edit, search, and run commands. Use task for work that requires file modifications or command execution. Provide a clear, self-contained prompt.", + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Complete task description for the sub-agent." + } + }, + "required": ["prompt"] + }, + "primary_key": "prompt" +} diff --git a/turnstone/tools/web_fetch.json b/turnstone/tools/web_fetch.json new file mode 100644 index 00000000..2fda4df2 --- /dev/null +++ b/turnstone/tools/web_fetch.json @@ -0,0 +1,22 @@ +{ + "name": "web_fetch", + "description": "Fetch a URL and extract specific information from it. You must provide a question or extraction guidance — the page is fetched, analyzed, and only relevant information is returned (not raw page content).", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to fetch (must start with http:// or https://)." + }, + "question": { + "type": "string", + "description": "What to extract or answer from the page content." + } + }, + "required": ["url", "question"] + }, + "agent": true, + "task_agent": true, + "auto_approve": true, + "primary_key": "url" +} diff --git a/turnstone/tools/web_search.json b/turnstone/tools/web_search.json new file mode 100644 index 00000000..1478aadc --- /dev/null +++ b/turnstone/tools/web_search.json @@ -0,0 +1,26 @@ +{ + "name": "web_search", + "description": "Search the web using a text query. Returns ranked results with titles, URLs, and content snippets.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query." + }, + "max_results": { + "type": "integer", + "description": "Max results to return (default 5, max 20)." + }, + "topic": { + "type": "string", + "description": "Search topic: general, news, or finance (default general)." + } + }, + "required": ["query"] + }, + "agent": true, + "task_agent": true, + "auto_approve": true, + "primary_key": "query" +} diff --git a/turnstone/tools/write_file.json b/turnstone/tools/write_file.json new file mode 100644 index 00000000..fd04afaa --- /dev/null +++ b/turnstone/tools/write_file.json @@ -0,0 +1,20 @@ +{ + "name": "write_file", + "description": "Write content to a file, creating it if needed. Overwrites existing content.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative file path." + }, + "content": { + "type": "string", + "description": "The full file content to write." + } + }, + "required": ["path", "content"] + }, + "task_agent": true, + "primary_key": "content" +} diff --git a/turnstone/ui/__init__.py b/turnstone/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/turnstone/ui/colors.py b/turnstone/ui/colors.py new file mode 100644 index 00000000..c813f26f --- /dev/null +++ b/turnstone/ui/colors.py @@ -0,0 +1,46 @@ +"""ANSI color constants and helper functions. + +Respects the NO_COLOR convention (https://no-color.org/) and suppresses +color when stdout is not a terminal (piped output). +""" + +import os +import sys + +_use_color = "NO_COLOR" not in os.environ and sys.stdout.isatty() + +RESET = "\033[0m" if _use_color else "" +BOLD = "\033[1m" if _use_color else "" +DIM = "\033[2m" if _use_color else "" +ITALIC = "\033[3m" if _use_color else "" +RED = "\033[31m" if _use_color else "" +GREEN = "\033[32m" if _use_color else "" +YELLOW = "\033[33m" if _use_color else "" +BLUE = "\033[34m" if _use_color else "" +MAGENTA = "\033[35m" if _use_color else "" +CYAN = "\033[36m" if _use_color else "" +GRAY = "\033[90m" if _use_color else "" + + +def red(s): + return f"{RED}{s}{RESET}" + + +def yellow(s): + return f"{YELLOW}{s}{RESET}" + + +def dim(s): + return f"{DIM}{s}{RESET}" + + +def bold(s): + return f"{BOLD}{s}{RESET}" + + +def cyan(s): + return f"{CYAN}{s}{RESET}" + + +def green(s): + return f"{GREEN}{s}{RESET}" diff --git a/turnstone/ui/markdown.py b/turnstone/ui/markdown.py new file mode 100644 index 00000000..b8a01253 --- /dev/null +++ b/turnstone/ui/markdown.py @@ -0,0 +1,66 @@ +"""Line-buffered markdown to ANSI renderer for streaming output.""" + +import re + +from turnstone.ui.colors import BOLD, CYAN, DIM, ITALIC, MAGENTA, RESET + + +class MarkdownRenderer: + """Line-buffered markdown → ANSI converter for streaming output. + + Buffers content until a newline arrives, then renders the complete line + with regex-based markdown → ANSI conversion. Multi-line constructs + (fenced code blocks) track state across lines. + """ + + def __init__(self): + self.in_code_block = False + self._buf = "" + + def feed(self, text: str) -> str: + """Feed text, return ANSI-rendered output for complete lines.""" + self._buf += text + out = [] + while "\n" in self._buf: + line, self._buf = self._buf.split("\n", 1) + out.append(self._render_line(line)) + return "\n".join(out) + "\n" if out else "" + + def flush(self) -> str: + """Flush remaining buffer (end of stream).""" + if self._buf: + rendered = self._render_line(self._buf) + self._buf = "" + return rendered + return "" + + def _render_line(self, line: str) -> str: + # Code block fence toggle + if line.strip().startswith("```"): + self.in_code_block = not self.in_code_block + return f"{DIM}{line}{RESET}" + + # Inside code block — cyan, no further markdown processing + if self.in_code_block: + return f"{CYAN}{line}{RESET}" + + # Headers (# H1, ## H2, ### H3, #### H4, ##### H5, ###### H6) + m = re.match(r"^(#{1,6}) (.+)", line) + if m: + return f"{BOLD}{MAGENTA}{m.group(2)}{RESET}" + + # Inline formatting (order matters: bold before italic) + line = re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", line) + line = re.sub(r"__(.+?)__", f"{BOLD}\\1{RESET}", line) + line = re.sub( + r"(? {name, state} +let currentWsId = null; +let contentEvtSource = null; +let globalEvtSource = null; +let contentBuffer = ""; +let contentRetryDelay = 1000; +let globalRetryDelay = 1000; +let dashboardVisible = false; +let _historyNavigation = false; // true while popstate is driving navigation + +/* Auth-aware fetch — shows login overlay on 401 */ +function authFetch(url, opts) { + return fetch(url, opts).then(function (r) { + if (r.status === 401) { + showLogin(); + throw new Error("auth"); + } + return r; + }); +} + +var _loginTrapHandler = null; +var _loginBusy = false; + +function initLogin() { + var overlay = document.createElement("div"); + overlay.id = "login-overlay"; + overlay.style.display = "none"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-labelledby", "login-title"); + overlay.innerHTML = + '
' + + '

turnstone

' + + '' + + '' + + '' + + '' + + "
"; + document.body.appendChild(overlay); + document.getElementById("login-submit").onclick = submitLogin; + document + .getElementById("login-token") + .addEventListener("keydown", function (e) { + if (e.key === "Enter") submitLogin(); + if (e.key === "Escape") { + var errEl = document.getElementById("login-error"); + if (errEl && errEl.style.display !== "none") { + errEl.style.display = "none"; + errEl.textContent = ""; + } + } + }); +} + +function showLogin() { + var overlay = document.getElementById("login-overlay"); + if (!overlay) return; + overlay.style.display = "flex"; + document.body.style.overflow = "hidden"; + var logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) logoutBtn.style.display = "none"; + var errEl = document.getElementById("login-error"); + if (errEl) { + errEl.style.display = "none"; + errEl.textContent = ""; + } + setTimeout(function () { + var inp = document.getElementById("login-token"); + if (inp) { + inp.value = ""; + inp.focus(); + } + }, 50); + // Focus trap + if (_loginTrapHandler) + document.removeEventListener("keydown", _loginTrapHandler); + _loginTrapHandler = function (e) { + if (e.key === "Tab") { + var box = document.getElementById("login-box"); + var focusable = box.querySelectorAll("input, button"); + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; + document.addEventListener("keydown", _loginTrapHandler); +} + +function hideLogin() { + var overlay = document.getElementById("login-overlay"); + if (overlay) overlay.style.display = "none"; + document.body.style.overflow = ""; + if (_loginTrapHandler) { + document.removeEventListener("keydown", _loginTrapHandler); + _loginTrapHandler = null; + } +} + +function submitLogin() { + if (_loginBusy) return; + var token = (document.getElementById("login-token").value || "").trim(); + if (!token) { + var errEl = document.getElementById("login-error"); + if (errEl) { + errEl.textContent = "Token is required"; + errEl.style.display = "block"; + } + document.getElementById("login-token").focus(); + return; + } + + _loginBusy = true; + var btn = document.getElementById("login-submit"); + var inp = document.getElementById("login-token"); + btn.disabled = true; + btn.textContent = "Signing in\u2026"; + inp.disabled = true; + + fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: token }), + }) + .then(function (r) { + if (r.status === 401 || r.status === 403) throw new Error("invalid"); + if (!r.ok) throw new Error("server"); + return r.json(); + }) + .then(function () { + _loginBusy = false; + btn.disabled = false; + btn.textContent = "Sign in"; + inp.disabled = false; + hideLogin(); + document.getElementById("logout-btn").style.display = ""; + // Re-initialize: fetch workstreams, connect SSE + authFetch("/api/workstreams") + .then(function (r) { + return r.json(); + }) + .then(function (data) { + data.workstreams.forEach(function (ws) { + workstreams[ws.id] = { name: ws.name, state: ws.state }; + }); + var wsIds = Object.keys(workstreams); + if (wsIds.length) { + currentWsId = wsIds[0]; + renderTabBar(); + connectContentSSE(currentWsId); + } + connectGlobalSSE(); + showDashboard(); + }); + }) + .catch(function (err) { + _loginBusy = false; + btn.disabled = false; + btn.textContent = "Sign in"; + inp.disabled = false; + var errEl = document.getElementById("login-error"); + if (errEl) { + errEl.textContent = + err.message === "invalid" + ? "Invalid token" + : "Connection failed \u2014 try again"; + errEl.style.display = "block"; + } + }); +} + +function logout() { + fetch("/api/auth/logout", { method: "POST" }).then(function () { + if (contentEvtSource) { + contentEvtSource.close(); + contentEvtSource = null; + } + if (globalEvtSource) { + globalEvtSource.close(); + globalEvtSource = null; + } + showLogin(); + }); +} + +// --- Dashboard helpers --- +var STATE_DISPLAY = { + running: { symbol: "\u25b8", label: "run" }, + thinking: { symbol: "\u25cc", label: "think" }, + attention: { symbol: "\u25c6", label: "attn" }, + idle: { symbol: "\u00b7", label: "idle" }, + error: { symbol: "\u2716", label: "err" }, +}; +function formatTokens(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + return String(n || 0); +} +function ctxClass(ratio) { + if (ratio <= 0) return "ctx-idle"; + var pct = ratio * 100; + if (pct < 30) return "ctx-low"; + if (pct < 50) return "ctx-mid"; + if (pct < 80) return "ctx-high"; + return "ctx-danger"; +} +function formatUptime(seconds) { + if (seconds < 60) return seconds + "s"; + var min = Math.floor(seconds / 60); + if (min < 60) return min + "m"; + var hr = Math.floor(min / 60); + return hr + "h " + (min % 60) + "m"; +} + +// --- Theme --- +function toggleTheme() { + var current = document.documentElement.dataset.theme; + var next = current === "light" ? "" : "light"; + document.documentElement.dataset.theme = next; + localStorage.setItem("pcode-theme", next || "dark"); + updateThemeMenuItem(); +} +function updateThemeMenuItem() { + var isLight = document.documentElement.dataset.theme === "light"; + // Show the target state icon+label (what you will switch to) + document.getElementById("theme-menu-icon").textContent = isLight + ? "\u263E" + : "\u2600"; + document.getElementById("theme-menu-label").textContent = isLight + ? "Dark mode" + : "Light mode"; + document + .getElementById("theme-menu-item") + .setAttribute( + "aria-label", + isLight + ? "Switch to dark mode (currently light)" + : "Switch to light mode (currently dark)", + ); +} +(function () { + if (localStorage.getItem("pcode-theme") === "light") + document.documentElement.dataset.theme = "light"; + updateThemeMenuItem(); +})(); + +// --- Hamburger menu --- +function toggleHamburger() { + var menu = document.getElementById("hamburger-menu"); + var btn = document.getElementById("hamburger-btn"); + var open = menu.classList.toggle("open"); + btn.setAttribute("aria-expanded", open ? "true" : "false"); + if (open) { + updateThemeMenuItem(); + // Focus first item + var first = menu.querySelector(".hmenu-item"); + if (first) first.focus(); + } +} +function closeHamburger() { + document.getElementById("hamburger-menu").classList.remove("open"); + document + .getElementById("hamburger-btn") + .setAttribute("aria-expanded", "false"); +} +function hamburgerDashboard() { + closeHamburger(); + toggleDashboard(); +} +function hamburgerTheme() { + toggleTheme(); + closeHamburger(); +} +// Close on outside click +document.addEventListener("click", function (e) { + var wrap = document.getElementById("hamburger-wrap"); + if (wrap && !wrap.contains(e.target)) closeHamburger(); +}); +// Keyboard nav within menu +document + .getElementById("hamburger-menu") + .addEventListener("keydown", function (e) { + var items = Array.from(this.querySelectorAll(".hmenu-item")); + var idx = items.indexOf(document.activeElement); + if (e.key === "ArrowDown") { + e.preventDefault(); + items[(idx + 1) % items.length].focus(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + items[(idx - 1 + items.length) % items.length].focus(); + } else if (e.key === "Home") { + e.preventDefault(); + items[0].focus(); + } else if (e.key === "End") { + e.preventDefault(); + items[items.length - 1].focus(); + } else if (e.key === "Escape") { + e.preventDefault(); + closeHamburger(); + document.getElementById("hamburger-btn").focus(); + } else if (e.key === "Tab") { + closeHamburger(); + } + }); +// Escape when focus is on the button itself +document + .getElementById("hamburger-btn") + .addEventListener("keydown", function (e) { + if ( + e.key === "Escape" && + document.getElementById("hamburger-menu").classList.contains("open") + ) { + e.preventDefault(); + closeHamburger(); + } + }); + +// --- Markdown rendering (basic regex, no external libs) --- +function renderMarkdown(text) { + // Protect code blocks first + const codeBlocks = []; + text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function (m, lang, code) { + codeBlocks.push( + '
' +
+        escapeHtml(code.replace(/\n$/, "")) +
+        "
", + ); + return "\x00CB" + (codeBlocks.length - 1) + "\x00"; + }); + + // Protect inline code + const inlineCodes = []; + text = text.replace(/`([^`\n]+)`/g, function (m, code) { + inlineCodes.push("" + escapeHtml(code) + ""); + return "\x00IC" + (inlineCodes.length - 1) + "\x00"; + }); + + // Process block-level elements per line + const lines = text.split("\n"); + const out = []; + let inList = false; + let listType = ""; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + + // Horizontal rule + if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) { + if (inList) { + out.push(listType === "ul" ? "" : ""); + inList = false; + } + out.push("
"); + continue; + } + + // Headers + const hm = line.match(/^(#{1,6})\s+(.+)/); + if (hm) { + if (inList) { + out.push(listType === "ul" ? "" : ""); + inList = false; + } + const level = hm[1].length; + out.push( + "" + inlineMarkdown(hm[2]) + "", + ); + continue; + } + + // Blockquote + if (line.startsWith("> ")) { + if (inList) { + out.push(listType === "ul" ? "" : ""); + inList = false; + } + out.push( + "
" + inlineMarkdown(line.slice(2)) + "
", + ); + continue; + } + + // Unordered list + const ulm = line.match(/^(\s*)[-*+]\s+(.+)/); + if (ulm) { + if (!inList || listType !== "ul") { + if (inList) out.push(listType === "ul" ? "" : ""); + out.push("
    "); + inList = true; + listType = "ul"; + } + out.push("
  • " + inlineMarkdown(ulm[2]) + "
  • "); + continue; + } + + // Ordered list + const olm = line.match(/^(\s*)\d+[.)]\s+(.+)/); + if (olm) { + if (!inList || listType !== "ol") { + if (inList) out.push(listType === "ul" ? "
" : ""); + out.push("
    "); + inList = true; + listType = "ol"; + } + out.push("
  1. " + inlineMarkdown(olm[2]) + "
  2. "); + continue; + } + + // Close list if we hit a non-list line + if (inList && line.trim() === "") { + out.push(listType === "ul" ? "" : "
"); + inList = false; + } + + // Paragraph / plain text + if (line.trim() === "") { + out.push(""); + } else { + out.push("

" + inlineMarkdown(line) + "

"); + } + } + if (inList) out.push(listType === "ul" ? "" : ""); + + let result = out.join("\n"); + + // Restore code blocks and inline code + result = result.replace(/\x00CB(\d+)\x00/g, function (m, idx) { + return codeBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00IC(\d+)\x00/g, function (m, idx) { + return inlineCodes[parseInt(idx)]; + }); + + return result; +} + +function inlineMarkdown(text) { + // Bold + text = text.replace(/\*\*(.+?)\*\*/g, "$1"); + text = text.replace(/__(.+?)__/g, "$1"); + // Italic + text = text.replace(/\*(.+?)\*/g, "$1"); + text = text.replace(/_(.+?)_/g, "$1"); + // Strikethrough + text = text.replace(/~~(.+?)~~/g, "$1"); + // Links + text = text.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '$1', + ); + return text; +} + +function escapeHtml(text) { + const d = document.createElement("div"); + d.textContent = text; + return d.innerHTML; +} + +// === Tab / Workstream management === + +function renderTabBar() { + // Remove existing tabs (keep the + button) + tabBar.querySelectorAll(".ws-tab").forEach(function (t) { + t.remove(); + }); + + var wsIds = Object.keys(workstreams); + wsIds.forEach(function (wsId) { + var ws = workstreams[wsId]; + var tab = document.createElement("div"); + tab.className = "ws-tab" + (wsId === currentWsId ? " active" : ""); + tab.dataset.wsId = wsId; + tab.setAttribute("role", "tab"); + tab.setAttribute("tabindex", "0"); + tab.setAttribute("aria-selected", wsId === currentWsId ? "true" : "false"); + tab.onclick = function (e) { + if (e.target.classList.contains("tab-close")) return; + switchTab(wsId); + }; + tab.onkeydown = function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + switchTab(wsId); + } + }; + + var indicator = document.createElement("span"); + indicator.className = "tab-indicator"; + indicator.dataset.state = ws.state || "idle"; + indicator.setAttribute("aria-label", ws.state || "idle"); + tab.appendChild(indicator); + + var name = document.createElement("span"); + name.className = "tab-name"; + name.textContent = ws.name || wsId.substring(0, 6); + tab.appendChild(name); + + // Close button (only if more than one tab) + if (wsIds.length > 1) { + var close = document.createElement("button"); + close.className = "tab-close"; + close.innerHTML = "×"; + close.title = "Close workstream"; + close.onclick = function (e) { + e.stopPropagation(); + closeWorkstream(wsId); + }; + tab.appendChild(close); + } + + tabBar.insertBefore(tab, newTabBtn); + }); +} + +function updateTabIndicator(wsId, state, extra) { + workstreams[wsId] = workstreams[wsId] || {}; + workstreams[wsId].state = state; + // Update tab bar indicator + var tab = tabBar.querySelector('.ws-tab[data-ws-id="' + wsId + '"]'); + if (tab) { + var ind = tab.querySelector(".tab-indicator"); + if (ind) ind.dataset.state = state; + } + // Update dashboard table row if dashboard is open + var row = document.querySelector( + '#dash-ws-table .dash-row[data-ws-id="' + wsId + '"]', + ); + if (row) { + var sd = STATE_DISPLAY[state] || STATE_DISPLAY.idle; + row.dataset.state = state; + var dot = row.querySelector(".dash-state-dot"); + if (dot) dot.dataset.state = state; + var label = row.querySelector(".dash-state-label"); + if (label) { + label.dataset.state = state; + label.textContent = sd.symbol + " " + sd.label; + } + if (extra) { + if (extra.tokens !== undefined) { + var tokEl = row.querySelector(".dash-cell-tokens"); + if (tokEl) tokEl.textContent = formatTokens(extra.tokens); + } + if (extra.context_ratio !== undefined) { + var ctxEl = row.querySelector(".dash-cell-ctx"); + if (ctxEl) { + ctxEl.className = "dash-cell-ctx " + ctxClass(extra.context_ratio); + ctxEl.textContent = + extra.context_ratio > 0 + ? Math.round(extra.context_ratio * 100) + "%" + : ""; + } + } + if (extra.activity !== undefined) { + var sub = row.querySelector(".dash-row-sub"); + if (sub) { + sub.textContent = extra.activity || ""; + if (extra.activity_state === "approval") + sub.classList.add("sub-attention"); + else sub.classList.remove("sub-attention"); + } + } + } + } +} + +function switchTab(wsId) { + if (wsId === currentWsId && !dashboardVisible) return; + + // Reset current tab state + currentAssistantEl = null; + currentReasoningEl = null; + contentBuffer = ""; + busy = false; + pendingApproval = false; + approvalBlockEl = null; + sendBtn.disabled = false; + inputEl.disabled = false; + + currentWsId = wsId; + messagesEl.innerHTML = ""; + showEmptyState(); + renderTabBar(); + connectContentSSE(wsId); + + // Push history entry so back button can retrace tab navigation. + if (!_historyNavigation) { + history.pushState({ pcode: "workstream", wsId: wsId }, ""); + } +} + +function newWorkstream() { + authFetch("/api/workstreams/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data.ws_id) { + workstreams[data.ws_id] = { name: data.name, state: "idle" }; + switchTab(data.ws_id); + } + }); +} + +function closeWorkstream(wsId) { + authFetch("/api/workstreams/close", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ws_id: wsId }), + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data.status === "ok") { + delete workstreams[wsId]; + if (wsId === currentWsId) { + var remaining = Object.keys(workstreams); + if (remaining.length) switchTab(remaining[0]); + } else { + renderTabBar(); + } + } + }); +} + +// === SSE connections === + +function connectContentSSE(wsId) { + if (contentEvtSource) { + contentEvtSource.close(); + contentEvtSource = null; + } + contentEvtSource = new EventSource( + "/api/events?ws_id=" + encodeURIComponent(wsId), + ); + contentEvtSource.onmessage = function (e) { + contentRetryDelay = 1000; + statusBar.classList.remove("disconnected"); + var data = JSON.parse(e.data); + handleEvent(data); + }; + contentEvtSource.onerror = function () { + contentEvtSource.close(); + contentEvtSource = null; + statusBar.textContent = "Reconnecting\u2026"; + statusBar.classList.add("disconnected"); + // Raw fetch (not authFetch) — need to inspect status before throwing + fetch("/api/workstreams") + .then(function (r) { + if (r.status === 401) { + showLogin(); + return; + } + setTimeout(function () { + connectContentSSE(currentWsId); + }, contentRetryDelay); + contentRetryDelay = Math.min(contentRetryDelay * 2, 30000); + }) + .catch(function () { + setTimeout(function () { + connectContentSSE(currentWsId); + }, contentRetryDelay); + contentRetryDelay = Math.min(contentRetryDelay * 2, 30000); + }); + }; +} + +function showEmptyState() { + if (!messagesEl.querySelector(".empty-state")) { + var el = document.createElement("div"); + el.className = "empty-state"; + el.textContent = "Type a message to start"; + messagesEl.appendChild(el); + } +} +function removeEmptyState() { + var el = messagesEl.querySelector(".empty-state"); + if (el) el.remove(); +} + +// --- Dashboard --- +function showDashboard() { + dashboardVisible = true; + closeHamburger(); + document.getElementById("dashboard").classList.add("active"); + document.getElementById("header").inert = true; + document.getElementById("tab-bar").inert = true; + document.getElementById("messages").inert = true; + document.getElementById("input-area").inert = true; + loadDashboard(); + setTimeout(function () { + document.getElementById("dashboard-input").focus(); + }, 50); +} +function hideDashboard() { + dashboardVisible = false; + document.getElementById("dashboard").classList.remove("active"); + document.getElementById("header").inert = false; + document.getElementById("tab-bar").inert = false; + document.getElementById("messages").inert = false; + document.getElementById("input-area").inert = false; + document.getElementById("dashboard-input").value = ""; + inputEl.focus(); +} +function toggleDashboard() { + if (dashboardVisible) hideDashboard(); + else showDashboard(); +} +function loadDashboard() { + var tableEl = document.getElementById("dash-ws-table"); + tableEl.innerHTML = '
Loading\u2026
'; + document.getElementById("dashboard-session-cards").innerHTML = + '
Loading\u2026
'; + var dashP = authFetch("/api/dashboard").then(function (r) { + return r.json(); + }); + var sessP = authFetch("/api/sessions").then(function (r) { + return r.json(); + }); + Promise.all([dashP, sessP]) + .then(function (res) { + var dashData = res[0]; + var wsList = dashData.workstreams || []; + var agg = dashData.aggregate || {}; + renderDashboardTable(wsList, agg); + // Collect active session IDs for dedup + var activeSessionIds = {}; + wsList.forEach(function (ws) { + if (ws.session_id) activeSessionIds[ws.session_id] = true; + }); + var sessList = (res[1].sessions || []).filter(function (s) { + return !activeSessionIds[s.session_id]; + }); + renderDashboardSessions(sessList); + }) + .catch(function () { + tableEl.innerHTML = '
Failed to load
'; + document.getElementById("dashboard-session-cards").innerHTML = + '
Failed to load
'; + }); +} +function renderDashboardTable(wsList, agg) { + // Update header summary + var activeCount = wsList.filter(function (w) { + return w.state !== "idle"; + }).length; + document.getElementById("dash-summary").textContent = + activeCount + " active \u00b7 " + wsList.length + " total"; + // Render rows + var table = document.getElementById("dash-ws-table"); + table.innerHTML = ""; + if (!wsList.length) { + table.innerHTML = + '
No active workstreams
'; + updateDashFooter(agg); + return; + } + wsList.forEach(function (ws) { + var liveState = + (workstreams[ws.id] && workstreams[ws.id].state) || ws.state || "idle"; + var liveName = + (workstreams[ws.id] && workstreams[ws.id].name) || ws.name || ws.id; + var sd = STATE_DISPLAY[liveState] || STATE_DISPLAY.idle; + + var row = document.createElement("div"); + row.className = "dash-row"; + row.dataset.wsId = ws.id; + row.dataset.state = liveState; + row.setAttribute("role", "button"); + row.setAttribute("tabindex", "0"); + var ariaLabel = liveName + " \u2014 " + sd.label; + if (ws.title) ariaLabel += ", task: " + ws.title; + if (ws.tokens) ariaLabel += ", " + formatTokens(ws.tokens) + " tokens"; + if (ws.context_ratio > 0) + ariaLabel += ", " + Math.round(ws.context_ratio * 100) + "% context"; + row.setAttribute("aria-label", ariaLabel); + + // Main line + var main = document.createElement("div"); + main.className = "dash-row-main"; + + // STATE cell + var stateCell = document.createElement("span"); + stateCell.className = "dash-cell-state"; + stateCell.innerHTML = + '' + + '' + + sd.symbol + + " " + + sd.label + + ""; + main.appendChild(stateCell); + + // NAME cell + var nameCell = document.createElement("span"); + nameCell.className = "dash-cell-name"; + nameCell.textContent = liveName; + main.appendChild(nameCell); + + // NODE cell + var nodeCell = document.createElement("span"); + nodeCell.className = "dash-cell-node"; + nodeCell.textContent = ws.node || "local"; + main.appendChild(nodeCell); + + // TASK cell + var taskCell = document.createElement("span"); + taskCell.className = "dash-cell-task"; + taskCell.textContent = ws.title || ""; + main.appendChild(taskCell); + + // TOKENS cell + var tokensCell = document.createElement("span"); + tokensCell.className = "dash-cell-tokens"; + tokensCell.textContent = ws.tokens ? formatTokens(ws.tokens) : ""; + main.appendChild(tokensCell); + + // CTX cell + var ctxCell = document.createElement("span"); + ctxCell.className = "dash-cell-ctx " + ctxClass(ws.context_ratio); + ctxCell.textContent = + ws.context_ratio > 0 ? Math.round(ws.context_ratio * 100) + "%" : ""; + main.appendChild(ctxCell); + + row.appendChild(main); + + // Sub-line (activity) + var sub = document.createElement("div"); + sub.className = "dash-row-sub"; + if (ws.activity_state === "approval") sub.classList.add("sub-attention"); + sub.textContent = ws.activity || ""; + row.appendChild(sub); + + // Click handler + row.onclick = function () { + dashboardSwitchWorkstream(ws.id); + }; + row.onkeydown = function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + dashboardSwitchWorkstream(ws.id); + } + }; + + table.appendChild(row); + }); + updateDashFooter(agg); + // Arrow key navigation between rows + table.onkeydown = function (e) { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + var rows = Array.from(table.querySelectorAll(".dash-row")); + var idx = rows.indexOf(document.activeElement); + if (idx === -1) return; + if (e.key === "ArrowDown" && idx < rows.length - 1) rows[idx + 1].focus(); + if (e.key === "ArrowUp" && idx > 0) rows[idx - 1].focus(); + }; +} +function updateDashFooter(agg) { + if (!agg) return; + var nodesEl = document.getElementById("dash-footer-nodes"); + var statsEl = document.getElementById("dash-footer-stats"); + nodesEl.innerHTML = + ' ' + + escapeHtml((agg.node || "local") + " (" + (agg.total_count || 0) + " ws)"); + var parts = []; + if (agg.total_tokens) parts.push(formatTokens(agg.total_tokens) + " tokens"); + if (agg.total_tool_calls) parts.push(agg.total_tool_calls + " tool calls"); + if (agg.uptime_seconds) + parts.push(formatUptime(agg.uptime_seconds) + " uptime"); + statsEl.textContent = parts.join(" \u00b7 "); +} +function renderDashboardSessions(sessions) { + var c = document.getElementById("dashboard-session-cards"); + c.innerHTML = ""; + if (!sessions.length) { + c.innerHTML = '
No saved sessions
'; + return; + } + sessions.forEach(function (sess) { + var card = document.createElement("div"); + card.className = "dashboard-card"; + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + var label = sess.alias || sess.title || sess.session_id; + card.setAttribute("aria-label", "Resume: " + label); + card.onclick = function () { + dashboardResumeSession(sess.session_id); + }; + card.onkeydown = function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + dashboardResumeSession(sess.session_id); + } + }; + var title = sess.alias || sess.title || sess.session_id.substring(0, 12); + var meta = sess.message_count + " msgs"; + if (sess.updated) meta += " \u00b7 " + formatRelativeTime(sess.updated); + card.innerHTML = + '
' + + escapeHtml(title) + + "
" + + '
' + + escapeHtml(meta) + + "
"; + c.appendChild(card); + }); +} +function formatRelativeTime(iso) { + if (!iso) return ""; + // SQLite datetime('now') produces "YYYY-MM-DD HH:MM:SS" (UTC, no timezone marker). + // Without a Z suffix JS parses it as local time, breaking relative time for western offsets. + var s = iso.replace(" ", "T"); + if (!s.endsWith("Z") && !s.includes("+")) s += "Z"; + var d = new Date(s); + if (isNaN(d)) return ""; + var now = new Date(); + var ms = now - d; + var min = Math.floor(ms / 60000); + if (min < 1) return "just now"; + if (min < 60) return min + "m ago"; + var hr = Math.floor(min / 60); + if (hr < 24) return hr + "h ago"; + var day = Math.floor(hr / 24); + if (day < 30) return day + "d ago"; + return d.toLocaleDateString(); +} +function dashboardSwitchWorkstream(wsId) { + if (workstreams[wsId]) { + hideDashboard(); + switchTab(wsId); + } else loadDashboard(); +} +function dashboardResumeSession(sessionId) { + authFetch("/api/workstreams/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ws_id) return; + workstreams[data.ws_id] = { name: data.name, state: "idle" }; + switchTab(data.ws_id); + hideDashboard(); + authFetch("/api/command", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ws_id: data.ws_id, + command: "/resume " + sessionId, + }), + }).catch(function (err) { + addErrorMessage("Failed to resume: " + err.message); + }); + }); +} +function dashboardNewChat() { + hideDashboard(); + newWorkstream(); +} +function dashboardSendMessage() { + var input = document.getElementById("dashboard-input"); + var text = input.value.trim(); + if (!text) return; + input.disabled = true; + var btn = document.querySelector(".dashboard-new-btn"); + btn.disabled = true; + authFetch("/api/workstreams/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data.ws_id) { + input.disabled = false; + btn.disabled = false; + return; + } + workstreams[data.ws_id] = { name: data.name, state: "idle" }; + switchTab(data.ws_id); + hideDashboard(); + input.disabled = false; + btn.disabled = false; + busy = true; + sendBtn.disabled = true; + addUserMessage(text); + authFetch("/api/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text, ws_id: data.ws_id }), + }).catch(function (err) { + addErrorMessage("Connection error: " + err.message); + busy = false; + sendBtn.disabled = false; + }); + }) + .catch(function () { + input.disabled = false; + btn.disabled = false; + }); +} + +function connectGlobalSSE() { + if (globalEvtSource) { + globalEvtSource.close(); + globalEvtSource = null; + } + globalEvtSource = new EventSource("/api/events/global"); + globalEvtSource.onmessage = function (e) { + globalRetryDelay = 1000; + var data = JSON.parse(e.data); + if (data.type === "ws_state") { + updateTabIndicator(data.ws_id, data.state, { + tokens: data.tokens, + context_ratio: data.context_ratio, + activity: data.activity, + activity_state: data.activity_state, + }); + } else if (data.type === "ws_activity") { + // Live-update dashboard row sub-line + var row = document.querySelector( + '#dash-ws-table .dash-row[data-ws-id="' + data.ws_id + '"]', + ); + if (row) { + var sub = row.querySelector(".dash-row-sub"); + if (sub) { + sub.textContent = data.activity || ""; + if (data.activity_state === "approval") + sub.classList.add("sub-attention"); + else sub.classList.remove("sub-attention"); + } + } + } else if (data.type === "ws_rename") { + if (workstreams[data.ws_id]) workstreams[data.ws_id].name = data.name; + var nameEl = document.querySelector( + '[data-ws-id="' + data.ws_id + '"] .tab-name', + ); + if (nameEl) nameEl.textContent = data.name; + } else if (data.type === "ws_closed") { + var wsId = data.ws_id; + delete workstreams[wsId]; + renderTabBar(); + if (wsId === currentWsId) { + var remaining = Object.keys(workstreams); + if (remaining.length) switchTab(remaining[0]); + else showDashboard(); + } + } + }; + globalEvtSource.onerror = function () { + globalEvtSource.close(); + globalEvtSource = null; + // Raw fetch (not authFetch) — need to inspect status before throwing + fetch("/api/workstreams") + .then(function (r) { + if (r.status === 401) { + showLogin(); + return; + } + setTimeout(connectGlobalSSE, globalRetryDelay); + globalRetryDelay = Math.min(globalRetryDelay * 2, 30000); + }) + .catch(function () { + setTimeout(connectGlobalSSE, globalRetryDelay); + globalRetryDelay = Math.min(globalRetryDelay * 2, 30000); + }); + }; +} + +function handleEvent(evt) { + switch (evt.type) { + case "thinking_start": + isThinking = true; + removeEmptyState(); + addThinkingIndicator(); + break; + + case "thinking_stop": + isThinking = false; + removeThinkingIndicator(); + break; + + case "reasoning": + removeThinkingIndicator(); + if (!currentReasoningEl) { + currentReasoningEl = document.createElement("div"); + currentReasoningEl.className = "msg msg-assistant reasoning"; + messagesEl.appendChild(currentReasoningEl); + } + currentReasoningEl.textContent += evt.text; + scrollToBottom(); + break; + + case "content": + removeThinkingIndicator(); + if (currentReasoningEl) { + currentReasoningEl = null; + } + if (!currentAssistantEl) { + currentAssistantEl = document.createElement("div"); + currentAssistantEl.className = "msg msg-assistant"; + messagesEl.appendChild(currentAssistantEl); + } + contentBuffer += evt.text; + currentAssistantEl.innerHTML = renderMarkdown(contentBuffer); + scrollToBottom(); + break; + + case "stream_end": + if (currentAssistantEl && contentBuffer) { + currentAssistantEl.innerHTML = renderMarkdown(contentBuffer); + } + currentAssistantEl = null; + currentReasoningEl = null; + contentBuffer = ""; + busy = false; + sendBtn.disabled = false; + inputEl.focus(); + scrollToBottom(true); + break; + + case "tool_info": + showInlineToolBlock(evt.items, true); + break; + + case "approve_request": + showInlineToolBlock(evt.items, false); + break; + + case "tool_result": + appendToolOutput(evt.name, evt.output); + break; + + case "status": + updateStatus(evt); + break; + + case "plan_review": + showPlanDialog(evt.content); + break; + + case "info": + addInfoMessage(evt.message); + break; + + case "error": + addErrorMessage(evt.message); + busy = false; + sendBtn.disabled = false; + break; + + case "busy_error": + addErrorMessage(evt.message); + busy = false; + sendBtn.disabled = false; + break; + + case "connected": + modelName.textContent = evt.model || ""; + if (evt.skip_permissions) { + var existing = document.querySelector(".skip-permissions-warning"); + if (!existing) { + var warn = document.createElement("div"); + warn.className = "skip-permissions-warning"; + warn.textContent = + "\u26a0 Running with --skip-permissions: all tool calls are auto-approved"; + document.getElementById("header").appendChild(warn); + } + } + break; + + case "history": + replayHistory(evt.messages); + break; + + case "clear_ui": + messagesEl.innerHTML = ""; + break; + } +} + +function addThinkingIndicator() { + if (document.getElementById("thinking")) return; + const el = document.createElement("div"); + el.id = "thinking"; + el.className = "thinking-indicator"; + el.textContent = "Thinking"; + messagesEl.appendChild(el); + scrollToBottom(); +} + +function removeThinkingIndicator() { + const el = document.getElementById("thinking"); + if (el) el.remove(); +} + +function addUserMessage(text) { + removeEmptyState(); + const el = document.createElement("div"); + el.className = "msg msg-user"; + el.textContent = text; + messagesEl.appendChild(el); + scrollToBottom(true); +} + +// --- History replay --- + +function replayHistory(messages) { + messagesEl.innerHTML = ""; + if (!messages.length) { + showEmptyState(); + return; + } + var lastToolBlock = null; + for (var i = 0; i < messages.length; i++) { + var msg = messages[i]; + if (msg.role === "user") { + addUserMessage(msg.content || ""); + lastToolBlock = null; + } else if (msg.role === "assistant") { + if (msg.tool_calls && msg.tool_calls.length) { + if (msg.pending) { + // Approval still outstanding — skip the approved block here. + // The server re-sends approve_request right after history, which + // will create the live approval UI. + lastToolBlock = null; + } else { + var block = document.createElement("div"); + block.className = "msg approval-block approved"; + msg.tool_calls.forEach(function (tc) { + var div = document.createElement("div"); + div.className = "approval-tool"; + div.dataset.funcName = tc.name; + var nameEl = document.createElement("div"); + nameEl.className = "tool-name"; + nameEl.textContent = tc.name; + div.appendChild(nameEl); + var cmd = document.createElement("div"); + cmd.className = "tool-cmd"; + try { + var args = JSON.parse(tc.arguments); + var preview = Object.values(args)[0] || ""; + if (tc.name === "bash") { + cmd.innerHTML = + '$ ' + + escapeHtml(String(preview)); + } else { + cmd.textContent = String(preview).substring(0, 200); + } + } catch (e) { + cmd.textContent = tc.arguments.substring(0, 100); + } + div.appendChild(cmd); + block.appendChild(div); + }); + var badge = document.createElement("div"); + badge.className = "approval-badge badge-approved"; + badge.textContent = "\u2713 approved"; + block.appendChild(badge); + messagesEl.appendChild(block); + lastToolBlock = block; + } + } + if (msg.content) { + var el = document.createElement("div"); + el.className = "msg msg-assistant"; + el.innerHTML = renderMarkdown(msg.content); + messagesEl.appendChild(el); + lastToolBlock = null; + } + } else if (msg.role === "tool") { + if (lastToolBlock) { + var stripped = (msg.content || "") + .replace(/\x1b\[[0-9;]*m/g, "") + .trim(); + if (stripped) { + var out = document.createElement("div"); + out.className = "tool-output"; + out.textContent = stripped; + if (stripped.split("\\n").length > 10) { + out.classList.add("collapsed"); + out.addEventListener("click", function () { + this.classList.remove("collapsed"); + }); + } + var bdg = lastToolBlock.querySelector(".approval-badge"); + if (bdg) lastToolBlock.insertBefore(out, bdg); + else lastToolBlock.appendChild(out); + } + } + } + } + scrollToBottom(); +} + +// --- Inline tool/approval blocks --- + +function stripAnsi(s) { + return s.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function buildToolDiv(item) { + const div = document.createElement("div"); + div.className = "approval-tool"; + div.dataset.funcName = item.func_name || ""; + + const name = document.createElement("div"); + name.className = "tool-name"; + name.textContent = item.func_name || ""; + if (item.error) name.style.color = "var(--red)"; + div.appendChild(name); + + // Command/header preview + const cmd = document.createElement("div"); + cmd.className = "tool-cmd"; + const headerText = stripAnsi(item.header || ""); + // Strip the leading icon + tool name prefix to show just the command + const cleaned = headerText.replace(/^[^\s]+\s+\w+:\s*/, ""); + if (item.func_name === "bash" && cleaned) { + cmd.innerHTML = '$ ' + escapeHtml(cleaned); + } else { + cmd.textContent = cleaned || headerText; + } + div.appendChild(cmd); + + // Diff preview for edit_file / write_file + if (item.preview) { + const diff = document.createElement("div"); + diff.className = "tool-diff"; + const lines = stripAnsi(item.preview).split("\n"); + diff.innerHTML = lines + .map(function (line) { + const trimmed = line.trim(); + if (trimmed.startsWith("-")) + return '' + escapeHtml(line) + ""; + if (trimmed.startsWith("+")) + return '' + escapeHtml(line) + ""; + if (trimmed.startsWith("Warning:")) + return '' + escapeHtml(line) + ""; + return escapeHtml(line); + }) + .join("\n"); + div.appendChild(diff); + } + + return div; +} + +function getFeedback() { + if (!approvalBlockEl) return null; + var inp = approvalBlockEl.querySelector(".approval-feedback-input"); + return inp && inp.value.trim() ? inp.value.trim() : null; +} + +function showInlineToolBlock(items, autoApproved) { + const block = document.createElement("div"); + block.className = "msg approval-block" + (autoApproved ? " approved" : ""); + if (!autoApproved) { + block.setAttribute("role", "alertdialog"); + block.setAttribute("aria-label", "Tool approval required"); + } + + items.forEach(function (item) { + block.appendChild(buildToolDiv(item)); + }); + + if (autoApproved) { + const badge = document.createElement("div"); + badge.className = "approval-badge badge-approved"; + badge.textContent = "\u2713 auto-approved"; + block.appendChild(badge); + } else { + const prompt = document.createElement("div"); + prompt.className = "approval-prompt"; + + const actions = document.createElement("div"); + actions.className = "approval-actions"; + actions.innerHTML = + '' + + '' + + ''; + prompt.appendChild(actions); + + const fbInput = document.createElement("input"); + fbInput.type = "text"; + fbInput.className = "approval-feedback-input"; + fbInput.placeholder = "feedback (optional)"; + prompt.appendChild(fbInput); + + block.appendChild(prompt); + pendingApproval = true; + approvalBlockEl = block; + inputEl.disabled = true; + sendBtn.disabled = true; + requestAnimationFrame(function () { + fbInput.focus(); + }); + } + + messagesEl.appendChild(block); + scrollToBottom(); +} + +function resolveInlineApproval(approved, always, feedback) { + if (!approvalBlockEl) return; + pendingApproval = false; + + // Remove prompt + const prompt = approvalBlockEl.querySelector(".approval-prompt"); + if (prompt) prompt.remove(); + + // Add badge + const badge = document.createElement("div"); + if (approved) { + badge.className = "approval-badge badge-approved"; + var label = always ? "\u2713 always approve" : "\u2713 approved"; + badge.textContent = feedback ? label + ": " + feedback : label; + approvalBlockEl.classList.add("approved"); + } else { + badge.className = "approval-badge badge-denied"; + badge.textContent = "\u2717 denied" + (feedback ? ": " + feedback : ""); + approvalBlockEl.classList.add("denied"); + } + approvalBlockEl.appendChild(badge); + approvalBlockEl = null; + + // Re-enable input + inputEl.disabled = false; + sendBtn.disabled = busy; + inputEl.focus(); + + // POST to server with ws_id + authFetch("/api/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approved: approved, + feedback: feedback || null, + always: !!always, + ws_id: currentWsId, + }), + }).catch(function (err) { + addErrorMessage("Connection error: " + err.message); + }); + + scrollToBottom(); +} + +function appendToolOutput(name, output) { + // Find the last approval-block in messages + const blocks = messagesEl.querySelectorAll(".approval-block"); + if (!blocks.length) return; + const block = blocks[blocks.length - 1]; + + // Find the matching tool div or use the last one + let target = null; + const tools = block.querySelectorAll(".approval-tool"); + for (let i = tools.length - 1; i >= 0; i--) { + if (tools[i].dataset.funcName === name) { + target = tools[i]; + break; + } + } + if (!target && tools.length) target = tools[tools.length - 1]; + if (!target) return; + + const stripped = stripAnsi(output || "").trim(); + if (!stripped) return; + + const out = document.createElement("div"); + out.className = "tool-output"; + out.textContent = stripped; + + // Auto-collapse long output + const lineCount = stripped.split("\n").length; + if (lineCount > 10) { + out.classList.add("collapsed"); + out.addEventListener("click", function () { + this.classList.remove("collapsed"); + }); + } + + // Insert after the target tool div + target.after(out); + scrollToBottom(); +} + +function addInfoMessage(text) { + const el = document.createElement("div"); + el.className = "msg msg-info"; + // Strip ANSI codes + el.textContent = text.replace(/\x1b\[[0-9;]*m/g, ""); + messagesEl.appendChild(el); + scrollToBottom(); +} + +function addErrorMessage(text) { + const el = document.createElement("div"); + el.className = "msg msg-error"; + el.setAttribute("role", "alert"); + el.textContent = text.replace(/\x1b\[[0-9;]*m/g, ""); + messagesEl.appendChild(el); + scrollToBottom(); +} + +function updateStatus(evt) { + let parts = [ + evt.total_tokens.toLocaleString() + + " / " + + evt.context_window.toLocaleString() + + " tokens (" + + evt.pct + + "%)", + ]; + if (evt.effort !== "medium") parts.push("reasoning: " + evt.effort); + statusBar.textContent = parts.join(" \u00b7 "); +} + +function isNearBottom() { + return ( + messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < + 80 + ); +} +function scrollToBottom(force) { + if (force || isNearBottom()) { + messagesEl.scrollTop = messagesEl.scrollHeight; + } +} + +// --- Plan review dialog --- +function showPlanDialog(content) { + document.getElementById("plan-content").textContent = content; + document.getElementById("plan-feedback").value = ""; + document.getElementById("plan-overlay").classList.add("active"); + setTimeout(function () { + document.getElementById("plan-feedback").focus(); + }, 50); +} + +function resolvePlan(defaultFeedback) { + let feedback = document.getElementById("plan-feedback").value.trim(); + if (!feedback && defaultFeedback) feedback = defaultFeedback; + document.getElementById("plan-overlay").classList.remove("active"); + inputEl.focus(); + authFetch("/api/plan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feedback: feedback, ws_id: currentWsId }), + }).catch(function (err) { + addErrorMessage("Connection error: " + err.message); + }); +} + +// --- Send message --- +function sendMessage() { + const text = inputEl.value.trim(); + if (!text || busy) return; + + if (text.startsWith("/")) { + // Slash command + authFetch("/api/command", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ command: text, ws_id: currentWsId }), + }); + addUserMessage(text); + inputEl.value = ""; + autoResize(); + return; + } + + busy = true; + sendBtn.disabled = true; + addUserMessage(text); + inputEl.value = ""; + autoResize(); + + authFetch("/api/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text, ws_id: currentWsId }), + }).catch(function (err) { + addErrorMessage("Connection error: " + err.message); + busy = false; + sendBtn.disabled = false; + }); +} + +// --- Textarea auto-resize and keyboard shortcuts --- +function autoResize() { + inputEl.style.height = "auto"; + inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + "px"; +} + +inputEl.addEventListener("input", autoResize); +inputEl.addEventListener("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } +}); +document + .getElementById("dashboard-input") + .addEventListener("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + dashboardSendMessage(); + } + }); + +// Keyboard shortcuts for inline approval + plan dialog + tabs +document.addEventListener("keydown", function (e) { + // Escape: close hamburger first, then dashboard + if ( + e.key === "Escape" && + document.getElementById("hamburger-menu").classList.contains("open") + ) { + e.preventDefault(); + closeHamburger(); + document.getElementById("hamburger-btn").focus(); + return; + } + if (e.key === "Escape" && dashboardVisible) { + e.preventDefault(); + hideDashboard(); + return; + } + // Ctrl+D: toggle dashboard + if (e.ctrlKey && e.key === "d") { + e.preventDefault(); + toggleDashboard(); + return; + } + // Ctrl+T: new tab + if (e.ctrlKey && e.key === "t") { + e.preventDefault(); + newWorkstream(); + return; + } + // Ctrl+1..9: switch tabs + if (e.ctrlKey && e.key >= "1" && e.key <= "9") { + e.preventDefault(); + var idx = parseInt(e.key) - 1; + var wsIds = Object.keys(workstreams); + if (idx < wsIds.length) switchTab(wsIds[idx]); + return; + } + // Ctrl+W: close current tab + if (e.ctrlKey && e.key === "w") { + if (Object.keys(workstreams).length > 1) { + e.preventDefault(); + closeWorkstream(currentWsId); + } + return; + } + + // Inline approval keybindings + if (pendingApproval) { + // If typing in the feedback input, let keys through except Enter/Escape + var fbInput = + approvalBlockEl && + approvalBlockEl.querySelector(".approval-feedback-input"); + if (fbInput && document.activeElement === fbInput) { + if (e.key === "Enter") { + e.preventDefault(); + resolveInlineApproval(true, false, getFeedback()); + } else if (e.key === "Escape") { + e.preventDefault(); + resolveInlineApproval(false, false, getFeedback()); + } + return; // let normal typing pass through + } + // Not in feedback input — intercept shortcut keys + e.preventDefault(); + e.stopPropagation(); + if (e.key === "y" || e.key === "Enter") { + resolveInlineApproval(true, false, getFeedback()); + } else if (e.key === "n" || e.key === "Escape") { + resolveInlineApproval(false, false, getFeedback()); + } else if (e.key === "a") { + resolveInlineApproval(true, true, getFeedback()); + } + return; + } + // Plan dialog + if (document.getElementById("plan-overlay").classList.contains("active")) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + resolvePlan(""); + } else if (e.key === "Escape") { + e.preventDefault(); + resolvePlan("reject"); + } else if (e.key === "Tab") { + var focusable = document.querySelectorAll( + "#plan-dialog input, #plan-dialog button", + ); + var first = focusable[0], + last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } +}); + +// --- Keyboard shortcuts help --- +function showKbHelp() { + var existing = document.getElementById("kb-overlay"); + if (existing) { + existing.remove(); + } + var overlay = document.createElement("div"); + overlay.id = "kb-overlay"; + overlay.innerHTML = + '"; + overlay.onclick = function (e) { + if (e.target === overlay) hideKbHelp(); + }; + document.body.appendChild(overlay); + document.getElementById("kb-box").focus(); +} +function hideKbHelp() { + var el = document.getElementById("kb-overlay"); + if (el) el.remove(); +} +document.addEventListener("keydown", function (e) { + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return; + var login = document.getElementById("login-overlay"); + if (login && login.style.display !== "none") return; + if (e.key === "?" && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + showKbHelp(); + return; + } + if (e.key === "Escape") { + var kb = document.getElementById("kb-overlay"); + if (kb) { + e.preventDefault(); + hideKbHelp(); + return; + } + } +}); + +// --- Init: fetch workstream list, then connect --- +initLogin(); +authFetch("/api/workstreams") + .then(function (r) { + return r.json(); + }) + .then(function (data) { + data.workstreams.forEach(function (ws) { + workstreams[ws.id] = { name: ws.name, state: ws.state }; + }); + // Default to first workstream + var wsIds = Object.keys(workstreams); + if (wsIds.length) { + currentWsId = wsIds[0]; + renderTabBar(); + connectContentSSE(currentWsId); + } + connectGlobalSSE(); + // Seed the history stack so back-from-workstream returns here. + history.replaceState({ pcode: "dashboard" }, ""); + showDashboard(); + }); + +// Back/forward button: retrace dashboard → tab1 → tab2 navigation. +window.addEventListener("popstate", function (e) { + _historyNavigation = true; + try { + if (e.state && e.state.pcode === "workstream") { + // Navigating to a workstream state (forward, or back between tabs). + if (dashboardVisible) hideDashboard(); + if (e.state.wsId && workstreams[e.state.wsId]) switchTab(e.state.wsId); + } else { + // Navigating to the dashboard state (back from any workstream). + if (!dashboardVisible) showDashboard(); + } + } finally { + _historyNavigation = false; + } +}); diff --git a/turnstone/ui/static/index.html b/turnstone/ui/static/index.html new file mode 100644 index 00000000..352e7487 --- /dev/null +++ b/turnstone/ui/static/index.html @@ -0,0 +1,88 @@ + + + + + +turnstone + + + + + +
+ +
+ + + +
+ + +
+ +
+ +
+ + +
+ + + + diff --git a/turnstone/ui/static/style.css b/turnstone/ui/static/style.css new file mode 100644 index 00000000..9ce24e9e --- /dev/null +++ b/turnstone/ui/static/style.css @@ -0,0 +1,299 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +:root { + --bg: #1a1b26; --bg-surface: #24283b; --bg-highlight: #292e42; + --fg: #c8d1f5; --fg-dim: #828db5; --fg-bright: #a9b1d6; + --accent: #7aa2f7; --green: #9ece6a; --red: #f7768e; + --yellow: #e0af68; --cyan: #7dcfff; --magenta: #bb9af7; + --border: #3b4261; --code-bg: #1f2335; + --radius: 8px; + --dash-grid: 72px 120px 100px 1fr 60px 48px; +} +[data-theme="light"] { + --bg: #f5f5f5; --bg-surface: #ffffff; --bg-highlight: #e8e8ec; + --fg: #1a1a2e; --fg-dim: #4b5563; --fg-bright: #374151; + --accent: #1d4ed8; --green: #15803d; --red: #b91c1c; + --yellow: #92400e; --cyan: #0e7490; --magenta: #7e22ce; + --border: #d1d5db; --code-bg: #eaeaef; +} +html, body { height: 100%; background: var(--bg); color: var(--fg); font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 14px; } +body { display: flex; flex-direction: column; } + +/* Header */ +#header { padding: 8px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; flex-shrink: 0; } +#header h1 { font-size: 16px; color: var(--accent); font-weight: 600; } +#status-bar { font-size: 12px; color: var(--fg-dim); margin-left: auto; } +.skip-permissions-warning { font-size: 12px; color: var(--yellow); font-weight: 600; padding: 2px 8px; border: 1px solid var(--yellow); border-radius: var(--radius); } + +/* Hamburger menu */ +#hamburger-wrap { position: relative; } +#hamburger-btn { background:none; border:1px solid var(--border); color:var(--fg); border-radius:var(--radius); width:32px; height:32px; cursor:pointer; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; padding:0; flex-shrink:0; } +#hamburger-btn:hover { background:var(--bg-highlight); border-color:var(--fg-dim); } +#hamburger-btn:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } +#hamburger-btn span { display:block; width:14px; height:2px; background:var(--fg); border-radius:1px; } +#hamburger-menu { display:none; position:absolute; top:calc(100% + 6px); left:0; background:var(--bg-surface); border:1px solid var(--border); border-radius:var(--radius); min-width:180px; box-shadow:0 4px 16px rgba(0,0,0,0.3); z-index:40; overflow:hidden; } +[data-theme="light"] #hamburger-menu { box-shadow:0 4px 16px rgba(0,0,0,0.12); } +#hamburger-menu.open { display:block; } +.hmenu-item { display:flex; align-items:center; gap:10px; width:100%; padding:10px 14px; background:none; border:none; color:var(--fg); font:inherit; font-size:13px; cursor:pointer; text-align:left; white-space:nowrap; } +.hmenu-item:hover { background:var(--bg-highlight); } +.hmenu-item:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } +.hmenu-item .hmenu-icon { width:16px; text-align:center; font-size:14px; opacity:0.8; } +.hmenu-sep { height:1px; background:var(--border); margin:4px 0; } +@media (max-width:600px) { + #hamburger-btn { width:40px; height:40px; } + .hmenu-item { padding:12px 14px; } +} + +/* Tab bar */ +#tab-bar { display: flex; align-items: center; gap: 2px; padding: 4px 16px; background: var(--bg-surface); border-bottom: 1px solid var(--border); flex-shrink: 0; overflow-x: auto; } +#tab-bar::-webkit-scrollbar { height: 4px; } +#tab-bar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } +.ws-tab { padding: 5px 10px; border-radius: 6px 6px 0 0; cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); border-bottom: none; color: var(--fg-dim); white-space: nowrap; user-select: none; position: relative; } +.ws-tab:hover { background: var(--bg-highlight); color: var(--fg-bright); } +.ws-tab.active { background: var(--bg-highlight); color: var(--fg-bright); border-bottom: 2px solid var(--accent); } +.ws-tab .tab-indicator { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.ws-tab .tab-indicator[data-state="idle"] { background: var(--fg-dim); opacity: 0.3; } +.ws-tab .tab-indicator[data-state="thinking"] { background: var(--cyan); animation: pulse 1.5s ease-in-out infinite; } +.ws-tab .tab-indicator[data-state="running"] { background: var(--green); border-radius: 2px; animation: pulse 1s ease-in-out infinite; } +.ws-tab .tab-indicator[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); animation: pulse 1s ease-in-out infinite; } +.ws-tab .tab-indicator[data-state="error"] { background: var(--red); } +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } +@media (prefers-reduced-motion: reduce) { + .ws-tab .tab-indicator[data-state="thinking"], + .ws-tab .tab-indicator[data-state="running"], + .ws-tab .tab-indicator[data-state="attention"], + .dash-state-dot[data-state="running"], + .dash-state-dot[data-state="thinking"], + .dash-state-dot[data-state="attention"] { animation: none; opacity: 1; } + .thinking-indicator::after { animation: none; content: '...'; } +} +.ws-tab .tab-close { background: none; border: none; color: var(--fg-dim); font-size: 14px; cursor: pointer; padding: 0 2px; line-height: 1; opacity:0; transition:opacity 0.15s; } +.ws-tab:hover .tab-close, .ws-tab:focus-within .tab-close, .ws-tab .tab-close:focus-visible { opacity:1; } +.ws-tab .tab-close:hover { color: var(--red); } +#new-tab-btn { background: none; border: 1px dashed var(--border); color: var(--fg-dim); border-radius: 6px 6px 0 0; padding: 5px 10px; cursor: pointer; font-family: inherit; font-size: 14px; line-height: 1; } +#new-tab-btn:hover { background: var(--bg-highlight); color: var(--fg-bright); border-color: var(--accent); } + +/* Messages */ +#messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.msg { padding: 10px 14px; border-radius: var(--radius); max-width: 95%; line-height: 1.55; word-wrap: break-word; overflow-wrap: break-word; } +.msg-user { background: var(--bg-highlight); border: 1px solid var(--border); align-self: flex-end; color: var(--fg-bright); } +.msg-assistant { align-self: flex-start; } +.msg-info { color: var(--cyan); font-size: 13px; padding: 4px 14px; white-space: pre-wrap; font-family: inherit; } +.msg-error { color: var(--red); font-size: 13px; padding: 4px 14px; } +.msg-tool { background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--yellow); font-size: 13px; padding: 8px 12px; align-self: flex-start; max-width: 95%; } +.msg-tool .tool-header { color: var(--yellow); font-weight: 600; margin-bottom: 4px; } +.msg-tool .tool-preview { color: var(--fg-dim); white-space: pre-wrap; font-size: 12px; max-height: 300px; overflow-y: auto; } + +/* Streaming content */ +.reasoning { color: var(--fg-dim); font-style: italic; } +.thinking-indicator { color: var(--fg-dim); font-size: 13px; padding: 6px 14px; } +.thinking-indicator::after { content: '...'; animation: dots 1.5s steps(3, end) infinite; } +@keyframes dots { 0% { content: '.'; } 33% { content: '..'; } 66% { content: '...'; } } + +/* Markdown styling */ +.msg-assistant h1, .msg-assistant h2, .msg-assistant h3 { color: var(--accent); margin: 8px 0 4px; } +.msg-assistant h1 { font-size: 18px; } .msg-assistant h2 { font-size: 16px; } .msg-assistant h3 { font-size: 14px; } +.msg-assistant strong { color: var(--fg-bright); } +.msg-assistant em { color: var(--magenta); } +.msg-assistant code { background: var(--code-bg); padding: 2px 5px; border-radius: 3px; font-size: 13px; } +.msg-assistant pre { background: var(--code-bg); padding: 10px 12px; border-radius: var(--radius); overflow-x: auto; margin: 6px 0; border: 1px solid var(--border); } +.msg-assistant pre code { background: none; padding: 0; } +.msg-assistant ul, .msg-assistant ol { margin: 4px 0 4px 20px; } +.msg-assistant li { margin: 2px 0; } +.msg-assistant a { color: var(--accent); text-decoration: underline; } +.msg-assistant blockquote { border-left: 3px solid var(--border); padding-left: 10px; color: var(--fg-dim); margin: 4px 0; } +.msg-assistant hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; } +.msg-assistant p { margin: 4px 0; } + +/* Input area */ +#input-area { padding: 12px 16px; background: var(--bg-surface); border-top: 1px solid var(--border); display: flex; gap: 8px; flex-shrink: 0; } +#input-area textarea { flex: 1; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; font-family: inherit; font-size: 14px; resize: none; outline: none; min-height: 40px; max-height: 200px; } +#input-area textarea:focus { border-color: var(--accent); } +#input-area button { background: var(--accent); color: var(--bg); border: none; border-radius: var(--radius); padding: 8px 16px; font-family: inherit; font-size: 14px; cursor: pointer; font-weight: 600; } +#input-area button:hover { opacity: 0.9; } +#input-area button:disabled { opacity: 0.4; cursor: not-allowed; } + +/* Inline approval blocks */ +.approval-block { background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--yellow); border-radius: var(--radius); padding: 0; align-self: flex-start; max-width: 95%; font-size: 13px; } +.approval-block.approved { border-left-color: var(--green); } +.approval-block.denied { border-left-color: var(--red); } +.approval-tool { padding: 6px 12px; border-bottom: 1px solid var(--border); } +.approval-tool:last-of-type { border-bottom: none; } +.approval-tool .tool-name { color: var(--yellow); font-weight: 600; font-size: 12px; margin-bottom: 2px; } +.approval-tool .tool-cmd { color: var(--fg-bright); white-space: pre-wrap; word-break: break-all; } +.approval-tool .tool-cmd .dollar { color: var(--green); } +.approval-tool .tool-diff { white-space: pre-wrap; font-size: 12px; margin-top: 4px; } +.approval-tool .tool-diff .diff-del { color: var(--red); } +.approval-tool .tool-diff .diff-add { color: var(--green); } +.approval-tool .tool-diff .diff-warn { color: var(--yellow); } +.approval-prompt { padding: 8px 12px; font-size: 12px; border-top: 1px solid var(--border); background: var(--bg-highlight); } +.approval-actions { display: flex; gap: 6px; margin-bottom: 6px; } +.approval-btn { background: var(--bg); border: 1px solid var(--border); color: var(--fg-bright); border-radius: 4px; padding: 4px 12px; font-family: inherit; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; } +.approval-btn:hover { background: var(--bg-highlight); } +.approval-btn .key { display: inline-block; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; font-size: 11px; color: var(--accent); font-weight: 600; min-width: 18px; text-align: center; } +.btn-approve:hover { border-color: var(--green); color: var(--green); } +.btn-deny:hover { border-color: var(--red); color: var(--red); } +.btn-always:hover { border-color: var(--accent); color: var(--accent); } +.approval-feedback-input { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; font-family: inherit; font-size: 12px; outline: none; } +.approval-feedback-input:focus { border-color: var(--accent); } +.approval-feedback-input::placeholder { color: var(--fg-dim); } +.approval-badge { padding: 6px 12px; font-size: 12px; font-weight: 600; border-top: 1px solid var(--border); } +.approval-badge.badge-approved { color: var(--green); } +.approval-badge.badge-denied { color: var(--red); } +.tool-output { padding: 6px 12px; background: var(--code-bg); border-top: 1px solid var(--border); white-space: pre-wrap; font-size: 12px; color: var(--fg-dim); max-height: 300px; overflow-y: auto; } +.tool-output.collapsed { max-height: 150px; position: relative; } +.tool-output.collapsed::after { content: 'click to expand'; position: absolute; bottom: 0; left: 0; right: 0; text-align: center; padding: 4px; background: linear-gradient(transparent, var(--code-bg) 60%); color: var(--fg-dim); font-size: 11px; cursor: pointer; } + +/* Plan review dialog */ +#plan-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100; justify-content: center; align-items: center; } +#plan-overlay.active { display: flex; } +#plan-dialog { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; max-width: 700px; width: 90%; max-height: 80vh; overflow-y: auto; } +#plan-dialog h3 { color: var(--accent); margin-bottom: 12px; font-size: 15px; } +#plan-content { white-space: pre-wrap; font-size: 13px; color: var(--fg-bright); margin-bottom: 16px; max-height: 50vh; overflow-y: auto; background: var(--code-bg); padding: 12px; border-radius: var(--radius); } +#plan-feedback { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; font-family: inherit; font-size: 13px; margin-bottom: 12px; } +#plan-buttons { display: flex; gap: 8px; justify-content: flex-end; } +#plan-buttons button { padding: 8px 20px; border: none; border-radius: var(--radius); font-family: inherit; font-size: 13px; cursor: pointer; font-weight: 600; } +#btn-plan-approve { background: var(--green); color: var(--bg); } +#btn-plan-reject { background: var(--red); color: var(--bg); } + +/* Scrollbar */ +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); } + +/* Focus indicators */ +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +#input-area textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); } +.approval-btn:focus-visible { outline-offset: 1px; } + +/* Empty state */ +.empty-state { color: var(--fg-dim); text-align: center; padding: 48px 16px; font-size: 13px; } + +/* Disconnected status */ +#status-bar.disconnected { color: var(--red); } + +/* Dashboard overlay */ +.dashboard-overlay { display:none; position:fixed; inset:0; background:var(--bg); z-index:50; overflow-y:auto; } +.dashboard-overlay.active { display:flex; justify-content:center; align-items:flex-start; } +.dashboard-content { width:100%; max-width:960px; padding:32px 16px 24px; } +.dashboard-input-row { display:flex; gap:8px; margin-bottom:24px; } +.dashboard-input { flex:1; background:var(--bg-surface); color:var(--fg); border:1px solid var(--border); border-radius:var(--radius); padding:12px 16px; font:inherit; font-size:15px; outline:none; } +.dashboard-input:focus { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 30%,transparent); } +.dashboard-input::placeholder { color:var(--fg-dim); } +.dashboard-new-btn { background:var(--accent); color:var(--bg); border:none; border-radius:var(--radius); padding:12px 20px; font:inherit; font-size:14px; font-weight:600; cursor:pointer; white-space:nowrap; } +.dashboard-new-btn:hover { opacity:0.9; } +.dashboard-new-btn:disabled { opacity:0.4; cursor:not-allowed; } +.dashboard-section { margin-bottom:24px; } +.dashboard-section-title { font-size:12px; text-transform:uppercase; letter-spacing:0.05em; color:var(--fg-dim); margin-bottom:10px; font-weight:600; } +.dashboard-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:10px; } +.dashboard-card { background:var(--bg-surface); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; cursor:pointer; transition:border-color 0.15s; } +.dashboard-card:hover { border-color:var(--accent); } +.dashboard-card:active { background:var(--bg-highlight); border-color:var(--accent); } +.dashboard-card:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } +.dashboard-card .card-title { font-size:13px; color:var(--fg-bright); font-weight:600; margin-bottom:4px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.dashboard-card .card-meta { font-size:11px; color:var(--fg-dim); } +.dashboard-empty { color:var(--fg-dim); font-size:13px; padding:8px 0; } + +/* Dashboard header bar */ +.dash-header { display:flex; justify-content:space-between; align-items:center; padding:8px 16px; background:var(--code-bg); border-radius:var(--radius) var(--radius) 0 0; } +.dash-header-title { color:var(--accent); font-size:12px; font-weight:bold; letter-spacing:0.05em; } +.dash-header-summary { color:var(--fg-dim); font-size:11px; } + +/* Dashboard column headers */ +.dash-colheaders { display:grid; grid-template-columns:var(--dash-grid); padding:4px 16px; background:var(--bg-surface); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-dim); text-transform:uppercase; letter-spacing:0.03em; } +.dash-col-tokens,.dash-col-ctx { text-align:right; } + +/* Dashboard table */ +.dash-table { min-height:40px; } +.dash-row { position:relative; border-left:3px solid transparent; cursor:pointer; transition:background 0.15s; } +.dash-row:nth-child(odd) { background:var(--bg); } +.dash-row:nth-child(even) { background:var(--bg-surface); } +.dash-row:hover { background:var(--bg-highlight); box-shadow:inset 0 0 0 1px var(--border); } +.dash-row:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } +.dash-row[data-state="running"] { border-left-color:var(--green); } +.dash-row[data-state="thinking"] { border-left-color:var(--accent); } +.dash-row[data-state="attention"] { border-left-color:var(--yellow); } +.dash-row[data-state="idle"] { border-left-color:var(--fg-dim); opacity:0.7; } +.dash-row[data-state="error"] { border-left-color:var(--red); } +.dash-row-main { display:grid; grid-template-columns:var(--dash-grid); padding:8px 16px 2px; align-items:center; font-size:12px; } +.dash-row-sub { padding:0 16px 8px 88px; font-size:11px; color:var(--fg-dim); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.dash-row-sub.sub-attention { color:var(--yellow); } + +/* State indicator cell */ +.dash-cell-state { display:flex; align-items:center; gap:6px; font-size:11px; } +.dash-state-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; } +.dash-state-dot[data-state="running"] { background:var(--green); border-radius:2px; animation:pulse 2s infinite; } +.dash-state-dot[data-state="thinking"] { background:var(--accent); animation:pulse 2.2s infinite; } +.dash-state-dot[data-state="attention"] { background:var(--yellow); border-radius:1px; transform:rotate(45deg); animation:pulse 1.8s infinite; } +.dash-state-dot[data-state="idle"] { background:var(--fg-dim); } +.dash-state-dot[data-state="error"] { background:var(--red); border-radius:0; } +.dash-state-label { white-space:nowrap; } +.dash-state-label[data-state="running"] { color:var(--green); } +.dash-state-label[data-state="thinking"] { color:var(--accent); } +.dash-state-label[data-state="attention"] { color:var(--yellow); } +.dash-state-label[data-state="idle"] { color:var(--fg-dim); } +.dash-state-label[data-state="error"] { color:var(--red); } + +/* Table cells */ +.dash-cell-name { font-weight:bold; color:var(--fg-bright); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.dash-row[data-state="idle"] .dash-cell-name { color:var(--fg-dim); } +.dash-cell-node { color:var(--fg-dim); font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.dash-cell-task { color:var(--fg-bright); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.dash-row[data-state="idle"] .dash-cell-task { color:var(--fg-dim); } +.dash-cell-tokens { text-align:right; color:var(--fg-dim); font-size:11px; } +.dash-cell-ctx { text-align:right; font-size:11px; } +.dash-cell-ctx.ctx-low { color:var(--green); } +.dash-cell-ctx.ctx-mid { color:var(--yellow); } +.dash-cell-ctx.ctx-high { color:var(--red); } +.dash-cell-ctx.ctx-danger { color:var(--red); font-weight:bold; } +.dash-cell-ctx.ctx-idle { color:var(--fg-dim); } + +/* Dashboard footer */ +.dash-footer { display:flex; justify-content:space-between; align-items:center; padding:8px 16px; background:var(--code-bg); border-top:1px solid var(--border); border-radius:0 0 var(--radius) var(--radius); font-size:11px; margin-bottom:24px; } +.dash-footer-nodes { color:var(--fg-dim); display:flex; align-items:center; gap:6px; } +.dash-footer-node-dot { width:6px; height:6px; border-radius:50%; background:var(--green); display:inline-block; flex-shrink:0; } +.dash-footer-stats { color:var(--fg-dim); } + +/* Dashboard responsive */ +@media (max-width:700px) { + :root { --dash-grid:68px 110px 1fr 56px 44px; } + .dash-col-node,.dash-cell-node { display:none; } + .dash-row-sub { padding-left:76px; } +} +@media (max-width:600px) { + .dashboard-input-row { flex-direction:column; } + .dashboard-new-btn { width:100%; } +} +@media (max-width:480px) { + .dashboard-content { padding:24px 12px 16px; } + .dashboard-cards { grid-template-columns:1fr; } + :root { --dash-grid:50px 1fr 50px; } + .dash-col-node,.dash-cell-node,.dash-col-task,.dash-cell-task,.dash-col-ctx,.dash-cell-ctx { display:none; } + .dash-row-sub { padding-left:66px; } +} + +/* Login overlay */ +#login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; } +#login-box { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; width: 320px; max-width: 90vw; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } +#login-box h2 { color: var(--accent); font-size: 16px; margin-bottom: 16px; } +#login-box input { width: 100%; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); font: inherit; font-size: 13px; margin-bottom: 12px; } +#login-box input:focus-visible { border-color: var(--accent); outline: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); } +#login-box input::placeholder { color: var(--fg-dim); } +#login-box button { width: 100%; padding: 12px; background: var(--accent); color: var(--bg); border: none; border-radius: 4px; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } +#login-box button:hover { opacity: 0.9; } +#login-box button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; } +#login-box button:disabled { opacity: 0.5; cursor: not-allowed; } +#login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +@media (max-width: 380px) { #login-box { padding: 24px 20px; } } + +/* Keyboard shortcuts overlay */ +#kb-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 999; } +#kb-box { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px 28px; width: 360px; max-width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } +#kb-box h2 { color: var(--accent); font-size: 14px; margin-bottom: 14px; } +.kb-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; } +.kb-key { color: var(--fg-bright); background: var(--bg-highlight); border: 1px solid var(--border); border-radius: 3px; padding: 1px 6px; font-family: inherit; font-size: 11px; white-space: nowrap; } +.kb-desc { color: var(--fg-dim); } +.kb-section { color: var(--fg-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 12px; margin-bottom: 4px; } +.kb-section:first-child { margin-top: 0; } +#kb-box .kb-hint { color: var(--fg-dim); font-size: 11px; text-align: center; margin-top: 14px; }